topfans/backend/services/assetService/service/peripheral_service.go
2026-07-13 11:22:56 +08:00

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
}