一、对方回复前,免费用户最多发送 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>
134 lines
5.3 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|