Files
kefu/im/backend/internal/app/push_integration_test.go
T
Your NameandClaude Opus 5 ce429afcf2 密码强度下限、离线推送下发
一、密码规则:至少 8 位且不能全是数字。短信验证可以在后台关掉,关掉之后
密码就是账号质量的唯一门槛,而此前 validUserPassword 只检查非空——"1"
也是合法密码。规则只在「设置密码」时校验(注册、找回、改密、后台建号与
后台重置),登录不再校验,已有账号照常使用。上下限都按字符数计算,否则
43 个汉字的密码会因为字节数超限被拒。各处错误提示改为直接说明规则,
而不是笼统的一句「请填写有效的密码」。

二、离线推送:此前客户端一直在上报 push token,服务端从未下发过任何东西,
App 退到后台或被杀掉时新消息完全没有提醒(MESSAGE_PUSH 只是 WebSocket
帧名)。补上服务端下发:
- 只发给「此刻不在线 + 未对该会话免打扰 + 未关闭消息通知」的接收者,
  在线的人已经从实时通道拿到了。
- 鉴权 token 按 provider 的过期时间缓存,个推的 auth 接口限流很紧。
- 失效的 cid(10001/10002)就地停用,不再每条消息重试一次。
- 整个过程在独立 goroutine 与独立 context 上进行,推送服务再慢也不会
  拖慢或拖垮一条已经发出的消息。
- 是否显示正文由 push.show_preview 控制,关闭后锁屏上不出现消息内容。
凭据在管理端「离线推送」页填写(迁移 035 先建出配置行——集成配置保存
走的是 UPDATE,行不存在会静默保存不上)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:09:33 +08:00

