diff --git a/docs/daily/2026-10-21-change-list.md b/docs/daily/2026-10-21-change-list.md index 80eb943..7941b1f 100644 --- a/docs/daily/2026-10-21-change-list.md +++ b/docs/daily/2026-10-21-change-list.md @@ -5,3 +5,10 @@ - **bootstrap**:将 `finishApp` 中 TCP/HTTP 装配抽到 `attachTransports`,满足 `funlen`;参数类型使用 `tcp.TaskPool`、`session.Manager` 接口。 - **client/logic**:`service_test.go` 中 `rangeValCopy` 改为索引遍历。 - **验证**:`make test` pass(golangci-lint + `go test ./...` + logic coverage ≥65%)。 + +## Session: 敏感词扩展到朋友圈 / 广场 / 昵称 / 群公告 + +- **词库**:仍是一份 `t_sensitive_words`,不按场景拆库。 +- **moderation**:新增 `GateText`,非会话命中 `conversation_id=0`,处罚与聊天一致(替换落库,拒绝/禁言/封禁不写入)。 +- **接入**:朋友圈与广场的正文和评论、用户昵称、群公告;启动时 `BindModeration`。 +- **验证**:`make test` pass。 diff --git a/docs/moderation.md b/docs/moderation.md index e3b0449..6347aaa 100644 --- a/docs/moderation.md +++ b/docs/moderation.md @@ -15,7 +15,7 @@ | 能力 | 说明 | | --- | --- | -| 文本机审 | 仅 `content.t = 1` 的 `text.body`(单聊 `t_private_message` / 群聊 `t_group_message`) | +| 文本机审 | 聊天 `text.body`;朋友圈/广场正文与评论;用户昵称;群公告。同一词库、同一 `GateText`/`Scan` | | 分类 | 涉黑 / 涉毒 / 涉黄 / 涉赌 / 涉政 / 宗教 / 辱骂 / 广告 / 其他 | | 处罚 | 按词条 `action` 执行;一条消息多命中取 **最严** action | | 账号闸 | 发送任意类型前检查 `t_users.status` 与 `send_muted_until` | @@ -26,7 +26,7 @@ ## 0.2 明确不做(v1) - 图片 OCR、语音 ASR、视频抽帧、谐音/拼音/变形字模型 -- 朋友圈 `moment`、广场 `square`、昵称、群公告、文件名、位置名称(同一 `Scan` 二期再挂) +- 文件名、位置名称、群名片昵称 - 独立审核微服务、Kafka、按词 `LIKE` 打发送热路径 - 把处罚做成群 `speak_muted` / `muted_all`(那是群维度,已由 `group/logic.CanSend` 处理) - 用现有 Admin「白名单 / 异常标记」接口(二者目前是空实现,忽略写入) @@ -488,7 +488,7 @@ OpenAPI 写在 `openapi.yaml` 的 `admin-music` tag;路径前缀 `/api/admin/s 5. 文本 Filter:log / replace / reject 6. mute / disable 7. Admin 词库与 hits -8. (二期)moment/square `Publish` 调同一 Scan;广场可打 `AuditPending` +8. 朋友圈/广场正文与评论、昵称、群公告走 `GateText`(会话 id 记 0) 9. (二期)Action=hide + 人工审核队列 --- diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 7dff4dc..2bf0e6e 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -216,6 +216,10 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge favorites := wireFavorites(idgen, db, objects, chat) moments := wireMoments(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) app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users) music := wireMusic(cfg, db, objects) diff --git a/internal/module/group/logic/service.go b/internal/module/group/logic/service.go index e1c7f63..c301724 100644 --- a/internal/module/group/logic/service.go +++ b/internal/module/group/logic/service.go @@ -2,6 +2,7 @@ package logic import ( "context" + "strings" "sync" "time" @@ -74,6 +75,27 @@ type Service struct { queue DeliverQueue objects Objects 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 { diff --git a/internal/module/group/logic/update.go b/internal/module/group/logic/update.go index cee3a1d..05b2941 100644 --- a/internal/module/group/logic/update.go +++ b/internal/module/group/logic/update.go @@ -47,7 +47,11 @@ func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateI g.Avatar = av } 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 { g.MutedAll = *in.MutedAll diff --git a/internal/module/moderation/logic/gate.go b/internal/module/moderation/logic/gate.go new file mode 100644 index 0000000..56ce08d --- /dev/null +++ b/internal/module/moderation/logic/gate.go @@ -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 +} diff --git a/internal/module/moderation/logic/moderation_test.go b/internal/module/moderation/logic/moderation_test.go index 3b3d510..eea42a3 100644 --- a/internal/module/moderation/logic/moderation_test.go +++ b/internal/module/moderation/logic/moderation_test.go @@ -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) { repo := NewMemoryRepo() svc := NewService(repo, nil, &fakeIDs{}) diff --git a/internal/module/moment/logic/comment.go b/internal/module/moment/logic/comment.go index ad177c9..a2fe79d 100644 --- a/internal/module/moment/logic/comment.go +++ b/internal/module/moment/logic/comment.go @@ -36,6 +36,10 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, in *Comment if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes { 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) if err != nil { return nil, err diff --git a/internal/module/moment/logic/publish.go b/internal/module/moment/logic/publish.go index 5abd042..7116bc7 100644 --- a/internal/module/moment/logic/publish.go +++ b/internal/module/moment/logic/publish.go @@ -50,13 +50,17 @@ func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) ( if err := s.checkAudience(ctx, userID, in); err != nil { return nil, err } + text, err := s.gateText(ctx, userID, strings.TrimSpace(in.Text)) + if err != nil { + return nil, err + } id, err := s.ids.NextID() if err != nil { return nil, err } now := time.Now() 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(), } if in.POI != nil { diff --git a/internal/module/moment/logic/service.go b/internal/module/moment/logic/service.go index 892272b..f3f1254 100644 --- a/internal/module/moment/logic/service.go +++ b/internal/module/moment/logic/service.go @@ -2,6 +2,7 @@ package logic import ( "context" + "strings" friendentity "spark-server/internal/module/friend/entity" "spark-server/internal/module/moment/entity" @@ -76,6 +77,7 @@ type Service struct { burst *burst media media.Policy log *zap.Logger + gate TextGate } 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 { 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) +} diff --git a/internal/module/square/logic/comment.go b/internal/module/square/logic/comment.go index d8c8304..6df7b72 100644 --- a/internal/module/square/logic/comment.go +++ b/internal/module/square/logic/comment.go @@ -36,6 +36,10 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, channel ent if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes { 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) if err != nil { return nil, err diff --git a/internal/module/square/logic/publish.go b/internal/module/square/logic/publish.go index fd7670e..32d6d5d 100644 --- a/internal/module/square/logic/publish.go +++ b/internal/module/square/logic/publish.go @@ -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 { return nil, err } + text, err := s.gateText(ctx, userID, strings.TrimSpace(in.Text)) + if err != nil { + return nil, err + } id, err := s.ids.NextID() if err != nil { return nil, err @@ -69,7 +73,7 @@ func (s *Service) Publish(ctx context.Context, userID int64, channel entity.Chan } post := &entity.Post{ 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, CreatedAtMS: now.UnixMilli(), } diff --git a/internal/module/square/logic/service.go b/internal/module/square/logic/service.go index 2aea09c..e1989aa 100644 --- a/internal/module/square/logic/service.go +++ b/internal/module/square/logic/service.go @@ -2,6 +2,7 @@ package logic import ( "context" + "strings" "sync" "sync/atomic" @@ -76,6 +77,7 @@ type Service struct { log *zap.Logger discoverMu sync.Mutex discoverReady atomic.Bool + gate TextGate } 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 } +// 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 { if ch == "" { return entity.ChannelSquare diff --git a/internal/module/user/logic/profile.go b/internal/module/user/logic/profile.go index c080057..c36c2e5 100644 --- a/internal/module/user/logic/profile.go +++ b/internal/module/user/logic/profile.go @@ -105,6 +105,13 @@ func (s *Service) UpdateProfile(ctx context.Context, userID int64, in UpdateProf if !profilePatchSet(&in) { 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 { return nil, err } diff --git a/internal/module/user/logic/service.go b/internal/module/user/logic/service.go index 34a33d5..7015f44 100644 --- a/internal/module/user/logic/service.go +++ b/internal/module/user/logic/service.go @@ -3,6 +3,7 @@ package logic import ( "context" + "strings" "time" smsentity "spark-server/internal/module/sms/entity" @@ -54,6 +55,27 @@ type Service struct { tokens TokenRevoker sessions SessionDropper 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 {