- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前。 - 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential / ErrTooManyLoginAttempts; user-not-found 跑 dummy bcrypt 抹平 ~100ms 时序差; mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级)。 - 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, noop EventProducer.Publish; Init 不再装配 streams; 11 处硬编码 'gallery'/'default' 抽常量到 pkg/queue/consts (值不变, 消漂移)。 - 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value, 50-goroutine race_test 零告警; MustInit 调用点 gateway main + auth_provider + loadgen 同步更新。 - 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误 记 WARN 不静默; Dify err 映射稳定用户文案。 - 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop worker。 - 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 GetFanIdentities 链式调用; DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go); 铸造双写改异步 channel+consumer (3 retry)。 - 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。 - .env.example JWT_SECRET 改为 ≥32 字节 base64 示例(原为空, 被 MustInit 立即拒)。 Co-Authored-By: Claude <noreply@anthropic.com>
233 lines
7.5 KiB
Go
233 lines
7.5 KiB
Go
package provider
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
|
||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
"google.golang.org/grpc/codes"
|
||
|
||
"github.com/topfans/backend/pkg/database"
|
||
"github.com/topfans/backend/pkg/models"
|
||
pb "github.com/topfans/backend/pkg/proto/asset"
|
||
"github.com/topfans/backend/services/assetService/repository"
|
||
"github.com/topfans/backend/services/assetService/service"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ---- GetAssetsByType (DB-backed, 自包含) ----
|
||
//
|
||
// 这些用例验证 starbook 分类查询的过滤语义:
|
||
// - type 过滤: 只返回请求类型的分组
|
||
// - grade 过滤: regular 类型下按等级筛选
|
||
// - category 过滤: 分类维度筛选
|
||
// - 未认证: ctx 无可信身份时拒绝
|
||
//
|
||
// 自包含约定(遵循 asset_level_service_test.go 的范式):
|
||
// - TEST_DB_* 覆盖,默认 localhost:15432 / postgres / 123456 / top-fans
|
||
// - 连不上则 t.Skip,不污染其它测试
|
||
// - 只清理本用例写入的 sentinel owner_uid 行,不做大范围 cleanup
|
||
|
||
const (
|
||
// sentinel 身份: 高位取值避免与既有测试/业务数据碰撞
|
||
sbTestOwnerUID = int64(990201)
|
||
sbTestStarID = int64(990202)
|
||
)
|
||
|
||
func sbEnvOrDefault(key, def string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
// starbookTestDB 建立 GetAssetsByType 用例专用的 DB 连接。
|
||
func starbookTestDB(t *testing.T) *gorm.DB {
|
||
t.Helper()
|
||
if os.Getenv("SKIP_DB_TESTS") != "" {
|
||
t.Skip("SKIP_DB_TESTS set")
|
||
}
|
||
|
||
portStr := sbEnvOrDefault("TEST_DB_PORT", "15432")
|
||
port, _ := strconv.Atoi(portStr)
|
||
if port == 0 {
|
||
port = 15432
|
||
}
|
||
|
||
if err := database.Init(database.Config{
|
||
Host: sbEnvOrDefault("TEST_DB_HOST", "localhost"),
|
||
Port: port,
|
||
User: sbEnvOrDefault("TEST_DB_USER", "postgres"),
|
||
Password: sbEnvOrDefault("TEST_DB_PASSWORD", "123456"),
|
||
DBName: sbEnvOrDefault("TEST_DB_NAME", "top-fans"),
|
||
SSLMode: "disable",
|
||
TimeZone: "Asia/Shanghai",
|
||
}); err != nil {
|
||
t.Skipf("Skipping: cannot connect to test database: %v", err)
|
||
}
|
||
|
||
db := database.GetDB()
|
||
if err := db.AutoMigrate(&models.Asset{}, &models.AssetRegistry{}); err != nil {
|
||
t.Skipf("Skipping: AutoMigrate failed: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
// sbCleanup 只删本用例的 sentinel 行(assets/registry + FK 依赖的 user/star)。
|
||
func sbCleanup(t *testing.T, db *gorm.DB) {
|
||
t.Helper()
|
||
db.Where("owner_uid = ? AND star_id = ?", sbTestOwnerUID, sbTestStarID).Delete(&models.AssetRegistry{})
|
||
db.Where("owner_uid = ? AND star_id = ?", sbTestOwnerUID, sbTestStarID).Delete(&models.Asset{})
|
||
db.Exec("DELETE FROM stars WHERE star_id = ?", sbTestStarID)
|
||
db.Exec("DELETE FROM users WHERE id = ?", sbTestOwnerUID)
|
||
}
|
||
|
||
// sbSeed 写入 sentinel 藏品 + registry:
|
||
// - regular grade 1
|
||
// - regular grade 3
|
||
// - collection (category=手办)
|
||
func sbSeed(t *testing.T, db *gorm.DB) {
|
||
t.Helper()
|
||
sbCleanup(t, db)
|
||
|
||
now := time.Now().UnixMilli()
|
||
|
||
// assets FK: owner_uid -> users(id), star_id -> stars(star_id)。
|
||
// 显式 id 插入后按 CLAUDE.md 规范同步序列,避免后续 GORM 自增撞主键。
|
||
require.NoError(t, db.Exec(
|
||
`INSERT INTO users (id, mobile, password_hash, created_at, updated_at)
|
||
VALUES (?, ?, 'x', ?, ?) ON CONFLICT (id) DO NOTHING`,
|
||
sbTestOwnerUID, "99902010001", now, now).Error)
|
||
require.NoError(t, db.Exec(`SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))`).Error)
|
||
require.NoError(t, db.Exec(
|
||
`INSERT INTO stars (star_id, name, identity_id, created_at, updated_at)
|
||
VALUES (?, 'sb_test_star', 'sb_test_identity', ?, ?) ON CONFLICT (star_id) DO NOTHING`,
|
||
sbTestStarID, now, now).Error)
|
||
require.NoError(t, db.Exec(`SELECT setval('stars_star_id_seq', (SELECT MAX(star_id) FROM stars))`).Error)
|
||
|
||
newAsset := func(name string) *models.Asset {
|
||
a := &models.Asset{
|
||
OwnerUID: sbTestOwnerUID,
|
||
StarID: sbTestStarID,
|
||
Name: name,
|
||
CoverURL: "https://cdn/" + name + ".png",
|
||
Status: 1,
|
||
IsActive: true,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
require.NoError(t, db.Create(a).Error)
|
||
return a
|
||
}
|
||
|
||
grade1 := int32(1)
|
||
grade3 := int32(3)
|
||
cat := "手办"
|
||
|
||
regA := newAsset("regular_g1")
|
||
regB := newAsset("regular_g3")
|
||
colC := newAsset("collection_x")
|
||
|
||
regs := []*models.AssetRegistry{
|
||
{AssetID: regA.ID, AssetType: models.AssetTypeRegular, OwnerUID: sbTestOwnerUID, StarID: sbTestStarID, Grade: &grade1, DisplayStatus: 1},
|
||
{AssetID: regB.ID, AssetType: models.AssetTypeRegular, OwnerUID: sbTestOwnerUID, StarID: sbTestStarID, Grade: &grade3, DisplayStatus: 1},
|
||
{AssetID: colC.ID, AssetType: models.AssetTypeCollection, OwnerUID: sbTestOwnerUID, StarID: sbTestStarID, CollectionCategory: &cat, DisplayStatus: 1},
|
||
}
|
||
for _, r := range regs {
|
||
require.NoError(t, db.Create(r).Error)
|
||
}
|
||
|
||
t.Cleanup(func() { sbCleanup(t, db) })
|
||
}
|
||
|
||
// sbProvider 组装真实 service(真实 repo + test DB),无关依赖传 nil。
|
||
func sbProvider(db *gorm.DB) *AssetProvider {
|
||
assetRepo := repository.NewAssetRepository(db)
|
||
registryRepo := repository.NewAssetRegistryRepository(db)
|
||
svc := service.NewAssetService(assetRepo, nil, nil, nil, db, registryRepo, nil)
|
||
return &AssetProvider{assetService: svc}
|
||
}
|
||
|
||
// sbCtx 构造携带可信身份的 Dubbo attachments ctx。
|
||
func sbCtx(userID, starID int64) context.Context {
|
||
return context.WithValue(context.Background(), constant.AttachmentKey, map[string]interface{}{
|
||
"user_id": userID,
|
||
"star_id": starID,
|
||
})
|
||
}
|
||
|
||
func TestGetAssetsByType_FilterByTypeRegular(t *testing.T) {
|
||
db := starbookTestDB(t)
|
||
sbSeed(t, db)
|
||
p := sbProvider(db)
|
||
|
||
resp, err := p.GetAssetsByType(sbCtx(sbTestOwnerUID, sbTestStarID), &pb.GetAssetsByTypeRequest{
|
||
Type: models.AssetTypeRegular,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, resp)
|
||
require.NotNil(t, resp.Data)
|
||
|
||
require.Len(t, resp.Data.Groups, 1, "type=regular 只应返回 regular 分组")
|
||
assert.Equal(t, models.AssetTypeRegular, resp.Data.Groups[0].Type)
|
||
|
||
// regular 分组含 grade 1 + grade 3 两个等级
|
||
var total int32
|
||
for _, g := range resp.Data.Groups[0].Grades {
|
||
total += g.TotalCount
|
||
}
|
||
assert.Equal(t, int32(2), total, "regular 应含两条(grade1+grade3)")
|
||
}
|
||
|
||
func TestGetAssetsByType_FilterByGrade(t *testing.T) {
|
||
db := starbookTestDB(t)
|
||
sbSeed(t, db)
|
||
p := sbProvider(db)
|
||
|
||
resp, err := p.GetAssetsByType(sbCtx(sbTestOwnerUID, sbTestStarID), &pb.GetAssetsByTypeRequest{
|
||
Type: models.AssetTypeRegular,
|
||
Grade: 3,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, resp.Data)
|
||
require.Len(t, resp.Data.Groups, 1)
|
||
|
||
grades := resp.Data.Groups[0].Grades
|
||
require.Len(t, grades, 1, "grade=3 过滤后只剩一个等级段")
|
||
assert.Equal(t, int32(3), grades[0].Grade)
|
||
assert.Equal(t, int32(1), grades[0].TotalCount, "grade=3 只有一条")
|
||
}
|
||
|
||
func TestGetAssetsByType_FilterByCategory(t *testing.T) {
|
||
db := starbookTestDB(t)
|
||
sbSeed(t, db)
|
||
p := sbProvider(db)
|
||
|
||
resp, err := p.GetAssetsByType(sbCtx(sbTestOwnerUID, sbTestStarID), &pb.GetAssetsByTypeRequest{
|
||
Type: models.AssetTypeCollection,
|
||
Category: "手办",
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, resp.Data)
|
||
require.Len(t, resp.Data.Groups, 1, "type=collection + category=手办 只返回该分组")
|
||
assert.Equal(t, models.AssetTypeCollection, resp.Data.Groups[0].Type)
|
||
assert.Equal(t, int32(1), resp.Data.Groups[0].TotalCount)
|
||
}
|
||
|
||
func TestGetAssetsByType_NoIdentity(t *testing.T) {
|
||
// 无需 DB: 缺身份必须在触达 service 前拒绝。
|
||
p := &AssetProvider{}
|
||
resp, err := p.GetAssetsByType(context.Background(), &pb.GetAssetsByTypeRequest{
|
||
Type: models.AssetTypeRegular,
|
||
})
|
||
require.Error(t, err)
|
||
if resp != nil && resp.Base != nil {
|
||
assert.Equal(t, uint32(codes.Unauthenticated), resp.Base.Code)
|
||
}
|
||
}
|