Files
kefu/im/backend/internal/app/im_membership_gate_integration_test.go
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

134 lines
5.3 KiB
Go

package app
import (
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
)
// Voice and image messages can be limited to members from the admin console.
// The switch has to hold on the real send path, not only in the UI.
func TestMessageMembershipGateMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
setGate := func(key, value string) {
t.Helper()
if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES(?,?,'boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, key, value); err != nil {
t.Fatal(err)
}
}
// 这里测的是语音/图片的会员限制,与开场消息条数无关,后者会干扰连发。
if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('membership.free_unanswered_message_limit','0','integer','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`); err != nil {
t.Fatal(err)
}
for _, media := range []struct {
url string
kind string
}{{"https://cdn.example.com/voice-1.m4a", "audio"}, {"https://cdn.example.com/image-1.jpg", "image"}} {
if _, err := db.Exec(`INSERT INTO media_assets(owner_user_id,media_type,public_url,status,moderation_status) VALUES(1,?,?,1,1)`, media.kind, media.url); err != nil {
t.Fatal(err)
}
}
var conversation struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &conversation); err != nil {
t.Fatal(err)
}
path := fmt.Sprintf("/api/v1/im/conversations/%d/messages", conversation.ID)
voice := `{"clientMsgId":%q,"type":3,"content":{"url":"https://cdn.example.com/voice-1.m4a","duration":3}}`
image := `{"clientMsgId":%q,"type":2,"content":{"url":"https://cdn.example.com/image-1.jpg"}}`
send := func(payload, key string, wantStatus int) string {
t.Helper()
w := httptest.NewRecorder()
a.sendMessageHTTP(w, imTestRequest(1, "POST", path, fmt.Sprintf(payload, key)))
if w.Code != wantStatus {
t.Fatalf("HTTP %d, want %d: %s", w.Code, wantStatus, w.Body.String())
}
return w.Body.String()
}
member := func(active bool) {
t.Helper()
if _, err := db.Exec(`DELETE FROM subscriptions WHERE user_id=1`); 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,1,'test',1,NOW(3),DATE_ADD(NOW(3),INTERVAL 30 DAY))`); err != nil {
t.Fatal(err)
}
}
t.Run("both kinds are open while the switches are off", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "false")
setGate("membership.image_message_requires_vip", "false")
send(voice, "open-voice", 200)
send(image, "open-image", 200)
})
t.Run("a non member is refused with its own code once the switch is on", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "true")
body := send(voice, "gated-voice", 402)
if !strings.Contains(body, "30006") || !strings.Contains(body, "发送语音消息需要开通会员") {
t.Fatalf("voice refusal = %s", body)
}
if body := send(image, "gated-image", 402); !strings.Contains(body, "发送图片消息需要开通会员") {
t.Fatalf("image refusal = %s", body)
}
// Text is never gated: the two switches must not silence the chat.
imTestCall(t, a.sendMessageHTTP, 1, "POST", path, `{"clientMsgId":"gated-text","type":1,"content":{"text":"仍然可以说话"}}`, 200)
})
t.Run("each switch only gates its own kind", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "false")
send(voice, "half-voice", 402)
send(image, "half-image", 200)
})
t.Run("a member sends both kinds", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "true")
member(true)
defer member(false)
send(voice, "member-voice", 200)
send(image, "member-image", 200)
})
t.Run("the membership status tells the client before it records anything", func(t *testing.T) {
setGate("membership.voice_message_requires_vip", "true")
setGate("membership.image_message_requires_vip", "false")
var status struct {
Entitlements struct {
VoiceRequiresVIP bool `json:"voiceMessageRequiresVip"`
ImageRequiresVIP bool `json:"imageMessageRequiresVip"`
CanSendVoice bool `json:"canSendVoiceMessage"`
CanSendImage bool `json:"canSendImageMessage"`
} `json:"entitlements"`
}
read := func(user int64) {
t.Helper()
if err := json.Unmarshal(imTestCall(t, a.membershipStatus, user, "GET", "/api/v1/membership/status", "", 200), &status); err != nil {
t.Fatal(err)
}
}
read(1)
if !status.Entitlements.VoiceRequiresVIP || status.Entitlements.ImageRequiresVIP {
t.Fatalf("switches not reported: %+v", status.Entitlements)
}
if status.Entitlements.CanSendVoice || !status.Entitlements.CanSendImage {
t.Fatalf("non member capabilities: %+v", status.Entitlements)
}
member(true)
defer member(false)
read(1)
if !status.Entitlements.CanSendVoice {
t.Fatalf("member must be allowed to send voice: %+v", status.Entitlements)
}
})
}