feat: 朋友圈、广场、昵称、群公告敏感词检测

This commit is contained in:
zhr 2026-10-21 16:44:24 +08:00
parent ec9d89be17
commit 5bb9db4a3c
15 changed files with 188 additions and 6 deletions

View File

@ -5,3 +5,10 @@
- **bootstrap**:将 `finishApp` 中 TCP/HTTP 装配抽到 `attachTransports`,满足 `funlen`;参数类型使用 `tcp.TaskPool`、`session.Manager` 接口。 - **bootstrap**:将 `finishApp` 中 TCP/HTTP 装配抽到 `attachTransports`,满足 `funlen`;参数类型使用 `tcp.TaskPool`、`session.Manager` 接口。
- **client/logic**:`service_test.go` 中 `rangeValCopy` 改为索引遍历。 - **client/logic**:`service_test.go` 中 `rangeValCopy` 改为索引遍历。
- **验证**:`make test` pass(golangci-lint + `go test ./...` + logic coverage ≥65%)。 - **验证**:`make test` pass(golangci-lint + `go test ./...` + logic coverage ≥65%)。
## Session: 敏感词扩展到朋友圈 / 广场 / 昵称 / 群公告
- **词库**:仍是一份 `t_sensitive_words`,不按场景拆库。
- **moderation**:新增 `GateText`,非会话命中 `conversation_id=0`,处罚与聊天一致(替换落库,拒绝/禁言/封禁不写入)。
- **接入**:朋友圈与广场的正文和评论、用户昵称、群公告;启动时 `BindModeration`。
- **验证**:`make test` pass。

View File

