原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读, 动态和消息只能一条一条点,运营想发一句公告没有任何入口。 公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知, 发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经 读过的假装收回去只会让统计说谎。 资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 + 申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。 退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行; 退完最后一分钱才撤销买到的东西,半份会员仍然是会员。 风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、 跳用户详情,事件可以标记跟进完成。 批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。 处罚仍然一条一条来——批量封禁是错封人最快的方式。 敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定; 转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
12 KiB
Go
306 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// 这一批补的都是"管理端原来只能看"的地方,所以断言集中在两件事上:
|
|
// 动作真的落到了数据上,以及每个动作都留下了能复核的痕迹。
|
|
|
|
func TestAdminBroadcastMySQL(t *testing.T) {
|
|
db := isolatedIMDatabase(t)
|
|
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
|
|
|
t.Run("an announcement reaches the chosen audience and nobody else", func(t *testing.T) {
|
|
if _, err := db.Exec(`UPDATE user_profiles SET is_vip=1 WHERE user_id=2`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
preview := httptest.NewRecorder()
|
|
a.adminBroadcastPreview(preview, imTestRequest(1, "GET", "/admin/v1/notifications/broadcasts/preview?audience=vip", ""))
|
|
if preview.Code != 200 || !strings.Contains(preview.Body.String(), `"count":1`) {
|
|
t.Fatalf("会员人群预览应当是 1 人: %s", preview.Body.String())
|
|
}
|
|
send := httptest.NewRecorder()
|
|
a.adminSendBroadcast(send, imTestRequest(1, "POST", "/admin/v1/notifications/broadcasts",
|
|
`{"title":"会员权益调整","content":"从下周起会员每天的主动搭讪额度提高到 30 次。","audience":"vip"}`))
|
|
if send.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", send.Code, send.Body.String())
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
ID int64 `json:"id"`
|
|
RecipientCount int `json:"recipientCount"`
|
|
} `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(send.Body.Bytes(), &envelope)
|
|
if envelope.Data.RecipientCount != 1 {
|
|
t.Fatalf("收件人数 = %d", envelope.Data.RecipientCount)
|
|
}
|
|
var forVIP, forOthers int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE biz_type='broadcast' AND user_id=2`).Scan(&forVIP); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE biz_type='broadcast' AND user_id<>2`).Scan(&forOthers); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if forVIP != 1 || forOthers != 0 {
|
|
t.Fatalf("只有会员该收到: vip=%d others=%d", forVIP, forOthers)
|
|
}
|
|
})
|
|
|
|
t.Run("empty or oversized announcements are refused", func(t *testing.T) {
|
|
for _, body := range []string{
|
|
`{"title":"","content":"内容够长了但没有标题","audience":"all"}`,
|
|
`{"title":"太短","content":"两个字","audience":"all"}`,
|
|
`{"title":"人群不存在","content":"这条应该发不出去","audience":"martians"}`,
|
|
`{"title":"没选人","content":"指定用户但一个都没给","audience":"users"}`,
|
|
} {
|
|
w := httptest.NewRecorder()
|
|
a.adminSendBroadcast(w, imTestRequest(1, "POST", "/admin/v1/notifications/broadcasts", body))
|
|
if w.Code != 400 {
|
|
t.Fatalf("应当被拒绝: %s -> HTTP %d", body, w.Code)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("revoking takes back only what nobody has read", func(t *testing.T) {
|
|
send := httptest.NewRecorder()
|
|
a.adminSendBroadcast(send, imTestRequest(1, "POST", "/admin/v1/notifications/broadcasts",
|
|
`{"title":"系统维护","content":"今晚 23:00 到 24:00 之间服务会短暂中断,给你带来不便很抱歉。","audience":"all"}`))
|
|
if send.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", send.Code, send.Body.String())
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(send.Body.Bytes(), &envelope)
|
|
// 一个人已经读了,撤回不该把他那条也抹掉。
|
|
if _, err := db.Exec(`UPDATE notifications SET read_at=NOW(3) WHERE biz_type='broadcast' AND biz_id=? AND user_id=1`, envelope.Data.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
revoke := httptest.NewRecorder()
|
|
a.adminRevokeBroadcast(revoke, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/notifications/broadcasts/%d/revoke", envelope.Data.ID), ``))
|
|
if revoke.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", revoke.Code, revoke.Body.String())
|
|
}
|
|
var left int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE biz_type='broadcast' AND biz_id=?`, envelope.Data.ID).Scan(&left); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if left != 1 {
|
|
t.Fatalf("已读的那条应当留着: 还剩 %d 条", left)
|
|
}
|
|
again := httptest.NewRecorder()
|
|
a.adminRevokeBroadcast(again, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/notifications/broadcasts/%d/revoke", envelope.Data.ID), ``))
|
|
if again.Code != 400 {
|
|
t.Fatalf("重复撤回应当被拒绝: HTTP %d", again.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAdminKeywordsMySQL(t *testing.T) {
|
|
db := isolatedIMDatabase(t)
|
|
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
|
invalidateKeywordCache()
|
|
|
|
create := httptest.NewRecorder()
|
|
a.adminCreateKeyword(create, imTestRequest(1, "POST", "/admin/v1/moderation/keywords",
|
|
`{"word":"加我微信","scope":"message","action":"block","category":"引流"}`))
|
|
if create.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", create.Code, create.Body.String())
|
|
}
|
|
duplicate := httptest.NewRecorder()
|
|
a.adminCreateKeyword(duplicate, imTestRequest(1, "POST", "/admin/v1/moderation/keywords",
|
|
`{"word":"加我微信","scope":"message","action":"review"}`))
|
|
if duplicate.Code != 400 {
|
|
t.Fatalf("同范围下的重复词应当被拒绝: HTTP %d", duplicate.Code)
|
|
}
|
|
review := httptest.NewRecorder()
|
|
a.adminCreateKeyword(review, imTestRequest(1, "POST", "/admin/v1/moderation/keywords",
|
|
`{"word":"兼职","scope":"all","action":"review","category":"疑似广告"}`))
|
|
if review.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", review.Code, review.Body.String())
|
|
}
|
|
invalidateKeywordCache()
|
|
|
|
t.Run("blocking beats review, and scope is respected", func(t *testing.T) {
|
|
word, action := a.matchKeyword(context.Background(), "message", "你好,加我微信聊,有兼职")
|
|
if action != "block" {
|
|
t.Fatalf("同时命中拦截与转人工时应当以拦截为准: %s %s", word, action)
|
|
}
|
|
word, action = a.matchKeyword(context.Background(), "post", "招人做兼职")
|
|
if word != "兼职" || action != "review" {
|
|
t.Fatalf("全局词应当在动态里生效: %q %q", word, action)
|
|
}
|
|
// message 范围的词不该影响动态。
|
|
if word, _ = a.matchKeyword(context.Background(), "post", "加我微信"); word != "" {
|
|
t.Fatalf("只对消息生效的词泄漏到了动态: %q", word)
|
|
}
|
|
})
|
|
|
|
t.Run("a message that trips a blocking word never reaches the database", func(t *testing.T) {
|
|
// 用真实的会话入口建会话,免得测试自己拼一套和线上不一样的数据。
|
|
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)
|
|
}
|
|
conversation := created.ID
|
|
_, _, err := a.persistMessageContext(context.Background(), conversation, 1, "client-block-1", 1,
|
|
map[string]any{"text": "加我微信详聊"})
|
|
if err == nil {
|
|
t.Fatal("命中拦截词的消息不该写进去")
|
|
}
|
|
var stored int
|
|
if queryErr := db.QueryRow(`SELECT COUNT(*) FROM im_messages WHERE conversation_id=?`, conversation).Scan(&stored); queryErr != nil {
|
|
t.Fatal(queryErr)
|
|
}
|
|
if stored != 0 {
|
|
t.Fatalf("库里不该有这条消息: %d", stored)
|
|
}
|
|
// 转人工的词照常发出去,但要在风险中心留一条。
|
|
if _, _, err = a.persistMessageContext(context.Background(), conversation, 1, "client-review-1", 1,
|
|
map[string]any{"text": "有兼职可以做吗"}); err != nil {
|
|
t.Fatalf("转人工的词不该拦下消息: %v", err)
|
|
}
|
|
var events int
|
|
if queryErr := db.QueryRow(`SELECT COUNT(*) FROM risk_events WHERE user_id=1 AND event_type='keyword_message'`).Scan(&events); queryErr != nil {
|
|
t.Fatal(queryErr)
|
|
}
|
|
if events != 1 {
|
|
t.Fatalf("风险事件 = %d,转人工的命中应当留痕", events)
|
|
}
|
|
})
|
|
|
|
t.Run("the test box answers before anyone complains", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
a.adminTestKeyword(w, imTestRequest(1, "POST", "/admin/v1/moderation/keywords/test",
|
|
`{"scope":"message","text":"加我微信"}`))
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), `"action":"block"`) {
|
|
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAdminRiskAndBatchMySQL(t *testing.T) {
|
|
db := isolatedIMDatabase(t)
|
|
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
|
|
|
if _, err := db.Exec(`INSERT INTO user_risk_profiles(user_id,risk_score,risk_level) VALUES(2,80,2)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO risk_events(user_id,event_type,score_delta) VALUES(2,'spam_message',10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Run("whitelisting needs a reason and is written down", func(t *testing.T) {
|
|
bare := httptest.NewRecorder()
|
|
a.adminWhitelistRiskUser(bare, imTestRequest(1, "POST", "/admin/v1/risk/users/2/whitelist", `{"on":true}`))
|
|
if bare.Code != 400 {
|
|
t.Fatalf("加白必须写理由: HTTP %d", bare.Code)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.adminWhitelistRiskUser(w, imTestRequest(1, "POST", "/admin/v1/risk/users/2/whitelist",
|
|
`{"on":true,"note":"官方运营账号,批量发消息属正常"}`))
|
|
if w.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var whitelisted bool
|
|
var note string
|
|
if err := db.QueryRow(`SELECT whitelisted,whitelist_note FROM user_risk_profiles WHERE user_id=2`).Scan(&whitelisted, ¬e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !whitelisted || note == "" {
|
|
t.Fatalf("whitelisted=%v note=%q", whitelisted, note)
|
|
}
|
|
var logged int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM admin_audit_logs WHERE action='risk_whitelist' AND target_id=2`).Scan(&logged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if logged != 1 {
|
|
t.Fatalf("加白要留审计: %d", logged)
|
|
}
|
|
})
|
|
|
|
t.Run("an event can be closed once", func(t *testing.T) {
|
|
var eventID int64
|
|
if err := db.QueryRow(`SELECT id FROM risk_events WHERE user_id=2`).Scan(&eventID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.adminHandleRiskEvent(w, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/risk/events/%d/handle", eventID), `{"note":"核实是正常运营行为"}`))
|
|
if w.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
|
}
|
|
again := httptest.NewRecorder()
|
|
a.adminHandleRiskEvent(again, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/risk/events/%d/handle", eventID), `{"note":"再点一次"}`))
|
|
if again.Code != 400 {
|
|
t.Fatalf("重复处理应当被拒绝: HTTP %d", again.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("batch actions are capped and leave a trace", func(t *testing.T) {
|
|
ids := []int64{}
|
|
for index := 0; index < 3; index++ {
|
|
result, err := db.Exec(`INSERT INTO posts(user_id,content,visibility) VALUES(1,?,1)`, fmt.Sprintf("批量测试 %d", index))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, _ := result.LastInsertId()
|
|
ids = append(ids, id)
|
|
}
|
|
payload, _ := json.Marshal(map[string]any{"ids": ids, "action": "remove", "reason": "批量清理垃圾内容"})
|
|
w := httptest.NewRecorder()
|
|
a.adminBatchPosts(w, imTestRequest(1, "POST", "/admin/v1/posts/batch", string(payload)))
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), `"affected":3`) {
|
|
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var live int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM posts WHERE status=1`).Scan(&live); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if live != 0 {
|
|
t.Fatalf("还剩 %d 条没下架", live)
|
|
}
|
|
// 恢复也走同一个入口。
|
|
restore, _ := json.Marshal(map[string]any{"ids": ids, "action": "restore"})
|
|
back := httptest.NewRecorder()
|
|
a.adminBatchPosts(back, imTestRequest(1, "POST", "/admin/v1/posts/batch", string(restore)))
|
|
if back.Code != 200 {
|
|
t.Fatalf("HTTP %d: %s", back.Code, back.Body.String())
|
|
}
|
|
// 空选择与超量都要挡住。
|
|
empty := httptest.NewRecorder()
|
|
a.adminBatchPosts(empty, imTestRequest(1, "POST", "/admin/v1/posts/batch", `{"ids":[],"action":"remove"}`))
|
|
if empty.Code != 400 {
|
|
t.Fatalf("空勾选应当被拒绝: HTTP %d", empty.Code)
|
|
}
|
|
many := make([]int64, 101)
|
|
for index := range many {
|
|
many[index] = int64(index + 1)
|
|
}
|
|
oversized, _ := json.Marshal(map[string]any{"ids": many, "action": "remove"})
|
|
over := httptest.NewRecorder()
|
|
a.adminBatchPosts(over, imTestRequest(1, "POST", "/admin/v1/posts/batch", string(oversized)))
|
|
if over.Code != 400 {
|
|
t.Fatalf("超过 100 条应当被拒绝: HTTP %d", over.Code)
|
|
}
|
|
var logged int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM admin_audit_logs WHERE action LIKE 'batch_%'`).Scan(&logged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if logged < 2 {
|
|
t.Fatalf("批量操作要留审计: %d", logged)
|
|
}
|
|
})
|
|
}
|