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

299 lines
9.3 KiB
Go

package app
import (
"context"
"net/http"
"strings"
"sync"
"time"
)
// One keyword list for the whole product. Before this, messages had no check at
// all, posts had none, and adoption had its own comma-separated config string —
// three different answers to the same question. The list is cached because it
// is read on every message and written a few times a week.
type contentKeyword struct {
ID int64
Word string
Scope string
Action string
}
type keywordCache struct {
mutex sync.RWMutex
words []contentKeyword
loadedAt time.Time
}
var keywords = &keywordCache{}
const keywordCacheTTL = time.Minute
func (a *App) loadKeywords(ctx context.Context) []contentKeyword {
keywords.mutex.RLock()
if time.Since(keywords.loadedAt) < keywordCacheTTL {
defer keywords.mutex.RUnlock()
return keywords.words
}
keywords.mutex.RUnlock()
rows, err := a.db.QueryContext(ctx, `SELECT id,word,scope,action FROM content_keywords WHERE status=1`)
if err != nil {
return nil
}
defer rows.Close()
loaded := []contentKeyword{}
for rows.Next() {
var item contentKeyword
if rows.Scan(&item.ID, &item.Word, &item.Scope, &item.Action) == nil && strings.TrimSpace(item.Word) != "" {
item.Word = strings.ToLower(strings.TrimSpace(item.Word))
loaded = append(loaded, item)
}
}
keywords.mutex.Lock()
keywords.words = loaded
keywords.loadedAt = time.Now()
keywords.mutex.Unlock()
return loaded
}
func invalidateKeywordCache() {
keywords.mutex.Lock()
keywords.loadedAt = time.Time{}
keywords.mutex.Unlock()
}
// matchKeyword returns the first word this text trips and what to do about it.
// A "block" anywhere wins over a "review": the stricter rule is the one the
// operator meant.
func (a *App) matchKeyword(ctx context.Context, scope, text string) (string, string) {
if strings.TrimSpace(text) == "" || !a.configBool(ctx, "moderation.keywords_enabled", true) {
return "", ""
}
lowered := strings.ToLower(text)
hit, action := "", ""
for _, item := range a.loadKeywords(ctx) {
if item.Scope != "all" && item.Scope != scope {
continue
}
if !strings.Contains(lowered, item.Word) {
continue
}
if item.Action == "block" {
a.countKeywordHit(ctx, item.ID)
return item.Word, "block"
}
if hit == "" {
hit, action = item.Word, "review"
}
}
if hit != "" {
for _, item := range a.loadKeywords(ctx) {
if item.Word == hit {
a.countKeywordHit(ctx, item.ID)
break
}
}
}
return hit, action
}
// 命中次数是运营调词库的依据:一个从来不命中的词和一个天天命中的词,价值不一样。
func (a *App) countKeywordHit(ctx context.Context, id int64) {
go func() {
background, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, _ = a.db.ExecContext(background, `UPDATE content_keywords SET hit_count=hit_count+1,last_hit_at=NOW(3) WHERE id=?`, id)
}()
}
// --- 管理端 ---------------------------------------------------------------
func (a *App) adminKeywords(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
where := " WHERE 1=1"
args := []any{}
if scope := strings.TrimSpace(r.URL.Query().Get("scope")); scope != "" {
where += " AND scope=?"
args = append(args, scope)
}
if action := strings.TrimSpace(r.URL.Query().Get("action")); action != "" {
where += " AND action=?"
args = append(args, action)
}
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
where += " AND (word LIKE ? OR category LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM content_keywords`+where, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,word,scope,action,category,note,status,hit_count,last_hit_at,created_at
FROM content_keywords`+where+` ORDER BY hit_count DESC,id DESC LIMIT ? OFFSET ?`, append(args, size, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询词库失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var word, scope, action, category, note string
var status, hits int
var lastHit any
var created time.Time
if rows.Scan(&id, &word, &scope, &action, &category, &note, &status, &hits, &lastHit, &created) != nil {
continue
}
items = append(items, map[string]any{
"id": id, "word": word, "scope": scope, "action": action, "category": category,
"note": note, "status": status, "hitCount": hits, "lastHitAt": lastHit,
"createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
}
type keywordRequest struct {
Word string `json:"word"`
Scope string `json:"scope"`
Action string `json:"action"`
Category string `json:"category"`
Note string `json:"note"`
Status int `json:"status"`
}
var keywordScopes = map[string]bool{"all": true, "message": true, "post": true, "adoption": true, "nickname": true}
func validKeyword(req *keywordRequest) string {
req.Word = strings.TrimSpace(req.Word)
req.Category = strings.TrimSpace(req.Category)
req.Note = strings.TrimSpace(req.Note)
if req.Scope == "" {
req.Scope = "all"
}
if req.Action == "" {
req.Action = "review"
}
if len([]rune(req.Word)) < 1 || len([]rune(req.Word)) > 30 {
return "关键词为 1 到 30 个字"
}
if !keywordScopes[req.Scope] {
return "适用范围无效"
}
if req.Action != "block" && req.Action != "review" {
return "命中动作只能是拦截或转人工"
}
if len([]rune(req.Category)) > 20 || len([]rune(req.Note)) > 100 {
return "分类或备注过长"
}
return ""
}
func (a *App) adminCreateKeyword(w http.ResponseWriter, r *http.Request) {
var req keywordRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
if message := validKeyword(&req); message != "" {
fail(w, http.StatusBadRequest, 20001, message)
return
}
result, err := a.db.ExecContext(r.Context(), `INSERT INTO content_keywords(word,scope,action,category,note,status,created_by)
VALUES(?,?,?,?,?,1,?)`, req.Word, req.Scope, req.Action, req.Category, req.Note, current(r).ID)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "这个词在该范围下已经存在")
return
}
id, _ := result.LastInsertId()
invalidateKeywordCache()
a.audit(r, "create", "content_keyword", id, map[string]any{"word": req.Word, "scope": req.Scope, "action": req.Action})
reply(w, map[string]any{"id": id})
}
func (a *App) adminUpdateKeyword(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req keywordRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
if message := validKeyword(&req); message != "" {
fail(w, http.StatusBadRequest, 20001, message)
return
}
status := 1
if req.Status == 0 {
status = 0
}
result, err := a.db.ExecContext(r.Context(), `UPDATE content_keywords SET word=?,scope=?,action=?,category=?,note=?,status=? WHERE id=?`,
req.Word, req.Scope, req.Action, req.Category, req.Note, status, id)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "保存失败,可能与已有词重复")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "词条不存在")
return
}
invalidateKeywordCache()
a.audit(r, "update", "content_keyword", id, map[string]any{"word": req.Word, "status": status})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminDeleteKeyword(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
result, err := a.db.ExecContext(r.Context(), `DELETE FROM content_keywords WHERE id=?`, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "删除失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "词条不存在")
return
}
invalidateKeywordCache()
a.audit(r, "delete", "content_keyword", id, nil)
reply(w, map[string]bool{"success": true})
}
// adminTestKeyword lets an operator paste a real message and see what the list
// would do with it, instead of finding out from a complaint.
func (a *App) adminTestKeyword(w http.ResponseWriter, r *http.Request) {
var req struct {
Scope string `json:"scope"`
Text string `json:"text"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
if req.Scope == "" {
req.Scope = "message"
}
word, action := a.matchKeyword(r.Context(), req.Scope, req.Text)
reply(w, map[string]any{"word": word, "action": action, "matched": word != ""})
}
// recordKeywordRisk turns a soft keyword hit into something visible: the risk
// centre is where a moderator would look, so that is where it goes.
func (a *App) recordKeywordRisk(ctx context.Context, userID int64, scope, word string) {
if userID <= 0 {
return
}
_, _ = a.db.ExecContext(ctx, `INSERT INTO risk_events(user_id,event_type,score_delta,metadata)
VALUES(?,?,?,JSON_OBJECT('scope',?,'word',?))`, userID, "keyword_"+scope, 2, scope, word)
_, _ = a.db.ExecContext(ctx, `INSERT INTO user_risk_profiles(user_id,risk_score,message_score)
VALUES(?,2,2) ON DUPLICATE KEY UPDATE risk_score=risk_score+2,message_score=message_score+2`, userID)
}