统一任务成功、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
|
|
}
|