topfans/backend/services/assetService/service/peripheral_service.go
zerosaturation 878bd46399 fix(backend): financial correctness — settlement/hours/mint idempotency (batch 1)
- 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>
2026-07-23 01:11:54 +08:00

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
}