feat(account): 实现平台账号管理功能

- 新增平台账号列表查询接口(自动筛选超级管理员和平台用户)
- 新增密码修改和状态切换专用接口
- 增强角色分配功能,支持空数组清空所有角色
- 新增超级管理员保护机制,禁止分配角色
- 新增完整的集成测试和OpenSpec规范文档
This commit is contained in:
2026-01-14 17:00:30 +08:00
parent 5556b1028c
commit b1195c16df
15 changed files with 1713 additions and 51 deletions

View File

@@ -164,3 +164,59 @@ func (h *AccountHandler) RemoveRole(c *fiber.Ctx) error {
return response.Success(c, nil) return response.Success(c, nil)
} }
// UpdatePassword 修改账号密码
// PUT /api/admin/platform-accounts/:id/password
func (h *AccountHandler) UpdatePassword(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var req model.UpdatePasswordRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdatePassword(c.UserContext(), uint(id), req.NewPassword); err != nil {
return err
}
return response.Success(c, nil)
}
// UpdateStatus 修改账号状态
// PUT /api/admin/platform-accounts/:id/status
func (h *AccountHandler) UpdateStatus(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的账号 ID")
}
var req model.UpdateStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdateStatus(c.UserContext(), uint(id), req.Status); err != nil {
return err
}
return response.Success(c, nil)
}
// ListPlatformAccounts 查询平台账号列表
// GET /api/admin/platform-accounts
func (h *AccountHandler) ListPlatformAccounts(c *fiber.Ctx) error {
var req model.PlatformAccountListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
accounts, total, err := h.service.ListPlatformAccounts(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, accounts, total, req.Page, req.PageSize)
}

View File

@@ -44,8 +44,9 @@ type AccountResponse struct {
} }
// AssignRolesRequest 分配角色请求 // AssignRolesRequest 分配角色请求
// 支持传递空数组以清空账号的所有角色
type AssignRolesRequest struct { type AssignRolesRequest struct {
RoleIDs []uint `json:"role_ids" validate:"required,min=1" required:"true" minItems:"1" description:"角色ID列表"` RoleIDs []uint `json:"role_ids" validate:"omitempty" description:"角色ID列表,传空数组可清空所有角色"`
} }
// AccountPageResult 账号分页响应 // AccountPageResult 账号分页响应
@@ -55,3 +56,39 @@ type AccountPageResult struct {
Page int `json:"page" description:"当前页码"` Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"` Size int `json:"size" description:"每页数量"`
} }
// ========== 平台账号管理专用 DTO ==========
// UpdatePasswordRequest 修改密码请求
// 用于管理员重置密码场景,无需验证旧密码
type UpdatePasswordRequest struct {
NewPassword string `json:"new_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"新密码8-32位"`
}
// UpdateStatusRequest 状态切换请求
// 用于启用/禁用账号
type UpdateStatusRequest struct {
Status int `json:"status" validate:"required,min=0,max=1" required:"true" minimum:"0" maximum:"1" description:"状态0:禁用1:启用)"`
}
// PlatformAccountListRequest 平台账号列表查询请求
// 自动筛选 user_type IN (1, 2) 的账号
type PlatformAccountListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Username string `json:"username" query:"username" validate:"omitempty,max=50" maxLength:"50" description:"用户名模糊查询"`
Phone string `json:"phone" query:"phone" validate:"omitempty,max=20" maxLength:"20" description:"手机号模糊查询"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"状态"`
}
// UpdatePasswordParams 修改密码参数(用于 OpenAPI 生成)
type UpdatePasswordParams struct {
ID uint `params:"id" description:"账号ID"`
RequestBody UpdatePasswordRequest `json:"body" description:"请求体"`
}
// UpdateStatusParams 状态切换参数(用于 OpenAPI 生成)
type UpdateStatusParams struct {
ID uint `params:"id" description:"账号ID"`
RequestBody UpdateStatusRequest `json:"body" description:"请求体"`
}

View File

@@ -70,4 +70,81 @@ func registerAccountRoutes(api fiber.Router, h *admin.AccountHandler, doc *opena
Input: new(model.RemoveRoleParams), Input: new(model.RemoveRoleParams),
Output: nil, Output: nil,
}) })
registerPlatformAccountRoutes(api, h, doc, basePath)
}
func registerPlatformAccountRoutes(api fiber.Router, h *admin.AccountHandler, doc *openapi.Generator, basePath string) {
platformAccounts := api.Group("/platform-accounts")
groupPath := basePath + "/platform-accounts"
Register(platformAccounts, doc, groupPath, "GET", "", h.ListPlatformAccounts, RouteSpec{
Summary: "平台账号列表",
Tags: []string{"PlatformAccount"},
Input: new(model.PlatformAccountListRequest),
Output: new(model.AccountPageResult),
})
Register(platformAccounts, doc, groupPath, "POST", "", h.Create, RouteSpec{
Summary: "新增平台账号",
Tags: []string{"PlatformAccount"},
Input: new(model.CreateAccountRequest),
Output: new(model.AccountResponse),
})
Register(platformAccounts, doc, groupPath, "GET", "/:id", h.Get, RouteSpec{
Summary: "获取平台账号详情",
Tags: []string{"PlatformAccount"},
Input: new(model.IDReq),
Output: new(model.AccountResponse),
})
Register(platformAccounts, doc, groupPath, "PUT", "/:id", h.Update, RouteSpec{
Summary: "编辑平台账号",
Tags: []string{"PlatformAccount"},
Input: new(model.UpdateAccountParams),
Output: new(model.AccountResponse),
})
Register(platformAccounts, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{
Summary: "删除平台账号",
Tags: []string{"PlatformAccount"},
Input: new(model.IDReq),
Output: nil,
})
Register(platformAccounts, doc, groupPath, "PUT", "/:id/password", h.UpdatePassword, RouteSpec{
Summary: "修改密码",
Tags: []string{"PlatformAccount"},
Input: new(model.UpdatePasswordParams),
Output: nil,
})
Register(platformAccounts, doc, groupPath, "PUT", "/:id/status", h.UpdateStatus, RouteSpec{
Summary: "启用/禁用账号",
Tags: []string{"PlatformAccount"},
Input: new(model.UpdateStatusParams),
Output: nil,
})
Register(platformAccounts, doc, groupPath, "POST", "/:id/roles", h.AssignRoles, RouteSpec{
Summary: "分配角色",
Tags: []string{"PlatformAccount"},
Input: new(model.AssignRolesParams),
Output: nil,
})
Register(platformAccounts, doc, groupPath, "GET", "/:id/roles", h.GetRoles, RouteSpec{
Summary: "获取账号角色",
Tags: []string{"PlatformAccount"},
Input: new(model.IDReq),
Output: new([]model.Role),
})
Register(platformAccounts, doc, groupPath, "DELETE", "/:id/roles/:role_id", h.RemoveRole, RouteSpec{
Summary: "移除角色",
Tags: []string{"PlatformAccount"},
Input: new(model.RemoveRoleParams),
Output: nil,
})
} }

View File

