From 32a0b87e9e450d40368ac25ebf37284743caaf38 Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Mon, 14 Sep 2026 02:57:13 +0800 Subject: [PATCH] =?UTF-8?q?bugfix=EF=BC=9A=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/mysqldao/quota.go | 65 +++++++++++++++++++++++------ internal/service/adreward.go | 28 +++++++++++-- internal/service/order.go | 79 ++++++++++++++++++++++++++++++++---- 3 files changed, 148 insertions(+), 24 deletions(-) diff --git a/internal/mysqldao/quota.go b/internal/mysqldao/quota.go index e6905a2..88e9391 100644 --- a/internal/mysqldao/quota.go +++ b/internal/mysqldao/quota.go @@ -428,16 +428,35 @@ func NewQuotaGrantDao() *QuotaGrantDao { return &QuotaGrantDao{db: global.MysqlEngine} } -// QuotaGrantCreate 创建额度凭证(ref_id+quota_type 唯一索引兜底幂等,冲突返回 1062)。 -func (d *QuotaGrantDao) QuotaGrantCreate(ctx context.Context, grant *mysqlmodel.QuotaGrant) error { - ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreate") +// QuotaGrantCreateWithFlow 事务内创建额度凭证并写发放流水(购买赠送/活动赠送)。 +// 返回值 created 表示本次是否新建了凭证:依赖 uk_grant_ref_type(ref_id, quota_type) 唯一索引实现幂等, +// 凭证已存在时返回 (false, nil) 且不写流水,避免重复回调/补偿重试重复记账 +//(流水侧另有 (order_no, quota_type, change_type) 唯一索引兜底)。 +func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreateWithFlow") defer span.End() - if err := d.db.WithContext(ctx).Create(grant).Error; err != nil { + created := false + err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "ref_id"}, {Name: "quota_type"}}, + DoNothing: true, + }).Create(grant) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil // 凭证已存在:幂等跳过,不重复写流水。 + } + created = true + ensureFlowOrderNo(flow) + return tx.Create(flow).Error + }) + if err != nil { span.RecordError(err) - return err + return false, err } - return nil + return created, nil } // QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。 @@ -474,16 +493,38 @@ func (d *QuotaGrantDao) QuotaGrantConsume(ctx context.Context, grantID int64, n return res.RowsAffected > 0, nil } -// QuotaGrantInvalidateByRefID 按关联ID软删除凭证(退款回收时使赠送额度失效)。 -func (d *QuotaGrantDao) QuotaGrantInvalidateByRefID(ctx context.Context, refID string) error { - ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefID") +// QuotaGrantListByRefID 查询某关联ID(订单号/广告记录ID)下未删除的额度凭证。 +func (d *QuotaGrantDao) QuotaGrantListByRefID(ctx context.Context, refID string) ([]mysqlmodel.QuotaGrant, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByRefID") defer span.End() - if err := d.db.WithContext(ctx).Where("ref_id = ?", refID).Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil { + var list []mysqlmodel.QuotaGrant + if err := d.db.WithContext(ctx).Where("ref_id = ?", refID).Find(&list).Error; err != nil { span.RecordError(err) - return err + return nil, err } - return nil + return list, nil +} + +// QuotaGrantInvalidateByRefIDWithFlows 事务内按关联ID软删除凭证,并写「退款回收」流水。 +// 凭证软删除后不再计入有效额度;已用部分不追回,故流水变更量由调用方按 (amount - used) 记负数。 +// 凭证失效与流水同事务,保证「额度被回收」与「额度明细」一致。 +func (d *QuotaGrantDao) QuotaGrantInvalidateByRefIDWithFlows(ctx context.Context, refID string, flows []*mysqlmodel.QuotaFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefIDWithFlows") + defer span.End() + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("ref_id = ?", refID).Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil { + return err + } + for _, f := range flows { + ensureFlowOrderNo(f) + if err := tx.Create(f).Error; err != nil { + return err + } + } + return nil + }) } // QuotaGrantExistsByRefIDAndType 判断凭证是否已存在(广告激励防重复领取)。 diff --git a/internal/service/adreward.go b/internal/service/adreward.go index 91aa090..21fb009 100644 --- a/internal/service/adreward.go +++ b/internal/service/adreward.go @@ -41,6 +41,12 @@ func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto. return nil, errcode.AdRewardInvalid.WithDetails("该激励类型未配置发放数量") } + // 变更前后取「有效剩余」,仅用于流水展示;权威数值以额度凭证表为准。 + before := int64(0) + if eff, qerr := getEffectiveQuota(ctx, userID, quotaType); qerr == nil { + before = eff.Remain + } + grant := &mysqlmodel.QuotaGrant{ UserID: userID, QuotaType: quotaType, @@ -50,12 +56,26 @@ func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto. ExpireTime: todayEndUnix(), // 当天 23:59:59 到期 RefID: request.RefID, } - if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil { - if isDuplicateKey(err) { - return nil, errcode.AdRewardClaimed - } + // 流水 order_no 复用广告记录ID(与凭证 ref_id 一致,便于回溯;非空故不影响唯一索引幂等)。 + flow := &mysqlmodel.QuotaFlowLog{ + QuotaFlowUUID: genUUID(), + UserID: userID, + ChangeTarget: QuotaChangeTargetAdjust, + ChangeType: QuotaChangeTypeActivity, + ChangeNum: amount, + OldValue: before, + NewValue: before + amount, + OrderNo: request.RefID, + QuotaType: quotaType, + Reason: "广告激励赠送", + } + created, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow) + if err != nil { return nil, errcode.DatabaseError } + if !created { + return nil, errcode.AdRewardClaimed // 凭证已存在:重复领取。 + } return &dto.AdRewardGrantResponse{ RefID: request.RefID, diff --git a/internal/service/order.go b/internal/service/order.go index 85e8f04..13432ae 100644 --- a/internal/service/order.go +++ b/internal/service/order.go @@ -578,12 +578,14 @@ func (s *OrderService) doGrantBenefit(ctx context.Context, task *mysqlmodel.Bene } } -// grantQuotaGrant 发放额度凭证(替代原 grantQuota 累加 user_quotas.total)。 +// grantQuotaGrant 发放额度凭证(替代原 grantQuota 累加 user_quotas.total),并同步写「额度明细」流水。 // 会员包(goods_type=1)凭证跟随 VIP(expire_time=0);加量包按商品 valid_days 设置独立到期时间。 -// ref_id 复用订单号,配合 (ref_id, quota_type) 唯一索引实现幂等。 +// ref_id 复用订单号,配合 (ref_id, quota_type) 唯一索引实现幂等;流水与凭证同事务写入, +// 保证「额度到账」与「额度明细」一致(此前只写凭证不写流水,导致购买后额度明细为空)。 func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask, quotaType int8) *errcode.Error { source := QuotaGrantSourceMember expireTime := int64(0) // 0 表示跟随 VIP + reason := "购买会员赠送" if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember { // 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。 @@ -593,6 +595,13 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben days = 30 } expireTime = nowUnix() + days*86400 + reason = "购买加量包赠送" + } + + // 变更前后取「有效剩余」,仅用于流水展示;权威数值以额度凭证表为准。 + before := int64(0) + if eff, qerr := getEffectiveQuota(ctx, task.UserID, quotaType); qerr == nil { + before = eff.Remain } grant := &mysqlmodel.QuotaGrant{ @@ -604,10 +613,19 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben ExpireTime: expireTime, RefID: task.OrderNo, } - if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil { - if isDuplicateKey(err) { - return nil // 已发放,幂等。 - } + flow := &mysqlmodel.QuotaFlowLog{ + QuotaFlowUUID: genUUID(), + UserID: task.UserID, + ChangeTarget: QuotaChangeTargetAdjust, + ChangeType: QuotaChangeTypePurchase, + ChangeNum: task.Amount, + OldValue: before, + NewValue: before + task.Amount, + OrderNo: task.OrderNo, + QuotaType: quotaType, + Reason: reason, + } + if _, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow); err != nil { return errcode.DatabaseError } return nil @@ -677,16 +695,61 @@ func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmode } // revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。 -// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度)。 +// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度), +// 并在同一事务内写「退款回收」流水,保证额度明细能看到回收记录。 func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) { if order.MemberDays > 0 { s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo) } - if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefID(ctx, order.OrderNo); err != nil { + flows := s.buildQuotaRevokeFlows(ctx, order) + if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefIDWithFlows(ctx, order.OrderNo, flows); err != nil { slog.WarnContext(ctx, "invalidate quota grants failed", "order_no", order.OrderNo, "error", err) } } +// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。 +// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。 +func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) []*mysqlmodel.QuotaFlowLog { + grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo) + if err != nil { + slog.WarnContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err) + return nil + } + + flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants)) + // 每类额度的当前有效剩余,逐张凭证递减(同一订单同类凭证通常只有一张,此处做通用处理)。 + current := make(map[int8]int64, len(grants)) + computed := make(map[int8]bool, len(grants)) + for i := range grants { + g := grants[i] + revoke := g.Amount - g.Used + if revoke <= 0 { + continue // 已全部用掉,无可回收额度。 + } + if !computed[g.QuotaType] { + if eff, qerr := getEffectiveQuota(ctx, order.UserID, g.QuotaType); qerr == nil { + current[g.QuotaType] = eff.Remain + } + computed[g.QuotaType] = true + } + before := current[g.QuotaType] + current[g.QuotaType] = before - revoke + flows = append(flows, &mysqlmodel.QuotaFlowLog{ + QuotaFlowUUID: genUUID(), + UserID: order.UserID, + ChangeTarget: QuotaChangeTargetAdjust, + ChangeType: QuotaChangeTypeRefundRevoke, + ChangeNum: -revoke, + OldValue: before, + NewValue: before - revoke, + OrderNo: order.OrderNo, + QuotaType: g.QuotaType, + Reason: "退款回收未使用的赠送额度", + }) + } + return flows +} + // revokeVIP 幂等回退会员天数(退款回收)。 func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) { user, err := s.userDao.UserGetByID(ctx, userID)