一、对方回复前,免费用户最多发送 N 条(默认 3,迁移 034,管理端可改, 0 表示不限)。约束在 persistMessageContext 的事务内,HTTP 与 WebSocket 两条发送路径都覆盖,并发也不会挤过最后一个名额。要点: - 对方一旦开口,限制永久解除;会员始终不受限。 - 撤回自己的消息不会换回名额,否则删了重发即可绕过。 - AI 托管账号的回复是「回答」不是「敲门」,不计入限制。 - 被拒时返回 30007 + HTTP 402,与会员媒体限制区分,客户端据此引导开通。 - 消息列表接口附带 unansweredQuota,聊天页在发送失败之前就把剩余条数 告诉用户。 二、附近下拉刷新按距离分档随机。原先带 seed 的分支排在最前,导致附近 一旦刷新就退化成全城纯随机、距离排序被完全丢弃。现在按 1 公里分档, 档内用同一 seed 做确定性洗牌:刷新会换人,但近处的人永远排在远处之前, 同一 seed 下分页顺序稳定,不会重复或漏人。 两个既有测试显式关闭了开场限制——它们测的是入队规则和媒体会员限制, 连发消息只是手段。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Opening a conversation is cheap for the sender and expensive for the reader,
|
|
// so a free account may only knock a few times before the other side answers.
|
|
// Once there is an answer the conversation is mutual and the cap is gone.
|
|
func TestUnansweredMessageLimitMySQL(t *testing.T) {
|
|
db := isolatedIMDatabase(t)
|
|
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
|
setLimit := func(value string) {
|
|
t.Helper()
|
|
if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('membership.free_unanswered_message_limit',?,'integer','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
conversation := func(viewer, peer int64) int64 {
|
|
t.Helper()
|
|
var created struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
body := fmt.Sprintf(`{"userId":%d}`, peer)
|
|
if err := json.Unmarshal(imTestCall(t, a.directConversation, viewer, "POST", "/api/v1/im/conversations", body, 200), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return created.ID
|
|
}
|
|
// A direct conversation between two people is idempotent, so each subtest
|
|
// starts from an empty one rather than inheriting the previous knocks.
|
|
fresh := func(viewer, peer int64) int64 {
|
|
t.Helper()
|
|
id := conversation(viewer, peer)
|
|
if _, err := db.Exec(`DELETE FROM im_messages WHERE conversation_id=?`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
send := func(conversationID, sender int64, key string) (int, string) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
path := fmt.Sprintf("/api/v1/im/conversations/%d/messages", conversationID)
|
|
payload := fmt.Sprintf(`{"clientMsgId":%q,"type":1,"content":{"text":"你好"}}`, key)
|
|
a.sendMessageHTTP(w, imTestRequest(sender, "POST", path, payload))
|
|
return w.Code, w.Body.String()
|
|
}
|
|
member := func(userID int64, active bool) {
|
|
t.Helper()
|
|
if _, err := db.Exec(`DELETE FROM subscriptions WHERE user_id=?`, userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !active {
|
|
return
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,1,'test',1,NOW(3),DATE_ADD(NOW(3),INTERVAL 30 DAY))`, userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
setLimit("3")
|
|
|
|
t.Run("the fourth unanswered message is refused with its own code", func(t *testing.T) {
|
|
id := fresh(1, 2)
|
|
for index := 1; index <= 3; index++ {
|
|
if status, body := send(id, 1, fmt.Sprintf("knock-%d", index)); status != 200 {
|
|
t.Fatalf("第 %d 条应当发出: HTTP %d %s", index, status, body)
|
|
}
|
|
}
|
|
status, body := send(id, 1, "knock-4")
|
|
if status != 402 || !strings.Contains(body, "30007") || !strings.Contains(body, "对方回复前最多发送 3 条") {
|
|
t.Fatalf("HTTP %d: %s", status, body)
|
|
}
|
|
})
|
|
|
|
t.Run("one answer lifts the cap for good", func(t *testing.T) {
|
|
id := fresh(1, 3)
|
|
for index := 1; index <= 3; index++ {
|
|
send(id, 1, fmt.Sprintf("a-knock-%d", index))
|
|
}
|
|
if status, _ := send(id, 1, "a-knock-4"); status != 402 {
|
|
t.Fatalf("HTTP %d", status)
|
|
}
|
|
if status, body := send(id, 3, "the-answer"); status != 200 {
|
|
t.Fatalf("对方回复本身不受限: HTTP %d %s", status, body)
|
|
}
|
|
for index := 5; index <= 8; index++ {
|
|
if status, body := send(id, 1, fmt.Sprintf("a-knock-%d", index)); status != 200 {
|
|
t.Fatalf("回复之后应当不限: HTTP %d %s", status, body)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a member is never capped", func(t *testing.T) {
|
|
member(2, true)
|
|
defer member(2, false)
|
|
id := fresh(2, 3)
|
|
for index := 1; index <= 6; index++ {
|
|
if status, body := send(id, 2, fmt.Sprintf("vip-%d", index)); status != 200 {
|
|
t.Fatalf("会员第 %d 条被拦: HTTP %d %s", index, status, body)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("deleting your own message does not hand back a slot", func(t *testing.T) {
|
|
id := fresh(1, 2)
|
|
for index := 1; index <= 3; index++ {
|
|
send(id, 1, fmt.Sprintf("recall-%d", index))
|
|
}
|
|
if _, err := db.Exec(`UPDATE im_messages SET recalled_at=NOW(3) WHERE conversation_id=? AND sender_id=1`, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status, _ := send(id, 1, "recall-4"); status != 402 {
|
|
t.Fatalf("撤回不能换来新的额度: HTTP %d", status)
|
|
}
|
|
})
|
|
|
|
t.Run("the limit can be switched off entirely", func(t *testing.T) {
|
|
setLimit("0")
|
|
defer setLimit("3")
|
|
id := fresh(1, 3)
|
|
for index := 1; index <= 5; index++ {
|
|
if status, body := send(id, 1, fmt.Sprintf("off-%d", index)); status != 200 {
|
|
t.Fatalf("HTTP %d: %s", status, body)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an AI reply is an answer, not a knock", func(t *testing.T) {
|
|
id := fresh(2, 3)
|
|
for index := 1; index <= 4; index++ {
|
|
if _, _, err := a.persistMessageContext(markAIGenerated(context.Background()), id, 3, fmt.Sprintf("ai-%d", index), 1, map[string]any{"text": "在的"}); err != nil {
|
|
t.Fatalf("托管账号的回复不该受开场限制: %v", err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("the chat page is told how many opening messages are left", func(t *testing.T) {
|
|
id := fresh(1, 2)
|
|
read := func(user int64) map[string]any {
|
|
t.Helper()
|
|
var payload struct {
|
|
Quota map[string]any `json:"unansweredQuota"`
|
|
}
|
|
path := fmt.Sprintf("/api/v1/im/conversations/%d/messages", id)
|
|
if err := json.Unmarshal(imTestCall(t, a.messages, user, "GET", path, "", 200), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return payload.Quota
|
|
}
|
|
if quota := read(1); quota["remaining"] != float64(3) || quota["unlimited"] != false {
|
|
t.Fatalf("quota = %v", quota)
|
|
}
|
|
send(id, 1, "quota-1")
|
|
if quota := read(1); quota["remaining"] != float64(2) {
|
|
t.Fatalf("quota = %v", read(1))
|
|
}
|
|
send(id, 2, "quota-answer")
|
|
if quota := read(1); quota["unlimited"] != true || quota["remaining"] != float64(-1) {
|
|
t.Fatalf("对方回复后不再有额度概念: %v", quota)
|
|
}
|
|
})
|
|
}
|