topfans/backend/services/assetService/repository/peripheral_repo.go
2026-07-17 16:53:57 +08:00

277 lines
9.9 KiB
Go

package repository
import (
"context"
"errors"
"strings"
"time"
"github.com/topfans/backend/pkg/models"
"gorm.io/gorm"
)
// ErrDuplicateRegistry 唯一约束冲突 sentinel 错误
//
// 当 InsertPeripheralRegistry 触发 PostgreSQL unique violation(错误码 23505,
// 命中 uk_registry_owner_star_type_asset)时,返回本错误。
// service 层用 errors.Is(err, repository.ErrDuplicateRegistry) 识别后转 BizCodeAlreadyAdded。
//
// 触发场景:ExistsRegistry 与 InsertPeripheralRegistry 之间存在 race condition,
// 并发双击 mint 时两次 ExistsRegistry 都返回 false,两次都尝试 INSERT,
// 后到达的请求撞唯一约束。
var ErrDuplicateRegistry = errors.New("peripheral: duplicate registry")
// PeripheralRepository 周边验真 + 加入藏品的数据访问层
type PeripheralRepository struct {
db *gorm.DB
}
// NewPeripheralRepository 创建 PeripheralRepository 实例
func NewPeripheralRepository(db *gorm.DB) *PeripheralRepository {
return &PeripheralRepository{db: db}
}
// GetAssetForVerification 查 asset(已下架/已删除视为不存在)
// 沿用现有 asset_repository.go:110 的过滤条件:is_active=true AND deleted_at IS NULL
// not found 返 (nil, nil);service 层把 nil 视为"物品不存在或已下架",返 50003
func (r *PeripheralRepository) GetAssetForVerification(ctx context.Context, assetID int64) (*models.Asset, error) {
if assetID <= 0 {
return nil, errors.New("asset_id must be greater than 0")
}
var asset models.Asset
err := r.db.WithContext(ctx).
Where("id = ? AND is_active = ? AND deleted_at IS NULL", assetID, true).
First(&asset).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &asset, nil
}
// GetPeripheralInfo 按 peripheral_info.asset_id(mint 后回填的业务 ID)查
// 用途:GetVerification 接口入参是 URL asset_id,该 ID 就是 mint 后回填的 asset_id
// service 层把"peripheral_info 缺失"视为"非周边",返 50003
func (r *PeripheralRepository) GetPeripheralInfo(ctx context.Context, assetID int64) (*models.PeripheralInfo, error) {
if assetID <= 0 {
return nil, errors.New("asset_id must be greater than 0")
}
var info models.PeripheralInfo
err := r.db.WithContext(ctx).
Where("asset_id = ?", assetID).
First(&info).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &info, nil
}
// GetPeripheralInfoByID 按 peripheral_info 主键 id 查
// 用途:verify_code.peripheral_info_id 字段存的就是 peripheral_info.id(自增主键),
//
// 由 admin 后端生成码时写入,与 asset_id(mint 后回填)语义不同
//
// ⚠️ 不要与 GetPeripheralInfo(asset_id 查)混用,字段语义不同会查不到
func (r *PeripheralRepository) GetPeripheralInfoByID(ctx context.Context, id int64) (*models.PeripheralInfo, error) {
if id <= 0 {
return nil, errors.New("id must be greater than 0")
}
var info models.PeripheralInfo
err := r.db.WithContext(ctx).
Where("id = ?", id).
First(&info).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &info, nil
}
// GetPeripheralInfoByHash 按加密 code(code_hash)查(stage 1 唯一扫码入口,详见 §5.1)
//
// 流程:peripheral_verify_code.code_hash → peripheral_info_id → peripheral_info
//
// code_hash 是 URL 中实际出现的"密文 code",由 admin 后端写入(决策 #8)
// 不可逆,只能查表;code_hash UNIQUE 索引保证查询性能
func (r *PeripheralRepository) GetPeripheralInfoByHash(ctx context.Context, codeHash string) (*models.PeripheralInfo, error) {
if codeHash == "" {
return nil, errors.New("code_hash required")
}
// step 1: 查 verify_code 表拿到 peripheral_info_id
var verifyCode models.PeripheralVerifyCode
err := r.db.WithContext(ctx).
Where("code_hash = ?", codeHash).
First(&verifyCode).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
if verifyCode.PeripheralInfoID == nil {
// 码已生成但 peripheral_info 还没建(罕见,理论不会出现)
return nil, nil
}
// step 2: 查 peripheral_info(按主键 id,不要用 GetPeripheralInfo(asset_id))
return r.GetPeripheralInfoByID(ctx, *verifyCode.PeripheralInfoID)
}
// UpdatePeripheralInfoOnMint mint 时回填 asset_id 和 user_id
func (r *PeripheralRepository) UpdatePeripheralInfoOnMint(ctx context.Context, peripheralID, assetID, userID int64) error {
return r.db.WithContext(ctx).
Model(&models.PeripheralInfo{}).
Where("id = ?", peripheralID).
Updates(map[string]interface{}{
"asset_id": assetID,
"user_id": userID,
}).Error
}
// ExistsRegistry 查重:同一用户同一 asset_id 同一 asset_type 是否已有记录
// 依赖 asset_registry 已有 uk_registry_owner_star_type_asset UNIQUE 约束
func (r *PeripheralRepository) ExistsRegistry(ctx context.Context, ownerUID, assetID int64, assetType string) (bool, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&models.AssetRegistry{}).
Where("owner_uid = ? AND asset_id = ? AND asset_type = ?", ownerUID, assetID, assetType).
Limit(1).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// CountRecentMint 限频:近 since 时间内同一用户同一 asset_type 的 mint 数
func (r *PeripheralRepository) CountRecentMint(ctx context.Context, ownerUID int64, assetType string, since time.Duration) (int64, error) {
var count int64
threshold := time.Now().Add(-since).UnixMilli()
err := r.db.WithContext(ctx).
Model(&models.AssetRegistry{}).
Where("owner_uid = ? AND asset_type = ? AND created_at > ?", ownerUID, assetType, threshold).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
// InsertPeripheralRegistry INSERT 一条 peripheral mint
// 返回 (newID, createdAtMs),createdAtMs 来自 BeforeCreate 钩子自动填的 time.Now().UnixMilli()
// 用 GORM Create 写入并从模型取回自增 ID 与 created_at,避免 service 二次查询
//
// ★ P1.3 修复:检测 PostgreSQL 唯一约束冲突(23505),返 sentinel 错误
//
// ErrDuplicateRegistry,让 service 层能识别并转 BizCodeAlreadyAdded (50004)。
// 仅用字符串匹配("23505" / "unique constraint")即可,不依赖 pgconn 包,
// 与项目其他位置(asset_like_service.go:67)的错误识别策略保持一致。
func (r *PeripheralRepository) InsertPeripheralRegistry(ctx context.Context, reg *models.AssetRegistry) (int64, int64, error) {
if err := r.db.WithContext(ctx).Create(reg).Error; err != nil {
if isUniqueViolationErr(err) {
return 0, 0, ErrDuplicateRegistry
}
return 0, 0, err
}
return reg.ID, reg.CreatedAt, nil
}
// isUniqueViolationErr 检测 PostgreSQL 唯一约束冲突(错误码 23505)
//
// 不引入 pgconn 依赖,直接用字符串匹配 GORM 包装后的 error 字符串;
// 与 asset_like_service.go:isUniqueConstraintViolation 保持一致。
func isUniqueViolationErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "23505") || strings.Contains(msg, "unique constraint")
}
// CreateAssetCopy mint 时复制一份新 asset,owner_uid 直接设给收藏者(而非改原 asset)
//
// 复制字段:star_id, name, cover_url, material_url, material_type, info
// 不复制:owner_uid(用 newOwnerUID)、is_original(false)、status(active)、like_count(0)
func (r *PeripheralRepository) CreateAssetCopy(ctx context.Context, original *models.Asset, newOwnerUID int64) (int64, error) {
grade := int32(1)
newAsset := &models.Asset{
OwnerUID: newOwnerUID,
StarID: original.StarID,
Name: original.Name,
CoverURL: original.CoverURL,
MaterialURL: original.MaterialURL,
MaterialType: original.MaterialType,
Info: original.Info,
Grade: &grade, // 初始等级 1
TxHash: original.TxHash,
Status: models.AssetStatusActive,
IsOriginal: false,
}
if err := r.db.WithContext(ctx).Create(newAsset).Error; err != nil {
return 0, err
}
return newAsset.ID, nil
}
// UpdateAssetOwner mint 时同步更新 assets.owner_uid,使藏品所有权转移给收藏者
func (r *PeripheralRepository) UpdateAssetOwner(ctx context.Context, assetID, newOwnerUID int64) error {
return r.db.WithContext(ctx).
Model(&models.Asset{}).
Where("id = ?", assetID).
Update("owner_uid", newOwnerUID).Error
}
// IncrementVerifyCount 扫码时原子自增 verify_count,同时处理首次验证时间
//
// 单条 SQL 完成:verify_count+1、首次验证写 first_verified_at、更新 updated_at,
// 用 RETURNING 取回最新值,避免 UPDATE + SELECT 的 race condition。
//
// 返回 (newVerifyCount, firstVerifiedAt, error)。
// firstVerifiedAt 用于 service 层判断是否为首次验证。
func (r *PeripheralRepository) IncrementVerifyCount(ctx context.Context, id int64, nowMs int64) (int64, int64, error) {
var result struct {
VerifyCount int64 `gorm:"column:verify_count"`
FirstVerifiedAt int64 `gorm:"column:first_verified_at"`
}
err := r.db.WithContext(ctx).Raw(`
UPDATE peripheral_info
SET verify_count = verify_count + 1,
first_verified_at = CASE WHEN first_verified_at = 0 THEN ? ELSE first_verified_at END,
updated_at = ?
WHERE id = ?
RETURNING verify_count, first_verified_at
`, nowMs, nowMs, id).Scan(&result).Error
if err != nil {
return 0, 0, err
}
return result.VerifyCount, result.FirstVerifiedAt, nil
}
// RefreshVerifyCount 异步刷新某周边 verify_count
// 事务包裹:Step A COUNT(*) → Step B UPDATE assets.verify_count = ?
func (r *PeripheralRepository) RefreshVerifyCount(ctx context.Context, assetID int64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&models.AssetRegistry{}).
Where("asset_id = ? AND asset_type = ?", assetID, "peripheral").
Count(&count).Error; err != nil {
return err
}
return tx.Model(&models.Asset{}).
Where("id = ?", assetID).
Update("verify_count", count).Error
})
}