@ -15,7 +15,7 @@
| 能力 | 说明 | | 能力 | 说明 |
| --- | --- | | --- | --- |
| 文本机审 | 仅 `content.t = 1` 的 `text.body`(单聊 `t_private_message` / 群聊 `t_group_message`) | | 文本机审 | 聊天 `text.body`;朋友圈/广场正文与评论;用户昵称;群公告。同一词库、同一 `GateText`/`Scan` |
| 分类 | 涉黑 / 涉毒 / 涉黄 / 涉赌 / 涉政 / 宗教 / 辱骂 / 广告 / 其他 | | 分类 | 涉黑 / 涉毒 / 涉黄 / 涉赌 / 涉政 / 宗教 / 辱骂 / 广告 / 其他 |
| 处罚 | 按词条 `action` 执行;一条消息多命中取 **最严** action | | 处罚 | 按词条 `action` 执行;一条消息多命中取 **最严** action |
| 账号闸 | 发送任意类型前检查 `t_users.status` 与 `send_muted_until` | | 账号闸 | 发送任意类型前检查 `t_users.status` 与 `send_muted_until` |
@ -26,7 +26,7 @@
## 0.2 明确不做(v1) ## 0.2 明确不做(v1)
- 图片 OCR、语音 ASR、视频抽帧、谐音/拼音/变形字模型 - 图片 OCR、语音 ASR、视频抽帧、谐音/拼音/变形字模型
- 朋友圈 `moment`、广场 `square`、昵称、群公告、文件名、位置名称(同一 `Scan` 二期再挂) - 文件名、位置名称、群名片昵称
- 独立审核微服务、Kafka、按词 `LIKE` 打发送热路径 - 独立审核微服务、Kafka、按词 `LIKE` 打发送热路径
- 把处罚做成群 `speak_muted` / `muted_all`(那是群维度,已由 `group/logic.CanSend` 处理) - 把处罚做成群 `speak_muted` / `muted_all`(那是群维度,已由 `group/logic.CanSend` 处理)
- 用现有 Admin「白名单 / 异常标记」接口(二者目前是空实现,忽略写入) - 用现有 Admin「白名单 / 异常标记」接口(二者目前是空实现,忽略写入)
@ -488,7 +488,7 @@ OpenAPI 写在 `openapi.yaml` 的 `admin-music` tag;路径前缀 `/api/admin/s
5. 文本 Filter:log / replace / reject 5. 文本 Filter:log / replace / reject
6. mute / disable 6. mute / disable
7. Admin 词库与 hits 7. Admin 词库与 hits
8. (二期)moment/square `Publish` 调同一 Scan;广场可打 `AuditPending` 8. 朋友圈/广场正文与评论、昵称、群公告走 `GateText`(会话 id 记 0)
9. (二期)Action=hide + 人工审核队列 9. (二期)Action=hide + 人工审核队列
--- ---

View File

@ -216,6 +216,10 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
favorites := wireFavorites(idgen, db, objects, chat) favorites := wireFavorites(idgen, db, objects, chat)
moments := wireMoments(idgen, db, objects, friends, users) moments := wireMoments(idgen, db, objects, friends, users)
square := wireSquare(idgen, db, objects, friends, users) square := wireSquare(idgen, db, objects, friends, users)
users.BindModeration(moderation)
groups.BindModeration(moderation)
moments.BindModeration(moderation)
square.BindModeration(moderation)
configureSocialMedia(moments, square, &cfg.Media, log) configureSocialMedia(moments, square, &cfg.Media, log)
app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users) app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users)
music := wireMusic(cfg, db, objects) music := wireMusic(cfg, db, objects)

View File

@ -2,6 +2,7 @@ package logic
import ( import (
"context" "context"
"strings"
"sync" "sync"
"time" "time"
@ -74,6 +75,27 @@ type Service struct {
queue DeliverQueue queue DeliverQueue
objects Objects objects Objects
cache sync.Map cache sync.Map
gate TextGate
}
// TextGate scans user-authored text. Nil skips the check.
type TextGate interface {
GateText(ctx context.Context, userID int64, text string) (string, error)
}
// BindModeration wires the shared sensitive-word gate.
func (s *Service) BindModeration(gate TextGate) {
if s == nil {
return
}
s.gate = gate
}
func (s *Service) gateText(ctx context.Context, userID int64, text string) (string, error) {
if s == nil || s.gate == nil || strings.TrimSpace(text) == "" {
return text, nil
}
return s.gate.GateText(ctx, userID, text)
} }
func NewService(repo Repository, users Users, friends Friendships, ids IDGen, msgs Messages, queue DeliverQueue, objects Objects) *Service { func NewService(repo Repository, users Users, friends Friendships, ids IDGen, msgs Messages, queue DeliverQueue, objects Objects) *Service {

View File

@ -47,7 +47,11 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI
g.Avatar = av g.Avatar = av
} }
if in.Announcement != nil { if in.Announcement != nil {
g.Announcement = strings.TrimSpace(*in.Announcement) ann, err := s.gateText(ctx, userID, strings.TrimSpace(*in.Announcement))
if err != nil {
return nil, err
}
g.Announcement = ann
} }
if in.MutedAll != nil { if in.MutedAll != nil {
g.MutedAll = *in.MutedAll g.MutedAll = *in.MutedAll

View File

@ -0,0 +1,31 @@
package logic
import (
"context"
"strings"
modentity "spark-server/internal/module/moderation/entity"
)
// GateText scans user-authored text outside chat (moments, square, nickname, announcement).
// A nil service or blank text is unchanged. Replace returns the masked body.
// Reject, mute, and disable record the hit and return ErrContentBlocked.
func (s *Service) GateText(ctx context.Context, userID int64, text string) (string, error) {
if s == nil || strings.TrimSpace(text) == "" {
return text, nil
}
res, err := s.Scan(ctx, text)
if err != nil || res == nil || len(res.Hits) == 0 {
return text, err
}
if err := s.Apply(ctx, userID, 0, &modentity.ApplyInput{Hits: res.Hits}); err != nil {
return text, err
}
if err := s.Enforce(ctx, userID, res); err != nil {
return text, err
}
if res.Action == modentity.ActionReplace && res.ReplacedBody != "" {
return res.ReplacedBody, nil
}
return text, nil
}

View File

@ -101,6 +101,31 @@ func TestEnforceMute(t *testing.T) {
} }
} }
func TestGateTextReplaceAndReject(t *testing.T) {
repo := NewMemoryRepo(
&modentity.Word{
ID: 11, Word: sensitive.FoldWord("赌博"), WordRaw: "赌博",
Category: modentity.CategoryGambling, Action: modentity.ActionReplace, Enabled: true,
},
&modentity.Word{
ID: 12, Word: sensitive.FoldWord("毒品"), WordRaw: "毒品",
Category: modentity.CategoryDrug, Action: modentity.ActionReject, Enabled: true,
},
)
svc := NewService(repo, &fakeUsers{}, &fakeIDs{})
out, err := svc.GateText(context.Background(), 7, "今晚赌博")
if err != nil || out != "今晚***" {
t.Fatalf("replace %q %v", out, err)
}
if _, err := svc.GateText(context.Background(), 7, "涉毒品"); !errors.Is(err, errs.ErrContentBlocked) {
t.Fatalf("reject %v", err)
}
same, err := svc.GateText(context.Background(), 7, " ")
if err != nil || same != " " {
t.Fatalf("blank %q %v", same, err)
}
}
func TestPatchAndDeleteWord(t *testing.T) { func TestPatchAndDeleteWord(t *testing.T) {
repo := NewMemoryRepo() repo := NewMemoryRepo()
svc := NewService(repo, nil, &fakeIDs{}) svc := NewService(repo, nil, &fakeIDs{})

View File

@ -36,6 +36,10 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, in *Comment
if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes { if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentCommentInvalid, nil)) return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyMomentCommentInvalid, nil))
} }
text, err = s.gateText(ctx, userID, text)
if err != nil {
return nil, err
}
parent, err := s.loadParent(ctx, postID, in.ReplyToID) parent, err := s.loadParent(ctx, postID, in.ReplyToID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -50,13 +50,17 @@ func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (
if err := s.checkAudience(ctx, userID, in); err != nil { if err := s.checkAudience(ctx, userID, in); err != nil {
return nil, err return nil, err
} }
text, err := s.gateText(ctx, userID, strings.TrimSpace(in.Text))
if err != nil {
return nil, err
}
id, err := s.ids.NextID() id, err := s.ids.NextID()
if err != nil { if err != nil {
return nil, err return nil, err
} }
now := time.Now() now := time.Now()
post := &entity.Post{ post := &entity.Post{
ID: id, UserID: userID, Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw, ID: id, UserID: userID, Kind: kind, Text: text, Content: raw,
Visibility: in.Visibility, Status: entity.StatusNormal, CreatedAtMS: now.UnixMilli(), Visibility: in.Visibility, Status: entity.StatusNormal, CreatedAtMS: now.UnixMilli(),
} }
if in.POI != nil { if in.POI != nil {

View File

@ -2,6 +2,7 @@ package logic
import ( import (
"context" "context"
"strings"
friendentity "spark-server/internal/module/friend/entity" friendentity "spark-server/internal/module/friend/entity"
"spark-server/internal/module/moment/entity" "spark-server/internal/module/moment/entity"
@ -76,6 +77,7 @@ type Service struct {
burst *burst burst *burst
media media.Policy media media.Policy
log *zap.Logger log *zap.Logger
gate TextGate
} }
func NewService(repo Repository, friends Friends, users Users, objects Objects, ids IDGen) *Service { func NewService(repo Repository, friends Friends, users Users, objects Objects, ids IDGen) *Service {
@ -96,3 +98,23 @@ func (s *Service) ConfigureMedia(p media.Policy, log *zap.Logger) {
func (s *Service) ready() bool { func (s *Service) ready() bool {
return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil
} }
// TextGate scans user-authored text. Nil skips the check.
type TextGate interface {
GateText(ctx context.Context, userID int64, text string) (string, error)
}
// BindModeration wires the shared sensitive-word gate.
func (s *Service) BindModeration(gate TextGate) {
if s == nil {
return
}
s.gate = gate
}
func (s *Service) gateText(ctx context.Context, userID int64, text string) (string, error) {
if s == nil || s.gate == nil || strings.TrimSpace(text) == "" {
return text, nil
}
return s.gate.GateText(ctx, userID, text)
}

View File

@ -36,6 +36,10 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, channel ent
if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes { if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareCommentInvalid, nil)) return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareCommentInvalid, nil))
} }
text, err = s.gateText(ctx, userID, text)
if err != nil {
return nil, err
}
parent, err := s.loadParent(ctx, postID, in.ReplyToID) parent, err := s.loadParent(ctx, postID, in.ReplyToID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -58,6 +58,10 @@ func (s *Service) Publish(ctx context.Context, userID int64, channel entity.Chan
if err := s.checkAudience(ctx, userID, in); err != nil { if err := s.checkAudience(ctx, userID, in); err != nil {
return nil, err return nil, err
} }
text, err := s.gateText(ctx, userID, strings.TrimSpace(in.Text))
if err != nil {
return nil, err
}
id, err := s.ids.NextID() id, err := s.ids.NextID()
if err != nil { if err != nil {
return nil, err return nil, err
@ -69,7 +73,7 @@ func (s *Service) Publish(ctx context.Context, userID int64, channel entity.Chan
} }
post := &entity.Post{ post := &entity.Post{
ID: id, UserID: userID, Channel: channel, Category: in.Category, ID: id, UserID: userID, Channel: channel, Category: in.Category,
Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw, Kind: kind, Text: text, Content: raw,
Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal, Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal,
CreatedAtMS: now.UnixMilli(), CreatedAtMS: now.UnixMilli(),
} }

View File

@ -2,6 +2,7 @@ package logic
import ( import (
"context" "context"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -76,6 +77,7 @@ type Service struct {
log *zap.Logger log *zap.Logger
discoverMu sync.Mutex discoverMu sync.Mutex
discoverReady atomic.Bool discoverReady atomic.Bool
gate TextGate
} }
func NewService(repo Repository, friends Friends, users Users, objects Objects, ids IDGen) *Service { func NewService(repo Repository, friends Friends, users Users, objects Objects, ids IDGen) *Service {
@ -97,6 +99,26 @@ func (s *Service) ready() bool {
return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil
} }
// TextGate scans user-authored text. Nil skips the check.
type TextGate interface {
GateText(ctx context.Context, userID int64, text string) (string, error)
}
// BindModeration wires the shared sensitive-word gate.
func (s *Service) BindModeration(gate TextGate) {
if s == nil {
return
}
s.gate = gate
}
func (s *Service) gateText(ctx context.Context, userID int64, text string) (string, error) {
if s == nil || s.gate == nil || strings.TrimSpace(text) == "" {
return text, nil
}
return s.gate.GateText(ctx, userID, text)
}
func normalizeChannel(ch entity.Channel) entity.Channel { func normalizeChannel(ch entity.Channel) entity.Channel {
if ch == "" { if ch == "" {
return entity.ChannelSquare return entity.ChannelSquare

View File

@ -105,6 +105,13 @@ func (s *Service) UpdateProfile(ctx context.Context, userID int64, in UpdateProf
if !profilePatchSet(&in) { if !profilePatchSet(&in) {
return u, nil return u, nil
} }
if in.Nickname != nil {
nick, err := s.gateText(ctx, userID, strings.TrimSpace(*in.Nickname))
if err != nil {
return nil, err
}
in.Nickname = &nick
}
if err := applyProfilePatch(ctx, u, &in); err != nil { if err := applyProfilePatch(ctx, u, &in); err != nil {
return nil, err return nil, err
} }

View File

@ -3,6 +3,7 @@ package logic
import ( import (
"context" "context"
"strings"
"time" "time"
smsentity "spark-server/internal/module/sms/entity" smsentity "spark-server/internal/module/sms/entity"
@ -54,6 +55,27 @@ type Service struct {
tokens TokenRevoker tokens TokenRevoker
sessions SessionDropper sessions SessionDropper
cleaners []UserDataCleaner cleaners []UserDataCleaner
gate TextGate
}
// TextGate scans user-authored text. Nil skips the check.
type TextGate interface {
GateText(ctx context.Context, userID int64, text string) (string, error)
}
// BindModeration wires the shared sensitive-word gate.
func (s *Service) BindModeration(gate TextGate) {
if s == nil {
return
}
s.gate = gate
}
func (s *Service) gateText(ctx context.Context, userID int64, text string) (string, error) {
if s == nil || s.gate == nil || strings.TrimSpace(text) == "" {
return text, nil
}
return s.gate.GateText(ctx, userID, text)
} }
func NewService(repo Repository, otp OTP, ids IDGen, objects Objects) *Service { func NewService(repo Repository, otp OTP, ids IDGen, objects Objects) *Service {