| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650 |
- package admin
- import (
- "context"
- "net/http"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/handler/dto"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
- "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- // UsageHandler handles admin usage-related requests
- type UsageHandler struct {
- usageService *service.UsageService
- apiKeyService *service.APIKeyService
- adminService service.AdminService
- cleanupService *service.UsageCleanupService
- }
- // NewUsageHandler creates a new admin usage handler
- func NewUsageHandler(
- usageService *service.UsageService,
- apiKeyService *service.APIKeyService,
- adminService service.AdminService,
- cleanupService *service.UsageCleanupService,
- ) *UsageHandler {
- return &UsageHandler{
- usageService: usageService,
- apiKeyService: apiKeyService,
- adminService: adminService,
- cleanupService: cleanupService,
- }
- }
- // CreateUsageCleanupTaskRequest represents cleanup task creation request
- type CreateUsageCleanupTaskRequest struct {
- StartDate string `json:"start_date"`
- EndDate string `json:"end_date"`
- UserID *int64 `json:"user_id"`
- APIKeyID *int64 `json:"api_key_id"`
- AccountID *int64 `json:"account_id"`
- GroupID *int64 `json:"group_id"`
- Model *string `json:"model"`
- RequestType *string `json:"request_type"`
- Stream *bool `json:"stream"`
- BillingType *int8 `json:"billing_type"`
- Timezone string `json:"timezone"`
- }
- // List handles listing all usage records with filters
- // GET /api/v1/admin/usage
- func (h *UsageHandler) List(c *gin.Context) {
- page, pageSize := response.ParsePagination(c)
- exactTotal := false
- if exactTotalRaw := strings.TrimSpace(c.Query("exact_total")); exactTotalRaw != "" {
- parsed, err := strconv.ParseBool(exactTotalRaw)
- if err != nil {
- response.BadRequest(c, "Invalid exact_total value, use true or false")
- return
- }
- exactTotal = parsed
- }
- // Parse filters
- var userID, apiKeyID, accountID, groupID int64
- if userIDStr := c.Query("user_id"); userIDStr != "" {
- id, err := strconv.ParseInt(userIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid user_id")
- return
- }
- userID = id
- }
- if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
- id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid api_key_id")
- return
- }
- apiKeyID = id
- }
- if accountIDStr := c.Query("account_id"); accountIDStr != "" {
- id, err := strconv.ParseInt(accountIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account_id")
- return
- }
- accountID = id
- }
- if groupIDStr := c.Query("group_id"); groupIDStr != "" {
- id, err := strconv.ParseInt(groupIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group_id")
- return
- }
- groupID = id
- }
- model := c.Query("model")
- requestID := strings.TrimSpace(c.Query("request_id"))
- billingMode := strings.TrimSpace(c.Query("billing_mode"))
- var requestType *int16
- var stream *bool
- if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
- parsed, err := service.ParseUsageRequestType(requestTypeStr)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- value := int16(parsed)
- requestType = &value
- } else if streamStr := c.Query("stream"); streamStr != "" {
- val, err := strconv.ParseBool(streamStr)
- if err != nil {
- response.BadRequest(c, "Invalid stream value, use true or false")
- return
- }
- stream = &val
- }
- nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
- if err != nil {
- response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
- return
- }
- var billingType *int8
- if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
- val, err := strconv.ParseInt(billingTypeStr, 10, 8)
- if err != nil {
- response.BadRequest(c, "Invalid billing_type")
- return
- }
- bt := int8(val)
- billingType = &bt
- }
- var upstreamModelMismatch *bool
- if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
- value, err := strconv.ParseBool(raw)
- if err != nil {
- response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
- return
- }
- upstreamModelMismatch = &value
- }
- // Parse date range
- var startTime, endTime *time.Time
- userTZ := c.Query("timezone") // Get user's timezone from request
- if startDateStr := c.Query("start_date"); startDateStr != "" {
- t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
- return
- }
- startTime = &t
- }
- if endDateStr := c.Query("end_date"); endDateStr != "" {
- t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
- return
- }
- // Use half-open range [start, end), move to next calendar day start (DST-safe).
- t = t.AddDate(0, 0, 1)
- endTime = &t
- }
- params := pagination.PaginationParams{
- Page: page,
- PageSize: pageSize,
- SortBy: c.DefaultQuery("sort_by", "created_at"),
- SortOrder: c.DefaultQuery("sort_order", "desc"),
- }
- filters := usagestats.UsageLogFilters{
- UserID: userID,
- APIKeyID: apiKeyID,
- AccountID: accountID,
- GroupID: groupID,
- RequestID: requestID,
- Model: model,
- ModelFilterSource: usagestats.ModelSourceRequested,
- RequestType: requestType,
- Stream: stream,
- NativeCompactionV2: nativeCompactionV2,
- BillingType: billingType,
- BillingMode: billingMode,
- UpstreamModelMismatch: upstreamModelMismatch,
- StartTime: startTime,
- EndTime: endTime,
- ExactTotal: exactTotal,
- }
- records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- out := make([]dto.AdminUsageLog, 0, len(records))
- for i := range records {
- out = append(out, *dto.UsageLogFromServiceAdmin(&records[i]))
- }
- response.Paginated(c, out, result.Total, page, pageSize)
- }
- // Stats handles getting usage statistics with filters
- // GET /api/v1/admin/usage/stats
- func (h *UsageHandler) Stats(c *gin.Context) {
- // Parse filters - same as List endpoint
- var userID, apiKeyID, accountID, groupID int64
- if userIDStr := c.Query("user_id"); userIDStr != "" {
- id, err := strconv.ParseInt(userIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid user_id")
- return
- }
- userID = id
- }
- if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
- id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid api_key_id")
- return
- }
- apiKeyID = id
- }
- if accountIDStr := c.Query("account_id"); accountIDStr != "" {
- id, err := strconv.ParseInt(accountIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account_id")
- return
- }
- accountID = id
- }
- if groupIDStr := c.Query("group_id"); groupIDStr != "" {
- id, err := strconv.ParseInt(groupIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group_id")
- return
- }
- groupID = id
- }
- model := c.Query("model")
- billingMode := strings.TrimSpace(c.Query("billing_mode"))
- var requestType *int16
- var stream *bool
- if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
- parsed, err := service.ParseUsageRequestType(requestTypeStr)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- value := int16(parsed)
- requestType = &value
- } else if streamStr := c.Query("stream"); streamStr != "" {
- val, err := strconv.ParseBool(streamStr)
- if err != nil {
- response.BadRequest(c, "Invalid stream value, use true or false")
- return
- }
- stream = &val
- }
- nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
- if err != nil {
- response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
- return
- }
- var billingType *int8
- if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
- val, err := strconv.ParseInt(billingTypeStr, 10, 8)
- if err != nil {
- response.BadRequest(c, "Invalid billing_type")
- return
- }
- bt := int8(val)
- billingType = &bt
- }
- var upstreamModelMismatch *bool
- if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
- value, err := strconv.ParseBool(raw)
- if err != nil {
- response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
- return
- }
- upstreamModelMismatch = &value
- }
- // Parse date range
- userTZ := c.Query("timezone")
- now := timezone.NowInUserLocation(userTZ)
- var startTime, endTime time.Time
- startDateStr := c.Query("start_date")
- endDateStr := c.Query("end_date")
- if startDateStr != "" && endDateStr != "" {
- var err error
- startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
- return
- }
- endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
- return
- }
- // 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
- endTime = endTime.AddDate(0, 0, 1)
- } else {
- period := c.DefaultQuery("period", "today")
- switch period {
- case "today":
- startTime = timezone.StartOfDayInUserLocation(now, userTZ)
- case "week":
- startTime = now.AddDate(0, 0, -7)
- case "month":
- startTime = now.AddDate(0, -1, 0)
- default:
- startTime = timezone.StartOfDayInUserLocation(now, userTZ)
- }
- endTime = now
- }
- // Build filters and call GetStatsWithFilters
- filters := usagestats.UsageLogFilters{
- UserID: userID,
- APIKeyID: apiKeyID,
- AccountID: accountID,
- GroupID: groupID,
- Model: model,
- ModelFilterSource: usagestats.ModelSourceRequested,
- RequestType: requestType,
- Stream: stream,
- NativeCompactionV2: nativeCompactionV2,
- BillingType: billingType,
- BillingMode: billingMode,
- UpstreamModelMismatch: upstreamModelMismatch,
- StartTime: &startTime,
- EndTime: &endTime,
- }
- var stats *usagestats.UsageStats
- // nocache: 绕过缓存直接回源,刷新者本人拿最新;不回写缓存(管理台"我刷新我自己拿最新"语义,非全局失效)。
- if parseBoolQueryWithDefault(c.Query("nocache"), false) {
- s, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- stats = s
- c.Header("X-Usage-Stats-Cache", "bypass")
- } else {
- s, hit, err := h.getStatsCached(c.Request.Context(), filters)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- stats = s
- c.Header("X-Usage-Stats-Cache", cacheStatusValue(hit))
- }
- response.Success(c, stats)
- }
- // SearchUsers handles searching users by email keyword
- // GET /api/v1/admin/usage/search-users
- func (h *UsageHandler) SearchUsers(c *gin.Context) {
- keyword := c.Query("q")
- if keyword == "" {
- response.Success(c, []any{})
- return
- }
- // Limit to 30 results
- users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword, IncludeDeleted: true}, "email", "asc")
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // Return simplified user list (only id, email and deleted flag)
- type SimpleUser struct {
- ID int64 `json:"id"`
- Email string `json:"email"`
- Deleted bool `json:"deleted"`
- }
- result := make([]SimpleUser, len(users))
- for i, u := range users {
- result[i] = SimpleUser{
- ID: u.ID,
- Email: u.Email,
- Deleted: u.DeletedAt != nil,
- }
- }
- response.Success(c, result)
- }
- // SearchAPIKeys handles searching API keys by user
- // GET /api/v1/admin/usage/search-api-keys
- func (h *UsageHandler) SearchAPIKeys(c *gin.Context) {
- userIDStr := c.Query("user_id")
- keyword := c.Query("q")
- var userID int64
- if userIDStr != "" {
- id, err := strconv.ParseInt(userIDStr, 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid user_id")
- return
- }
- userID = id
- }
- keys, err := h.apiKeyService.SearchAPIKeys(c.Request.Context(), userID, keyword, 30)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // Return simplified API key list (only id and name)
- type SimpleAPIKey struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- UserID int64 `json:"user_id"`
- }
- result := make([]SimpleAPIKey, len(keys))
- for i, k := range keys {
- result[i] = SimpleAPIKey{
- ID: k.ID,
- Name: k.Name,
- UserID: k.UserID,
- }
- }
- response.Success(c, result)
- }
- // ListCleanupTasks handles listing usage cleanup tasks
- // GET /api/v1/admin/usage/cleanup-tasks
- func (h *UsageHandler) ListCleanupTasks(c *gin.Context) {
- if h.cleanupService == nil {
- response.Error(c, http.StatusServiceUnavailable, "Usage cleanup service unavailable")
- return
- }
- operator := int64(0)
- if subject, ok := middleware.GetAuthSubjectFromContext(c); ok {
- operator = subject.UserID
- }
- page, pageSize := response.ParsePagination(c)
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 请求清理任务列表: operator=%d page=%d page_size=%d", operator, page, pageSize)
- params := pagination.PaginationParams{Page: page, PageSize: pageSize}
- tasks, result, err := h.cleanupService.ListTasks(c.Request.Context(), params)
- if err != nil {
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 查询清理任务列表失败: operator=%d page=%d page_size=%d err=%v", operator, page, pageSize, err)
- response.ErrorFrom(c, err)
- return
- }
- out := make([]dto.UsageCleanupTask, 0, len(tasks))
- for i := range tasks {
- out = append(out, *dto.UsageCleanupTaskFromService(&tasks[i]))
- }
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 返回清理任务列表: operator=%d total=%d items=%d page=%d page_size=%d", operator, result.Total, len(out), page, pageSize)
- response.Paginated(c, out, result.Total, page, pageSize)
- }
- // CreateCleanupTask handles creating a usage cleanup task
- // POST /api/v1/admin/usage/cleanup-tasks
- func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
- if h.cleanupService == nil {
- response.Error(c, http.StatusServiceUnavailable, "Usage cleanup service unavailable")
- return
- }
- subject, ok := middleware.GetAuthSubjectFromContext(c)
- if !ok || subject.UserID <= 0 {
- response.Unauthorized(c, "Unauthorized")
- return
- }
- var req CreateUsageCleanupTaskRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- req.StartDate = strings.TrimSpace(req.StartDate)
- req.EndDate = strings.TrimSpace(req.EndDate)
- if req.StartDate == "" || req.EndDate == "" {
- response.BadRequest(c, "start_date and end_date are required")
- return
- }
- startTime, err := timezone.ParseInUserLocation("2006-01-02", req.StartDate, req.Timezone)
- if err != nil {
- response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
- return
- }
- endTime, err := timezone.ParseInUserLocation("2006-01-02", req.EndDate, req.Timezone)
- if err != nil {
- response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
- return
- }
- endTime = endTime.Add(24*time.Hour - time.Nanosecond)
- var requestType *int16
- stream := req.Stream
- if req.RequestType != nil {
- parsed, err := service.ParseUsageRequestType(*req.RequestType)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- value := int16(parsed)
- requestType = &value
- stream = nil
- }
- filters := service.UsageCleanupFilters{
- StartTime: startTime,
- EndTime: endTime,
- UserID: req.UserID,
- APIKeyID: req.APIKeyID,
- AccountID: req.AccountID,
- GroupID: req.GroupID,
- Model: req.Model,
- RequestType: requestType,
- Stream: stream,
- BillingType: req.BillingType,
- }
- var userID any
- if filters.UserID != nil {
- userID = *filters.UserID
- }
- var apiKeyID any
- if filters.APIKeyID != nil {
- apiKeyID = *filters.APIKeyID
- }
- var accountID any
- if filters.AccountID != nil {
- accountID = *filters.AccountID
- }
- var groupID any
- if filters.GroupID != nil {
- groupID = *filters.GroupID
- }
- var model any
- if filters.Model != nil {
- model = *filters.Model
- }
- var streamValue any
- if filters.Stream != nil {
- streamValue = *filters.Stream
- }
- var requestTypeName any
- if filters.RequestType != nil {
- requestTypeName = service.RequestTypeFromInt16(*filters.RequestType).String()
- }
- var billingType any
- if filters.BillingType != nil {
- billingType = *filters.BillingType
- }
- idempotencyPayload := struct {
- OperatorID int64 `json:"operator_id"`
- Body CreateUsageCleanupTaskRequest `json:"body"`
- }{
- OperatorID: subject.UserID,
- Body: req,
- }
- executeAdminIdempotentJSON(c, "admin.usage.cleanup_tasks.create", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
- 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",
- subject.UserID,
- filters.StartTime.Format(time.RFC3339),
- filters.EndTime.Format(time.RFC3339),
- userID,
- apiKeyID,
- accountID,
- groupID,
- model,
- requestTypeName,
- streamValue,
- billingType,
- req.Timezone,
- )
- task, err := h.cleanupService.CreateTask(ctx, filters, subject.UserID)
- if err != nil {
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 创建清理任务失败: operator=%d err=%v", subject.UserID, err)
- return nil, err
- }
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 清理任务已创建: task=%d operator=%d status=%s", task.ID, subject.UserID, task.Status)
- return dto.UsageCleanupTaskFromService(task), nil
- })
- }
- // CancelCleanupTask handles canceling a usage cleanup task
- // POST /api/v1/admin/usage/cleanup-tasks/:id/cancel
- func (h *UsageHandler) CancelCleanupTask(c *gin.Context) {
- if h.cleanupService == nil {
- response.Error(c, http.StatusServiceUnavailable, "Usage cleanup service unavailable")
- return
- }
- subject, ok := middleware.GetAuthSubjectFromContext(c)
- if !ok || subject.UserID <= 0 {
- response.Unauthorized(c, "Unauthorized")
- return
- }
- idStr := strings.TrimSpace(c.Param("id"))
- taskID, err := strconv.ParseInt(idStr, 10, 64)
- if err != nil || taskID <= 0 {
- response.BadRequest(c, "Invalid task id")
- return
- }
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 请求取消清理任务: task=%d operator=%d", taskID, subject.UserID)
- if err := h.cleanupService.CancelTask(c.Request.Context(), taskID, subject.UserID); err != nil {
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 取消清理任务失败: task=%d operator=%d err=%v", taskID, subject.UserID, err)
- response.ErrorFrom(c, err)
- return
- }
- logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 清理任务已取消: task=%d operator=%d", taskID, subject.UserID)
- response.Success(c, gin.H{"id": taskID, "status": service.UsageCleanupStatusCanceled})
- }
|