mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
修复 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 等)改为记日志
355 lines
10 KiB
Go
355 lines
10 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const (
|
|
defaultBatchImageReadyKey = "batch_image:queue:ready"
|
|
defaultBatchImageDelayedKey = "batch_image:queue:delayed"
|
|
defaultBatchImageActiveKey = "batch_image:queue:active"
|
|
defaultBatchImageInflightPrefix = "batch_image:queue:inflight:"
|
|
defaultBatchImageLockPrefix = "batch_image:queue:lock:"
|
|
defaultBatchImageInflightTTL = 7 * 24 * time.Hour
|
|
defaultBatchImageJobLockTTL = 5 * time.Minute
|
|
|
|
// batchImageReservePollInterval 是原子 Reserve 脚本空轮询的间隔。
|
|
// 用轮询替代 BRPop 是为了保证 "弹出 + 写 active" 的原子性。
|
|
batchImageReservePollInterval = time.Second
|
|
)
|
|
|
|
var batchImageMoveDueDelayedScript = redis.NewScript(`
|
|
local jobs = redis.call("ZRANGEBYSCORE", KEYS[1], "-inf", ARGV[1], "LIMIT", 0, ARGV[2])
|
|
for _, job in ipairs(jobs) do
|
|
redis.call("ZREM", KEYS[1], job)
|
|
redis.call("LPUSH", KEYS[2], job)
|
|
end
|
|
return #jobs
|
|
`)
|
|
|
|
var batchImageRecoverStaleActiveScript = redis.NewScript(`
|
|
local jobs = redis.call("ZRANGEBYSCORE", KEYS[1], "-inf", ARGV[1], "LIMIT", 0, ARGV[2])
|
|
for _, job in ipairs(jobs) do
|
|
redis.call("ZREM", KEYS[1], job)
|
|
redis.call("LPUSH", KEYS[2], job)
|
|
end
|
|
return #jobs
|
|
`)
|
|
|
|
var batchImageReleaseLockScript = redis.NewScript(`
|
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
return redis.call("DEL", KEYS[1])
|
|
end
|
|
return 0
|
|
`)
|
|
|
|
var batchImageRefreshLockScript = redis.NewScript(`
|
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
|
end
|
|
return 0
|
|
`)
|
|
|
|
// batchImageReserveScript 原子地从 ready 弹出并写入 active zset。
|
|
// BRPop + ZAdd 两步方案在两步之间进程崩溃时 job 会脱离所有队列结构,
|
|
// 且 inflight 去重键(默认 7 天)会挡住所有重新入队。
|
|
var batchImageReserveScript = redis.NewScript(`
|
|
local job = redis.call("RPOP", KEYS[1])
|
|
if not job then
|
|
return nil
|
|
end
|
|
redis.call("ZADD", KEYS[2], ARGV[1], job)
|
|
return job
|
|
`)
|
|
|
|
// batchImageEnqueueScript 原子地设置 inflight 去重键并推入 ready。
|
|
// SetNX + LPush 两步方案在两步之间进程崩溃时,inflight 键(默认 7 天)
|
|
// 会挡住所有后续入队,而 job 从未进入 ready。
|
|
var batchImageEnqueueScript = redis.NewScript(`
|
|
if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
|
|
redis.call("LPUSH", KEYS[2], ARGV[1])
|
|
return 1
|
|
end
|
|
return 0
|
|
`)
|
|
|
|
type batchImageQueue struct {
|
|
rdb *redis.Client
|
|
readyKey string
|
|
delayedKey string
|
|
activeKey string
|
|
inflightPrefix string
|
|
lockPrefix string
|
|
inflightTTL time.Duration
|
|
lockTTL time.Duration
|
|
}
|
|
|
|
func NewBatchImageQueue(rdb *redis.Client, cfg *config.Config) service.BatchImageQueue {
|
|
return newBatchImageQueueWithOptions(rdb, batchImageQueueOptionsFromConfig(cfg))
|
|
}
|
|
|
|
type batchImageQueueOptions struct {
|
|
ReadyKey string
|
|
DelayedKey string
|
|
ActiveKey string
|
|
InflightPrefix string
|
|
LockPrefix string
|
|
InflightTTL time.Duration
|
|
LockTTL time.Duration
|
|
}
|
|
|
|
func newBatchImageQueueWithOptions(rdb *redis.Client, opts batchImageQueueOptions) *batchImageQueue {
|
|
opts = normalizeBatchImageQueueOptions(opts)
|
|
return &batchImageQueue{
|
|
rdb: rdb,
|
|
readyKey: opts.ReadyKey,
|
|
delayedKey: opts.DelayedKey,
|
|
activeKey: opts.ActiveKey,
|
|
inflightPrefix: opts.InflightPrefix,
|
|
lockPrefix: opts.LockPrefix,
|
|
inflightTTL: opts.InflightTTL,
|
|
lockTTL: opts.LockTTL,
|
|
}
|
|
}
|
|
|
|
func batchImageQueueOptionsFromConfig(cfg *config.Config) batchImageQueueOptions {
|
|
if cfg == nil {
|
|
return batchImageQueueOptions{}
|
|
}
|
|
return batchImageQueueOptions{
|
|
ReadyKey: cfg.BatchImage.QueueReadyKey,
|
|
DelayedKey: cfg.BatchImage.QueueDelayedKey,
|
|
ActiveKey: cfg.BatchImage.QueueActiveKey,
|
|
InflightPrefix: cfg.BatchImage.InflightKeyPrefix,
|
|
LockPrefix: cfg.BatchImage.LockKeyPrefix,
|
|
InflightTTL: time.Duration(cfg.BatchImage.InflightTTLSeconds) * time.Second,
|
|
LockTTL: time.Duration(cfg.BatchImage.JobLockTTLSeconds) * time.Second,
|
|
}
|
|
}
|
|
|
|
func normalizeBatchImageQueueOptions(opts batchImageQueueOptions) batchImageQueueOptions {
|
|
if opts.ReadyKey == "" {
|
|
opts.ReadyKey = defaultBatchImageReadyKey
|
|
}
|
|
if opts.DelayedKey == "" {
|
|
opts.DelayedKey = defaultBatchImageDelayedKey
|
|
}
|
|
if opts.ActiveKey == "" {
|
|
opts.ActiveKey = defaultBatchImageActiveKey
|
|
}
|
|
if opts.InflightPrefix == "" {
|
|
opts.InflightPrefix = defaultBatchImageInflightPrefix
|
|
}
|
|
if opts.LockPrefix == "" {
|
|
opts.LockPrefix = defaultBatchImageLockPrefix
|
|
}
|
|
if opts.InflightTTL <= 0 {
|
|
opts.InflightTTL = defaultBatchImageInflightTTL
|
|
}
|
|
if opts.LockTTL <= 0 {
|
|
opts.LockTTL = defaultBatchImageJobLockTTL
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func (q *batchImageQueue) Enqueue(ctx context.Context, batchID string) error {
|
|
if !service.IsValidBatchImageID(batchID) {
|
|
return service.ErrInvalidBatchImageQueuePayload
|
|
}
|
|
|
|
applied, err := batchImageEnqueueScript.Run(ctx, q.rdb,
|
|
[]string{q.inflightKey(batchID), q.readyKey},
|
|
batchID, q.inflightTTL.Milliseconds(),
|
|
).Int()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if applied == 0 {
|
|
return service.ErrBatchImageAlreadyQueued
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (q *batchImageQueue) Reserve(ctx context.Context, blockTimeout time.Duration) (service.ReservedBatchImageJob, error) {
|
|
deadline := time.Now().Add(blockTimeout)
|
|
for {
|
|
batchID, err := q.reserveOnce(ctx)
|
|
if err == nil {
|
|
return service.ReservedBatchImageJob{BatchID: batchID}, nil
|
|
}
|
|
if !errors.Is(err, service.ErrBatchImageQueueEmpty) {
|
|
return service.ReservedBatchImageJob{}, err
|
|
}
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
return service.ReservedBatchImageJob{}, service.ErrBatchImageQueueEmpty
|
|
}
|
|
wait := batchImageReservePollInterval
|
|
if remaining < wait {
|
|
wait = remaining
|
|
}
|
|
timer := time.NewTimer(wait)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return service.ReservedBatchImageJob{}, ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (q *batchImageQueue) reserveOnce(ctx context.Context) (string, error) {
|
|
raw, err := batchImageReserveScript.Run(ctx, q.rdb, []string{q.readyKey, q.activeKey}, time.Now().UnixMilli()).Result()
|
|
if errors.Is(err, redis.Nil) {
|
|
return "", service.ErrBatchImageQueueEmpty
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
batchID, ok := raw.(string)
|
|
if !ok || !service.IsValidBatchImageID(batchID) {
|
|
// 非法 payload 已被脚本写入 active,必须移除,否则 stale 恢复会把它
|
|
// 无限重投回 ready。
|
|
if ok && batchID != "" {
|
|
_ = q.rdb.ZRem(ctx, q.activeKey, batchID).Err()
|
|
}
|
|
return "", service.ErrInvalidBatchImageQueuePayload
|
|
}
|
|
return batchID, nil
|
|
}
|
|
|
|
func (q *batchImageQueue) RequeueAfter(ctx context.Context, batchID string, delay time.Duration) error {
|
|
if !service.IsValidBatchImageID(batchID) {
|
|
return service.ErrInvalidBatchImageQueuePayload
|
|
}
|
|
pipe := q.rdb.TxPipeline()
|
|
pipe.ZRem(ctx, q.activeKey, batchID)
|
|
pipe.ZRem(ctx, q.delayedKey, batchID)
|
|
if delay <= 0 {
|
|
pipe.LPush(ctx, q.readyKey, batchID)
|
|
} else {
|
|
pipe.ZAdd(ctx, q.delayedKey, redis.Z{
|
|
Score: float64(time.Now().Add(delay).UnixMilli()),
|
|
Member: batchID,
|
|
})
|
|
}
|
|
_, err := pipe.Exec(ctx)
|
|
return err
|
|
}
|
|
|
|
func (q *batchImageQueue) Ack(ctx context.Context, batchID string) error {
|
|
if !service.IsValidBatchImageID(batchID) {
|
|
return service.ErrInvalidBatchImageQueuePayload
|
|
}
|
|
pipe := q.rdb.TxPipeline()
|
|
pipe.ZRem(ctx, q.activeKey, batchID)
|
|
pipe.ZRem(ctx, q.delayedKey, batchID)
|
|
pipe.Del(ctx, q.inflightKey(batchID))
|
|
_, err := pipe.Exec(ctx)
|
|
return err
|
|
}
|
|
|
|
func (q *batchImageQueue) Heartbeat(ctx context.Context, batchID string) error {
|
|
if !service.IsValidBatchImageID(batchID) {
|
|
return service.ErrInvalidBatchImageQueuePayload
|
|
}
|
|
// XX:只刷新已存在的 active 成员。无条件 ZAdd 会在 Ack/Requeue 之后的
|
|
// 竞态心跳里把幽灵成员塞回 active zset。
|
|
return q.rdb.ZAddXX(ctx, q.activeKey, redis.Z{
|
|
Score: float64(time.Now().UnixMilli()),
|
|
Member: batchID,
|
|
}).Err()
|
|
}
|
|
|
|
func (q *batchImageQueue) MoveDueDelayedToReady(ctx context.Context, limit int) (int, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
return batchImageMoveDueDelayedScript.Run(ctx, q.rdb, []string{q.delayedKey, q.readyKey}, time.Now().UnixMilli(), limit).Int()
|
|
}
|
|
|
|
func (q *batchImageQueue) RecoverStaleActive(ctx context.Context, staleAfter time.Duration, limit int) (int, error) {
|
|
if staleAfter <= 0 {
|
|
return 0, service.ErrInvalidBatchImageQueuePayload
|
|
}
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
cutoff := time.Now().Add(-staleAfter).UnixMilli()
|
|
return batchImageRecoverStaleActiveScript.Run(ctx, q.rdb, []string{q.activeKey, q.readyKey}, cutoff, limit).Int()
|
|
}
|
|
|
|
func (q *batchImageQueue) TryAcquireJobLock(ctx context.Context, batchID string, ttl time.Duration) (service.BatchImageJobLock, bool, error) {
|
|
if !service.IsValidBatchImageID(batchID) {
|
|
return nil, false, service.ErrInvalidBatchImageQueuePayload
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = q.lockTTL
|
|
}
|
|
token, err := newBatchImageLockToken()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
key := q.lockKey(batchID)
|
|
ok, err := q.rdb.SetNX(ctx, key, token, ttl).Result()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
return &batchImageRedisJobLock{rdb: q.rdb, key: key, token: token}, true, nil
|
|
}
|
|
|
|
func (q *batchImageQueue) inflightKey(batchID string) string {
|
|
return q.inflightPrefix + batchID
|
|
}
|
|
|
|
func (q *batchImageQueue) lockKey(batchID string) string {
|
|
return q.lockPrefix + batchID
|
|
}
|
|
|
|
type batchImageRedisJobLock struct {
|
|
rdb *redis.Client
|
|
key string
|
|
token string
|
|
}
|
|
|
|
func (l *batchImageRedisJobLock) Release(ctx context.Context) error {
|
|
if l == nil || l.rdb == nil || l.key == "" || l.token == "" {
|
|
return nil
|
|
}
|
|
return batchImageReleaseLockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err()
|
|
}
|
|
|
|
// Refresh 在仍持有锁(token 匹配)时续期 TTL,供长处理任务的心跳调用。
|
|
func (l *batchImageRedisJobLock) Refresh(ctx context.Context, ttl time.Duration) error {
|
|
if l == nil || l.rdb == nil || l.key == "" || l.token == "" {
|
|
return nil
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = defaultBatchImageJobLockTTL
|
|
}
|
|
return batchImageRefreshLockScript.Run(ctx, l.rdb, []string{l.key}, l.token, ttl.Milliseconds()).Err()
|
|
}
|
|
|
|
var _ service.BatchImageJobLockRefresher = (*batchImageRedisJobLock)(nil)
|
|
|
|
func newBatchImageLockToken() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b[:]), nil
|
|
}
|
|
|
|
var _ service.BatchImageQueue = (*batchImageQueue)(nil)
|