接下來我們要處理聊天頻道、訊息及通知等。處了要建立頻道還要能取得頻道列表,取得密鑰等,並且也要能傳送訊息、讀取訊息,最後我們還要建立一套通知系統,來通知使用者有新訊息。
範例程式碼:https://github.com/ksw2000/ironman-2024/tree/master/golang-server/whisper
目前整個系統由各個不同的 package
組成
接著我們來處理建立聊天室的邏輯
Path: /api/v1/channels
Method: POST
Header:
- Authorization
Request:
- attendee int (朋友的 id)
- key_encrypted_by_attendee_public_key string
- key_encrypted_by_host_public_key string
Response:
success:
- 201 Created
fail:
- 400 Bad Request 請求格式不正確
- 401 Unauthorized 未經授權
- 403 Forbidden 無法向此用戶傳送傳訊
- 409 Conflict 聊天室已存在
- 500 Internal Server Error 伺服器端發生錯誤
content:
- error string
- channel_id int
建立聊天室時,按照以下這幾個步驟來實現金鑰交換
這個是昨天所提到的 channels
表格,根據我們的需求還需要擴增欄位:
create table channels(
id serial not null primary key,
friend_id int references friends(id) on delete cascade,
created_at timestamptz default now()
);
需要增加兩個用來存放已加密的金鑰的欄位
key_encrypted_by_inviter_key text not null
key_encrypted_by_invitee_key text not null
此時我們可以使用
alter table channels add column key_encrypted_by_inviter_key text not null;
alter table channels add column key_encrypted_by_invitee_key text not null;
如果表格中還有資料可能會無法加入這兩個 column,我們可以使用
delete from channels;
或利用drop
刪除整個表。
首先我們先根據 channels
表在 channels
package 建立對應的 struct
.
└── whipser/
├── auth/
│ └── auth.go
├── channels/
│ └── channels.go
├── friend/
│ └── friends.go
├── users/
│ └── users.go
├── utils/
│ └── utils.go
├── go.mod
├── go.sum
└── main.go
type Channel struct {
ID int
FriendID int
KeyEncryptedByInviterKey string
KeyEncryptedByInviteeKey string
CreatedAt time.Time
}
以及用來對接 API 使用的 struct
type ChannelReq struct {
Attendee int `json:"attendee" binding:"required"`
KeyEncryptedByAttendeePublicKey string `json:"key_encrypted_by_attendee_public_key" binding:"required"`
KeyEncryptedByHostPublicKey string `json:"key_encrypted_by_host_public_key" binding:"required"`
}
接著我們對 ChannelReq
新增一個 Create
方法
首先我們先檢查 Host 和 Attendee 的好友關係,若他們不是好友則回傳 ErrorAttendeeIsNotYourFriend
。接著我們檢查頻道是否已存在,如果已經存在則回傳 ErrorChannelAlreadyExisted
。如果一切準備就續,我們將 Host 和 Attendee 分別對應回原本的好友關係,然後將其存入資料庫
var (
ErrorAttendeeIsNotYourFriend = errors.New("attendee is not your friend")
ErrorChannelAlreadyExisted = errors.New("channel already existed")
)
func (c *ChannelReq) Create(db *pg.DB, hostUID int) (int, error) {
tx, err := db.Begin()
if err != nil {
return -1, fmt.Errorf("db.Begin failed: %w", err)
}
defer tx.Rollback()
f := friends.Friend{}
err = db.Model(&f).Where("accepted = true").Where(`
(invitee = ? and inviter = ?)
or
(invitee = ? and inviter = ?)
`, hostUID, c.Attendee, c.Attendee, hostUID).Select()
if err != nil {
if err == pg.ErrNoRows {
return -1, ErrorAttendeeIsNotYourFriend
}
return -1, fmt.Errorf("db.Model.Where.Where.Select failed: %w", err)
}
channel := Channel{
FriendID: f.ID,
}
n, err := db.Model(&channel).Where("friend_id = ?", f.ID).Count()
if err != nil {
return -1, fmt.Errorf("db.Model.Where.Count failed: %w", err)
}
if n > 0 {
return -1, ErrorChannelAlreadyExisted
}
if f.Invitee == hostUID {
channel.KeyEncryptedByInviteeKey = c.KeyEncryptedByHostPublicKey
channel.KeyEncryptedByInviterKey = c.KeyEncryptedByAttendeePublicKey
} else {
channel.KeyEncryptedByInviteeKey = c.KeyEncryptedByAttendeePublicKey
channel.KeyEncryptedByInviterKey = c.KeyEncryptedByHostPublicKey
}
_, err = db.Model(&channel).Insert()
if err != nil {
return -1, fmt.Errorf("db.Model.Where.Select failed: %w", err)
}
err = tx.Commit()
if err != nil {
return -1, fmt.Errorf("tx.Commit failed: %w", err)
}
return channel.ID, nil
}
完成後,我們將此邏輯註冊回 router
router.POST("/api/v1/channels", func(c *gin.Context) {
req := channels.ChannelReq{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
token := c.GetHeader("Authorization")
me, err := auth.CheckLogin(db, token)
if err != nil {
if err == auth.ErrorAuthenticationFailed {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "驗證失敗",
})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
channelID, err := req.Create(db, me.ID)
if err != nil {
if err == channels.ErrorAttendeeIsNotYourFriend {
c.JSON(http.StatusForbidden, gin.H{
"error": "不是朋友無法建立聊天室",
})
} else if err == channels.ErrorChannelAlreadyExisted {
c.JSON(http.StatusConflict, gin.H{
"error": "聊天室已存在",
})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
c.JSON(http.StatusCreated, gin.H{
"error": "",
"channel_id": channelID,
})
})
我們使用乙宗梢的帳號像藤島慈建立頻道
> curl -X POST http://localhost:8081/api/v1/channels -H "Content-Type: applicaton/json" -H "Authorization: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0" -d "{\"attendee\": 5, \"key_encrypted_by_attendee_public_key\": \"example_key_encrypted_by_attendee\", \"key_encrypted_by_host_public_key\": \"example_key_encrypted_by_host\"}"
{"channel_id":1,"error":""}
查看資料庫
whisper=# select * from channels;
id | friend_id | key_encrypted_by_inviter_key | key_encrypted_by_invitee_key | created_at
----+-----------+-------------------------------+-----------------------------------+-------------------------------
1 | 2 | example_key_encrypted_by_host | example_key_encrypted_by_attendee | 2024-09-30 14:38:18.189193+00
(1 row)
whisper=# select * from friends where id = 2;
id | inviter | invitee | accepted | created_at | updated_at
----+---------+---------+----------+-------------------------------+-------------------------------
2 | 4 | 5 | t | 2024-09-30 05:43:35.266268+00 | 2024-09-30 05:43:35.266268+00
(1 row)
當新的聊天室建立時,另一位用戶無法取得金鑰,因此我們必需設計一個 API 讓該用戶可以取得加密過的金鑰。
Path: /api/v1/channels/key/:channel_id
Method: GET
Header:
- Authorization
Response:
success:
- 200 OK
fail:
- 400 Bad Request 請求格式不正確
- 401 Unauthorized 未經授權
- 403 Forbidden 無法存取該聊天室
- 500 Internal Server Error 伺服器端發生錯誤
content:
- error string
- encrypted_key string
設計 GetEncryptedKey
,設計時我們先檢查該聊天室是否存在。若不存在返回 ErrorChannelNotFound
。接著透過綁定的 friend_id
向 friends_table
查詢,查詢 uid
是 inviter
還是 invitee
,如果都不是,則代表該用戶不屬於該聊天室,回傳 ErrorUserNotInChannel
func GetEncryptedKey(db *pg.DB, channelID, uid int) (string, error) {
channel := Channel{}
err := db.Model(&channel).Where("id = ?", channelID).Select()
if err != nil {
if err == pg.ErrNoRows {
return "", ErrorChannelNotFound
}
return "", fmt.Errorf("db.Model(channel).Where.Select failed: %w", err)
}
friend := friends.Friend{}
err = db.Model(&friend).Where("id = ?", channel.FriendID).Select()
if err != nil {
return "", fmt.Errorf("db.Model(friend).Where.Select failed: %w", err)
}
if friend.Invitee == uid {
return channel.KeyEncryptedByInviteeKey, nil
} else if friend.Inviter == uid {
return channel.KeyEncryptedByInviterKey, nil
}
return "", ErrorUserNotInChannel
}
每次都要查兩個表有點麻煩,因上我們可以建立 view
來簡化查詢
create view view_friend_channels as
select
c.id as channel_id,
key_encrypted_by_inviter_key,
key_encrypted_by_invitee_key,
inviter,
invitee
from
channels as c,
friends as f
where
c.friend_id = f.id and
f.accepted = true;
whisper=# select * from view_friend_channels;
channel_id | key_encrypted_by_inviter_key | key_encrypted_by_invitee_key | inviter | invitee
------------+-------------------------------+-----------------------------------+---------+---------
1 | example_key_encrypted_by_host | example_key_encrypted_by_attendee | 4 | 5
2 | example_key_encrypted_by_host | example_key_encrypted_by_attendee | 4 | 6
(2 rows)
對應的 Go struct
type ViewFriendChannels struct {
ChannelID int
Inviter int
Invitee int
KeyEncryptedByInviterKey string
KeyEncryptedByInviteeKey string
}
利用 view_friend_channels ,我們可以把程式碼簡化成查一次表即可,但是,如果利用 channel_id
查不到並不一定代表 channel
不存在。我們可以一律簡化回傳 ErrorForbidden
var (
ErrorForbidden = errors.New("forbidden")
)
func GetEncryptedKey(db *pg.DB, channelID, uid int) (string, error) {
view := ViewFriendChannels{}
err := db.Model(&view).Where("channel_id = ?", channelID).Select()
if err != nil {
if err == pg.ErrNoRows {
return "", ErrorForbidden
}
return "", fmt.Errorf("db.Model(channel).Where.Select failed: %w", err)
}
if view.Invitee == uid {
return view.KeyEncryptedByInviteeKey, nil
} else if view.Inviter == uid {
return view.KeyEncryptedByInviterKey, nil
}
return "", ErrorForbidden
}
接著我們將該邏輯註冊到 router
上
router.GET("/api/v1/channels/key/:channel_id", func(c *gin.Context) {
channelID, err := strconv.Atoi(c.Param("channel_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
token := c.GetHeader("Authorization")
me, err := auth.CheckLogin(db, token)
if err != nil {
if err == auth.ErrorAuthenticationFailed {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "驗證失敗",
})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
encryptedKey, err := channels.GetEncryptedKey(db, channelID, me.ID)
if err != nil {
if err == channels.ErrorForbidden {
c.JSON(http.StatusForbidden, gin.H{"error": "無法存取該聊天室"})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
c.JSON(http.StatusOK, gin.H{
"error": "",
"encrypted_key": encryptedKey,
})
})
嘗試取得金鑰
> curl -X GET http://localhost:8081/api/v1/channels/key/1 -H "Content-Type: applicaton/json" -H "Authorization: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"encrypted_key":"example_key_encrypted_by_host","error":""}
嘗試取得不存在的聊天室
> curl -X GET http://localhost:8081/api/v1/channels/key/3 -H "Content-Type: applicaton/json" -H "Authorization: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"無法存取該聊天室"}
已不同帳號登入,嘗試取得不屬於自己的聊天室 (以花帆的身份登入)
> curl -X POST http://localhost:8081/api/v1/auth/login -H "Content-Type: application/json" -d "{\"user\": \"kaho_chan\", \"password\": \"Hinoshita0522\"}"
{"error":"","token":"BIkBbQ8+1Hc1B1IqrCCab/GwDFiCSE3SIAlwDyI90ygvnWTFct9QzHd8/BE5Hy5n"}
> curl -X GET http://localhost:8081/api/v1/channels/key/1 -H "Content-Type: applicaton/json" -H "Authorization: BIkBbQ
8+1Hc1B1IqrCCab/GwDFiCSE3SIAlwDyI90ygvnWTFct9QzHd8/BE5Hy5n"
{"error":"用戶不屬於該聊天室"}
接著我們處理傳送訊息的邏輯,傳送訊息前,我們要檢查該用戶是否屬於該聊天室,且聊天室存在。如果都符合則我們要將訊息放入另一個資料表 messages
,並且我們還要啟用通知。
Path: /api/v1/messages/:channel_id
Method: POST
Header:
- Authorization
Request:
- encrypted_msg string
Response:
success:
- 200 OK
fail:
- 400 Bad Request 請求格式不正確
- 401 Unauthorized 未經授權
- 403 Forbidden 無法存取該聊天室
- 500 Internal Server Error 伺服器端發生錯誤
content:
- error string
- message_id int
首先我們先在資料庫中建立 messages
表
create table messages(
id serial not null primary key,
channel_id int not null references channels(id) on delete cascade,
sender int references users(id),
encrypted_message text not null,
created_at timestampz not null default now()
);
對應的 struct
type Message struct {
ID int
ChannelID int
Sender int
EncryptedMessage string
CreatedAt time.Time
}
接著我們開始實作 SendMessage
func SendMessage(db *pg.DB, sender, channelID int, encryptedMessage string) (int, error) {
tx, err := db.Begin()
if err != nil {
return -1, fmt.Errorf("db.Begin failed: %w", err)
}
defer tx.Rollback()
view := ViewFriendChannels{}
err = tx.Model(&view).
Where("channel_id = ?", channelID).
Where("inviter = ? or invitee = ?", sender, sender).
Select()
if err != nil {
if err == pg.ErrNoRows {
return -1, ErrorForbidden
}
return -1, fmt.Errorf("tx.Model.Where.Where.Select failed: %w", err)
}
message := Message{
ChannelID: channelID,
Sender: sender,
EncryptedMessage: encryptedMessage,
}
_, err = tx.Model(&message).Insert()
if err != nil {
return -1, fmt.Errorf("tx.Model.Insert failed: %w", err)
}
err = tx.Commit()
if err != nil {
return -1, fmt.Errorf("tx.Commit failed: %w", err)
}
return message.ID, nil
}
接著將 SendMessage
註冊到 router
router.POST("/api/v1/messages/:channel_id", func(c *gin.Context) {
channelID, err := strconv.Atoi(c.Param("channel_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
req := struct {
EncryptedMessage string `json:"encrypted_msg" binding:"required"`
}{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
token := c.GetHeader("Authorization")
me, err := auth.CheckLogin(db, token)
if err != nil {
if err == auth.ErrorAuthenticationFailed {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "驗證失敗",
})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
messageID, err := channels.SendMessage(db, me.ID, channelID, req.EncryptedMessage)
if err != nil {
if err == channels.ErrorForbidden {
c.JSON(http.StatusForbidden, gin.H{"error": "無法存取該聊天室"})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
c.JSON(http.StatusOK, gin.H{
"error": "",
"message_id": messageID,
})
})
嘗試發送訊息
> curl -X POST http://localhost:8081/api/v1/messages/1 -H "Content-Type: applicaton/json" -H "Authorization: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0" -d "{\"encrypted_msg\": \"hello\"}"
{"error":"","message_id":1}
由於聊天室訊息會不斷變動,無法單純使用 offset
的方式進行存取,我們可以先給最新的 10 則訊息,並以最後一則的 message id 做為下次 API 的指針。
Path: /api/v1/messages/:channel_id/:next
Method: GET
Header:
- Authorization
Response:
success:
- 200 OK
fail:
- 400 Bad Request 請求格式不正確
- 401 Unauthorized 未經授權
- 403 Forbidden 無法存取該聊天室
- 500 Internal Server Error 伺服器端發生錯誤
content:
- error string
- next int (-1 表示無下一則訊息)
- list:
- encrypted_msg string
- sender int
- time int
我們實作 GetMessages
,一開始一樣先對 uid
做檢查,確定 uid
在 channel
之內。接著我們假設 cursor
為 0
時代表取得最新訊息。
const maxMessage = 10
func GetMessages(db *pg.DB, uid, channelID int, cursor int) (messages []Message, err error) {
view := ViewFriendChannels{}
err = db.Model(&view).
Where("channel_id = ?", channelID).
Where("inviter = ? or invitee = ?", uid, uid).
Select()
if err != nil {
if err == pg.ErrNoRows {
return messages, ErrorForbidden
}
return messages, fmt.Errorf("db.Model.Where.Where.Select failed: %w", err)
}
query := db.Model(&messages)
if cursor != 0 {
query = query.Where("id < ?", cursor)
}
err = query.
Order("id DESC").
Limit(maxMessage).
Select()
if err != nil && err != pg.ErrNoRows {
return messages, fmt.Errorf("db.Model.Order.Limit.Select failed: %w", err)
}
return messages, nil
}
我們將 GetMessages
註冊到 router
上。當呼叫 GetMessages
所得的訊息列表為空時,我們將回傳的 next
設為 -1
另外為了將 Message
struct 正確序列化為 JSON
我們要對其欄位增加 tag
type Message struct {
ID int `json:"-"`
ChannelID int `json:"-"`
Sender int `json:"sender"`
EncryptedMessage string `json:"encrypted_msg"`
CreatedAt time.Time `json:"time"`
}
router.GET("/api/v1/messages/:channel_id/:next", func(c *gin.Context) {
channelID, err := strconv.Atoi(c.Param("channel_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
next, err := strconv.Atoi(c.Param("next"))
if err != nil || next < 0{
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
token := c.GetHeader("Authorization")
me, err := auth.CheckLogin(db, token)
if err != nil {
if err == auth.ErrorAuthenticationFailed {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "驗證失敗",
})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
list, err := channels.GetMessages(db, me.ID, channelID, next)
if err != nil {
if err == channels.ErrorForbidden {
c.JSON(http.StatusForbidden, gin.H{"error": "無法存取該聊天室"})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
n := -1
if len(list) > 0 {
n = list[len(list)-1].ID
}
ret := struct {
Error string `json:"error"`
Next int `json:"next"`
List []channels.Message `json:"list"`
}{
Error: "",
Next: n,
List: list,
}
c.JSON(http.StatusOK, &ret)
})
測試取得聊天訊息前,我們先手動以先前傳送訊息的 API 新增多筆訊息,新增後如下圖
whisper=# select * from messages;
id | channel_id | sender | encrypted_message | created_at
----+------------+--------+-------------------+-------------------------------
1 | 1 | 4 | hello | 2024-10-01 06:09:40.656219+00
2 | 1 | 4 | message 2 | 2024-10-01 06:09:46.589309+00
3 | 1 | 4 | message 3 | 2024-10-01 06:09:48.653006+00
4 | 1 | 4 | message 4 | 2024-10-01 06:09:50.415599+00
5 | 1 | 4 | message 5 | 2024-10-01 06:09:51.950446+00
6 | 1 | 4 | message 6 | 2024-10-01 06:09:53.906179+00
7 | 1 | 4 | message 7 | 2024-10-01 06:09:55.403372+00
8 | 1 | 4 | message 8 | 2024-10-01 06:09:57.095325+00
9 | 1 | 4 | message 9 | 2024-10-01 06:09:59.111018+00
10 | 1 | 4 | message 10 | 2024-10-01 06:10:00.911462+00
11 | 1 | 4 | message 11 | 2024-10-01 06:10:02.229499+00
12 | 1 | 4 | message 12 | 2024-10-01 06:10:03.501134+00
13 | 1 | 4 | message 13 | 2024-10-01 06:10:05.027641+00
14 | 1 | 4 | message 14 | 2024-10-01 06:10:07.490861+00
15 | 1 | 4 | message 15 | 2024-10-01 06:10:08.912864+00
16 | 1 | 4 | message 16 | 2024-10-01 06:10:10.427643+00
17 | 1 | 4 | message 17 | 2024-10-01 06:10:12.003017+00
18 | 1 | 4 | message 18 | 2024-10-01 06:10:13.660654+00
19 | 1 | 4 | message 19 | 2024-10-01 06:10:15.479134+00
20 | 1 | 4 | message 20 | 2024-10-01 06:10:17.251483+00
(20 rows)
嘗試取得新訊息,此時 next
回傳 11
> curl -X GET http://localhost:8081/api/v1/messages/1/0 -H "Content-Type: applicaton/json" -H "Authorizati
on: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"","next":11,"list":[{"sender":4,"encrypted_msg":"message 20","time":"2024-10-01T06:10:17.251483Z"}, ..., {"sender":4,"encrypted_msg":"message 11","time":"2024-10-01T06:10:02.229499Z"}]}
我們以 11
繼續往下打
> curl -X GET http://localhost:8081/api/v1/messages/1/11 -H "Content-Type: applicaton/json" -H "Authorizat
ion: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"","next":1,"list":[{"sender":4,"encrypted_msg":"message 10","time":"2024-10-01T06:10:00.911462Z"},...,{"sender":4,"encrypted_msg":"hello","time":"2024-10-01T06:09:40.656219Z"}]}
以 1
繼續往下打
> curl -X GET http://localhost:8081/api/v1/messages/1/1 -H "Content-Type: applicaton/json" -H "Authorizati
on: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"","next":-1,"list":null}
此時得到 next: -1
如果繼續以 1
向下打的話我們回傳 Bad Request
> curl -X GET http://localhost:8081/api/v1/messages/1/-1 -H "Content-Type: applicaton/json" -H "Authorizat
ion: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"bad request"}
Path: /api/v1/channels/:next
Method: GET
Header:
- Authorization
Response:
success:
- 200 OK
fail:
- 400 Bad Request 請求格式不正確
- 401 Unauthorized 未經授權
- 500 Internal Server Error 伺服器端發生錯誤
content:
- error string
- next int (-1 代表無下一筆)
- list:
- channel_id int
- attendee int (朋友的 id)
- attendee_profile string (朋友的頭貼)
- encrypted_key string (用自己的公鑰加密的 key)
- last_encrypted_msg string (已加密的最新訊息)
雖然這個要求有點複雜,但我還是勉強生出一個可行的 SQL,如果沒有 message
時,message.created_at
會為空,為了解決這個問題,我們可以在當沒有最新訊息時,以聊天室建立的時間 channel.created_at
代替,重新改些 view_friend_channels
create view view_channel_lists as
select
t1.me,
t1.channel_id,
t1.attendee,
t1.attendee_profile,
t1.encrypted_key,
t2.encrypted_message,
coalesce(t2.created_at, t1.channel_created_at) as timing
from
(select
u.id as me,
c.id as channel_id,
v.id as attendee,
v.profile as attendee_profile,
c.key_encrypted_by_inviter_key as encrypted_key,
c.created_at as channel_created_at
from
friends as f,
users as u,
users as v,
channels as c
where
f.inviter = u.id and
f.invitee = v.id and
f.id = c.friend_id
union
select
u.id as me,
c.id as channel_id,
v.id as attendee,
v.profile as attendee_profile,
c.key_encrypted_by_invitee_key as encrypted_key,
c.created_at as channel_created_at
from
friends as f,
users as u,
users as v,
channels as c
where
f.invitee = u.id and
f.inviter = v.id and
f.id = c.friend_id) t1
left join
(select
m.channel_id,
m.encrypted_message,
m.created_at
from
messages as m
order by created_at desc limit 1
) t2
on t2.channel_id = t1.channel_id;
為了同時把會在 invite
或 invitee
出現的始用者抓出來,我們先對資料動點手腳。把原本 4 號乙宗梢和 6 號花帆的好友關係換個方向,再重新建立頻道
whisper=# select inviter, invitee from friends;
inviter | invitee
---------+---------
4 | 5
4 | 7
4 | 8
4 | 9
4 | 10
4 | 11
4 | 12
6 | 5
6 | 4
(9 rows)
接下來看一下 view_friend_channels
和 view_channel_lists
的效果:
使用 Go 建立對應的 struct
type ViewChannelLists struct {
Me int `json:"-"`
ChannelID int `json:"channel_id"`
Attendee int `json:"attendee"`
AttendeeProfile string `json:"attendee_profile"`
EncryptedKey string `json:"encrypted_key"`
EncryptedMessage string `json:"encrypted_message"`
Timing time.Time `json:"-"`
}
接下來,我們只要對 me
和 timing
做 query 就以無痛完成 API 了!
const maxChannels = 1
func GetChannels(db *pg.DB, next time.Time, uid int) (list []ViewChannelLists, err error) {
err = db.Model(&list).
Where("me = ?", uid).
Where("timing < ?", next).
Order("timing DESC").
Limit(maxChannels).
Select()
if err != nil && err != pg.ErrNoRows {
return list, fmt.Errorf("db.Model failed: %w", err)
}
return list, err
}
接著再將 GetChannels
註冊到 router
中。
router.GET("/api/v1/channels/:next", func(c *gin.Context) {
next, err := time.Parse(time.RFC3339, c.Param("next"))
if err != nil || next == time.Unix(0, 0) {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
token := c.GetHeader("Authorization")
me, err := auth.CheckLogin(db, token)
if err != nil {
if err == auth.ErrorAuthenticationFailed {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "驗證失敗",
})
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
list, err := channels.GetChannels(db, next, me.ID)
if err != nil {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
return
}
n := time.Unix(0, 0)
if len(list) > 0 {
n = list[len(list)-1].Timing
}
ret := struct {
Error string `json:"error"`
Next time.Time `json:"next"`
List []channels.ViewChannelLists `json:"list"`
}{
Error: "",
Next: n,
List: list,
}
c.JSON(http.StatusOK, &ret)
})
打 API 時先以現在時間下去打,接著繼續往下,因為我們資料庫只有兩筆資料,所以上限先設 1 筆來檢查😂
> curl -X GET http://localhost:8081/api/v1/channels/2024-10-02T00:17:00+08:00 -H "Content-Type: applicaton/json" -H "Authorization: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"","next":"2024-10-01T14:31:55.807598Z","list":[{"channel_id":3,"attendee":6,"attendee_profile":"","encrypted_key":"example_key_encrypted_by_attendee","encrypted_message":""}]}
> curl -X GET http://localhost:8081/api/v1/channels/2024-10-01T14:31:55.807598Z -H "Content-Type: applicaton/json" -H "Authorizat
ion: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"","next":"2024-10-01T06:10:17.251483Z","list":[{"channel_id":1,"attendee":5,"attendee_profile":"","encrypted_key":"example_key_encrypted_by_host","encrypted_message":"message 20"}]}
> curl -X GET http://localhost:8081/api/v1/channels/2024-10-01T06:10:17.251483Z -H "Content-Type: applicaton/json" -H "Authorizat
ion: 34rCF++rUIgMUexEMdIyk4FNHfSu7UxJofgv/ND+fBK+MTbheIRDf5b9h3t0OeE0"
{"error":"","next":"1970-01-01T08:00:00+08:00","list":null}