feat: 实现门店套餐分配功能并统一测试基础设施
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s

新增功能:
- 门店套餐分配管理(shop_package_allocation):支持门店套餐库存管理
- 门店套餐系列分配管理(shop_series_allocation):支持套餐系列分配和佣金层级设置
- 我的套餐查询(my_package):支持门店查询自己的套餐分配情况

测试改进:
- 统一集成测试基础设施,新增 testutils.NewIntegrationTestEnv
- 重构所有集成测试使用新的测试环境设置
- 移除旧的测试辅助函数和冗余测试文件
- 新增 test_helpers_test.go 统一任务测试辅助

技术细节:
- 新增数据库迁移 000025_create_shop_allocation_tables
- 新增 3 个 Handler、Service、Store 和对应的单元测试
- 更新 OpenAPI 文档和文档生成器
- 测试覆盖率:Service 层 > 90%

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 10:45:16 +08:00
parent 5fefe9d0cb
commit 23eb0307bb
73 changed files with 8716 additions and 4558 deletions

View File

@@ -0,0 +1,401 @@
package integ
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// IntegrationTestEnv 集成测试环境
// 封装集成测试所需的所有依赖,提供统一的测试环境设置
type IntegrationTestEnv struct {
TX *gorm.DB // 自动回滚的数据库事务
Redis *redis.Client // 全局 Redis 连接
Logger *zap.Logger // 测试用日志记录器
TokenManager *auth.TokenManager // Token 管理器
App *fiber.App // 配置好的 Fiber 应用实例
Handlers *bootstrap.Handlers
Middlewares *bootstrap.Middlewares
t *testing.T
superAdmin *model.Account
currentToken string
}
// NewIntegrationTestEnv 创建集成测试环境
//
// 自动完成以下初始化:
// - 创建独立的数据库事务(测试结束后自动回滚)
// - 获取全局 Redis 连接并清理测试键
// - 创建 Logger 和 TokenManager
// - 通过 Bootstrap 初始化所有 Handlers 和 Middlewares
// - 配置 Fiber App 并注册路由
//
// 用法:
//
// func TestXxx(t *testing.T) {
// env := testutils.NewIntegrationTestEnv(t)
// // env.App 已配置好,可直接发送请求
// // env.TX 是独立事务,测试结束后自动回滚
// }
var configOnce sync.Once
func NewIntegrationTestEnv(t *testing.T) *IntegrationTestEnv {
t.Helper()
configOnce.Do(func() {
_, _ = config.Load()
})
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
logger, _ := zap.NewDevelopment()
tokenManager := auth.NewTokenManager(rdb, 24*time.Hour, 7*24*time.Hour)
deps := &bootstrap.Dependencies{
DB: tx,
Redis: rdb,
Logger: logger,
TokenManager: tokenManager,
}
result, err := bootstrap.Bootstrap(deps)
require.NoError(t, err, "Bootstrap 初始化失败")
app := fiber.New(fiber.Config{
ErrorHandler: errors.SafeErrorHandler(logger),
})
routes.RegisterRoutes(app, result.Handlers, result.Middlewares)
env := &IntegrationTestEnv{
TX: tx,
Redis: rdb,
Logger: logger,
TokenManager: tokenManager,
App: app,
Handlers: result.Handlers,
Middlewares: result.Middlewares,
t: t,
}
return env
}
// AsSuperAdmin 设置当前请求使用超级管理员身份
// 返回 IntegrationTestEnv 以支持链式调用
//
// 用法:
//
// resp, err := env.AsSuperAdmin().Request("GET", "/api/admin/roles", nil)
func (e *IntegrationTestEnv) AsSuperAdmin() *IntegrationTestEnv {
e.t.Helper()
if e.superAdmin == nil {
e.superAdmin = e.ensureSuperAdmin()
}
e.currentToken = e.generateToken(e.superAdmin)
return e
}
// AsUser 设置当前请求使用指定用户身份
// 返回 IntegrationTestEnv 以支持链式调用
//
// 用法:
//
// account := e.CreateTestAccount(...)
// resp, err := env.AsUser(account).Request("GET", "/api/admin/shops", nil)
func (e *IntegrationTestEnv) AsUser(account *model.Account) *IntegrationTestEnv {
e.t.Helper()
token := e.generateToken(account)
e.currentToken = token
return e
}
// Request 发送 HTTP 请求
// 自动添加 Authorization header如果已设置用户身份
//
// 用法:
//
// resp, err := env.AsSuperAdmin().Request("GET", "/api/admin/roles", nil)
// resp, err := env.Request("POST", "/api/admin/login", loginBody)
func (e *IntegrationTestEnv) Request(method, path string, body []byte) (*http.Response, error) {
e.t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if e.currentToken != "" {
req.Header.Set("Authorization", "Bearer "+e.currentToken)
}
return e.App.Test(req, -1)
}
// RequestWithHeaders 发送带自定义 Headers 的 HTTP 请求
func (e *IntegrationTestEnv) RequestWithHeaders(method, path string, body []byte, headers map[string]string) (*http.Response, error) {
e.t.Helper()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, path, nil)
}
for k, v := range headers {
req.Header.Set(k, v)
}
if body != nil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
if e.currentToken != "" && req.Header.Get("Authorization") == "" {
req.Header.Set("Authorization", "Bearer "+e.currentToken)
}
return e.App.Test(req, -1)
}
// ClearAuth 清除当前认证状态
func (e *IntegrationTestEnv) ClearAuth() *IntegrationTestEnv {
e.currentToken = ""
return e
}
// ensureSuperAdmin 确保超级管理员账号存在
func (e *IntegrationTestEnv) ensureSuperAdmin() *model.Account {
e.t.Helper()
var existing model.Account
err := e.TX.Where("user_type = ?", constants.UserTypeSuperAdmin).First(&existing).Error
if err == nil {
return &existing
}
return e.CreateTestAccount("superadmin", "password123", constants.UserTypeSuperAdmin, nil, nil)
}
// generateToken 为账号生成访问 Token
func (e *IntegrationTestEnv) generateToken(account *model.Account) string {
e.t.Helper()
ctx := context.Background()
var shopID, enterpriseID uint
if account.ShopID != nil {
shopID = *account.ShopID
}
if account.EnterpriseID != nil {
enterpriseID = *account.EnterpriseID
}
tokenInfo := &auth.TokenInfo{
UserID: account.ID,
UserType: account.UserType,
ShopID: shopID,
EnterpriseID: enterpriseID,
Username: account.Username,
Device: "test",
IP: "127.0.0.1",
}
accessToken, _, err := e.TokenManager.GenerateTokenPair(ctx, tokenInfo)
require.NoError(e.t, err, "生成 Token 失败")
return accessToken
}
var (
usernameCounter uint64
phoneCounter uint64
shopCodeCounter uint64
)
// CreateTestAccount 创建测试账号
func (e *IntegrationTestEnv) CreateTestAccount(username, password string, userType int, shopID, enterpriseID *uint) *model.Account {
e.t.Helper()
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
require.NoError(e.t, err)
counter := atomic.AddUint64(&usernameCounter, 1)
uniqueUsername := fmt.Sprintf("%s_%d", username, counter)
uniquePhone := fmt.Sprintf("138%08d", atomic.AddUint64(&phoneCounter, 1))
account := &model.Account{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
Username: uniqueUsername,
Phone: uniquePhone,
Password: string(hashedPassword),
UserType: userType,
ShopID: shopID,
EnterpriseID: enterpriseID,
Status: 1,
}
err = e.TX.Create(account).Error
require.NoError(e.t, err, "创建测试账号失败")
return account
}
// CreateTestShop 创建测试商户
func (e *IntegrationTestEnv) CreateTestShop(name string, level int, parentID *uint) *model.Shop {
e.t.Helper()
counter := atomic.AddUint64(&shopCodeCounter, 1)
uniqueCode := fmt.Sprintf("SHOP_%d_%d", time.Now().UnixNano()%10000, counter)
uniqueName := fmt.Sprintf("%s_%d", name, counter)
shop := &model.Shop{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
ShopName: uniqueName,
ShopCode: uniqueCode,
Level: level,
ParentID: parentID,
Status: 1,
}
err := e.TX.Create(shop).Error
require.NoError(e.t, err, "创建测试商户失败")
return shop
}
// CreateTestEnterprise 创建测试企业
func (e *IntegrationTestEnv) CreateTestEnterprise(name string, ownerShopID *uint) *model.Enterprise {
e.t.Helper()
counter := atomic.AddUint64(&shopCodeCounter, 1)
uniqueCode := fmt.Sprintf("ENT_%d_%d", time.Now().UnixNano()%10000, counter)
uniqueName := fmt.Sprintf("%s_%d", name, counter)
enterprise := &model.Enterprise{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
EnterpriseName: uniqueName,
EnterpriseCode: uniqueCode,
OwnerShopID: ownerShopID,
Status: 1,
}
err := e.TX.Create(enterprise).Error
require.NoError(e.t, err, "创建测试企业失败")
return enterprise
}
// CreateTestRole 创建测试角色
func (e *IntegrationTestEnv) CreateTestRole(name string, roleType int) *model.Role {
e.t.Helper()
counter := atomic.AddUint64(&usernameCounter, 1)
uniqueName := fmt.Sprintf("%s_%d", name, counter)
role := &model.Role{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
RoleName: uniqueName,
RoleType: roleType,
Status: constants.StatusEnabled,
}
err := e.TX.Create(role).Error
require.NoError(e.t, err, "创建测试角色失败")
return role
}
// CreateTestPermission 创建测试权限
func (e *IntegrationTestEnv) CreateTestPermission(name, code string, permType int) *model.Permission {
e.t.Helper()
counter := atomic.AddUint64(&usernameCounter, 1)
uniqueName := fmt.Sprintf("%s_%d", name, counter)
uniqueCode := fmt.Sprintf("%s_%d", code, counter)
permission := &model.Permission{
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
PermName: uniqueName,
PermCode: uniqueCode,
PermType: permType,
Status: constants.StatusEnabled,
}
err := e.TX.Create(permission).Error
require.NoError(e.t, err, "创建测试权限失败")
return permission
}
// SetUserContext 设置用户上下文(用于直接调用 Service 层测试)
func (e *IntegrationTestEnv) SetUserContext(ctx context.Context, userID uint, userType int, shopID uint) context.Context {
return middleware.SetUserContext(ctx, middleware.NewSimpleUserContext(userID, userType, shopID))
}
// GetSuperAdminContext 获取超级管理员上下文
func (e *IntegrationTestEnv) GetSuperAdminContext() context.Context {
if e.superAdmin == nil {
e.superAdmin = e.ensureSuperAdmin()
}
return e.SetUserContext(context.Background(), e.superAdmin.ID, constants.UserTypeSuperAdmin, 0)
}
// RawDB 获取跳过数据权限过滤的数据库连接
// 用于测试中验证数据是否正确写入,不受 GORM Callback 影响
//
// 用法:
//
// var count int64
// env.RawDB().Model(&model.Role{}).Where("role_name = ?", name).Count(&count)
func (e *IntegrationTestEnv) RawDB() *gorm.DB {
ctx := e.GetSuperAdminContext()
return e.TX.WithContext(ctx)
}