Files
kefu/im/backend/internal/app/admin_operations_integration_test.go
T
Your NameandClaude Opus 5 0031f79eb3 管理端补上运营真正要用的那几件事
原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读,
动态和消息只能一条一条点,运营想发一句公告没有任何入口。

公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知,
发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经
读过的假装收回去只会让统计说谎。

资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 +
申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。

退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行;
退完最后一分钱才撤销买到的东西,半份会员仍然是会员。

风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、
跳用户详情,事件可以标记跟进完成。

批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。
处罚仍然一条一条来——批量封禁是错封人最快的方式。

敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定;
转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:07:58 +08:00

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, &note); 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)
}
})
}