feat(billing): 完成异步结算与请求执行闭环

统一任务成功、Attempt 与结算 Outbox 的事务边界,增加多实例安全结算、人工复核、请求幂等与执行租约。钱包决策使用九位精确金额,并通过审计保护约束保留流水事实;同时补充管理接口、指标与 PostgreSQL 集成测试。
This commit is contained in:
2026-07-21 00:25:53 +08:00
parent 7cea21f765
commit 5b2b94b1bd
33 changed files with 2884 additions and 418 deletions
+40 -4
View File
@@ -917,7 +917,13 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
}
estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user)
if err != nil {
if s.billingMetrics != nil {
s.billingMetrics.ObserveBillingEvent("estimate_failed")
}
if runner.IsPricingUnavailable(err) {
if s.billingMetrics != nil {
s.billingMetrics.ObserveBillingEvent("pricing_unavailable")
}
writeErrorWithDetails(w, http.StatusServiceUnavailable, runErrorMessage(err), runErrorDetails(err), "pricing_unavailable")
return
}
@@ -982,6 +988,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Produce json
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param Idempotency-Key header string false "可选请求幂等键;同一用户范围内唯一"
// @Param input body TaskRequest true "AI 任务请求,字段随任务类型变化"
// @Success 200 {object} CompatibleResponse
// @Success 202 {object} TaskAcceptedResponse
@@ -1050,6 +1057,11 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
return
}
responsePlan := planTaskResponse(kind, compatible, body, r)
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
return
}
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
if err != nil {
s.logger.Warn("prepare task request failed", "kind", kind, "error", err)
@@ -1061,7 +1073,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
return
}
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
createInput := store.CreateTaskInput{
Kind: kind,
Model: model,
RunMode: runModeFromRequest(prepared.Body),
@@ -1070,10 +1082,32 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
ConversationID: prepared.ConversationID,
NewMessageCount: prepared.NewMessageCount,
MessageRefs: prepared.MessageRefs,
}, user)
}
if hasIdempotencyKey {
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(kind, responsePlan.asyncMode, responsePlan.streamMode, prepared.Body)
}
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
if err != nil {
s.logger.Error("create task failed", "kind", kind, "error", err)
writeError(w, http.StatusInternalServerError, "create task failed")
if errors.Is(err, store.ErrIdempotencyKeyReused) {
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
return
}
s.logger.Error("create task failed", "kind", kind, "error_category", "task_create_failed")
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
return
}
task := created.Task
if created.Replayed {
if s.billingMetrics != nil {
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
}
w.Header().Set("Idempotent-Replayed", "true")
if responsePlan.streamMode {
writeError(w, http.StatusConflict, "streaming idempotent replay is not supported", "idempotency_stream_replay_unsupported")
return
}
writeIdempotentTaskReplay(w, task, responsePlan.compatibleMode)
return
}
if responsePlan.asyncMode {
@@ -1388,6 +1422,8 @@ func scopeForTaskKind(kind string) string {
func statusFromRunError(err error) int {
switch {
case clients.ErrorCode(err) == "billing_hold":
return http.StatusServiceUnavailable
case runner.IsPricingUnavailable(err):
return http.StatusServiceUnavailable
case clients.ErrorCode(err) == "invalid_previous_response_id" || clients.ErrorCode(err) == "response_chain_too_deep" || clients.ErrorCode(err) == "unsupported_model_protocol" || clients.ErrorCode(err) == "unsupported_response_tool" || clients.ErrorCode(err) == "unsupported_response_parameter":