package service import ( "context" "crypto/rand" "errors" "fmt" "math/big" "net/url" "os" "strconv" "strings" "time" "github.com/aliyun/aliyun-oss-go-sdk/oss" "github.com/aliyun/credentials-go/credentials" "github.com/google/uuid" eventPb "github.com/topfans/backend/pkg/proto/event" "github.com/topfans/backend/pkg/statistic" appErrors "github.com/topfans/backend/pkg/errors" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/asset" pbCommon "github.com/topfans/backend/pkg/proto/common" "github.com/topfans/backend/pkg/validator" "github.com/topfans/backend/services/assetService/client" "github.com/topfans/backend/services/assetService/config" "github.com/topfans/backend/services/assetService/repository" "github.com/topfans/backend/services/assetService/util" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" "google.golang.org/grpc/codes" ) // MintService 铸造服务接口 type MintService interface { // InitMintOrder 阶段一:初始化订单(仅落库 order_id,status=PENDING,幂等) InitMintOrder(orderID string, userID, starID int64) (*pb.InitMintOrderResponse, error) // PreCreateMintOrder 阶段一:预创建订单(生成 order_id) PreCreateMintOrder(req *pb.PreCreateMintOrderRequest, userID, starID int64) (*pb.PreCreateMintOrderResponse, error) // CreateMintOrder 创建铸造订单 CreateMintOrder(req *pb.CreateMintOrderRequest, userID, starID int64) (*pb.CreateMintOrderResponse, error) // GetMintOrder 查询铸造订单状态 GetMintOrder(orderID string, userID, starID int64) (*pb.GetMintOrderResponse, error) // CancelMintOrder 取消铸造订单 CancelMintOrder(orderID string, userID, starID int64) error // GetMintCost 获取铸造消耗配置 GetMintCost(mintCount int32) (*models.MintCostConfig, error) // GetUserMintCount 获取用户累计铸爱次数 GetUserMintCount(userID, starID int64) (int32, error) // EstimateMintCost 估算铸造费用(不实际扣款) EstimateMintCost(userID, starID int64) (*MintCostEstimate, error) // UpdateMintCountAndBoost 更新铸爱次数和收益提升 UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error // ReconcileStuckMintOrders 对账:扫描 status=PROCESSING 且 updated_at // 早于 staleAfter 的订单,根据是否已扣水晶做"幂等补单"或"安全标 FAILED"。 // 调用方:可由 worker 周期触发,也可手动调用(管理后台/运维)。 ReconcileStuckMintOrders(ctx context.Context, staleAfter time.Duration) error } // mintService 铸造服务实现 type mintService struct { assetRepo repository.AssetRepository mintOrderRepo repository.MintOrderRepository userClient client.UserServiceClient db *gorm.DB config *config.AssetConfig registryRepo repository.AssetRegistryRepository // 资产索引仓库(原 starbookService/repository,批次 4.2 已下沉) localMintCostRepo repository.MintCostRepository // 铸造消耗配置仓库 userMintCountRepo repository.UserMintCountRepository // 用户铸爱累计仓库 assetLevelService AssetLevelService // 资产等级服务 } // NewMintService 创建铸造服务实例 func NewMintService( assetRepo repository.AssetRepository, mintOrderRepo repository.MintOrderRepository, userClient client.UserServiceClient, db *gorm.DB, cfg *config.AssetConfig, registryRepo repository.AssetRegistryRepository, localMintCostRepo repository.MintCostRepository, userMintCountRepo repository.UserMintCountRepository, assetLevelService AssetLevelService, ) MintService { return &mintService{ assetRepo: assetRepo, mintOrderRepo: mintOrderRepo, userClient: userClient, db: db, config: cfg, registryRepo: registryRepo, localMintCostRepo: localMintCostRepo, userMintCountRepo: userMintCountRepo, assetLevelService: assetLevelService, } } // InitMintOrder 阶段一:初始化订单(仅落库 order_id,status=PENDING,幂等) func (s *mintService) InitMintOrder(orderID string, userID, starID int64) (*pb.InitMintOrderResponse, error) { if orderID == "" { return nil, fmt.Errorf("order_id is required") } if !validator.ValidateUserID(userID) { return nil, appErrors.ErrInvalidUserID } if !validator.ValidateStarID(starID) { return nil, appErrors.ErrInvalidStarID } // 已存在则直接返回(幂等) existing, err := s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID) if err == nil && existing != nil { return &pb.InitMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli(), }, Order: ModelToProtoMintOrder(existing), }, nil } order := &models.MintOrder{ OrderID: orderID, UserID: userID, StarID: starID, Status: models.MintOrderStatusPending, CostCrystal: 0, ErrorMessage: nil, RetryCount: 0, } if err := s.mintOrderRepo.Create(order); err != nil { // 处理并发下重复创建:再次查询返回 existing2, err2 := s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID) if err2 == nil && existing2 != nil { return &pb.InitMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli(), }, Order: ModelToProtoMintOrder(existing2), }, nil } return nil, fmt.Errorf("failed to init mint order: %w", err) } return &pb.InitMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli(), }, Order: ModelToProtoMintOrder(order), }, nil } // PreCreateMintOrder 阶段一:预创建订单(生成 order_id,状态=PENDING) func (s *mintService) PreCreateMintOrder(req *pb.PreCreateMintOrderRequest, userID, starID int64) (*pb.PreCreateMintOrderResponse, error) { if !validator.ValidateUserID(userID) { return nil, appErrors.ErrInvalidUserID } if !validator.ValidateStarID(starID) { return nil, appErrors.ErrInvalidStarID } if req.MaterialUrl == "" { return nil, fmt.Errorf("material_url is required") } orderID := uuid.New().String() materialType := req.MaterialType if materialType == "" { materialType = "new" } order := &models.MintOrder{ OrderID: orderID, UserID: userID, StarID: starID, Status: models.MintOrderStatusPending, CostCrystal: 0, ErrorMessage: nil, RetryCount: 0, MaterialURL: stringToPtr(req.MaterialUrl), Name: stringToPtr(req.Name), Description: stringToPtr(req.Description), MaterialType: stringToPtr(materialType), Event: stringToPtr(req.Event), } if err := s.db.Create(order).Error; err != nil { return nil, fmt.Errorf("failed to create mint order draft: %w", err) } return &pb.PreCreateMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli(), }, Order: ModelToProtoMintOrder(order), }, nil } // CreateMintOrder 创建铸造订单 // // ★ mint-task-3 P0 重构:把 RPC 移出 DB 事务,消除连接池占用 + 跨服务事务风险, // 并引入 PROCESSING 中间态,确保任何阶段失败都不会留下"状态机不对齐"的订单。 // // 三段式事务: // - txn_local#1: PENDING → PROCESSING + 校验 + cost 计算 + mint_count 自增 + 序列同步 // (不调 RPC、不写 asset) // - 事务外 RPC: UpdateCrystalBalance 扣水晶(靠 mint-task-1 source_id 幂等保护) // - txn_local#2: 建 asset/registry + 订单 → SUCCESS // // 失败补偿:任一阶段失败 → 独立事务 markMintOrderFailed(不回调水晶,不重试)。 // // 状态机:PENDING → PROCESSING → SUCCESS | FAILED。FAILED 订单不再二次扣, // 用户必须 PreCreateMintOrder 新建订单才能重试。 func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, starID int64) (*pb.CreateMintOrderResponse, error) { // 1. 参数验证 if !validator.ValidateUserID(userID) { logger.Logger.Warn("Invalid user_id", zap.Int64("user_id", userID), ) return nil, appErrors.ErrInvalidUserID } if !validator.ValidateStarID(starID) { logger.Logger.Warn("Invalid star_id", zap.Int64("star_id", starID), ) return nil, appErrors.ErrInvalidStarID } // 按新流程:order_id 必填,所有后续操作以 order_id 为唯一标识 if req.OrderId == "" { return nil, fmt.Errorf("order_id is required(请先调用 /api/v1/assets/mints/precreate 获取)") } // ★ 幂等短路:已 SUCCESS 的同 order_id 直接返回,不再走扣费/建档流程。 // 这是铸造正确性 plan 双层防护的第二层(assetService 入口); // 第一层在 userService.UpdateCrystalBalance(source_id + 唯一索引),由 mint-task-1 覆盖。 existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID) if err == nil && existing != nil && existing.Status == models.MintOrderStatusSuccess { logger.Logger.Info("CreateMintOrder idempotent hit", zap.String("order_id", existing.OrderID), zap.Int64("user_id", userID), ) // 重放响应: 用既有 asset / 既有成本,不再二次扣。 var assetProto *pb.Asset if existing.AssetID != nil { if a, gerr := s.assetRepo.GetByID(*existing.AssetID); gerr == nil && a != nil { assetProto = ModelToProtoAssetDetail(a, "", "", false, 0, 0, 0, 0, getInt32Value(a.Grade)) } } return &pb.CreateMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "idempotent", Timestamp: time.Now().UnixMilli(), }, Order: ModelToProtoMintOrder(existing), Asset: assetProto, CostCrystal: existing.CostCrystal, BalanceAfter: 0, // 余额不再重算(避免对 userService 二次调用) }, nil } // 2. 获取当前累计铸爱次数,用于计算阶梯费用 currentMintCount, err := s.GetUserMintCount(userID, starID) if err != nil { logger.Logger.Warn("Failed to get user mint count, using 0", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) currentMintCount = 0 } // ==================================================================== // 阶段1 (txn_local#1):PENDING → PROCESSING + 校验 + 费用/序列/mint_count // - 不调 RPC // - 不写 asset // - 失败回滚到 PENDING(订单本就是 PENDING,无副作用) // ==================================================================== var costCrystal int64 var mintOrder *models.MintOrder var localMintCost *models.MintCostConfig var boostBps int32 err = s.db.Transaction(func(tx *gorm.DB) error { // 3.0 取出阶段一订单,并校验状态/所有者 logger.Logger.Info("[MintOrder] Step 3.0: 获取订单", zap.String("order_id", req.OrderId), zap.Int64("user_id", userID), zap.Int64("star_id", starID)) existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID) if err != nil { logger.Logger.Error("[MintOrder] Step 3.0 失败: 获取订单错误", zap.String("order_id", req.OrderId), zap.Error(err)) return fmt.Errorf("order not found: %w", err) } logger.Logger.Info("[MintOrder] Step 3.0 查询成功", zap.String("order_id", existing.OrderID), zap.String("status", existing.Status)) if existing.Status != models.MintOrderStatusPending { return fmt.Errorf("订单状态为%s,不能继续铸造", existing.Status) } mintOrder = existing // 若阶段二传了字段,则覆盖阶段一的存储值(允许再次编辑元数据) if req.MaterialUrl != "" { mintOrder.MaterialURL = stringToPtr(req.MaterialUrl) } if req.Name != "" { mintOrder.Name = stringToPtr(req.Name) } if req.Description != "" { mintOrder.Description = stringToPtr(req.Description) } if req.MaterialType != "" { mintOrder.MaterialType = stringToPtr(req.MaterialType) } else { mintOrder.MaterialType = stringToPtr("new") } if req.Info != "" { mintOrder.Info = stringToPtr(req.Info) } // 继续铸造时必须有素材(可来自阶段一或阶段二) if getStringValue(mintOrder.MaterialURL) == "" { return fmt.Errorf("material_url is required") } if getStringValue(mintOrder.Info) == "" { return fmt.Errorf("info is required") } // 如果没有提供名称,使用默认名称 if getStringValue(mintOrder.Name) == "" { mintOrder.Name = stringToPtr("未命名藏品") } // 3.1 获取铸造消耗配置(阶梯计价) localMintCost, err = s.GetMintCost(currentMintCount + 1) if err != nil { logger.Logger.Error("[MintOrder] Step 3.1 失败", zap.Error(err)) return fmt.Errorf("获取铸造消耗配置失败: %w", err) } costCrystal = localMintCost.CostCrystal // 3.3 检查是否触发保底(纯本地随机 + DB 写,事务内合法;见 mint-task-4) boostBps = 0 if localMintCost.Probability > 0 && localMintCost.RewardValue > 0 { if rollGuarantee(localMintCost.Probability) { boostBps = int32(localMintCost.RewardValue) logger.Logger.Info("Mint guarantee triggered", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int32("mint_count", currentMintCount+1), zap.Int32("boost_bps", boostBps), zap.Int64("probability", localMintCost.Probability)) } } // 3.4 更新用户铸爱次数和收益提升(纯 DB,无 RPC) if err := s.UpdateMintCountAndBoost(context.Background(), tx, userID, starID, boostBps); err != nil { logger.Logger.Error("[MintOrder] Step 3.4 失败", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return fmt.Errorf("failed to update mint count: %w", err) } // 序列同步(后续会写 asset,提前对齐序列) if err := syncAssetsIDSequence(tx); err != nil { logger.Logger.Warn("[MintOrder] sync assets_id_seq failed", zap.Error(err)) return fmt.Errorf("failed to sync asset id sequence: %w", err) } // ★ 推进订单 PENDING → PROCESSING(写 cost_crystal 快照) if err := tx.Model(&models.MintOrder{}). Where("order_id = ?", req.OrderId). Updates(map[string]interface{}{ "status": models.MintOrderStatusProcessing, "cost_crystal": costCrystal, "updated_at": time.Now().UnixMilli(), }).Error; err != nil { return fmt.Errorf("failed to mark PROCESSING: %w", err) } mintOrder.Status = models.MintOrderStatusProcessing mintOrder.CostCrystal = costCrystal logger.Logger.Info("[MintOrder] Phase 1 done, status=PROCESSING", zap.String("order_id", mintOrder.OrderID), zap.Int64("cost_crystal", costCrystal)) return nil }) if err != nil { // 阶段1 失败:订单本就是 PENDING,自动回滚即正确状态。 // 用户可重新 PreCreateMintOrder + CreateMintOrder 重试。 logger.Logger.Error("[MintOrder] Phase 1 failed, order stays PENDING", zap.String("order_id", req.OrderId), zap.Error(err)) return nil, fmt.Errorf("phase1 prepare: %w", err) } // ==================================================================== // 阶段2 (事务外 RPC):扣水晶 // - 依赖 mint-task-1 的 source_id 幂等保护,重试安全。 // - 失败 → markMintOrderFailed(独立事务,标 FAILED,不回调水晶)。 // - 已扣 → txn_local#2 继续。 // ==================================================================== var newBalance int64 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() logger.Logger.Info("[MintOrder] Step 3.2: 扣除水晶(事务外)", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("delta", -costCrystal)) newBalance, err = s.userClient.UpdateCrystalBalance(ctx, userID, starID, -costCrystal, "mint_cost", req.OrderId, fmt.Sprintf("铸造藏品 #%s", req.OrderId)) if err != nil { logger.Logger.Error("[MintOrder] Step 3.2 失败: 扣除水晶错误", zap.String("order_id", req.OrderId), zap.Error(err)) // ★ 阶段2 失败:独立事务标 FAILED,不让订单卡在 PROCESSING 永远等 s.markMintOrderFailed(req.OrderId, fmt.Errorf("phase2 rpc: %w", err)) return nil, fmt.Errorf("水晶扣费失败: %w", err) } logger.Logger.Info("[MintOrder] Step 3.2 完成", zap.Int64("new_balance", newBalance)) // ==================================================================== // 阶段3 (txn_local#2):建 asset + registry + mint_order → SUCCESS // - 失败 → markMintOrderFailed(水晶已扣,走对账任务;不在本路径退款) // ==================================================================== var asset *models.Asset err = s.db.Transaction(func(tx *gorm.DB) error { // 3.5 创建资产记录 logger.Logger.Info("[MintOrder] Step 3.5: 创建资产") materialURLValue := getStringValue(mintOrder.MaterialURL) mintedAt := time.Now().UnixMilli() asset = &models.Asset{ OwnerUID: userID, StarID: starID, Name: getStringValue(mintOrder.Name), CoverURL: materialURLValue, MaterialURL: &materialURLValue, Description: mintOrder.Description, Visibility: models.AssetVisibilityPrivate, Status: models.AssetStatusActive, LikeCount: 0, Info: getStringValue(mintOrder.Info), MintedAt: &mintedAt, } if req.Grade != 0 { asset.Grade = int32ToPtr(req.Grade) } if len(req.Tags) > 0 { asset.Tags = models.StringArray(req.Tags) } if err := tx.Create(asset).Error; err != nil { logger.Logger.Error("[MintOrder] Step 3.5 失败: 创建资产错误", zap.String("material_url", materialURLValue), zap.Error(err)) return fmt.Errorf("failed to create asset: %w", err) } logger.Logger.Info("Asset created", zap.Int64("asset_id", asset.ID), zap.Int64("user_id", userID)) grade := int32(1) registry := &models.AssetRegistry{ AssetID: asset.ID, AssetType: models.AssetTypeRegular, OwnerUID: userID, StarID: starID, Grade: &grade, Status: models.AssetRegistryStatusActive, LikeCount: 0, CreatedAt: time.Now().UnixMilli(), UpdatedAt: time.Now().UnixMilli(), } if err := tx.Create(registry).Error; err != nil { logger.Logger.Error("Failed to create asset registry", zap.Int64("asset_id", asset.ID), zap.Int64("user_id", userID), zap.Error(err)) return fmt.Errorf("failed to create asset registry: %w", err) } logger.Logger.Info("Asset registry created", zap.Int64("asset_id", asset.ID), zap.Int64("user_id", userID), zap.Int32("grade", grade)) // 推进订单 PROCESSING → SUCCESS,关联资产 mintedAt = time.Now().UnixMilli() updates := map[string]interface{}{ "asset_id": asset.ID, "status": models.MintOrderStatusSuccess, "cost_crystal": localMintCost.CostCrystal, "error_message": nil, "material_url": getStringValue(mintOrder.MaterialURL), "name": getStringValue(mintOrder.Name), "description": getStringValue(mintOrder.Description), "material_type": getStringValue(mintOrder.MaterialType), "event": getStringValue(mintOrder.Event), "info": getStringValue(mintOrder.Info), "minted_at": mintedAt, } if err := tx.Model(&models.MintOrder{}). Where("order_id = ? AND user_id = ? AND star_id = ?", mintOrder.OrderID, userID, starID). Updates(updates).Error; err != nil { return fmt.Errorf("failed to update mint order: %w", err) } assetID := asset.ID mintOrder.AssetID = &assetID mintOrder.Status = models.MintOrderStatusSuccess mintOrder.CostCrystal = localMintCost.CostCrystal logger.Logger.Info("Mint order created", zap.String("order_id", mintOrder.OrderID), zap.Int64("asset_id", asset.ID)) return nil }) if err != nil { // ★ 阶段3 失败:标 FAILED,水晶已扣由 ReconcileStuckMintOrders 补单 logger.Logger.Error("[MintOrder] Phase 3 failed, marking FAILED", zap.String("order_id", req.OrderId), zap.Error(err)) s.markMintOrderFailed(req.OrderId, fmt.Errorf("phase3 finalize: %w", err)) return nil, fmt.Errorf("phase2 finalize: %w", err) } // 4. 初始化资产等级记录 if s.assetLevelService != nil && asset != nil { if _, err := s.assetLevelService.GetOrCreateRecord(asset.ID); err != nil { logger.Logger.Warn("Failed to create asset level record", zap.Int64("asset_id", asset.ID), zap.Error(err)) } } // 5. 获取所有者的昵称和头像(创建时所有者就是当前用户) var ownerNickname string var ownerAvatar string profile, err := s.userClient.GetFanProfile(context.Background(), userID, starID) if err != nil { logger.Logger.Warn("Failed to get owner fan profile, will return without nickname", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err), ) ownerNickname = "" } else { ownerNickname = profile.Nickname ownerAvatar = profile.AvatarUrl } // 6. 构建响应 response := &pb.CreateMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli(), }, Order: ModelToProtoMintOrder(mintOrder), Asset: ModelToProtoAssetDetail(asset, ownerNickname, ownerAvatar, false, 0, 0, 0, 0, getInt32Value(asset.Grade)), CostCrystal: costCrystal, BalanceAfter: newBalance, } logger.Logger.Info("Create mint order successful", zap.String("order_id", mintOrder.OrderID), zap.Int64("asset_id", asset.ID), zap.Int64("user_id", userID), zap.Int64("cost_crystal", costCrystal), zap.Int64("balance_after", newBalance), ) // 事件埋点:asset.mint(fire-and-forget) statistic.Get().TrackEvent(context.Background(), &eventPb.Event{ EventType: "asset.mint", UserId: userID, StarId: starID, OccurredAt: time.Now().UnixMilli(), Properties: map[string]string{ "asset_id": strconv.FormatInt(asset.ID, 10), "order_id": mintOrder.OrderID, }, }) return response, nil } // markMintOrderFailed 独立事务中标 FAILED + 写入 error_message。 // // 失败仅日志,不阻塞主流程(因为 markMintOrderFailed 自身失败时, // ReconcileStuckMintOrders 也会兜底)。 // // ★ Minor#6 nil 防御:cause == nil 时不能 cause.Error() panic, // 落 "unknown error" 占位,避免把订单标 FAILED 但 error_message 为 NULL。 func (s *mintService) markMintOrderFailed(orderID string, cause error) { if orderID == "" { return } if cause == nil { cause = errors.New("unknown error") } now := time.Now().UnixMilli() msg := cause.Error() err := s.db.Model(&models.MintOrder{}). Where("order_id = ?", orderID). Updates(map[string]interface{}{ "status": models.MintOrderStatusFailed, "error_message": msg, "updated_at": now, }).Error if err != nil { logger.Logger.Error("markMintOrderFailed failed", zap.String("order_id", orderID), zap.Error(err), ) } } // ModelToProtoMintOrder 将数据库模型转换为Proto格式 func ModelToProtoMintOrder(order *models.MintOrder) *pb.MintOrder { if order == nil { return nil } return &pb.MintOrder{ OrderId: order.OrderID, UserId: order.UserID, AssetId: getInt64Value(order.AssetID), StarId: order.StarID, Status: order.Status, CostCrystal: order.CostCrystal, ErrorMessage: getStringValue(order.ErrorMessage), RetryCount: order.RetryCount, MaterialUrl: getStringValue(order.MaterialURL), Name: getStringValue(order.Name), Description: getStringValue(order.Description), MaterialType: getStringValue(order.MaterialType), Event: getStringValue(order.Event), CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, MintedAt: getInt64Value(order.MintedAt), Info: getStringValue(order.Info), } } // stringToPtr 将字符串转换为指针 func stringToPtr(s string) *string { if s == "" { return nil } return &s } // int32ToPtr 将int32转换为指针 func int32ToPtr(i int32) *int32 { return &i } // GetMintOrder 查询铸造订单状态 func (s *mintService) GetMintOrder(orderID string, userID, starID int64) (*pb.GetMintOrderResponse, error) { // 1. 参数验证 if orderID == "" { logger.Logger.Warn("Invalid order_id (empty)") return nil, fmt.Errorf("order_id不能为空") } if !validator.ValidateUserID(userID) { logger.Logger.Warn("Invalid user_id", zap.Int64("user_id", userID), ) return nil, appErrors.ErrInvalidUserID } if !validator.ValidateStarID(starID) { logger.Logger.Warn("Invalid star_id", zap.Int64("star_id", starID), ) return nil, appErrors.ErrInvalidStarID } // 2. 查询订单(验证权限) order, err := s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID) if err != nil { logger.Logger.Error("Failed to get mint order", zap.String("order_id", orderID), zap.Int64("user_id", userID), zap.Error(err), ) return nil, fmt.Errorf("订单不存在或无权访问: %w", err) } // 3. 查询关联的资产(如果存在) var assetProto *pb.Asset if order.AssetID != nil { asset, err := s.assetRepo.GetByID(*order.AssetID) if err == nil && asset != nil { // 获取所有者昵称和头像 var ownerNickname string var ownerAvatar string profile, err := s.userClient.GetFanProfile(context.Background(), asset.OwnerUID, asset.StarID) if err == nil && profile != nil { ownerNickname = profile.Nickname ownerAvatar = profile.AvatarUrl } // 转换为 Proto(这里需要调用 ModelToProtoAssetDetail,但需要 is_liked 参数) // 由于是查询自己的订单,is_liked 设为 false(简化处理) // 获取 display_status displayStatus, _ := s.assetRepo.GetDisplayStatusByAssetID(asset.ID) assetProto = ModelToProtoAssetDetail(asset, ownerNickname, ownerAvatar, false, displayStatus, 0, 0, 0, getInt32Value(asset.Grade)) // 新创建的资产,earnings、hourlyEarnings 和 exhibitionExpireAt 为 0 // 如果 cover_url 存在,生成预签名 URL if assetProto.CoverUrl != "" { signedURL, err := s.generatePresignedURL(assetProto.CoverUrl, 3600) if err == nil { // 将预签名 URL 添加到 asset 的扩展字段中 // 注意:proto 定义中可能没有 cover_url_signed 字段,需要检查 // 这里先记录日志,后续可以在 DTO 层处理 logger.Logger.Debug("生成预签名URL成功", zap.String("cover_url", assetProto.CoverUrl), zap.String("signed_url", signedURL), ) // TODO: 将 signedURL 添加到响应中(需要在 proto 中添加字段或通过 DTO 处理) } } } } // 4. 构建响应 response := &pb.GetMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: uint32(codes.OK), Message: "", Timestamp: time.Now().UnixMilli(), }, Order: ModelToProtoMintOrder(order), Asset: assetProto, } logger.Logger.Info("GetMintOrder successful", zap.String("order_id", orderID), zap.Int64("user_id", userID), zap.String("status", order.Status), ) return response, nil } // generatePresignedURL 生成预签名 URL(复用 Gateway 的逻辑) func (s *mintService) generatePresignedURL(filePath string, expiresInSeconds int64) (string, error) { // 从环境变量读取 OSS 配置(必须设置,无默认值) region := os.Getenv("OSS_REGION") bucketName := os.Getenv("OSS_BUCKET_NAME") roleArn := os.Getenv("OSS_STS_ROLE_ARN") accessKeyID := os.Getenv("OSS_ACCESS_KEY_ID") accessKeySecret := os.Getenv("OSS_ACCESS_KEY_SECRET") // 验证必需的配置项 if region == "" || bucketName == "" || roleArn == "" || accessKeyID == "" || accessKeySecret == "" { return "", fmt.Errorf("OSS 配置不完整,请设置环境变量: OSS_REGION, OSS_BUCKET_NAME, OSS_STS_ROLE_ARN, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET") } // 获取 STS 临时凭证 credConfig := new(credentials.Config). SetType("ram_role_arn"). SetAccessKeyId(accessKeyID). SetAccessKeySecret(accessKeySecret). SetRoleArn(roleArn). SetRoleSessionName("topfans-download-session"). SetPolicy(""). SetRoleSessionExpiration(int(expiresInSeconds)) provider, err := credentials.NewCredential(credConfig) if err != nil { return "", fmt.Errorf("创建凭证提供器失败: %w", err) } cred, err := provider.GetCredential() if err != nil { return "", fmt.Errorf("获取临时凭证失败: %w", err) } // 创建 OSS 客户端 endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", region) client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.AccessKeySecret, oss.SecurityToken(*cred.SecurityToken)) if err != nil { return "", fmt.Errorf("创建OSS客户端失败: %w", err) } // 获取 Bucket bucket, err := client.Bucket(bucketName) if err != nil { return "", fmt.Errorf("获取Bucket失败: %w", err) } // 从完整 URL 中提取 OSS key // 格式: https://bucket.oss-region.aliyuncs.com/key ossKey := filePath if strings.HasPrefix(filePath, "https://") { // 提取 key 部分 // 例如: https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/7/88/covers/123_1234567890.png // 需要提取: asset/7/88/covers/123_1234567890.png parts := strings.SplitN(filePath, ".oss-", 2) if len(parts) == 2 { // parts[1] = "cn-shanghai.aliyuncs.com/asset/7/88/covers/123_1234567890.png" keyParts := strings.SplitN(parts[1], "/", 2) if len(keyParts) == 2 { ossKey = keyParts[1] // asset/7/88/covers/123_1234567890.png } } } // 生成预签名 URL signedURL, err := bucket.SignURL(ossKey, oss.HTTPGet, expiresInSeconds) if err != nil { return "", fmt.Errorf("生成预签名URL失败: %w", err) } // 修复 path 的 URL 编码:OSS SDK 的 buildURL 会把 / 编成 %2F,导致 OSS 按字面 key 查找失败(403)。 // 只把 path 段(? 之前)的 %2F 改回 /。 if idx := strings.Index(signedURL, "?"); idx >= 0 { signedURL = strings.ReplaceAll(signedURL[:idx], "%2F", "/") + signedURL[idx:] } else { signedURL = strings.ReplaceAll(signedURL, "%2F", "/") } // 若 SDK 未把 STS 的 security-token 加入 URL,则手动追加(使用 STS 时预签名必须带此参数,否则 403) if !strings.Contains(signedURL, "security-token") && cred.SecurityToken != nil && *cred.SecurityToken != "" { signedURL = signedURL + "&security-token=" + url.QueryEscape(*cred.SecurityToken) } return signedURL, nil } // CancelMintOrder 取消铸造订单 func (s *mintService) CancelMintOrder(orderID string, userID, starID int64) error { // 1. 参数验证 if orderID == "" { logger.Logger.Warn("Invalid order_id (empty)") return fmt.Errorf("order_id不能为空") } if !validator.ValidateUserID(userID) { logger.Logger.Warn("Invalid user_id", zap.Int64("user_id", userID), ) return appErrors.ErrInvalidUserID } if !validator.ValidateStarID(starID) { logger.Logger.Warn("Invalid star_id", zap.Int64("star_id", starID), ) return appErrors.ErrInvalidStarID } // 2. 查询订单 order, err := s.mintOrderRepo.GetByOrderID(orderID) if err != nil { logger.Logger.Error("Failed to get mint order", zap.String("order_id", orderID), zap.Error(err), ) return fmt.Errorf("failed to get mint order: %w", err) } // 3. 验证订单所有者 if order.UserID != userID || order.StarID != starID { logger.Logger.Warn("Unauthorized to cancel this order", zap.String("order_id", orderID), zap.Int64("order_user_id", order.UserID), zap.Int64("order_star_id", order.StarID), zap.Int64("request_user_id", userID), zap.Int64("request_star_id", starID), ) return appErrors.ErrMintOrderAccessDenied } // 4. 检查订单状态(新流程:PENDING 才允许“取消并清理素材与订单”) if order.Status != models.MintOrderStatusPending && order.Status != models.MintOrderStatusFailed { logger.Logger.Warn("Cannot cancel order in current status", zap.String("order_id", orderID), zap.String("status", order.Status), ) return fmt.Errorf("订单状态为%s,不能取消", order.Status) } // 5. PENDING:删除素材 + 删除订单(按 list.txt 要求) if order.Status == models.MintOrderStatusPending { // 删除 OSS 素材(best-effort) if mu := getStringValue(order.MaterialURL); mu != "" { ossKey := extractOSSKeyFromURLForService(mu) if ossKey != "" { cfg := util.OSSConfig{ Region: os.Getenv("OSS_REGION"), BucketName: os.Getenv("OSS_BUCKET_NAME"), RoleArn: os.Getenv("OSS_STS_ROLE_ARN"), AccessKeyID: os.Getenv("OSS_ACCESS_KEY_ID"), AccessKeySecret: os.Getenv("OSS_ACCESS_KEY_SECRET"), } _ = util.DeleteObjectFromOSS(cfg, ossKey) } } // 删除订单记录 if err := s.mintOrderRepo.DeleteByOrderID(orderID); err != nil { return fmt.Errorf("failed to delete mint order: %w", err) } return nil } // 6. FAILED:保留兼容旧逻辑(只改状态为 CANCELLED) if err := s.mintOrderRepo.CancelOrder(orderID); err != nil { return fmt.Errorf("failed to cancel mint order: %w", err) } // 注意:不需要退回水晶,因为水晶是在创建订单时扣除的 // 取消订单不会退款 logger.Logger.Info("Mint order cancelled successfully", zap.String("order_id", orderID), zap.Int64("user_id", userID), zap.Int64("star_id", starID), ) return nil } // GetMintCost 获取铸造消耗配置 // 根据当前累计铸爱次数获取下次铸造的消耗配置 func (s *mintService) GetMintCost(mintCount int32) (*models.MintCostConfig, error) { // 铸爱次数从1开始,最大10 if mintCount < 1 { mintCount = 1 } if mintCount > 10 { mintCount = 10 } config, err := s.localMintCostRepo.GetByMintCount(mintCount) if err != nil { logger.Logger.Error("Failed to get mint cost config", zap.Int32("mint_count", mintCount), zap.Error(err)) return nil, fmt.Errorf("获取铸造配置失败: %w", err) } return config, nil } // GetUserMintCount 获取用户累计铸爱次数 func (s *mintService) GetUserMintCount(userID, starID int64) (int32, error) { record, err := s.userMintCountRepo.Get(userID, starID) if err != nil { // 如果记录不存在,返回0 return 0, nil } return record.MintCount, nil } // EstimateMintCost 估算铸造费用(不实际扣款) func (s *mintService) EstimateMintCost(userID, starID int64) (*MintCostEstimate, error) { // 获取当前累计铸爱次数 currentMintCount, err := s.GetUserMintCount(userID, starID) if err != nil { logger.Logger.Warn("Failed to get user mint count, using 0", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) currentMintCount = 0 } // 获取铸造消耗配置(本次铸造是第 currentMintCount+1 次) localMintCost, err := s.GetMintCost(currentMintCount + 1) if err != nil { return nil, fmt.Errorf("获取铸造消耗配置失败: %w", err) } // 获取当前水晶余额 profile, err := s.userClient.GetFanProfile(context.Background(), userID, starID) var currentBalance int64 = 0 if err != nil { logger.Logger.Warn("Failed to get fan profile, balance will be 0", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) } else { currentBalance = profile.CrystalBalance } // 计算铸造后余额 afterBalance := currentBalance - localMintCost.CostCrystal if afterBalance < 0 { afterBalance = 0 } // 下一阶梯费用 var nextTierCost int64 nextMintCount := currentMintCount + 2 // 下一次铸造的次数 if nextMintCount <= 10 { nextCost, err := s.GetMintCost(nextMintCount) if err == nil { nextTierCost = nextCost.CostCrystal } } return &MintCostEstimate{ CostCrystal: localMintCost.CostCrystal, CurrentBalance: currentBalance, AfterBalance: afterBalance, MintCount: currentMintCount + 1, // 本次将是第几次铸造 NextTierCost: nextTierCost, }, nil } // MintCostEstimate 铸造费用估算结果 type MintCostEstimate struct { CostCrystal int64 // 本次铸造消耗水晶 CurrentBalance int64 // 当前余额(铸造前) AfterBalance int64 // 铸造后余额 MintCount int32 // 本次铸造是第几次 NextTierCost int64 // 下一阶梯费用 } // UpdateMintCountAndBoost 更新铸爱次数和收益提升 // 在事务内调用,tx 为nil时会创建新事务 func (s *mintService) UpdateMintCountAndBoost(ctx context.Context, tx *gorm.DB, userID, starID int64, boostBps int32) error { // 获取或创建用户铸爱累计记录 record, _, err := s.userMintCountRepo.GetOrCreate(tx, userID, starID) if err != nil { logger.Logger.Error("Failed to get or create user mint count", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return fmt.Errorf("获取用户铸爱累计失败: %w", err) } // 计算新的铸爱次数 newMintCount := record.MintCount + 1 // 如果达到10次,重置为0 if newMintCount > 10 { newMintCount = 0 } // 更新记录 record.MintCount = newMintCount if boostBps > 0 { record.RevenueBoostBps += boostBps } record.UpdatedAt = time.Now().UnixMilli() // 注意:GetOrCreate 已经插入了一条记录(isNew=true),所以这里直接 Save 更新即可 // 不需要再 Create,否则会违反 user_id+star_id 唯一索引 if err := tx.Save(record).Error; err != nil { return fmt.Errorf("更新用户铸爱累计记录失败: %w", err) } logger.Logger.Info("Updated mint count and boost", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int32("new_mint_count", newMintCount), zap.Int32("revenue_boost_bps", record.RevenueBoostBps)) return nil } // extractOSSKeyFromURLForService 从 OSS URL 提取 key(服务内使用) func extractOSSKeyFromURLForService(filePath string) string { ossKey := filePath if strings.HasPrefix(filePath, "https://") { parts := strings.SplitN(filePath, ".oss-", 2) if len(parts) == 2 { keyParts := strings.SplitN(parts[1], "/", 2) if len(keyParts) == 2 { ossKey = keyParts[1] } } } return ossKey } // syncAssetsIDSequence 将 assets_id_seq 对齐到 MAX(id),避免序列落后导致 assets_pkey 冲突 (23505) func syncAssetsIDSequence(tx *gorm.DB) error { return tx.Exec(` SELECT setval( pg_get_serial_sequence('assets', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM assets), 1), 1) ) `).Error } // rollGuarantee 按 probability(0-100) 概率返回是否触发保底. // // ★ 批次1.5: 用 crypto/rand 替换原 time.Now().UnixNano()%100, // 消除并发同纳秒相同结果与脚本卡点操纵风险。 // 边界约定: // - probability <= 0 → 永不触发(false) // - probability >= 100 → 总是触发(true) // - 熵源失败时降级为不触发(false),避免在极端环境下出现伪随机劣化 // // 入参取 int64 与 models.MintCostConfig.Probability 字段类型一致, // 避免调用点显式转换;非法值(负数 / 远超 100)按既定边界处理。 func rollGuarantee(probability int64) bool { if probability <= 0 { return false } if probability >= 100 { return true } v, err := rand.Int(rand.Reader, big.NewInt(100)) if err != nil { // 熵源失败: 降级为不触发(避免伪随机劣化;线上极少出现) logger.Logger.Warn("crypto/rand.Int failed, falling back to no-guarantee", zap.Error(err)) return false } return v.Int64() < probability } // ===================================================================== // 孤儿订单对账 (mint-task-3) // // 背景: // CreateMintOrder 拆为三段后,如果资产服务在阶段2(RPC 扣费)和阶段3(建 asset) // 之间崩溃,可能留下"水晶已扣但 asset 未建"的订单卡在 PROCESSING 状态。 // // 如果 markMintOrderFailed 也失败(罕见的 DB 故障),同样会留下 PROCESSING 订单。 // // 设计: // 1) 扫描 status=PROCESSING 且 updated_at 早于 staleAfter 的订单; // 2) 对每单,查 crystal_transaction_records 是否有该 order_id 的 mint_cost 流水; // 3) 决策: // - 已扣水晶 + 无 asset(资产已建但订单状态没推进) → 幂等补完 phase3; // - 未扣水晶 → 安全标 FAILED(没扣钱,直接结束状态机); // - 已扣 + 已建 asset(理论上不会发生) → 不动,留人工排查。 // // 边界: // - 幂等:重复跑不会重复建 asset,靠 mint_order.asset_id / asset_registry.asset_id 判断。 // - 不退款:避免双花。只标 FAILED 或补完 SUCCESS,绝不调用 UpdateCrystalBalance 正向补扣。 // // 调用方: // - 可由 main.go 启动时起一个 goroutine 周期触发(建议 5~15 分钟一次); // - 也提供 RPC / HTTP 管理接口供运维手动触发(本任务未实现)。 // ===================================================================== // reconcileThreshold 对账扫描阈值(默认 10 分钟前还在 PROCESSING 的订单视为孤儿) // 与 brief 推荐一致。函数签名上仍允许调用方传入 staleAfter 覆盖默认。 const reconcileThreshold = 10 * time.Minute // ReconcileStuckMintOrders 扫描 status=PROCESSING 且 updated_at 早于 staleAfter 的订单, // 对每单判定: // - 水晶已扣(mint_cost 流水存在) + 无 asset → 幂等补完 phase3(建 asset + 订单→SUCCESS) // - 水晶未扣 → 标 FAILED(没扣钱,安全结束状态机) // - 水晶已扣 + asset 已建(理论上不会发生) → 不动,告警日志留人工排查 // // 幂等:重复跑不会重复建 asset,不会二次扣水晶,不会把 SUCCESS/FAILED 订单再次处理。 // // 返回:扫描到的 PROCESSING 订单数,以及最终处理结果(recovered / marked_failed / skipped)。 func (s *mintService) ReconcileStuckMintOrders(ctx context.Context, staleAfter time.Duration) error { if staleAfter <= 0 { staleAfter = reconcileThreshold } cutoff := time.Now().Add(-staleAfter).UnixMilli() // 1) 扫描陈旧 PROCESSING 订单(每次最多处理 100 单,避免长事务) var stuckOrders []models.MintOrder if err := s.db.WithContext(ctx). Where("status = ? AND updated_at < ?", models.MintOrderStatusProcessing, cutoff). Order("updated_at ASC"). Limit(100). Find(&stuckOrders).Error; err != nil { return fmt.Errorf("scan stuck mint orders: %w", err) } logger.Logger.Info("ReconcileStuckMintOrders start", zap.Int("stuck_count", len(stuckOrders)), zap.Int64("cutoff_ms", cutoff), zap.Duration("stale_after", staleAfter)) var recovered, failed, skipped int for i := range stuckOrders { order := stuckOrders[i] action, err := s.reconcileOneMintOrder(ctx, &order) if err != nil { // 单单失败不中断对账(其他订单还有救) failed++ logger.Logger.Error("reconcile one order failed", zap.String("order_id", order.OrderID), zap.Error(err)) continue } switch action { case reconcileActionRecovered: recovered++ case reconcileActionFailed: failed++ default: skipped++ } } logger.Logger.Info("ReconcileStuckMintOrders done", zap.Int("scanned", len(stuckOrders)), zap.Int("recovered", recovered), zap.Int("marked_failed", failed), zap.Int("skipped", skipped)) return nil } // reconcileAction 对账动作结果(给 worker 上报计数用) type reconcileAction int const ( reconcileActionSkipped reconcileAction = iota reconcileActionFailed // 安全标 FAILED(无 mint_cost 流水) reconcileActionRecovered // 幂等补完 SUCCESS ) // reconcileOneMintOrder 处理单个孤儿订单。 // // 决策表: // // | asset_id | mint_cost 流水 | 动作 | // | -------- | -------------- | -------------------------- | // | NULL | 无 | markMintOrderFailed (FAILED) | // | NULL | 有 | 补完 phase3 (Recovered) | // | 已设 | (任意) | force SUCCESS (Recovered) | // // 返回 action + error:error 不为 nil 时 action 忽略不计(failed)。 func (s *mintService) reconcileOneMintOrder(ctx context.Context, order *models.MintOrder) (reconcileAction, error) { // 二次校验:防止扫描后被并发改状态 fresh, err := s.mintOrderRepo.GetByOrderID(order.OrderID) if err != nil { return reconcileActionSkipped, fmt.Errorf("reload order: %w", err) } if fresh.Status != models.MintOrderStatusProcessing { logger.Logger.Debug("reconcile: order no longer PROCESSING, skip", zap.String("order_id", order.OrderID), zap.String("status", fresh.Status)) return reconcileActionSkipped, nil } // 查水晶流水:source_id = order_id, change_type = 'mint_cost' var txCount int64 if err := s.db.WithContext(ctx). Table("crystal_transaction_records"). Where("source_id = ? AND change_type = ?", order.OrderID, "mint_cost"). Count(&txCount).Error; err != nil { return reconcileActionSkipped, fmt.Errorf("count crystal tx: %w", err) } // 决策分支 if txCount == 0 { // 未扣水晶:安全标 FAILED logger.Logger.Info("reconcile: no crystal charged, mark FAILED", zap.String("order_id", order.OrderID), zap.Int64("user_id", order.UserID), zap.Int64("star_id", order.StarID)) s.markMintOrderFailed(order.OrderID, fmt.Errorf("reconciled: PROCESSING without mint_cost flow")) return reconcileActionFailed, nil } // 已扣水晶 if fresh.AssetID != nil && *fresh.AssetID > 0 { // 理论上不该发生:asset 已建但订单 PROCESSING。 // 直接推进到 SUCCESS 兜底,避免无限对账。 logger.Logger.Warn("reconcile: order has asset_id but status=PROCESSING, force SUCCESS", zap.String("order_id", order.OrderID), zap.Int64("asset_id", *fresh.AssetID)) now := time.Now().UnixMilli() if err := s.db.Model(&models.MintOrder{}). Where("order_id = ? AND status = ?", order.OrderID, models.MintOrderStatusProcessing). Updates(map[string]interface{}{ "status": models.MintOrderStatusSuccess, "updated_at": now, }).Error; err != nil { return reconcileActionSkipped, fmt.Errorf("force SUCCESS: %w", err) } return reconcileActionRecovered, nil } // 已扣水晶 + 无 asset:幂等补完 phase3(带行锁) logger.Logger.Info("reconcile: crystal charged but no asset, recovering phase3", zap.String("order_id", order.OrderID), zap.Int64("user_id", order.UserID), zap.Int64("star_id", order.StarID)) if err := s.finalizeMintOrderRecovery(ctx, order.OrderID); err != nil { return reconcileActionSkipped, err } return reconcileActionRecovered, nil } // finalizeMintOrderRecovery 在对账场景下补完 phase3(建 asset + registry + 订单→SUCCESS)。 // // 与 CreateMintOrder 阶段3 的区别: // - 不再调 UpdateCrystalBalance(钱已扣过) // - 不再 UpdateMintCountAndBoost(阶段1 已更新过) // - 复用同样的事务结构 + 序列同步 // - ★ mint-task-3 review: 事务**开头**对该 order 加 SELECT...FOR UPDATE 行锁, // 锁内重新判定 status/asset_id,避免与并发的 CreateMintOrder-phase3 双建 asset。 // // 失败处理:写 ERROR 日志。下一轮对账或人工介入, // 关键原则:**绝不退款,避免双花**。 func (s *mintService) finalizeMintOrderRecovery(ctx context.Context, orderID string) error { now := time.Now().UnixMilli() var createdAssetID int64 var recoveredUserID, recoveredStarID int64 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // ★ 行锁:SELECT...FOR UPDATE 串行化并发的 finalize / CreateMintOrder-phase3 var locked models.MintOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("order_id = ?", orderID). First(&locked).Error; err != nil { return fmt.Errorf("lock order for recovery: %w", err) } // 锁内重判:状态机是否已被并发推进? if locked.Status != models.MintOrderStatusProcessing { logger.Logger.Info("recovery: order no longer PROCESSING after lock, skip", zap.String("order_id", orderID), zap.String("status", locked.Status)) return nil } recoveredUserID = locked.UserID recoveredStarID = locked.StarID // 锁内重判:asset 已被并发路径建好了? hasAsset := false var existingAsset models.Asset if locked.AssetID != nil && *locked.AssetID > 0 { if terr := tx.Where("id = ?", *locked.AssetID).First(&existingAsset).Error; terr == nil { hasAsset = true createdAssetID = existingAsset.ID } } if !hasAsset { // 序列同步 if serr := syncAssetsIDSequence(tx); serr != nil { return fmt.Errorf("sync asset id sequence: %w", serr) } // 重建 asset(与正常 CreateMintOrder 阶段3 一致) materialURLValue := getStringValue(locked.MaterialURL) nameValue := getStringValue(locked.Name) if nameValue == "" { nameValue = "未命名藏品" } infoValue := getStringValue(locked.Info) mintedAt := now asset := &models.Asset{ OwnerUID: locked.UserID, StarID: locked.StarID, Name: nameValue, CoverURL: materialURLValue, MaterialURL: &materialURLValue, Description: locked.Description, Visibility: models.AssetVisibilityPrivate, Status: models.AssetStatusActive, LikeCount: 0, Info: infoValue, MintedAt: &mintedAt, } if err := tx.Create(asset).Error; err != nil { return fmt.Errorf("create asset during recovery: %w", err) } createdAssetID = asset.ID grade := int32(1) registry := &models.AssetRegistry{ AssetID: asset.ID, AssetType: models.AssetTypeRegular, OwnerUID: locked.UserID, StarID: locked.StarID, Grade: &grade, Status: models.AssetRegistryStatusActive, LikeCount: 0, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(registry).Error; err != nil { return fmt.Errorf("create asset registry during recovery: %w", err) } } // 推进订单 PROCESSING → SUCCESS(带 status 守卫,防双写) updates := map[string]interface{}{ "status": models.MintOrderStatusSuccess, "updated_at": now, } if createdAssetID > 0 { updates["asset_id"] = createdAssetID updates["minted_at"] = now } // mint_count 在阶段1 已自增过,这里不再回退(防止 mint_count 倒退) // 同样不重置 cost_crystal(已记录的快照代表真实扣费) res := tx.Model(&models.MintOrder{}). Where("order_id = ? AND status = ?", orderID, models.MintOrderStatusProcessing). Updates(updates) if res.Error != nil { return fmt.Errorf("mark SUCCESS during recovery: %w", res.Error) } if res.RowsAffected == 0 { // 理论上锁内不可能被改;RowsAffected=0 极少见,留 WARN 不算错。 logger.Logger.Warn("recovery: status guard returned 0 rows, status may have flipped", zap.String("order_id", orderID)) return nil } // 初始化资产等级记录(如果有 service) if s.assetLevelService != nil && createdAssetID > 0 { if _, lerr := s.assetLevelService.GetOrCreateRecord(createdAssetID); lerr != nil { // 不阻塞主流程,只 WARN logger.Logger.Warn("recovery: failed to create asset level record", zap.Int64("asset_id", createdAssetID), zap.Error(lerr)) } } return nil }) if err != nil { logger.Logger.Error("recovery transaction failed, will retry next round", zap.String("order_id", orderID), zap.Int64("user_id", recoveredUserID), zap.Int64("star_id", recoveredStarID), zap.Error(err)) // 不标 FAILED — 留到下轮重试或人工排查。 // 因为水晶已扣,如果本轮标 FAILED,运营只能"手动重建 asset"或"退款", // 都比"再让对账跑一次"风险更高。 return err } logger.Logger.Info("recovery succeeded", zap.String("order_id", orderID), zap.Int64("asset_id", createdAssetID), zap.Int64("user_id", recoveredUserID), zap.Int64("star_id", recoveredStarID)) return nil }