统一任务成功、Attempt 与结算 Outbox 的事务边界,增加多实例安全结算、人工复核、请求幂等与执行租约。钱包决策使用九位精确金额,并通过审计保护约束保留流水事实;同时补充管理接口、指标与 PostgreSQL 集成测试。
146 lines
5.4 KiB
Go
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
|
||
}
|