Files
easyai-ai-gateway/apps/api/internal/httpapi/billing_settlement_handlers.go
T
chengcheng 5b2b94b1bd feat(billing): 完成异步结算与请求执行闭环
统一任务成功、Attempt 与结算 Outbox 的事务边界,增加多实例安全结算、人工复核、请求幂等与执行租约。钱包决策使用九位精确金额,并通过审计保护约束保留流水事实;同时补充管理接口、指标与 PostgreSQL 集成测试。
2026-07-21 00:25:53 +08:00

146 lines
5.4 KiB
Go

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
}