package service import ( "context" "log/slog" "net/http" "strings" "time" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" "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" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" "gorm.io/gorm" "gorm.io/gorm/clause" ) // OrderService 订单与退款业务服务。 type OrderService struct { orderDao *mysqldao.OrderDao goodsDao *mysqldao.GoodsDao userDao *mysqldao.UserDao refundDao *mysqldao.RefundFlowLogDao vipDao *mysqldao.VIPFlowLogDao } // NewOrderService 创建订单服务。 func NewOrderService() *OrderService { return &OrderService{ orderDao: mysqldao.NewOrderDao(), goodsDao: mysqldao.NewGoodsDao(), userDao: mysqldao.NewUserDao(), refundDao: mysqldao.NewRefundFlowLogDao(), vipDao: mysqldao.NewVIPFlowLogDao(), } } // Create 创建订单并发起微信 JSAPI 下单。 func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.OrderCreateRequest) (*dto.OrderCreateResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OrderCreate") defer span.End() goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode) if err != nil { if isNotFound(err) { return nil, errcode.GoodsNotFound } return nil, errcode.DatabaseError } if goods.Status != GoodsStatusOnShelf { return nil, errcode.GoodsOffShelf } // 支付用户 openid。 user, uerr := s.userDao.UserGetByID(ctx, userID) if uerr != nil { if isNotFound(uerr) { return nil, errcode.UserNotFound } return nil, errcode.DatabaseError } if user.WxOpenID == "" { return nil, errcode.OrderCreateFailed.WithDetails("缺少微信 openid") } orderNo := genOrderNo() expireTime := nowUnix() + orderPayTTLSeconds payAmount := goods.DiscountPrice order := &mysqlmodel.Order{ OrderNo: orderNo, UserID: userID, GoodsID: goods.ID, GoodsName: goods.GoodsName, UnitPrice: goods.DiscountPrice, TotalAmount: payAmount, PayAmount: payAmount, PayChannel: PayChannelWechat, OrderStatus: OrderStatusPending, MemberDays: goods.MemberDays, GoodsType: goods.GoodsType, QuotaValidDays: goods.ValidDays, CosQuota: goods.CosQuota, AIRecommendQuota: goods.AIRecommendQuota, ClosetShareQuota: goods.ClosetShareQuota, OutfitShareQuota: goods.OutfitShareQuota, ExpireTime: expireTime, } // 先落库再微信下单:微信下单失败时本地订单保留为待支付并超时自动过期, // 避免「先微信下单、落库失败」产生无本地记录的孤儿预支付单。 if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil { return nil, errcode.OrderCreateFailed } payParams, perr := wechat.JsapiPrepay(ctx, user.WxOpenID, orderNo, goods.GoodsName, payAmount) if perr != nil { return nil, errcode.OrderCreateFailed } return &dto.OrderCreateResponse{ OrderNo: orderNo, PayAmount: payAmount, ExpireTime: expireTime, PayParams: &dto.WechatPayParams{ TimeStamp: payParams.TimeStamp, NonceStr: payParams.NonceStr, Package: payParams.Package, SignType: payParams.SignType, PaySign: payParams.PaySign, }, }, nil } // PayNotify 处理微信支付结果回调(供回调 handler 调用):验签解密 + 幂等 + 发放权益。 func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.OrderPayNotify") defer span.End() data, err := wechat.VerifyAndDecryptPayNotify(ctx, request) if err != nil { return errcode.RPCError } if data.TradeState != "SUCCESS" { return nil // 非支付成功,忽略。 } // 幂等锁。 lockKey := fmtLockKey(redismodel.KeyLockPayCallback, data.OutTradeNo) lockVal := genUUID() if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok { return nil // 并发回调,交由持锁者处理。 } defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() // 先查订单并校验回调金额,防止金额被篡改导致错误发放。 order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo) if gerr != nil { if isNotFound(gerr) { slog.ErrorContext(ctx, "pay notify: order not found", "out_trade_no", data.OutTradeNo) return nil } return errcode.DatabaseError } if data.Amount.Total != order.PayAmount { slog.ErrorContext(ctx, "pay notify: amount mismatch", "out_trade_no", data.OutTradeNo, "notify_amount", data.Amount.Total, "order_amount", order.PayAmount, ) return nil // 金额不一致,不流转、不发放权益,人工介入。 } // 条件流转:待支付 -> 已支付,防重复回调。 updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusPending, OrderStatusPaid) if uerr != nil { return errcode.DatabaseError } if updated { s.finalizePaidOrder(ctx, order, data) return nil } // 流转失败:重新读取订单,处理「本地已过期/已取消但用户实际已付款」的竞态。 current, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo) if gerr != nil { if isNotFound(gerr) { return nil } return errcode.DatabaseError } switch current.OrderStatus { case OrderStatusPaid: // 已被并发回调处理。兜底确保支付信息与权益发放已完成(均幂等), // 覆盖「订单已流转为 Paid 但权益发放前崩溃导致权益丢失」的场景。 _ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{ "pay_time": nowUnix(), "transaction_id": data.TransactionID, "callback_raw": data.Raw, }) s.grantOrderBenefits(ctx, current) return nil case OrderStatusExpired: // 订单被系统过期但用户已付款:过期 -> 已支付并补发货,避免用户付款却不到账。 ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusExpired, OrderStatusPaid) if uerr != nil { return errcode.DatabaseError } if !ok { return nil // 已被其它回调处理。 } s.finalizePaidOrder(ctx, current, data) return nil case OrderStatusCancelled: // 用户主动取消却仍支付(关闭订单与支付存在竞态窗口):自动发起退款,避免用户付款却拿不到权益。 s.autoRefundCancelledOrder(ctx, current, data) return nil default: return nil } } // Cancel 取消待支付订单(校验归属)。 func (s *OrderService) Cancel(ctx context.Context, userID int64, request *dto.OrderCancelRequest) (*dto.OrderResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OrderCancel") defer span.End() order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID) if cerr != nil { return nil, cerr } // 虚拟支付订单不可取消:虚拟支付无服务端关单 API,本地取消后微信侧单仍有效, // 用户继续支付会造成「已付款但权益不发放」。禁止取消从源头杜绝该资损场景。 if order.PayChannel == PayChannelVirtual { return nil, errcode.VirtualOrderCancelNotAllowed } if order.OrderStatus == OrderStatusPaid { return nil, errcode.OrderAlreadyPaid } if order.OrderStatus != OrderStatusPending { return nil, errcode.OrderStatusInvalid } ok, err := s.orderDao.OrderUpdateStatusByOrderNo(ctx, request.OrderNo, OrderStatusPending, OrderStatusCancelled) if err != nil { return nil, errcode.OrderCancelFailed } if !ok { return nil, errcode.OrderStatusInvalid } // 本地取消成功后,尽力关闭微信侧订单,防止用户继续支付(失败仅告警,不阻断取消)。 if cerr := wechat.CloseOrder(ctx, request.OrderNo); cerr != nil { slog.WarnContext(ctx, "close wechat order failed", "order_no", request.OrderNo, "error", cerr) } order.OrderStatus = OrderStatusCancelled response := buildOrderResponse(order) return &response, nil } // List 分页查询我的订单。 func (s *OrderService) List(ctx context.Context, userID int64, request *dto.OrderListRequest) (*dto.OrderListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OrderList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.orderDao.OrderListByUserID(ctx, userID, request.Status, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items := make([]dto.OrderResponse, 0, len(list)) for i := range list { items = append(items, buildOrderResponse(&list[i])) } return &dto.OrderListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // RefundApply 申请退款(仅已支付订单)。 func (s *OrderService) RefundApply(ctx context.Context, userID int64, request *dto.RefundApplyRequest) (*dto.RefundResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundApply") defer span.End() order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID) if cerr != nil { return nil, cerr } // 虚拟支付 iOS 订单不可主动退款:用户走 App Store 申请,Apple 通过退款问询推送回收。 if order.PayChannel == PayChannelVirtual && order.XPayPlatform == XPayPlatformIOS { return nil, errcode.IOSRefundViaAppStore } if order.OrderStatus != OrderStatusPaid { return nil, errcode.RefundNotAllowed } // 已有退款流水则按状态处理:非失败不允许重复申请;失败则复用旧记录重新申请。 existing, gerr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, request.OrderNo) if gerr != nil && !isNotFound(gerr) { return nil, errcode.DatabaseError } if existing != nil && existing.Status != RefundStatusFailed { return nil, errcode.RefundNotAllowed } refundNo := genRefundNo() applyTime := nowUnix() if existing != nil { // 失败重试:复用旧记录,更新退款单号与状态。 if err := s.refundDao.RefundFlowLogUpdateStatus(ctx, existing.RefundNo, map[string]any{ "refund_no": refundNo, "refund_amount": order.PayAmount, "refund_reason": request.Reason, "status": RefundStatusPending, "apply_time": applyTime, "fail_reason": "", }); err != nil { return nil, errcode.RefundApplyFailed } } else { // 新建退款流水(order_no 唯一索引兜底并发,冲突即已有申请)。 flow := &mysqlmodel.RefundFlowLog{ OrderID: order.ID, OrderNo: order.OrderNo, UserID: userID, RefundNo: refundNo, RefundAmount: order.PayAmount, RefundReason: request.Reason, Status: RefundStatusPending, ApplyTime: applyTime, } if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil { if isDuplicateKey(err) { return nil, errcode.RefundNotAllowed } return nil, errcode.RefundApplyFailed } } // 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。 // 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。 return &dto.RefundResponse{ RefundNo: refundNo, RefundAmount: order.PayAmount, Status: RefundStatusPending, ApplyTime: applyTime, }, nil } // RefundNotify 处理微信退款结果回调(供回调 handler 调用):验签解密 + 幂等 + 更新状态。 func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundNotify") defer span.End() data, err := wechat.VerifyAndDecryptRefundNotify(ctx, request) if err != nil { return errcode.RPCError } lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, data.OutRefundNo) lockVal := genUUID() if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok { return nil } defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() flow, ferr := s.refundDao.RefundFlowLogGetByRefundNo(ctx, data.OutRefundNo) if ferr != nil { if isNotFound(ferr) { return nil } return errcode.DatabaseError } if flow.Status == RefundStatusFailed { return nil } if data.Amount.Refund != flow.RefundAmount { slog.ErrorContext(ctx, "refund notify: amount mismatch", "out_refund_no", data.OutRefundNo, "notify_amount", data.Amount.Refund, "flow_amount", flow.RefundAmount) return nil } if data.RefundStatus == "SUCCESS" { if flow.Status != RefundStatusSuccess { ok, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix(), "callback_raw": data.Raw}) if uerr != nil { return errcode.DatabaseError } if !ok { return errcode.DatabaseError } } order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo) if gerr != nil { return errcode.DatabaseError } if err := s.settleRefundSuccess(ctx, order, flow.RefundAmount); err != nil { return errcode.DatabaseError } } else if flow.Status != RefundStatusSuccess { if _, err := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusFailed, "fail_reason": "微信退款未成功:" + data.RefundStatus, "callback_raw": data.Raw}); err != nil { return errcode.DatabaseError } } return nil } // autoRefundCancelledOrder 订单已取消但用户已付款:生成待审核退款单,交由管理员在后台手动退款。 // 不再自动调用微信退款(所有退款均由管理员操作),避免「自动退款失败后流水卡死无重试」的资损风险。 func (s *OrderService) autoRefundCancelledOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) { // 幂等:已有退款流水则跳过,避免重复生成待审核退款单。 if existing, err := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo); err == nil && existing != nil { return } // 补记支付信息(订单已取消,此前未记录),便于管理员审核退款时核对。 _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ "pay_time": nowUnix(), "transaction_id": data.TransactionID, "callback_raw": data.Raw, }) refundNo := genRefundNo() flow := &mysqlmodel.RefundFlowLog{ OrderID: order.ID, OrderNo: order.OrderNo, UserID: order.UserID, RefundNo: refundNo, RefundAmount: order.PayAmount, RefundReason: "订单已取消但已付款,待管理员退款", Status: RefundStatusPending, ApplyTime: nowUnix(), } if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil { if isDuplicateKey(err) { return } slog.ErrorContext(ctx, "auto refund: create refund flow failed", "order_no", order.OrderNo, "error", err) return } slog.InfoContext(ctx, "cancelled order refund flow created, waiting manual review", "order_no", order.OrderNo, "refund_no", refundNo) } // ---- 内部辅助 ---- // getOwnedOrder 查询订单并校验归属。 func (s *OrderService) getOwnedOrder(ctx context.Context, orderNo string, userID int64) (*mysqlmodel.Order, *errcode.Error) { order, err := s.orderDao.OrderGetByOrderNo(ctx, orderNo) if err != nil { if isNotFound(err) { return nil, errcode.OrderNotFound } return nil, errcode.DatabaseError } if order.UserID != userID { return nil, errcode.Forbidden } return order, nil } // finalizePaidOrder 记录支付信息、发放权益并发送通知(支付成功或过期补发货后调用)。 func (s *OrderService) finalizePaidOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) { _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ "pay_time": nowUnix(), "transaction_id": data.TransactionID, "callback_raw": data.Raw, }) s.grantOrderBenefits(ctx, order) sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "订单支付成功", "你购买的「"+order.GoodsName+"」已到账") } // grantOrderBenefits 支付成功后幂等发放会员天数与各类额度权益(使用下单时的订单快照,避免商品变更导致错发)。 func (s *OrderService) grantOrderBenefits(ctx context.Context, order *mysqlmodel.Order) { benefits := []struct { benefitType int8 amount int64 }{ {BenefitTypeVIP, order.MemberDays}, {BenefitTypeCos, order.CosQuota}, {BenefitTypeAI, order.AIRecommendQuota}, {BenefitTypeClosetShare, order.ClosetShareQuota}, {BenefitTypeOutfitShare, order.OutfitShareQuota}, } for _, b := range benefits { if b.amount > 0 { s.grantBenefitIdempotent(ctx, order, b.benefitType, b.amount) } } } // grantBenefitIdempotent 幂等发放单个权益:先落补偿任务凭证(唯一索引 order_no+benefit_type), // 凭证创建成功才执行发放;凭证冲突说明已有记录,按状态跳过或交定时任务处理。 func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlmodel.Order, benefitType int8, amount int64) { taskDao := mysqldao.NewBenefitGrantTaskDao() task := &mysqlmodel.BenefitGrantTask{ UserID: order.UserID, OrderNo: order.OrderNo, GoodsID: order.GoodsID, BenefitType: benefitType, Amount: amount, Status: BenefitTaskPending, MaxRetry: benefitMaxRetry, } if err := taskDao.BenefitGrantTaskCreate(ctx, task); err != nil { // 非唯一冲突(DB 临时故障等):记录错误返回,交由补偿/人工,不能静默丢权益。 if !isDuplicateKey(err) { slog.ErrorContext(ctx, "create benefit grant task failed", "order_no", order.OrderNo, "benefit_type", benefitType, "error", err) return } // 唯一冲突:已有记录。 existing, gerr := taskDao.BenefitGrantTaskGetByOrderNoAndType(ctx, order.OrderNo, benefitType) if gerr != nil { slog.ErrorContext(ctx, "get benefit grant task failed", "order_no", order.OrderNo, "benefit_type", benefitType, "error", gerr) return } if existing.Status == BenefitTaskSuccess { return // 已发放成功。 } if existing.Status == BenefitTaskFailed { // 曾失败过:冷却期内不重置(防微信回调重试风暴无限重置任务绕过重试上限), // 冷却期外重置为待发放并尝试发放(发放本身有幂等兜底,重置失败仅告警)。 if nowUnix()-existing.UpdatedAt < benefitRetryCooldownSeconds { slog.WarnContext(ctx, "benefit grant task in cooldown, skip retry", "order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID) return } if rerr := taskDao.BenefitGrantTaskReset(ctx, existing.ID); rerr != nil { slog.WarnContext(ctx, "reset benefit grant task failed", "order_no", order.OrderNo, "benefit_type", benefitType, "error", rerr) return } s.executeGrant(ctx, existing) } return // pending 交由定时任务处理。 } // 凭证创建成功,执行发放(claim 防并发)。 s.executeGrant(ctx, task) } // executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。 func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) { var grantErr error err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var order mysqlmodel.Order if err := tx.Select("id", "order_status").Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", task.OrderNo).First(&order).Error; err != nil { return err } if order.OrderStatus != OrderStatusPaid { return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskCancelled).Error } res := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskProcessing) if res.Error != nil || res.RowsAffected == 0 { return res.Error } if e := s.doGrantBenefit(ctx, task); e != nil { grantErr = e return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": e.Error()}).Error } return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskSuccess, "last_error": ""}).Error }) if err != nil { slog.ErrorContext(ctx, "grant benefit task update failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err) } else if grantErr != nil { slog.ErrorContext(ctx, "grant benefit failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", grantErr) } } // doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。 func (s *OrderService) doGrantBenefit(ctx context.Context, task *mysqlmodel.BenefitGrantTask) *errcode.Error { switch task.BenefitType { case BenefitTypeVIP: if err := s.grantVIP(ctx, task.UserID, task.Amount, task.OrderNo); err != nil { return errcode.DatabaseError } return nil case BenefitTypeCos: return s.grantQuotaGrant(ctx, task, QuotaTypeCos) case BenefitTypeAI: return s.grantQuotaGrant(ctx, task, QuotaTypeAI) case BenefitTypeClosetShare: return s.grantQuotaGrant(ctx, task, QuotaTypeClosetShare) case BenefitTypeOutfitShare: return s.grantQuotaGrant(ctx, task, QuotaTypeOutfitShare) default: return errcode.DatabaseError } } // grantQuotaGrant 发放额度凭证(替代原 grantQuota 累加 user_quotas.total),并同步写「额度明细」流水。 // 会员包(goods_type=1)凭证跟随 VIP(expire_time=0);加量包按商品 valid_days 设置独立到期时间。 // 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 := "购买会员赠送" order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo) if err != nil { return errcode.DatabaseError } goodsType, validDays := order.GoodsType, order.QuotaValidDays if goodsType == 0 { // 兼容迁移前创建、尚未回填的历史订单。 goods, gerr := s.goodsDao.GoodsGetByID(ctx, task.GoodsID) if gerr != nil { return errcode.DatabaseError } goodsType, validDays = goods.GoodsType, goods.ValidDays } if goodsType != GoodsTypeMember { // 加量包:独立有效期,从发放时刻起算快照 valid_days 天;未配置则兜底 30 天。 source = QuotaGrantSourceAddon days := validDays if days <= 0 { 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{ UserID: task.UserID, QuotaType: quotaType, Amount: task.Amount, Used: 0, Source: source, ExpireTime: expireTime, RefID: task.OrderNo, } 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, } grantDao := mysqldao.NewQuotaGrantDao() if source == QuotaGrantSourceMember { // 同一用户、同一会员包只赠送一次;续费和补偿重试共用事务内检查。 _, err = grantDao.MemberQuotaGrantCreateWithFlow(ctx, task.GoodsID, grant, flow) } else { _, err = grantDao.QuotaGrantCreateWithFlow(ctx, grant, flow) } if err != nil { return errcode.DatabaseError } return nil } // grantVIP 开通/续费会员,并写 VIP 流水(事务内幂等:累加与流水同事务,流水唯一索引兜底)。 func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, orderNo string) error { user, err := s.userDao.UserGetByID(ctx, userID) if err != nil { return err } now := nowUnix() newVIPType := VIPTypeMonthly if memberDays >= 365 { newVIPType = VIPTypeYearly } changeType := VIPChangeTypeOpen if user.VIPType != VIPTypeNone && user.VIPExpire > now { changeType = VIPChangeTypeRenew } flow := &mysqlmodel.VIPFlowLog{ UserID: userID, ChangeType: changeType, OldVIPType: user.VIPType, NewVIPType: newVIPType, OldVIPExpire: user.VIPExpire, OrderNo: orderNo, Source: VIPSourcePersonalPay, Reason: "购买会员", } if err := s.userDao.VIPGrantIdempotent(ctx, userID, now, memberDays, newVIPType, flow); err != nil { if isDuplicateKey(err) { return nil // 已发放,幂等。 } return err } // 用户信息已变更,失效缓存。 _ = redisdao.UserInfoDel(ctx, userID) return nil } // settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。 // 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单 // (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。 func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) error { settled, err := mysqldao.NewBenefitRevokeTaskDao().RefundSettleWithRevokeTask(ctx, order.OrderNo, refundAmount, nowUnix()) if err != nil { return err } if settled { sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款") } // 立即尝试;失败时任务仍为待处理,由定时任务恢复。 s.executeRevokeTaskByOrder(ctx, order.OrderNo) return nil } // revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。 // 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度), // 并在同一事务内写「退款回收」流水,保证额度明细能看到回收记录。 func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) error { if order.MemberDays > 0 { if err := s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo); err != nil { return err } } flows, err := s.buildQuotaRevokeFlows(ctx, order) if err != nil { return err } return mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefIDWithFlows(ctx, order.OrderNo, flows) } // buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。 // 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。 func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) { 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, err } 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, nil } // revokeVIP 幂等回退会员天数(退款回收)。 func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) error { user, err := s.userDao.UserGetByID(ctx, userID) if err != nil { return err } flow := &mysqlmodel.VIPFlowLog{UserID: userID, ChangeType: VIPChangeTypeRefundRevoke, OldVIPType: user.VIPType, NewVIPType: user.VIPType, OldVIPExpire: user.VIPExpire, OrderNo: orderNo, Source: VIPSourceSystem, Reason: "退款回收会员"} if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil && !isDuplicateKey(err) { return err } _ = redisdao.UserInfoDel(ctx, userID) return nil } // buildOrderResponse 将订单模型映射为响应。 func buildOrderResponse(o *mysqlmodel.Order) dto.OrderResponse { return dto.OrderResponse{ OrderNo: o.OrderNo, GoodsName: o.GoodsName, UnitPrice: o.UnitPrice, PayAmount: o.PayAmount, PayChannel: o.PayChannel, OrderStatus: o.OrderStatus, MemberDays: o.MemberDays, ExpireTime: o.ExpireTime, PayTime: o.PayTime, RefundTime: o.RefundTime, RefundAmount: o.RefundAmount, CreatedAt: o.CreatedAt, } } // genOrderNo 生成唯一商户订单号。 func genOrderNo() string { return "OD" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16] } // genRefundNo 生成唯一商户退款单号。 func genRefundNo() string { return "RF" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16] } // fmtLockKey 拼接分布式锁 key(key 模板含单个 %s 占位符)。 func fmtLockKey(tmpl, arg string) string { return strings.Replace(tmpl, "%s", arg, 1) }