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 }) }