This commit is contained in:
zhengchiliu
2026-09-15 00:53:04 +08:00
parent 32a0b87e9e
commit 112c5f8e46
42 changed files with 1121 additions and 304 deletions
+13 -1
View File
@@ -69,7 +69,12 @@ func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.
QuotaType: quotaType,
Reason: "广告激励赠送",
}
created, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow)
maxClaims := global.AdRewardSetting.MaxClaimsPerDay
if maxClaims <= 0 {
maxClaims = 1
}
dayStart, dayEnd := todayRangeUnix()
created, err := mysqldao.NewQuotaGrantDao().AdRewardGrantCreateWithFlow(ctx, grant, flow, dayStart, dayEnd, maxClaims)
if err != nil {
return nil, errcode.DatabaseError
}
@@ -126,6 +131,13 @@ func parseAdRewardType(rewardType string) (int8, bool) {
}
}
// todayRangeUnix 返回本地时区当天的左闭右开时间范围。
func todayRangeUnix() (int64, int64) {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return start.Unix(), start.AddDate(0, 0, 1).Unix()
}
// todayEndUnix 返回当天 23:59:59 的秒级时间戳(广告激励当天有效)。
func todayEndUnix() int64 {
now := time.Now()
+13 -14
View File
@@ -15,6 +15,7 @@ import (
"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/tokenhub"
"gorm.io/gorm"
)
// AIRecommendService AI 穿搭推荐业务服务。
@@ -112,19 +113,18 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *
AuditStatus: OutfitAuditPending,
Status: OutfitStatusUnsaved,
}
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
return nil, fail(errcode.OutfitCreateFailed, "穿搭落库失败")
}
clothesIDs := result.GetClothesIDs()
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
for _, cid := range clothesIDs {
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
}
if err := s.outfitItemDao.OutfitItemCreateBatch(ctx, items); err != nil {
// 明细落库失败:删除已建穿搭并回退额度,避免产生无明细的空推荐。
_ = s.outfitDao.OutfitDeleteByUUID(ctx, outfit.OutfitUUID)
return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(outfit).Error; err != nil {
return err
}
clothesIDs := result.GetClothesIDs()
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
for _, cid := range clothesIDs {
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
}
return tx.Create(&items).Error
}); err != nil {
return nil, fail(errcode.OutfitCreateFailed, "穿搭及明细落库失败")
}
// 7. 写成功流水 + 当日计数。
@@ -140,7 +140,6 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *
}); err != nil {
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
}
recordDailyUsage(ctx, userID, QuotaTypeAI)
// 8. 组装响应(衣物 UUID 来自校正后的 items)。
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
+51 -5
View File
@@ -9,6 +9,7 @@ import (
"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"
"gorm.io/gorm"
)
// StartBenefitCompensationScheduler 启动权益发放补偿定时任务:周期性重试发放失败的权益。
@@ -46,6 +47,15 @@ func runBenefitCompensation() {
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
taskDao := mysqldao.NewBenefitGrantTaskDao()
s := NewOrderService()
// 对账已支付但任务未完整落库的订单,覆盖支付状态提交后进程崩溃/数据库瞬时错误。
if orders, err := mysqldao.NewOrderDao().OrderListPaidMissingBenefitTasks(ctx, 100); err == nil {
for i := range orders {
s.grantOrderBenefits(ctx, &orders[i])
}
} else {
slog.ErrorContext(ctx, "reconcile paid order benefits failed", "error", err)
}
// 恢复超时的「发放中」任务(认领后崩溃的场景),使其回到待发放继续重试。
_, _ = taskDao.BenefitGrantTaskRecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
@@ -55,14 +65,19 @@ func runBenefitCompensation() {
slog.ErrorContext(ctx, "list pending benefit tasks failed", "error", err)
return
}
if len(tasks) == 0 {
return
}
s := NewOrderService()
for i := range tasks {
s.retryBenefitTask(ctx, &tasks[i])
}
revokeDao := mysqldao.NewBenefitRevokeTaskDao()
_ = revokeDao.RecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
if revokeTasks, err := revokeDao.ListPending(ctx, 100); err == nil {
for i := range revokeTasks {
s.executeRevokeTask(ctx, &revokeTasks[i])
}
} else {
slog.ErrorContext(ctx, "list pending benefit revoke tasks failed", "error", err)
}
}
// retryBenefitTask 重试单个补偿任务(幂等:先查发放流水避免重复发放)。
@@ -110,3 +125,34 @@ func (s *OrderService) benefitAlreadyGranted(ctx context.Context, task *mysqlmod
return false, nil
}
}
func (s *OrderService) executeRevokeTaskByOrder(ctx context.Context, orderNo string) {
task, err := mysqldao.NewBenefitRevokeTaskDao().GetByOrderNo(ctx, orderNo)
if err == nil {
s.executeRevokeTask(ctx, task)
}
}
func (s *OrderService) executeRevokeTask(ctx context.Context, task *mysqlmodel.BenefitRevokeTask) {
dao := mysqldao.NewBenefitRevokeTaskDao()
if task.Status == BenefitTaskSuccess {
return
}
if task.RetryCount >= task.MaxRetry {
slog.ErrorContext(ctx, "benefit revoke task exceeded normal retry threshold; continuing for eventual consistency", "order_no", task.OrderNo, "retry_count", task.RetryCount)
}
claimed, err := dao.Claim(ctx, task.ID)
if err != nil || !claimed {
return
}
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
if err == nil {
err = s.revokeOrderBenefits(ctx, order)
}
if err != nil {
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": err.Error()})
slog.ErrorContext(ctx, "revoke refunded order benefits failed", "order_no", task.OrderNo, "error", err)
return
}
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess, "last_error": ""})
}
+12 -17
View File
@@ -11,6 +11,7 @@ import (
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
"gorm.io/gorm"
)
// ClosetShareService 衣柜分享业务服务。
@@ -72,26 +73,22 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
return nil, qerr
}
// 3. 创建分享。
// 3. 原子创建分享及快照明细,避免留下空分享。
shareUUID := genUUID()
share := &mysqlmodel.ClosetShare{
ShareUUID: shareUUID,
UserID: userID,
ShareType: request.ShareType,
ExpireTime: request.ExpireTime,
Status: ShareStatusNormal,
}
if err := s.shareDao.ClosetShareCreate(ctx, share); err != nil {
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
return nil, errcode.DatabaseError
}
// 4. 快照衣物明细(明细是分享核心数据,失败则回退额度并报错,避免产生空分享)。
share := &mysqlmodel.ClosetShare{ShareUUID: shareUUID, UserID: userID, ShareType: request.ShareType, ExpireTime: request.ExpireTime, Status: ShareStatusNormal}
items := make([]mysqlmodel.ClosetShareItem, 0, len(clothesIDs))
for _, cid := range clothesIDs {
items = append(items, mysqlmodel.ClosetShareItem{ShareUUID: shareUUID, ClothesID: cid})
}
if err := s.shareItemDao.ClosetShareItemCreateBatch(ctx, items); err != nil {
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(share).Error; err != nil {
return err
}
if len(items) > 0 {
return tx.Create(&items).Error
}
return nil
}); err != nil {
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
return nil, errcode.DatabaseError
}
@@ -105,8 +102,6 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
slog.WarnContext(ctx, "write closet audit log failed", "share_id", share.ID, "error", err)
}
recordDailyUsage(ctx, userID, QuotaTypeClosetShare)
return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil
}
+81 -12
View File
@@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"mime/multipart"
"os"
"time"
"go.opentelemetry.io/otel/trace"
@@ -87,20 +88,28 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
// 5. 创建衣物记录,初始状态为排队中。
clothesUUID := genUUID()
originalName := clothesUUID + ext
originalPath, serr := upload.FileSaveTmpBytes(ctx, imageData, originalName, fmt.Sprintf("pending/%d", userID))
if serr != nil {
refundCosQuota(ctx, userID, 1, 0, "原图持久化失败回退")
return nil, errcode.UploadFileSaveFailed
}
clothes := &mysqlmodel.Clothes{
ClothesUUID: clothesUUID,
UserID: userID,
ImageSize: fileHeader.Size / 1024,
ImageProcessStatus: ImageProcessQueuing,
OriginalImagePath: originalPath,
}
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
_ = os.Remove(originalPath)
// 建记录失败,回退已扣额度。
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
return nil, errcode.ClothesCreateFailed
}
// 6. 异步抠图 + 分类。
go s.processImageAsync(ctx, userID, clothesUUID, imageData)
go s.processImageAsync(ctx, userID, clothesUUID, imageData, originalPath)
return &dto.ClothesUploadResponse{
ClothesUUID: clothesUUID,
@@ -109,7 +118,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
}
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte) {
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte, originalPath string) {
// 限流:获取信号量,超出并发上限时阻塞等待。
imageProcessSem <- struct{}{}
defer func() { <-imageProcessSem }()
@@ -152,11 +161,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
return
}
subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID)
if _, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir); serr != nil {
processedPath, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir)
if serr != nil {
slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr)
s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
return
}
defer os.Remove(processedPath)
cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr)
@@ -209,10 +220,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
"processed_image": cosKey,
"image_process_status": ImageProcessSuccess,
"fail_reason": "",
"original_image_path": "",
}
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
return
}
_ = os.Remove(originalPath)
}
// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
@@ -258,35 +272,42 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
vipExpire := getUserVIPExpire(ctx, userID)
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 调用方的三分钟处理 context 可能已超时;清理与额度回退必须使用独立短 context。
cleanupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
vipExpire := getUserVIPExpire(cleanupCtx, userID)
var originalPath string
err := global.MysqlEngine.WithContext(cleanupCtx).Transaction(func(tx *gorm.DB) error {
var c mysqlmodel.Clothes
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("clothes_uuid = ?", clothesUUID).
First(&c).Error; err != nil {
if isNotFound(err) {
// 衣物已删除:删除事务已回退额度,跳过。
return nil
}
return err
}
// 已是失败态:说明失败回退已处理过,跳过。
if c.ImageProcessStatus == ImageProcessFailed {
return nil
}
// 置失败。
originalPath = c.OriginalImagePath
if err := tx.Model(&mysqlmodel.Clothes{}).
Where("clothes_uuid = ?", clothesUUID).
Updates(map[string]any{"image_process_status": ImageProcessFailed, "fail_reason": reason}).Error; err != nil {
Updates(map[string]any{
"image_process_status": ImageProcessFailed,
"fail_reason": reason,
"original_image_path": "",
}).Error; err != nil {
return err
}
// 同事务回退额度(优先免费额度,不足回退凭证)。
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
})
if err != nil {
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
return
}
if originalPath != "" {
_ = os.Remove(originalPath)
}
}
@@ -416,6 +437,7 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
// 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
vipExpire := getUserVIPExpire(ctx, userID)
var processedImage string
var originalImagePath string
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var c mysqlmodel.Clothes
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -424,6 +446,7 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
return err
}
processedImage = c.ProcessedImage
originalImagePath = c.OriginalImagePath
if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
return err
}
@@ -442,6 +465,9 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
}
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
if originalImagePath != "" {
_ = os.Remove(originalImagePath)
}
if processedImage != "" {
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr)
@@ -524,3 +550,46 @@ func setIfNotEmpty(m map[string]any, key, val string) {
m[key] = val
}
}
// StartClothesProcessingRecoveryScheduler 周期恢复因进程退出或超时遗留的图片任务。
func StartClothesProcessingRecoveryScheduler() {
go func() {
slog.Info("clothes processing recovery scheduler started")
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("clothes processing recovery scheduler panic", "recover", r)
}
}()
runClothesProcessingRecovery()
}()
time.Sleep(time.Minute)
}
}()
}
func runClothesProcessingRecovery() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
before := time.Now().Add(-5 * time.Minute).Unix()
dao := mysqldao.NewClothesDao()
list, err := dao.ClothesListStaleProcessing(ctx, before, 50)
if err != nil {
slog.ErrorContext(ctx, "list stale clothes processing tasks failed", "error", err)
return
}
service := NewClothesService()
for i := range list {
claimed, err := dao.ClothesClaimStaleProcessing(ctx, list[i].ClothesUUID, before)
if err != nil || !claimed {
continue
}
imageData, err := os.ReadFile(list[i].OriginalImagePath)
if err != nil || !upload.FileCheckImageMagic(imageData) {
service.markProcessFailed(ctx, list[i].UserID, list[i].ClothesUUID, "原图丢失或损坏,任务无法恢复")
continue
}
go service.processImageAsync(context.Background(), list[i].UserID, list[i].ClothesUUID, imageData, list[i].OriginalImagePath)
}
}
+18 -16
View File
@@ -36,12 +36,12 @@ const (
QuotaChangeTargetUse int8 = 2 // 使用
// ChangeType 变更类型
QuotaChangeTypePurchase int8 = 1 // 购买获赠
QuotaChangeTypeActivity int8 = 2 // 活动赠送
QuotaChangeTypeConsume int8 = 3 // 上传消耗
QuotaChangeTypeRefund int8 = 4 // 删除回退
QuotaChangeTypeManual int8 = 5 // 人工调整
QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收
QuotaChangeTypePurchase int8 = 1 // 购买获赠
QuotaChangeTypeActivity int8 = 2 // 活动赠送
QuotaChangeTypeConsume int8 = 3 // 上传消耗
QuotaChangeTypeRefund int8 = 4 // 删除回退
QuotaChangeTypeManual int8 = 5 // 人工调整
QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收
)
// 新用户默认免费额度(内置兜底值)。
@@ -154,10 +154,10 @@ const (
PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
// 虚拟支付平台(由前端按 systemInfo.platform 上报)
XPayPlatformAndroid = "android"
XPayPlatformIOS = "ios"
XPayPlatformHarmony = "harmony"
XPayPlatformWindows = "windows"
XPayPlatformAndroid = "android"
XPayPlatformIOS = "ios"
XPayPlatformHarmony = "harmony"
XPayPlatformWindows = "windows"
)
// ===== 退款 Refund =====
@@ -170,12 +170,12 @@ const (
// ===== VIP 流水 =====
const (
VIPChangeTypeOpen int8 = 1 // 开通
VIPChangeTypeRenew int8 = 2 // 续费
VIPChangeTypeActivity int8 = 3 // 活动赠送
VIPChangeTypeExpire int8 = 4 // 到期失效
VIPChangeTypeManual int8 = 5 // 人工调整
VIPChangeTypeRefundRevoke int8 = 6 // 退款回收
VIPChangeTypeOpen int8 = 1 // 开通
VIPChangeTypeRenew int8 = 2 // 续费
VIPChangeTypeActivity int8 = 3 // 活动赠送
VIPChangeTypeExpire int8 = 4 // 到期失效
VIPChangeTypeManual int8 = 5 // 人工调整
VIPChangeTypeRefundRevoke int8 = 6 // 退款回收
VIPSourcePersonalPay int8 = 1 // 个人支付
VIPSourceActivity int8 = 2 // 活动赠送
@@ -204,10 +204,12 @@ const (
BenefitTaskSuccess int8 = 1 // 已发放
BenefitTaskFailed int8 = 2 // 发放失败(重试超限)
BenefitTaskProcessing int8 = 3 // 发放中(认领后,防并发重复发放)
BenefitTaskCancelled int8 = 4 // 订单已退款,禁止继续发放
benefitMaxRetry int = 3 // 默认最大重试次数
// benefitRetryCooldownSeconds 失败任务被回调重新触发的冷却期(秒):
// 冷却期内不重置,防止微信回调重试风暴无限重置任务绕过重试上限。
benefitRetryCooldownSeconds int64 = 300
benefitRevokeMaxRetry = 10
)
+94 -89
View File
@@ -16,6 +16,7 @@ import (
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OrderService 订单与退款业务服务。
@@ -81,6 +82,8 @@ func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.Or
PayChannel: PayChannelWechat,
OrderStatus: OrderStatusPending,
MemberDays: goods.MemberDays,
GoodsType: goods.GoodsType,
QuotaValidDays: goods.ValidDays,
CosQuota: goods.CosQuota,
AIRecommendQuota: goods.AIRecommendQuota,
ClosetShareQuota: goods.ClosetShareQuota,
@@ -358,43 +361,35 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
}
return errcode.DatabaseError
}
// 已终态则幂等返回。
if flow.Status == RefundStatusSuccess || flow.Status == RefundStatusFailed {
if 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,
)
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 // 已被并发回调处理。
if flow.Status != RefundStatusSuccess {
ok, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix(), "callback_raw": data.Raw})
if uerr != nil {
return errcode.DatabaseError
}
if !ok {
return errcode.DatabaseError
}
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
if gerr == nil {
s.settleRefundSuccess(ctx, order, flow.RefundAmount)
if gerr != nil {
return errcode.DatabaseError
}
if err := s.settleRefundSuccess(ctx, order, flow.RefundAmount); err != nil {
return errcode.DatabaseError
}
} else if flow.Status != RefundStatusSuccess {
if _, err := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusFailed, "fail_reason": "微信退款未成功:" + data.RefundStatus, "callback_raw": data.Raw}); err != nil {
return errcode.DatabaseError
}
} else {
_, _ = s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
"status": RefundStatusFailed,
"fail_reason": "微信退款未成功:" + data.RefundStatus,
"callback_raw": data.Raw,
})
}
return nil
}
@@ -538,23 +533,30 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
// 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 // 已被其他实例处理。
var grantErr error
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order mysqlmodel.Order
if err := tx.Select("id", "order_status").Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", task.OrderNo).First(&order).Error; err != nil {
return err
}
if order.OrderStatus != OrderStatusPaid {
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskCancelled).Error
}
res := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskProcessing)
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
if e := s.doGrantBenefit(ctx, task); e != nil {
grantErr = e
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": e.Error()}).Error
}
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskSuccess, "last_error": ""}).Error
})
if err != nil {
slog.ErrorContext(ctx, "grant benefit task update failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err)
} else if grantErr != nil {
slog.ErrorContext(ctx, "grant benefit failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", grantErr)
}
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 实际发放,额度类写入有有效期凭证。
@@ -587,10 +589,22 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
expireTime := int64(0) // 0 表示跟随 VIP
reason := "购买会员赠送"
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
if err != nil {
return errcode.DatabaseError
}
goodsType, validDays := order.GoodsType, order.QuotaValidDays
if goodsType == 0 { // 兼容迁移前创建、尚未回填的历史订单。
goods, gerr := s.goodsDao.GoodsGetByID(ctx, task.GoodsID)
if gerr != nil {
return errcode.DatabaseError
}
goodsType, validDays = goods.GoodsType, goods.ValidDays
}
if goodsType != GoodsTypeMember {
// 加量包:独立有效期,从发放时刻起算快照 valid_days 天;未配置则兜底 30 天。
source = QuotaGrantSourceAddon
days := goods.ValidDays
days := validDays
if days <= 0 {
days = 30
}
@@ -625,7 +639,14 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
QuotaType: quotaType,
Reason: reason,
}
if _, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow); err != nil {
grantDao := mysqldao.NewQuotaGrantDao()
if source == QuotaGrantSourceMember {
// 同一用户、同一会员包只赠送一次;续费和补偿重试共用事务内检查。
_, err = grantDao.MemberQuotaGrantCreateWithFlow(ctx, task.GoodsID, grant, flow)
} else {
_, err = grantDao.QuotaGrantCreateWithFlow(ctx, grant, flow)
}
if err != nil {
return errcode.DatabaseError
}
return nil
@@ -674,46 +695,42 @@ func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, o
// 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
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) error {
settled, err := mysqldao.NewBenefitRevokeTaskDao().RefundSettleWithRevokeTask(ctx, order.OrderNo, refundAmount, nowUnix())
if err != nil {
return err
}
ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded)
if !ok {
return
if settled {
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
}
_ = 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+"」已退款")
// 立即尝试;失败时任务仍为待处理,由定时任务恢复。
s.executeRevokeTaskByOrder(ctx, order.OrderNo)
return nil
}
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度),
// 并在同一事务内写「退款回收」流水,保证额度明细能看到回收记录。
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) error {
if order.MemberDays > 0 {
s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo)
if err := s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo); err != nil {
return err
}
}
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)
flows, err := s.buildQuotaRevokeFlows(ctx, order)
if err != nil {
return err
}
return mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefIDWithFlows(ctx, order.OrderNo, flows)
}
// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) []*mysqlmodel.QuotaFlowLog {
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) {
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
if err != nil {
slog.WarnContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err)
return nil
return nil, err
}
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
@@ -747,33 +764,21 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
Reason: "退款回收未使用的赠送额度",
})
}
return flows
return flows, nil
}
// revokeVIP 幂等回退会员天数(退款回收)。
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) {
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) error {
user, err := s.userDao.UserGetByID(ctx, userID)
if err != nil {
slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err)
return
return err
}
flow := &mysqlmodel.VIPFlowLog{
UserID: userID,
ChangeType: VIPChangeTypeRefundRevoke,
OldVIPType: user.VIPType,
NewVIPType: user.VIPType,
OldVIPExpire: user.VIPExpire,
OrderNo: orderNo,
Source: VIPSourceSystem,
Reason: "退款回收会员",
flow := &mysqlmodel.VIPFlowLog{UserID: userID, ChangeType: VIPChangeTypeRefundRevoke, OldVIPType: user.VIPType, NewVIPType: user.VIPType, OldVIPExpire: user.VIPExpire, OrderNo: orderNo, Source: VIPSourceSystem, Reason: "退款回收会员"}
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil && !isDuplicateKey(err) {
return err
}
if 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)
return nil
}
// buildOrderResponse 将订单模型映射为响应。
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"context"
"errors"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"github.com/DATA-DOG/go-sqlmock"
"go.opentelemetry.io/otel/trace/noop"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupOrderQuotaTest(t *testing.T) sqlmock.Sqlmock {
t.Helper()
conn, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
oldDB, oldTracer := global.MysqlEngine, global.Tracer
global.MysqlEngine, global.Tracer = db, noop.NewTracerProvider().Tracer("order-test")
t.Cleanup(func() { global.MysqlEngine, global.Tracer = oldDB, oldTracer; conn.Close() })
return mock
}
func TestOrderQuotaGoodsReadFailureDoesNotGrant(t *testing.T) {
mock := setupOrderQuotaTest(t)
mock.ExpectQuery("SELECT .* FROM `orders`").WillReturnRows(sqlmock.NewRows([]string{"order_no", "goods_type", "quota_valid_days"}).AddRow("RENEW", 0, 0))
mock.ExpectQuery("SELECT .* FROM `goods`").WillReturnError(errors.New("database unavailable"))
err := NewOrderService().grantQuotaGrant(context.Background(), &mysqlmodel.BenefitGrantTask{UserID: 7, GoodsID: 9, OrderNo: "RENEW", Amount: 100}, QuotaTypeCos)
if err != errcode.DatabaseError {
t.Fatalf("got %v, want DatabaseError", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOrderRenewalAndCompensationSkipQuota(t *testing.T) {
mock := setupOrderQuotaTest(t)
svc := NewOrderService()
// 首购凭证已存在:新续费订单和该订单补偿执行均不得插入额度或流水。
for i := 0; i < 2; i++ {
mock.ExpectQuery("SELECT .* FROM `orders`").WillReturnRows(sqlmock.NewRows([]string{"order_no", "goods_type", "quota_valid_days"}).AddRow("RENEW", GoodsTypeMember, 30))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).AddRow(1, QuotaGrantSourceMember, 100, 40, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
mock.ExpectQuery("SELECT quota_grants.* .*FOR UPDATE").WithArgs(7, 9, QuotaTypeCos, QuotaGrantSourceMember, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "amount", "used", "ref_id"}).AddRow(1, 100, 40, "FIRST"))
mock.ExpectCommit()
err := svc.doGrantBenefit(context.Background(), &mysqlmodel.BenefitGrantTask{UserID: 7, GoodsID: 9, OrderNo: "RENEW", Amount: 100, BenefitType: BenefitTypeCos})
if err != nil {
t.Fatal(err)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+24 -15
View File
@@ -60,11 +60,19 @@ func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.O
AuditStatus: OutfitAuditPending,
Status: OutfitStatusNormal,
}
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
return nil, errcode.OutfitCreateFailed
}
if err := s.createItems(ctx, outfit.ID, clothesIDs); err != nil {
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(outfit).Error; err != nil {
return err
}
dbItems := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
for _, cid := range clothesIDs {
dbItems = append(dbItems, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
}
if len(dbItems) > 0 {
return tx.Create(&dbItems).Error
}
return nil
}); err != nil {
return nil, errcode.OutfitCreateFailed
}
@@ -119,14 +127,12 @@ func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request
setIfNotEmpty(updates, "outfit_name", request.OutfitName)
setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc)
setIfNotEmpty(updates, "scene", request.Scene)
if len(updates) > 0 {
if err := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, updates); err != nil {
return nil, errcode.OutfitUpdateFailed
}
}
// 重建明细(事务内先删后建,避免中途失败留下空明细)。
rebuildErr := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if len(updates) > 0 {
if err := tx.Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", request.OutfitUUID).Updates(updates).Error; err != nil {
return err
}
}
if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
return err
}
@@ -178,10 +184,14 @@ func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.O
if cerr != nil {
return nil, cerr
}
if err := s.outfitDao.OutfitDeleteByUUID(ctx, request.OutfitUUID); err != nil {
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
return err
}
return tx.Where("outfit_uuid = ?", request.OutfitUUID).Delete(&mysqlmodel.Outfit{}).Error
}); err != nil {
return nil, errcode.OutfitDeleteFailed
}
_ = s.outfitItemDao.OutfitItemDeleteByOutfitID(ctx, outfit.ID)
return &dto.OutfitDeleteResponse{}, nil
}
@@ -250,7 +260,6 @@ func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.O
}
// 记录当日社区分享计数(统计)。
recordDailyUsage(ctx, userID, QuotaTypeOutfitShare)
return &dto.OutfitPublicResponse{}, nil
}
+29 -43
View File
@@ -82,37 +82,9 @@ func genUUID() string {
// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。
// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。
func recordDailyUsage(ctx context.Context, userID int64, kind int8) {
usageDao := mysqldao.NewUserDailyUsageDao()
statDate := todayStatDate()
// 确保当日计数行存在。
if _, err := usageDao.UserDailyUsageGetByUserIDAndDate(ctx, userID, statDate); err != nil {
if isNotFound(err) {
if cerr := usageDao.UserDailyUsageCreate(ctx, &mysqlmodel.UserDailyUsage{
UserID: userID,
StatDate: statDate,
}); cerr != nil {
slog.WarnContext(ctx, "create daily usage failed", "user_id", userID, "error", cerr)
return
}
} else {
slog.WarnContext(ctx, "get daily usage failed", "user_id", userID, "error", err)
return
}
}
var err error
switch kind {
case QuotaTypeAI:
err = usageDao.UserDailyUsageIncrementAI(ctx, userID, statDate)
case QuotaTypeOutfitShare:
err = usageDao.UserDailyUsageIncrementOutfitShare(ctx, userID, statDate)
case QuotaTypeClosetShare:
err = usageDao.UserDailyUsageIncrementClosetShare(ctx, userID, statDate)
}
if err != nil {
slog.WarnContext(ctx, "increment daily usage failed", "user_id", userID, "kind", kind, "error", err)
func recordDailyUsage(ctx context.Context, userID int64, kind int8, delta int64) {
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), kind, delta); err != nil {
slog.WarnContext(ctx, "adjust daily usage failed", "user_id", userID, "kind", kind, "delta", delta, "error", err)
}
}
@@ -216,16 +188,24 @@ func dailyQuotaKey(quotaType int8, userID int64, date int64) string {
}
}
// getDailyUsed 读取当日已用计数(Redis 优先,异常回落 MySQL 统计)。
// getDailyUsed 读取 MySQL 权威当日计数;数据库异常时只把 Redis 作为展示兜底。
func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 {
key := dailyQuotaKey(quotaType, userID, todayStatDate())
if key == "" {
return 0
usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
if err == nil {
switch quotaType {
case QuotaTypeAI:
return usage.AIRecommendCount
case QuotaTypeOutfitShare:
return usage.OutfitShareCount
case QuotaTypeClosetShare:
return usage.ClosetShareCount
}
}
if val, err := redisdao.DailyQuotaValue(ctx, key); err == nil {
key := dailyQuotaKey(quotaType, userID, todayStatDate())
if val, rerr := redisdao.DailyQuotaValue(ctx, key); rerr == nil {
return val
}
return getDailyUsedFromMySQL(ctx, userID, quotaType)
return 0
}
// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。
@@ -257,15 +237,17 @@ func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int6
if key == "" {
return errcode.QuotaInsufficient
}
val, err := redisdao.DailyQuotaConsume(ctx, key, quota.Total, n)
consumed, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageConsume(ctx, userID, todayStatDate(), quotaType, n, quota.Total)
if err != nil {
return errcode.CacheError
return errcode.DatabaseError
}
if val < 0 {
if !consumed {
return errcode.QuotaInsufficient
}
// 同步 MySQL 当日计数(统计/兜底)。
recordDailyUsage(ctx, userID, quotaType)
// Redis 只作缓存;删除后下次从 MySQL 权威计数恢复,不受丢键影响。
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
slog.WarnContext(ctx, "delete daily quota cache failed", "user_id", userID, "quota_type", quotaType, "error", err)
}
return nil
}
@@ -275,8 +257,12 @@ func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64
if key == "" {
return
}
if err := redisdao.DailyQuotaRefund(ctx, key, n); err != nil {
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), quotaType, -n); err != nil {
slog.WarnContext(ctx, "refund daily quota failed", "user_id", userID, "quota_type", quotaType, "error", err)
return
}
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
slog.WarnContext(ctx, "delete daily quota cache after refund failed", "user_id", userID, "quota_type", quotaType, "error", err)
}
}
+17 -15
View File
@@ -68,24 +68,26 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
platform := request.Platform
order := &mysqlmodel.Order{
OrderNo: orderNo,
UserID: userID,
GoodsID: goods.ID,
GoodsName: goods.GoodsName,
UnitPrice: goods.DiscountPrice,
TotalAmount: payAmount,
PayAmount: payAmount,
PayChannel: PayChannelVirtual,
OrderStatus: OrderStatusPending,
MemberDays: goods.MemberDays,
CosQuota: goods.CosQuota,
OrderNo: orderNo,
UserID: userID,
GoodsID: goods.ID,
GoodsName: goods.GoodsName,
UnitPrice: goods.DiscountPrice,
TotalAmount: payAmount,
PayAmount: payAmount,
PayChannel: PayChannelVirtual,
OrderStatus: OrderStatusPending,
MemberDays: goods.MemberDays,
GoodsType: goods.GoodsType,
QuotaValidDays: goods.ValidDays,
CosQuota: goods.CosQuota,
AIRecommendQuota: goods.AIRecommendQuota,
ClosetShareQuota: goods.ClosetShareQuota,
OutfitShareQuota: goods.OutfitShareQuota,
ExpireTime: expireTime,
XPayProductID: goods.ProductID,
XPayPlatform: platform,
XPayEnv: int8(xpayEnv),
ExpireTime: expireTime,
XPayProductID: goods.ProductID,
XPayPlatform: platform,
XPayEnv: int8(xpayEnv),
}
if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil {
return nil, errcode.VirtualOrderCreateFailed