bugfix:支付
This commit is contained in:
+52
-11
@@ -428,16 +428,35 @@ func NewQuotaGrantDao() *QuotaGrantDao {
|
|||||||
return &QuotaGrantDao{db: global.MysqlEngine}
|
return &QuotaGrantDao{db: global.MysqlEngine}
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaGrantCreate 创建额度凭证(ref_id+quota_type 唯一索引兜底幂等,冲突返回 1062)。
|
// QuotaGrantCreateWithFlow 事务内创建额度凭证并写发放流水(购买赠送/活动赠送)。
|
||||||
func (d *QuotaGrantDao) QuotaGrantCreate(ctx context.Context, grant *mysqlmodel.QuotaGrant) error {
|
// 返回值 created 表示本次是否新建了凭证:依赖 uk_grant_ref_type(ref_id, quota_type) 唯一索引实现幂等,
|
||||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreate")
|
// 凭证已存在时返回 (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()
|
defer span.End()
|
||||||
|
|
||||||
if err := d.db.WithContext(ctx).Create(grant).Error; err != nil {
|
created := false
|
||||||
span.RecordError(err)
|
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
return err
|
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
|
||||||
}
|
}
|
||||||
return nil
|
if res.RowsAffected == 0 {
|
||||||
|
return nil // 凭证已存在:幂等跳过,不重复写流水。
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
ensureFlowOrderNo(flow)
|
||||||
|
return tx.Create(flow).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。
|
// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。
|
||||||
@@ -474,16 +493,38 @@ func (d *QuotaGrantDao) QuotaGrantConsume(ctx context.Context, grantID int64, n
|
|||||||
return res.RowsAffected > 0, nil
|
return res.RowsAffected > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaGrantInvalidateByRefID 按关联ID软删除凭证(退款回收时使赠送额度失效)。
|
// QuotaGrantListByRefID 查询某关联ID(订单号/广告记录ID)下未删除的额度凭证。
|
||||||
func (d *QuotaGrantDao) QuotaGrantInvalidateByRefID(ctx context.Context, refID string) error {
|
func (d *QuotaGrantDao) QuotaGrantListByRefID(ctx context.Context, refID string) ([]mysqlmodel.QuotaGrant, error) {
|
||||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefID")
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByRefID")
|
||||||
defer span.End()
|
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)
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
for _, f := range flows {
|
||||||
|
ensureFlowOrderNo(f)
|
||||||
|
if err := tx.Create(f).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaGrantExistsByRefIDAndType 判断凭证是否已存在(广告激励防重复领取)。
|
// QuotaGrantExistsByRefIDAndType 判断凭证是否已存在(广告激励防重复领取)。
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.
|
|||||||
return nil, errcode.AdRewardInvalid.WithDetails("该激励类型未配置发放数量")
|
return nil, errcode.AdRewardInvalid.WithDetails("该激励类型未配置发放数量")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 变更前后取「有效剩余」,仅用于流水展示;权威数值以额度凭证表为准。
|
||||||
|
before := int64(0)
|
||||||
|
if eff, qerr := getEffectiveQuota(ctx, userID, quotaType); qerr == nil {
|
||||||
|
before = eff.Remain
|
||||||
|
}
|
||||||
|
|
||||||
grant := &mysqlmodel.QuotaGrant{
|
grant := &mysqlmodel.QuotaGrant{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
QuotaType: quotaType,
|
QuotaType: quotaType,
|
||||||
@@ -50,12 +56,26 @@ func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.
|
|||||||
ExpireTime: todayEndUnix(), // 当天 23:59:59 到期
|
ExpireTime: todayEndUnix(), // 当天 23:59:59 到期
|
||||||
RefID: request.RefID,
|
RefID: request.RefID,
|
||||||
}
|
}
|
||||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil {
|
// 流水 order_no 复用广告记录ID(与凭证 ref_id 一致,便于回溯;非空故不影响唯一索引幂等)。
|
||||||
if isDuplicateKey(err) {
|
flow := &mysqlmodel.QuotaFlowLog{
|
||||||
return nil, errcode.AdRewardClaimed
|
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
|
return nil, errcode.DatabaseError
|
||||||
}
|
}
|
||||||
|
if !created {
|
||||||
|
return nil, errcode.AdRewardClaimed // 凭证已存在:重复领取。
|
||||||
|
}
|
||||||
|
|
||||||
return &dto.AdRewardGrantResponse{
|
return &dto.AdRewardGrantResponse{
|
||||||
RefID: request.RefID,
|
RefID: request.RefID,
|
||||||
|
|||||||
@@ -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 设置独立到期时间。
|
// 会员包(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 {
|
func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask, quotaType int8) *errcode.Error {
|
||||||
source := QuotaGrantSourceMember
|
source := QuotaGrantSourceMember
|
||||||
expireTime := int64(0) // 0 表示跟随 VIP
|
expireTime := int64(0) // 0 表示跟随 VIP
|
||||||
|
reason := "购买会员赠送"
|
||||||
|
|
||||||
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
|
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
|
||||||
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
|
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
|
||||||
@@ -593,6 +595,13 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
|
|||||||
days = 30
|
days = 30
|
||||||
}
|
}
|
||||||
expireTime = nowUnix() + days*86400
|
expireTime = nowUnix() + days*86400
|
||||||
|
reason = "购买加量包赠送"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 变更前后取「有效剩余」,仅用于流水展示;权威数值以额度凭证表为准。
|
||||||
|
before := int64(0)
|
||||||
|
if eff, qerr := getEffectiveQuota(ctx, task.UserID, quotaType); qerr == nil {
|
||||||
|
before = eff.Remain
|
||||||
}
|
}
|
||||||
|
|
||||||
grant := &mysqlmodel.QuotaGrant{
|
grant := &mysqlmodel.QuotaGrant{
|
||||||
@@ -604,10 +613,19 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
|
|||||||
ExpireTime: expireTime,
|
ExpireTime: expireTime,
|
||||||
RefID: task.OrderNo,
|
RefID: task.OrderNo,
|
||||||
}
|
}
|
||||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil {
|
flow := &mysqlmodel.QuotaFlowLog{
|
||||||
if isDuplicateKey(err) {
|
QuotaFlowUUID: genUUID(),
|
||||||
return nil // 已发放,幂等。
|
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 errcode.DatabaseError
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -677,16 +695,61 @@ func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmode
|
|||||||
}
|
}
|
||||||
|
|
||||||
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
|
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
|
||||||
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度)。
|
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度),
|
||||||
|
// 并在同一事务内写「退款回收」流水,保证额度明细能看到回收记录。
|
||||||
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
|
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
|
||||||
if order.MemberDays > 0 {
|
if order.MemberDays > 0 {
|
||||||
s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo)
|
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)
|
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 幂等回退会员天数(退款回收)。
|
// revokeVIP 幂等回退会员天数(退款回收)。
|
||||||
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) {
|
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) {
|
||||||
user, err := s.userDao.UserGetByID(ctx, userID)
|
user, err := s.userDao.UserGetByID(ctx, userID)
|
||||||
|
|||||||
Reference in New Issue
Block a user