280 lines
10 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
type capturedPush struct {
mu sync.Mutex
paths []string
auths []map[string]any
sends []map[string]any
}
func (c *capturedPush) record(path string, body map[string]any) {
c.mu.Lock()
defer c.mu.Unlock()
c.paths = append(c.paths, path)
if strings.HasSuffix(path, "/auth") {
c.auths = append(c.auths, body)
return
}
c.sends = append(c.sends, body)
}
func (c *capturedPush) snapshot() ([]string, []map[string]any, []map[string]any) {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string{}, c.paths...), append([]map[string]any{}, c.auths...), append([]map[string]any{}, c.sends...)
}
func pushFixture(t *testing.T, reply func(path string) string) (*App, *capturedPush) {
t.Helper()
db := isolatedIMDatabase(t)
captured := &capturedPush{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
var body map[string]any
_ = json.Unmarshal(raw, &body)
if !strings.HasSuffix(r.URL.Path, "/auth") {
body["__token_header"] = r.Header.Get("token")
}
captured.record(r.URL.Path, body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(reply(r.URL.Path)))
}))
t.Cleanup(server.Close)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
for _, statement := range []string{
`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES
('push.enabled','true','boolean',''),('push.app_id','APP123','text',''),('push.app_key','KEY456','text',''),
('push.show_preview','true','boolean',''),('membership.free_unanswered_message_limit','0','integer','')
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatal(err)
}
}
secret, err := a.encryptSecret("SECRET789")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('push.master_secret',?,'secret','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value),value_type='secret'`, secret); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('push.base_url',?,'text','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, server.URL); err != nil {
t.Fatal(err)
}
return a, captured
}
func okReply(path string) string {
if strings.HasSuffix(path, "/auth") {
return `{"code":0,"msg":"success","data":{"token":"auth-token-1","expire_time":"99999999999999"}}`
}
return `{"code":0,"msg":"success"}`
}
// The App collected push tokens for months and never sent anything: a message
// arriving while the app was closed made no sound at all.
func TestOfflineMessagePushMySQL(t *testing.T) {
a, captured := pushFixture(t, okReply)
db := a.db
if _, err := db.Exec(`INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(2,'device-2','unipush','cid-of-user-2','android',1)`); err != nil {
t.Fatal(err)
}
var created struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &created); err != nil {
t.Fatal(err)
}
settle := func() {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, _, sends := captured.snapshot(); len(sends) > 0 {
return
}
time.Sleep(30 * time.Millisecond)
}
}
if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, "push-1", 1, map[string]any{"text": "在吗,明天有空吗"}); err != nil {
t.Fatal(err)
}
settle()
paths, auths, sends := captured.snapshot()
if len(sends) != 1 {
t.Fatalf("应当下发一条推送: paths=%v", paths)
}
if len(auths) != 1 || auths[0]["appkey"] != "KEY456" || auths[0]["sign"] == "" {
t.Fatalf("鉴权请求不正确: %v", auths)
}
if !strings.Contains(paths[0], "/v2/APP123/auth") || !strings.Contains(paths[1], "/v2/APP123/push/single/cid") {
t.Fatalf("接口路径不正确: %v", paths)
}
if sends[0]["__token_header"] != "auth-token-1" {
t.Fatalf("推送请求必须带鉴权 token: %v", sends[0])
}
audience := sends[0]["audience"].(map[string]any)["cid"].([]any)
if len(audience) != 1 || audience[0] != "cid-of-user-2" {
t.Fatalf("收件人不正确: %v", audience)
}
notification := sends[0]["push_message"].(map[string]any)["notification"].(map[string]any)
if notification["title"] != "IM测试1" || notification["body"] != "在吗,明天有空吗" {
t.Fatalf("通知内容: %v", notification)
}
// The auth token is reused: the provider rate-limits that endpoint hard.
if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, "push-2", 1, map[string]any{"text": "再问一次"}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, _, sends := captured.snapshot(); len(sends) > 1 {
break
}
time.Sleep(30 * time.Millisecond)
}
if _, auths, sends := captured.snapshot(); len(auths) != 1 || len(sends) != 2 {
t.Fatalf("第二次不应重新鉴权: auths=%d sends=%d", len(auths), len(sends))
}
}
func TestPushIsSkippedForEveryoneWhoDoesNotNeedItMySQL(t *testing.T) {
a, captured := pushFixture(t, okReply)
db := a.db
for _, statement := range []string{
`INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(2,'device-2','unipush','cid-2','android',1)`,
`INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(3,'device-3','unipush','cid-3','android',1)`,
`INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(1,'device-1','unipush','cid-1','android',1)`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatal(err)
}
}
var created struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &created); err != nil {
t.Fatal(err)
}
// The client message id is ASCII only, so the case name and the id differ.
attempt := 0
quiet := func(reason string) {
t.Helper()
attempt++
if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, fmt.Sprintf("quiet-%d", attempt), 1, map[string]any{"text": "在吗"}); err != nil {
t.Fatal(err)
}
time.Sleep(400 * time.Millisecond)
if _, _, sends := captured.snapshot(); len(sends) != 0 {
t.Fatalf("%s: 不应下发推送,实际 %d 条", reason, len(sends))
}
}
// Muted conversation.
if _, err := db.Exec(`UPDATE im_conversation_members SET muted=1 WHERE conversation_id=? AND user_id=2`, created.ID); err != nil {
t.Fatal(err)
}
quiet("会话已免打扰")
if _, err := db.Exec(`UPDATE im_conversation_members SET muted=0 WHERE conversation_id=? AND user_id=2`, created.ID); err != nil {
t.Fatal(err)
}
// Notifications switched off by the recipient.
if _, err := db.Exec(`INSERT INTO user_notification_settings(user_id,im_enabled) VALUES(2,0) ON DUPLICATE KEY UPDATE im_enabled=0`); err != nil {
t.Fatal(err)
}
quiet("用户关闭了消息通知")
if _, err := db.Exec(`UPDATE user_notification_settings SET im_enabled=1 WHERE user_id=2`); err != nil {
t.Fatal(err)
}
// The switch in the admin console.
if _, err := db.Exec(`UPDATE system_configs SET config_value='false' WHERE config_key='push.enabled'`); err != nil {
t.Fatal(err)
}
quiet("总开关关闭")
if _, err := db.Exec(`UPDATE system_configs SET config_value='true' WHERE config_key='push.enabled'`); err != nil {
t.Fatal(err)
}
// Nobody pushes to themselves, and an online recipient already has it.
if _, _, err := a.persistMessageContext(context.Background(), created.ID, 2, "own-message", 1, map[string]any{"text": "我自己发的"}); err != nil {
t.Fatal(err)
}
time.Sleep(400 * time.Millisecond)
_, _, sends := captured.snapshot()
if len(sends) != 1 {
t.Fatalf("只应给对方发 1 条,实际 %d", len(sends))
}
audience := sends[0]["audience"].(map[string]any)["cid"].([]any)
if len(audience) != 1 || audience[0] != "cid-1" {
t.Fatalf("推送应当只发给对方,实际收件人 %v", audience)
}
}
func TestPushPreviewRespectsTheContentSwitch(t *testing.T) {
long := strings.Repeat("很长的一句话", 20)
if got := pushPreview(1, map[string]any{"text": long}, true); len([]rune(got)) != pushPreviewRunes+1 {
t.Fatalf("预览应当截断: %d", len([]rune(got)))
}
if got := pushPreview(1, map[string]any{"text": "晚上一起吃饭吗"}, true); got != "晚上一起吃饭吗" {
t.Fatalf("got %q", got)
}
// Locked screens are public: with the switch off nothing of the text leaks.
if got := pushPreview(1, map[string]any{"text": "银行卡密码是 1234"}, false); strings.Contains(got, "1234") {
t.Fatalf("关闭预览后不得泄露正文: %q", got)
}
if got := pushPreview(messageTypeImage, nil, true); got != "[图片]" {
t.Fatalf("got %q", got)
}
if got := pushPreview(messageTypeVoice, nil, true); got != "[语音]" {
t.Fatalf("got %q", got)
}
}
// A client id the provider no longer knows is retired, so it is not retried on
// every single message from then on.
func TestRetiredPushTokenIsDisabledMySQL(t *testing.T) {
a, _ := pushFixture(t, func(path string) string {
if strings.HasSuffix(path, "/auth") {
return `{"code":0,"msg":"success","data":{"token":"auth-token-1","expire_time":"99999999999999"}}`
}
return `{"code":10001,"msg":"cid is invalid"}`
})
db := a.db
if _, err := db.Exec(`INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(2,'device-2','unipush','stale-cid','android',1)`); err != nil {
t.Fatal(err)
}
var created struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &created); err != nil {
t.Fatal(err)
}
if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, "stale", 1, map[string]any{"text": "在吗"}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var status int
if db.QueryRow(`SELECT status FROM user_push_tokens WHERE push_token='stale-cid'`).Scan(&status) == nil && status == 0 {
return
}
time.Sleep(30 * time.Millisecond)
}
t.Fatal("失效的 cid 应当被停用")
}