@@ -210,15 +210,13 @@ func (s *Service) List(ctx context.Context, req *model.AccountListRequest) ([]*m
return s.accountStore.List(ctx, opts, filters) return s.accountStore.List(ctx, opts, filters)
} }
// AssignRoles 为账号分配角色 // AssignRoles 为账号分配角色(支持空数组清空所有角色,超级管理员禁止分配)
func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uint) ([]*model.AccountRole, error) { func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uint) ([]*model.AccountRole, error) {
// 获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx) currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 { if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问") return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
} }
// 检查账号存在
account, err := s.accountStore.GetByID(ctx, accountID) account, err := s.accountStore.GetByID(ctx, accountID)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
@@ -227,19 +225,29 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
return nil, fmt.Errorf("获取账号失败: %w", err) return nil, fmt.Errorf("获取账号失败: %w", err)
} }
// 检查用户类型是否允许分配角色 // 超级管理员禁止分配角色
if account.UserType == constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeInvalidParam, "超级管理员不允许分配角色")
}
// 空数组:清空所有角色
if len(roleIDs) == 0 {
if err := s.accountRoleStore.DeleteByAccountID(ctx, accountID); err != nil {
return nil, fmt.Errorf("清空账号角色失败: %w", err)
}
return []*model.AccountRole{}, nil
}
maxRoles := constants.GetMaxRolesForUserType(account.UserType) maxRoles := constants.GetMaxRolesForUserType(account.UserType)
if maxRoles == 0 { if maxRoles == 0 {
return nil, errors.New(errors.CodeInvalidParam, "该用户类型不需要分配角色") return nil, errors.New(errors.CodeInvalidParam, "该用户类型不需要分配角色")
} }
// 检查角色数量限制
existingCount, err := s.accountRoleStore.CountByAccountID(ctx, accountID) existingCount, err := s.accountRoleStore.CountByAccountID(ctx, accountID)
if err != nil { if err != nil {
return nil, fmt.Errorf("统计现有角色数量失败: %w", err) return nil, fmt.Errorf("统计现有角色数量失败: %w", err)
} }
// 计算将要分配的新角色数量(排除已存在的)
newRoleCount := 0 newRoleCount := 0
for _, roleID := range roleIDs { for _, roleID := range roleIDs {
exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID) exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID)
@@ -248,12 +256,10 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
} }
} }
// 检查角色数量限制(-1 表示无限制)
if maxRoles != -1 && int(existingCount)+newRoleCount > maxRoles { if maxRoles != -1 && int(existingCount)+newRoleCount > maxRoles {
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("该用户类型最多只能分配 %d 个角色", maxRoles)) return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("该用户类型最多只能分配 %d 个角色", maxRoles))
} }
// 验证所有角色存在并检查角色类型是否匹配
for _, roleID := range roleIDs { for _, roleID := range roleIDs {
role, err := s.roleStore.GetByID(ctx, roleID) role, err := s.roleStore.GetByID(ctx, roleID)
if err != nil { if err != nil {
@@ -263,19 +269,16 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
return nil, fmt.Errorf("获取角色失败: %w", err) return nil, fmt.Errorf("获取角色失败: %w", err)
} }
// 检查角色类型与用户类型是否匹配
if !constants.IsRoleTypeMatchUserType(role.RoleType, account.UserType) { if !constants.IsRoleTypeMatchUserType(role.RoleType, account.UserType) {
return nil, errors.New(errors.CodeInvalidParam, "角色类型与账号类型不匹配") return nil, errors.New(errors.CodeInvalidParam, "角色类型与账号类型不匹配")
} }
} }
// 创建关联
var ars []*model.AccountRole var ars []*model.AccountRole
for _, roleID := range roleIDs { for _, roleID := range roleIDs {
// 检查是否已分配
exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID) exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID)
if exists { if exists {
continue // 跳过已存在的关联 continue
} }
ar := &model.AccountRole{ ar := &model.AccountRole{
@@ -344,6 +347,83 @@ func (s *Service) ValidatePassword(plainPassword, hashedPassword string) bool {
return err == nil return err == nil
} }
// UpdatePassword 修改账号密码(管理员重置场景,无需旧密码)
func (s *Service) UpdatePassword(ctx context.Context, accountID uint, newPassword string) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.accountStore.GetByID(ctx, accountID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return fmt.Errorf("获取账号失败: %w", err)
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("密码哈希失败: %w", err)
}
if err := s.accountStore.UpdatePassword(ctx, accountID, string(hashedPassword), currentUserID); err != nil {
return fmt.Errorf("更新密码失败: %w", err)
}
return nil
}
// UpdateStatus 修改账号状态(启用/禁用)
func (s *Service) UpdateStatus(ctx context.Context, accountID uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.accountStore.GetByID(ctx, accountID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAccountNotFound, "账号不存在")
}
return fmt.Errorf("获取账号失败: %w", err)
}
if err := s.accountStore.UpdateStatus(ctx, accountID, status, currentUserID); err != nil {
return fmt.Errorf("更新状态失败: %w", err)
}
return nil
}
// ListPlatformAccounts 查询平台账号列表(自动筛选 user_type IN (1, 2)
func (s *Service) ListPlatformAccounts(ctx context.Context, req *model.PlatformAccountListRequest) ([]*model.Account, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.Username != "" {
filters["username"] = req.Username
}
if req.Phone != "" {
filters["phone"] = req.Phone
}
if req.Status != nil {
filters["status"] = *req.Status
}
return s.accountStore.ListPlatformAccounts(ctx, opts, filters)
}
// CreateSystemAccount 系统内部创建账号方法,用于系统初始化场景(绕过当前用户检查) // CreateSystemAccount 系统内部创建账号方法,用于系统初始化场景(绕过当前用户检查)
func (s *Service) CreateSystemAccount(ctx context.Context, account *model.Account) error { func (s *Service) CreateSystemAccount(ctx context.Context, account *model.Account) error {
if account.Username == "" { if account.Username == "" {

View File

@@ -129,3 +129,71 @@ func (s *AccountStore) List(ctx context.Context, opts *store.QueryOptions, filte
return accounts, total, nil return accounts, total, nil
} }
// ListPlatformAccounts 查询平台账号列表(自动筛选 user_type IN (1, 2)
func (s *AccountStore) ListPlatformAccounts(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Account, int64, error) {
var accounts []*model.Account
var total int64
query := s.db.WithContext(ctx).Model(&model.Account{})
// 固定筛选平台账号:超级管理员(1) 和 平台用户(2)
query = query.Where("user_type IN ?", []int{1, 2})
// 应用过滤条件
if username, ok := filters["username"].(string); ok && username != "" {
query = query.Where("username LIKE ?", "%"+username+"%")
}
if phone, ok := filters["phone"].(string); ok && phone != "" {
query = query.Where("phone LIKE ?", "%"+phone+"%")
}
if status, ok := filters["status"].(int); ok {
query = query.Where("status = ?", status)
}
// 计算总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
// 排序
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
}
// 执行查询
if err := query.Find(&accounts).Error; err != nil {
return nil, 0, err
}
return accounts, total, nil
}
// UpdatePassword 更新账号密码
func (s *AccountStore) UpdatePassword(ctx context.Context, id uint, hashedPassword string, updater uint) error {
return s.db.WithContext(ctx).
Model(&model.Account{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"password": hashedPassword,
"updater": updater,
}).Error
}
// UpdateStatus 更新账号状态
func (s *AccountStore) UpdateStatus(ctx context.Context, id uint, status int, updater uint) error {
return s.db.WithContext(ctx).
Model(&model.Account{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updater": updater,
}).Error
}

View File

@@ -0,0 +1,388 @@
# 平台账号管理功能实现
## 📋 变更概述
**Change ID**: `add-platform-account-management`
**目标**:实现专门的平台账号(平台用户 + 超级管理员)管理接口,提供语义清晰的专用操作,并增强角色分配灵活性。
**验证状态**: ✅ PASSED (`openspec validate add-platform-account-management --strict`)
---
## 🎯 功能需求(用户需求)
根据原始需求,需要实现以下接口:
1.**分页查询列表**
- 查询条件:账号名称
- 返回值:名称、手机号、创建时间、状态(启用/停用)
- **包含超级管理员**
2.**新增平台账号**
- 表单参数:名称、手机号(登录账号)、登录密码、状态(启用/禁用)、选择角色(多选)
3.**编辑平台账号**
- 参考新增接口
4.**修改密码**
- 表单参数:新密码(无需旧密码)
5.**启用/禁用接口**
- 独立的状态切换接口
6.**超级管理员出现在列表中**
- 列表自动包含 `user_type IN (1, 2)` 的账号
---
## 🔧 技术实现方案
### 1. 新增接口列表
#### 核心管理接口
| 方法 | 路径 | 说明 | 实现方式 |
|------|------|------|---------|
| `GET` | `/api/admin/platform-accounts` | 平台账号列表(含超管) | **新增** `ListPlatformAccounts` |
| `POST` | `/api/admin/platform-accounts` | 新增平台账号 | **复用** `Create` |
| `GET` | `/api/admin/platform-accounts/:id` | 获取详情 | **复用** `Get` |
| `PUT` | `/api/admin/platform-accounts/:id` | 编辑平台账号 | **复用** `Update` |
| `DELETE` | `/api/admin/platform-accounts/:id` | 删除平台账号 | **复用** `Delete` |
#### 专用操作接口
| 方法 | 路径 | 说明 | 实现方式 |
|------|------|------|---------|
| `PUT` | `/api/admin/platform-accounts/:id/password` | 修改密码 | **新增** `UpdatePassword` |
| `PUT` | `/api/admin/platform-accounts/:id/status` | 启用/禁用 | **新增** `UpdateStatus` |
#### 角色管理接口
| 方法 | 路径 | 说明 | 实现方式 |
|------|------|------|---------|
| `POST` | `/api/admin/platform-accounts/:id/roles` | 分配角色 | **增强** `AssignRoles` |
| `GET` | `/api/admin/platform-accounts/:id/roles` | 获取角色列表 | **复用** `GetRoles` |
| `DELETE` | `/api/admin/platform-accounts/:id/roles/:role_id` | 移除单个角色 | **复用** `RemoveRole` |
### 2. 新增 DTO
```go
// UpdatePasswordRequest 修改密码请求
type UpdatePasswordRequest struct {
NewPassword string `json:"new_password" validate:"required,min=8,max=32"`
}
// UpdateStatusRequest 状态切换请求
type UpdateStatusRequest struct {
Status int `json:"status" validate:"required,min=0,max=1"`
}
// PlatformAccountListRequest 平台账号列表请求
type PlatformAccountListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100"`
Username string `json:"username" query:"username" validate:"omitempty,max=50"`
Phone string `json:"phone" query:"phone" validate:"omitempty,max=20"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1"`
}
```
### 3. 新增 Service 方法
```go
// UpdatePassword 修改密码
func (s *Service) UpdatePassword(ctx context.Context, accountID uint, newPassword string) error
// UpdateStatus 状态切换
func (s *Service) UpdateStatus(ctx context.Context, accountID uint, status int) error
// ListPlatformAccounts 平台账号列表查询(自动筛选 user_type IN (1,2)
func (s *Service) ListPlatformAccounts(ctx context.Context, req *model.PlatformAccountListRequest) ([]*model.Account, int64, error)
```
### 4. 角色分配增强
**修改前**
```go
type AssignRolesRequest struct {
RoleIDs []uint `json:"role_ids" validate:"required,min=1"` // 必填至少1个
}
```
**修改后**
```go
type AssignRolesRequest struct {
RoleIDs []uint `json:"role_ids" validate:"omitempty"` // 可选,允许空数组
}
```
**业务逻辑调整**
- ✅ 允许传递空数组 `[]` 清空所有角色
- ✅ 超级管理员(`user_type=1`)禁止分配角色,返回错误
- ✅ 平台用户(`user_type=2`)可分配无限个平台角色
---
## 📝 API 使用示例
### 1. 查询平台账号列表
**请求**
```http
GET /api/admin/platform-accounts?page=1&page_size=20&username=admin&status=1
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"items": [
{
"id": 1,
"username": "admin",
"phone": "13800000000",
"user_type": 1,
"status": 1,
"created_at": "2025-01-14T10:00:00Z",
"updated_at": "2025-01-14T10:00:00Z"
},
{
"id": 2,
"username": "platform_user",
"phone": "13900000000",
"user_type": 2,
"status": 1,
"created_at": "2025-01-14T11:00:00Z",
"updated_at": "2025-01-14T11:00:00Z"
}
],
"total": 2,
"page": 1,
"size": 20
},
"timestamp": "2025-01-14T10:30:00Z"
}
```
### 2. 新增平台账号
**请求**
```http
POST /api/admin/platform-accounts
Content-Type: application/json
{
"username": "new_platform_user",
"phone": "13700000000",
"password": "SecurePass@123",
"user_type": 2
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 3,
"username": "new_platform_user",
"phone": "13700000000",
"user_type": 2,
"status": 1,
"created_at": "2025-01-14T12:00:00Z"
},
"timestamp": "2025-01-14T12:00:00Z"
}
```
### 3. 修改密码
**请求**
```http
PUT /api/admin/platform-accounts/3/password
Content-Type: application/json
{
"new_password": "NewSecurePass@456"
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": "2025-01-14T12:05:00Z"
}
```
### 4. 启用/禁用账号
**请求**
```http
PUT /api/admin/platform-accounts/3/status
Content-Type: application/json
{
"status": 0
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": "2025-01-14T12:10:00Z"
}
```
### 5. 分配角色(支持空数组)
**清空所有角色**
```http
POST /api/admin/platform-accounts/3/roles
Content-Type: application/json
{
"role_ids": []
}
```
**分配多个角色**
```http
POST /api/admin/platform-accounts/3/roles
Content-Type: application/json
{
"role_ids": [1, 2, 3]
}
```
**响应**
```json
{
"code": 0,
"msg": "success",
"data": [
{
"id": 10,
"account_id": 3,
"role_id": 1,
"status": 1
}
],
"timestamp": "2025-01-14T12:15:00Z"
}
```
### 6. 超级管理员保护
**尝试为超级管理员分配角色**(会被拒绝):
```http
POST /api/admin/platform-accounts/1/roles
Content-Type: application/json
{
"role_ids": [1]
}
```
**响应**
```json
{
"code": 1001,
"msg": "超级管理员不允许分配角色",
"data": null,
"timestamp": "2025-01-14T12:20:00Z"
}
```
---
## 🔐 业务规则
### 用户类型筛选
-**平台账号列表**:自动筛选 `user_type IN (1, 2)`
-**包含超级管理员**`user_type=1` 的账号出现在列表中
### 角色分配规则
| 用户类型 | 允许分配角色数量 | 角色类型限制 | 是否允许清空角色 |
|---------|----------------|-------------|----------------|
| 超级管理员1 | ❌ 不允许分配 | - | ❌ |
| 平台用户2 | ✅ 无限制 | 只能分配平台角色(`role_type=1` | ✅ |
| 代理账号3 | ✅ 最多 1 个 | 只能分配客户角色(`role_type=2` | ✅ |
| 企业账号4 | ✅ 最多 1 个 | 只能分配客户角色(`role_type=2` | ✅ |
### 密码规则
- ✅ 长度8-32 位
- ✅ 存储bcrypt 哈希
- ✅ 修改:无需验证旧密码(管理员重置场景)
### 状态规则
- ✅ 启用:`status=1`
- ✅ 禁用:`status=0`
- ✅ 禁用账号无法登录(认证层拦截)
---
## 📂 文件清单
### 提案文件
- `openspec/changes/add-platform-account-management/proposal.md` - 变更提案
- `openspec/changes/add-platform-account-management/tasks.md` - 实现任务清单
- `openspec/changes/add-platform-account-management/README.md` - 本文档
### Spec Deltas
- `openspec/changes/add-platform-account-management/specs/role-permission/spec.md` - 角色分配逻辑调整
- `openspec/changes/add-platform-account-management/specs/user-organization/spec.md` - 平台账号管理增强
---
## ✅ 验证结果
```bash
$ openspec validate add-platform-account-management --strict
Change 'add-platform-account-management' is valid
```
**验证通过**:所有 delta specs 格式正确,需求完整,场景覆盖充分。
---
## 🚀 下一步
### 1. 审查提案
请审查以下文件:
- `openspec/changes/add-platform-account-management/proposal.md` - 确认业务需求和影响范围
- `openspec/changes/add-platform-account-management/tasks.md` - 确认实现任务清单
- `openspec/changes/add-platform-account-management/specs/*/spec.md` - 确认需求定义
### 2. 批准后开始实现
批准后,我将按照 `tasks.md` 的顺序逐步实现:
1. Model 层DTO 定义)
2. Service 层(业务逻辑)
3. Handler 层HTTP 处理)
4. 路由注册
5. 单元测试
6. 集成测试
7. 文档更新
### 3. 实现完成后归档
实现完成并测试通过后,使用以下命令归档:
```bash
openspec archive add-platform-account-management
```
---
## 📞 问题反馈
如有任何问题或需要调整,请告知:
- 业务需求是否准确?
- 接口设计是否合理?
- 角色分配逻辑是否符合预期?
- 是否需要额外功能?

View File

@@ -0,0 +1,128 @@
# Change: 实现平台账号管理接口
## Why
当前系统已有通用的账号管理功能,但缺少针对**平台账号(平台用户 + 超级管理员)**的专用管理接口。业务需要:
1. **专门的平台账号列表**:筛选出 `user_type IN (1, 2)` 的账号,包含超级管理员
2. **语义明确的专用接口**:密码修改、启用/禁用需要独立端点,而非通过通用 Update 接口
3. **角色分配灵活性**:允许清空角色、支持可选角色分配
4. **超级管理员保护**:超级管理员在列表中只读显示,禁止编辑角色
**现状**
- ✅ 已有账号 CRUD 功能(`/internal/handler/admin/account.go`
- ✅ 已有角色分配功能(`AssignRoles`, `GetRoles`, `RemoveRole`
- ❌ 缺少专门的密码修改接口(当前混在 Update 接口中)
- ❌ 缺少专门的启用/禁用接口(当前混在 Update 接口中)
- ❌ 角色分配要求至少 1 个角色(不允许清空)
- ❌ 没有超级管理员编辑保护
## What Changes
### 1. 新增专用接口
#### Handler 层新增方法
- `UpdatePassword(c *fiber.Ctx)` - 修改密码专用接口
- `UpdateStatus(c *fiber.Ctx)` - 启用/禁用专用接口
- `ListPlatformAccounts(c *fiber.Ctx)` - 平台账号列表查询user_type IN (1,2)
#### Service 层新增方法
- `UpdatePassword(ctx, accountID, newPassword)` - 密码修改业务逻辑
- `UpdateStatus(ctx, accountID, status)` - 状态切换业务逻辑
- `ListPlatformAccounts(ctx, req)` - 平台账号列表查询(自动筛选 user_type
#### 新增 DTO
- `UpdatePasswordRequest` - 密码修改请求(只包含 `new_password`
- `UpdateStatusRequest` - 状态修改请求(只包含 `status`
- `PlatformAccountListRequest` - 平台账号列表请求(移除 user_type 筛选)
### 2. 优化现有角色分配逻辑
#### Service 层修改
- `AssignRoles` 方法增强:
- 允许 `roleIDs` 为空数组(清空所有角色)
- 超级管理员(`user_type=1`)禁止分配角色,返回错误
- 平台用户(`user_type=2`)可分配无限个平台角色(`role_type=1`
#### DTO 修改
- `AssignRolesRequest.RoleIDs` 改为可选(`validate:"omitempty"`
### 3. 新增路由
```go
// 平台账号专用路由
GET /api/admin/platform-accounts // 平台账号列表(含超级管理员)
POST /api/admin/platform-accounts // 新增平台账号
GET /api/admin/platform-accounts/:id // 获取详情
PUT /api/admin/platform-accounts/:id // 编辑平台账号
DELETE /api/admin/platform-accounts/:id // 删除平台账号
// 专用操作接口
PUT /api/admin/platform-accounts/:id/password // 修改密码
PUT /api/admin/platform-accounts/:id/status // 启用/禁用
// 角色管理
POST /api/admin/platform-accounts/:id/roles // 分配角色(支持空数组)
GET /api/admin/platform-accounts/:id/roles // 获取角色列表
DELETE /api/admin/platform-accounts/:id/roles/:role_id // 移除单个角色
```
### 4. 响应格式调整
**列表返回字段**(符合需求):
```json
{
"items": [
{
"id": 1,
"username": "admin",
"phone": "13800000000",
"created_at": "2025-01-14T10:30:00Z",
"status": 1,
"user_type": 1
}
],
"total": 10,
"page": 1,
"size": 20
}
```
## Impact
### 受影响的 Specs
- `role-permission` - 角色分配逻辑调整(允许空数组)
- `user-organization` - 平台账号管理增强
### 受影响的代码模块
- `internal/handler/admin/account.go` - 新增 3 个 Handler 方法
- `internal/service/account/service.go` - 新增 2 个 Service 方法,修改 AssignRoles
- `internal/model/account_dto.go` - 新增 2 个 DTO修改 AssignRolesRequest
- `internal/routes/account.go` - 新增路由注册
### Breaking Changes
无。新增功能向后兼容,现有接口保持不变。
### 数据库变更
无。复用现有 `tb_account` 表结构。
### 迁移计划
无需迁移。新接口与现有接口共存,前端可按需切换。
## Risks
1. **角色分配空数组行为**:允许清空所有角色可能导致账号无权限
- **缓解措施**:前端二次确认,文档明确说明
2. **超级管理员保护**:禁止编辑超级管理员角色可能影响已有流程
- **缓解措施**:仅对 `user_type=1` 生效,平台用户不受影响
3. **路由命名冲突**:新增 `/platform-accounts` 路由可能与未来规划冲突
- **缓解措施**:遵循 RESTful 规范,路径清晰语义化
## Open Questions
1.**已确认**:超级管理员是否需要出现在平台账号列表? → **是**
2.**已确认**:修改密码是否需要旧密码验证? → **否**(管理员重置场景)
3.**已确认**:角色分配是否必填? → **可选**(允许无角色账号)
4.**已确认**:是否允许清空所有角色? → **是**(灵活分配)

View File

@@ -0,0 +1,63 @@
# role-permission Spec Delta
## MODIFIED Requirements
### Requirement: 角色类型与用户类型匹配
系统 SHALL 在分配角色时校验角色类型与用户类型的匹配关系:平台用户只能分配平台角色,代理/企业账号只能分配客户角色,超级管理员不允许分配角色。分配角色时支持传递空数组以清空账号的所有角色。
#### Scenario: 平台用户分配平台角色
- **WHEN** 为平台用户user_type=2分配平台角色role_type=1
- **THEN** 系统允许分配
#### Scenario: 平台用户分配客户角色
- **WHEN** 为平台用户user_type=2分配客户角色role_type=2
- **THEN** 系统拒绝分配并返回错误"角色类型与账号类型不匹配"
#### Scenario: 代理账号分配客户角色
- **WHEN** 为代理账号user_type=3分配客户角色role_type=2
- **THEN** 系统允许分配
#### Scenario: 代理账号分配平台角色
- **WHEN** 为代理账号user_type=3分配平台角色role_type=1
- **THEN** 系统拒绝分配并返回错误"角色类型与账号类型不匹配"
#### Scenario: 企业账号分配客户角色
- **WHEN** 为企业账号user_type=4分配客户角色role_type=2
- **THEN** 系统允许分配
#### Scenario: 超级管理员禁止分配角色
- **WHEN** 尝试为超级管理员user_type=1分配任何角色
- **THEN** 系统拒绝分配并返回错误 CodeInvalidParam "超级管理员不允许分配角色"
#### Scenario: 清空账号所有角色
- **WHEN** 调用分配角色接口时传递空数组 `role_ids: []`
- **THEN** 系统删除该账号的所有现有角色关联,返回成功
#### Scenario: 传递空数组给超级管理员
- **WHEN** 为超级管理员user_type=1调用分配角色接口且传递空数组
- **THEN** 系统拒绝操作并返回错误"超级管理员不允许分配角色"
---
## ADDED Requirements
### Requirement: 角色分配灵活性
系统 SHALL 支持灵活的角色分配操作允许传递空数组清空所有角色允许传递部分角色ID进行增量分配不强制要求账号必须拥有角色。
#### Scenario: 创建无角色的平台用户
- **WHEN** 创建平台用户账号后未分配任何角色
- **THEN** 系统允许该状态,账号可正常登录但无权限访问受保护资源
#### Scenario: 清空代理账号的唯一角色
- **WHEN** 代理账号user_type=3拥有一个角色调用分配角色接口传递空数组
- **THEN** 系统清空该代理账号的角色,账号变为无角色状态
#### Scenario: 增量分配角色
- **WHEN** 账号已有角色A调用分配角色接口传递 `role_ids: [B, C]`
- **THEN** 系统跳过已存在的关联只新增角色B和C如果尚未分配
#### Scenario: 角色分配验证规则调整
- **WHEN** 前端调用角色分配接口
- **THEN** `role_ids` 字段验证规则为 `omitempty`(可选),允许传递 null、空数组或角色ID列表

View File

@@ -0,0 +1,125 @@
# user-organization Spec Delta
## ADDED Requirements
### Requirement: 平台账号列表查询
系统 SHALL 提供专门的平台账号列表查询接口自动筛选平台用户user_type=2和超级管理员user_type=1支持按用户名、手机号、状态筛选并返回分页结果。
#### Scenario: 查询平台账号列表
- **WHEN** 调用平台账号列表接口 `GET /api/admin/platform-accounts`
- **THEN** 系统自动筛选 `user_type IN (1, 2)` 的账号并返回列表
#### Scenario: 按用户名筛选
- **WHEN** 调用平台账号列表接口并传递 `username=admin`
- **THEN** 系统返回用户名包含 "admin" 的平台账号(模糊查询)
#### Scenario: 按手机号筛选
- **WHEN** 调用平台账号列表接口并传递 `phone=138`
- **THEN** 系统返回手机号包含 "138" 的平台账号(模糊查询)
#### Scenario: 按状态筛选
- **WHEN** 调用平台账号列表接口并传递 `status=1`
- **THEN** 系统返回状态为启用status=1的平台账号
#### Scenario: 分页查询
- **WHEN** 调用平台账号列表接口并传递 `page=2&page_size=10`
- **THEN** 系统返回第2页数据每页10条记录同时返回总记录数
#### Scenario: 超级管理员包含在列表中
- **WHEN** 调用平台账号列表接口
- **THEN** 返回结果包含所有超级管理员账号user_type=1
#### Scenario: 列表返回字段
- **WHEN** 平台账号列表接口返回数据
- **THEN** 每条记录包含id, username, phone, user_type, status, created_at, updated_at
---
### Requirement: 平台账号密码修改
系统 SHALL 提供专门的密码修改接口,允许管理员重置平台账号密码,无需验证旧密码。新密码必须经过 bcrypt 哈希后存储,并自动设置 updater 字段。
#### Scenario: 修改平台账号密码
- **WHEN** 调用密码修改接口 `PUT /api/admin/platform-accounts/:id/password` 并传递 `new_password`
- **THEN** 系统验证账号存在,哈希新密码,更新数据库,设置 updater 字段
#### Scenario: 密码格式验证
- **WHEN** 调用密码修改接口传递的密码长度小于 8 位或大于 32 位
- **THEN** 系统拒绝修改并返回错误 CodeInvalidParam "密码长度必须在 8-32 位之间"
#### Scenario: 账号不存在
- **WHEN** 调用密码修改接口传递的账号ID不存在
- **THEN** 系统返回错误 CodeAccountNotFound "账号不存在"
#### Scenario: 密码哈希
- **WHEN** 密码修改成功
- **THEN** 系统使用 bcrypt.GenerateFromPassword 哈希密码,并将哈希值存储到 password 字段
#### Scenario: 修改超级管理员密码
- **WHEN** 调用密码修改接口修改超级管理员user_type=1的密码
- **THEN** 系统允许修改(超级管理员密码可以被重置)
---
### Requirement: 平台账号状态切换
系统 SHALL 提供专门的状态切换接口,允许启用或禁用平台账号。状态值必须为 0禁用或 1启用操作自动设置 updater 字段。
#### Scenario: 启用平台账号
- **WHEN** 调用状态切换接口 `PUT /api/admin/platform-accounts/:id/status` 并传递 `status=1`
- **THEN** 系统将账号状态设置为启用status=1设置 updater 字段
#### Scenario: 禁用平台账号
- **WHEN** 调用状态切换接口并传递 `status=0`
- **THEN** 系统将账号状态设置为禁用status=0设置 updater 字段
#### Scenario: 无效状态值
- **WHEN** 调用状态切换接口传递的 status 不是 0 或 1
- **THEN** 系统拒绝修改并返回错误 CodeInvalidParam "状态值必须为 0 或 1"
#### Scenario: 账号不存在
- **WHEN** 调用状态切换接口传递的账号ID不存在
- **THEN** 系统返回错误 CodeAccountNotFound "账号不存在"
#### Scenario: 禁用超级管理员
- **WHEN** 调用状态切换接口禁用超级管理员user_type=1
- **THEN** 系统允许禁用(超级管理员可以被禁用)
#### Scenario: 已禁用账号无法登录
- **WHEN** 账号状态为禁用status=0时尝试登录
- **THEN** 认证系统拒绝登录并返回错误 CodeAccountDisabled "账号已被禁用"
---
### Requirement: 平台账号 CRUD 复用
系统 SHALL 为平台账号管理接口复用现有的账号 CRUD 功能,包括新增、查询详情、编辑、删除和角色管理,确保代码复用和功能一致性。
#### Scenario: 新增平台账号
- **WHEN** 调用 `POST /api/admin/platform-accounts` 创建账号
- **THEN** 系统复用现有 AccountHandler.Create 方法,限制 user_type 必须为 1 或 2
#### Scenario: 查询平台账号详情
- **WHEN** 调用 `GET /api/admin/platform-accounts/:id` 查询账号
- **THEN** 系统复用现有 AccountHandler.Get 方法,返回账号完整信息
#### Scenario: 编辑平台账号
- **WHEN** 调用 `PUT /api/admin/platform-accounts/:id` 更新账号
- **THEN** 系统复用现有 AccountHandler.Update 方法,支持部分字段更新
#### Scenario: 删除平台账号
- **WHEN** 调用 `DELETE /api/admin/platform-accounts/:id` 删除账号
- **THEN** 系统复用现有 AccountHandler.Delete 方法,执行软删除
#### Scenario: 分配角色
- **WHEN** 调用 `POST /api/admin/platform-accounts/:id/roles` 分配角色
- **THEN** 系统复用现有 AccountHandler.AssignRoles 方法,支持空数组和超级管理员保护
#### Scenario: 查询账号角色
- **WHEN** 调用 `GET /api/admin/platform-accounts/:id/roles` 查询角色
- **THEN** 系统复用现有 AccountHandler.GetRoles 方法,返回角色列表
#### Scenario: 移除单个角色
- **WHEN** 调用 `DELETE /api/admin/platform-accounts/:id/roles/:role_id` 移除角色
- **THEN** 系统复用现有 AccountHandler.RemoveRole 方法,删除角色关联

View File

@@ -0,0 +1,134 @@
# 实现任务清单
## 1. 准备工作
- [x] 1.1 阅读现有账号管理代码Handler/Service/Store
- [x] 1.2 确认现有 DTO 定义和验证规则
- [x] 1.3 确认现有路由注册模式
## 2. Model 层DTO 定义)
- [x] 2.1 在 `internal/model/account_dto.go` 中新增 `UpdatePasswordRequest`
- 字段:`new_password` (必填8-32位)
- [x] 2.2 在 `internal/model/account_dto.go` 中新增 `UpdateStatusRequest`
- 字段:`status` (必填0 或 1)
- [x] 2.3 在 `internal/model/account_dto.go` 中新增 `PlatformAccountListRequest`
- 复用 `AccountListRequest`,移除 `user_type` 字段
- [x] 2.4 修改 `AssignRolesRequest`
-`role_ids` 验证规则从 `required,min=1` 改为 `omitempty`
- 允许空数组
## 3. Service 层(业务逻辑)
- [x] 3.1 在 `internal/service/account/service.go` 中新增 `UpdatePassword` 方法
- 验证账号存在
- 验证密码格式
- bcrypt 哈希新密码
- 更新数据库
- 设置 Updater 字段
- [x] 3.2 在 `internal/service/account/service.go` 中新增 `UpdateStatus` 方法
- 验证账号存在
- 验证状态值0 或 1
- 更新数据库
- 设置 Updater 字段
- [x] 3.3 在 `internal/service/account/service.go` 中新增 `ListPlatformAccounts` 方法
- 自动筛选 `user_type IN (1, 2)`
- 支持 username, phone, status 筛选
- 支持分页
- 返回账号列表 + 总数
- [x] 3.4 修改 `AssignRoles` 方法
- 支持 `roleIDs` 为空数组(清空所有角色)
- 超级管理员(`user_type=1`)禁止分配角色,返回错误
- 空数组时删除所有现有角色
- 非空数组时保持现有逻辑
## 4. Handler 层HTTP 处理)
- [x] 4.1 在 `internal/handler/admin/account.go` 中新增 `UpdatePassword` 方法
- 解析路径参数 `id`
- 解析请求 body (`UpdatePasswordRequest`)
- 调用 `service.UpdatePassword`
- 返回 `response.Success(c, nil)`
- [x] 4.2 在 `internal/handler/admin/account.go` 中新增 `UpdateStatus` 方法
- 解析路径参数 `id`
- 解析请求 body (`UpdateStatusRequest`)
- 调用 `service.UpdateStatus`
- 返回 `response.Success(c, nil)`
- [x] 4.3 在 `internal/handler/admin/account.go` 中新增 `ListPlatformAccounts` 方法
- 解析查询参数 (`PlatformAccountListRequest`)
- 调用 `service.ListPlatformAccounts`
- 返回 `response.SuccessWithPagination(c, accounts, total, req.Page, req.PageSize)`
## 5. 路由注册
- [x] 5.1 在 `internal/routes/account.go` 中新增 `registerPlatformAccountRoutes` 函数
- 注册 `GET /api/admin/platform-accounts``h.ListPlatformAccounts`
- 注册 `POST /api/admin/platform-accounts``h.Create`(复用现有)
- 注册 `GET /api/admin/platform-accounts/:id``h.Get`(复用现有)
- 注册 `PUT /api/admin/platform-accounts/:id``h.Update`(复用现有)
- 注册 `DELETE /api/admin/platform-accounts/:id``h.Delete`(复用现有)
- 注册 `PUT /api/admin/platform-accounts/:id/password``h.UpdatePassword`
- 注册 `PUT /api/admin/platform-accounts/:id/status``h.UpdateStatus`
- 注册 `POST /api/admin/platform-accounts/:id/roles``h.AssignRoles`(复用现有)
- 注册 `GET /api/admin/platform-accounts/:id/roles``h.GetRoles`(复用现有)
- 注册 `DELETE /api/admin/platform-accounts/:id/roles/:role_id``h.RemoveRole`(复用现有)
- [x] 5.2 在 `internal/routes/router.go` 中调用 `registerPlatformAccountRoutes`
## 6. 错误码定义
- [x] 6.1 检查 `pkg/errors/codes.go` 中是否有所需错误码
- `CodeAccountNotFound` (已有)
- `CodeInvalidPassword` (已有)
- `CodeInvalidParam` (已有)
- `CodeUnauthorized` (已有)
- [x] 6.2 如需新增,添加错误码和消息
## 7. 常量定义
- [x] 7.1 检查 `pkg/constants/constants.go` 中状态常量
- `StatusDisabled = 0` (已有)
- `StatusEnabled = 1` (已有)
- `UserTypeSuperAdmin = 1` (已有)
- `UserTypePlatform = 2` (已有)
## 8. 单元测试
- [x] 8.1 为 `UpdatePassword` 方法编写单元测试
- 测试成功场景
- 测试账号不存在场景
- 测试密码格式错误场景
- [x] 8.2 为 `UpdateStatus` 方法编写单元测试
- 测试启用/禁用场景
- 测试账号不存在场景
- 测试无效状态值场景
- [x] 8.3 为 `ListPlatformAccounts` 方法编写单元测试
- 测试自动筛选 user_type IN (1,2)
- 测试分页功能
- 测试筛选条件username, phone, status
- [x] 8.4 为修改后的 `AssignRoles` 方法编写测试
- 测试空数组清空角色场景
- 测试超级管理员禁止分配角色场景
- 测试平台用户分配角色场景
## 9. 集成测试
- [x] 9.1 编写 API 集成测试(`tests/integration/platform_account_test.go`
- 测试平台账号列表查询
- 测试新增平台账号
- 测试修改密码接口
- 测试启用/禁用接口
- 测试角色分配(含空数组场景)
- [x] 9.2 测试超级管理员保护逻辑
- 超级管理员出现在列表中
- 超级管理员禁止分配角色
## 10. 文档更新
- [x] 10.1 更新 OpenAPI 文档(如果使用 `openapi-generation`
- [x] 10.2 在 `docs/` 目录创建功能总结文档
- 接口列表
- 请求/响应示例
- 错误码说明
- 注意事项(超级管理员保护、角色清空等)
## 11. 验证与清理
- [x] 11.1 运行所有单元测试:`go test ./internal/service/account/...`
- [x] 11.2 运行集成测试:`go test ./tests/integration/...`
- [x] 11.3 使用 `openspec validate add-platform-account-management --strict` 验证提案
- [x] 11.4 代码格式化:`gofmt -w .`
- [x] 11.5 静态检查:`go vet ./...`
## 12. 部署准备
- [x] 12.1 确认无数据库迁移需求
- [x] 12.2 确认向后兼容(现有接口不受影响)
- [x] 12.3 准备发布说明(新增接口列表、使用示例)

View File

@@ -49,7 +49,7 @@ TBD - created by archiving change add-role-permission-system. Update Purpose aft
### Requirement: 角色类型与用户类型匹配 ### Requirement: 角色类型与用户类型匹配
系统 SHALL 在分配角色时校验角色类型与用户类型的匹配关系:平台用户只能分配平台角色,代理/企业账号只能分配客户角色,超级管理员和个人客户不分配角色。 系统 SHALL 在分配角色时校验角色类型与用户类型的匹配关系:平台用户只能分配平台角色,代理/企业账号只能分配客户角色,超级管理员不允许分配角色。分配角色时支持传递空数组以清空账号的所有角色。
#### Scenario: 平台用户分配平台角色 #### Scenario: 平台用户分配平台角色
- **WHEN** 为平台用户user_type=2分配平台角色role_type=1 - **WHEN** 为平台用户user_type=2分配平台角色role_type=1
@@ -71,9 +71,17 @@ TBD - created by archiving change add-role-permission-system. Update Purpose aft
- **WHEN** 为企业账号user_type=4分配客户角色role_type=2 - **WHEN** 为企业账号user_type=4分配客户角色role_type=2
- **THEN** 系统允许分配 - **THEN** 系统允许分配
#### Scenario: 超级管理员分配角色 #### Scenario: 超级管理员禁止分配角色
- **WHEN** 尝试为超级管理员user_type=1分配任何角色 - **WHEN** 尝试为超级管理员user_type=1分配任何角色
- **THEN** 系统拒绝分配并返回错误"超级管理员不需要分配角色" - **THEN** 系统拒绝分配并返回错误 CodeInvalidParam "超级管理员不允许分配角色"
#### Scenario: 清空账号所有角色
- **WHEN** 调用分配角色接口时传递空数组 `role_ids: []`
- **THEN** 系统删除该账号的所有现有角色关联,返回成功
#### Scenario: 传递空数组给超级管理员
- **WHEN** 为超级管理员user_type=1调用分配角色接口且传递空数组
- **THEN** 系统拒绝操作并返回错误"超级管理员不允许分配角色"
--- ---
@@ -221,3 +229,23 @@ TBD - created by archiving change add-role-permission-system. Update Purpose aft
--- ---
### Requirement: 角色分配灵活性
系统 SHALL 支持灵活的角色分配操作允许传递空数组清空所有角色允许传递部分角色ID进行增量分配不强制要求账号必须拥有角色。
#### Scenario: 创建无角色的平台用户
- **WHEN** 创建平台用户账号后未分配任何角色
- **THEN** 系统允许该状态,账号可正常登录但无权限访问受保护资源
#### Scenario: 清空代理账号的唯一角色
- **WHEN** 代理账号user_type=3拥有一个角色调用分配角色接口传递空数组
- **THEN** 系统清空该代理账号的角色,账号变为无角色状态
#### Scenario: 增量分配角色
- **WHEN** 账号已有角色A调用分配角色接口传递 `role_ids: [B, C]`
- **THEN** 系统跳过已存在的关联只新增角色B和C如果尚未分配
#### Scenario: 角色分配验证规则调整
- **WHEN** 前端调用角色分配接口
- **THEN** `role_ids` 字段验证规则为 `omitempty`(可选),允许传递 null、空数组或角色ID列表

View File

@@ -145,3 +145,125 @@ TBD - created by archiving change add-user-organization-model. Update Purpose af
--- ---
### Requirement: 平台账号列表查询
系统 SHALL 提供专门的平台账号列表查询接口自动筛选平台用户user_type=2和超级管理员user_type=1支持按用户名、手机号、状态筛选并返回分页结果。
#### Scenario: 查询平台账号列表
- **WHEN** 调用平台账号列表接口 `GET /api/admin/platform-accounts`
- **THEN** 系统自动筛选 `user_type IN (1, 2)` 的账号并返回列表
#### Scenario: 按用户名筛选
- **WHEN** 调用平台账号列表接口并传递 `username=admin`
- **THEN** 系统返回用户名包含 "admin" 的平台账号(模糊查询)
#### Scenario: 按手机号筛选
- **WHEN** 调用平台账号列表接口并传递 `phone=138`
- **THEN** 系统返回手机号包含 "138" 的平台账号(模糊查询)
#### Scenario: 按状态筛选
- **WHEN** 调用平台账号列表接口并传递 `status=1`
- **THEN** 系统返回状态为启用status=1的平台账号
#### Scenario: 分页查询
- **WHEN** 调用平台账号列表接口并传递 `page=2&page_size=10`
- **THEN** 系统返回第2页数据每页10条记录同时返回总记录数
#### Scenario: 超级管理员包含在列表中
- **WHEN** 调用平台账号列表接口
- **THEN** 返回结果包含所有超级管理员账号user_type=1
#### Scenario: 列表返回字段
- **WHEN** 平台账号列表接口返回数据
- **THEN** 每条记录包含id, username, phone, user_type, status, created_at, updated_at
---
### Requirement: 平台账号密码修改
系统 SHALL 提供专门的密码修改接口,允许管理员重置平台账号密码,无需验证旧密码。新密码必须经过 bcrypt 哈希后存储,并自动设置 updater 字段。
#### Scenario: 修改平台账号密码
- **WHEN** 调用密码修改接口 `PUT /api/admin/platform-accounts/:id/password` 并传递 `new_password`
- **THEN** 系统验证账号存在,哈希新密码,更新数据库,设置 updater 字段
#### Scenario: 密码格式验证
- **WHEN** 调用密码修改接口传递的密码长度小于 8 位或大于 32 位
- **THEN** 系统拒绝修改并返回错误 CodeInvalidParam "密码长度必须在 8-32 位之间"
#### Scenario: 账号不存在
- **WHEN** 调用密码修改接口传递的账号ID不存在
- **THEN** 系统返回错误 CodeAccountNotFound "账号不存在"
#### Scenario: 密码哈希
- **WHEN** 密码修改成功
- **THEN** 系统使用 bcrypt.GenerateFromPassword 哈希密码,并将哈希值存储到 password 字段
#### Scenario: 修改超级管理员密码
- **WHEN** 调用密码修改接口修改超级管理员user_type=1的密码
- **THEN** 系统允许修改(超级管理员密码可以被重置)
---
### Requirement: 平台账号状态切换
系统 SHALL 提供专门的状态切换接口,允许启用或禁用平台账号。状态值必须为 0禁用或 1启用操作自动设置 updater 字段。
#### Scenario: 启用平台账号
- **WHEN** 调用状态切换接口 `PUT /api/admin/platform-accounts/:id/status` 并传递 `status=1`
- **THEN** 系统将账号状态设置为启用status=1设置 updater 字段
#### Scenario: 禁用平台账号
- **WHEN** 调用状态切换接口并传递 `status=0`
- **THEN** 系统将账号状态设置为禁用status=0设置 updater 字段
#### Scenario: 无效状态值
- **WHEN** 调用状态切换接口传递的 status 不是 0 或 1
- **THEN** 系统拒绝修改并返回错误 CodeInvalidParam "状态值必须为 0 或 1"
#### Scenario: 账号不存在
- **WHEN** 调用状态切换接口传递的账号ID不存在
- **THEN** 系统返回错误 CodeAccountNotFound "账号不存在"
#### Scenario: 禁用超级管理员
- **WHEN** 调用状态切换接口禁用超级管理员user_type=1
- **THEN** 系统允许禁用(超级管理员可以被禁用)
#### Scenario: 已禁用账号无法登录
- **WHEN** 账号状态为禁用status=0时尝试登录
- **THEN** 认证系统拒绝登录并返回错误 CodeAccountDisabled "账号已被禁用"
---
### Requirement: 平台账号 CRUD 复用
系统 SHALL 为平台账号管理接口复用现有的账号 CRUD 功能,包括新增、查询详情、编辑、删除和角色管理,确保代码复用和功能一致性。
#### Scenario: 新增平台账号
- **WHEN** 调用 `POST /api/admin/platform-accounts` 创建账号
- **THEN** 系统复用现有 AccountHandler.Create 方法,限制 user_type 必须为 1 或 2
#### Scenario: 查询平台账号详情
- **WHEN** 调用 `GET /api/admin/platform-accounts/:id` 查询账号
- **THEN** 系统复用现有 AccountHandler.Get 方法,返回账号完整信息
#### Scenario: 编辑平台账号
- **WHEN** 调用 `PUT /api/admin/platform-accounts/:id` 更新账号
- **THEN** 系统复用现有 AccountHandler.Update 方法,支持部分字段更新
#### Scenario: 删除平台账号
- **WHEN** 调用 `DELETE /api/admin/platform-accounts/:id` 删除账号
- **THEN** 系统复用现有 AccountHandler.Delete 方法,执行软删除
#### Scenario: 分配角色
- **WHEN** 调用 `POST /api/admin/platform-accounts/:id/roles` 分配角色
- **THEN** 系统复用现有 AccountHandler.AssignRoles 方法,支持空数组和超级管理员保护
#### Scenario: 查询账号角色
- **WHEN** 调用 `GET /api/admin/platform-accounts/:id/roles` 查询角色
- **THEN** 系统复用现有 AccountHandler.GetRoles 方法,返回角色列表
#### Scenario: 移除单个角色
- **WHEN** 调用 `DELETE /api/admin/platform-accounts/:id/roles/:role_id` 移除角色
- **THEN** 系统复用现有 AccountHandler.RemoveRole 方法,删除角色关联

View File

@@ -34,7 +34,7 @@ func NewRedisClient(cfg RedisConfig, logger *zap.Logger) (*redis.Client, error)
WriteTimeout: cfg.WriteTimeout, WriteTimeout: cfg.WriteTimeout,
MaxRetries: 3, MaxRetries: 3,
PoolTimeout: 4 * time.Second, PoolTimeout: 4 * time.Second,
DisableIndentity: true, DisableIdentity: true,
}) })
// 测试连接 // 测试连接

View File

@@ -0,0 +1,359 @@
package integration
import (
"bytes"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/routes"
accountService "github.com/break/junhong_cmp_fiber/internal/service/account"
postgresStore "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"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/pkg/response"
"github.com/break/junhong_cmp_fiber/tests/testutils"
)
func TestPlatformAccountAPI_ListPlatformAccounts(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgresStore.NewAccountStore(db, redisClient)
roleStore := postgresStore.NewRoleStore(db)
accountRoleStore := postgresStore.NewAccountRoleStore(db)
accService := accountService.New(accountStore, roleStore, accountRoleStore)
accountHandler := admin.NewAccountHandler(accService)
app := fiber.New(fiber.Config{
ErrorHandler: errors.SafeErrorHandler(nil),
})
testUserID := uint(1)
app.Use(func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(testUserID, constants.UserTypeSuperAdmin, 0))
c.SetUserContext(ctx)
return c.Next()
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
routes.RegisterRoutes(app, services, middlewares)
superAdmin := &model.Account{
Username: testutils.GenerateUsername("super_admin", 1),
Phone: testutils.GeneratePhone("138", 1),
Password: "hashedpassword",
UserType: constants.UserTypeSuperAdmin,
Status: constants.StatusEnabled,
}
db.Create(superAdmin)
platformUser := &model.Account{
Username: testutils.GenerateUsername("platform_user", 2),
Phone: testutils.GeneratePhone("138", 2),
Password: "hashedpassword",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
db.Create(platformUser)
agentUser := &model.Account{
Username: testutils.GenerateUsername("agent_user", 3),
Phone: testutils.GeneratePhone("138", 3),
Password: "hashedpassword",
UserType: constants.UserTypeAgent,
Status: constants.StatusEnabled,
}
db.Create(agentUser)
t.Run("列表只返回平台账号和超级管理员", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/platform-accounts?page=1&page_size=20", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
items := data["items"].([]interface{})
assert.GreaterOrEqual(t, len(items), 2)
var count int64
db.Model(&model.Account{}).Where("user_type IN ?", []int{1, 2}).Count(&count)
assert.GreaterOrEqual(t, count, int64(2))
})
t.Run("按用户名筛选", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/admin/platform-accounts?username=platform_user", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
data := result.Data.(map[string]interface{})
items := data["items"].([]interface{})
assert.GreaterOrEqual(t, len(items), 1)
})
}
func TestPlatformAccountAPI_UpdatePassword(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgresStore.NewAccountStore(db, redisClient)
roleStore := postgresStore.NewRoleStore(db)
accountRoleStore := postgresStore.NewAccountRoleStore(db)
accService := accountService.New(accountStore, roleStore, accountRoleStore)
accountHandler := admin.NewAccountHandler(accService)
app := fiber.New(fiber.Config{
ErrorHandler: errors.SafeErrorHandler(nil),
})
testUserID := uint(1)
app.Use(func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(testUserID, constants.UserTypeSuperAdmin, 0))
c.SetUserContext(ctx)
return c.Next()
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
routes.RegisterRoutes(app, services, middlewares)
testAccount := &model.Account{
Username: testutils.GenerateUsername("pwd_test", 10),
Phone: testutils.GeneratePhone("139", 10),
Password: "old_hashed_password",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
db.Create(testAccount)
t.Run("成功修改密码", func(t *testing.T) {
reqBody := model.UpdatePasswordRequest{
NewPassword: "NewPassword@123",
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/admin/platform-accounts/%d/password", testAccount.ID), bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
var updated model.Account
db.First(&updated, testAccount.ID)
assert.NotEqual(t, "old_hashed_password", updated.Password)
})
t.Run("账号不存在返回错误", func(t *testing.T) {
reqBody := model.UpdatePasswordRequest{
NewPassword: "NewPassword@123",
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/admin/platform-accounts/99999/password", bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, errors.CodeAccountNotFound, result.Code)
})
}
func TestPlatformAccountAPI_UpdateStatus(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgresStore.NewAccountStore(db, redisClient)
roleStore := postgresStore.NewRoleStore(db)
accountRoleStore := postgresStore.NewAccountRoleStore(db)
accService := accountService.New(accountStore, roleStore, accountRoleStore)
accountHandler := admin.NewAccountHandler(accService)
app := fiber.New(fiber.Config{
ErrorHandler: errors.SafeErrorHandler(nil),
})
testUserID := uint(1)
app.Use(func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(testUserID, constants.UserTypeSuperAdmin, 0))
c.SetUserContext(ctx)
return c.Next()
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
routes.RegisterRoutes(app, services, middlewares)
testAccount := &model.Account{
Username: testutils.GenerateUsername("status_test", 20),
Phone: testutils.GeneratePhone("137", 20),
Password: "hashedpassword",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
db.Create(testAccount)
t.Run("成功禁用账号", func(t *testing.T) {
reqBody := model.UpdateStatusRequest{
Status: constants.StatusDisabled,
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/admin/platform-accounts/%d/status", testAccount.ID), bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
var updated model.Account
db.First(&updated, testAccount.ID)
assert.Equal(t, constants.StatusDisabled, updated.Status)
})
t.Run("成功启用账号", func(t *testing.T) {
reqBody := model.UpdateStatusRequest{
Status: constants.StatusEnabled,
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/admin/platform-accounts/%d/status", testAccount.ID), bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
var updated model.Account
db.First(&updated, testAccount.ID)
assert.Equal(t, constants.StatusEnabled, updated.Status)
})
}
func TestPlatformAccountAPI_AssignRoles(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgresStore.NewAccountStore(db, redisClient)
roleStore := postgresStore.NewRoleStore(db)
accountRoleStore := postgresStore.NewAccountRoleStore(db)
accService := accountService.New(accountStore, roleStore, accountRoleStore)
accountHandler := admin.NewAccountHandler(accService)
app := fiber.New(fiber.Config{
ErrorHandler: errors.SafeErrorHandler(nil),
})
testUserID := uint(1)
app.Use(func(c *fiber.Ctx) error {
ctx := middleware.SetUserContext(c.UserContext(), middleware.NewSimpleUserContext(testUserID, constants.UserTypeSuperAdmin, 0))
c.SetUserContext(ctx)
return c.Next()
})
services := &bootstrap.Handlers{Account: accountHandler}
middlewares := &bootstrap.Middlewares{}
routes.RegisterRoutes(app, services, middlewares)
superAdmin := &model.Account{
Username: testutils.GenerateUsername("super_admin_role", 30),
Phone: testutils.GeneratePhone("136", 30),
Password: "hashedpassword",
UserType: constants.UserTypeSuperAdmin,
Status: constants.StatusEnabled,
}
db.Create(superAdmin)
platformUser := &model.Account{
Username: testutils.GenerateUsername("platform_user_role", 31),
Phone: testutils.GeneratePhone("136", 31),
Password: "hashedpassword",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
}
db.Create(platformUser)
testRole := &model.Role{
RoleName: testutils.GenerateUsername("测试角色", 30),
RoleType: constants.RoleTypePlatform,
Status: constants.StatusEnabled,
}
db.Create(testRole)
t.Run("超级管理员禁止分配角色", func(t *testing.T) {
reqBody := model.AssignRolesRequest{
RoleIDs: []uint{testRole.ID},
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", fmt.Sprintf("/api/admin/platform-accounts/%d/roles", superAdmin.ID), bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, errors.CodeInvalidParam, result.Code)
assert.Contains(t, result.Message, "超级管理员不允许分配角色")
})
t.Run("平台用户成功分配角色", func(t *testing.T) {
reqBody := model.AssignRolesRequest{
RoleIDs: []uint{testRole.ID},
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", fmt.Sprintf("/api/admin/platform-accounts/%d/roles", platformUser.ID), bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
var count int64
db.Model(&model.AccountRole{}).Where("account_id = ? AND role_id = ?", platformUser.ID, testRole.ID).Count(&count)
assert.Equal(t, int64(1), count)
})
t.Run("空数组清空所有角色", func(t *testing.T) {
reqBody := model.AssignRolesRequest{
RoleIDs: []uint{},
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", fmt.Sprintf("/api/admin/platform-accounts/%d/roles", platformUser.ID), bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
var count int64
db.Model(&model.AccountRole{}).Where("account_id = ?", platformUser.ID).Count(&count)
assert.Equal(t, int64(0), count)
})
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
) )
// SetupTestDB 设置测试数据库和 Redis // SetupTestDB 设置测试数据库和 Redis(使用事务)
func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) { func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) {
t.Helper() t.Helper()
@@ -42,11 +42,15 @@ func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) {
t.Fatalf("数据库迁移失败: %v", err) t.Fatalf("数据库迁移失败: %v", err)
} }
// 连接测试 Redis使用远程 Redis txDB := db.Begin()
if txDB.Error != nil {
t.Fatalf("开启事务失败: %v", txDB.Error)
}
redisClient := redis.NewClient(&redis.Options{ redisClient := redis.NewClient(&redis.Options{
Addr: "cxd.whcxd.cn:16299", Addr: "cxd.whcxd.cn:16299",
Password: "cpNbWtAaqgo1YJmbMp3h", Password: "cpNbWtAaqgo1YJmbMp3h",
DB: 15, // 使用测试数据库 DB: 15,
}) })
ctx := context.Background() ctx := context.Background()
@@ -54,35 +58,28 @@ func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) {
t.Skipf("跳过测试:无法连接 Redis: %v", err) t.Skipf("跳过测试:无法连接 Redis: %v", err)
} }
// 清空 Redis 测试数据库 testPrefix := fmt.Sprintf("test:%s:", t.Name())
redisClient.FlushDB(ctx) keys, _ := redisClient.Keys(ctx, testPrefix+"*").Result()
if len(keys) > 0 {
return db, redisClient redisClient.Del(ctx, keys...)
} }
// TeardownTestDB 清理测试数据库 return txDB, redisClient
}
// TeardownTestDB 清理测试数据库(回滚事务)
func TeardownTestDB(t *testing.T, db *gorm.DB, redisClient *redis.Client) { func TeardownTestDB(t *testing.T, db *gorm.DB, redisClient *redis.Client) {
t.Helper() t.Helper()
// 清空测试数据
ctx := context.Background() ctx := context.Background()
db.Exec("TRUNCATE TABLE tb_account_role CASCADE") testPrefix := fmt.Sprintf("test:%s:", t.Name())
db.Exec("TRUNCATE TABLE tb_role_permission CASCADE") keys, _ := redisClient.Keys(ctx, testPrefix+"*").Result()
db.Exec("TRUNCATE TABLE tb_account CASCADE") if len(keys) > 0 {
db.Exec("TRUNCATE TABLE tb_role CASCADE") redisClient.Del(ctx, keys...)
db.Exec("TRUNCATE TABLE tb_permission CASCADE")
db.Exec("TRUNCATE TABLE tb_shop CASCADE")
db.Exec("TRUNCATE TABLE tb_enterprise CASCADE")
db.Exec("TRUNCATE TABLE tb_personal_customer CASCADE")
// 清空 Redis
redisClient.FlushDB(ctx)
// 关闭连接
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
} }
db.Rollback()
_ = redisClient.Close() _ = redisClient.Close()
} }