usage_handler.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. package admin
  2. import (
  3. "context"
  4. "net/http"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/handler/dto"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  14. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/gin-gonic/gin"
  17. )
  18. // UsageHandler handles admin usage-related requests
  19. type UsageHandler struct {
  20. usageService *service.UsageService
  21. apiKeyService *service.APIKeyService
  22. adminService service.AdminService
  23. cleanupService *service.UsageCleanupService
  24. }
  25. // NewUsageHandler creates a new admin usage handler
  26. func NewUsageHandler(
  27. usageService *service.UsageService,
  28. apiKeyService *service.APIKeyService,
  29. adminService service.AdminService,
  30. cleanupService *service.UsageCleanupService,
  31. ) *UsageHandler {
  32. return &UsageHandler{
  33. usageService: usageService,
  34. apiKeyService: apiKeyService,
  35. adminService: adminService,
  36. cleanupService: cleanupService,
  37. }
  38. }
  39. // CreateUsageCleanupTaskRequest represents cleanup task creation request
  40. type CreateUsageCleanupTaskRequest struct {
  41. StartDate string `json:"start_date"`
  42. EndDate string `json:"end_date"`
  43. UserID *int64 `json:"user_id"`
  44. APIKeyID *int64 `json:"api_key_id"`
  45. AccountID *int64 `json:"account_id"`
  46. GroupID *int64 `json:"group_id"`
  47. Model *string `json:"model"`
  48. RequestType *string `json:"request_type"`
  49. Stream *bool `json:"stream"`
  50. BillingType *int8 `json:"billing_type"`
  51. Timezone string `json:"timezone"`
  52. }
  53. // List handles listing all usage records with filters
  54. // GET /api/v1/admin/usage
  55. func (h *UsageHandler) List(c *gin.Context) {
  56. page, pageSize := response.ParsePagination(c)
  57. exactTotal := false
  58. if exactTotalRaw := strings.TrimSpace(c.Query("exact_total")); exactTotalRaw != "" {
  59. parsed, err := strconv.ParseBool(exactTotalRaw)
  60. if err != nil {
  61. response.BadRequest(c, "Invalid exact_total value, use true or false")
  62. return
  63. }
  64. exactTotal = parsed
  65. }
  66. // Parse filters
  67. var userID, apiKeyID, accountID, groupID int64
  68. if userIDStr := c.Query("user_id"); userIDStr != "" {
  69. id, err := strconv.ParseInt(userIDStr, 10, 64)
  70. if err != nil {
  71. response.BadRequest(c, "Invalid user_id")
  72. return
  73. }
  74. userID = id
  75. }
  76. if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
  77. id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
  78. if err != nil {
  79. response.BadRequest(c, "Invalid api_key_id")
  80. return
  81. }
  82. apiKeyID = id
  83. }
  84. if accountIDStr := c.Query("account_id"); accountIDStr != "" {
  85. id, err := strconv.ParseInt(accountIDStr, 10, 64)
  86. if err != nil {
  87. response.BadRequest(c, "Invalid account_id")
  88. return
  89. }
  90. accountID = id
  91. }
  92. if groupIDStr := c.Query("group_id"); groupIDStr != "" {
  93. id, err := strconv.ParseInt(groupIDStr, 10, 64)
  94. if err != nil {
  95. response.BadRequest(c, "Invalid group_id")
  96. return
  97. }
  98. groupID = id
  99. }
  100. model := c.Query("model")
  101. requestID := strings.TrimSpace(c.Query("request_id"))
  102. billingMode := strings.TrimSpace(c.Query("billing_mode"))
  103. var requestType *int16
  104. var stream *bool
  105. if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
  106. parsed, err := service.ParseUsageRequestType(requestTypeStr)
  107. if err != nil {
  108. response.BadRequest(c, err.Error())
  109. return
  110. }
  111. value := int16(parsed)
  112. requestType = &value
  113. } else if streamStr := c.Query("stream"); streamStr != "" {
  114. val, err := strconv.ParseBool(streamStr)
  115. if err != nil {
  116. response.BadRequest(c, "Invalid stream value, use true or false")
  117. return
  118. }
  119. stream = &val
  120. }
  121. nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
  122. if err != nil {
  123. response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
  124. return
  125. }
  126. var billingType *int8
  127. if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
  128. val, err := strconv.ParseInt(billingTypeStr, 10, 8)
  129. if err != nil {
  130. response.BadRequest(c, "Invalid billing_type")
  131. return
  132. }
  133. bt := int8(val)
  134. billingType = &bt
  135. }
  136. var upstreamModelMismatch *bool
  137. if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
  138. value, err := strconv.ParseBool(raw)
  139. if err != nil {
  140. response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
  141. return
  142. }
  143. upstreamModelMismatch = &value
  144. }
  145. // Parse date range
  146. var startTime, endTime *time.Time
  147. userTZ := c.Query("timezone") // Get user's timezone from request
  148. if startDateStr := c.Query("start_date"); startDateStr != "" {
  149. t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
  150. if err != nil {
  151. response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
  152. return
  153. }
  154. startTime = &t
  155. }
  156. if endDateStr := c.Query("end_date"); endDateStr != "" {
  157. t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
  158. if err != nil {
  159. response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
  160. return
  161. }
  162. // Use half-open range [start, end), move to next calendar day start (DST-safe).
  163. t = t.AddDate(0, 0, 1)
  164. endTime = &t
  165. }
  166. params := pagination.PaginationParams{
  167. Page: page,
  168. PageSize: pageSize,
  169. SortBy: c.DefaultQuery("sort_by", "created_at"),
  170. SortOrder: c.DefaultQuery("sort_order", "desc"),
  171. }
  172. filters := usagestats.UsageLogFilters{
  173. UserID: userID,
  174. APIKeyID: apiKeyID,
  175. AccountID: accountID,
  176. GroupID: groupID,
  177. RequestID: requestID,
  178. Model: model,
  179. ModelFilterSource: usagestats.ModelSourceRequested,
  180. RequestType: requestType,
  181. Stream: stream,
  182. NativeCompactionV2: nativeCompactionV2,
  183. BillingType: billingType,
  184. BillingMode: billingMode,
  185. UpstreamModelMismatch: upstreamModelMismatch,
  186. StartTime: startTime,
  187. EndTime: endTime,
  188. ExactTotal: exactTotal,
  189. }
  190. records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
  191. if err != nil {
  192. response.ErrorFrom(c, err)
  193. return
  194. }
  195. out := make([]dto.AdminUsageLog, 0, len(records))
  196. for i := range records {
  197. out = append(out, *dto.UsageLogFromServiceAdmin(&records[i]))
  198. }
  199. response.Paginated(c, out, result.Total, page, pageSize)
  200. }
  201. // Stats handles getting usage statistics with filters
  202. // GET /api/v1/admin/usage/stats
  203. func (h *UsageHandler) Stats(c *gin.Context) {
  204. // Parse filters - same as List endpoint
  205. var userID, apiKeyID, accountID, groupID int64
  206. if userIDStr := c.Query("user_id"); userIDStr != "" {
  207. id, err := strconv.ParseInt(userIDStr, 10, 64)
  208. if err != nil {
  209. response.BadRequest(c, "Invalid user_id")
  210. return
  211. }
  212. userID = id
  213. }
  214. if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
  215. id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
  216. if err != nil {
  217. response.BadRequest(c, "Invalid api_key_id")
  218. return
  219. }
  220. apiKeyID = id
  221. }
  222. if accountIDStr := c.Query("account_id"); accountIDStr != "" {
  223. id, err := strconv.ParseInt(accountIDStr, 10, 64)
  224. if err != nil {
  225. response.BadRequest(c, "Invalid account_id")
  226. return
  227. }
  228. accountID = id
  229. }
  230. if groupIDStr := c.Query("group_id"); groupIDStr != "" {
  231. id, err := strconv.ParseInt(groupIDStr, 10, 64)
  232. if err != nil {
  233. response.BadRequest(c, "Invalid group_id")
  234. return
  235. }
  236. groupID = id
  237. }
  238. model := c.Query("model")
  239. billingMode := strings.TrimSpace(c.Query("billing_mode"))
  240. var requestType *int16
  241. var stream *bool
  242. if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
  243. parsed, err := service.ParseUsageRequestType(requestTypeStr)
  244. if err != nil {
  245. response.BadRequest(c, err.Error())
  246. return
  247. }
  248. value := int16(parsed)
  249. requestType = &value
  250. } else if streamStr := c.Query("stream"); streamStr != "" {
  251. val, err := strconv.ParseBool(streamStr)
  252. if err != nil {
  253. response.BadRequest(c, "Invalid stream value, use true or false")
  254. return
  255. }
  256. stream = &val
  257. }
  258. nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
  259. if err != nil {
  260. response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
  261. return
  262. }
  263. var billingType *int8
  264. if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
  265. val, err := strconv.ParseInt(billingTypeStr, 10, 8)
  266. if err != nil {
  267. response.BadRequest(c, "Invalid billing_type")
  268. return
  269. }
  270. bt := int8(val)
  271. billingType = &bt
  272. }
  273. var upstreamModelMismatch *bool
  274. if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
  275. value, err := strconv.ParseBool(raw)
  276. if err != nil {
  277. response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
  278. return
  279. }
  280. upstreamModelMismatch = &value
  281. }
  282. // Parse date range
  283. userTZ := c.Query("timezone")
  284. now := timezone.NowInUserLocation(userTZ)
  285. var startTime, endTime time.Time
  286. startDateStr := c.Query("start_date")
  287. endDateStr := c.Query("end_date")
  288. if startDateStr != "" && endDateStr != "" {
  289. var err error
  290. startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
  291. if err != nil {
  292. response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
  293. return
  294. }
  295. endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
  296. if err != nil {
  297. response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
  298. return
  299. }
  300. // 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
  301. endTime = endTime.AddDate(0, 0, 1)
  302. } else {
  303. period := c.DefaultQuery("period", "today")
  304. switch period {
  305. case "today":
  306. startTime = timezone.StartOfDayInUserLocation(now, userTZ)
  307. case "week":
  308. startTime = now.AddDate(0, 0, -7)
  309. case "month":
  310. startTime = now.AddDate(0, -1, 0)
  311. default:
  312. startTime = timezone.StartOfDayInUserLocation(now, userTZ)
  313. }
  314. endTime = now
  315. }
  316. // Build filters and call GetStatsWithFilters
  317. filters := usagestats.UsageLogFilters{
  318. UserID: userID,
  319. APIKeyID: apiKeyID,
  320. AccountID: accountID,
  321. GroupID: groupID,
  322. Model: model,
  323. ModelFilterSource: usagestats.ModelSourceRequested,
  324. RequestType: requestType,
  325. Stream: stream,
  326. NativeCompactionV2: nativeCompactionV2,
  327. BillingType: billingType,
  328. BillingMode: billingMode,
  329. UpstreamModelMismatch: upstreamModelMismatch,
  330. StartTime: &startTime,
  331. EndTime: &endTime,
  332. }
  333. var stats *usagestats.UsageStats
  334. // nocache: 绕过缓存直接回源,刷新者本人拿最新;不回写缓存(管理台"我刷新我自己拿最新"语义,非全局失效)。
  335. if parseBoolQueryWithDefault(c.Query("nocache"), false) {
  336. s, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
  337. if err != nil {
  338. response.ErrorFrom(c, err)
  339. return
  340. }
  341. stats = s
  342. c.Header("X-Usage-Stats-Cache", "bypass")
  343. } else {
  344. s, hit, err := h.getStatsCached(c.Request.Context(), filters)
  345. if err != nil {
  346. response.ErrorFrom(c, err)
  347. return
  348. }
  349. stats = s
  350. c.Header("X-Usage-Stats-Cache", cacheStatusValue(hit))
  351. }
  352. response.Success(c, stats)
  353. }
  354. // SearchUsers handles searching users by email keyword
  355. // GET /api/v1/admin/usage/search-users
  356. func (h *UsageHandler) SearchUsers(c *gin.Context) {
  357. keyword := c.Query("q")
  358. if keyword == "" {
  359. response.Success(c, []any{})
  360. return
  361. }
  362. // Limit to 30 results
  363. users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword, IncludeDeleted: true}, "email", "asc")
  364. if err != nil {
  365. response.ErrorFrom(c, err)
  366. return
  367. }
  368. // Return simplified user list (only id, email and deleted flag)
  369. type SimpleUser struct {
  370. ID int64 `json:"id"`
  371. Email string `json:"email"`
  372. Deleted bool `json:"deleted"`
  373. }
  374. result := make([]SimpleUser, len(users))
  375. for i, u := range users {
  376. result[i] = SimpleUser{
  377. ID: u.ID,
  378. Email: u.Email,
  379. Deleted: u.DeletedAt != nil,
  380. }
  381. }
  382. response.Success(c, result)
  383. }
  384. // SearchAPIKeys handles searching API keys by user
  385. // GET /api/v1/admin/usage/search-api-keys
  386. func (h *UsageHandler) SearchAPIKeys(c *gin.Context) {
  387. userIDStr := c.Query("user_id")
  388. keyword := c.Query("q")
  389. var userID int64
  390. if userIDStr != "" {
  391. id, err := strconv.ParseInt(userIDStr, 10, 64)
  392. if err != nil {
  393. response.BadRequest(c, "Invalid user_id")
  394. return
  395. }
  396. userID = id
  397. }
  398. keys, err := h.apiKeyService.SearchAPIKeys(c.Request.Context(), userID, keyword, 30)
  399. if err != nil {
  400. response.ErrorFrom(c, err)
  401. return
  402. }
  403. // Return simplified API key list (only id and name)
  404. type SimpleAPIKey struct {
  405. ID int64 `json:"id"`
  406. Name string `json:"name"`
  407. UserID int64 `json:"user_id"`
  408. }
  409. result := make([]SimpleAPIKey, len(keys))
  410. for i, k := range keys {
  411. result[i] = SimpleAPIKey{
  412. ID: k.ID,
  413. Name: k.Name,
  414. UserID: k.UserID,
  415. }
  416. }
  417. response.Success(c, result)
  418. }
  419. // ListCleanupTasks handles listing usage cleanup tasks
  420. // GET /api/v1/admin/usage/cleanup-tasks
  421. func (h *UsageHandler) ListCleanupTasks(c *gin.Context) {
  422. if h.cleanupService == nil {
  423. response.Error(c, http.StatusServiceUnavailable, "Usage cleanup service unavailable")
  424. return
  425. }
  426. operator := int64(0)
  427. if subject, ok := middleware.GetAuthSubjectFromContext(c); ok {
  428. operator = subject.UserID
  429. }
  430. page, pageSize := response.ParsePagination(c)
  431. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 请求清理任务列表: operator=%d page=%d page_size=%d", operator, page, pageSize)
  432. params := pagination.PaginationParams{Page: page, PageSize: pageSize}
  433. tasks, result, err := h.cleanupService.ListTasks(c.Request.Context(), params)
  434. if err != nil {
  435. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 查询清理任务列表失败: operator=%d page=%d page_size=%d err=%v", operator, page, pageSize, err)
  436. response.ErrorFrom(c, err)
  437. return
  438. }
  439. out := make([]dto.UsageCleanupTask, 0, len(tasks))
  440. for i := range tasks {
  441. out = append(out, *dto.UsageCleanupTaskFromService(&tasks[i]))
  442. }
  443. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 返回清理任务列表: operator=%d total=%d items=%d page=%d page_size=%d", operator, result.Total, len(out), page, pageSize)
  444. response.Paginated(c, out, result.Total, page, pageSize)
  445. }
  446. // CreateCleanupTask handles creating a usage cleanup task
  447. // POST /api/v1/admin/usage/cleanup-tasks
  448. func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
  449. if h.cleanupService == nil {
  450. response.Error(c, http.StatusServiceUnavailable, "Usage cleanup service unavailable")
  451. return
  452. }
  453. subject, ok := middleware.GetAuthSubjectFromContext(c)
  454. if !ok || subject.UserID <= 0 {
  455. response.Unauthorized(c, "Unauthorized")
  456. return
  457. }
  458. var req CreateUsageCleanupTaskRequest
  459. if err := c.ShouldBindJSON(&req); err != nil {
  460. response.BadRequest(c, "Invalid request: "+err.Error())
  461. return
  462. }
  463. req.StartDate = strings.TrimSpace(req.StartDate)
  464. req.EndDate = strings.TrimSpace(req.EndDate)
  465. if req.StartDate == "" || req.EndDate == "" {
  466. response.BadRequest(c, "start_date and end_date are required")
  467. return
  468. }
  469. startTime, err := timezone.ParseInUserLocation("2006-01-02", req.StartDate, req.Timezone)
  470. if err != nil {
  471. response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
  472. return
  473. }
  474. endTime, err := timezone.ParseInUserLocation("2006-01-02", req.EndDate, req.Timezone)
  475. if err != nil {
  476. response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
  477. return
  478. }
  479. endTime = endTime.Add(24*time.Hour - time.Nanosecond)
  480. var requestType *int16
  481. stream := req.Stream
  482. if req.RequestType != nil {
  483. parsed, err := service.ParseUsageRequestType(*req.RequestType)
  484. if err != nil {
  485. response.BadRequest(c, err.Error())
  486. return
  487. }
  488. value := int16(parsed)
  489. requestType = &value
  490. stream = nil
  491. }
  492. filters := service.UsageCleanupFilters{
  493. StartTime: startTime,
  494. EndTime: endTime,
  495. UserID: req.UserID,
  496. APIKeyID: req.APIKeyID,
  497. AccountID: req.AccountID,
  498. GroupID: req.GroupID,
  499. Model: req.Model,
  500. RequestType: requestType,
  501. Stream: stream,
  502. BillingType: req.BillingType,
  503. }
  504. var userID any
  505. if filters.UserID != nil {
  506. userID = *filters.UserID
  507. }
  508. var apiKeyID any
  509. if filters.APIKeyID != nil {
  510. apiKeyID = *filters.APIKeyID
  511. }
  512. var accountID any
  513. if filters.AccountID != nil {
  514. accountID = *filters.AccountID
  515. }
  516. var groupID any
  517. if filters.GroupID != nil {
  518. groupID = *filters.GroupID
  519. }
  520. var model any
  521. if filters.Model != nil {
  522. model = *filters.Model
  523. }
  524. var streamValue any
  525. if filters.Stream != nil {
  526. streamValue = *filters.Stream
  527. }
  528. var requestTypeName any
  529. if filters.RequestType != nil {
  530. requestTypeName = service.RequestTypeFromInt16(*filters.RequestType).String()
  531. }
  532. var billingType any
  533. if filters.BillingType != nil {
  534. billingType = *filters.BillingType
  535. }
  536. idempotencyPayload := struct {
  537. OperatorID int64 `json:"operator_id"`
  538. Body CreateUsageCleanupTaskRequest `json:"body"`
  539. }{
  540. OperatorID: subject.UserID,
  541. Body: req,
  542. }
  543. executeAdminIdempotentJSON(c, "admin.usage.cleanup_tasks.create", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
  544. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 请求创建清理任务: operator=%d start=%s end=%s user_id=%v api_key_id=%v account_id=%v group_id=%v model=%v request_type=%v stream=%v billing_type=%v tz=%q",
  545. subject.UserID,
  546. filters.StartTime.Format(time.RFC3339),
  547. filters.EndTime.Format(time.RFC3339),
  548. userID,
  549. apiKeyID,
  550. accountID,
  551. groupID,
  552. model,
  553. requestTypeName,
  554. streamValue,
  555. billingType,
  556. req.Timezone,
  557. )
  558. task, err := h.cleanupService.CreateTask(ctx, filters, subject.UserID)
  559. if err != nil {
  560. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 创建清理任务失败: operator=%d err=%v", subject.UserID, err)
  561. return nil, err
  562. }
  563. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 清理任务已创建: task=%d operator=%d status=%s", task.ID, subject.UserID, task.Status)
  564. return dto.UsageCleanupTaskFromService(task), nil
  565. })
  566. }
  567. // CancelCleanupTask handles canceling a usage cleanup task
  568. // POST /api/v1/admin/usage/cleanup-tasks/:id/cancel
  569. func (h *UsageHandler) CancelCleanupTask(c *gin.Context) {
  570. if h.cleanupService == nil {
  571. response.Error(c, http.StatusServiceUnavailable, "Usage cleanup service unavailable")
  572. return
  573. }
  574. subject, ok := middleware.GetAuthSubjectFromContext(c)
  575. if !ok || subject.UserID <= 0 {
  576. response.Unauthorized(c, "Unauthorized")
  577. return
  578. }
  579. idStr := strings.TrimSpace(c.Param("id"))
  580. taskID, err := strconv.ParseInt(idStr, 10, 64)
  581. if err != nil || taskID <= 0 {
  582. response.BadRequest(c, "Invalid task id")
  583. return
  584. }
  585. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 请求取消清理任务: task=%d operator=%d", taskID, subject.UserID)
  586. if err := h.cleanupService.CancelTask(c.Request.Context(), taskID, subject.UserID); err != nil {
  587. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 取消清理任务失败: task=%d operator=%d err=%v", taskID, subject.UserID, err)
  588. response.ErrorFrom(c, err)
  589. return
  590. }
  591. logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 清理任务已取消: task=%d operator=%d", taskID, subject.UserID)
  592. response.Success(c, gin.H{"id": taskID, "status": service.UsageCleanupStatusCanceled})
  593. }