- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
641 lines
23 KiB
Go
641 lines
23 KiB
Go
// Package service 承载业务逻辑层,编排 dto、mysqldao、redisdao 与 pkg 能力。
|
||
//
|
||
// 约定:
|
||
// - 每个业务域一个 Service 结构体,NewXxxService() 构造,内部持有所需 DAO。
|
||
// - 方法统一返回 (*dto.XxxResponse, *errcode.Error),将底层 error 映射为语义化错误。
|
||
// - redis 未命中返回 redis.Nil,按缓存未命中处理,不视为错误。
|
||
// - 所有"我的"资源操作在本层做归属校验(AuthZ)。
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||
"github.com/go-sql-driver/mysql"
|
||
"github.com/google/uuid"
|
||
"github.com/redis/go-redis/v9"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// isNotFound 判断 gorm 是否为「记录不存在」。
|
||
func isNotFound(err error) bool {
|
||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||
}
|
||
|
||
// isCacheMiss 判断 redis 是否为「未命中」。
|
||
func isCacheMiss(err error) bool {
|
||
return errors.Is(err, redis.Nil)
|
||
}
|
||
|
||
// isDuplicateKey 判断是否为 MySQL 唯一键冲突(Error 1062),用于幂等发放/回退。
|
||
func isDuplicateKey(err error) bool {
|
||
var mysqlErr *mysql.MySQLError
|
||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||
}
|
||
|
||
// 分页默认值。
|
||
const (
|
||
defaultPage = 1
|
||
defaultPageSize = 10
|
||
maxPageSize = 50
|
||
)
|
||
|
||
// normalizePage 规整分页参数,返回可直接用于 DAO 的 page/pageSize。
|
||
func normalizePage(page, pageSize int) (int, int) {
|
||
if page <= 0 {
|
||
page = defaultPage
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = defaultPageSize
|
||
}
|
||
if pageSize > maxPageSize {
|
||
pageSize = maxPageSize
|
||
}
|
||
return page, pageSize
|
||
}
|
||
|
||
// nowUnix 当前秒级时间戳。
|
||
func nowUnix() int64 {
|
||
return time.Now().Unix()
|
||
}
|
||
|
||
// todayStatDate 返回本地时区今日的 YYYYMMDD 整数。
|
||
func todayStatDate() int64 {
|
||
now := time.Now()
|
||
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||
}
|
||
|
||
// genUUID 生成新的 UUID 字符串。
|
||
func genUUID() string {
|
||
return uuid.NewString()
|
||
}
|
||
|
||
// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。
|
||
// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。
|
||
func recordDailyUsage(ctx context.Context, userID int64, kind int8, delta int64) {
|
||
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), kind, delta); err != nil {
|
||
slog.WarnContext(ctx, "adjust daily usage failed", "user_id", userID, "kind", kind, "delta", delta, "error", err)
|
||
}
|
||
}
|
||
|
||
// ---- 新额度体系:每日额度 + 凭证有效期 ----
|
||
|
||
// EffectiveQuota 某类额度的有效额度概览。
|
||
// Cos:Total=永久总额度、Used=永久累计已用、Remain=永久剩余;
|
||
// AI/Outfit/Closet:Total=每日总额度、Used=当日已用、Remain=当日剩余。
|
||
type EffectiveQuota struct {
|
||
Total int64
|
||
Used int64
|
||
Remain int64
|
||
}
|
||
|
||
// getUserVIPExpire 读取用户 VIP 到期时间(秒);用户不存在或查询失败返回 0(视为非会员)。
|
||
func getUserVIPExpire(ctx context.Context, userID int64) int64 {
|
||
user, err := mysqldao.NewUserDao().UserGetByID(ctx, userID)
|
||
if err != nil {
|
||
// 查询失败一律视为非会员(跟随 VIP 的凭证将按失效处理),必须留痕便于排查额度差异。
|
||
if !isNotFound(err) {
|
||
slog.WarnContext(ctx, "get user vip expire failed, treat as non-vip", "user_id", userID, "error", err)
|
||
}
|
||
return 0
|
||
}
|
||
return user.VIPExpire
|
||
}
|
||
|
||
// grantExpired 判断凭证是否已失效:expire_time=0 表示跟随 VIP,其余按到期时间判断。
|
||
func grantExpired(expireTime, vipExpire, now int64) bool {
|
||
if expireTime == 0 {
|
||
return vipExpire <= now
|
||
}
|
||
return expireTime <= now
|
||
}
|
||
|
||
// listValidGrants 查询用户某类型当前有效的额度凭证。
|
||
func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpire, now int64) ([]mysqlmodel.QuotaGrant, error) {
|
||
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, userID, quotaType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
valid := make([]mysqlmodel.QuotaGrant, 0, len(grants))
|
||
for _, g := range grants {
|
||
if !grantExpired(g.ExpireTime, vipExpire, now) {
|
||
valid = append(valid, g)
|
||
}
|
||
}
|
||
return valid, nil
|
||
}
|
||
|
||
// pickEffectiveMemberGrant 在有效凭证中选出会员包来源(source=1)的生效份。
|
||
// 会员包额度不叠加:月度包与年度包同时持有时只取一份计入有效额度,避免额度翻倍。
|
||
//
|
||
// 规则:排除已用尽(Amount-Used<=0)的份,保证旧包用完后新购包能自动接续、不浪费已购额度;
|
||
// 候选中按 Amount 降序(取额度最大的一份)→ Used 降序(保留正在使用的一份,避免购买新包后
|
||
// 剩余额度抖动)→ ID 降序(新购优先)取第一条;无候选返回 nil,表示会员包贡献为 0。
|
||
//
|
||
// 纯函数、无副作用,调用方需保证 grants 为「当前有效」的凭证集合;
|
||
// getEffectiveQuota / consumeCosQuota / cosRemainTx / refundCosQuotaTx 共用该规则,保证展示、扣减、回退口径一致。
|
||
func pickEffectiveMemberGrant(grants []mysqlmodel.QuotaGrant) *mysqlmodel.QuotaGrant {
|
||
var picked *mysqlmodel.QuotaGrant
|
||
for i := range grants {
|
||
g := &grants[i]
|
||
if g.Source != QuotaGrantSourceMember || g.Amount-g.Used <= 0 {
|
||
continue
|
||
}
|
||
if picked == nil || memberGrantBetter(g, picked) {
|
||
picked = g
|
||
}
|
||
}
|
||
return picked
|
||
}
|
||
|
||
// memberGrantBetter 报告 a 是否应优先于 b:Amount 降序 → Used 降序 → ID 降序。
|
||
func memberGrantBetter(a, b *mysqlmodel.QuotaGrant) bool {
|
||
switch {
|
||
case a.Amount != b.Amount:
|
||
return a.Amount > b.Amount
|
||
case a.Used != b.Used:
|
||
return a.Used > b.Used
|
||
default:
|
||
return a.ID > b.ID
|
||
}
|
||
}
|
||
|
||
// pickMemberGrantForRefund 选择会员包来源(source=1)的回退目标份(跳过 handled 中已处理的份)。
|
||
// 会员包额度不叠加,回退需与展示口径一致:
|
||
// 1. 存在「当前生效份」(未用尽且 used>0)时优先回退它,保证剩余额度即时回升、账实一致;
|
||
// 2. 生效份无可退额度(used=0)时,退而选择 used>0 的份中 memberGrantBetter 最优的一条,
|
||
// 用于复活刚被扣至用尽的一份(例如消费恰好把生效份用尽后回退)。
|
||
func pickMemberGrantForRefund(grants []mysqlmodel.QuotaGrant, handled map[int64]bool) *mysqlmodel.QuotaGrant {
|
||
candidates := make([]mysqlmodel.QuotaGrant, 0, len(grants))
|
||
for i := range grants {
|
||
g := grants[i]
|
||
if g.Source != QuotaGrantSourceMember || g.Used <= 0 || handled[g.ID] {
|
||
continue
|
||
}
|
||
candidates = append(candidates, g)
|
||
}
|
||
if len(candidates) == 0 {
|
||
return nil
|
||
}
|
||
if effective := pickEffectiveMemberGrant(candidates); effective != nil {
|
||
return effective
|
||
}
|
||
picked := &candidates[0]
|
||
for i := 1; i < len(candidates); i++ {
|
||
if memberGrantBetter(&candidates[i], picked) {
|
||
picked = &candidates[i]
|
||
}
|
||
}
|
||
return picked
|
||
}
|
||
|
||
// getEffectiveQuota 返回某类额度的有效额度概览(Cos 永久 / 每日额度)。
|
||
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) {
|
||
now := nowUnix()
|
||
vipExpire := getUserVIPExpire(ctx, userID)
|
||
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "get effective quota: list valid grants failed",
|
||
"user_id", userID, "quota_type", quotaType, "error", err)
|
||
return nil, errcode.DatabaseError
|
||
}
|
||
return effectiveQuotaFromGrants(ctx, userID, quotaType, grants), nil
|
||
}
|
||
|
||
// effectiveQuotaFromGrants 基于有效凭证快照聚合某类额度的有效额度概览(免费部分从库读取)。
|
||
// 有效总额度 = 免费配置值 + 会员包生效份(source=1,不叠加只取一份)+ 其他来源凭证 SUM;
|
||
// 已用按类型分别取永久累计或当日计数。
|
||
func effectiveQuotaFromGrants(ctx context.Context, userID int64, quotaType int8, grants []mysqlmodel.QuotaGrant) *EffectiveQuota {
|
||
freeTotal := defaultQuotaValue(quotaType)
|
||
|
||
// 会员包来源不叠加:只取生效的一份;加量包/广告/体验等其他来源仍叠加求和。
|
||
var grantTotal, grantUsed int64
|
||
for i := range grants {
|
||
if grants[i].Source == QuotaGrantSourceMember {
|
||
continue
|
||
}
|
||
grantTotal += grants[i].Amount
|
||
grantUsed += grants[i].Used
|
||
}
|
||
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
|
||
grantTotal += memberGrant.Amount
|
||
grantUsed += memberGrant.Used
|
||
}
|
||
|
||
if quotaType == QuotaTypeCos {
|
||
// 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。
|
||
freeUsed := int64(0)
|
||
if q, qerr := mysqldao.NewQuotaDao().QuotaGetByUserIDAndType(ctx, userID, quotaType); qerr == nil {
|
||
freeUsed = q.Used
|
||
} else if !isNotFound(qerr) {
|
||
slog.WarnContext(ctx, "get effective quota: query free quota failed, treat as 0",
|
||
"user_id", userID, "quota_type", quotaType, "error", qerr)
|
||
}
|
||
total := freeTotal + grantTotal
|
||
used := freeUsed + grantUsed
|
||
remain := total - used
|
||
if remain < 0 {
|
||
remain = 0
|
||
}
|
||
return &EffectiveQuota{Total: total, Used: used, Remain: remain}
|
||
}
|
||
|
||
// 每日额度:免费与赠送合并为每日总额度,已用为当日计数。
|
||
total := freeTotal + grantTotal
|
||
used := getDailyUsed(ctx, userID, quotaType)
|
||
remain := total - used
|
||
if remain < 0 {
|
||
remain = 0
|
||
}
|
||
return &EffectiveQuota{Total: total, Used: used, Remain: remain}
|
||
}
|
||
|
||
// effectiveQuotaRemainExcluding 计算「排除指定凭证(按 ID)」后的某类有效剩余,供退款回收差值记账使用。
|
||
// 聚合口径与 getEffectiveQuota 完全一致(会员包不叠加只取一份,其他来源叠加),
|
||
// 保证「退款回收」流水的变更量等于用户真实可感知的剩余变化。
|
||
func effectiveQuotaRemainExcluding(ctx context.Context, userID int64, quotaType int8, excluded map[int64]bool) (int64, error) {
|
||
now := nowUnix()
|
||
vipExpire := getUserVIPExpire(ctx, userID)
|
||
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
kept := make([]mysqlmodel.QuotaGrant, 0, len(grants))
|
||
for i := range grants {
|
||
if excluded[grants[i].ID] {
|
||
continue
|
||
}
|
||
kept = append(kept, grants[i])
|
||
}
|
||
return effectiveQuotaFromGrants(ctx, userID, quotaType, kept).Remain, nil
|
||
}
|
||
|
||
// dailyQuotaKey 按额度类型构造当日计数 Redis key。
|
||
func dailyQuotaKey(quotaType int8, userID int64, date int64) string {
|
||
switch quotaType {
|
||
case QuotaTypeAI:
|
||
return fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date)
|
||
case QuotaTypeOutfitShare:
|
||
return fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date)
|
||
case QuotaTypeClosetShare:
|
||
return fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// getDailyUsed 读取 MySQL 权威当日计数;数据库异常时只把 Redis 作为展示兜底。
|
||
func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 {
|
||
usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
|
||
if err == nil {
|
||
switch quotaType {
|
||
case QuotaTypeAI:
|
||
return usage.AIRecommendCount
|
||
case QuotaTypeOutfitShare:
|
||
return usage.OutfitShareCount
|
||
case QuotaTypeClosetShare:
|
||
return usage.ClosetShareCount
|
||
}
|
||
}
|
||
key := dailyQuotaKey(quotaType, userID, todayStatDate())
|
||
if val, rerr := redisdao.DailyQuotaValue(ctx, key); rerr == nil {
|
||
return val
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。
|
||
func getDailyUsedFromMySQL(ctx context.Context, userID int64, quotaType int8) int64 {
|
||
usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
switch quotaType {
|
||
case QuotaTypeAI:
|
||
return usage.AIRecommendCount
|
||
case QuotaTypeOutfitShare:
|
||
return usage.OutfitShareCount
|
||
case QuotaTypeClosetShare:
|
||
return usage.ClosetShareCount
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// consumeDailyQuota 原子消费 AI/Outfit/Closet 当日额度。
|
||
// 额度不足返回 errcode.QuotaInsufficient;Redis 异常返回 errcode.CacheError。
|
||
func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error {
|
||
quota, qerr := getEffectiveQuota(ctx, userID, quotaType)
|
||
if qerr != nil {
|
||
return qerr
|
||
}
|
||
key := dailyQuotaKey(quotaType, userID, todayStatDate())
|
||
if key == "" {
|
||
return errcode.QuotaInsufficient
|
||
}
|
||
consumed, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageConsume(ctx, userID, todayStatDate(), quotaType, n, quota.Total)
|
||
if err != nil {
|
||
return errcode.DatabaseError
|
||
}
|
||
if !consumed {
|
||
return errcode.QuotaInsufficient
|
||
}
|
||
// Redis 只作缓存;删除后下次从 MySQL 权威计数恢复,不受丢键影响。
|
||
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
|
||
slog.WarnContext(ctx, "delete daily quota cache failed", "user_id", userID, "quota_type", quotaType, "error", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// refundDailyQuota 业务失败时回退当日已用计数(best-effort,保证不小于 0)。
|
||
func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64) {
|
||
key := dailyQuotaKey(quotaType, userID, todayStatDate())
|
||
if key == "" {
|
||
return
|
||
}
|
||
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), quotaType, -n); err != nil {
|
||
slog.WarnContext(ctx, "refund daily quota failed", "user_id", userID, "quota_type", quotaType, "error", err)
|
||
return
|
||
}
|
||
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
|
||
slog.WarnContext(ctx, "delete daily quota cache after refund failed", "user_id", userID, "quota_type", quotaType, "error", err)
|
||
}
|
||
}
|
||
|
||
// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣会员包生效份,最后扣免费额度),事务内保证数值与流水一致。
|
||
// 会员包来源(source=1)不叠加:多份会员包凭证中只扣生效的一份,未生效的份不参与扣减。
|
||
func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) *errcode.Error {
|
||
now := nowUnix()
|
||
vipExpire := getUserVIPExpire(ctx, userID)
|
||
|
||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 1. 有效凭证(expire_time>now,或跟随 VIP 且 VIP 有效),按先到期先用锁定。
|
||
var grants []mysqlmodel.QuotaGrant
|
||
q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
|
||
if vipExpire > now {
|
||
q = q.Where("(expire_time > ? OR expire_time = 0)", now)
|
||
} else {
|
||
q = q.Where("expire_time > ?", now)
|
||
}
|
||
if err := q.Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC").
|
||
Order("expire_time ASC").
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Find(&grants).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
// consumeGrant 扣减单份凭证的已用(条件更新防并发超扣,扣不动则整单回滚)。
|
||
consumeGrant := func(g *mysqlmodel.QuotaGrant, want int64) (int64, error) {
|
||
delta := g.Amount - g.Used
|
||
if delta <= 0 {
|
||
return 0, nil
|
||
}
|
||
if delta > want {
|
||
delta = want
|
||
}
|
||
res := tx.Model(&mysqlmodel.QuotaGrant{}).
|
||
Where("id = ? AND used + ? <= amount", g.ID, delta).
|
||
UpdateColumn("used", gorm.Expr("used + ?", delta))
|
||
if res.Error != nil {
|
||
return 0, res.Error
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
// 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度,
|
||
// 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。
|
||
return 0, errcode.QuotaInsufficient
|
||
}
|
||
return delta, nil
|
||
}
|
||
|
||
remain := n
|
||
// 1.1 先扣非会员包凭证(加量包/广告/体验,先到期先用)。
|
||
for i := range grants {
|
||
if remain <= 0 {
|
||
break
|
||
}
|
||
if grants[i].Source == QuotaGrantSourceMember {
|
||
continue
|
||
}
|
||
delta, cerr := consumeGrant(&grants[i], remain)
|
||
if cerr != nil {
|
||
return cerr
|
||
}
|
||
remain -= delta
|
||
}
|
||
// 1.2 会员包额度不叠加:只扣生效的一份,未生效的份不参与扣减。
|
||
if remain > 0 {
|
||
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
|
||
delta, cerr := consumeGrant(memberGrant, remain)
|
||
if cerr != nil {
|
||
return cerr
|
||
}
|
||
remain -= delta
|
||
}
|
||
}
|
||
|
||
// 2. 凭证不够再扣免费额度(免费上限以配置值为准,避免历史 user_quotas.total 污染)。
|
||
if remain > 0 {
|
||
freeLimit := defaultQuotaValue(QuotaTypeCos)
|
||
res := tx.Model(&mysqlmodel.Quota{}).
|
||
Where("user_id = ? AND quota_type = ? AND used + ? <= ?", userID, QuotaTypeCos, remain, freeLimit).
|
||
UpdateColumn("used", gorm.Expr("used + ?", remain))
|
||
if res.Error != nil {
|
||
return res.Error
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return errcode.QuotaInsufficient
|
||
}
|
||
}
|
||
|
||
// 3. 写消费流水(old/new 记录 Cos 有效剩余)。
|
||
before := cosRemainTx(ctx, tx, userID, vipExpire, now)
|
||
flow := &mysqlmodel.QuotaFlowLog{
|
||
QuotaFlowUUID: genUUID(),
|
||
UserID: userID,
|
||
ChangeTarget: QuotaChangeTargetUse,
|
||
ChangeType: QuotaChangeTypeConsume,
|
||
ChangeNum: -n,
|
||
OldValue: before + n,
|
||
NewValue: before,
|
||
QuotaType: QuotaTypeCos,
|
||
RelatedID: relatedID,
|
||
Reason: reason,
|
||
}
|
||
// 消费类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。
|
||
flow.OrderNo = flow.QuotaFlowUUID
|
||
return tx.Create(flow).Error
|
||
})
|
||
|
||
if err != nil {
|
||
if errors.Is(err, errcode.QuotaInsufficient) {
|
||
return errcode.QuotaInsufficient
|
||
}
|
||
slog.ErrorContext(ctx, "consume cos quota: transaction failed",
|
||
"user_id", userID, "amount", n, "related_id", relatedID, "error", err)
|
||
return errcode.DatabaseError
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
|
||
// 会员包来源不叠加:只计生效的一份,与 getEffectiveQuota 展示口径保持一致。
|
||
func cosRemainTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
|
||
freeRemain := int64(0)
|
||
var freeQ mysqlmodel.Quota
|
||
if err := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos).First(&freeQ).Error; err == nil {
|
||
freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used
|
||
}
|
||
|
||
var grants []mysqlmodel.QuotaGrant
|
||
q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
|
||
if vipExpire > now {
|
||
q = q.Where("(expire_time > ? OR expire_time = 0)", now)
|
||
} else {
|
||
q = q.Where("expire_time > ?", now)
|
||
}
|
||
if err := q.Find(&grants).Error; err != nil {
|
||
slog.WarnContext(ctx, "calc cos grant remain failed", "user_id", userID, "error", err)
|
||
}
|
||
|
||
var grantRemain int64
|
||
for i := range grants {
|
||
if grants[i].Source == QuotaGrantSourceMember {
|
||
continue
|
||
}
|
||
if r := grants[i].Amount - grants[i].Used; r > 0 {
|
||
grantRemain += r
|
||
}
|
||
}
|
||
if memberGrant := pickEffectiveMemberGrant(grants); memberGrant != nil {
|
||
if r := memberGrant.Amount - memberGrant.Used; r > 0 {
|
||
grantRemain += r
|
||
}
|
||
}
|
||
if freeRemain < 0 {
|
||
freeRemain = 0
|
||
}
|
||
return freeRemain + grantRemain
|
||
}
|
||
|
||
// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
|
||
// 回退策略(后进先出):优先回退免费额度(最后扣减的),再回退非会员包凭证(最后到期优先),
|
||
// 最后回退会员包凭证(不叠加口径:优先当前生效份,生效份无可退额度时复活刚被扣尽的份)。
|
||
func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
|
||
now := nowUnix()
|
||
|
||
remain := n
|
||
// 1. 优先回退免费额度(used >= n 时整体回退,否则不满足则跳过)。
|
||
res := tx.Model(&mysqlmodel.Quota{}).
|
||
Where("user_id = ? AND quota_type = ? AND used >= ?", userID, QuotaTypeCos, remain).
|
||
UpdateColumn("used", gorm.Expr("used - ?", remain))
|
||
if res.Error != nil {
|
||
return res.Error
|
||
}
|
||
if res.RowsAffected > 0 {
|
||
remain = 0
|
||
}
|
||
|
||
// 2. 免费额度不足,先回退非会员包凭证(加量包/广告/体验,按到期时间倒序,最后到期的优先回退)。
|
||
if remain > 0 {
|
||
var grants []mysqlmodel.QuotaGrant
|
||
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source <> ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
|
||
Order("expire_time DESC").
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Find(&grants).Error; err != nil {
|
||
return err
|
||
}
|
||
for i := range grants {
|
||
if remain <= 0 {
|
||
break
|
||
}
|
||
delta := grants[i].Used
|
||
if delta > remain {
|
||
delta = remain
|
||
}
|
||
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
|
||
Where("id = ? AND used >= ?", grants[i].ID, delta).
|
||
UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil {
|
||
return err
|
||
}
|
||
remain -= delta
|
||
}
|
||
}
|
||
|
||
// 3. 再回退会员包凭证(不叠加口径:按回退选择规则逐份回退,未生效份不作为回退目标)。
|
||
if remain > 0 {
|
||
var grants []mysqlmodel.QuotaGrant
|
||
if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0 AND source = ?", userID, QuotaTypeCos, QuotaGrantSourceMember).
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Find(&grants).Error; err != nil {
|
||
return err
|
||
}
|
||
handled := make(map[int64]bool, len(grants))
|
||
for remain > 0 {
|
||
target := pickMemberGrantForRefund(grants, handled)
|
||
if target == nil {
|
||
break
|
||
}
|
||
delta := target.Used
|
||
if delta > remain {
|
||
delta = remain
|
||
}
|
||
if err := tx.Model(&mysqlmodel.QuotaGrant{}).
|
||
Where("id = ? AND used >= ?", target.ID, delta).
|
||
UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil {
|
||
return err
|
||
}
|
||
remain -= delta
|
||
handled[target.ID] = true
|
||
}
|
||
}
|
||
|
||
// 4. 写回退流水:变更量取「实际回退量」。
|
||
// 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致);
|
||
// 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账,
|
||
// 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。
|
||
refunded := n - remain
|
||
flowReason := reason
|
||
if refunded < n {
|
||
flowReason = fmt.Sprintf("%s(实际回退 %d/%d,可用已退额度不足)", reason, refunded, n)
|
||
slog.WarnContext(ctx, "refund cos quota partially applied",
|
||
"user_id", userID, "want", n, "refunded", refunded, "related_id", relatedID)
|
||
}
|
||
after := cosRemainTx(ctx, tx, userID, vipExpire, now)
|
||
flow := &mysqlmodel.QuotaFlowLog{
|
||
QuotaFlowUUID: genUUID(),
|
||
UserID: userID,
|
||
ChangeTarget: QuotaChangeTargetUse,
|
||
ChangeType: QuotaChangeTypeRefund,
|
||
ChangeNum: refunded,
|
||
OldValue: after - refunded,
|
||
NewValue: after,
|
||
QuotaType: QuotaTypeCos,
|
||
RelatedID: relatedID,
|
||
Reason: flowReason,
|
||
}
|
||
// 回退类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。
|
||
flow.OrderNo = flow.QuotaFlowUUID
|
||
return tx.Create(flow).Error
|
||
}
|
||
|
||
// refundCosQuota 独立事务回退 Cos 额度(上传失败等非事务场景)。
|
||
func refundCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) {
|
||
vipExpire := getUserVIPExpire(ctx, userID)
|
||
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
return refundCosQuotaTx(ctx, tx, userID, vipExpire, n, relatedID, reason)
|
||
}); err != nil {
|
||
slog.WarnContext(ctx, "refund cos quota failed",
|
||
"user_id", userID, "amount", n, "related_id", relatedID, "reason", reason, "error", err)
|
||
}
|
||
}
|