first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+747
View File
@@ -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)
}