package httpapi import ( "crypto/sha256" "encoding/hex" "errors" "net/http" "strings" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" "github.com/google/uuid" ) // listBillingSettlements godoc // @Summary 查询计费结算队列 // @Description 管理端分页查询待结算、重试失败和人工复核记录。 // @Tags billing // @Produce json // @Security BearerAuth // @Param status query string false "结算状态" // @Param action query string false "动作:settle 或 release" // @Param page query int false "页码" default(1) // @Param pageSize query int false "每页数量" default(50) // @Success 200 {object} BillingSettlementListResponse // @Failure 400 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope // @Router /api/admin/runtime/billing-settlements [get] func (s *Server) listBillingSettlements(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() page, err := positiveQueryInt(query.Get("page"), 1) if err != nil { writeError(w, http.StatusBadRequest, "invalid page", "invalid_request") return } pageSize, err := positiveQueryInt(query.Get("pageSize"), 50) if err != nil { writeError(w, http.StatusBadRequest, "invalid pageSize", "invalid_request") return } result, err := s.store.ListBillingSettlements(r.Context(), store.BillingSettlementListFilter{ Status: query.Get("status"), Action: query.Get("action"), Page: page, PageSize: pageSize, }) if err != nil { s.logger.Error("list billing settlements failed", "error_category", "billing_settlement_list_failed") writeError(w, http.StatusInternalServerError, "list billing settlements failed", "billing_settlement_list_failed") return } writeJSON(w, http.StatusOK, result) } // retryBillingSettlement godoc // @Summary 重试计费结算 // @Description Manager 使用单值 Idempotency-Key 将重试失败或人工复核记录重新放入队列,并记录审计日志。 // @Tags billing // @Produce json // @Security BearerAuth // @Param settlementId path string true "结算记录 ID" // @Param Idempotency-Key header string true "幂等键" // @Success 200 {object} BillingSettlementRetryResponse // @Failure 400 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope // @Failure 409 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope // @Router /api/admin/runtime/billing-settlements/{settlementId}/retry [post] func (s *Server) retryBillingSettlement(w http.ResponseWriter, r *http.Request) { key, ok := singleIdempotencyKey(r) if !ok { writeError(w, http.StatusBadRequest, "a single Idempotency-Key is required", "idempotency_key_required") return } keyHash := sha256.Sum256([]byte(key)) settlementID := strings.TrimSpace(r.PathValue("settlementId")) if _, err := uuid.Parse(settlementID); err != nil { writeError(w, http.StatusBadRequest, "invalid billing settlement ID", "invalid_request") return } actor, _ := auth.UserFromContext(r.Context()) var item store.BillingSettlement var audit store.AuditLog var replayed bool err := s.store.InTx(r.Context(), func(tx store.Tx) error { var err error item, replayed, err = s.store.RetryBillingSettlementTx(r.Context(), tx, settlementID, hex.EncodeToString(keyHash[:])) if err != nil || replayed { return err } audit, err = s.store.RecordAuditLogTx(r.Context(), tx, billingSettlementRetryAuditInput(r, actor, item)) return err }) if err != nil { switch { case store.IsNotFound(err): writeError(w, http.StatusNotFound, "billing settlement not found", "billing_settlement_not_found") case errors.Is(err, store.ErrBillingSettlementNotRetryable): writeError(w, http.StatusConflict, "billing settlement is not retryable", "billing_settlement_not_retryable") default: s.logger.Error("retry billing settlement failed", "settlementID", settlementID, "error_category", "billing_settlement_retry_failed") writeError(w, http.StatusInternalServerError, "retry billing settlement failed", "billing_settlement_retry_failed") } return } if replayed { w.Header().Set("Idempotent-Replayed", "true") } response := map[string]any{"settlement": item} if !replayed { response["auditLog"] = audit } writeJSON(w, http.StatusOK, response) } func singleIdempotencyKey(r *http.Request) (string, bool) { values := r.Header.Values("Idempotency-Key") if len(values) != 1 { return "", false } value := strings.TrimSpace(values[0]) if value == "" || len(value) > 255 || strings.Contains(value, ",") { return "", false } return value, true } func billingSettlementRetryAuditInput(r *http.Request, actor *auth.User, item store.BillingSettlement) store.AuditLogInput { input := store.AuditLogInput{ Category: "billing", Action: "billing.settlement.retry", TargetType: "billing_settlement", TargetID: item.ID, RequestIP: requestIP(r), UserAgent: r.UserAgent(), AfterState: map[string]any{"status": item.Status, "attempts": item.Attempts}, Metadata: map[string]any{"taskId": item.TaskID, "action": item.Action, "currency": item.Currency}, } if actor != nil { input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID)) input.ActorUserID = actor.ID input.ActorUsername = actor.Username input.ActorSource = actor.Source input.ActorRoles = actor.Roles } return input }