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, ¬e, &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) }