Files
zt-closet-backend/internal/service/order.go
T
2026-09-11 01:41:52 +08:00

748 lines
26 KiB
Go
Raw 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"
)
// 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)
}