security_policy.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. package service
  2. import (
  3. "context"
  4. "strings"
  5. "time"
  6. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  7. )
  8. // SecurityPolicyModeBlockSession 命中后终止会话:同一会话后续请求直接 403,
  9. // 需新建会话才能继续。
  10. const SecurityPolicyModeBlockSession = "block_session"
  11. // SecurityPolicyModeBlockRequest 命中后仅拦截当次请求,不记录会话封禁。
  12. const SecurityPolicyModeBlockRequest = "block_request"
  13. // NormalizeSecurityPolicyMode 把未知/空模式收敛为 block_session,保证读路径
  14. // 永不因脏配置静默放行。
  15. func NormalizeSecurityPolicyMode(mode string) string {
  16. if strings.EqualFold(strings.TrimSpace(mode), SecurityPolicyModeBlockRequest) {
  17. return SecurityPolicyModeBlockRequest
  18. }
  19. return SecurityPolicyModeBlockSession
  20. }
  21. // SecurityPolicyActionBlock / SecurityPolicyActionSessionBlock 是写入
  22. // content_moderation_logs 的 action。两者恒排除在封号计数之外
  23. // (安全策略只做会话处置,不做用户封禁)。
  24. const (
  25. SecurityPolicyActionBlock = "security_policy_block"
  26. SecurityPolicyActionSessionBlock = "security_policy_session_block"
  27. SecurityPolicyActionCategory = "security_policy"
  28. SecurityPolicyErrorCodeViolation = "SECURITY_POLICY_VIOLATION"
  29. SecurityPolicyErrorCodeTerminated = "SECURITY_POLICY_SESSION_TERMINATED"
  30. )
  31. // SecurityPolicySessionTTL 会话封禁默认有效期:命中后同一会话 24h 内直接拒绝。
  32. const SecurityPolicySessionTTL = 24 * time.Hour
  33. // SecurityPolicyKeyword 是 security_policy_keywords 表的行映射。
  34. type SecurityPolicyKeyword struct {
  35. ID int64
  36. GroupID *int64
  37. Keyword string
  38. Category string
  39. Enabled bool
  40. CreatedAt time.Time
  41. UpdatedAt time.Time
  42. }
  43. // SecurityPolicyKeywordInput 是新建自定义词的入参。GroupID 为 nil 表示全局词。
  44. type SecurityPolicyKeywordInput struct {
  45. GroupID *int64
  46. Keyword string
  47. Category string
  48. }
  49. // SecurityPolicyKeywordUpdate 是更新自定义词的入参,全 nil 表示无变更。
  50. type SecurityPolicyKeywordUpdate struct {
  51. Keyword *string
  52. Category *string
  53. Enabled *bool
  54. }
  55. var (
  56. // ErrSecurityPolicyKeywordExists 关键词在同一作用域已存在。
  57. ErrSecurityPolicyKeywordExists = infraerrors.Conflict("SECURITY_POLICY_KEYWORD_EXISTS", "security policy keyword already exists")
  58. // ErrSecurityPolicyKeywordNotFound 关键词不存在。
  59. ErrSecurityPolicyKeywordNotFound = infraerrors.NotFound("SECURITY_POLICY_KEYWORD_NOT_FOUND", "security policy keyword not found")
  60. )
  61. // SecurityPolicyRepository 是安全策略关键词仓储接口。
  62. type SecurityPolicyRepository interface {
  63. // ListEffectiveKeywords 返回对指定分组生效的自定义词(全局 + 本组),仅 enabled。
  64. ListEffectiveKeywords(ctx context.Context, groupID *int64) ([]SecurityPolicyKeyword, error)
  65. // ListKeywords 管理端查询:groupID nil = 全部,0 = 仅全局,>0 = 仅指定分组。
  66. ListKeywords(ctx context.Context, groupID *int64, includeDisabled bool) ([]SecurityPolicyKeyword, error)
  67. CreateKeyword(ctx context.Context, in SecurityPolicyKeywordInput) (*SecurityPolicyKeyword, error)
  68. UpdateKeyword(ctx context.Context, id int64, in SecurityPolicyKeywordUpdate) (*SecurityPolicyKeyword, error)
  69. DeleteKeyword(ctx context.Context, id int64) error
  70. }
  71. // SecurityPolicySessionStore 是会话封禁存储(Redis HASH:scope -> field)。
  72. type SecurityPolicySessionStore interface {
  73. // MarkSessionBlocked 在 scope 下写入一批封禁 field 并刷新 TTL。
  74. MarkSessionBlocked(ctx context.Context, scope string, fields []string, ttl time.Duration) error
  75. // FindBlockedSessionField 按序检查 fields,返回命中的第一个。
  76. FindBlockedSessionField(ctx context.Context, scope string, fields []string) (string, error)
  77. // UnblockSessionScope 清除整个 scope(管理端解封)。
  78. UnblockSessionScope(ctx context.Context, scope string) (bool, error)
  79. }
  80. // SecurityPolicySnapshot 是一次合并后的词表快照:全局集合(内置 seed +
  81. // 全局自定义词)与按组集合(各组自定义词),分别编译 AC 自动机。
  82. type SecurityPolicySnapshot struct {
  83. Global *securityPolicyCompiledSet
  84. ByGroup map[int64]*securityPolicyCompiledSet
  85. }
  86. type securityPolicyCompiledSet struct {
  87. Keywords []string
  88. Categories map[string]string
  89. Matcher *contentModerationKeywordMatcher
  90. }
  91. // BuildSecurityPolicySnapshot 合并内置 seed 与自定义词并编译。
  92. // 自定义词按 GroupID 归属分组集合(nil = 全局);去重大小写不敏感,
  93. // 内置优先保留分类。
  94. func BuildSecurityPolicySnapshot(custom []SecurityPolicyKeyword) *SecurityPolicySnapshot {
  95. snapshot := &SecurityPolicySnapshot{ByGroup: make(map[int64]*securityPolicyCompiledSet)}
  96. globalWords := make([]string, 0, len(SecurityPolicyBuiltinKeywords())+len(custom))
  97. globalCats := make(map[string]string)
  98. globalSeen := make(map[string]struct{})
  99. for _, seed := range SecurityPolicyBuiltinKeywords() {
  100. key := strings.ToLower(strings.TrimSpace(seed.Keyword))
  101. if key == "" {
  102. continue
  103. }
  104. if _, dup := globalSeen[key]; dup {
  105. continue
  106. }
  107. globalSeen[key] = struct{}{}
  108. globalWords = append(globalWords, seed.Keyword)
  109. globalCats[seed.Keyword] = seed.Category
  110. }
  111. grouped := make(map[int64]map[string]SecurityPolicyKeyword)
  112. for _, word := range custom {
  113. keyword := strings.TrimSpace(word.Keyword)
  114. key := strings.ToLower(keyword)
  115. if key == "" {
  116. continue
  117. }
  118. if word.GroupID == nil {
  119. if _, dup := globalSeen[key]; dup {
  120. continue
  121. }
  122. globalSeen[key] = struct{}{}
  123. globalWords = append(globalWords, keyword)
  124. category := strings.TrimSpace(word.Category)
  125. if category == "" {
  126. category = SecurityPolicyCategoryCustom
  127. }
  128. globalCats[keyword] = category
  129. continue
  130. }
  131. if grouped[*word.GroupID] == nil {
  132. grouped[*word.GroupID] = make(map[string]SecurityPolicyKeyword)
  133. }
  134. if _, dup := globalSeen[key]; dup {
  135. continue
  136. }
  137. if _, dup := grouped[*word.GroupID][key]; dup {
  138. continue
  139. }
  140. grouped[*word.GroupID][key] = word
  141. }
  142. snapshot.Global = compileSecurityPolicySet(globalWords, globalCats)
  143. for groupID, words := range grouped {
  144. keywords := make([]string, 0, len(words))
  145. categories := make(map[string]string, len(words))
  146. for _, word := range words {
  147. keywords = append(keywords, word.Keyword)
  148. category := strings.TrimSpace(word.Category)
  149. if category == "" {
  150. category = SecurityPolicyCategoryCustom
  151. }
  152. categories[word.Keyword] = category
  153. }
  154. snapshot.ByGroup[groupID] = compileSecurityPolicySet(keywords, categories)
  155. }
  156. return snapshot
  157. }
  158. func compileSecurityPolicySet(keywords []string, categories map[string]string) *securityPolicyCompiledSet {
  159. return &securityPolicyCompiledSet{
  160. Keywords: keywords,
  161. Categories: categories,
  162. Matcher: newContentModerationKeywordMatcher(keywords),
  163. }
  164. }
  165. // Match 在快照中匹配文本:先全局集合,再本组集合。返回命中的关键词原文与分类。
  166. func (s *SecurityPolicySnapshot) Match(groupID int64, text string) (string, string, bool) {
  167. if s == nil || strings.TrimSpace(text) == "" {
  168. return "", "", false
  169. }
  170. if s.Global != nil && s.Global.Matcher != nil {
  171. if keyword, ok := s.Global.Matcher.Match(text); ok {
  172. return keyword, s.Global.Categories[keyword], true
  173. }
  174. }
  175. if g := s.ByGroup[groupID]; g != nil && g.Matcher != nil {
  176. if keyword, ok := g.Matcher.Match(text); ok {
  177. return keyword, g.Categories[keyword], true
  178. }
  179. }
  180. return "", "", false
  181. }