iT邦幫忙

2024 iThome 鐵人賽

DAY 28
0
Mobile Development

從零開始以Flutter打造跨平台聊天APP系列 第 28

Day-28 實作(9) 使用 Gin 建立頻道、通知系統

  • 分享至 

  • xImage
  •  

Genrated by ChatGPT GPT-4o

接下來我們要處理聊天頻道、訊息及通知等。處了要建立頻道還要能取得頻道列表,取得密鑰等,並且也要能傳送訊息、讀取訊息,最後我們還要建立一套通知系統,來通知使用者有新訊息。

範例程式碼: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

建立聊天室時,按照以下這幾個步驟來實現金鑰交換

  1. 要求建立的一方稱為 Host,談話另一方稱為 Attendee
  2. 在前端,Host 建立金鑰並使用 Attendee 的公鑰將金鑰加密,發送給伺服器
  3. 此時,Attendee 可以利用自己的私鑰解得金鑰
  4. 除此之外,如果 Host 更換裝置的話,無法得到金鑰,因此 Host 也需將金鑰以自己的公鑰加密存放到伺服器
  5. 由於伺服器都不知道雙方的私鑰,無法解開金鑰,因此伺服器無法查看被金鑰加密過的訊息

這個是昨天所提到的 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_idfriends_table 查詢,查詢 uidinviter 還是 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 做檢查,確定 uidchannel 之內。接著我們假設 cursor0 時代表取得最新訊息。

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;

為了同時把會在 inviteinvitee 出現的始用者抓出來,我們先對資料動點手腳。把原本 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_channelsview_channel_lists 的效果:

demo-of-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:"-"`
}

接下來,我們只要對 metiming 做 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}

上一篇
Day-27 實作(8) 使用 Gin 完成個人資料及朋友處理系統
下一篇
Day-29 實作(10) 將 Flutter 串接 API 到後端伺服器
系列文
從零開始以Flutter打造跨平台聊天APP30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言