811 lines
29 KiB
Go
811 lines
29 KiB
Go
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"
|
||
)
|
||
|
||
// 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,
|
||
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 == RefundStatusSuccess || 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" {
|
||
// 条件流转,防止并发/乱序回调覆盖状态。
|
||
ok, _ := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
|
||
"status": RefundStatusSuccess,
|
||
"success_time": nowUnix(),
|
||
"callback_raw": data.Raw,
|
||
})
|
||
if !ok {
|
||
return nil // 已被并发回调处理。
|
||
}
|
||
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
|
||
if gerr == nil {
|
||
s.settleRefundSuccess(ctx, order, flow.RefundAmount)
|
||
}
|
||
} else {
|
||
_, _ = s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
|
||
"status": RefundStatusFailed,
|
||
"fail_reason": "微信退款未成功:" + data.RefundStatus,
|
||
"callback_raw": data.Raw,
|
||
})
|
||
}
|
||
|
||
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) {
|
||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
||
claimed, err := taskDao.BenefitGrantTaskClaim(ctx, task.ID)
|
||
if err != nil || !claimed {
|
||
return // 已被其他实例处理。
|
||
}
|
||
if e := s.doGrantBenefit(ctx, task); e != nil {
|
||
// 发放失败:回退为待发放并记录错误,交由补偿任务重试。
|
||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{
|
||
"status": BenefitTaskPending,
|
||
"retry_count": gorm.Expr("retry_count + 1"),
|
||
"last_error": e.Error(),
|
||
})
|
||
slog.ErrorContext(ctx, "grant benefit failed",
|
||
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", e)
|
||
return
|
||
}
|
||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
|
||
}
|
||
|
||
// 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 := "购买会员赠送"
|
||
|
||
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
|
||
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
|
||
source = QuotaGrantSourceAddon
|
||
days := goods.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,
|
||
}
|
||
if _, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow); 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) {
|
||
from := order.OrderStatus
|
||
if from != OrderStatusPaid && from != OrderStatusCancelled {
|
||
return
|
||
}
|
||
ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded)
|
||
if !ok {
|
||
return
|
||
}
|
||
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
|
||
"refund_time": nowUnix(),
|
||
"refund_amount": refundAmount,
|
||
})
|
||
// 仅已支付订单回退已发放权益;已取消订单权益从未发放,无需回收。
|
||
if from == OrderStatusPaid {
|
||
s.revokeOrderBenefits(ctx, order)
|
||
}
|
||
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
|
||
}
|
||
|
||
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
|
||
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度),
|
||
// 并在同一事务内写「退款回收」流水,保证额度明细能看到回收记录。
|
||
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
|
||
if order.MemberDays > 0 {
|
||
s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo)
|
||
}
|
||
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)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err)
|
||
return
|
||
}
|
||
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 {
|
||
if !isDuplicateKey(err) {
|
||
slog.ErrorContext(ctx, "revoke vip failed", "order_no", orderNo, "error", err)
|
||
}
|
||
}
|
||
// 会员信息已变更,失效缓存。
|
||
_ = redisdao.UserInfoDel(ctx, userID)
|
||
}
|
||
|
||
// 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)
|
||
}
|