270 lines
9.5 KiB
Go
270 lines
9.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/topfans/backend/pkg/logger"
|
|
"github.com/topfans/backend/pkg/models"
|
|
"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 // 今日提交过于频繁
|
|
)
|
|
|
|
// 藏品类型常量(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 秒
|
|
SourceURL string `json:"source_url"`
|
|
MaterialType string `json:"material_type,omitempty"` // 周边素材类型(空时前端 fallback 显示 "new")
|
|
}
|
|
|
|
// PeripheralService 周边验真 + 加入藏品的业务层
|
|
type PeripheralService struct {
|
|
repo *repository.PeripheralRepository
|
|
}
|
|
|
|
// NewPeripheralService 创建 PeripheralService 实例
|
|
func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService {
|
|
return &PeripheralService{repo: repo}
|
|
}
|
|
|
|
// GetVerification 验真接口(spec §4.1)
|
|
//
|
|
// 流程:查 asset → 查 peripheral_info → 读 assets.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: "物品不存在或已下架"}
|
|
}
|
|
|
|
return &VerificationResult{
|
|
AssetID: asset.ID,
|
|
Code: info.Code, // 周边实体编号(空字符串时 omitempty 不传字段)
|
|
Company: info.Company,
|
|
Hash: info.Hash,
|
|
VerifyCount: int64(asset.VerifyCount), // 读缓存,不实时 COUNT
|
|
Brand: info.Brand,
|
|
Image: info.Image, // 优先用 peripheral_info.image(asset 可能未创建)
|
|
Verifier: info.Verifier,
|
|
VerifiedAt: info.FirstVerifiedAt / 1000, // 毫秒 → 秒(spec §4.1 数据契约)
|
|
SourceURL: fmt.Sprintf("https://topfans.online/verify/%d", asset.ID),
|
|
MaterialType: MintMaterialTypeNew, // 验真页前端展示用,固定 "new"
|
|
}, nil
|
|
}
|
|
|
|
// GetVerificationByCode 按周边编号(code)查验真(spec §4.1)
|
|
//
|
|
// 流程:查 peripheral_info WHERE code = ? → 组装响应(asset_id 为 NULL 时也返回)
|
|
func (s *PeripheralService) GetVerificationByCode(ctx context.Context, code string) (*VerificationResult, error) {
|
|
info, err := s.repo.GetPeripheralInfoByCode(ctx, code)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_CODE_FAILED: %w", err)
|
|
}
|
|
if info == nil {
|
|
return nil, &BizError{Code: BizCodeAssetNotFound, Message: "物品不存在或已下架"}
|
|
}
|
|
|
|
assetID := int64(0)
|
|
if info.AssetID != nil {
|
|
assetID = *info.AssetID
|
|
}
|
|
return &VerificationResult{
|
|
AssetID: assetID, // 可能为 0(mint 前)
|
|
Code: info.Code,
|
|
Company: info.Company,
|
|
Hash: info.Hash,
|
|
VerifyCount: 0, // mint 前无统计
|
|
Brand: info.Brand,
|
|
Image: info.Image,
|
|
Verifier: info.Verifier,
|
|
VerifiedAt: info.FirstVerifiedAt / 1000, // 毫秒 → 秒
|
|
SourceURL: fmt.Sprintf("https://topfans.online/verify/%d", assetID),
|
|
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"`
|
|
}
|
|
|
|
// MintFromPeripheral 加入藏品(简化版 mint,跳过 AI 链路)
|
|
//
|
|
// 流程:查 peripheral_info(code) → mint 后建新 asset → INSERT asset_registry → 异步刷 verify_count
|
|
func (s *PeripheralService) MintFromPeripheral(ctx context.Context, ownerUID int64, code string) (*MintResult, error) {
|
|
// 1. 查 peripheral_info(按 code,asset_id 可能为 NULL)
|
|
info, err := s.repo.GetPeripheralInfoByCode(ctx, code)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DB_GET_PERIPHERAL_INFO_BY_CODE_FAILED: %w", err)
|
|
}
|
|
if info == nil {
|
|
return nil, &BizError{Code: BizCodeAssetNotFound, Message: "周边不存在"}
|
|
}
|
|
assetID := int64(0)
|
|
if info.AssetID != nil {
|
|
assetID = *info.AssetID
|
|
}
|
|
|
|
// 2. 查重(依赖已有 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: "您已添加过此周边"}
|
|
}
|
|
|
|
// 3. 限频:24h 最多 10 次
|
|
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: "今日提交过于频繁,请稍后再试"}
|
|
}
|
|
|
|
// 4. 建新 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)
|
|
}
|
|
|
|
// 4.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),
|
|
)
|
|
}
|
|
|
|
// 5. 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)
|
|
}
|
|
|
|
// 6. 异步刷新 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
|
|
}
|
|
|
|
// strPtr 字符串 → *string helper
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
|
|
// derefStr *string → string(nil 返 "")
|
|
func derefStr(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
}
|