- 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>
397 lines
15 KiB
Go
397 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/topfans/backend/pkg/models"
|
|
"github.com/topfans/backend/pkg/peripheral"
|
|
"github.com/topfans/backend/services/assetService/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// peripheralTestCodeHash 测试 fixture 中 peripheral_info.hash 列的固定值;
|
|
// 见 setupAssetWithPeripheral 的 INSERT 语句。MintFromPeripheralByHash 要求 codeHash
|
|
// 参数等于 hash 列(由 peripheral.EncryptCode(code) 生成),sign 必须是 SignURL(codeHash)。
|
|
const peripheralTestCodeHash = "0xdeadbeef"
|
|
|
|
// mintSignFor 测试用 sign 计算 helper —— 与 peripheral.SignURL 同语义,
|
|
// 保证 verifySignOrFail 通过。生产 URL sign 由前端加密生成。
|
|
func mintSignFor(codeHash string) string {
|
|
return peripheral.SignURL(codeHash)
|
|
}
|
|
|
|
// setupAssetWithPeripheral 准备测试用 asset + peripheral_info
|
|
//
|
|
// ★ 复用包内共享 helper(createServiceTestStar / createServiceTestUser / createServiceTestAsset),
|
|
//
|
|
// 资源会自动被 cleanupServiceTestDB 清理。
|
|
// ★ peripheral_info 用 raw SQL INSERT(避开任何潜在的 GORM 钩子,参考 repository 包风格)
|
|
// ★ assets.verify_count 通过 UPDATE 设置,因为 helper 的 createServiceTestAsset 默认 0
|
|
func setupAssetWithPeripheral(t *testing.T, db *gorm.DB) (assetID, starID int64) {
|
|
t.Helper()
|
|
|
|
star := createServiceTestStar(t, db, "test_peripheral_verify")
|
|
user := createServiceTestUser(t, db, "19900099001")
|
|
asset := createServiceTestAsset(t, db, user.ID, star.StarID, "p-verify")
|
|
// peripheral_info 用 raw SQL INSERT(避开任何潜在的 GORM 钩子)
|
|
if err := db.Exec(`INSERT INTO peripheral_info (asset_id, star_id, user_id, code, image, brand, company, hash, verifier, first_verified_at, created_at, updated_at)
|
|
VALUES (?, 87, 0, 'PERI-2026-001', 'https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/1/87/1778876360335.jpg', 'BrandX', 'CompanyY', '0xdeadbeef', 'verifierZ', 1715600000000, 1, 1)`,
|
|
asset.ID).Error; err != nil {
|
|
t.Fatalf("Failed to create test peripheral_info: %v", err)
|
|
}
|
|
if err := db.Exec("DELETE FROM peripheral_verify_code WHERE code = ? OR code_hash = ?", "PERI-2026-001", peripheralTestCodeHash).Error; err != nil {
|
|
t.Fatalf("Failed to clear test peripheral_verify_code: %v", err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO peripheral_verify_code
|
|
(code, code_hash, peripheral_info_id, status, created_by, created_at, updated_at)
|
|
SELECT 'PERI-2026-001', ?, id, 1, 'service-test', 1, 1
|
|
FROM peripheral_info WHERE asset_id = ?`, peripheralTestCodeHash, asset.ID).Error; err != nil {
|
|
t.Fatalf("Failed to create test peripheral_verify_code: %v", err)
|
|
}
|
|
|
|
return asset.ID, star.StarID
|
|
}
|
|
|
|
func TestPeripheralService_GetVerification_Success(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
assetID, _ := setupAssetWithPeripheral(t, db)
|
|
|
|
result, err := svc.GetVerification(context.Background(), assetID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected result, got nil")
|
|
}
|
|
if result.AssetID <= 0 {
|
|
t.Errorf("expected assetID=%d, got %d", assetID, result.AssetID)
|
|
}
|
|
// verify_count 来自 peripheral_info.verify_count(扫码自增后的实时值)
|
|
// 测试 fixture 未显式设置 verify_count,默认 0,调用 GetVerification 后自增为 1
|
|
if result.VerifyCount != 1 {
|
|
t.Errorf("expected verify_count=1 (默认0 + 自增1), got %d", result.VerifyCount)
|
|
}
|
|
if result.Brand != "BrandX" {
|
|
t.Errorf("expected brand=BrandX, got %s", result.Brand)
|
|
}
|
|
if result.Company != "CompanyY" {
|
|
t.Errorf("expected company=CompanyY, got %s", result.Company)
|
|
}
|
|
if result.Hash != "0xdeadbeef" {
|
|
t.Errorf("expected hash=0xdeadbeef, got %s", result.Hash)
|
|
}
|
|
if result.Verifier != "verifierZ" {
|
|
t.Errorf("expected verifier=verifierZ, got %s", result.Verifier)
|
|
}
|
|
if result.Image != "https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/1/87/1778876360335.jpg" {
|
|
t.Errorf("expected image=https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/1/87/1778876360335.jpg, got %s", result.Image)
|
|
}
|
|
// first_verified_at=1715600000000 ms → 1715600000 s
|
|
if result.VerifiedAt != 1715600000 {
|
|
t.Errorf("expected verified_at=1715600000 (seconds), got %d", result.VerifiedAt)
|
|
}
|
|
}
|
|
|
|
func TestPeripheralService_GetVerification_AssetNotFound(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
|
|
_, err := svc.GetVerification(context.Background(), 999999999999)
|
|
var bizErr *BizError
|
|
if !errors.As(err, &bizErr) {
|
|
t.Fatalf("expected BizError, got %T: %v", err, err)
|
|
}
|
|
if bizErr.Code != BizCodeAssetNotFound {
|
|
t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_GetVerification_PeripheralInfoMissing 验证"只有 asset 没有 peripheral_info"场景
|
|
//
|
|
// 业务语义:周边验真接口要求必须存在 peripheral_info,否则视为"非周边"返回 50003。
|
|
func TestPeripheralService_GetVerification_PeripheralInfoMissing(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
|
|
// 只有 asset,没有 peripheral_info
|
|
star := createServiceTestStar(t, db, "test_peripheral_verify_no_info")
|
|
user := createServiceTestUser(t, db, "19900099002")
|
|
asset := createServiceTestAsset(t, db, user.ID, star.StarID, "p-verify-noinfo")
|
|
|
|
_, err := svc.GetVerification(context.Background(), asset.ID)
|
|
var bizErr *BizError
|
|
if !errors.As(err, &bizErr) {
|
|
t.Fatalf("expected BizError, got %T: %v", err, err)
|
|
}
|
|
if bizErr.Code != BizCodeAssetNotFound {
|
|
t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_MintFromPeripheral_Success 验证 happy path:
|
|
//
|
|
// asset + peripheral_info 存在 → ExistsRegistry=false → CountRecentMint<10 → INSERT → 返 MintResult
|
|
func TestPeripheralService_MintFromPeripheral_Success(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
_, _ = setupAssetWithPeripheral(t, db)
|
|
user := createServiceTestUser(t, db, "19900099901")
|
|
ownerUID := user.ID
|
|
|
|
// 清理 ownerUID 旧记录(避免残留影响)
|
|
defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
|
|
|
|
result, err := svc.MintFromPeripheralByHash(context.Background(), ownerUID, peripheralTestCodeHash, mintSignFor(peripheralTestCodeHash))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected result, got nil")
|
|
}
|
|
if result.InstanceID <= 0 {
|
|
t.Errorf("expected instance_id > 0, got %d", result.InstanceID)
|
|
}
|
|
if result.MintedAt <= 0 {
|
|
t.Errorf("expected minted_at > 0, got %d", result.MintedAt)
|
|
}
|
|
if result.AssetID <= 0 {
|
|
t.Errorf("expected new asset_id > 0, got %d", result.AssetID)
|
|
}
|
|
if result.CoverImage == "" {
|
|
t.Error("expected cover_image populated")
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_MintFromPeripheral_AlreadyAdded 验证查重:
|
|
//
|
|
// 同一 owner_uid + asset_id + asset_type=peripheral 已存在记录 → 返 50004
|
|
func TestPeripheralService_MintFromPeripheral_AlreadyAdded(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
assetID, _ := setupAssetWithPeripheral(t, db)
|
|
user := createServiceTestUser(t, db, "19900099002")
|
|
ownerUID := user.ID
|
|
|
|
// 先插一条,再调 mint
|
|
if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
|
|
VALUES (?, ?, 1, 'peripheral', 1, 1, 1)`, ownerUID, assetID).Error; err != nil {
|
|
t.Fatalf("Failed to seed asset_registry: %v", err)
|
|
}
|
|
defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
|
|
|
|
_, err := svc.MintFromPeripheralByHash(context.Background(), ownerUID, peripheralTestCodeHash, mintSignFor(peripheralTestCodeHash))
|
|
var bizErr *BizError
|
|
if !errors.As(err, &bizErr) {
|
|
t.Fatalf("expected BizError, got %T: %v", err, err)
|
|
}
|
|
if bizErr.Code != BizCodeAlreadyAdded {
|
|
t.Errorf("expected code=%d, got %d", BizCodeAlreadyAdded, bizErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_MintFromPeripheral_RateLimited 验证限频:
|
|
//
|
|
// 同一 owner_uid 在 24h 内已有 10 条 peripheral mint → 返 50012
|
|
//
|
|
// 注意:asset_registry 的 UNIQUE 约束是 (owner_uid, star_id, asset_type, asset_id),
|
|
//
|
|
// 故 10 条记录需 10 个不同的 asset_id。这里使用固定大数段 90000100~90000109 作为 fixture asset_id。
|
|
func TestPeripheralService_MintFromPeripheral_RateLimited(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
_, _ = setupAssetWithPeripheral(t, db)
|
|
user := createServiceTestUser(t, db, "19900099003")
|
|
ownerUID := user.ID
|
|
|
|
// 插 10 条最近 mint(限频阈值);star_id=1 即可,asset_id 用 10 个不同值绕开 UNIQUE 约束
|
|
now := time.Now().UnixMilli()
|
|
cleanupAssets := make([]int64, 0, 10)
|
|
for i := 0; i < 10; i++ {
|
|
fakeAssetID := int64(90000100 + i)
|
|
cleanupAssets = append(cleanupAssets, fakeAssetID)
|
|
if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
|
|
VALUES (?, ?, 1, 'peripheral', 1, ?, ?)`, ownerUID, fakeAssetID, now, now).Error; err != nil {
|
|
t.Fatalf("Failed to seed rate-limit asset_registry row %d: %v", i, err)
|
|
}
|
|
}
|
|
defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
|
|
defer db.Exec("DELETE FROM assets WHERE id IN ?", cleanupAssets)
|
|
|
|
_, err := svc.MintFromPeripheralByHash(context.Background(), ownerUID, peripheralTestCodeHash, mintSignFor(peripheralTestCodeHash))
|
|
var bizErr *BizError
|
|
if !errors.As(err, &bizErr) {
|
|
t.Fatalf("expected BizError, got %T: %v", err, err)
|
|
}
|
|
if bizErr.Code != BizCodeRateLimited {
|
|
t.Errorf("expected code=%d, got %d", BizCodeRateLimited, bizErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_MintFromPeripheral_AssetNotFound 验证物品不存在:
|
|
//
|
|
// asset_id 不存在 → 返 50003(不区分"非周边"与"已下架")
|
|
func TestPeripheralService_MintFromPeripheral_AssetNotFound(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
|
|
_, err := svc.MintFromPeripheralByHash(context.Background(), 999, "999999999999", mintSignFor("999999999999"))
|
|
var bizErr *BizError
|
|
if !errors.As(err, &bizErr) {
|
|
t.Fatalf("expected BizError, got %T: %v", err, err)
|
|
}
|
|
if bizErr.Code != BizCodeAssetNotFound {
|
|
t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_MintFromPeripheral_PeripheralInfoMissing 验证"有 asset 无 peripheral_info":
|
|
//
|
|
// asset 存在但 peripheral_info 缺失 → 返 50003(与 GetVerification 保持一致,不泄漏"非周边"细节)
|
|
func TestPeripheralService_MintFromPeripheral_PeripheralInfoMissing(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
repo := repository.NewPeripheralRepository(db)
|
|
svc := NewPeripheralService(repo)
|
|
|
|
// 只有 asset,没有 peripheral_info(复用 setupAssetWithPeripheral 风格)
|
|
star := createServiceTestStar(t, db, "test_peripheral_mint_no_info")
|
|
user := createServiceTestUser(t, db, "19900099003")
|
|
_ = createServiceTestAsset(t, db, user.ID, star.StarID, "p-mint-noinfo")
|
|
|
|
_, err := svc.MintFromPeripheralByHash(context.Background(), 999, "PERI-2026-001", mintSignFor("PERI-2026-001"))
|
|
var bizErr *BizError
|
|
if !errors.As(err, &bizErr) {
|
|
t.Fatalf("expected BizError, got %T: %v", err, err)
|
|
}
|
|
if bizErr.Code != BizCodeAssetNotFound {
|
|
t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_MintFromPeripheral_RateLimitAtomic verifies that the
|
|
// Redis Lua limiter admits exactly 10 of 11 concurrent doMint calls for one owner.
|
|
func TestPeripheralService_MintFromPeripheral_RateLimitAtomic(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
|
|
miniRedis, err := miniredis.Run()
|
|
if err != nil {
|
|
t.Fatalf("start miniredis: %v", err)
|
|
}
|
|
defer miniRedis.Close()
|
|
|
|
rdb := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
|
defer rdb.Close()
|
|
limiter := NewMintRateLimiter(rdb, 10, 24*time.Hour)
|
|
svc := NewPeripheralServiceWithLimiter(repository.NewPeripheralRepository(db), limiter)
|
|
|
|
star := createServiceTestStar(t, db, "test_peripheral_mint_atomic_limit")
|
|
user := createServiceTestUser(t, db, "19900077010")
|
|
|
|
var wg sync.WaitGroup
|
|
var succeeded, rateLimited, other atomic.Int32
|
|
for i := 0; i < 11; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, mintErr := svc.doMint(context.Background(), user.ID, &models.PeripheralInfo{
|
|
StarID: star.StarID,
|
|
Code: fmt.Sprintf("PERI-ATOMIC-%02d", i),
|
|
Image: fmt.Sprintf("https://example.com/peripheral/%d.jpg", i),
|
|
Brand: "Brand",
|
|
Company: "Company",
|
|
Hash: fmt.Sprintf("hash-%02d", i),
|
|
})
|
|
if mintErr == nil {
|
|
succeeded.Add(1)
|
|
return
|
|
}
|
|
var bizErr *BizError
|
|
if errors.As(mintErr, &bizErr) && bizErr.Code == BizCodeRateLimited {
|
|
rateLimited.Add(1)
|
|
return
|
|
}
|
|
other.Add(1)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if gotOK, gotLimited, gotOther := succeeded.Load(), rateLimited.Load(), other.Load(); gotOK != 10 || gotLimited != 1 || gotOther != 0 {
|
|
t.Fatalf("want succeeded=10 rateLimited=1 other=0, got succeeded=%d rateLimited=%d other=%d", gotOK, gotLimited, gotOther)
|
|
}
|
|
}
|
|
|
|
// TestPeripheralService_MintFromPeripheral_RedisFailureFallsBackToDB verifies
|
|
// that a Redis outage uses the existing DB count and rejects the 11th mint.
|
|
func TestPeripheralService_MintFromPeripheral_RedisFailureFallsBackToDB(t *testing.T) {
|
|
db := setupServiceTestDB(t)
|
|
defer cleanupServiceTestDB(t, db)
|
|
|
|
miniRedis, err := miniredis.Run()
|
|
if err != nil {
|
|
t.Fatalf("start miniredis: %v", err)
|
|
}
|
|
addr := miniRedis.Addr()
|
|
miniRedis.Close()
|
|
|
|
rdb := redis.NewClient(&redis.Options{
|
|
Addr: addr,
|
|
DialTimeout: 100 * time.Millisecond,
|
|
ReadTimeout: 100 * time.Millisecond,
|
|
WriteTimeout: 100 * time.Millisecond,
|
|
MaxRetries: -1,
|
|
})
|
|
defer rdb.Close()
|
|
limiter := NewMintRateLimiter(rdb, 10, 24*time.Hour)
|
|
svc := NewPeripheralServiceWithLimiter(repository.NewPeripheralRepository(db), limiter)
|
|
|
|
star := createServiceTestStar(t, db, "test_peripheral_mint_redis_fallback")
|
|
user := createServiceTestUser(t, db, "19900077011")
|
|
now := time.Now().UnixMilli()
|
|
for i := 0; i < 10; i++ {
|
|
assetID := user.ID*100 + int64(i)
|
|
if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, 'peripheral', 1, ?, ?)`, user.ID, assetID, star.StarID, now, now).Error; err != nil {
|
|
t.Fatalf("seed DB rate-limit row %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
_, mintErr := svc.doMint(context.Background(), user.ID, &models.PeripheralInfo{
|
|
StarID: star.StarID,
|
|
Code: "PERI-FALLBACK",
|
|
Image: "https://example.com/peripheral/fallback.jpg",
|
|
Brand: "Brand",
|
|
Hash: "hash-fallback",
|
|
})
|
|
var bizErr *BizError
|
|
if !errors.As(mintErr, &bizErr) {
|
|
t.Fatalf("expected BizError from DB fallback, got %T: %v", mintErr, mintErr)
|
|
}
|
|
if bizErr.Code != BizCodeRateLimited {
|
|
t.Fatalf("expected code=%d, got %d", BizCodeRateLimited, bizErr.Code)
|
|
}
|
|
}
|