main.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. package main
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "database/sql"
  6. "encoding/hex"
  7. "encoding/json"
  8. "flag"
  9. "fmt"
  10. "log"
  11. "sort"
  12. "strings"
  13. "time"
  14. _ "github.com/Wei-Shaw/sub2api/ent/runtime"
  15. "github.com/Wei-Shaw/sub2api/internal/config"
  16. "github.com/Wei-Shaw/sub2api/internal/repository"
  17. "github.com/lib/pq"
  18. )
  19. const classifierVersion = "ingress-reject-v1"
  20. type candidate struct {
  21. id int64
  22. statusCode int
  23. message string
  24. body string
  25. }
  26. func main() {
  27. beforeRaw := flag.String("before", "", "required RFC3339 cutoff; only older rows are considered")
  28. execute := flag.Bool("execute", false, "delete matched rows (default is dry-run)")
  29. batchSize := flag.Int("batch-size", 5000, "scan/delete batch size (1-5000)")
  30. flag.Parse()
  31. if *beforeRaw == "" {
  32. log.Fatal("--before is required")
  33. }
  34. before, err := time.Parse(time.RFC3339, *beforeRaw)
  35. if err != nil {
  36. log.Fatalf("invalid --before: %v", err)
  37. }
  38. if *batchSize < 1 || *batchSize > 5000 {
  39. log.Fatal("--batch-size must be between 1 and 5000")
  40. }
  41. cfg, err := config.LoadForBootstrap()
  42. if err != nil {
  43. log.Fatalf("load config: %v", err)
  44. }
  45. client, db, err := repository.InitEnt(cfg)
  46. if err != nil {
  47. log.Fatalf("initialize database: %v", err)
  48. }
  49. defer func() { _ = client.Close() }()
  50. ctx := context.Background()
  51. counts, scanned, matched, deleted, err := cleanup(ctx, db, before, *batchSize, *execute)
  52. if err != nil {
  53. log.Fatalf("cleanup failed: %v", err)
  54. }
  55. digest := sha256.Sum256([]byte(classifierVersion))
  56. mode := "dry-run"
  57. if *execute {
  58. mode = "execute"
  59. }
  60. fmt.Printf("mode=%s before=%s classifier=%s scanned=%d matched=%d deleted=%d\n",
  61. mode, before.UTC().Format(time.RFC3339), hex.EncodeToString(digest[:]), scanned, matched, deleted)
  62. reasons := make([]string, 0, len(counts))
  63. for reason := range counts {
  64. reasons = append(reasons, reason)
  65. }
  66. sort.Strings(reasons)
  67. for _, reason := range reasons {
  68. fmt.Printf("reason=%s count=%d\n", reason, counts[reason])
  69. }
  70. if *execute && deleted > 0 {
  71. fmt.Println("cleanup complete; schedule VACUUM (ANALYZE) ops_error_logs during normal maintenance")
  72. }
  73. }
  74. func cleanup(ctx context.Context, db *sql.DB, before time.Time, batchSize int, execute bool) (map[string]int64, int64, int64, int64, error) {
  75. counts := make(map[string]int64)
  76. var cursor, scanned, matched, deleted int64
  77. for {
  78. rows, err := db.QueryContext(ctx, `
  79. SELECT id, COALESCE(status_code, 0), COALESCE(error_message, ''), COALESCE(error_body, '')
  80. FROM ops_error_logs
  81. WHERE id > $1
  82. AND created_at < $2
  83. AND error_phase = 'auth'
  84. AND account_id IS NULL
  85. AND upstream_status_code IS NULL
  86. AND COALESCE(upstream_error_message, '') = ''
  87. AND COALESCE(upstream_error_detail, '') = ''
  88. ORDER BY id ASC
  89. LIMIT $3`, cursor, before, batchSize)
  90. if err != nil {
  91. return nil, scanned, matched, deleted, err
  92. }
  93. batch := make([]candidate, 0, batchSize)
  94. for rows.Next() {
  95. var item candidate
  96. if err := rows.Scan(&item.id, &item.statusCode, &item.message, &item.body); err != nil {
  97. _ = rows.Close()
  98. return nil, scanned, matched, deleted, err
  99. }
  100. batch = append(batch, item)
  101. cursor = item.id
  102. }
  103. if err := rows.Err(); err != nil {
  104. _ = rows.Close()
  105. return nil, scanned, matched, deleted, err
  106. }
  107. _ = rows.Close()
  108. if len(batch) == 0 {
  109. break
  110. }
  111. ids := make([]int64, 0, len(batch))
  112. for _, item := range batch {
  113. scanned++
  114. if reason, ok := historicalIngressRejectReason(item); ok {
  115. matched++
  116. counts[reason]++
  117. ids = append(ids, item.id)
  118. }
  119. }
  120. if execute && len(ids) > 0 {
  121. result, err := db.ExecContext(ctx,
  122. `DELETE FROM ops_error_logs WHERE id = ANY($1) AND created_at < $2`, pq.Array(ids), before)
  123. if err != nil {
  124. return nil, scanned, matched, deleted, err
  125. }
  126. n, err := result.RowsAffected()
  127. if err != nil {
  128. return nil, scanned, matched, deleted, err
  129. }
  130. deleted += n
  131. }
  132. }
  133. return counts, scanned, matched, deleted, nil
  134. }
  135. func historicalIngressRejectReason(item candidate) (string, bool) {
  136. code, message := parseErrorIdentity(item.body, item.message)
  137. switch code {
  138. case "API_KEY_REQUIRED":
  139. return "missing_key", true
  140. case "INVALID_API_KEY":
  141. return "invalid_key", true
  142. case "API_KEY_DISABLED":
  143. return "key_disabled", true
  144. case "USER_INACTIVE":
  145. return "user_inactive", true
  146. case "GROUP_DELETED":
  147. return "group_deleted", true
  148. case "GROUP_DISABLED":
  149. return "group_disabled", true
  150. case "GROUP_NOT_ALLOWED":
  151. return "group_forbidden", true
  152. case "ACCESS_DENIED":
  153. return "ip_acl_denied", true
  154. case "api_key_in_query_deprecated":
  155. return "query_key_deprecated", true
  156. }
  157. normalized := strings.TrimSpace(message)
  158. switch {
  159. case normalized == "API key is required":
  160. return "missing_key", true
  161. case normalized == "Invalid API key":
  162. return "invalid_key", true
  163. case normalized == "API key is disabled":
  164. return "key_disabled", true
  165. case normalized == "User account is not active":
  166. return "user_inactive", true
  167. case normalized == "API Key 所属分组已删除":
  168. return "group_deleted", true
  169. case normalized == "API Key 所属分组已停用":
  170. return "group_disabled", true
  171. case normalized == "API Key 所属专属分组不再允许当前用户使用":
  172. return "group_forbidden", true
  173. case normalized == "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.":
  174. return "group_unassigned", true
  175. case strings.HasPrefix(normalized, "Access denied. Your IP is "):
  176. return "ip_acl_denied", true
  177. case normalized == "Query parameter api_key is deprecated. Use Authorization header or key instead.":
  178. return "query_key_deprecated", true
  179. default:
  180. return "", false
  181. }
  182. }
  183. func parseErrorIdentity(body, fallbackMessage string) (string, string) {
  184. var payload struct {
  185. Code string `json:"code"`
  186. Message string `json:"message"`
  187. Error struct {
  188. Code json.RawMessage `json:"code"`
  189. Message string `json:"message"`
  190. } `json:"error"`
  191. }
  192. if err := json.Unmarshal([]byte(body), &payload); err != nil {
  193. return "", fallbackMessage
  194. }
  195. message := payload.Message
  196. if message == "" {
  197. message = payload.Error.Message
  198. }
  199. if message == "" {
  200. message = fallbackMessage
  201. }
  202. return strings.TrimSpace(payload.Code), message
  203. }