feat(account): 实现平台账号管理功能
- 新增平台账号列表查询接口(自动筛选超级管理员和平台用户) - 新增密码修改和状态切换专用接口 - 增强角色分配功能,支持空数组清空所有角色 - 新增超级管理员保护机制,禁止分配角色 - 新增完整的集成测试和OpenSpec规范文档
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:"请求体"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 问题反馈
|
||||||
|
|
||||||
|
如有任何问题或需要调整,请告知:
|
||||||
|
- 业务需求是否准确?
|
||||||
|
- 接口设计是否合理?
|
||||||
|
- 角色分配逻辑是否符合预期?
|
||||||
|
- 是否需要额外功能?
|
||||||
@@ -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. ✅ **已确认**:是否允许清空所有角色? → **是**(灵活分配)
|
||||||
@@ -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列表
|
||||||
@@ -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 方法,删除角色关联
|
||||||
@@ -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 准备发布说明(新增接口列表、使用示例)
|
||||||
@@ -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列表
|
||||||
|
|
||||||
|
|||||||
@@ -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 方法,删除角色关联
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 测试连接
|
// 测试连接
|
||||||
|
|||||||
359
tests/integration/platform_account_test.go
Normal file
359
tests/integration/platform_account_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
redisClient.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
|
||||||
return db, redisClient
|
return txDB, redisClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// TeardownTestDB 清理测试数据库
|
// 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user