原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读, 动态和消息只能一条一条点,运营想发一句公告没有任何入口。 公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知, 发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经 读过的假装收回去只会让统计说谎。 资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 + 申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。 退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行; 退完最后一分钱才撤销买到的东西,半份会员仍然是会员。 风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、 跳用户详情,事件可以标记跟进完成。 批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。 处罚仍然一条一条来——批量封禁是错封人最快的方式。 敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定; 转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
9.3 KiB
Go
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, ¬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)
|
|
}
|