修复 PR #3768 批量图像 MVP 合并后审计报告中的全部问题: 结算与计费(高危): - 所有 SETTLEMENT_* 失败(超冻结/计数非法/manifest 冲突/定价缺失/扣费失败) 统一计入 retry_count 并在耗尽时释放冻结转 failed,消灭 settling 无限 requeue 导致的冻结余额永久锁死 - 耗尽出口的释放指纹统一为 RequestHash,与 processor/Cancel/recovery 一致; release 遇同 request id 指纹冲突视为幂等成功,治愈历史毒消息 - 管理端校验 hold_multiplier >= discount_multiplier,定价快照对存量脏数据钳制 - 释放前校验 per-job hold claim(dedup+归档表),杜绝幻影释放 索引对账(高危): - provider 输出与提交 custom_id 集对账:未知条目丢弃并记事件, 漏项补 PROVIDER_RESULT_MISSING 失败行,保证 success+fail == item_count 提交与恢复(高危): - 提交前转 uploading 并在 provider.Submit 期间心跳刷新 updated_at; 恢复扫描改为原子复核(FailStaleUnsubmittedBatchImageJob), 消灭慢提交被误杀退款而上游任务照常计费的孤儿场景 - 上游任务创建成功但本地状态推进失败时,尽力取消上游并清理输入 - recovery 释放失败时入队交由 worker releaseTerminalHold 兜底重试 队列与并发(中危): - Enqueue(SetNX+LPush)与 Reserve(BRPop+ZAdd)均改为 Lua 原子脚本, 消灭崩溃窗口导致 job 脱离队列、被 7 天 inflight 键锁死 - 锁冲突按 LockConflictDelay 重新入队(原直接丢弃需等 10 分钟 stale 恢复) - 处理期间心跳:active zset 续期(ZAddXX 防幽灵成员)+ 锁 TTL 续期 - ReplaceBatchImageItemsForJob 增加 indexing 状态守卫,防掉队 worker 重写账目 存量回归(中危): - image-only 定价条目(仅图片价无 token 价)恢复 token 计费 fail-closed, 不再按 $0 计费;图片计费路径不受影响 - 鉴权余额门槛恢复 balance <= 0 语义,MinimumBalanceReserve 不再作硬 403 加固: - ZIP max_items 钳制到管理员上限;Submit 补齐 Platform==Gemini 校验; gemini downloadUri 跟随前做 host 白名单校验 - 批量客户端改用共享 httpclient(拨号/TLS/响应头超时有界) - 审计点名的忽略错误(MarkDownloaded/SettlementFailed/AppendEvent 等)改为记日志
104 lines
3.1 KiB
Go
104 lines
3.1 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
defaultBatchImageBillingRecoveryStaleAfter = 10 * time.Minute
|
|
defaultBatchImageBillingRecoveryLimit = 100
|
|
)
|
|
|
|
type BatchImageBillingRecoveryService struct {
|
|
Repo BatchImageRepository
|
|
Billing UsageBillingRepository
|
|
AuthCache APIKeyAuthCacheInvalidator
|
|
Queue BatchImageQueue
|
|
StaleAfter time.Duration
|
|
Limit int
|
|
}
|
|
|
|
func (s *BatchImageBillingRecoveryService) ReleaseStaleUnsubmittedOnce(ctx context.Context) (int, error) {
|
|
if s == nil || s.Repo == nil || s.Billing == nil {
|
|
return 0, nil
|
|
}
|
|
staleAfter := s.StaleAfter
|
|
if staleAfter <= 0 {
|
|
staleAfter = defaultBatchImageBillingRecoveryStaleAfter
|
|
}
|
|
limit := s.Limit
|
|
if limit <= 0 {
|
|
limit = defaultBatchImageBillingRecoveryLimit
|
|
}
|
|
cutoff := time.Now().Add(-staleAfter)
|
|
jobs, err := s.Repo.ListStaleUnsubmittedBatchImageJobs(ctx, cutoff, limit)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
released := 0
|
|
var lastErr error
|
|
for _, job := range jobs {
|
|
if job == nil {
|
|
continue
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return released, err
|
|
}
|
|
msg := "batch image submission did not reach provider before recovery cutoff"
|
|
// 原子转 failed 并复核 stale 条件:List 与转态之间 job 可能已被慢提交
|
|
// 心跳续期或提交成功(provider_job_name 已写入),此时绝不能退款,
|
|
// 否则上游任务照常产生成本而用户已拿回冻结余额。
|
|
applied, err := s.Repo.FailStaleUnsubmittedBatchImageJob(ctx, job.BatchID, cutoff, "SUBMIT_STALE_BEFORE_PROVIDER", msg)
|
|
if err != nil {
|
|
// applied=true 时 UPDATE 已提交(仅审计事件写入失败):必须继续释放,
|
|
// 否则 job 已转 failed、不再出现在 stale 列表,冻结余额会永久泄漏。
|
|
if !applied {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
logger.L().Warn("batch_image.recovery_fail_event_append_failed",
|
|
zap.String("batch_id", job.BatchID),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
if !applied {
|
|
continue
|
|
}
|
|
job.Status = BatchImageJobStatusFailed
|
|
if err := releaseBatchImageBalanceHold(ctx, s.Billing, job, batchImageDerefString(job.RequestHash)); err != nil {
|
|
// job 已转 failed、不会再进入 stale 列表:必须给释放失败留下
|
|
// 自动重试路径(入队后由 worker 的 releaseTerminalHold 兜底),
|
|
// 否则冻结余额永久泄漏。
|
|
logger.L().Warn("batch_image.recovery_release_failed",
|
|
zap.String("batch_id", job.BatchID),
|
|
zap.Error(err),
|
|
)
|
|
s.enqueueReleaseRetry(ctx, job.BatchID)
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if s.AuthCache != nil && job.UserID > 0 {
|
|
s.AuthCache.InvalidateAuthCacheByUserID(ctx, job.UserID)
|
|
}
|
|
released++
|
|
}
|
|
return released, lastErr
|
|
}
|
|
|
|
func (s *BatchImageBillingRecoveryService) enqueueReleaseRetry(ctx context.Context, batchID string) {
|
|
if s == nil || s.Queue == nil {
|
|
return
|
|
}
|
|
if err := s.Queue.Enqueue(ctx, batchID); err != nil && !errors.Is(err, ErrBatchImageAlreadyQueued) {
|
|
logger.L().Warn("batch_image.recovery_release_retry_enqueue_failed",
|
|
zap.String("batch_id", batchID),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|