Redis 同步权威 + DB 镜像,不在进程内维护 delta: - 写入点 HasUserPlatformQuotaLimit 守卫:无 limit 跳过 Redis 写与持久化 - 累加 usage 的 Lua 在 flusher_enabled 时 SADD 脏集 billing:upq:dirty - UserPlatformQuotaUsageFlusher 定时 SPOP 脏集 → 批量 HGETALL 读当前窗口 usage 快照 → BatchSnapshotUsage 绝对值 UPSERT 覆盖 DB(去 SELECT FOR UPDATE 行锁) → 失败 SADD 回 / FK(23503)整批丢弃 - flusher 单批 clamp 到 ≤6000,保证一次 flush 只生成一条 UPSERT(单事务原子) - flusher_enabled 默认 false(降级=旧异步直写 DB) 效果:DB 写连接从 O(QPS) 收敛到 O(副本)。 循环依赖:service 层独立 Snapshot/FK 类型,repository adapter 转换 + %w 映射 FK error。 admin reset/upsert 后失效 cache(脏残留被 flusher 当 MISS 跳过)。 健壮性与可观测性: - flusher_enabled=false 时 Start 不注册定时器;flush_interval_ms 非法回退 2s - Readd 回填失败单独计 dirty_lost(不再误记 dirty_readd)并 ALERT;脏集 Readd 补兜底 TTL - 单 tick 达 max batches 上限仍有积压时记 log - admin 失效 cache 失败升级为 ALERT(提示 enforcement 可能延迟至 sentinel TTL) - BatchGet 单条命令失败 / usage 字段损坏均记 log,避免静默以 0 覆写 DB 三态 go vet + 单测/集成测全绿。 已知取舍(默认 flusher_enabled=false 不触发): - FK 整批丢弃牵连同批正常 key(活跃 key 靠下次 SADD+绝对值快照自愈;Redis 仍权威) - admin reset/upsert 直写 DB 与 flusher 异步刷存在覆盖竞态:flusher 持旧快照在途时可能覆盖 admin 刚写值(limit 列不受影响;usage 有 preflight windowExpired 兜底;低频)。彻底消除需 version OCC。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
70 lines
3.5 KiB
Go
70 lines
3.5 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"time"
|
||
)
|
||
|
||
// ErrUserPlatformQuotaNotFound service 层 sentinel:quota 记录不存在。
|
||
// adapter 将 repository.ErrUserPlatformQuotaNotFound 包装为此错误,
|
||
// handler 只需引用 service 包,无需直接依赖 repository 包。
|
||
var ErrUserPlatformQuotaNotFound = errors.New("user platform quota not found")
|
||
|
||
// ErrUserPlatformQuotaFKViolation service 层 sentinel:批量 snapshot UPSERT 时存在
|
||
// user_id 不在 users 表的记录(外键违反)。adapter 负责将 repository 层同名 sentinel 包装为此错误。
|
||
var ErrUserPlatformQuotaFKViolation = errors.New("user platform quota snapshot FK violation")
|
||
|
||
// UserPlatformQuotaSnapshot 是 service 层 flusher 向 DB 写入快照时使用的传输结构。
|
||
// 字段语义与 repository.UserPlatformQuotaSnapshot 完全对应,由 adapter 负责转换。
|
||
type UserPlatformQuotaSnapshot struct {
|
||
UserID int64
|
||
Platform string
|
||
DailyUsageUSD float64
|
||
WeeklyUsageUSD float64
|
||
MonthlyUsageUSD float64
|
||
DailyWindowStart time.Time
|
||
WeeklyWindowStart time.Time
|
||
MonthlyWindowStart time.Time
|
||
}
|
||
|
||
// UserPlatformQuotaRecord service 层传输结构体(与 repository 层解耦)。
|
||
type UserPlatformQuotaRecord struct {
|
||
UserID int64
|
||
Platform string
|
||
DailyLimitUSD *float64
|
||
WeeklyLimitUSD *float64
|
||
MonthlyLimitUSD *float64
|
||
DailyUsageUSD float64
|
||
WeeklyUsageUSD float64
|
||
MonthlyUsageUSD float64
|
||
// 窗口起始时间(可选,用于未来 reset 校验)
|
||
DailyWindowStart *time.Time
|
||
WeeklyWindowStart *time.Time
|
||
MonthlyWindowStart *time.Time
|
||
}
|
||
|
||
// UserPlatformQuotaRepository 定义 service 层所需的 user × platform quota 数据访问端口。
|
||
// repository 包的 userPlatformQuotaRepository 实现此接口。
|
||
type UserPlatformQuotaRepository interface {
|
||
// GetByUserPlatform 查询单条配额记录,未找到时返回 (nil, nil)。
|
||
GetByUserPlatform(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaRecord, error)
|
||
// BulkInsertInitial 幂等批量插入初始配额记录(ON CONFLICT DO NOTHING)。
|
||
BulkInsertInitial(ctx context.Context, records []UserPlatformQuotaRecord) error
|
||
// IncrementUsageWithReset 原子地累加用量,若窗口已过期则先重置再累加。
|
||
IncrementUsageWithReset(ctx context.Context, userID int64, platform string, cost float64, now time.Time) error
|
||
// ListByUser 查询用户的所有平台配额记录。
|
||
ListByUser(ctx context.Context, userID int64) ([]UserPlatformQuotaRecord, error)
|
||
// UpsertForUser 全量替换该用户所有平台限额配置(事务内):
|
||
// 1. 软删除未在 records 中出现的所有 active 行
|
||
// 2. 对 records 中每条:UPDATE 已存在的(含重新激活软删行);UPDATE 未命中时 INSERT
|
||
// 仅改 *_limit_usd + deleted_at + updated_at,保留 *_usage_usd / *_window_start。
|
||
// records 为空时仅执行步骤 1。
|
||
UpsertForUser(ctx context.Context, userID int64, records []UserPlatformQuotaRecord) error
|
||
// ResetExpiredWindow 重置指定窗口("daily"|"weekly"|"monthly")的用量与起始时间。
|
||
// 未命中活跃记录时返回(service-side wrapper of repository.ErrUserPlatformQuotaNotFound)。
|
||
ResetExpiredWindow(ctx context.Context, userID int64, platform string, window string, newStart time.Time) error
|
||
// BatchSnapshotUsage 绝对值覆盖写入整批 usage 快照。FK 违反返回 ErrUserPlatformQuotaFKViolation。
|
||
BatchSnapshotUsage(ctx context.Context, snapshots []UserPlatformQuotaSnapshot, now time.Time) error
|
||
}
|