Files
zhengchiliu b94dd6dffb feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
2026-09-16 03:44:09 +08:00

934 lines
40 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
slog.InfoContext(ctx, "create order: goods not found", "user_id", userID, "goods_code", request.GoodsCode)
return nil, errcode.GoodsNotFound
}
slog.ErrorContext(ctx, "create order: query goods failed", "goods_code", request.GoodsCode, "error", err)
return nil, errcode.DatabaseError
}
if goods.Status != GoodsStatusOnShelf {
slog.InfoContext(ctx, "create order: goods off shelf", "user_id", userID, "goods_code", request.GoodsCode)
return nil, errcode.GoodsOffShelf
}
// 支付用户 openid。
user, uerr := s.userDao.UserGetByID(ctx, userID)
if uerr != nil {
if isNotFound(uerr) {
slog.WarnContext(ctx, "create order: user not found", "user_id", userID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "create order: query user failed", "user_id", userID, "error", uerr)
return nil, errcode.DatabaseError
}
if user.WxOpenID == "" {
slog.WarnContext(ctx, "create order: user openid missing", "user_id", userID)
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 {
slog.ErrorContext(ctx, "create order: save order failed",
"order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "error", cerr)
return nil, errcode.OrderCreateFailed
}
payParams, perr := wechat.JsapiPrepay(ctx, user.WxOpenID, orderNo, goods.GoodsName, payAmount)
if perr != nil {
slog.ErrorContext(ctx, "create order: jsapi prepay failed", "order_no", orderNo, "user_id", userID, "error", perr)
return nil, errcode.OrderCreateFailed
}
slog.InfoContext(ctx, "order created",
"order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "pay_amount", payAmount)
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 {
slog.ErrorContext(ctx, "pay notify: verify or decrypt failed", "remote_addr", request.RemoteAddr, "error", err)
return errcode.RPCError
}
if data.TradeState != "SUCCESS" {
slog.InfoContext(ctx, "pay notify: ignored non-success trade state",
"out_trade_no", data.OutTradeNo, "trade_state", data.TradeState)
return nil // 非支付成功,忽略。
}
// 幂等锁。
lockKey := fmtLockKey(redismodel.KeyLockPayCallback, data.OutTradeNo)
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "pay notify: acquire lock failed", "out_trade_no", data.OutTradeNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "pay notify: concurrent callback, skip", "out_trade_no", data.OutTradeNo)
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
}
slog.ErrorContext(ctx, "pay notify: query order failed", "out_trade_no", data.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
if data.Amount.Total != order.PayAmount {
// 金额不一致:不流转、不发放权益,人工介入。
// 这里回的是成功应答(微信不再重试),所以必须把原始报文落库留痕,
// 否则这笔钱在系统里将无任何可追溯记录(callback_raw 是唯一能事后对账的字段)。
slog.ErrorContext(ctx, "pay notify: amount mismatch",
"out_trade_no", data.OutTradeNo,
"notify_amount", data.Amount.Total,
"order_amount", order.PayAmount,
)
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"callback_raw": data.Raw,
}); uerr != nil {
slog.ErrorContext(ctx, "pay notify: record mismatch callback raw failed",
"out_trade_no", data.OutTradeNo, "error", uerr)
}
return nil
}
// 条件流转:待支付 -> 已支付,防重复回调。
updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusPending, OrderStatusPaid)
if uerr != nil {
slog.ErrorContext(ctx, "pay notify: update order status failed", "out_trade_no", data.OutTradeNo, "error", uerr)
return errcode.DatabaseError
}
if updated {
slog.InfoContext(ctx, "pay notify: order paid",
"order_no", order.OrderNo, "user_id", order.UserID, "pay_amount", order.PayAmount, "transaction_id", data.TransactionID)
s.finalizePaidOrder(ctx, order, data)
return nil
}
// 流转失败:重新读取订单,处理「本地已过期/已取消但用户实际已付款」的竞态。
current, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
if gerr != nil {
if isNotFound(gerr) {
slog.WarnContext(ctx, "pay notify: order disappeared after status conflict", "out_trade_no", data.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "pay notify: reload order failed", "out_trade_no", data.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
switch current.OrderStatus {
case OrderStatusPaid:
// 已被并发回调处理。兜底确保支付信息与权益发放已完成(均幂等),
// 覆盖「订单已流转为 Paid 但权益发放前崩溃导致权益丢失」的场景。
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
"pay_time": nowUnix(),
"transaction_id": data.TransactionID,
"callback_raw": data.Raw,
}); uerr != nil {
slog.ErrorContext(ctx, "pay notify: reconcile paid order pay info failed", "order_no", current.OrderNo, "error", uerr)
}
slog.InfoContext(ctx, "pay notify: order already paid, reconcile benefits", "order_no", current.OrderNo, "user_id", current.UserID)
s.grantOrderBenefits(ctx, current)
return nil
case OrderStatusExpired:
// 订单被系统过期但用户已付款:过期 -> 已支付并补发货,避免用户付款却不到账。
ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusExpired, OrderStatusPaid)
if uerr != nil {
slog.ErrorContext(ctx, "pay notify: compensate expired order failed", "out_trade_no", data.OutTradeNo, "error", uerr)
return errcode.DatabaseError
}
if !ok {
slog.DebugContext(ctx, "pay notify: expired order already handled by another callback", "out_trade_no", data.OutTradeNo)
return nil // 已被其它回调处理。
}
slog.InfoContext(ctx, "pay notify: expired order compensated", "order_no", current.OrderNo, "user_id", current.UserID)
s.finalizePaidOrder(ctx, current, data)
return nil
case OrderStatusCancelled:
// 用户主动取消却仍支付(关闭订单与支付存在竞态窗口):自动发起退款,避免用户付款却拿不到权益。
s.autoRefundCancelledOrder(ctx, current, data)
return nil
default:
slog.DebugContext(ctx, "pay notify: unhandled order status", "out_trade_no", data.OutTradeNo, "order_status", current.OrderStatus)
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
}
refundNo := genRefundNo()
applyTime := nowUnix()
// 受理与订单状态流转原子完成:订单 2 已支付 -> 6 申请退款中,并落退款流水
// (已有失败流水则复用旧记录换新退款单号)。订单行锁兜底并发与重复申请。
if _, allowed, err := s.refundDao.RefundApplyWithOrderStatus(ctx, order.OrderNo, refundNo, request.Reason, applyTime); err != nil {
slog.ErrorContext(ctx, "refund apply failed", "order_no", order.OrderNo, "user_id", userID, "error", err)
return nil, errcode.RefundApplyFailed
} else if !allowed {
slog.WarnContext(ctx, "refund apply rejected: order not paid or refund already in progress",
"order_no", order.OrderNo, "user_id", userID, "order_status", order.OrderStatus)
return nil, errcode.RefundNotAllowed
}
// 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。
// 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。
slog.InfoContext(ctx, "refund applied, order marked refunding",
"order_no", order.OrderNo, "user_id", userID, "refund_no", refundNo,
"refund_amount", order.PayAmount, "order_status", OrderStatusRefunding)
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 {
slog.ErrorContext(ctx, "refund notify: verify or decrypt failed", "remote_addr", request.RemoteAddr, "error", err)
return errcode.RPCError
}
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, data.OutRefundNo)
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "refund notify: acquire lock failed", "out_refund_no", data.OutRefundNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "refund notify: concurrent callback, skip", "out_refund_no", data.OutRefundNo)
return nil
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
flow, ferr := s.refundDao.RefundFlowLogGetByRefundNo(ctx, data.OutRefundNo)
if ferr != nil {
if isNotFound(ferr) {
slog.WarnContext(ctx, "refund notify: refund flow not found", "out_refund_no", data.OutRefundNo)
return nil
}
slog.ErrorContext(ctx, "refund notify: get refund flow failed", "out_refund_no", data.OutRefundNo, "error", ferr)
return errcode.DatabaseError
}
if flow.Status == RefundStatusFailed {
slog.DebugContext(ctx, "refund notify: refund flow already failed, skip", "out_refund_no", data.OutRefundNo)
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 {
slog.ErrorContext(ctx, "refund notify: update refund flow status failed", "out_refund_no", data.OutRefundNo, "error", uerr)
return errcode.DatabaseError
}
if !ok {
slog.WarnContext(ctx, "refund notify: refund flow status changed concurrently", "out_refund_no", data.OutRefundNo)
return errcode.DatabaseError
}
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
if gerr != nil {
slog.ErrorContext(ctx, "refund notify: get order failed", "out_trade_no", data.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
if err := s.settleRefundSuccess(ctx, order, flow.RefundAmount); err != nil {
slog.ErrorContext(ctx, "refund notify: settle refund success failed",
"out_refund_no", data.OutRefundNo, "order_no", order.OrderNo, "error", err)
return errcode.DatabaseError
}
slog.InfoContext(ctx, "refund notify: refund success settled",
"order_no", order.OrderNo, "out_refund_no", data.OutRefundNo, "refund_amount", flow.RefundAmount)
} else if flow.Status != RefundStatusSuccess {
// 退款失败(微信返回 CLOSED/ABNORMAL 等):先把订单回退为「2 已支付」,允许用户重新申请;
// 顺序上先回退订单再落流水失败态,任一步失败时微信重试仍会回到本分支(流水尚未置失败),
// 不会出现「流水已失败但订单卡在 6 申请退款中」的死角。
reverted, rerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusRefunding, OrderStatusPaid)
if rerr != nil {
slog.ErrorContext(ctx, "refund notify: revert order status failed",
"out_trade_no", data.OutTradeNo, "out_refund_no", data.OutRefundNo, "error", rerr)
return errcode.DatabaseError
}
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 {
slog.ErrorContext(ctx, "refund notify: mark refund failed status failed",
"out_refund_no", data.OutRefundNo, "refund_status", data.RefundStatus, "error", err)
return errcode.DatabaseError
}
slog.WarnContext(ctx, "refund notify: refund failed, order reverted to paid",
"order_no", data.OutTradeNo, "out_refund_no", data.OutRefundNo,
"refund_status", data.RefundStatus, "order_reverted", reverted)
}
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 {
slog.InfoContext(ctx, "auto refund: refund flow already exists, skip", "order_no", order.OrderNo)
return
} else if err != nil && !isNotFound(err) {
slog.ErrorContext(ctx, "auto refund: query existing refund flow failed", "order_no", order.OrderNo, "error", err)
}
// 补记支付信息(订单已取消,此前未记录),便于管理员审核退款时核对。
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"pay_time": nowUnix(),
"transaction_id": data.TransactionID,
"callback_raw": data.Raw,
}); uerr != nil {
slog.ErrorContext(ctx, "auto refund: record pay info failed", "order_no", order.OrderNo, "error", uerr)
}
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.WarnContext(ctx, "cancelled order refund flow created, waiting manual review",
"order_no", order.OrderNo, "user_id", order.UserID, "refund_no", refundNo, "refund_amount", order.PayAmount)
}
// ---- 内部辅助 ----
// 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) {
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"pay_time": nowUnix(),
"transaction_id": data.TransactionID,
"callback_raw": data.Raw,
}); uerr != nil {
slog.ErrorContext(ctx, "finalize paid order: update pay info failed", "order_no", order.OrderNo, "error", uerr)
}
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.InfoContext(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
}
if existing.Status == BenefitTaskCancelled {
// 订单曾进入退款流程导致任务被取消,但订单已回到已支付(如退款失败回退):
// 恢复为待发放并立即尝试,避免「已付款却拿不到权益」;若订单实际仍为已退款,
// executeGrant 内的订单状态校验会重新取消该任务(幂等兜底)。
if rerr := taskDao.BenefitGrantTaskReset(ctx, existing.ID); rerr != nil {
slog.WarnContext(ctx, "reset cancelled benefit grant task failed",
"order_no", order.OrderNo, "benefit_type", benefitType, "error", rerr)
return
}
slog.InfoContext(ctx, "revive cancelled benefit grant task for paid order",
"order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID)
s.executeGrant(ctx, existing)
return
}
return // pending 交由定时任务处理。
}
// 凭证创建成功,执行发放(claim 防并发)。
s.executeGrant(ctx, task)
}
// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
var grantErr error
var granted bool
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 {
slog.InfoContext(ctx, "grant benefit skipped: order not paid",
"order_no", task.OrderNo, "benefit_type", task.BenefitType)
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 {
if res.Error == nil {
slog.InfoContext(ctx, "grant benefit skipped: task already claimed or status changed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "task_id", task.ID)
}
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
}
granted = true
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, "user_id", task.UserID,
"retry_count", task.RetryCount, "amount", task.Amount, "error", err)
} else if grantErr != nil {
slog.ErrorContext(ctx, "grant benefit failed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "user_id", task.UserID,
"retry_count", task.RetryCount, "amount", task.Amount, "error", grantErr)
} else if granted {
slog.InfoContext(ctx, "benefit granted",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "user_id", task.UserID, "amount", task.Amount)
}
}
// 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
}
// 用户信息已变更,失效缓存。
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "grant vip: invalidate user cache failed", "user_id", userID, "order_no", orderNo, "error", derr)
}
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 {
slog.InfoContext(ctx, "refund settled", "order_no", order.OrderNo, "user_id", order.UserID, "refund_amount", refundAmount)
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
} else {
slog.InfoContext(ctx, "refund settle skipped: order already settled or not in paid status", "order_no", order.OrderNo)
}
// 立即尝试;失败时任务仍为待处理,由定时任务恢复。
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 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
// 记账口径为「吊销前有效剩余 vs 排除该订单凭证后重算的有效剩余」的差值,与额度聚合规则
// (会员包来源不叠加、只取一份)保持一致:已用部分不追回;吊销后有效剩余不变的凭证
// (例如被其他生效会员包覆盖的重复凭证)不写流水,避免出现用户实际感知不到的回收记录。
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.ErrorContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err)
return nil, err
}
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
// 每类额度「吊销前」的有效剩余基准;excluded 累积本订单已计划吊销的凭证。
before := make(map[int8]int64, len(grants))
beforeKnown := make(map[int8]bool, len(grants))
excluded := make(map[int64]bool, len(grants))
for i := range grants {
g := grants[i]
if g.Amount-g.Used <= 0 {
continue // 已全部用掉,无可回收额度。
}
if !beforeKnown[g.QuotaType] {
if eff, qerr := getEffectiveQuota(ctx, order.UserID, g.QuotaType); qerr == nil {
before[g.QuotaType] = eff.Remain
beforeKnown[g.QuotaType] = true
} else {
slog.WarnContext(ctx, "build quota revoke flows: calc effective quota failed",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "error", qerr)
}
}
excluded[g.ID] = true
if !beforeKnown[g.QuotaType] {
continue // 基准未知:宁可少记不可记错,跳过该凭证的回收流水。
}
after, aerr := effectiveQuotaRemainExcluding(ctx, order.UserID, g.QuotaType, excluded)
if aerr != nil {
slog.WarnContext(ctx, "build quota revoke flows: calc remain after revoke failed",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "grant_id", g.ID, "error", aerr)
beforeKnown[g.QuotaType] = false // 后续同类凭证不再基于错误基准记账。
continue
}
revoke := before[g.QuotaType] - after
before[g.QuotaType] = after
if revoke < 0 {
// 吊销后有效剩余反而增加(例如另一份未生效会员包被启用),属预期内的覆盖切换,只留痕不改动用户剩余。
slog.InfoContext(ctx, "quota revoke increases effective remain, skip flow",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "grant_id", g.ID, "delta", -revoke)
continue
}
if revoke == 0 {
continue // 吊销不改变有效剩余(重复凭证被覆盖),无回收流水。
}
flows = append(flows, &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: order.UserID,
ChangeTarget: QuotaChangeTargetAdjust,
ChangeType: QuotaChangeTypeRefundRevoke,
ChangeNum: -revoke,
OldValue: before[g.QuotaType] + revoke,
NewValue: before[g.QuotaType],
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
}
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "revoke vip: invalidate user cache failed", "user_id", userID, "order_no", orderNo, "error", derr)
}
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)
}