batch_image_settlement.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "errors"
  7. "fmt"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. "go.uber.org/zap"
  14. )
  15. const (
  16. batchImageSettlementRequestPrefix = "batch_image_settlement:"
  17. batchImageSettlementRetryDelay = time.Minute
  18. batchImageSettlementMaxRetries = 5
  19. batchImageCostEpsilon = 0.00000001
  20. )
  21. type BatchImagePricingResolver interface {
  22. BatchImageUnitPrice(ctx context.Context, job *BatchImageJob) (float64, error)
  23. }
  24. type BatchImageModelPricingResolver struct {
  25. Resolver *ModelPricingResolver
  26. }
  27. func (r *BatchImageModelPricingResolver) BatchImageUnitPrice(ctx context.Context, job *BatchImageJob) (float64, error) {
  28. if r == nil || r.Resolver == nil || job == nil || strings.TrimSpace(job.Model) == "" {
  29. return 0, ErrBatchImageSettlementPricingMissing
  30. }
  31. resolved := r.Resolver.Resolve(ctx, PricingInput{Model: job.Model})
  32. if resolved == nil {
  33. return 0, ErrBatchImageSettlementPricingMissing
  34. }
  35. switch resolved.Mode {
  36. case BillingModeImage, BillingModePerRequest:
  37. if resolved.DefaultPerRequestPrice > 0 {
  38. return resolved.DefaultPerRequestPrice, nil
  39. }
  40. if len(resolved.RequestTiers) == 1 && resolved.RequestTiers[0].PerRequestPrice != nil && *resolved.RequestTiers[0].PerRequestPrice >= 0 {
  41. return *resolved.RequestTiers[0].PerRequestPrice, nil
  42. }
  43. case BillingModeToken:
  44. if resolved.BasePricing != nil && (resolved.BasePricing.ImageOutputPriceExplicit || resolved.BasePricing.ImageOutputPricePerToken > 0) {
  45. return resolved.BasePricing.ImageOutputPricePerToken, nil
  46. }
  47. }
  48. return 0, ErrBatchImageSettlementPricingMissing
  49. }
  50. type BatchImageSettlementService struct {
  51. Repo BatchImageRepository
  52. BillingRepo UsageBillingRepository
  53. UsageLogRepo UsageLogRepository
  54. Pricing BatchImagePricingResolver
  55. AuthCache APIKeyAuthCacheInvalidator
  56. Config *config.Config
  57. }
  58. type BatchImageSettlementResult struct {
  59. BatchID string
  60. SuccessCount int
  61. FailCount int
  62. ActualCost float64
  63. ManifestHash string
  64. RequestID string
  65. AlreadySettled bool
  66. }
  67. func (s *BatchImageSettlementService) Settle(ctx context.Context, batchID string) (*BatchImageSettlementResult, error) {
  68. if s == nil || s.Repo == nil || s.BillingRepo == nil || s.Pricing == nil {
  69. return nil, ErrBatchImageSettlementBillingFailed.WithCause(errors.New("batch image settlement service is not configured"))
  70. }
  71. job, err := s.Repo.GetBatchImageJobByBatchID(ctx, batchID)
  72. if err != nil {
  73. return nil, err
  74. }
  75. manifestHash := BuildBatchImageSettlementManifestHash(job)
  76. result := &BatchImageSettlementResult{
  77. BatchID: job.BatchID,
  78. SuccessCount: job.SuccessCount,
  79. FailCount: job.FailCount,
  80. ManifestHash: manifestHash,
  81. RequestID: BatchImageCaptureRequestID(job.BatchID),
  82. }
  83. if job.ActualCost != nil {
  84. result.ActualCost = *job.ActualCost
  85. }
  86. if job.Status == BatchImageJobStatusCompleted {
  87. result.AlreadySettled = true
  88. return result, nil
  89. }
  90. if job.Status != BatchImageJobStatusSettling {
  91. return nil, ErrBatchImageSettlementInvalidStatus
  92. }
  93. if job.APIKeyID == nil || *job.APIKeyID <= 0 {
  94. return nil, ErrBatchImageSettlementMissingAPIKeyID
  95. }
  96. if job.AccountID == nil || *job.AccountID <= 0 {
  97. return nil, ErrBatchImageSettlementMissingAccountID
  98. }
  99. // 重试耗尽检查必须先于各类可重复失败的校验(counts/manifest/定价/超冻结),
  100. // 否则这些错误路径会绕过耗尽出口,settling job 无限 requeue、冻结余额永不释放。
  101. if isBatchImageSettlementRetryExhausted(job) {
  102. return nil, s.failExhaustedSettlement(ctx, job, "settlement retry limit reached: "+batchImageDerefString(job.LastErrorCode))
  103. }
  104. if job.SuccessCount < 0 || job.FailCount < 0 || job.ItemCount < 0 || job.SuccessCount+job.FailCount > job.ItemCount {
  105. if failErr := s.recordSettlementFailure(ctx, job, "SETTLEMENT_INVALID_COUNTS",
  106. fmt.Sprintf("success=%d fail=%d item_count=%d", job.SuccessCount, job.FailCount, job.ItemCount)); failErr != nil {
  107. return nil, failErr
  108. }
  109. return nil, ErrBatchImageSettlementInvalidCounts
  110. }
  111. if strings.TrimSpace(batchImageDerefString(job.ManifestHash)) != "" && batchImageDerefString(job.ManifestHash) != manifestHash {
  112. if failErr := s.recordSettlementFailure(ctx, job, "SETTLEMENT_MANIFEST_CONFLICT", "manifest hash conflict"); failErr != nil {
  113. return nil, failErr
  114. }
  115. return nil, ErrBatchImageSettlementManifestConflict
  116. }
  117. unitPrice, err := s.settlementUnitPrice(ctx, job)
  118. if err == nil && unitPrice < 0 {
  119. err = ErrBatchImageSettlementPricingMissing
  120. }
  121. if err != nil {
  122. if failErr := s.recordSettlementFailure(ctx, job, "SETTLEMENT_PRICING_MISSING", err.Error()); failErr != nil {
  123. return nil, failErr
  124. }
  125. return nil, err
  126. }
  127. actualCost := float64(job.SuccessCount) * unitPrice
  128. result.ActualCost = actualCost
  129. holdAmount := job.EstimatedCost
  130. if job.HoldAmount != nil {
  131. holdAmount = *job.HoldAmount
  132. }
  133. if actualCost-holdAmount > batchImageCostEpsilon {
  134. msg := fmt.Sprintf("actual cost %.10f exceeds held amount %.10f", actualCost, holdAmount)
  135. if failErr := s.recordSettlementFailure(ctx, job, "SETTLEMENT_COST_EXCEEDS_HOLD", msg); failErr != nil {
  136. return nil, failErr
  137. }
  138. return nil, ErrBatchImageSettlementCostExceedsHold
  139. }
  140. if err := captureBatchImageBalanceHold(ctx, s.BillingRepo, job, actualCost, manifestHash); err != nil {
  141. msg := truncateBatchImageMessage(err.Error(), batchImageMaxErrorMessageLength)
  142. if failErr := s.recordSettlementFailure(ctx, job, "SETTLEMENT_BILLING_FAILED", msg); failErr != nil {
  143. return nil, failErr
  144. }
  145. return nil, err
  146. }
  147. s.invalidateAuthCache(ctx, job.UserID)
  148. now := time.Now()
  149. outputExpiresAt := now.Add(s.outputRetentionAfterTerminal())
  150. if err := s.Repo.MarkBatchImageJobSettled(ctx, MarkBatchImageJobSettledParams{
  151. BatchID: job.BatchID,
  152. ActualCost: actualCost,
  153. ManifestHash: manifestHash,
  154. Now: &now,
  155. OutputExpiresAt: &outputExpiresAt,
  156. EventPayload: map[string]any{
  157. "batch_id": job.BatchID,
  158. "request_id": result.RequestID,
  159. "success_count": job.SuccessCount,
  160. "fail_count": job.FailCount,
  161. "actual_cost": actualCost,
  162. "manifest_hash": manifestHash,
  163. },
  164. }); err != nil {
  165. return nil, err
  166. }
  167. s.recordUsageLog(ctx, job, actualCost, result.RequestID, now)
  168. return result, nil
  169. }
  170. // isBatchImageSettlementRetryExhausted 判断 settling job 是否已达重试上限。
  171. // 必须覆盖所有 SETTLEMENT_* 失败码(而非仅 SETTLEMENT_BILLING_FAILED),
  172. // 否则 SETTLEMENT_COST_EXCEEDS_HOLD / SETTLEMENT_INVALID_COUNTS 等错误会无限 requeue。
  173. func isBatchImageSettlementRetryExhausted(job *BatchImageJob) bool {
  174. return job != nil &&
  175. job.Status == BatchImageJobStatusSettling &&
  176. job.RetryCount >= batchImageSettlementMaxRetries &&
  177. strings.HasPrefix(batchImageDerefString(job.LastErrorCode), "SETTLEMENT_")
  178. }
  179. // recordSettlementFailure 记录一次结算失败并递增 retry_count。
  180. // 重试达到上限时立即走耗尽出口(释放冻结余额并转 failed);
  181. // 返回非 nil 时调用方应直接返回该错误。
  182. func (s *BatchImageSettlementService) recordSettlementFailure(ctx context.Context, job *BatchImageJob, code, message string) error {
  183. retryCount, recordErr := s.Repo.SetBatchImageJobSettlementFailed(ctx, job.BatchID, code, truncateBatchImageMessage(message, batchImageMaxErrorMessageLength))
  184. if recordErr != nil {
  185. logger.L().Warn("batch_image.settlement_failure_record_failed",
  186. zap.String("batch_id", job.BatchID),
  187. zap.String("code", code),
  188. zap.Error(recordErr),
  189. )
  190. return nil
  191. }
  192. job.RetryCount = retryCount
  193. job.LastErrorCode = &code
  194. if retryCount >= batchImageSettlementMaxRetries {
  195. return s.failExhaustedSettlement(ctx, job, message)
  196. }
  197. return nil
  198. }
  199. func (s *BatchImageSettlementService) failExhaustedSettlement(ctx context.Context, job *BatchImageJob, message string) error {
  200. if s == nil || s.Repo == nil {
  201. return ErrBatchImageSettlementBillingFailed
  202. }
  203. // 释放指纹必须与其余所有释放点(processor/Cancel/recovery)一致地使用 RequestHash:
  204. // 它们共享同一 request id,payloadHash 不同会触发 ErrUsageBillingRequestConflict,
  205. // 导致后续 Cancel/重试永远失败、terminal job 变成毒消息。
  206. if err := releaseBatchImageBalanceHold(ctx, s.BillingRepo, job, batchImageDerefString(job.RequestHash)); err != nil {
  207. msg := truncateBatchImageMessage(err.Error(), batchImageMaxErrorMessageLength)
  208. if _, recordErr := s.Repo.SetBatchImageJobSettlementFailed(ctx, job.BatchID, "SETTLEMENT_RELEASE_FAILED", msg); recordErr != nil {
  209. logger.L().Warn("batch_image.settlement_release_failure_record_failed",
  210. zap.String("batch_id", job.BatchID),
  211. zap.Error(recordErr),
  212. )
  213. }
  214. return ErrBatchImageSettlementBillingFailed.WithCause(err)
  215. }
  216. s.invalidateAuthCache(ctx, job.UserID)
  217. msg := strings.TrimSpace(message)
  218. if msg == "" {
  219. msg = "settlement billing retry limit reached"
  220. }
  221. if err := s.Repo.TransitionBatchImageJobStatus(ctx, job.BatchID, BatchImageJobStatusFailed, BatchImageTransitionOptions{
  222. ErrorCode: batchImageStringPtr("SETTLEMENT_BILLING_RETRY_EXHAUSTED"),
  223. ErrorMessage: batchImageStringPtr(msg),
  224. EventType: "settlement_retry_exhausted",
  225. EventPayload: map[string]any{
  226. "batch_id": job.BatchID,
  227. "retry_count": job.RetryCount,
  228. },
  229. }); err != nil {
  230. return err
  231. }
  232. return ErrBatchImageSettlementBillingFailed
  233. }
  234. func (s *BatchImageSettlementService) recordUsageLog(ctx context.Context, job *BatchImageJob, actualCost float64, requestID string, createdAt time.Time) {
  235. if s == nil || s.UsageLogRepo == nil || job == nil || job.APIKeyID == nil || job.AccountID == nil {
  236. return
  237. }
  238. billingMode := string(BillingModeImage)
  239. accountRateMultiplier := job.AccountRateMultiplier
  240. inboundEndpoint := "/v1/images/batches"
  241. upstreamEndpoint := "vertex:batchPredictionJobs"
  242. imageSize := "1K"
  243. usageLog := &UsageLog{
  244. UserID: job.UserID,
  245. APIKeyID: *job.APIKeyID,
  246. AccountID: *job.AccountID,
  247. RequestID: strings.TrimSpace(requestID),
  248. Model: job.Model,
  249. RequestedModel: job.Model,
  250. InboundEndpoint: &inboundEndpoint,
  251. UpstreamEndpoint: &upstreamEndpoint,
  252. ImageCount: job.SuccessCount,
  253. ImageOutputCost: actualCost,
  254. TotalCost: actualCost,
  255. ActualCost: actualCost,
  256. RateMultiplier: job.GroupRateMultiplier * job.BatchDiscountMultiplier,
  257. AccountRateMultiplier: &accountRateMultiplier,
  258. BillingType: BillingTypeBalance,
  259. RequestType: RequestTypeSync,
  260. BillingMode: &billingMode,
  261. ImageSize: &imageSize,
  262. SessionID: job.SessionID,
  263. CreatedAt: createdAt,
  264. }
  265. writeUsageLogBestEffort(ctx, s.UsageLogRepo, usageLog, "service.batch_image_settlement")
  266. }
  267. func (s *BatchImageSettlementService) invalidateAuthCache(ctx context.Context, userID int64) {
  268. if s != nil && s.AuthCache != nil && userID > 0 {
  269. s.AuthCache.InvalidateAuthCacheByUserID(ctx, userID)
  270. }
  271. }
  272. func (s *BatchImageSettlementService) settlementUnitPrice(ctx context.Context, job *BatchImageJob) (float64, error) {
  273. if job != nil && job.PricingSnapshotVersion >= 1 {
  274. if job.BillableUnitPrice < 0 {
  275. return 0, ErrBatchImageSettlementPricingMissing
  276. }
  277. return job.BillableUnitPrice, nil
  278. }
  279. unitPrice, err := s.Pricing.BatchImageUnitPrice(ctx, job)
  280. if err != nil {
  281. return 0, err
  282. }
  283. return unitPrice, nil
  284. }
  285. func (s *BatchImageSettlementService) outputRetentionAfterTerminal() time.Duration {
  286. if s != nil && s.Config != nil && s.Config.BatchImage.OutputRetentionAfterTerminalHours > 0 {
  287. return time.Duration(s.Config.BatchImage.OutputRetentionAfterTerminalHours) * time.Hour
  288. }
  289. return 72 * time.Hour
  290. }
  291. func BatchImageSettlementRequestID(batchID string) string {
  292. return batchImageSettlementRequestPrefix + strings.TrimSpace(batchID)
  293. }
  294. func BuildBatchImageSettlementManifestHash(job *BatchImageJob) string {
  295. if job == nil {
  296. return ""
  297. }
  298. parts := []string{
  299. strings.TrimSpace(job.BatchID),
  300. strings.TrimSpace(job.Provider),
  301. strings.TrimSpace(job.Model),
  302. batchImageDerefString(job.ProviderJobName),
  303. batchImageDerefString(job.ProviderOutputRef),
  304. strconv.Itoa(job.SuccessCount),
  305. strconv.Itoa(job.FailCount),
  306. strconv.Itoa(job.ItemCount),
  307. }
  308. sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
  309. return hex.EncodeToString(sum[:])
  310. }
  311. type BatchImagePipelineProcessor struct {
  312. ProviderProcessor *BatchImageProviderProcessor
  313. SettlementService *BatchImageSettlementService
  314. RetryDelay time.Duration
  315. }
  316. func (p *BatchImagePipelineProcessor) Process(ctx context.Context, batchID string) (BatchImageProcessResult, error) {
  317. if p == nil || p.ProviderProcessor == nil {
  318. return BatchImageProcessResult{}, errors.New("batch image pipeline processor is not configured")
  319. }
  320. job, err := p.ProviderProcessor.Repo.GetBatchImageJobByBatchID(ctx, batchID)
  321. if err != nil {
  322. return BatchImageProcessResult{}, err
  323. }
  324. if job.Status == BatchImageJobStatusSettling {
  325. if p.SettlementService == nil {
  326. return BatchImageProcessResult{Terminal: true}, nil
  327. }
  328. _, err := p.SettlementService.Settle(ctx, batchID)
  329. if err != nil {
  330. if errors.Is(err, ErrBatchImageSettlementBillingFailed) {
  331. updated, getErr := p.ProviderProcessor.Repo.GetBatchImageJobByBatchID(ctx, batchID)
  332. if getErr == nil && IsTerminalBatchImageJobStatus(updated.Status) {
  333. return BatchImageProcessResult{Terminal: true}, nil
  334. }
  335. delay := p.RetryDelay
  336. if delay <= 0 {
  337. delay = batchImageSettlementRetryDelay
  338. }
  339. return BatchImageProcessResult{RequeueAfter: delay}, nil
  340. }
  341. return BatchImageProcessResult{}, err
  342. }
  343. return BatchImageProcessResult{Terminal: true}, nil
  344. }
  345. return p.ProviderProcessor.Process(ctx, batchID)
  346. }
  347. func (r *BatchImageSettlementResult) String() string {
  348. if r == nil {
  349. return ""
  350. }
  351. return fmt.Sprintf("batch_id=%s success=%d fail=%d actual_cost=%0.10f already_settled=%t",
  352. r.BatchID, r.SuccessCount, r.FailCount, r.ActualCost, r.AlreadySettled)
  353. }