usage_billing_repo.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "strings"
  7. dbent "github.com/Wei-Shaw/sub2api/ent"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. )
  11. type usageBillingRepository struct {
  12. db *sql.DB
  13. }
  14. func NewUsageBillingRepository(_ *dbent.Client, sqlDB *sql.DB) service.UsageBillingRepository {
  15. return &usageBillingRepository{db: sqlDB}
  16. }
  17. func (r *usageBillingRepository) Apply(ctx context.Context, cmd *service.UsageBillingCommand) (_ *service.UsageBillingApplyResult, err error) {
  18. if cmd == nil {
  19. return &service.UsageBillingApplyResult{}, nil
  20. }
  21. if r == nil || r.db == nil {
  22. return nil, errors.New("usage billing repository db is nil")
  23. }
  24. cmd.Normalize()
  25. if cmd.RequestID == "" {
  26. return nil, service.ErrUsageBillingRequestIDRequired
  27. }
  28. tx, err := r.db.BeginTx(ctx, nil)
  29. if err != nil {
  30. return nil, err
  31. }
  32. defer func() {
  33. if tx != nil {
  34. _ = tx.Rollback()
  35. }
  36. }()
  37. applied, err := r.claimUsageBillingKey(ctx, tx, cmd)
  38. if err != nil {
  39. return nil, err
  40. }
  41. if !applied {
  42. return &service.UsageBillingApplyResult{Applied: false}, nil
  43. }
  44. result := &service.UsageBillingApplyResult{Applied: true}
  45. if err := r.applyUsageBillingEffects(ctx, tx, cmd, result); err != nil {
  46. return nil, err
  47. }
  48. if err := tx.Commit(); err != nil {
  49. return nil, err
  50. }
  51. tx = nil
  52. return result, nil
  53. }
  54. func (r *usageBillingRepository) claimUsageBillingKey(ctx context.Context, tx *sql.Tx, cmd *service.UsageBillingCommand) (bool, error) {
  55. return r.claimUsageBillingRequest(ctx, tx, cmd.RequestID, cmd.APIKeyID, cmd.RequestFingerprint)
  56. }
  57. func (r *usageBillingRepository) claimUsageBillingRequest(ctx context.Context, tx *sql.Tx, requestID string, apiKeyID int64, requestFingerprint string) (bool, error) {
  58. var id int64
  59. err := tx.QueryRowContext(ctx, `
  60. INSERT INTO usage_billing_dedup (request_id, api_key_id, request_fingerprint)
  61. VALUES ($1, $2, $3)
  62. ON CONFLICT (request_id, api_key_id) DO NOTHING
  63. RETURNING id
  64. `, requestID, apiKeyID, requestFingerprint).Scan(&id)
  65. if errors.Is(err, sql.ErrNoRows) {
  66. var existingFingerprint string
  67. if err := tx.QueryRowContext(ctx, `
  68. SELECT request_fingerprint
  69. FROM usage_billing_dedup
  70. WHERE request_id = $1 AND api_key_id = $2
  71. `, requestID, apiKeyID).Scan(&existingFingerprint); err != nil {
  72. return false, err
  73. }
  74. if strings.TrimSpace(existingFingerprint) != strings.TrimSpace(requestFingerprint) {
  75. return false, service.ErrUsageBillingRequestConflict
  76. }
  77. return false, nil
  78. }
  79. if err != nil {
  80. return false, err
  81. }
  82. var archivedFingerprint string
  83. err = tx.QueryRowContext(ctx, `
  84. SELECT request_fingerprint
  85. FROM usage_billing_dedup_archive
  86. WHERE request_id = $1 AND api_key_id = $2
  87. `, requestID, apiKeyID).Scan(&archivedFingerprint)
  88. if err == nil {
  89. if strings.TrimSpace(archivedFingerprint) != strings.TrimSpace(requestFingerprint) {
  90. return false, service.ErrUsageBillingRequestConflict
  91. }
  92. return false, nil
  93. }
  94. if !errors.Is(err, sql.ErrNoRows) {
  95. return false, err
  96. }
  97. return true, nil
  98. }
  99. func (r *usageBillingRepository) ReserveBatchImageBalance(ctx context.Context, cmd *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error) {
  100. return r.applyBatchImageBalanceHold(ctx, cmd, reserveUsageBillingBatchImageBalance)
  101. }
  102. func (r *usageBillingRepository) CaptureBatchImageBalance(ctx context.Context, cmd *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error) {
  103. return r.applyBatchImageBalanceHold(ctx, cmd, captureUsageBillingBatchImageBalance)
  104. }
  105. func (r *usageBillingRepository) ReleaseBatchImageBalance(ctx context.Context, cmd *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error) {
  106. return r.applyBatchImageBalanceHold(ctx, cmd, releaseUsageBillingBatchImageBalance)
  107. }
  108. func (r *usageBillingRepository) applyBatchImageBalanceHold(
  109. ctx context.Context,
  110. cmd *service.BatchImageBalanceHoldCommand,
  111. apply func(context.Context, *sql.Tx, *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error),
  112. ) (_ *service.BatchImageBalanceHoldResult, err error) {
  113. if cmd == nil {
  114. return &service.BatchImageBalanceHoldResult{}, nil
  115. }
  116. if r == nil || r.db == nil {
  117. return nil, errors.New("usage billing repository db is nil")
  118. }
  119. cmd.Normalize()
  120. if cmd.RequestID == "" {
  121. return nil, service.ErrUsageBillingRequestIDRequired
  122. }
  123. tx, err := r.db.BeginTx(ctx, nil)
  124. if err != nil {
  125. return nil, err
  126. }
  127. defer func() {
  128. if tx != nil {
  129. _ = tx.Rollback()
  130. }
  131. }()
  132. applied, err := r.claimUsageBillingRequest(ctx, tx, cmd.RequestID, cmd.APIKeyID, cmd.RequestFingerprint)
  133. if err != nil {
  134. return nil, err
  135. }
  136. if !applied {
  137. return &service.BatchImageBalanceHoldResult{Applied: false}, nil
  138. }
  139. result, err := apply(ctx, tx, cmd)
  140. if err != nil {
  141. return nil, err
  142. }
  143. if result == nil {
  144. result = &service.BatchImageBalanceHoldResult{}
  145. }
  146. result.Applied = true
  147. if err := tx.Commit(); err != nil {
  148. return nil, err
  149. }
  150. tx = nil
  151. return result, nil
  152. }
  153. func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, tx *sql.Tx, cmd *service.UsageBillingCommand, result *service.UsageBillingApplyResult) error {
  154. if cmd.SubscriptionCost > 0 && cmd.SubscriptionID != nil {
  155. if err := incrementUsageBillingSubscription(ctx, tx, *cmd.SubscriptionID, cmd.SubscriptionCost); err != nil {
  156. return err
  157. }
  158. }
  159. if cmd.BalanceCost > 0 {
  160. newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost)
  161. if err != nil {
  162. return err
  163. }
  164. result.NewBalance = &newBalance
  165. result.BalanceOverdrafted = !sufficient
  166. }
  167. if cmd.APIKeyQuotaCost > 0 {
  168. exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost)
  169. if err != nil {
  170. return err
  171. }
  172. result.APIKeyQuotaExhausted = exhausted
  173. }
  174. if cmd.APIKeyRateLimitCost > 0 {
  175. if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil {
  176. return err
  177. }
  178. }
  179. if cmd.AccountQuotaCost > 0 && (strings.EqualFold(cmd.AccountType, service.AccountTypeAPIKey) || strings.EqualFold(cmd.AccountType, service.AccountTypeBedrock)) {
  180. quotaState, err := incrementUsageBillingAccountQuota(ctx, tx, cmd.AccountID, cmd.AccountQuotaCost)
  181. if err != nil {
  182. return err
  183. }
  184. result.QuotaState = quotaState
  185. }
  186. return nil
  187. }
  188. func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscriptionID int64, costUSD float64) error {
  189. const updateSQL = `
  190. UPDATE user_subscriptions us
  191. SET
  192. daily_usage_usd = us.daily_usage_usd + $1,
  193. weekly_usage_usd = us.weekly_usage_usd + $1,
  194. monthly_usage_usd = us.monthly_usage_usd + $1,
  195. updated_at = NOW()
  196. FROM groups g
  197. WHERE us.id = $2
  198. AND us.deleted_at IS NULL
  199. AND us.group_id = g.id
  200. AND g.deleted_at IS NULL
  201. `
  202. res, err := tx.ExecContext(ctx, updateSQL, costUSD, subscriptionID)
  203. if err != nil {
  204. return err
  205. }
  206. affected, err := res.RowsAffected()
  207. if err != nil {
  208. return err
  209. }
  210. if affected > 0 {
  211. return nil
  212. }
  213. return service.ErrSubscriptionNotFound
  214. }
  215. func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, bool, error) {
  216. var newBalance float64
  217. err := tx.QueryRowContext(ctx, `
  218. UPDATE users
  219. SET balance = balance - $1,
  220. updated_at = NOW()
  221. WHERE id = $2 AND deleted_at IS NULL AND balance >= $1
  222. RETURNING balance
  223. `, amount, userID).Scan(&newBalance)
  224. if err == nil {
  225. return newBalance, true, nil
  226. }
  227. if !errors.Is(err, sql.ErrNoRows) {
  228. return 0, false, err
  229. }
  230. err = tx.QueryRowContext(ctx, `
  231. UPDATE users
  232. SET balance = balance - $1,
  233. updated_at = NOW()
  234. WHERE id = $2 AND deleted_at IS NULL
  235. RETURNING balance
  236. `, amount, userID).Scan(&newBalance)
  237. if errors.Is(err, sql.ErrNoRows) {
  238. return 0, false, service.ErrUserNotFound
  239. }
  240. if err != nil {
  241. return 0, false, err
  242. }
  243. return newBalance, false, nil
  244. }
  245. func reserveUsageBillingBatchImageBalance(ctx context.Context, tx *sql.Tx, cmd *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error) {
  246. if cmd.HoldAmount <= 0 {
  247. return &service.BatchImageBalanceHoldResult{}, nil
  248. }
  249. var balance, frozen float64
  250. err := tx.QueryRowContext(ctx, `
  251. UPDATE users
  252. SET balance = balance - $1,
  253. frozen_balance = COALESCE(frozen_balance, 0) + $1,
  254. updated_at = NOW()
  255. WHERE id = $2 AND deleted_at IS NULL AND balance >= $1
  256. RETURNING balance, frozen_balance
  257. `, cmd.HoldAmount, cmd.UserID).Scan(&balance, &frozen)
  258. if err == nil {
  259. return &service.BatchImageBalanceHoldResult{NewBalance: &balance, FrozenBalance: &frozen}, nil
  260. }
  261. if !errors.Is(err, sql.ErrNoRows) {
  262. return nil, err
  263. }
  264. if exists, existsErr := userExistsForBilling(ctx, tx, cmd.UserID); existsErr != nil {
  265. return nil, existsErr
  266. } else if !exists {
  267. return nil, service.ErrUserNotFound
  268. }
  269. return nil, service.ErrBatchImageInsufficientBalance
  270. }
  271. func captureUsageBillingBatchImageBalance(ctx context.Context, tx *sql.Tx, cmd *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error) {
  272. if cmd.HoldAmount <= 0 && cmd.ActualAmount <= 0 {
  273. return &service.BatchImageBalanceHoldResult{}, nil
  274. }
  275. if cmd.ActualAmount-cmd.HoldAmount > 0.00000001 {
  276. return nil, service.ErrBatchImageSettlementCostExceedsHold
  277. }
  278. var balance, frozen float64
  279. err := tx.QueryRowContext(ctx, `
  280. UPDATE users
  281. SET balance = balance
  282. + CASE WHEN $1 > $2 THEN $1 - $2 ELSE 0 END
  283. - CASE WHEN $2 > $1 THEN $2 - $1 ELSE 0 END,
  284. frozen_balance = COALESCE(frozen_balance, 0) - $1,
  285. updated_at = NOW()
  286. WHERE id = $3 AND deleted_at IS NULL AND COALESCE(frozen_balance, 0) >= $1
  287. RETURNING balance, frozen_balance
  288. `, cmd.HoldAmount, cmd.ActualAmount, cmd.UserID).Scan(&balance, &frozen)
  289. if err == nil {
  290. return &service.BatchImageBalanceHoldResult{NewBalance: &balance, FrozenBalance: &frozen}, nil
  291. }
  292. if !errors.Is(err, sql.ErrNoRows) {
  293. return nil, err
  294. }
  295. if exists, existsErr := userExistsForBilling(ctx, tx, cmd.UserID); existsErr != nil {
  296. return nil, existsErr
  297. } else if !exists {
  298. return nil, service.ErrUserNotFound
  299. }
  300. return nil, errors.New("batch image frozen balance is insufficient")
  301. }
  302. func releaseUsageBillingBatchImageBalance(ctx context.Context, tx *sql.Tx, cmd *service.BatchImageBalanceHoldCommand) (*service.BatchImageBalanceHoldResult, error) {
  303. if cmd.HoldAmount <= 0 {
  304. return &service.BatchImageBalanceHoldResult{}, nil
  305. }
  306. // 释放前校验该 job 确实预留过 hold(hold request id 已被 claim),
  307. // 防止从未成功冻结的 job 触发"幻影释放",从其他用户的冻结资金池中凭空生成余额。
  308. held, heldErr := batchImageHoldClaimExists(ctx, tx, service.BatchImageHoldRequestID(cmd.BatchID), cmd.APIKeyID)
  309. if heldErr != nil {
  310. return nil, heldErr
  311. }
  312. if !held {
  313. logger.LegacyPrintf("repository.usage_billing", "[BatchImage] release skipped, hold was never reserved: batch=%s", cmd.BatchID)
  314. return &service.BatchImageBalanceHoldResult{}, nil
  315. }
  316. var balance, frozen float64
  317. err := tx.QueryRowContext(ctx, `
  318. UPDATE users
  319. SET balance = balance + $1,
  320. frozen_balance = COALESCE(frozen_balance, 0) - $1,
  321. updated_at = NOW()
  322. WHERE id = $2 AND deleted_at IS NULL AND COALESCE(frozen_balance, 0) >= $1
  323. RETURNING balance, frozen_balance
  324. `, cmd.HoldAmount, cmd.UserID).Scan(&balance, &frozen)
  325. if err == nil {
  326. return &service.BatchImageBalanceHoldResult{NewBalance: &balance, FrozenBalance: &frozen}, nil
  327. }
  328. if !errors.Is(err, sql.ErrNoRows) {
  329. return nil, err
  330. }
  331. if exists, existsErr := userExistsForBilling(ctx, tx, cmd.UserID); existsErr != nil {
  332. return nil, existsErr
  333. } else if !exists {
  334. return nil, service.ErrUserNotFound
  335. }
  336. return nil, errors.New("batch image frozen balance is insufficient")
  337. }
  338. // batchImageHoldClaimExists 检查 hold request id 是否已在 dedup(或归档)表中被 claim,
  339. // 即该 batch 的冻结操作确实成功提交过。
  340. func batchImageHoldClaimExists(ctx context.Context, tx *sql.Tx, holdRequestID string, apiKeyID int64) (bool, error) {
  341. var exists int
  342. err := tx.QueryRowContext(ctx, `
  343. SELECT 1
  344. FROM usage_billing_dedup
  345. WHERE request_id = $1 AND api_key_id = $2
  346. `, holdRequestID, apiKeyID).Scan(&exists)
  347. if err == nil {
  348. return true, nil
  349. }
  350. if !errors.Is(err, sql.ErrNoRows) {
  351. return false, err
  352. }
  353. err = tx.QueryRowContext(ctx, `
  354. SELECT 1
  355. FROM usage_billing_dedup_archive
  356. WHERE request_id = $1 AND api_key_id = $2
  357. `, holdRequestID, apiKeyID).Scan(&exists)
  358. if err == nil {
  359. return true, nil
  360. }
  361. if errors.Is(err, sql.ErrNoRows) {
  362. return false, nil
  363. }
  364. return false, err
  365. }
  366. func userExistsForBilling(ctx context.Context, tx *sql.Tx, userID int64) (bool, error) {
  367. var exists int
  368. err := tx.QueryRowContext(ctx, `
  369. SELECT 1
  370. FROM users
  371. WHERE id = $1 AND deleted_at IS NULL
  372. `, userID).Scan(&exists)
  373. if errors.Is(err, sql.ErrNoRows) {
  374. return false, nil
  375. }
  376. if err != nil {
  377. return false, err
  378. }
  379. return true, nil
  380. }
  381. func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID int64, amount float64) (bool, error) {
  382. var exhausted bool
  383. err := tx.QueryRowContext(ctx, `
  384. UPDATE api_keys
  385. SET quota_used = quota_used + $1,
  386. status = CASE
  387. WHEN quota > 0
  388. AND status = $3
  389. AND quota_used < quota
  390. AND quota_used + $1 >= quota
  391. THEN $4
  392. ELSE status
  393. END,
  394. updated_at = NOW()
  395. WHERE id = $2 AND deleted_at IS NULL
  396. RETURNING quota > 0 AND quota_used >= quota AND quota_used - $1 < quota
  397. `, amount, apiKeyID, service.StatusAPIKeyActive, service.StatusAPIKeyQuotaExhausted).Scan(&exhausted)
  398. if errors.Is(err, sql.ErrNoRows) {
  399. return false, service.ErrAPIKeyNotFound
  400. }
  401. if err != nil {
  402. return false, err
  403. }
  404. return exhausted, nil
  405. }
  406. func incrementUsageBillingAPIKeyRateLimit(ctx context.Context, tx *sql.Tx, apiKeyID int64, cost float64) error {
  407. res, err := tx.ExecContext(ctx, `
  408. UPDATE api_keys SET
  409. usage_5h = CASE WHEN window_5h_start IS NOT NULL AND window_5h_start + INTERVAL '5 hours' <= NOW() THEN $1 ELSE usage_5h + $1 END,
  410. usage_1d = CASE WHEN window_1d_start IS NOT NULL AND window_1d_start + INTERVAL '24 hours' <= NOW() THEN $1 ELSE usage_1d + $1 END,
  411. usage_7d = CASE WHEN window_7d_start IS NOT NULL AND window_7d_start + INTERVAL '7 days' <= NOW() THEN $1 ELSE usage_7d + $1 END,
  412. window_5h_start = CASE WHEN window_5h_start IS NULL OR window_5h_start + INTERVAL '5 hours' <= NOW() THEN NOW() ELSE window_5h_start END,
  413. window_1d_start = CASE WHEN window_1d_start IS NULL OR window_1d_start + INTERVAL '24 hours' <= NOW() THEN date_trunc('day', NOW()) ELSE window_1d_start END,
  414. window_7d_start = CASE WHEN window_7d_start IS NULL OR window_7d_start + INTERVAL '7 days' <= NOW() THEN date_trunc('day', NOW()) ELSE window_7d_start END,
  415. updated_at = NOW()
  416. WHERE id = $2 AND deleted_at IS NULL
  417. `, cost, apiKeyID)
  418. if err != nil {
  419. return err
  420. }
  421. affected, err := res.RowsAffected()
  422. if err != nil {
  423. return err
  424. }
  425. if affected == 0 {
  426. return service.ErrAPIKeyNotFound
  427. }
  428. return nil
  429. }
  430. func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountID int64, amount float64) (*service.AccountQuotaState, error) {
  431. rows, err := tx.QueryContext(ctx,
  432. `UPDATE accounts SET extra = (
  433. COALESCE(extra, '{}'::jsonb)
  434. || jsonb_build_object('quota_used', COALESCE((extra->>'quota_used')::numeric, 0) + $1)
  435. || CASE WHEN COALESCE((extra->>'quota_daily_limit')::numeric, 0) > 0 THEN
  436. jsonb_build_object(
  437. 'quota_daily_used',
  438. CASE WHEN `+dailyExpiredExpr+`
  439. THEN $1
  440. ELSE COALESCE((extra->>'quota_daily_used')::numeric, 0) + $1 END,
  441. 'quota_daily_start',
  442. CASE WHEN `+dailyExpiredExpr+`
  443. THEN `+nowUTC+`
  444. ELSE COALESCE(extra->>'quota_daily_start', `+nowUTC+`) END
  445. )
  446. || CASE WHEN `+dailyExpiredExpr+` AND `+nextDailyResetAtExpr+` IS NOT NULL
  447. THEN jsonb_build_object('quota_daily_reset_at', `+nextDailyResetAtExpr+`)
  448. ELSE '{}'::jsonb END
  449. ELSE '{}'::jsonb END
  450. || CASE WHEN COALESCE((extra->>'quota_weekly_limit')::numeric, 0) > 0 THEN
  451. jsonb_build_object(
  452. 'quota_weekly_used',
  453. CASE WHEN `+weeklyExpiredExpr+`
  454. THEN $1
  455. ELSE COALESCE((extra->>'quota_weekly_used')::numeric, 0) + $1 END,
  456. 'quota_weekly_start',
  457. CASE WHEN `+weeklyExpiredExpr+`
  458. THEN `+nowUTC+`
  459. ELSE COALESCE(extra->>'quota_weekly_start', `+nowUTC+`) END
  460. )
  461. || CASE WHEN `+weeklyExpiredExpr+` AND `+nextWeeklyResetAtExpr+` IS NOT NULL
  462. THEN jsonb_build_object('quota_weekly_reset_at', `+nextWeeklyResetAtExpr+`)
  463. ELSE '{}'::jsonb END
  464. ELSE '{}'::jsonb END
  465. ), updated_at = NOW()
  466. WHERE id = $2 AND deleted_at IS NULL
  467. RETURNING
  468. COALESCE((extra->>'quota_used')::numeric, 0),
  469. COALESCE((extra->>'quota_limit')::numeric, 0),
  470. COALESCE((extra->>'quota_daily_used')::numeric, 0),
  471. COALESCE((extra->>'quota_daily_limit')::numeric, 0),
  472. COALESCE((extra->>'quota_weekly_used')::numeric, 0),
  473. COALESCE((extra->>'quota_weekly_limit')::numeric, 0)`,
  474. amount, accountID)
  475. if err != nil {
  476. return nil, err
  477. }
  478. var state service.AccountQuotaState
  479. if rows.Next() {
  480. if err := rows.Scan(
  481. &state.TotalUsed, &state.TotalLimit,
  482. &state.DailyUsed, &state.DailyLimit,
  483. &state.WeeklyUsed, &state.WeeklyLimit,
  484. ); err != nil {
  485. _ = rows.Close()
  486. return nil, err
  487. }
  488. } else {
  489. if err := rows.Err(); err != nil {
  490. _ = rows.Close()
  491. return nil, err
  492. }
  493. _ = rows.Close()
  494. return nil, service.ErrAccountNotFound
  495. }
  496. if err := rows.Err(); err != nil {
  497. _ = rows.Close()
  498. return nil, err
  499. }
  500. // 必须在执行下一条 SQL 前显式关闭 rows:pq 驱动在同一连接上
  501. // 不允许前一条查询的结果集未耗尽时启动新查询,否则会返回
  502. // "unexpected Parse response" 错误。
  503. if err := rows.Close(); err != nil {
  504. return nil, err
  505. }
  506. // 任意维度额度在本次递增中从"未超"跨越到"已超"时,必须刷新调度快照,
  507. // 否则 Redis 中缓存的 Account 仍显示旧的 used 值,后续请求会继续选中本账号,
  508. // 最终观察到 daily_used / weekly_used 大幅超过配置的 limit。
  509. // 对于日/周额度,即使本次触发了周期重置(pre=0、post=amount),
  510. // 判定式 (post-amount) < limit 同样成立,逻辑与总额度保持一致。
  511. crossedTotal := state.TotalLimit > 0 && state.TotalUsed >= state.TotalLimit && (state.TotalUsed-amount) < state.TotalLimit
  512. crossedDaily := state.DailyLimit > 0 && state.DailyUsed >= state.DailyLimit && (state.DailyUsed-amount) < state.DailyLimit
  513. crossedWeekly := state.WeeklyLimit > 0 && state.WeeklyUsed >= state.WeeklyLimit && (state.WeeklyUsed-amount) < state.WeeklyLimit
  514. if crossedTotal || crossedDaily || crossedWeekly {
  515. if err := enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventAccountChanged, &accountID, nil, nil); err != nil {
  516. logger.LegacyPrintf("repository.usage_billing", "[SchedulerOutbox] enqueue quota exceeded failed: account=%d err=%v", accountID, err)
  517. return nil, err
  518. }
  519. }
  520. return &state, nil
  521. }