From b1195c16df618cfe144d39af4e5af6b45b30f2fa Mon Sep 17 00:00:00 2001 From: huang Date: Wed, 14 Jan 2026 17:00:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(account):=20=E5=AE=9E=E7=8E=B0=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增平台账号列表查询接口(自动筛选超级管理员和平台用户) - 新增密码修改和状态切换专用接口 - 增强角色分配功能,支持空数组清空所有角色 - 新增超级管理员保护机制,禁止分配角色 - 新增完整的集成测试和OpenSpec规范文档 --- internal/handler/admin/account.go | 56 +++ internal/model/account_dto.go | 39 +- internal/routes/account.go | 77 ++++ internal/service/account/service.go | 104 ++++- internal/store/postgres/account_store.go | 68 +++ .../README.md | 388 ++++++++++++++++++ .../proposal.md | 128 ++++++ .../specs/role-permission/spec.md | 63 +++ .../specs/user-organization/spec.md | 125 ++++++ .../tasks.md | 134 ++++++ openspec/specs/role-permission/spec.md | 34 +- openspec/specs/user-organization/spec.md | 122 ++++++ pkg/database/redis.go | 22 +- tests/integration/platform_account_test.go | 359 ++++++++++++++++ tests/testutils/setup.go | 45 +- 15 files changed, 1713 insertions(+), 51 deletions(-) create mode 100644 openspec/changes/archive/2026-01-14-add-platform-account-management/README.md create mode 100644 openspec/changes/archive/2026-01-14-add-platform-account-management/proposal.md create mode 100644 openspec/changes/archive/2026-01-14-add-platform-account-management/specs/role-permission/spec.md create mode 100644 openspec/changes/archive/2026-01-14-add-platform-account-management/specs/user-organization/spec.md create mode 100644 openspec/changes/archive/2026-01-14-add-platform-account-management/tasks.md create mode 100644 tests/integration/platform_account_test.go diff --git a/internal/handler/admin/account.go b/internal/handler/admin/account.go index e823220..3dff5ad 100644 --- a/internal/handler/admin/account.go +++ b/internal/handler/admin/account.go @@ -164,3 +164,59 @@ func (h *AccountHandler) RemoveRole(c *fiber.Ctx) error { 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) +} diff --git a/internal/model/account_dto.go b/internal/model/account_dto.go index b221b3a..5519a48 100644 --- a/internal/model/account_dto.go +++ b/internal/model/account_dto.go @@ -44,8 +44,9 @@ type AccountResponse struct { } // AssignRolesRequest 分配角色请求 +// 支持传递空数组以清空账号的所有角色 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 账号分页响应 @@ -55,3 +56,39 @@ type AccountPageResult struct { Page int `json:"page" 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:"请求体"` +} diff --git a/internal/routes/account.go b/internal/routes/account.go index 9a72264..f456af5 100644 --- a/internal/routes/account.go +++ b/internal/routes/account.go @@ -70,4 +70,81 @@ func registerAccountRoutes(api fiber.Router, h *admin.AccountHandler, doc *opena Input: new(model.RemoveRoleParams), 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, + }) } diff --git a/internal/service/account/service.go b/internal/service/account/service.go index 6ac8cb5..87831d7 100644 --- a/internal/service/account/service.go +++ b/internal/service/account/service.go @@ -210,15 +210,13 @@ func (s *Service) List(ctx context.Context, req *model.AccountListRequest) ([]*m return s.accountStore.List(ctx, opts, filters) } -// AssignRoles 为账号分配角色 +// AssignRoles 为账号分配角色(支持空数组清空所有角色,超级管理员禁止分配) func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uint) ([]*model.AccountRole, error) { - // 获取当前用户 ID currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") } - // 检查账号存在 account, err := s.accountStore.GetByID(ctx, accountID) if err != nil { 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) } - // 检查用户类型是否允许分配角色 + // 超级管理员禁止分配角色 + 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) if maxRoles == 0 { return nil, errors.New(errors.CodeInvalidParam, "该用户类型不需要分配角色") } - // 检查角色数量限制 existingCount, err := s.accountRoleStore.CountByAccountID(ctx, accountID) if err != nil { return nil, fmt.Errorf("统计现有角色数量失败: %w", err) } - // 计算将要分配的新角色数量(排除已存在的) newRoleCount := 0 for _, roleID := range roleIDs { 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 { return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("该用户类型最多只能分配 %d 个角色", maxRoles)) } - // 验证所有角色存在并检查角色类型是否匹配 for _, roleID := range roleIDs { role, err := s.roleStore.GetByID(ctx, roleID) if err != nil { @@ -263,19 +269,16 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin return nil, fmt.Errorf("获取角色失败: %w", err) } - // 检查角色类型与用户类型是否匹配 if !constants.IsRoleTypeMatchUserType(role.RoleType, account.UserType) { return nil, errors.New(errors.CodeInvalidParam, "角色类型与账号类型不匹配") } } - // 创建关联 var ars []*model.AccountRole for _, roleID := range roleIDs { - // 检查是否已分配 exists, _ := s.accountRoleStore.Exists(ctx, accountID, roleID) if exists { - continue // 跳过已存在的关联 + continue } ar := &model.AccountRole{ @@ -344,6 +347,83 @@ func (s *Service) ValidatePassword(plainPassword, hashedPassword string) bool { 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 系统内部创建账号方法,用于系统初始化场景(绕过当前用户检查) func (s *Service) CreateSystemAccount(ctx context.Context, account *model.Account) error { if account.Username == "" { diff --git a/internal/store/postgres/account_store.go b/internal/store/postgres/account_store.go index 4cad0ee..aca568e 100644 --- a/internal/store/postgres/account_store.go +++ b/internal/store/postgres/account_store.go @@ -129,3 +129,71 @@ func (s *AccountStore) List(ctx context.Context, opts *store.QueryOptions, filte 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 +} diff --git a/openspec/changes/archive/2026-01-14-add-platform-account-management/README.md b/openspec/changes/archive/2026-01-14-add-platform-account-management/README.md new file mode 100644 index 0000000..d7e0930 --- /dev/null +++ b/openspec/changes/archive/2026-01-14-add-platform-account-management/README.md @@ -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 +``` + +--- + +## 📞 问题反馈 + +如有任何问题或需要调整,请告知: +- 业务需求是否准确? +- 接口设计是否合理? +- 角色分配逻辑是否符合预期? +- 是否需要额外功能? diff --git a/openspec/changes/archive/2026-01-14-add-platform-account-management/proposal.md b/openspec/changes/archive/2026-01-14-add-platform-account-management/proposal.md new file mode 100644 index 0000000..dee1133 --- /dev/null +++ b/openspec/changes/archive/2026-01-14-add-platform-account-management/proposal.md @@ -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. ✅ **已确认**:是否允许清空所有角色? → **是**(灵活分配) diff --git a/openspec/changes/archive/2026-01-14-add-platform-account-management/specs/role-permission/spec.md b/openspec/changes/archive/2026-01-14-add-platform-account-management/specs/role-permission/spec.md new file mode 100644 index 0000000..0bcd376 --- /dev/null +++ b/openspec/changes/archive/2026-01-14-add-platform-account-management/specs/role-permission/spec.md @@ -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列表 diff --git a/openspec/changes/archive/2026-01-14-add-platform-account-management/specs/user-organization/spec.md b/openspec/changes/archive/2026-01-14-add-platform-account-management/specs/user-organization/spec.md new file mode 100644 index 0000000..d1b48af --- /dev/null +++ b/openspec/changes/archive/2026-01-14-add-platform-account-management/specs/user-organization/spec.md @@ -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 方法,删除角色关联 diff --git a/openspec/changes/archive/2026-01-14-add-platform-account-management/tasks.md b/openspec/changes/archive/2026-01-14-add-platform-account-management/tasks.md new file mode 100644 index 0000000..78d0e18 --- /dev/null +++ b/openspec/changes/archive/2026-01-14-add-platform-account-management/tasks.md @@ -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 准备发布说明(新增接口列表、使用示例) diff --git a/openspec/specs/role-permission/spec.md b/openspec/specs/role-permission/spec.md index 85ae618..9d571aa 100644 --- a/openspec/specs/role-permission/spec.md +++ b/openspec/specs/role-permission/spec.md @@ -49,7 +49,7 @@ TBD - created by archiving change add-role-permission-system. Update Purpose aft ### Requirement: 角色类型与用户类型匹配 -系统 SHALL 在分配角色时校验角色类型与用户类型的匹配关系:平台用户只能分配平台角色,代理/企业账号只能分配客户角色,超级管理员和个人客户不分配角色。 +系统 SHALL 在分配角色时校验角色类型与用户类型的匹配关系:平台用户只能分配平台角色,代理/企业账号只能分配客户角色,超级管理员不允许分配角色。分配角色时支持传递空数组以清空账号的所有角色。 #### Scenario: 平台用户分配平台角色 - **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) - **THEN** 系统允许分配 -#### Scenario: 超级管理员分配角色 +#### Scenario: 超级管理员禁止分配角色 - **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列表 + diff --git a/openspec/specs/user-organization/spec.md b/openspec/specs/user-organization/spec.md index 6583a39..cfbcae6 100644 --- a/openspec/specs/user-organization/spec.md +++ b/openspec/specs/user-organization/spec.md @@ -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 方法,删除角色关联 + diff --git a/pkg/database/redis.go b/pkg/database/redis.go index f14ceb3..dcfdfbf 100644 --- a/pkg/database/redis.go +++ b/pkg/database/redis.go @@ -24,17 +24,17 @@ type RedisConfig struct { // NewRedisClient 创建新的 Redis 客户端 func NewRedisClient(cfg RedisConfig, logger *zap.Logger) (*redis.Client, error) { client := redis.NewClient(&redis.Options{ - Addr: cfg.Address, - Password: cfg.Password, - DB: cfg.DB, - PoolSize: cfg.PoolSize, - MinIdleConns: cfg.MinIdleConns, - DialTimeout: cfg.DialTimeout, - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - MaxRetries: 3, - PoolTimeout: 4 * time.Second, - DisableIndentity: true, + Addr: cfg.Address, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConns, + DialTimeout: cfg.DialTimeout, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + MaxRetries: 3, + PoolTimeout: 4 * time.Second, + DisableIdentity: true, }) // 测试连接 diff --git a/tests/integration/platform_account_test.go b/tests/integration/platform_account_test.go new file mode 100644 index 0000000..0562bb3 --- /dev/null +++ b/tests/integration/platform_account_test.go @@ -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) + }) +} diff --git a/tests/testutils/setup.go b/tests/testutils/setup.go index a54d3a1..f40c277 100644 --- a/tests/testutils/setup.go +++ b/tests/testutils/setup.go @@ -14,7 +14,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" ) -// SetupTestDB 设置测试数据库和 Redis +// SetupTestDB 设置测试数据库和 Redis(使用事务) func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) { t.Helper() @@ -42,11 +42,15 @@ func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) { t.Fatalf("数据库迁移失败: %v", err) } - // 连接测试 Redis(使用远程 Redis) + txDB := db.Begin() + if txDB.Error != nil { + t.Fatalf("开启事务失败: %v", txDB.Error) + } + redisClient := redis.NewClient(&redis.Options{ Addr: "cxd.whcxd.cn:16299", Password: "cpNbWtAaqgo1YJmbMp3h", - DB: 15, // 使用测试数据库 + DB: 15, }) ctx := context.Background() @@ -54,35 +58,28 @@ func SetupTestDB(t *testing.T) (*gorm.DB, *redis.Client) { t.Skipf("跳过测试:无法连接 Redis: %v", err) } - // 清空 Redis 测试数据库 - redisClient.FlushDB(ctx) + testPrefix := fmt.Sprintf("test:%s:", t.Name()) + keys, _ := redisClient.Keys(ctx, testPrefix+"*").Result() + if len(keys) > 0 { + redisClient.Del(ctx, keys...) + } - return db, redisClient + return txDB, redisClient } -// TeardownTestDB 清理测试数据库 +// TeardownTestDB 清理测试数据库(回滚事务) func TeardownTestDB(t *testing.T, db *gorm.DB, redisClient *redis.Client) { t.Helper() - // 清空测试数据 ctx := context.Background() - db.Exec("TRUNCATE TABLE tb_account_role CASCADE") - db.Exec("TRUNCATE TABLE tb_role_permission CASCADE") - db.Exec("TRUNCATE TABLE tb_account CASCADE") - db.Exec("TRUNCATE TABLE tb_role CASCADE") - 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() + testPrefix := fmt.Sprintf("test:%s:", t.Name()) + keys, _ := redisClient.Keys(ctx, testPrefix+"*").Result() + if len(keys) > 0 { + redisClient.Del(ctx, keys...) } + + db.Rollback() + _ = redisClient.Close() }