package service import ( "context" "errors" "fmt" "time" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" "github.com/topfans/backend/pkg/peripheral" "github.com/topfans/backend/services/assetService/repository" "go.uber.org/zap" ) // BizError 自定义业务错误 // // pkg/errors 的 NewError(codes.Code, msg) 用 google.rpc.Code(0-16), // 装不下周边体系的业务码 50003/50004/50011/50012 等(均 > 16)。 // 故在 peripheral 模块本地定义 BizError(后续如需入 pkg/errors 再升级)。 type BizError struct { Code int Message string } func (e *BizError) Error() string { return fmt.Sprintf("[%d] %s", e.Code, e.Message) } // 周边业务码(spec §4.1/§4.2/§8.2 错误码表) const ( BizCodeAssetNotFound = 50003 // 物品不存在或已下架 BizCodeAlreadyAdded = 50004 // 您已添加过此周边 BizCodeCannotAddSelf = 50011 // 防御性:周边不该出现 BizCodeRateLimited = 50012 // 今日提交过于频繁 BizCodeInvalidSignature = 50013 // URL sign 验证失败(§5.1.3 第二道防线) BizCodeInactive = 50014 // 周边尚未激活(未上架) ) // 藏品类型常量(2026-07-10 加) // 所有新铸造的藏品(周边 mint / castlove / 未来其他途径)默认类型都是 "new", // 后续可由运营/算法改成其他分类(rare/epic/legendary 等),但 mint 入口永远写 "new"。 const ( MintMaterialTypeNew = "new" ) // VerificationResult 验真接口响应(spec §4.1) type VerificationResult struct { AssetID int64 `json:"asset_id"` Code string `json:"code,omitempty"` // 周边实体唯一编号(SKU/序列号;空时 omitempty) Company string `json:"company"` Hash string `json:"hash"` VerifyCount int64 `json:"verify_count"` Brand string `json:"brand"` Image string `json:"image"` Verifier string `json:"verifier"` VerifiedAt int64 `json:"verified_at"` // unix 秒 MaterialType string `json:"material_type,omitempty"` // 周边素材类型(空时前端 fallback 显示 "new") } // PeripheralService 周边验真 + 加入藏品的业务层 type PeripheralService struct { repo *repository.PeripheralRepository rateLimiter *MintRateLimiter } // NewPeripheralService creates a service with the DB fallback rate-limit path. func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService { return &PeripheralService{repo: repo} } // NewPeripheralServiceWithLimiter creates a service with Redis Lua atomic rate limiting. func NewPeripheralServiceWithLimiter(repo *repository.PeripheralRepository, limiter *MintRateLimiter) *PeripheralService { return &PeripheralService{repo: repo, rateLimiter: limiter} } // GetVerification 验真接口(spec §4.1) // // 流程:查 asset → 查 peripheral_info → 自增 peripheral_info.verify_count → 组装响应 // // 业务语义:asset 不存在 / peripheral_info 缺失(非周边)均返 BizCodeAssetNotFound(50003), // 前端看到的是"物品不存在或已下架"统一文案,避免泄漏"非周边"细节。 func (s *PeripheralService) GetVerification(ctx context.Context, assetID int64) (*VerificationResult, error) { asset, err := s.repo.GetAssetForVerification(ctx, assetID) if err != nil { return nil, fmt.Errorf("DB_GET_ASSET_FAILED: %w", err) } if asset == nil { return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"} } info, err := s.repo.GetPeripheralInfo(ctx, assetID) if err != nil { return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_FAILED: %w", err) } if info == nil { return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"} } // 查看验真时原子自增 verify_count,同时处理首次验证时间 nowMs := time.Now().UnixMilli() newCount, firstVerifiedAt, err := s.repo.IncrementVerifyCount(ctx, info.ID, nowMs) if err != nil { logger.Logger.Warn("IncrementVerifyCount failed", zap.Error(err), zap.Int64("peripheral_info_id", info.ID), ) newCount = info.VerifyCount firstVerifiedAt = info.FirstVerifiedAt } return &VerificationResult{ AssetID: asset.ID, Code: info.Code, // 周边实体编号(空字符串时 omitempty 不传字段) Company: info.Company, Hash: info.Hash, VerifyCount: newCount, // 实时计数(含本次查看) Brand: info.Brand, Image: info.Image, // 优先用 peripheral_info.image(asset 可能未创建) Verifier: info.Verifier, VerifiedAt: firstVerifiedAt / 1000, // 毫秒 → 秒(spec §4.1 数据契约) MaterialType: MintMaterialTypeNew, // 验真页前端展示用,固定 "new" }, nil } // GetVerificationByHash 按加密 code(code_hash)查验真(stage 1 唯一验真入口,详见 §5.1.3/§5.1.5) // // 流程:verify sign(防 URL 篡改) → peripheral_verify_code.code_hash → peripheral_info // // 入参: // - codeHash:URL path 中的 encrypted_code(32 hex,HMAC-SHA256(code)[:32]) // - sign:URL query 中的 HMAC 签名(第二道防线) func (s *PeripheralService) GetVerificationByHash(ctx context.Context, codeHash, sign string) (*VerificationResult, error) { logger.Logger.Info("DEBUG GetVerificationByHash start", zap.String("codeHash", codeHash), zap.String("sign", sign)) // step 1: 验证 sign(第二道防线,必须在查 DB 之前,详见 §5.1.5) if !verifySignOrFail(codeHash, sign) { logger.Logger.Warn("DEBUG sign verify failed") return nil, &BizError{Code: BizCodeInvalidSignature, Message: "签名错误,URL 可能被篡改"} } logger.Logger.Info("DEBUG sign verify passed") // step 2: 查表(走新增的 repository.GetPeripheralInfoByHash) info, err := s.repo.GetPeripheralInfoByHash(ctx, codeHash) if err != nil { logger.Logger.Error("DEBUG repo error", zap.Error(err)) return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_hash_FAILED: %w", err) } if info == nil { logger.Logger.Warn("DEBUG info is nil (not found)") return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"} } logger.Logger.Info("DEBUG info found", zap.Int64("id", info.ID), zap.String("code", info.Code), zap.Any("status", info.Status)) // step 2.5: 校验 status(未激活 / 已作废 / 已冻结 不可扫) // status=0 待激活、=2 已作废、=3 已冻结 — 三种状态都不应验真成功 if info.Status != nil && *info.Status != 1 { return nil, &BizError{ Code: BizCodeInactive, Message: statusInactiveMessage(*info.Status), } } assetID := int64(0) if info.AssetID != nil { assetID = *info.AssetID } // step 3: 原子自增 verify_count nowMs := time.Now().UnixMilli() newCount, firstVerifiedAt, err := s.repo.IncrementVerifyCount(ctx, info.ID, nowMs) if err != nil { logger.Logger.Warn("IncrementVerifyCount failed", zap.Error(err), zap.Int64("peripheral_info_id", info.ID), ) newCount = info.VerifyCount firstVerifiedAt = info.FirstVerifiedAt } return &VerificationResult{ AssetID: assetID, Code: info.Code, Company: info.Company, Hash: info.Hash, VerifyCount: newCount, Brand: info.Brand, Image: info.Image, Verifier: info.Verifier, VerifiedAt: firstVerifiedAt / 1000, MaterialType: MintMaterialTypeNew, }, nil } // MintResult 加入藏品接口响应(spec §4.2) type MintResult struct { InstanceID int64 `json:"instance_id"` AssetID int64 `json:"asset_id"` MintedAt int64 `json:"minted_at"` // unix 秒 CoverImage string `json:"cover_image"` } // MintFromPeripheralByHash 按加密 code(code_hash)加入藏品(stage 1 唯一入口,详见 §5.1.5) // // 流程:verify sign → peripheral_verify_code.code_hash → peripheral_info → mint func (s *PeripheralService) MintFromPeripheralByHash(ctx context.Context, ownerUID int64, codeHash, sign string) (*MintResult, error) { // step 1: 验签(第二道防线) if !verifySignOrFail(codeHash, sign) { return nil, &BizError{Code: BizCodeInvalidSignature, Message: "签名错误,URL 可能被篡改"} } // step 2: 按 hash 查 peripheral_info info, err := s.repo.GetPeripheralInfoByHash(ctx, codeHash) if err != nil { return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_HASH_FAILED: %w", err) } if info == nil { return nil, &BizError{Code: BizCodeAssetNotFound, Message: "周边不存在"} } return s.doMint(ctx, ownerUID, info) } // doMint mint 核心逻辑(由 MintFromPeripheralByHash 调用) // // 流程:查重 → 限频 → 建 asset → 回填 peripheral_info → INSERT asset_registry → 异步刷 verify_count func (s *PeripheralService) doMint(ctx context.Context, ownerUID int64, info *models.PeripheralInfo) (*MintResult, error) { assetID := int64(0) if info.AssetID != nil { assetID = *info.AssetID } // 1. 查重(依赖已有 uk_registry_owner_star_type_asset 约束) exists, err := s.repo.ExistsRegistry(ctx, ownerUID, assetID, "peripheral") if err != nil { return nil, fmt.Errorf("DB_EXISTS_FAILED: %w", err) } if exists { return nil, &BizError{Code: BizCodeAlreadyAdded, Message: "您已添加过此周边"} } // 2. 限频: Redis Lua 原子自增; Redis 故障时降级为原 DB count 路径。 if s.rateLimiter != nil { count, allowed, err := s.rateLimiter.IncrAndCheck(ctx, ownerUID, "peripheral") if err != nil { logger.Logger.Warn("MintRateLimiter failed, falling back to DB count", zap.Int64("owner_uid", ownerUID), zap.Error(err)) dbCount, dbErr := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour) if dbErr != nil { return nil, fmt.Errorf("DB_COUNT_FALLBACK_FAILED: %w", dbErr) } if dbCount >= 10 { return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"} } } else if !allowed { logger.Logger.Info("Mint rate limited", zap.Int64("owner_uid", ownerUID), zap.Int64("count", count)) return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"} } } else { count, err := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour) if err != nil { return nil, fmt.Errorf("DB_COUNT_FAILED: %w", err) } if count >= 10 { return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"} } } // 3. 建新 asset(从 peripheral_info 字段填充) tmpAssetID := assetID if tmpAssetID == 0 { tmpAssetID = 0 // 让 GORM 分配 } grade := int32(1) newAssetID, err := s.repo.CreateAssetCopy(ctx, &models.Asset{ ID: tmpAssetID, StarID: info.StarID, Name: info.Brand + " " + info.Code, CoverURL: info.Image, MaterialURL: strPtr(info.Image), MaterialType: strPtr(MintMaterialTypeNew), Grade: &grade, Info: info.Company, TxHash: strPtr(info.Hash), IsOriginal: false, }, ownerUID) if err != nil { return nil, fmt.Errorf("DB_CREATE_ASSET_FAILED: %w", err) } // 3.1 回填 peripheral_info.asset_id 和 user_id if err := s.repo.UpdatePeripheralInfoOnMint(ctx, info.ID, newAssetID, ownerUID); err != nil { logger.Logger.Warn("UpdatePeripheralInfoOnMint failed (non-blocking)", zap.Error(err), zap.Int64("peripheral_info_id", info.ID), ) } // 4. INSERT asset_registry newID, createdAtMs, err := s.repo.InsertPeripheralRegistry(ctx, &models.AssetRegistry{ OwnerUID: ownerUID, AssetID: newAssetID, StarID: info.StarID, AssetType: "peripheral", MaterialType: strPtr(MintMaterialTypeNew), Status: models.AssetRegistryStatusActive, }) if err != nil { // ★ P1.3 修复:ExistsRegistry → INSERT 之间存在 race condition。 // 并发双击 mint 时两次 ExistsRegistry 都返回 false,两次都尝试 INSERT, // 后到达的请求撞 uk_registry_owner_star_type_asset 唯一约束。 // repo 层已把 PG 23505 转 sentinel ErrDuplicateRegistry,此处统一返 50004, // 配合上方 ExistsRegistry 检查,获得"先查 + 写时再查"双层防护。 if errors.Is(err, repository.ErrDuplicateRegistry) { return nil, &BizError{Code: BizCodeAlreadyAdded, Message: "您已添加过此周边"} } return nil, fmt.Errorf("DB_INSERT_FAILED: %w", err) } // 5. 异步刷新 verify_count(失败仅日志,不阻塞 mint 主流程) // // ★ P0 修复:加 panic recovery,防止内部 panic(数据库驱动、事务关闭、未来 JOIN 错误等) // crash 整个 gateway 进程;同时把 `_ =` 吞错改为显式 WARN 日志, // 便于排障。范式参考 backend/services/galleryService/config/gallery_config.go:103。 go func() { defer func() { if r := recover(); r != nil { logger.Logger.Error("RefreshVerifyCount goroutine panic recovered", zap.Any("panic", r), zap.Int64("asset_id", assetID), ) } }() if err := s.repo.RefreshVerifyCount(context.Background(), assetID); err != nil { logger.Logger.Warn("RefreshVerifyCount failed", zap.Error(err), zap.Int64("asset_id", assetID), ) } }() return &MintResult{ InstanceID: newID, // asset_registry.id(收藏记录) AssetID: newAssetID, // ★ 新 asset.id(收藏者的藏品,跳 asset-detail 用) MintedAt: createdAtMs / 1000, // 毫秒 → 秒,与 §4.2 数据契约对齐 CoverImage: info.Image, }, nil } // verifySignOrFail 验签 helper(包装 peripheral.VerifySign,加日志) func verifySignOrFail(encryptedCode, sign string) bool { ok := peripheral.VerifySign(encryptedCode, sign) if !ok { logger.Logger.Warn("Peripheral sign verification failed", zap.String("encrypted_code_prefix", encryptedCode[:8]+"..."), ) } return ok } // strPtr 字符串 → *string helper func strPtr(s string) *string { return &s } // statusInactiveMessage 周边未激活的友好提示 func statusInactiveMessage(status int16) string { switch status { case 0: return "该周边尚未激活,请联系客服" case 2: return "该周边已作废" case 3: return "该周边已冻结" case 4: return "该周边已核销" default: return "该周边当前不可验真" } } // derefStr *string → string(nil 返 "") func derefStr(p *string) string { if p == nil { return "" } return *p }