first commit
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
grant := &mysqlmodel.QuotaGrant{
|
||||
UserID: task.UserID,
|
||||
QuotaType: quotaType,
|
||||
Amount: task.Amount,
|
||||
Used: 0,
|
||||
Source: source,
|
||||
ExpireTime: expireTime,
|
||||
RefID: task.OrderNo,
|
||||
}
|
||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return 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)
|
||||
}
|
||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefID(ctx, order.OrderNo); err != nil {
|
||||
slog.WarnContext(ctx, "invalidate quota grants failed", "order_no", order.OrderNo, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user