- settlement(1.1): exhibition_revenue_records 加 UNIQUE(exhibition_id,cycle_start_time) + CreateRevenueRecord ON CONFLICT DO NOTHING; MQ 用 settled_at 替代复用 is_processed; 恢复扫描器过滤改 settled_at IS NULL; created_at 统一毫秒; 删死代码 cleanup_worker.go。 - hours(1.2): 新增 exhibition_hours_log/asset_exhibition_hours_log(source_id 唯一)幂等表; fan_profile/assetLevel 的 AddExhibitionHours 按 sourceID 幂等(事务包裹); 存量重算脚本。 - mint(1.4/1.5/1.6): crystal_transaction_records (source_id,change_type) 部分唯一索引 + UpdateCrystalBalance/CreateMintOrder 幂等; 保底改 crypto/rand; 下线伪 tx_hash; doMint Redis Lua 原子限流。 - migrations 001/002/003; 各服务单测(自包含, 缺 DB t.Skip)。 Co-Authored-By: Claude <noreply@anthropic.com>
383 lines
14 KiB
Go
383 lines
14 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/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
|
|
}
|