content_moderation_repo.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "fmt"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. )
  12. type contentModerationRepository struct {
  13. db *sql.DB
  14. }
  15. func NewContentModerationRepository(db *sql.DB) service.ContentModerationRepository {
  16. return &contentModerationRepository{db: db}
  17. }
  18. func (r *contentModerationRepository) CreateLog(ctx context.Context, log *service.ContentModerationLog) error {
  19. if log == nil {
  20. return nil
  21. }
  22. categoryScores, err := json.Marshal(log.CategoryScores)
  23. if err != nil {
  24. return fmt.Errorf("marshal moderation category scores: %w", err)
  25. }
  26. thresholdSnapshot, err := json.Marshal(log.ThresholdSnapshot)
  27. if err != nil {
  28. return fmt.Errorf("marshal moderation thresholds: %w", err)
  29. }
  30. var userID any
  31. if log.UserID != nil {
  32. userID = *log.UserID
  33. }
  34. var apiKeyID any
  35. if log.APIKeyID != nil {
  36. apiKeyID = *log.APIKeyID
  37. }
  38. var groupID any
  39. if log.GroupID != nil {
  40. groupID = *log.GroupID
  41. }
  42. var latency any
  43. if log.UpstreamLatencyMS != nil {
  44. latency = *log.UpstreamLatencyMS
  45. }
  46. err = r.db.QueryRowContext(ctx, `
  47. INSERT INTO content_moderation_logs (
  48. request_id, user_id, user_email, api_key_id, api_key_name, group_id, group_name,
  49. endpoint, provider, model, mode, action, flagged, highest_category, highest_score,
  50. category_scores, threshold_snapshot, input_excerpt, upstream_latency_ms, error,
  51. violation_count, auto_banned, email_sent, queue_delay_ms, matched_keyword, overturned
  52. ) VALUES (
  53. $1, $2, $3, $4, $5, $6, $7,
  54. $8, $9, $10, $11, $12, $13, $14, $15,
  55. $16::jsonb, $17::jsonb, $18, $19, $20,
  56. $21, $22, $23, $24, $25, $26
  57. ) RETURNING id, created_at`,
  58. log.RequestID, userID, log.UserEmail, apiKeyID, log.APIKeyName, groupID, log.GroupName,
  59. log.Endpoint, log.Provider, log.Model, log.Mode, log.Action, log.Flagged, log.HighestCategory, log.HighestScore,
  60. string(categoryScores), string(thresholdSnapshot), log.InputExcerpt, latency, log.Error,
  61. log.ViolationCount, log.AutoBanned, log.EmailSent, nullableIntPtr(log.QueueDelayMS), log.MatchedKeyword, log.Overturned,
  62. ).Scan(&log.ID, &log.CreatedAt)
  63. if err != nil {
  64. return fmt.Errorf("insert content moderation log: %w", err)
  65. }
  66. return nil
  67. }
  68. func (r *contentModerationRepository) ListLogs(ctx context.Context, filter service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
  69. where, args := buildContentModerationLogWhere(filter)
  70. whereSQL := "WHERE " + strings.Join(where, " AND ")
  71. var total int64
  72. if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM content_moderation_logs l "+whereSQL, args...).Scan(&total); err != nil {
  73. return nil, nil, fmt.Errorf("count content moderation logs: %w", err)
  74. }
  75. params := filter.Pagination
  76. if params.Page <= 0 {
  77. params.Page = 1
  78. }
  79. if params.PageSize <= 0 {
  80. params.PageSize = 20
  81. }
  82. if params.PageSize > 100 {
  83. params.PageSize = 100
  84. }
  85. queryArgs := append([]any{}, args...)
  86. queryArgs = append(queryArgs, params.Limit(), params.Offset())
  87. rows, err := r.db.QueryContext(ctx, `
  88. SELECT
  89. l.id, l.request_id, l.user_id, l.user_email, l.api_key_id, l.api_key_name, l.group_id, l.group_name,
  90. l.endpoint, l.provider, l.model, l.mode, l.action, l.flagged, l.highest_category, l.highest_score,
  91. l.category_scores, l.threshold_snapshot, l.input_excerpt, l.upstream_latency_ms, l.error,
  92. l.violation_count, l.auto_banned, l.email_sent, COALESCE(u.status, ''), l.queue_delay_ms, l.matched_keyword, l.overturned, l.created_at
  93. FROM content_moderation_logs l
  94. LEFT JOIN users u ON u.id = l.user_id `+whereSQL+`
  95. ORDER BY l.created_at DESC, l.id DESC
  96. LIMIT $`+fmt.Sprint(len(queryArgs)-1)+` OFFSET $`+fmt.Sprint(len(queryArgs)),
  97. queryArgs...,
  98. )
  99. if err != nil {
  100. return nil, nil, fmt.Errorf("list content moderation logs: %w", err)
  101. }
  102. defer func() { _ = rows.Close() }()
  103. items := make([]service.ContentModerationLog, 0)
  104. for rows.Next() {
  105. var item service.ContentModerationLog
  106. var userID, apiKeyID, groupID, latency, queueDelay sql.NullInt64
  107. var scoresRaw, thresholdsRaw []byte
  108. if err := rows.Scan(
  109. &item.ID,
  110. &item.RequestID,
  111. &userID,
  112. &item.UserEmail,
  113. &apiKeyID,
  114. &item.APIKeyName,
  115. &groupID,
  116. &item.GroupName,
  117. &item.Endpoint,
  118. &item.Provider,
  119. &item.Model,
  120. &item.Mode,
  121. &item.Action,
  122. &item.Flagged,
  123. &item.HighestCategory,
  124. &item.HighestScore,
  125. &scoresRaw,
  126. &thresholdsRaw,
  127. &item.InputExcerpt,
  128. &latency,
  129. &item.Error,
  130. &item.ViolationCount,
  131. &item.AutoBanned,
  132. &item.EmailSent,
  133. &item.UserStatus,
  134. &queueDelay,
  135. &item.MatchedKeyword,
  136. &item.Overturned,
  137. &item.CreatedAt,
  138. ); err != nil {
  139. return nil, nil, fmt.Errorf("scan content moderation log: %w", err)
  140. }
  141. if userID.Valid {
  142. v := userID.Int64
  143. item.UserID = &v
  144. }
  145. if apiKeyID.Valid {
  146. v := apiKeyID.Int64
  147. item.APIKeyID = &v
  148. }
  149. if groupID.Valid {
  150. v := groupID.Int64
  151. item.GroupID = &v
  152. }
  153. if latency.Valid {
  154. v := int(latency.Int64)
  155. item.UpstreamLatencyMS = &v
  156. }
  157. if queueDelay.Valid {
  158. v := int(queueDelay.Int64)
  159. item.QueueDelayMS = &v
  160. }
  161. item.CategoryScores = map[string]float64{}
  162. _ = json.Unmarshal(scoresRaw, &item.CategoryScores)
  163. item.ThresholdSnapshot = map[string]float64{}
  164. _ = json.Unmarshal(thresholdsRaw, &item.ThresholdSnapshot)
  165. items = append(items, item)
  166. }
  167. if err := rows.Err(); err != nil {
  168. return nil, nil, fmt.Errorf("iterate content moderation logs: %w", err)
  169. }
  170. return items, paginationResultFromTotal(total, params), nil
  171. }
  172. func (r *contentModerationRepository) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
  173. if userID <= 0 {
  174. return 0, nil
  175. }
  176. // SQL 中的 'cyber_policy' 字面量须与 service.ContentModerationActionCyberPolicy 保持一致。
  177. // security_policy_* 由分组安全策略写入,只做会话处置,恒不参与封号计数。
  178. var count int
  179. err := r.db.QueryRowContext(ctx, `
  180. WITH last_auto_ban AS (
  181. SELECT MAX(created_at) AS at
  182. FROM content_moderation_logs
  183. WHERE user_id = $1 AND auto_banned = TRUE
  184. )
  185. SELECT COUNT(*)
  186. FROM content_moderation_logs
  187. WHERE user_id = $1
  188. AND flagged = TRUE
  189. AND action <> 'hash_block'
  190. AND ($3::bool IS FALSE OR action <> 'cyber_policy')
  191. AND action NOT IN ('security_policy_block', 'security_policy_session_block')
  192. AND created_at >= $2
  193. AND created_at > COALESCE((SELECT at FROM last_auto_ban), '-infinity'::timestamptz)
  194. `, userID, since, excludeCyberPolicy).Scan(&count)
  195. if err != nil {
  196. return 0, fmt.Errorf("count user content moderation flagged logs: %w", err)
  197. }
  198. return count, nil
  199. }
  200. func (r *contentModerationRepository) UpdateLogEmailSent(ctx context.Context, id int64, sent bool) error {
  201. _, err := r.db.ExecContext(ctx, `UPDATE content_moderation_logs SET email_sent = $1 WHERE id = $2`, sent, id)
  202. if err != nil {
  203. return fmt.Errorf("update content moderation log email_sent: %w", err)
  204. }
  205. return nil
  206. }
  207. // UpdateLogOverturned 标记模型复核推翻(分组安全策略专用)。
  208. func (r *contentModerationRepository) UpdateLogOverturned(ctx context.Context, id int64) error {
  209. _, err := r.db.ExecContext(ctx, `UPDATE content_moderation_logs SET overturned = TRUE WHERE id = $1`, id)
  210. if err != nil {
  211. return fmt.Errorf("update content moderation log overturned: %w", err)
  212. }
  213. return nil
  214. }
  215. func (r *contentModerationRepository) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*service.ContentModerationCleanupResult, error) {
  216. result := &service.ContentModerationCleanupResult{FinishedAt: time.Now()}
  217. if r == nil || r.db == nil {
  218. return result, nil
  219. }
  220. hitExec, err := r.db.ExecContext(ctx, `
  221. DELETE FROM content_moderation_logs
  222. WHERE flagged = TRUE AND created_at < $1
  223. `, hitBefore)
  224. if err != nil {
  225. return nil, fmt.Errorf("delete expired hit content moderation logs: %w", err)
  226. }
  227. result.DeletedHit, _ = hitExec.RowsAffected()
  228. nonHitExec, err := r.db.ExecContext(ctx, `
  229. DELETE FROM content_moderation_logs
  230. WHERE flagged = FALSE AND created_at < $1
  231. `, nonHitBefore)
  232. if err != nil {
  233. return nil, fmt.Errorf("delete expired non-hit content moderation logs: %w", err)
  234. }
  235. result.DeletedNonHit, _ = nonHitExec.RowsAffected()
  236. result.FinishedAt = time.Now()
  237. return result, nil
  238. }
  239. func nullableIntPtr(value *int) any {
  240. if value == nil {
  241. return nil
  242. }
  243. return *value
  244. }
  245. func buildContentModerationLogWhere(filter service.ContentModerationLogFilter) ([]string, []any) {
  246. where := []string{"l.id IS NOT NULL"}
  247. args := make([]any, 0)
  248. add := func(expr string, value any) {
  249. args = append(args, value)
  250. where = append(where, fmt.Sprintf(expr, len(args)))
  251. }
  252. switch strings.ToLower(strings.TrimSpace(filter.Result)) {
  253. case "hit", "flagged":
  254. where = append(where, "l.flagged = TRUE")
  255. case "blocked", "block":
  256. where = append(where, "l.action IN ('block', 'keyword_block', 'hash_block')")
  257. case "pass", "allow":
  258. where = append(where, "l.flagged = FALSE AND l.error = ''")
  259. case "error":
  260. where = append(where, "l.error <> ''")
  261. }
  262. if filter.GroupID != nil {
  263. add("l.group_id = $%d", *filter.GroupID)
  264. }
  265. if endpoint := strings.TrimSpace(filter.Endpoint); endpoint != "" {
  266. add("l.endpoint = $%d", endpoint)
  267. }
  268. if search := strings.TrimSpace(filter.Search); search != "" {
  269. like := "%" + search + "%"
  270. args = append(args, like, like, like, like, like)
  271. idx := len(args) - 4
  272. where = append(where, fmt.Sprintf("(l.request_id ILIKE $%d OR l.user_email ILIKE $%d OR l.api_key_name ILIKE $%d OR l.model ILIKE $%d OR l.input_excerpt ILIKE $%d)", idx, idx+1, idx+2, idx+3, idx+4))
  273. }
  274. if filter.From != nil && !filter.From.IsZero() {
  275. add("l.created_at >= $%d", *filter.From)
  276. }
  277. if filter.To != nil && !filter.To.IsZero() {
  278. add("l.created_at <= $%d", *filter.To)
  279. }
  280. return where, args
  281. }