feat: 朋友圈、广场、昵称、群公告敏感词检测
This commit is contained in:
parent
ec9d89be17
commit
5bb9db4a3c
@ -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。
|
||||
|
||||
@ -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 + 人工审核队列
|
||||
|
||||
---
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
31
internal/module/moderation/logic/gate.go
Normal file
31
internal/module/moderation/logic/gate.go
Normal 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
|
||||
}
|
||||
@ -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{})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user