security_policy_service.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "strings"
  7. "sync"
  8. "sync/atomic"
  9. "time"
  10. "github.com/gin-gonic/gin"
  11. "golang.org/x/sync/singleflight"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. )
  14. // SecurityPolicyModelReviewer 是模型复核通道。*ContentModerationService
  15. // 已实现(ReviewTextForSecurityPolicy),测试用替身即可。
  16. type SecurityPolicyModelReviewer interface {
  17. ReviewTextForSecurityPolicy(ctx context.Context, text string) (bool, string, error)
  18. }
  19. // SecurityPolicyService 是分组安全策略的运行时服务:本地关键词前置检测、
  20. // 跨协议会话封禁、命中记录与邮件提醒,外加模型复核推翻。开关默认关闭
  21. // (DB 默认 false),未开启分组零行为变化。
  22. type SecurityPolicyService struct {
  23. reviewer SecurityPolicyModelReviewer
  24. keywordRepo SecurityPolicyRepository
  25. sessionStore SecurityPolicySessionStore
  26. moderationRepo ContentModerationRepository
  27. settingRepo SettingRepository
  28. groupRepo GroupRepository
  29. emailService *EmailService
  30. snapshot atomic.Value // *securityPolicyKeywordSnapshot
  31. snapshotSF singleflight.Group
  32. // emailThrottleMu/emailThrottle 按收件人节流违规提醒邮件:共享 key 被刷
  33. // 敏感词时只Limited首封即时送达,冷却窗内命中只落库不重发,避免邮箱轰炸。
  34. emailThrottleMu sync.Mutex
  35. emailThrottle map[string]time.Time
  36. }
  37. // securityPolicyViolationEmailCooldown 同一收件人违规提醒邮件冷却窗。
  38. const securityPolicyViolationEmailCooldown = time.Hour
  39. // securityPolicyEmailThrottleMaxEntries 节流表上限:超限时顺手清掉已过期条目,
  40. // 仍满则拒绝记录(退化为允许发送,避免误拦截正常提醒)。
  41. const securityPolicyEmailThrottleMaxEntries = 100000
  42. type securityPolicyKeywordSnapshot struct {
  43. at time.Time
  44. snapshot *SecurityPolicySnapshot
  45. }
  46. // securityPolicySnapshotTTL 词表快照 TTL:关键词变更最多延迟 60s 生效。
  47. const securityPolicySnapshotTTL = 60 * time.Second
  48. // securityPolicySnapshotErrorTTL 快照加载失败时的退避,避免 DB 故障时每请求重试。
  49. const securityPolicySnapshotErrorTTL = 5 * time.Second
  50. func NewSecurityPolicyService(
  51. reviewer SecurityPolicyModelReviewer,
  52. keywordRepo SecurityPolicyRepository,
  53. sessionStore SecurityPolicySessionStore,
  54. moderationRepo ContentModerationRepository,
  55. settingRepo SettingRepository,
  56. groupRepo GroupRepository,
  57. emailService *EmailService,
  58. ) *SecurityPolicyService {
  59. return &SecurityPolicyService{
  60. reviewer: reviewer,
  61. keywordRepo: keywordRepo,
  62. sessionStore: sessionStore,
  63. moderationRepo: moderationRepo,
  64. settingRepo: settingRepo,
  65. groupRepo: groupRepo,
  66. emailService: emailService,
  67. }
  68. }
  69. // SecurityPolicyRequest 是单次安全策略评估的输入,均来自已认证请求上下文。
  70. type SecurityPolicyRequest struct {
  71. APIKey *APIKey
  72. Protocol string
  73. Model string
  74. Endpoint string
  75. Body []byte
  76. RequestID string
  77. ClientIP string
  78. UserAgent string
  79. }
  80. // SecurityPolicyVerdict 是评估结论。Allowed=false 时调用方必须拒绝请求。
  81. type SecurityPolicyVerdict struct {
  82. Allowed bool
  83. SessionTerminated bool // 已终止会话的再次命中(需提示新建会话)
  84. MatchedKeyword string
  85. Category string
  86. ErrorCode string
  87. ClientMessage string
  88. // 以下字段供命中记录使用(RecordHit 入参)。
  89. SessionKeys []string
  90. ScopeKey string
  91. Excerpt string
  92. // ReviewText 归一化后的完整输入文本,供模型复核使用(不落库原文,
  93. // 仅内存传递;日志仍只存脱敏 excerpt)。
  94. ReviewText string
  95. }
  96. // EvaluateRequest 执行分组安全策略评估:开关关闭直接放行;先查会话封禁,
  97. // 再做本地关键词匹配。本方法只读(快照缓存 + Redis 查询),不写任何状态。
  98. func (s *SecurityPolicyService) EvaluateRequest(ctx context.Context, c *gin.Context, in SecurityPolicyRequest) *SecurityPolicyVerdict {
  99. allow := &SecurityPolicyVerdict{Allowed: true}
  100. if s == nil || in.APIKey == nil {
  101. return allow
  102. }
  103. group := in.APIKey.Group
  104. if group == nil || !group.SecurityPolicyEnabled {
  105. return allow
  106. }
  107. groupID := group.ID
  108. apiKeyID := in.APIKey.ID
  109. scope := SecurityPolicySessionScopeKey(groupID, apiKeyID)
  110. lookupKeys := SecurityPolicySessionLookupKeys(in.Protocol, apiKeyID, c, in.Body)
  111. if len(lookupKeys) > 0 && s.sessionStore != nil {
  112. if field, err := s.sessionStore.FindBlockedSessionField(ctx, scope, lookupKeys); err != nil {
  113. slog.Warn("security_policy.session_lookup_failed", "group_id", groupID, "api_key_id", apiKeyID, "error", err)
  114. } else if field != "" {
  115. return &SecurityPolicyVerdict{
  116. Allowed: false,
  117. SessionTerminated: true,
  118. ErrorCode: SecurityPolicyErrorCodeTerminated,
  119. ClientMessage: "该会话因违反安全策略已被终止,请新建会话后继续。如有疑问请联系管理员。",
  120. ScopeKey: scope,
  121. SessionKeys: []string{field},
  122. }
  123. }
  124. }
  125. input := ExtractContentModerationInput(in.Protocol, in.Body)
  126. input.Normalize()
  127. if input.IsEmpty() {
  128. return allow
  129. }
  130. snapshot := s.keywordSnapshot(ctx)
  131. keyword, category, ok := snapshot.Match(groupID, input.Text)
  132. if !ok {
  133. return allow
  134. }
  135. return &SecurityPolicyVerdict{
  136. Allowed: false,
  137. MatchedKeyword: keyword,
  138. Category: category,
  139. ErrorCode: SecurityPolicyErrorCodeViolation,
  140. ClientMessage: "请求内容违反本分组安全策略(敏感话题),该请求已被拦截。请调整输入后重试。",
  141. ScopeKey: scope,
  142. SessionKeys: lookupKeys,
  143. Excerpt: trimRunes(redactContentModerationSecrets(input.Text), maxModerationExcerptRunes),
  144. ReviewText: input.Text,
  145. }
  146. }
  147. // SecurityPolicyHitRecord 是命中落库/通知的载荷。
  148. type SecurityPolicyHitRecord struct {
  149. RequestID string
  150. APIKey *APIKey
  151. Protocol string
  152. Model string
  153. Endpoint string
  154. Verdict *SecurityPolicyVerdict
  155. SessionMode string // block_session / block_request(实际生效的处置)
  156. }
  157. // RecordHit 持久化命中日志;block_session 模式写会话封禁;邮件开关开启时提醒用户。
  158. // 设计为异步调用(handler 侧 go func),内部对各 IO 独立容错。
  159. func (s *SecurityPolicyService) RecordHit(ctx context.Context, in SecurityPolicyHitRecord) {
  160. if s == nil || in.Verdict == nil || in.APIKey == nil {
  161. return
  162. }
  163. group := in.APIKey.Group
  164. var groupID *int64
  165. var groupName string
  166. if group != nil {
  167. groupID = &group.ID
  168. groupName = group.Name
  169. }
  170. var userID *int64
  171. var userEmail string
  172. if in.APIKey.User != nil {
  173. if in.APIKey.User.ID > 0 {
  174. uid := in.APIKey.User.ID
  175. userID = &uid
  176. }
  177. userEmail = strings.TrimSpace(in.APIKey.User.Email)
  178. }
  179. var apiKeyID *int64
  180. if in.APIKey.ID > 0 {
  181. id := in.APIKey.ID
  182. apiKeyID = &id
  183. }
  184. action := SecurityPolicyActionBlock
  185. if in.Verdict.SessionTerminated {
  186. action = SecurityPolicyActionSessionBlock
  187. }
  188. category := in.Verdict.Category
  189. if category == "" {
  190. category = SecurityPolicyActionCategory
  191. } else {
  192. category = SecurityPolicyActionCategory + ":" + category
  193. }
  194. log := &ContentModerationLog{
  195. RequestID: in.RequestID,
  196. UserID: userID,
  197. UserEmail: userEmail,
  198. APIKeyID: apiKeyID,
  199. APIKeyName: in.APIKey.Name,
  200. GroupID: cloneInt64Ptr(groupID),
  201. GroupName: groupName,
  202. Endpoint: in.Endpoint,
  203. Provider: securityPolicyProviderOf(in.Protocol),
  204. Model: in.Model,
  205. Mode: "pre_request",
  206. Action: action,
  207. Flagged: true,
  208. HighestCategory: category,
  209. HighestScore: 1.0,
  210. MatchedKeyword: in.Verdict.MatchedKeyword,
  211. InputExcerpt: in.Verdict.Excerpt,
  212. CreatedAt: time.Now(),
  213. }
  214. if s.moderationRepo != nil {
  215. if err := s.moderationRepo.CreateLog(ctx, log); err != nil {
  216. slog.Warn("security_policy.create_log_failed", "group_id", groupID, "error", err)
  217. }
  218. }
  219. // block_session 模式:将会话 key 写入封禁,下轮同会话直接拒绝。
  220. if in.SessionMode == SecurityPolicyModeBlockSession && len(in.Verdict.SessionKeys) > 0 && s.sessionStore != nil {
  221. markCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  222. defer cancel()
  223. if err := s.sessionStore.MarkSessionBlocked(markCtx, in.Verdict.ScopeKey, in.Verdict.SessionKeys, SecurityPolicySessionTTL); err != nil {
  224. slog.Warn("security_policy.mark_session_failed", "scope", in.Verdict.ScopeKey, "error", err)
  225. }
  226. }
  227. // 邮件提醒:分组开关可自由开启/关闭(默认开),无邮箱时静默跳过。
  228. // 同一收件人冷却窗内只发首封(防共享 key 被刷词轰炸邮箱),超窗恢复。
  229. if group != nil && group.SecurityPolicyEmailEnabled && userEmail != "" && s.emailService != nil {
  230. if !s.allowViolationEmail(userEmail, in.APIKey.ID) {
  231. slog.Info("security_policy.email_throttled", "user_id", userID, "api_key_id", in.APIKey.ID)
  232. } else if err := s.sendViolationEmail(ctx, log, in.Verdict); err != nil {
  233. slog.Warn("security_policy.email_failed", "user_id", userID, "error", err)
  234. } else if s.moderationRepo != nil && log.ID > 0 {
  235. if err := s.moderationRepo.UpdateLogEmailSent(ctx, log.ID, true); err != nil {
  236. slog.Warn("security_policy.update_email_sent_failed", "log_id", log.ID, "error", err)
  237. }
  238. }
  239. }
  240. // 模型复核推翻:仅 block_session 的新鲜命中参与(entry 复访不重复复核)。
  241. // 复核为 benign → 解封会话 + 标记 overturned;确认/不可用 → 维持本地拦截。
  242. s.reviewHitAsync(ctx, log, in)
  243. }
  244. // allowViolationEmail 按收件人执行冷却窗节流:窗内返回 false(跳过发送),
  245. // 首封/过窗返回 true 并记录时间。key 优先用邮箱小写,缺失时退化到 apiKeyID。
  246. // map 满且清不掉过期条目时返回 true(退化为允许发送,不误拦截)。
  247. func (s *SecurityPolicyService) allowViolationEmail(userEmail string, apiKeyID int64) bool {
  248. key := "mail:" + strings.ToLower(strings.TrimSpace(userEmail))
  249. if key == "mail:" {
  250. key = fmt.Sprintf("key:%d", apiKeyID)
  251. }
  252. now := time.Now()
  253. s.emailThrottleMu.Lock()
  254. defer s.emailThrottleMu.Unlock()
  255. if last, ok := s.emailThrottle[key]; ok && now.Sub(last) < securityPolicyViolationEmailCooldown {
  256. return false
  257. }
  258. if s.emailThrottle == nil {
  259. s.emailThrottle = make(map[string]time.Time)
  260. }
  261. if len(s.emailThrottle) >= securityPolicyEmailThrottleMaxEntries {
  262. for k, last := range s.emailThrottle {
  263. if now.Sub(last) >= securityPolicyViolationEmailCooldown {
  264. delete(s.emailThrottle, k)
  265. }
  266. }
  267. if len(s.emailThrottle) >= securityPolicyEmailThrottleMaxEntries {
  268. return true
  269. }
  270. }
  271. s.emailThrottle[key] = now
  272. return true
  273. }
  274. // reviewHitAsync 异步模型复核。本地关键词命中先执行(快),模型结论后到:
  275. func (s *SecurityPolicyService) reviewHitAsync(ctx context.Context, log *ContentModerationLog, in SecurityPolicyHitRecord) {
  276. if s == nil || s.reviewer == nil || in.Verdict == nil || in.Verdict.SessionTerminated {
  277. return
  278. }
  279. if in.SessionMode != SecurityPolicyModeBlockSession {
  280. return
  281. }
  282. if log == nil || log.ID <= 0 || strings.TrimSpace(in.Verdict.ReviewText) == "" {
  283. return
  284. }
  285. reviewCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
  286. defer cancel()
  287. flagged, category, err := s.reviewer.ReviewTextForSecurityPolicy(reviewCtx, in.Verdict.ReviewText)
  288. if err != nil {
  289. slog.Warn("security_policy.review_unavailable", "log_id", log.ID, "error", err)
  290. return
  291. }
  292. if flagged {
  293. slog.Info("security_policy.review_confirmed", "log_id", log.ID, "category", category)
  294. return
  295. }
  296. // 推翻:解封会话 + 标记日志。用户侧无需任何操作,重试即放行。
  297. var groupID, apiKeyID int64
  298. if in.APIKey != nil {
  299. if in.APIKey.Group != nil {
  300. groupID = in.APIKey.Group.ID
  301. }
  302. apiKeyID = in.APIKey.ID
  303. }
  304. if unblocked, uerr := s.UnblockSessionScope(reviewCtx, groupID, apiKeyID); uerr != nil {
  305. slog.Warn("security_policy.overturn_unblock_failed", "log_id", log.ID, "error", uerr)
  306. } else if !unblocked {
  307. slog.Info("security_policy.overturned_session_already_clear", "log_id", log.ID)
  308. }
  309. if s.moderationRepo != nil {
  310. if uerr := s.moderationRepo.UpdateLogOverturned(reviewCtx, log.ID); uerr != nil {
  311. slog.Warn("security_policy.overturn_mark_failed", "log_id", log.ID, "error", uerr)
  312. }
  313. }
  314. slog.Info("security_policy.overturned", "log_id", log.ID,
  315. "keyword", in.Verdict.MatchedKeyword, "group_id", groupID, "api_key_id", apiKeyID)
  316. }
  317. func securityPolicyProviderOf(protocol string) string {
  318. switch protocol {
  319. case ContentModerationProtocolOpenAIResponses,
  320. ContentModerationProtocolOpenAIChat,
  321. ContentModerationProtocolOpenAIImages:
  322. return "openai"
  323. case ContentModerationProtocolAnthropicMessages:
  324. return "anthropic"
  325. case ContentModerationProtocolGemini:
  326. return "gemini"
  327. default:
  328. return strings.TrimSpace(protocol)
  329. }
  330. }
  331. func (s *SecurityPolicyService) sendViolationEmail(ctx context.Context, log *ContentModerationLog, verdict *SecurityPolicyVerdict) error {
  332. siteName := s.siteName(ctx)
  333. variables := map[string]string{
  334. "triggered_at": log.CreatedAt.UTC().Format(time.RFC3339),
  335. "model": defaultContentModerationString(log.Model, "-"),
  336. "group_name": defaultContentModerationString(log.GroupName, "-"),
  337. "matched_keyword": defaultContentModerationString(verdict.MatchedKeyword, "-"),
  338. }
  339. if s.emailService.notificationEmailService != nil {
  340. err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  341. Event: NotificationEmailEventSecurityPolicyNotice,
  342. RecipientEmail: log.UserEmail,
  343. RecipientName: emailRecipientName(log.UserEmail),
  344. UserID: contentModerationEmailUserID(log),
  345. SourceType: "security_policy",
  346. SourceID: contentModerationEmailSourceID(log),
  347. Variables: variables,
  348. })
  349. if err == nil {
  350. return nil
  351. }
  352. if !shouldFallbackNotificationEmail(err) {
  353. return err
  354. }
  355. slog.Warn("template security policy email failed; falling back", "err", err.Error())
  356. }
  357. subject := fmt.Sprintf("[%s] 安全策略提醒 / Security Policy Notice", sanitizeEmailHeader(siteName))
  358. return s.emailService.SendEmail(ctx, log.UserEmail, subject, buildSecurityPolicyNoticeEmailBody(siteName, log, verdict))
  359. }
  360. func buildSecurityPolicyNoticeEmailBody(siteName string, log *ContentModerationLog, verdict *SecurityPolicyVerdict) string {
  361. var sb strings.Builder
  362. sb.WriteString("您好 " + emailRecipientName(log.UserEmail) + ":\n\n")
  363. sb.WriteString("您在分组「" + log.GroupName + "」的一次请求触发了安全策略(敏感话题),该请求已被拦截。\n")
  364. if verdict.MatchedKeyword != "" {
  365. sb.WriteString("命中关键词:" + verdict.MatchedKeyword + "\n")
  366. }
  367. if verdict.SessionTerminated {
  368. sb.WriteString("该会话已被终止,请新建会话后继续。\n")
  369. }
  370. sb.WriteString("\n如认为系误判,请调整输入措辞后重试,或联系管理员。\n")
  371. sb.WriteString("\n—— " + siteName)
  372. return sb.String()
  373. }
  374. // UnblockSessionScope 管理端解封:清除指定分组+Key 的全部会话封禁。
  375. func (s *SecurityPolicyService) UnblockSessionScope(ctx context.Context, groupID, apiKeyID int64) (bool, error) {
  376. if s == nil || s.sessionStore == nil {
  377. return false, nil
  378. }
  379. return s.sessionStore.UnblockSessionScope(ctx, SecurityPolicySessionStoreKeyForAdmin(groupID, apiKeyID))
  380. }
  381. // GetBuiltinKeywords 返回内置 seed 词包(管理端展示/审计用)。
  382. func (s *SecurityPolicyService) GetBuiltinKeywords() []SecurityPolicyKeywordSeed {
  383. return SecurityPolicyBuiltinKeywords()
  384. }
  385. // ListKeywords 管理端查询自定义词。
  386. func (s *SecurityPolicyService) ListKeywords(ctx context.Context, groupID *int64, includeDisabled bool) ([]SecurityPolicyKeyword, error) {
  387. if s == nil || s.keywordRepo == nil {
  388. return []SecurityPolicyKeyword{}, nil
  389. }
  390. return s.keywordRepo.ListKeywords(ctx, groupID, includeDisabled)
  391. }
  392. // CreateKeyword 新建自定义词。groupID 非 nil 时必须指向存在的分组。
  393. func (s *SecurityPolicyService) CreateKeyword(ctx context.Context, in SecurityPolicyKeywordInput) (*SecurityPolicyKeyword, error) {
  394. if s == nil || s.keywordRepo == nil {
  395. return nil, fmt.Errorf("security policy service unavailable")
  396. }
  397. keyword := strings.TrimSpace(in.Keyword)
  398. if keyword == "" {
  399. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_KEYWORD", "keyword must not be empty")
  400. }
  401. if len([]rune(keyword)) > 200 {
  402. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_KEYWORD", "keyword must not exceed 200 runes")
  403. }
  404. category := strings.TrimSpace(in.Category)
  405. if category == "" {
  406. category = SecurityPolicyCategoryCustom
  407. }
  408. if len([]rune(category)) > 50 {
  409. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_CATEGORY", "category must not exceed 50 runes")
  410. }
  411. if in.GroupID != nil && *in.GroupID > 0 && s.groupRepo != nil {
  412. if _, err := s.groupRepo.GetByIDLite(ctx, *in.GroupID); err != nil {
  413. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_GROUP", "group not found")
  414. }
  415. }
  416. created, err := s.keywordRepo.CreateKeyword(ctx, SecurityPolicyKeywordInput{
  417. GroupID: in.GroupID,
  418. Keyword: keyword,
  419. Category: category,
  420. })
  421. if err != nil {
  422. return nil, err
  423. }
  424. s.refreshSnapshotAsync()
  425. return created, nil
  426. }
  427. // UpdateKeyword 更新自定义词(词/分类/启用)。
  428. func (s *SecurityPolicyService) UpdateKeyword(ctx context.Context, id int64, in SecurityPolicyKeywordUpdate) (*SecurityPolicyKeyword, error) {
  429. if s == nil || s.keywordRepo == nil {
  430. return nil, fmt.Errorf("security policy service unavailable")
  431. }
  432. if id <= 0 {
  433. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_KEYWORD_ID", "invalid keyword id")
  434. }
  435. if in.Keyword != nil {
  436. keyword := strings.TrimSpace(*in.Keyword)
  437. if keyword == "" {
  438. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_KEYWORD", "keyword must not be empty")
  439. }
  440. if len([]rune(keyword)) > 200 {
  441. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_KEYWORD", "keyword must not exceed 200 runes")
  442. }
  443. in.Keyword = &keyword
  444. }
  445. if in.Category != nil {
  446. category := strings.TrimSpace(*in.Category)
  447. if category == "" {
  448. category = SecurityPolicyCategoryCustom
  449. }
  450. if len([]rune(category)) > 50 {
  451. return nil, infraerrors.BadRequest("INVALID_SECURITY_POLICY_CATEGORY", "category must not exceed 50 runes")
  452. }
  453. in.Category = &category
  454. }
  455. updated, err := s.keywordRepo.UpdateKeyword(ctx, id, in)
  456. if err != nil {
  457. return nil, err
  458. }
  459. s.refreshSnapshotAsync()
  460. return updated, nil
  461. }
  462. // DeleteKeyword 软删除自定义词。
  463. func (s *SecurityPolicyService) DeleteKeyword(ctx context.Context, id int64) error {
  464. if s == nil || s.keywordRepo == nil {
  465. return fmt.Errorf("security policy service unavailable")
  466. }
  467. if id <= 0 {
  468. return infraerrors.BadRequest("INVALID_SECURITY_POLICY_KEYWORD_ID", "invalid keyword id")
  469. }
  470. if err := s.keywordRepo.DeleteKeyword(ctx, id); err != nil {
  471. return err
  472. }
  473. s.refreshSnapshotAsync()
  474. return nil
  475. }
  476. // refreshSnapshotAsync 使词表快照尽快过期(下次评估重建,最多延迟一个 TTL)。
  477. func (s *SecurityPolicyService) refreshSnapshotAsync() {
  478. if s == nil {
  479. return
  480. }
  481. s.snapshot.Store(&securityPolicyKeywordSnapshot{at: time.Now().Add(-securityPolicySnapshotTTL), snapshot: nil})
  482. }
  483. // keywordSnapshot 返回合并词表快照(60s TTL + 失败退避)。仓储不可用时退化为纯内置表。
  484. func (s *SecurityPolicyService) keywordSnapshot(ctx context.Context) *SecurityPolicySnapshot {
  485. if s == nil {
  486. return BuildSecurityPolicySnapshot(nil)
  487. }
  488. if cached, ok := s.snapshot.Load().(*securityPolicyKeywordSnapshot); ok && cached != nil {
  489. if time.Since(cached.at) < securityPolicySnapshotTTL {
  490. return cached.snapshot
  491. }
  492. }
  493. result, _, _ := s.snapshotSF.Do("security_policy_keywords", func() (any, error) {
  494. if cached, ok := s.snapshot.Load().(*securityPolicyKeywordSnapshot); ok && cached != nil {
  495. if time.Since(cached.at) < securityPolicySnapshotTTL {
  496. return cached.snapshot, nil
  497. }
  498. }
  499. var custom []SecurityPolicyKeyword
  500. if s.keywordRepo != nil {
  501. dbCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  502. defer cancel()
  503. // 全量 enabled 词(全局 + 各组),按组拆分在快照内完成。
  504. words, err := s.keywordRepo.ListKeywords(dbCtx, nil, false)
  505. if err != nil {
  506. slog.Warn("security_policy.keywords_load_failed", "error", err)
  507. } else {
  508. custom = words
  509. }
  510. }
  511. snapshot := BuildSecurityPolicySnapshot(custom)
  512. s.snapshot.Store(&securityPolicyKeywordSnapshot{at: time.Now(), snapshot: snapshot})
  513. return snapshot, nil
  514. })
  515. if snapshot, ok := result.(*SecurityPolicySnapshot); ok && snapshot != nil {
  516. return snapshot
  517. }
  518. return BuildSecurityPolicySnapshot(nil)
  519. }
  520. // siteName 读取站点名(邮件标题用),失败时回退默认。
  521. func (s *SecurityPolicyService) siteName(ctx context.Context) string {
  522. if s == nil || s.settingRepo == nil {
  523. return "Sub2API"
  524. }
  525. name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
  526. if err != nil || strings.TrimSpace(name) == "" {
  527. return "Sub2API"
  528. }
  529. return strings.TrimSpace(name)
  530. }
  531. // ReviewTextForSecurityPolicy 以纯模型复核文本是否违规,绕过关键词/
  532. // 分组/采样门控。无可用配置或调用失败时返回 err,调用方保留本地拦截。
  533. // 与 ContentModerationService 同包,直接复用其快照与审计通道。
  534. func (s *ContentModerationService) ReviewTextForSecurityPolicy(ctx context.Context, text string) (bool, string, error) {
  535. if s == nil {
  536. return false, "", fmt.Errorf("content moderation service unavailable")
  537. }
  538. if strings.TrimSpace(text) == "" {
  539. return false, "", nil
  540. }
  541. snapshot, err := s.loadRuntimeSnapshot(ctx)
  542. if err != nil {
  543. return false, "", err
  544. }
  545. cfg := snapshot.config
  546. if len(cfg.apiKeys()) == 0 {
  547. return false, "", fmt.Errorf("no moderation api key available")
  548. }
  549. result, err := s.callModeration(ctx, cfg, text, true)
  550. if err != nil {
  551. return false, "", err
  552. }
  553. flagged, category, _ := evaluateModerationScores(result.CategoryScores, cfg.Thresholds)
  554. return flagged, category, nil
  555. }