Files
kefu/im/backend/internal/app/unanswered_limit_integration_test.go
T
Your NameandClaude Opus 5 f313979e88 开场消息条数限制,附近按距离分档随机刷新
一、对方回复前,免费用户最多发送 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>
2026-09-04 18:21:19 +08:00

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)
}
})
}