diff --git a/.codebuddy/docs/admin-auth.md b/.codebuddy/docs/admin-auth.md new file mode 100644 index 0000000..f7a3207 --- /dev/null +++ b/.codebuddy/docs/admin-auth.md @@ -0,0 +1,53 @@ +# 后台管理 · 认证模块 + +> 本文件描述 `/api/admin` 下的**认证相关**接口(登录、当前管理员信息)。公共约定(请求头、统一响应、鉴权中间件、错误码、配置)见 [admin-api.md](./admin-api.md)。 + +## 1. 接口列表 + +### 1.1 `POST /api/admin/login` + +后台管理员登录,签发 JWT。无需 `Authorization` 头。 + +**请求体** + +```json +{ + "username": "admin", + "password": "admin123" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `username` | string | 是 | 管理员账号(当前示例固定 `admin`)。 | +| `password` | string | 是 | 密码(当前示例固定 `admin123`;生产应接 DB + 密码哈希)。 | + +**响应** + +```json +{ "code": 0, "msg": "ok", "data": { "token": "" } } +``` + +**curl 示例** + +```bash +curl -X POST http://localhost:8080/api/admin/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' +``` + +### 1.2 `GET /api/admin/info` + +受保护接口示例,需有效 JWT,返回当前管理员 ID。 + +**请求头**:`Authorization: Bearer ` + +**响应** + +```json +{ "code": 0, "msg": "ok", "data": { "admin_id": "1" } } +``` + +## 2. 备注 + +- **后台账号**:`POST /api/admin/login` 当前为固定示例账号,生产环境需接入数据库与密码哈希校验。 diff --git a/.codebuddy/docs/admin-miniapp.md b/.codebuddy/docs/admin-miniapp.md new file mode 100644 index 0000000..32df8b5 --- /dev/null +++ b/.codebuddy/docs/admin-miniapp.md @@ -0,0 +1,135 @@ +# 后台管理 · 小程序管理模块 + +> 本文件描述 `/api/admin` 下的**被纳管小程序**相关接口(增删改查)。公共约定(请求头、统一响应、鉴权中间件、错误码、配置)见 [admin-api.md](./admin-api.md)。 + +## 1. 接口列表 + +所有接口均需在请求头携带 `Authorization: Bearer `(由 `AdminAuth` 校验)。 + +### 1.1 `POST /api/admin/miniapps` + +新建被纳管小程序。 + +**请求体** + +```json +{ + "name": "示例小程序", + "description": "用于演示", + "app_id": "wx1234567890", + "app_secret": "secret-xxxx", + "status": 1, + "ext": "{\"k\":\"v\"}" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `name` | string | 是 | 小程序名称。 | +| `description` | string | 否 | 描述。 | +| `app_id` | string | 是 | 微信 AppID,全局唯一。 | +| `app_secret` | string | 是 | 微信 AppSecret(**不会在响应中返回**)。 | +| `status` | int8 | 否 | 状态:`0` 停用,`1` 启用。缺省为 `1`。 | +| `ext` | string | 否 | 扩展字段,必须为合法 JSON 字符串(允许空串)。 | + +**响应**:返回创建后的 `MiniProgram` 对象(见 §2.1),`app_secret` 字段不返回。 + +### 1.2 `PUT /api/admin/miniapps/:id` + +按 ID 局部更新小程序。 + +**路径参数** + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `id` | uint64 | 小程序主键。 | + +**请求体**(所有字段均为可选,仅在提供时更新;`ext` 若提供需为合法 JSON) + +```json +{ + "name": "新名称", + "description": "新描述", + "app_secret": "new-secret", + "status": 0, + "ext": "{\"theme\":\"dark\"}" +} +``` + +> 字段以指针区分「未提供」与「置空」;若全部为空则不更新,直接返回原记录。 + +**响应**:返回更新后的 `MiniProgram` 对象。 + +### 1.3 `GET /api/admin/miniapps/:id` + +查询单个小程序。 + +**路径参数**:`id`(uint64)。 + +**响应**:返回 `MiniProgram` 对象;不存在返回 `40400`。 + +### 1.4 `GET /api/admin/miniapps` + +分页列表查询小程序。 + +**查询参数** + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `status` | int8 | `-1`(全部) | 按状态筛选:`0` 停用,`1` 启用,`-1` 全部。 | +| `page` | int | `1` | 页码,从 1 开始。 | +| `size` | int | `20` | 每页条数。 | + +**响应** + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "list": [ { "id": 1, "name": "示例小程序", "app_id": "wx123...", "status": 1 } ], + "total": 1, + "page": 1, + "size": 20 + } +} +``` + +**curl 示例** + +```bash +curl "http://localhost:8080/api/admin/miniapps?status=1&page=1&size=20" \ + -H "Authorization: Bearer " +``` + +### 1.5 `DELETE /api/admin/miniapps/:id` + +软删除小程序(置 `deleted_at`)。 + +**路径参数**:`id`(uint64)。 + +**响应** + +```json +{ "code": 0, "msg": "ok" } +``` + +## 2. 数据模型 + +### 2.1 MiniProgram(被纳管小程序) + +| 字段 | 类型 | JSON | 说明 | +| --- | --- | --- | --- | +| `ID` | uint64 | `id` | 主键,自增。 | +| `Name` | string | `name` | 名称。 | +| `Description` | string | `description` | 描述。 | +| `AppID` | string | `app_id` | 微信 AppID,全局唯一。 | +| `AppSecret` | string | `app_secret` | **不返回**(json `-`)。 | +| `Status` | int8 | `status` | `0` 停用,`1` 启用。 | +| `Ext` | string | `ext` | 扩展 JSON 文本。 | +| `CreatedAt` | time | `created_at` | 创建时间。 | +| `UpdatedAt` | time | `updated_at` | 更新时间。 | + +## 3. 备注 + +- **小程序管理**:本模块下的 `miniapps` 接口用于纳管微信小程序配置(增删改查),是小程序端 `X-App-ID` 鉴权的数据来源。 diff --git a/.codebuddy/docs/admin-user.md b/.codebuddy/docs/admin-user.md new file mode 100644 index 0000000..d0e9701 --- /dev/null +++ b/.codebuddy/docs/admin-user.md @@ -0,0 +1,133 @@ +# 后台管理 · 用户管理模块 + +> 本文件描述 `/api/admin` 下的**小程序端用户**相关接口(列表、详情、编辑、禁用/启用)。公共约定(请求头、统一响应、鉴权中间件、错误码、配置)见 [admin-api.md](./admin-api.md)。 + +## 1. 接口列表 + +所有接口均需在请求头携带 `Authorization: Bearer `(由 `AdminAuth` 校验)。 + +### 1.1 `GET /api/admin/users` + +分页列表查询小程序端用户,支持按小程序与关键字(昵称 / OpenID)过滤。 + +**查询参数** + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `mini_id` | uint64 | `0`(全部) | 按所属小程序筛选;`0` 表示不限小程序。 | +| `keyword` | string | 空 | 模糊匹配 `nickname` 或 `openid`(前后自动加 `%`)。 | +| `page` | int | `1` | 页码,从 1 开始。 | +| `size` | int | `20` | 每页条数。 | + +**响应** + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "list": [ + { + "id": 1, + "mini_id": 10, + "openid": "oABC...", + "union_id": "", + "nickname": "张三", + "avatar": "https://...", + "status": 1, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "size": 20 + } +} +``` + +**curl 示例** + +```bash +curl "http://localhost:8080/api/admin/users?mini_id=10&keyword=张三&page=1&size=20" \ + -H "Authorization: Bearer " +``` + +### 1.2 `GET /api/admin/users/:id` + +查询单个用户详情(后台视角,不限小程序)。 + +**路径参数** + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `id` | uint64 | 用户主键。 | + +**响应**:返回 `User` 对象(见 §2.1);不存在返回 `40400`。 + +### 1.3 `PUT /api/admin/users/:id` + +编辑用户资料(昵称 / 头像 / 状态)。字段均为可选,仅在提供时更新。 + +**路径参数**:`id`(uint64)。 + +**请求体** + +```json +{ + "nickname": "新昵称", + "avatar": "https://new-avatar.png", + "status": 0 +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `nickname` | string | 否 | 昵称,提供即更新。 | +| `avatar` | string | 否 | 头像 URL,提供即更新。 | +| `status` | int8 | 否 | 状态:`0` 禁用,`1` 正常;非法值返回 `40000`。 | + +> 所有字段以指针区分「未提供」与「置空」;若全部为空则不更新,直接返回原记录。 + +**响应**:返回更新后的 `User` 对象。 + +### 1.4 `POST /api/admin/users/:id/status` + +禁用 / 启用用户。 + +**路径参数**:`id`(uint64)。 + +**请求体** + +```json +{ "status": 0 } +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `status` | int8 | 是(binding:`required`) | `0` 禁用,`1` 正常;非法值返回 `40000`。 | + +**响应**:返回更新后的 `User` 对象。 + +## 2. 数据模型 + +### 2.1 User(小程序端用户) + +| 字段 | 类型 | JSON | 说明 | +| --- | --- | --- | --- | +| `ID` | uint64 | `id` | 主键,自增。 | +| `MiniID` | uint64 | `mini_id` | 所属小程序(`mini_programs.id`)。 | +| `OpenID` | string | `openid` | 微信 OpenID,与 `mini_id` 共同唯一。 | +| `UnionID` | string | `union_id` | 微信 UnionID(可能为空)。 | +| `Nickname` | string | `nickname` | 昵称。 | +| `Avatar` | string | `avatar` | 头像 URL。 | +| `Status` | int8 | `status` | `0` 禁用,`1` 正常(默认 `1`)。 | +| `CreatedAt` | time | `created_at` | 创建时间。 | +| `UpdatedAt` | time | `updated_at` | 更新时间。 | + +## 3. 备注 + +- **用户管理**:`/api/admin/users` 下的接口用于后台管理各小程序端用户(列表、详情、编辑、禁用/启用)。`User` 模型新增的 `status` 字段需手动在数据库中加列后方可生效(因项目未开启 AutoMigrate): + ```sql + ALTER TABLE users ADD COLUMN status TINYINT NOT NULL DEFAULT 1; + ``` diff --git a/internal/handler/admin/rbac/handler.go b/internal/handler/admin/rbac/handler.go new file mode 100644 index 0000000..8d7752a --- /dev/null +++ b/internal/handler/admin/rbac/handler.go @@ -0,0 +1,373 @@ +// Package rbachdl 是后台 RBAC 管理(管理员 / 角色 / 权限)的 HTTP 接入层。 +package rbachdl + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/middleware" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/response" + "portal-mini-go/internal/service/admin/rbac" +) + +// Handler 聚合管理员 / 角色 / 权限三类管理接口。 +type Handler struct { + adminSvc *rbac.AdminService + roleSvc *rbac.RoleService + permSvc *rbac.PermissionService +} + +func NewHandler(adminSvc *rbac.AdminService, roleSvc *rbac.RoleService, permSvc *rbac.PermissionService) *Handler { + return &Handler{adminSvc: adminSvc, roleSvc: roleSvc, permSvc: permSvc} +} + +// ---- 管理员 ---- + +// CreateAdmin 新建管理员。 +func (h *Handler) CreateAdmin(c *gin.Context) { + var req rbac.CreateAdminReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + u, err := h.adminSvc.Create(c.Request.Context(), req) + if err != nil { + c.Error(err) + return + } + response.OK(c, u) +} + +// ListAdmins 分页查询管理员。支持 query:keyword、status、page、size。 +func (h *Handler) ListAdmins(c *gin.Context) { + keyword := c.Query("keyword") + status, _ := strconv.ParseInt(c.DefaultQuery("status", "-1"), 10, 8) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.adminSvc.List(c.Request.Context(), rbac.AdminListReq{ + Keyword: keyword, + Status: int8(status), + Page: page, + Size: size, + }) + if err != nil { + c.Error(err) + return + } + response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) +} + +// GetAdmin 查询单个管理员。 +func (h *Handler) GetAdmin(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + u, err := h.adminSvc.GetByID(c.Request.Context(), id) + if err != nil { + c.Error(err) + return + } + response.OK(c, u) +} + +// UpdateAdmin 编辑管理员昵称 / 状态。 +func (h *Handler) UpdateAdmin(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req rbac.UpdateAdminReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + u, err := h.adminSvc.Update(c.Request.Context(), id, req) + if err != nil { + c.Error(err) + return + } + response.OK(c, u) +} + +// DeleteAdmin 删除管理员。 +func (h *Handler) DeleteAdmin(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.adminSvc.Delete(c.Request.Context(), id); err != nil { + c.Error(err) + return + } + response.OK(c, nil) +} + +// AssignAdminRoles 为管理员分配角色。 +func (h *Handler) AssignAdminRoles(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req struct { + RoleIDs []uint64 `json:"role_ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + if err := h.adminSvc.SetRoles(c.Request.Context(), id, req.RoleIDs); err != nil { + c.Error(err) + return + } + response.OK(c, nil) +} + +// ResetAdminPassword 重置管理员密码。 +func (h *Handler) ResetAdminPassword(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req struct { + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + if err := h.adminSvc.ResetPassword(c.Request.Context(), id, req.Password); err != nil { + c.Error(err) + return + } + response.OK(c, nil) +} + +// ---- 角色 ---- + +// CreateRole 新建角色。 +func (h *Handler) CreateRole(c *gin.Context) { + var req rbac.CreateRoleReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + r, err := h.roleSvc.Create(c.Request.Context(), req) + if err != nil { + c.Error(err) + return + } + response.OK(c, r) +} + +// ListRoles 分页查询角色。支持 query:keyword、status、page、size。 +func (h *Handler) ListRoles(c *gin.Context) { + keyword := c.Query("keyword") + status, _ := strconv.ParseInt(c.DefaultQuery("status", "-1"), 10, 8) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.roleSvc.List(c.Request.Context(), rbac.RoleListReq{ + Keyword: keyword, + Status: int8(status), + Page: page, + Size: size, + }) + if err != nil { + c.Error(err) + return + } + response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) +} + +// GetRole 查询单个角色。 +func (h *Handler) GetRole(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + r, err := h.roleSvc.GetByID(c.Request.Context(), id) + if err != nil { + c.Error(err) + return + } + response.OK(c, r) +} + +// UpdateRole 编辑角色。 +func (h *Handler) UpdateRole(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req rbac.UpdateRoleReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + r, err := h.roleSvc.Update(c.Request.Context(), id, req) + if err != nil { + c.Error(err) + return + } + response.OK(c, r) +} + +// DeleteRole 删除角色。 +func (h *Handler) DeleteRole(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.roleSvc.Delete(c.Request.Context(), id); err != nil { + c.Error(err) + return + } + response.OK(c, nil) +} + +// AssignRolePermissions 为角色分配权限。 +func (h *Handler) AssignRolePermissions(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req struct { + PermissionIDs []uint64 `json:"permission_ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + if err := h.roleSvc.SetPermissions(c.Request.Context(), id, req.PermissionIDs); err != nil { + c.Error(err) + return + } + response.OK(c, nil) +} + +// ---- 权限 ---- + +// ListPermissions 分页查询权限。支持 query:type(api/menu)、keyword、page、size。 +func (h *Handler) ListPermissions(c *gin.Context) { + permType := c.Query("type") + keyword := c.Query("keyword") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.permSvc.List(c.Request.Context(), rbac.PermListReq{ + Type: permType, + Keyword: keyword, + Page: page, + Size: size, + }) + if err != nil { + c.Error(err) + return + } + response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) +} + +// CreatePermission 新建权限。 +func (h *Handler) CreatePermission(c *gin.Context) { + var req rbac.CreatePermReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + p, err := h.permSvc.Create(c.Request.Context(), req) + if err != nil { + c.Error(err) + return + } + response.OK(c, p) +} + +// GetPermission 查询单个权限。 +func (h *Handler) GetPermission(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + p, err := h.permSvc.GetByID(c.Request.Context(), id) + if err != nil { + c.Error(err) + return + } + response.OK(c, p) +} + +// UpdatePermission 编辑权限。 +func (h *Handler) UpdatePermission(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req rbac.UpdatePermReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + p, err := h.permSvc.Update(c.Request.Context(), id, req) + if err != nil { + c.Error(err) + return + } + response.OK(c, p) +} + +// DeletePermission 删除权限。 +func (h *Handler) DeletePermission(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.permSvc.Delete(c.Request.Context(), id); err != nil { + c.Error(err) + return + } + response.OK(c, nil) +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.Error(errors.ErrBadRequest) + return 0, false + } + return id, true +} + +// Register 注册后台 RBAC 管理路由,各资源按权限粒度挂载守卫。 +func Register(g *gin.RouterGroup, h *Handler) { + // 管理员 + ag := g.Group("/rbac/admins", middleware.RequirePerm(middleware.PermAdminManage)) + { + ag.POST("", h.CreateAdmin) + ag.GET("", h.ListAdmins) + ag.GET("/:id", h.GetAdmin) + ag.PUT("/:id", h.UpdateAdmin) + ag.DELETE("/:id", h.DeleteAdmin) + ag.POST("/:id/roles", h.AssignAdminRoles) + ag.POST("/:id/reset-pwd", h.ResetAdminPassword) + } + // 角色 + rg := g.Group("/rbac/roles", middleware.RequirePerm(middleware.PermRoleManage)) + { + rg.POST("", h.CreateRole) + rg.GET("", h.ListRoles) + rg.GET("/:id", h.GetRole) + rg.PUT("/:id", h.UpdateRole) + rg.DELETE("/:id", h.DeleteRole) + rg.POST("/:id/permissions", h.AssignRolePermissions) + } + // 权限 + pg := g.Group("/rbac/permissions", middleware.RequirePerm(middleware.PermPermManage)) + { + pg.POST("", h.CreatePermission) + pg.GET("", h.ListPermissions) + pg.GET("/:id", h.GetPermission) + pg.PUT("/:id", h.UpdatePermission) + pg.DELETE("/:id", h.DeletePermission) + } +} diff --git a/internal/middleware/permission.go b/internal/middleware/permission.go new file mode 100644 index 0000000..5ec3179 --- /dev/null +++ b/internal/middleware/permission.go @@ -0,0 +1,133 @@ +// Package middleware 的权限授权部分:定义权限提供接口、权限加载中间件与接口守卫。 +// 设计为“认证后授权”:PermissionLoader 在 AdminAuth 之后运行,把管理员角色与权限集 +// 注入 gin.Context;RequirePerm / RequireRole 在路由层比对集合,缺失即返回 403。 +package middleware + +import ( + "context" + "log/slog" + + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/errors" +) + +// PermissionProvider 是权限数据的提供方(由 service/admin/rbac 实现)。 +// 中间件仅依赖此接口,便于单测以 mock 替换。 +type PermissionProvider interface { + // Load 返回管理员的角色 code 与权限 code 集合(优先走缓存)。 + Load(ctx context.Context, adminID uint64) (roles, perms []string, err error) + RolesOf(ctx context.Context, adminID uint64) ([]string, error) + PermissionsOf(ctx context.Context, adminID uint64) ([]string, error) + // MenuTree 返回该管理员可见的菜单树(按权限过滤,超级管理员返回完整树)。 + MenuTree(ctx context.Context, adminID uint64) ([]admin.MenuNode, error) +} + +// 后台权限 code 常量(接口级),供路由守卫引用,避免散落字符串。 +const ( + PermMiniView = "mini:view" + PermMiniManage = "mini:manage" + PermUserView = "user:view" + PermUserManage = "user:manage" + PermAdminManage = "admin:manage" + PermRoleManage = "role:manage" + PermPermManage = "perm:manage" +) + +const ( + adminRolesCtxKey = "admin_roles" + adminPermsCtxKey = "admin_perms" +) + +// PermissionLoader 在 AdminAuth 之后加载管理员权限集并写入 context。 +// 加载失败时记告警日志并放行(fail-closed 由守卫层保证:空集必然不通过)。 +func PermissionLoader(p PermissionProvider) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := AdminIDUint(c) + if !ok { + c.Next() + return + } + roles, perms, err := p.Load(c.Request.Context(), id) + if err != nil { + slog.Warn("load admin permissions failed", + slog.Uint64("admin_id", id), slog.Any("err", err)) + c.Next() + return + } + c.Set(adminRolesCtxKey, roles) + set := make(map[string]struct{}, len(perms)) + for _, pc := range perms { + set[pc] = struct{}{} + } + c.Set(adminPermsCtxKey, set) + c.Next() + } +} + +// RequirePerm 接口守卫:管理员需同时拥有全部给定权限(超级管理员短路放行)。 +func RequirePerm(perms ...string) gin.HandlerFunc { + return func(c *gin.Context) { + if HasRole(c, admin.SuperAdminRoleCode) { + c.Next() + return + } + got := permsOf(c) + for _, need := range perms { + if _, ok := got[need]; !ok { + c.Error(errors.ErrForbidden) + c.Abort() + return + } + } + c.Next() + } +} + +// RequireRole 角色守卫:管理员需拥有任一给定角色(超级管理员始终通过)。 +func RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + if HasRole(c, admin.SuperAdminRoleCode) { + c.Next() + return + } + for _, need := range roles { + if HasRole(c, need) { + c.Next() + return + } + } + c.Error(errors.ErrForbidden) + c.Abort() + } +} + +// HasRole 判断 context 中是否含指定角色。 +func HasRole(c *gin.Context, code string) bool { + if v, ok := c.Get(adminRolesCtxKey); ok { + if roles, ok := v.([]string); ok { + for _, r := range roles { + if r == code { + return true + } + } + } + } + return false +} + +// HasPerm 判断 context 中是否含指定权限。 +func HasPerm(c *gin.Context, code string) bool { + _, ok := permsOf(c)[code] + return ok +} + +func permsOf(c *gin.Context) map[string]struct{} { + if v, ok := c.Get(adminPermsCtxKey); ok { + if set, ok := v.(map[string]struct{}); ok { + return set + } + } + return nil +} diff --git a/internal/middleware/permission_test.go b/internal/middleware/permission_test.go new file mode 100644 index 0000000..a9ead46 --- /dev/null +++ b/internal/middleware/permission_test.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/response" +) + +// simulateLoader 模拟 PermissionLoader 把角色与权限集写入 context。 +func simulateLoader(roles, perms []string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set(adminRolesCtxKey, roles) + set := make(map[string]struct{}, len(perms)) + for _, p := range perms { + set[p] = struct{}{} + } + c.Set(adminPermsCtxKey, set) + c.Next() + } +} + +func doReq(r *gin.Engine, method, path string) (int, int) { + req := httptest.NewRequest(method, path, bytes.NewReader(nil)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + var body response.Response + _ = json.Unmarshal(w.Body.Bytes(), &body) + return w.Code, body.Code +} + +func TestRequirePerm(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + name string + roles []string + perms []string + need []string + wantCode int // 业务码:0 通过,40300 禁止 + }{ + {"super_admin 短路", []string{admin.SuperAdminRoleCode}, nil, []string{"any:perm"}, 0}, + {"拥有所需权限", nil, []string{"user:view"}, []string{"user:view"}, 0}, + {"缺少所需权限", nil, []string{"other"}, []string{"user:view"}, 40300}, + {"多权限全部满足", nil, []string{"a", "b"}, []string{"a", "b"}, 0}, + {"多权限部分缺失", nil, []string{"a"}, []string{"a", "b"}, 40300}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := gin.New() + r.Use(ErrorHandler()) + g := r.Group("/x") + g.Use(simulateLoader(tc.roles, tc.perms)) + g.GET("/need", RequirePerm(tc.need...), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + _, code := doReq(r, http.MethodGet, "/x/need") + require.Equal(t, tc.wantCode, code) + }) + } +} diff --git a/internal/model/admin/admin.go b/internal/model/admin/admin.go new file mode 100644 index 0000000..eaf0a81 --- /dev/null +++ b/internal/model/admin/admin.go @@ -0,0 +1,86 @@ +// Package admin 定义后台管理专用领域模型(RBAC)。 +// 表名统一以 admin_ 为前缀,与小程序业务(users / mini_programs)物理隔离, +// 因此本包模型不进小程序数据隔离逻辑。 +package admin + +import "time" + +// User 是后台管理员账号。用户名全局唯一,密码以 bcrypt 哈希存储。 +type User struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` + Nickname string `gorm:"column:nickname;type:varchar(128)" json:"nickname"` + Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (User) TableName() string { return "admin_users" } + +// 管理员状态:0 禁用、1 启用。 +const ( + UserStatusDisabled int8 = 0 + UserStatusEnabled int8 = 1 +) + +// Role 是后台角色,与权限多对多关联,再与管理员多对多关联。 +type Role struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Code string `gorm:"column:code;type:varchar(64);uniqueIndex;not null" json:"code"` + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` + Description string `gorm:"column:description;type:varchar(512)" json:"description"` + Status int8 `gorm:"column:status;type:tinyint;not null;default:1" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Role) TableName() string { return "admin_roles" } + +// Permission 是后台权限,分接口(api)与菜单(menu)两类。 +// 接口权限 code 对应后端 API 守卫;菜单权限 code 用于前端动态渲染菜单树。 +type Permission struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Code string `gorm:"column:code;type:varchar(128);uniqueIndex;not null" json:"code"` + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` + Type string `gorm:"column:type;type:varchar(16);not null;default:'api'" json:"type"` + Method string `gorm:"column:method;type:varchar(16)" json:"method"` + Path string `gorm:"column:path;type:varchar(255)" json:"path"` + ParentID uint64 `gorm:"column:parent_id;index" json:"parent_id"` + Sort int `gorm:"column:sort;not null;default:0" json:"sort"` + CreatedAt time.Time `json:"created_at"` +} + +func (Permission) TableName() string { return "admin_permissions" } + +// 权限类型。 +const ( + PermTypeAPI = "api" + PermTypeMenu = "menu" +) + +// UserRole 管理员-角色关联(多对多中间表)。 +type UserRole struct { + AdminID uint64 `gorm:"primaryKey;column:admin_id" json:"admin_id"` + RoleID uint64 `gorm:"primaryKey;column:role_id" json:"role_id"` +} + +func (UserRole) TableName() string { return "admin_user_roles" } + +// RolePermission 角色-权限关联(多对多中间表)。 +type RolePermission struct { + RoleID uint64 `gorm:"primaryKey;column:role_id" json:"role_id"` + PermissionID uint64 `gorm:"primaryKey;column:permission_id" json:"permission_id"` +} + +func (RolePermission) TableName() string { return "admin_role_permissions" } + +// SuperAdminRoleCode 超级管理员角色 code。守卫层对其短路放行, +// 且菜单树返回完整菜单,无需逐条枚举权限分配。 +const SuperAdminRoleCode = "super_admin" + +// MenuNode 是菜单树节点,用于 /admin/info 返回前端渲染。 +type MenuNode struct { + Permission + Children []MenuNode `json:"children,omitempty"` +} diff --git a/internal/pkg/auth/password.go b/internal/pkg/auth/password.go new file mode 100644 index 0000000..e58dd51 --- /dev/null +++ b/internal/pkg/auth/password.go @@ -0,0 +1,18 @@ +// Package auth 提供后台账号密码哈希与校验能力(bcrypt)。 +package auth + +import "golang.org/x/crypto/bcrypt" + +// HashPassword 生成 bcrypt 哈希,用于安全存储管理员密码。 +func HashPassword(plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(b), nil +} + +// VerifyPassword 校验明文密码与 bcrypt 哈希是否匹配。 +func VerifyPassword(plain, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} diff --git a/internal/pkg/auth/password_test.go b/internal/pkg/auth/password_test.go new file mode 100644 index 0000000..8b426dc --- /dev/null +++ b/internal/pkg/auth/password_test.go @@ -0,0 +1,18 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHashAndVerifyPassword(t *testing.T) { + plain := "Admin@123456" + hash, err := HashPassword(plain) + require.NoError(t, err) + require.NotEmpty(t, hash) + require.NotEqual(t, plain, hash) + + require.True(t, VerifyPassword(plain, hash)) + require.False(t, VerifyPassword("wrong", hash)) +} diff --git a/internal/repository/admin/permission_repo.go b/internal/repository/admin/permission_repo.go new file mode 100644 index 0000000..589ee01 --- /dev/null +++ b/internal/repository/admin/permission_repo.go @@ -0,0 +1,116 @@ +package admin + +import ( + "context" + + "gorm.io/gorm" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/errors" +) + +// PermissionRepository 权限(接口 / 菜单)的数据访问。 +type PermissionRepository struct { + db *gorm.DB +} + +func NewPermissionRepository(db *gorm.DB) *PermissionRepository { + return &PermissionRepository{db: db} +} + +// GetByID 按 ID 查询权限,不存在返回 ErrNotFound。 +func (r *PermissionRepository) GetByID(ctx context.Context, id uint64) (*admin.Permission, error) { + var m admin.Permission + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query permission", err) + } + return &m, nil +} + +// Create 新建权限。 +func (r *PermissionRepository) Create(ctx context.Context, m *admin.Permission) error { + if err := r.db.WithContext(ctx).Create(m).Error; err != nil { + return errors.Wrap(50000, "create permission", err) + } + return nil +} + +// PermListFilter 权限列表过滤条件。Type 为空表示不过滤类型。 +type PermListFilter struct { + Type string + Keyword string + Page int + Size int +} + +// List 分页查询权限,支持类型与关键字(名称 / code / 路径)过滤。 +func (r *PermissionRepository) List(ctx context.Context, f PermListFilter) ([]admin.Permission, int64, error) { + q := r.db.WithContext(ctx).Model(&admin.Permission{}) + if f.Type != "" { + q = q.Where("type = ?", f.Type) + } + if f.Keyword != "" { + kw := "%" + f.Keyword + "%" + q = q.Where("name LIKE ? OR code LIKE ? OR path LIKE ?", kw, kw, kw) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(50000, "count permission", err) + } + var list []admin.Permission + if err := q.Order("sort ASC, id ASC").Offset((f.Page - 1) * f.Size).Limit(f.Size).Find(&list).Error; err != nil { + return nil, 0, errors.Wrap(50000, "list permission", err) + } + return list, total, nil +} + +// Update 按 ID 局部更新权限非零值字段。 +func (r *PermissionRepository) Update(ctx context.Context, id uint64, cols map[string]interface{}) error { + res := r.db.WithContext(ctx).Model(&admin.Permission{}).Where("id = ?", id).Updates(cols) + if res.Error != nil { + return errors.Wrap(50000, "update permission", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} + +// Delete 删除权限,并清理角色-权限关联。 +func (r *PermissionRepository) Delete(ctx context.Context, id uint64) error { + if err := r.db.WithContext(ctx).Where("permission_id = ?", id).Delete(&admin.RolePermission{}).Error; err != nil { + return errors.Wrap(50000, "delete role permissions", err) + } + res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&admin.Permission{}) + if res.Error != nil { + return errors.Wrap(50000, "delete permission", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} + +// AllMenuPermissions 返回全部菜单类型权限(用于组装菜单树)。 +func (r *PermissionRepository) AllMenuPermissions(ctx context.Context) ([]admin.Permission, error) { + var list []admin.Permission + if err := r.db.WithContext(ctx). + Where("type = ?", admin.PermTypeMenu). + Order("sort ASC, id ASC"). + Find(&list).Error; err != nil { + return nil, errors.Wrap(50000, "list menu permissions", err) + } + return list, nil +} + +// AllCodes 返回全部权限 code(超级管理员短路放行时使用)。 +func (r *PermissionRepository) AllCodes(ctx context.Context) ([]string, error) { + var codes []string + if err := r.db.WithContext(ctx).Model(&admin.Permission{}).Pluck("code", &codes).Error; err != nil { + return nil, errors.Wrap(50000, "list permission codes", err) + } + return codes, nil +} diff --git a/internal/repository/admin/role_repo.go b/internal/repository/admin/role_repo.go new file mode 100644 index 0000000..040aafc --- /dev/null +++ b/internal/repository/admin/role_repo.go @@ -0,0 +1,139 @@ +package admin + +import ( + "context" + + "gorm.io/gorm" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/errors" +) + +// RoleRepository 角色与权限关联的数据访问。 +type RoleRepository struct { + db *gorm.DB +} + +func NewRoleRepository(db *gorm.DB) *RoleRepository { return &RoleRepository{db: db} } + +// GetByID 按 ID 查询角色,不存在返回 ErrNotFound。 +func (r *RoleRepository) GetByID(ctx context.Context, id uint64) (*admin.Role, error) { + var m admin.Role + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query role", err) + } + return &m, nil +} + +// GetByCode 按 code 查询角色,不存在返回 ErrNotFound。 +func (r *RoleRepository) GetByCode(ctx context.Context, code string) (*admin.Role, error) { + var m admin.Role + if err := r.db.WithContext(ctx).Where("code = ?", code).First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query role", err) + } + return &m, nil +} + +// Create 新建角色。 +func (r *RoleRepository) Create(ctx context.Context, m *admin.Role) error { + if err := r.db.WithContext(ctx).Create(m).Error; err != nil { + return errors.Wrap(50000, "create role", err) + } + return nil +} + +// RoleListFilter 角色列表过滤条件。 +type RoleListFilter struct { + Keyword string + Status int8 + Page int + Size int +} + +// List 分页查询角色,支持关键字与状态过滤。 +func (r *RoleRepository) List(ctx context.Context, f RoleListFilter) ([]admin.Role, int64, error) { + q := r.db.WithContext(ctx).Model(&admin.Role{}) + if f.Status >= 0 { + q = q.Where("status = ?", f.Status) + } + if f.Keyword != "" { + kw := "%" + f.Keyword + "%" + q = q.Where("name LIKE ? OR code LIKE ?", kw, kw) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(50000, "count role", err) + } + var list []admin.Role + if err := q.Order("id DESC").Offset((f.Page - 1) * f.Size).Limit(f.Size).Find(&list).Error; err != nil { + return nil, 0, errors.Wrap(50000, "list role", err) + } + return list, total, nil +} + +// Update 按 ID 局部更新角色非零值字段。 +func (r *RoleRepository) Update(ctx context.Context, id uint64, cols map[string]interface{}) error { + res := r.db.WithContext(ctx).Model(&admin.Role{}).Where("id = ?", id).Updates(cols) + if res.Error != nil { + return errors.Wrap(50000, "update role", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} + +// Delete 删除角色,并清理角色-权限与管理员-角色关联。 +func (r *RoleRepository) Delete(ctx context.Context, id uint64) error { + if err := r.db.WithContext(ctx).Where("role_id = ?", id).Delete(&admin.RolePermission{}).Error; err != nil { + return errors.Wrap(50000, "delete role permissions", err) + } + if err := r.db.WithContext(ctx).Where("role_id = ?", id).Delete(&admin.UserRole{}).Error; err != nil { + return errors.Wrap(50000, "delete user roles", err) + } + res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&admin.Role{}) + if res.Error != nil { + return errors.Wrap(50000, "delete role", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} + +// SetPermissions 全量替换角色所拥有的权限(先删后插)。 +func (r *RoleRepository) SetPermissions(ctx context.Context, roleID uint64, permIDs []uint64) error { + if err := r.db.WithContext(ctx).Where("role_id = ?", roleID).Delete(&admin.RolePermission{}).Error; err != nil { + return errors.Wrap(50000, "reset role permissions", err) + } + if len(permIDs) == 0 { + return nil + } + rows := make([]admin.RolePermission, 0, len(permIDs)) + for _, pid := range permIDs { + rows = append(rows, admin.RolePermission{RoleID: roleID, PermissionID: pid}) + } + if err := r.db.WithContext(ctx).Create(&rows).Error; err != nil { + return errors.Wrap(50000, "assign role permissions", err) + } + return nil +} + +// PermissionCodesOf 返回角色拥有的权限 code 列表。 +func (r *RoleRepository) PermissionCodesOf(ctx context.Context, roleID uint64) ([]string, error) { + var codes []string + if err := r.db.WithContext(ctx). + Table("admin_role_permissions rp"). + Joins("JOIN admin_permissions p ON p.id = rp.permission_id"). + Where("rp.role_id = ?", roleID). + Pluck("p.code", &codes).Error; err != nil { + return nil, errors.Wrap(50000, "query role permission codes", err) + } + return codes, nil +} diff --git a/internal/repository/admin/user_repo.go b/internal/repository/admin/user_repo.go new file mode 100644 index 0000000..9efcb61 --- /dev/null +++ b/internal/repository/admin/user_repo.go @@ -0,0 +1,162 @@ +// Package admin 提供后台 RBAC 相关表的数据访问。 +// 包含管理员(admin_users)、角色(admin_roles)、权限(admin_permissions) +// 及其多对多关联表的读写。所有查询均参数化,杜绝 SQL 注入。 +package admin + +import ( + "context" + + "gorm.io/gorm" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/errors" +) + +// UserRepository 管理员账号与角色关联的数据访问。 +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { return &UserRepository{db: db} } + +// GetByUsername 按用户名查询管理员,不存在返回 ErrNotFound。 +func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*admin.User, error) { + var u admin.User + if err := r.db.WithContext(ctx).Where("username = ?", username).First(&u).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query admin user", err) + } + return &u, nil +} + +// GetByID 按 ID 查询管理员,不存在返回 ErrNotFound。 +func (r *UserRepository) GetByID(ctx context.Context, id uint64) (*admin.User, error) { + var u admin.User + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&u).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query admin user", err) + } + return &u, nil +} + +// Create 新建管理员。 +func (r *UserRepository) Create(ctx context.Context, u *admin.User) error { + if err := r.db.WithContext(ctx).Create(u).Error; err != nil { + return errors.Wrap(50000, "create admin user", err) + } + return nil +} + +// Count 统计管理员总数。 +func (r *UserRepository) Count(ctx context.Context) (int64, error) { + var n int64 + if err := r.db.WithContext(ctx).Model(&admin.User{}).Count(&n).Error; err != nil { + return 0, errors.Wrap(50000, "count admin user", err) + } + return n, nil +} + +// UserListFilter 管理员列表过滤条件。 +type UserListFilter struct { + Keyword string + Status int8 // <0 表示不过滤 + Page int + Size int +} + +// List 分页查询管理员,支持关键字(用户名 / 昵称)与状态过滤。 +func (r *UserRepository) List(ctx context.Context, f UserListFilter) ([]admin.User, int64, error) { + q := r.db.WithContext(ctx).Model(&admin.User{}) + if f.Status >= 0 { + q = q.Where("status = ?", f.Status) + } + if f.Keyword != "" { + kw := "%" + f.Keyword + "%" + q = q.Where("username LIKE ? OR nickname LIKE ?", kw, kw) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(50000, "count admin user", err) + } + var list []admin.User + if err := q.Order("id DESC").Offset((f.Page - 1) * f.Size).Limit(f.Size).Find(&list).Error; err != nil { + return nil, 0, errors.Wrap(50000, "list admin user", err) + } + return list, total, nil +} + +// Update 按 ID 局部更新管理员非零值字段(cols 为待更新列)。 +func (r *UserRepository) Update(ctx context.Context, id uint64, cols map[string]interface{}) error { + res := r.db.WithContext(ctx).Model(&admin.User{}).Where("id = ?", id).Updates(cols) + if res.Error != nil { + return errors.Wrap(50000, "update admin user", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} + +// Delete 删除管理员,并清理其角色关联。 +func (r *UserRepository) Delete(ctx context.Context, id uint64) error { + if err := r.db.WithContext(ctx).Where("admin_id = ?", id).Delete(&admin.UserRole{}).Error; err != nil { + return errors.Wrap(50000, "delete admin user roles", err) + } + res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&admin.User{}) + if res.Error != nil { + return errors.Wrap(50000, "delete admin user", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} + +// SetRoles 全量替换管理员所属角色(先删后插)。 +func (r *UserRepository) SetRoles(ctx context.Context, adminID uint64, roleIDs []uint64) error { + if err := r.db.WithContext(ctx).Where("admin_id = ?", adminID).Delete(&admin.UserRole{}).Error; err != nil { + return errors.Wrap(50000, "reset admin roles", err) + } + if len(roleIDs) == 0 { + return nil + } + rows := make([]admin.UserRole, 0, len(roleIDs)) + for _, rid := range roleIDs { + rows = append(rows, admin.UserRole{AdminID: adminID, RoleID: rid}) + } + if err := r.db.WithContext(ctx).Create(&rows).Error; err != nil { + return errors.Wrap(50000, "assign admin roles", err) + } + return nil +} + +// RoleCodesOf 返回管理员所属角色 code 列表(经 admin_user_roles 关联)。 +func (r *UserRepository) RoleCodesOf(ctx context.Context, adminID uint64) ([]string, error) { + var codes []string + if err := r.db.WithContext(ctx). + Table("admin_user_roles"). + Joins("JOIN admin_roles ON admin_roles.id = admin_user_roles.role_id"). + Where("admin_user_roles.admin_id = ?", adminID). + Pluck("admin_roles.code", &codes).Error; err != nil { + return nil, errors.Wrap(50000, "query admin role codes", err) + } + return codes, nil +} + +// PermissionCodesOf 返回管理员经角色聚合后的全部权限 code 列表(去重)。 +func (r *UserRepository) PermissionCodesOf(ctx context.Context, adminID uint64) ([]string, error) { + var codes []string + if err := r.db.WithContext(ctx). + Table("admin_user_roles ur"). + Joins("JOIN admin_role_permissions rp ON rp.role_id = ur.role_id"). + Joins("JOIN admin_permissions p ON p.id = rp.permission_id"). + Where("ur.admin_id = ?", adminID). + Pluck("p.code", &codes).Error; err != nil { + return nil, errors.Wrap(50000, "query admin permission codes", err) + } + return codes, nil +} diff --git a/internal/service/admin/auth/service.go b/internal/service/admin/auth/service.go new file mode 100644 index 0000000..be87fa2 --- /dev/null +++ b/internal/service/admin/auth/service.go @@ -0,0 +1,92 @@ +// Package authsvc 是后台登录与当前用户信息的业务逻辑。 +package authsvc + +import ( + "context" + "strconv" + "time" + + "portal-mini-go/internal/middleware" + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/auth" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/token" +) + +// userRepo 是登录与信息所需的用户仓储最小接口(由 repository/admin 实现)。 +type userRepo interface { + GetByUsername(ctx context.Context, username string) (*admin.User, error) + GetByID(ctx context.Context, id uint64) (*admin.User, error) +} + +// Service 后台认证业务:登录签发 JWT、当前用户信息聚合。 +type Service struct { + userRepo userRepo + provider middleware.PermissionProvider + jwtSecret string + tokenTTL time.Duration +} + +func New(userRepo userRepo, provider middleware.PermissionProvider, jwtSecret string, tokenTTL time.Duration) *Service { + return &Service{userRepo: userRepo, provider: provider, jwtSecret: jwtSecret, tokenTTL: tokenTTL} +} + +// LoginReq 登录请求。 +type LoginReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// Login 校验账号密码,成功返回 JWT。账号不存在 / 密码错误 / 禁用均返回未授权, +// 避免泄露账号是否存在。 +func (s *Service) Login(ctx context.Context, req LoginReq) (string, error) { + u, err := s.userRepo.GetByUsername(ctx, req.Username) + if err != nil { + return "", errors.ErrUnauthorized + } + if u.Status != admin.UserStatusEnabled { + return "", errors.ErrUnauthorized + } + if !auth.VerifyPassword(req.Password, u.PasswordHash) { + return "", errors.ErrUnauthorized + } + return token.Generate(s.jwtSecret, strconv.FormatUint(u.ID, 10), s.tokenTTL) +} + +// Info 是当前管理员聚合信息,供前端渲染与鉴权。 +type Info struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + Menus []admin.MenuNode `json:"menus"` +} + +// Info 返回当前管理员的角色、权限与可见菜单树。 +func (s *Service) Info(ctx context.Context, adminID uint64) (*Info, error) { + u, err := s.userRepo.GetByID(ctx, adminID) + if err != nil { + return nil, err + } + roles, err := s.provider.RolesOf(ctx, adminID) + if err != nil { + return nil, err + } + perms, err := s.provider.PermissionsOf(ctx, adminID) + if err != nil { + return nil, err + } + menus, err := s.provider.MenuTree(ctx, adminID) + if err != nil { + return nil, err + } + return &Info{ + ID: strconv.FormatUint(u.ID, 10), + Username: u.Username, + Nickname: u.Nickname, + Roles: roles, + Permissions: perms, + Menus: menus, + }, nil +} diff --git a/internal/service/admin/auth/service_test.go b/internal/service/admin/auth/service_test.go new file mode 100644 index 0000000..7644c80 --- /dev/null +++ b/internal/service/admin/auth/service_test.go @@ -0,0 +1,98 @@ +package authsvc + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/auth" + "portal-mini-go/internal/pkg/errors" +) + +type fakeUserRepo struct { + byUsername map[string]*admin.User + byID map[uint64]*admin.User +} + +func (r *fakeUserRepo) GetByUsername(_ context.Context, username string) (*admin.User, error) { + if u, ok := r.byUsername[username]; ok { + return u, nil + } + return nil, errors.ErrNotFound +} +func (r *fakeUserRepo) GetByID(_ context.Context, id uint64) (*admin.User, error) { + if u, ok := r.byID[id]; ok { + return u, nil + } + return nil, errors.ErrNotFound +} + +type fakeProvider struct { + roles []string + perms []string + menus []admin.MenuNode +} + +func (p *fakeProvider) Load(context.Context, uint64) ([]string, []string, error) { + return p.roles, p.perms, nil +} +func (p *fakeProvider) RolesOf(context.Context, uint64) ([]string, error) { return p.roles, nil } +func (p *fakeProvider) PermissionsOf(context.Context, uint64) ([]string, error) { + return p.perms, nil +} +func (p *fakeProvider) MenuTree(context.Context, uint64) ([]admin.MenuNode, error) { + return p.menus, nil +} + +func newSvc(u *admin.User) *Service { + repo := &fakeUserRepo{ + byUsername: map[string]*admin.User{u.Username: u}, + byID: map[uint64]*admin.User{u.ID: u}, + } + return New(repo, &fakeProvider{}, "secret", time.Hour) +} + +func TestLogin_Success(t *testing.T) { + hash, _ := auth.HashPassword("Admin@123456") + u := &admin.User{ID: 7, Username: "admin", PasswordHash: hash, Status: admin.UserStatusEnabled} + svc := newSvc(u) + + tok, err := svc.Login(context.Background(), LoginReq{Username: "admin", Password: "Admin@123456"}) + require.NoError(t, err) + require.NotEmpty(t, tok) +} + +func TestLogin_Failures(t *testing.T) { + hash, _ := auth.HashPassword("Admin@123456") + u := &admin.User{ID: 7, Username: "admin", PasswordHash: hash, Status: admin.UserStatusEnabled} + svc := newSvc(u) + + _, err := svc.Login(context.Background(), LoginReq{Username: "admin", Password: "wrong"}) + require.ErrorIs(t, err, errors.ErrUnauthorized) + + disabled := &admin.User{ID: 8, Username: "disabled", PasswordHash: hash, Status: admin.UserStatusDisabled} + svc2 := newSvc(disabled) + _, err = svc2.Login(context.Background(), LoginReq{Username: "disabled", Password: "Admin@123456"}) + require.ErrorIs(t, err, errors.ErrUnauthorized) + + _, err = svc.Login(context.Background(), LoginReq{Username: "nope", Password: "x"}) + require.ErrorIs(t, err, errors.ErrUnauthorized) +} + +func TestInfo(t *testing.T) { + hash, _ := auth.HashPassword("Admin@123456") + u := &admin.User{ID: 7, Username: "admin", Nickname: "超管", PasswordHash: hash, Status: admin.UserStatusEnabled} + repo := &fakeUserRepo{byID: map[uint64]*admin.User{7: u}} + prov := &fakeProvider{roles: []string{"super_admin"}, perms: []string{"user:view"}, menus: nil} + svc := New(repo, prov, "secret", time.Hour) + + info, err := svc.Info(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, "7", info.ID) + require.Equal(t, "admin", info.Username) + require.Equal(t, []string{"super_admin"}, info.Roles) + require.Equal(t, []string{"user:view"}, info.Permissions) +} diff --git a/internal/service/admin/rbac/admin_service.go b/internal/service/admin/rbac/admin_service.go new file mode 100644 index 0000000..ba87d11 --- /dev/null +++ b/internal/service/admin/rbac/admin_service.go @@ -0,0 +1,146 @@ +package rbac + +import ( + "context" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/auth" + "portal-mini-go/internal/pkg/errors" + adminrepo "portal-mini-go/internal/repository/admin" +) + +// AdminService 管理员账号业务:CRUD、角色分配、密码重置。 +type AdminService struct { + repo UserRepository + invalidator CacheInvalidator +} + +func NewAdminService(repo UserRepository, invalidator CacheInvalidator) *AdminService { + return &AdminService{repo: repo, invalidator: invalidator} +} + +// CreateReq 新建管理员请求。 +type CreateAdminReq struct { + Username string `json:"username" binding:"required"` + Nickname string `json:"nickname"` + Password string `json:"password" binding:"required"` + Status int8 `json:"status"` +} + +// Create 新建管理员,用户名唯一,密码以 bcrypt 存储。 +func (s *AdminService) Create(ctx context.Context, req CreateAdminReq) (*admin.User, error) { + if req.Status == 0 { + req.Status = admin.UserStatusEnabled + } + if req.Status != admin.UserStatusEnabled && req.Status != admin.UserStatusDisabled { + return nil, errors.New(40000, "非法的管理员状态") + } + if _, err := s.repo.GetByUsername(ctx, req.Username); err == nil { + return nil, errors.New(40900, "用户名已存在") + } else if !errors.Is(err, errors.ErrNotFound) { + return nil, err + } + hash, err := auth.HashPassword(req.Password) + if err != nil { + return nil, errors.Wrap(50000, "hash password", err) + } + u := &admin.User{ + Username: req.Username, + Nickname: req.Nickname, + PasswordHash: hash, + Status: req.Status, + } + if err := s.repo.Create(ctx, u); err != nil { + return nil, err + } + return u, nil +} + +// AdminListReq 管理员列表过滤条件。 +type AdminListReq struct { + Keyword string + Status int8 + Page int + Size int +} + +// List 分页查询管理员。 +func (s *AdminService) List(ctx context.Context, req AdminListReq) ([]admin.User, int64, error) { + page, size := req.Page, req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + return s.repo.List(ctx, adminrepo.UserListFilter{ + Keyword: req.Keyword, + Status: req.Status, + Page: page, + Size: size, + }) +} + +// GetByID 按 ID 查询管理员。 +func (s *AdminService) GetByID(ctx context.Context, id uint64) (*admin.User, error) { + return s.repo.GetByID(ctx, id) +} + +// UpdateAdminReq 编辑管理员请求;指针区分“未提供”与“置空”。 +type UpdateAdminReq struct { + Nickname *string `json:"nickname"` + Status *int8 `json:"status"` +} + +// Update 局部更新管理员昵称与状态。 +func (s *AdminService) Update(ctx context.Context, id uint64, req UpdateAdminReq) (*admin.User, error) { + cols := map[string]interface{}{} + if req.Nickname != nil { + cols["nickname"] = *req.Nickname + } + if req.Status != nil { + if *req.Status != admin.UserStatusEnabled && *req.Status != admin.UserStatusDisabled { + return nil, errors.New(40000, "非法的管理员状态") + } + cols["status"] = *req.Status + } + if len(cols) == 0 { + return s.repo.GetByID(ctx, id) + } + if err := s.repo.Update(ctx, id, cols); err != nil { + return nil, err + } + s.invalidator.Invalidate(ctx, id) + return s.repo.GetByID(ctx, id) +} + +// Delete 删除管理员(同时清理其角色关联,并失效缓存)。 +func (s *AdminService) Delete(ctx context.Context, id uint64) error { + if err := s.repo.Delete(ctx, id); err != nil { + return err + } + s.invalidator.Invalidate(ctx, id) + return nil +} + +// SetRoles 为管理员分配角色(全量替换)。 +func (s *AdminService) SetRoles(ctx context.Context, id uint64, roleIDs []uint64) error { + if err := s.repo.SetRoles(ctx, id, roleIDs); err != nil { + return err + } + s.invalidator.Invalidate(ctx, id) + return nil +} + +// ResetPassword 重置管理员密码。 +func (s *AdminService) ResetPassword(ctx context.Context, id uint64, newPassword string) error { + hash, err := auth.HashPassword(newPassword) + if err != nil { + return errors.Wrap(50000, "hash password", err) + } + if err := s.repo.Update(ctx, id, map[string]interface{}{"password_hash": hash}); err != nil { + return err + } + s.invalidator.Invalidate(ctx, id) + return nil +} diff --git a/internal/service/admin/rbac/permission_service.go b/internal/service/admin/rbac/permission_service.go new file mode 100644 index 0000000..991bc2a --- /dev/null +++ b/internal/service/admin/rbac/permission_service.go @@ -0,0 +1,135 @@ +package rbac + +import ( + "context" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/errors" + adminrepo "portal-mini-go/internal/repository/admin" +) + +// PermissionService 权限(接口 / 菜单)业务:CRUD。 +type PermissionService struct { + repo PermissionRepository + invalidator CacheInvalidator +} + +func NewPermissionService(repo PermissionRepository, invalidator CacheInvalidator) *PermissionService { + return &PermissionService{repo: repo, invalidator: invalidator} +} + +// CreatePermReq 新建权限请求。 +// Type 取 admin.PermTypeAPI / admin.PermTypeMenu;菜单需指定 ParentID 与 Sort。 +type CreatePermReq struct { + Code string `json:"code" binding:"required"` + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Method string `json:"method"` + Path string `json:"path"` + ParentID uint64 `json:"parent_id"` + Sort int `json:"sort"` +} + +// Create 新建权限,code 唯一。 +func (s *PermissionService) Create(ctx context.Context, req CreatePermReq) (*admin.Permission, error) { + if req.Type != admin.PermTypeAPI && req.Type != admin.PermTypeMenu { + return nil, errors.New(40000, "非法的权限类型") + } + p := &admin.Permission{ + Code: req.Code, + Name: req.Name, + Type: req.Type, + Method: req.Method, + Path: req.Path, + ParentID: req.ParentID, + Sort: req.Sort, + } + if err := s.repo.Create(ctx, p); err != nil { + return nil, err + } + s.invalidator.InvalidateAll(ctx) + return p, nil +} + +// PermListReq 权限列表过滤条件。 +type PermListReq struct { + Type string + Keyword string + Page int + Size int +} + +// List 分页查询权限,Type 为空表示全部类型。 +func (s *PermissionService) List(ctx context.Context, req PermListReq) ([]admin.Permission, int64, error) { + page, size := req.Page, req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + return s.repo.List(ctx, adminrepo.PermListFilter{ + Type: req.Type, + Keyword: req.Keyword, + Page: page, + Size: size, + }) +} + +// GetByID 按 ID 查询权限。 +func (s *PermissionService) GetByID(ctx context.Context, id uint64) (*admin.Permission, error) { + return s.repo.GetByID(ctx, id) +} + +// UpdatePermReq 编辑权限请求;指针区分“未提供”与“置空”。 +type UpdatePermReq struct { + Name *string `json:"name"` + Type *string `json:"type"` + Method *string `json:"method"` + Path *string `json:"path"` + ParentID *uint64 `json:"parent_id"` + Sort *int `json:"sort"` +} + +// Update 局部更新权限。 +func (s *PermissionService) Update(ctx context.Context, id uint64, req UpdatePermReq) (*admin.Permission, error) { + cols := map[string]interface{}{} + if req.Name != nil { + cols["name"] = *req.Name + } + if req.Type != nil { + if *req.Type != admin.PermTypeAPI && *req.Type != admin.PermTypeMenu { + return nil, errors.New(40000, "非法的权限类型") + } + cols["type"] = *req.Type + } + if req.Method != nil { + cols["method"] = *req.Method + } + if req.Path != nil { + cols["path"] = *req.Path + } + if req.ParentID != nil { + cols["parent_id"] = *req.ParentID + } + if req.Sort != nil { + cols["sort"] = *req.Sort + } + if len(cols) == 0 { + return s.repo.GetByID(ctx, id) + } + if err := s.repo.Update(ctx, id, cols); err != nil { + return nil, err + } + s.invalidator.InvalidateAll(ctx) + return s.repo.GetByID(ctx, id) +} + +// Delete 删除权限(清理关联并失效全部缓存)。 +func (s *PermissionService) Delete(ctx context.Context, id uint64) error { + if err := s.repo.Delete(ctx, id); err != nil { + return err + } + s.invalidator.InvalidateAll(ctx) + return nil +} diff --git a/internal/service/admin/rbac/provider.go b/internal/service/admin/rbac/provider.go new file mode 100644 index 0000000..45ee10b --- /dev/null +++ b/internal/service/admin/rbac/provider.go @@ -0,0 +1,323 @@ +// Package rbac 提供后台 RBAC 的权限聚合、菜单树组装、缓存与种子初始化, +// 以及管理员 / 角色 / 权限(接口、菜单)的 CRUD 业务。 +// +// 依赖方向:handler → 本包 service → repository/admin → model/admin。 +package rbac + +import ( + "context" + "sort" + "sync" + "time" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/auth" + "portal-mini-go/internal/pkg/errors" + adminrepo "portal-mini-go/internal/repository/admin" +) + +// UserRepository 是 Provider 与管理员业务所需的用户仓储接口(由 repository/admin 实现)。 +type UserRepository interface { + GetByUsername(ctx context.Context, username string) (*admin.User, error) + GetByID(ctx context.Context, id uint64) (*admin.User, error) + Create(ctx context.Context, u *admin.User) error + Count(ctx context.Context) (int64, error) + List(ctx context.Context, f adminrepo.UserListFilter) ([]admin.User, int64, error) + Update(ctx context.Context, id uint64, cols map[string]interface{}) error + Delete(ctx context.Context, id uint64) error + SetRoles(ctx context.Context, adminID uint64, roleIDs []uint64) error + RoleCodesOf(ctx context.Context, adminID uint64) ([]string, error) + PermissionCodesOf(ctx context.Context, adminID uint64) ([]string, error) +} + +// RoleRepository 是 Provider 与角色业务所需的角色仓储接口。 +type RoleRepository interface { + GetByID(ctx context.Context, id uint64) (*admin.Role, error) + GetByCode(ctx context.Context, code string) (*admin.Role, error) + Create(ctx context.Context, m *admin.Role) error + List(ctx context.Context, f adminrepo.RoleListFilter) ([]admin.Role, int64, error) + Update(ctx context.Context, id uint64, cols map[string]interface{}) error + Delete(ctx context.Context, id uint64) error + SetPermissions(ctx context.Context, roleID uint64, permIDs []uint64) error + PermissionCodesOf(ctx context.Context, roleID uint64) ([]string, error) +} + +// PermissionRepository 是 Provider 与权限业务所需的权限仓储接口。 +type PermissionRepository interface { + GetByID(ctx context.Context, id uint64) (*admin.Permission, error) + Create(ctx context.Context, m *admin.Permission) error + List(ctx context.Context, f adminrepo.PermListFilter) ([]admin.Permission, int64, error) + Update(ctx context.Context, id uint64, cols map[string]interface{}) error + Delete(ctx context.Context, id uint64) error + AllMenuPermissions(ctx context.Context) ([]admin.Permission, error) + AllCodes(ctx context.Context) ([]string, error) +} + +// CacheInvalidator 用于写操作后失效权限缓存,由 Provider 实现并被各业务服务依赖。 +type CacheInvalidator interface { + Invalidate(ctx context.Context, adminID uint64) + InvalidateAll(ctx context.Context) +} + +// PermissionCache 是管理员权限集的缓存抽象。默认实现为内存版; +// 生产环境若接入 Redis,可实现该接口替换(见 NewRedisCache 预留说明)。 +type PermissionCache interface { + Get(ctx context.Context, adminID uint64) (roles, perms []string, ok bool) + Set(ctx context.Context, adminID uint64, roles, perms []string) + Invalidate(ctx context.Context, adminID uint64) + InvalidateAll(ctx context.Context) +} + +// Provider 聚合管理员权限数据,实现 middleware.PermissionProvider。 +type Provider struct { + userRepo UserRepository + roleRepo RoleRepository + permRepo PermissionRepository + cache PermissionCache +} + +// NewProvider 构造权限 Provider。 +func NewProvider( + userRepo UserRepository, + roleRepo RoleRepository, + permRepo PermissionRepository, + cache PermissionCache, +) *Provider { + return &Provider{userRepo: userRepo, roleRepo: roleRepo, permRepo: permRepo, cache: cache} +} + +// Load 返回管理员的角色与权限 code 集合,优先读缓存。 +// 若含超级管理员角色,则权限集为全部权限 code(短路放行)。 +func (p *Provider) Load(ctx context.Context, adminID uint64) (roles, perms []string, err error) { + if r, pp, ok := p.cache.Get(ctx, adminID); ok { + return r, pp, nil + } + roles, err = p.userRepo.RoleCodesOf(ctx, adminID) + if err != nil { + return nil, nil, err + } + if contains(roles, admin.SuperAdminRoleCode) { + all, e := p.permRepo.AllCodes(ctx) + if e != nil { + return nil, nil, e + } + perms = all + } else { + perms, err = p.userRepo.PermissionCodesOf(ctx, adminID) + if err != nil { + return nil, nil, err + } + } + p.cache.Set(ctx, adminID, roles, perms) + return roles, perms, nil +} + +// RolesOf 返回管理员角色 code 列表。 +func (p *Provider) RolesOf(ctx context.Context, adminID uint64) ([]string, error) { + roles, _, err := p.Load(ctx, adminID) + return roles, err +} + +// PermissionsOf 返回管理员权限 code 列表。 +func (p *Provider) PermissionsOf(ctx context.Context, adminID uint64) ([]string, error) { + _, perms, err := p.Load(ctx, adminID) + return perms, err +} + +// MenuTree 返回该管理员可见的菜单树(按权限过滤,超级管理员返回完整树)。 +func (p *Provider) MenuTree(ctx context.Context, adminID uint64) ([]admin.MenuNode, error) { + roles, perms, err := p.Load(ctx, adminID) + if err != nil { + return nil, err + } + menus, err := p.permRepo.AllMenuPermissions(ctx) + if err != nil { + return nil, err + } + allowed := make(map[string]struct{}, len(perms)) + for _, c := range perms { + allowed[c] = struct{}{} + } + return buildMenuTree(menus, contains(roles, admin.SuperAdminRoleCode), allowed), nil +} + +// Invalidate 失效指定管理员的权限缓存。 +func (p *Provider) Invalidate(ctx context.Context, adminID uint64) { p.cache.Invalidate(ctx, adminID) } + +// InvalidateAll 失效全部管理员的权限缓存(角色 / 权限变更后调用)。 +func (p *Provider) InvalidateAll(ctx context.Context) { p.cache.InvalidateAll(ctx) } + +// Seed 初始化超级管理员角色与首个管理员账号(若表为空)。 +// defaultUser / defaultPwd 来自环境变量,缺失时使用安全默认值。 +func (p *Provider) Seed(ctx context.Context, defaultUser, defaultPwd string) error { + if _, err := p.roleRepo.GetByCode(ctx, admin.SuperAdminRoleCode); err != nil { + if !errors.Is(err, errors.ErrNotFound) { + return err + } + if err := p.roleRepo.Create(ctx, &admin.Role{ + Code: admin.SuperAdminRoleCode, + Name: "超级管理员", + Status: admin.UserStatusEnabled, + }); err != nil { + return err + } + } + n, err := p.userRepo.Count(ctx) + if err != nil { + return err + } + if n > 0 { + return nil + } + hash, err := auth.HashPassword(defaultPwd) + if err != nil { + return errors.Wrap(50000, "hash seed password", err) + } + u := &admin.User{ + Username: defaultUser, + PasswordHash: hash, + Nickname: "超级管理员", + Status: admin.UserStatusEnabled, + } + if err := p.userRepo.Create(ctx, u); err != nil { + return err + } + role, err := p.roleRepo.GetByCode(ctx, admin.SuperAdminRoleCode) + if err != nil { + return err + } + return p.userRepo.SetRoles(ctx, u.ID, []uint64{role.ID}) +} + +// buildMenuTree 依据菜单权限列表与管理员可见集合组装树。 +// 节点被保留的条件:自身 code 在 allowed 中(超级管理员恒为真),或存在被保留的后代。 +func buildMenuTree(menus []admin.Permission, isSuper bool, allowed map[string]struct{}) []admin.MenuNode { + byID := make(map[uint64]admin.Permission, len(menus)) + childrenOf := make(map[uint64][]uint64) + for _, m := range menus { + byID[m.ID] = m + childrenOf[m.ParentID] = append(childrenOf[m.ParentID], m.ID) + } + kept := make(map[uint64]bool, len(menus)) + var mark func(id uint64) bool + mark = func(id uint64) bool { + if v, ok := kept[id]; ok { + return v + } + p := byID[id] + self := isSuper + if !isSuper { + if _, ok := allowed[p.Code]; ok { + self = true + } + } + childKept := false + for _, cid := range childrenOf[id] { + if mark(cid) { + childKept = true + } + } + keep := self || childKept + kept[id] = keep + return keep + } + for id := range byID { + mark(id) + } + + var build func(parentID uint64) []admin.MenuNode + build = func(parentID uint64) []admin.MenuNode { + ids := childrenOf[parentID] + out := make([]admin.MenuNode, 0, len(ids)) + for _, id := range ids { + if !kept[id] { + continue + } + node := admin.MenuNode{Permission: byID[id]} + node.Children = build(id) + out = append(out, node) + } + sort.Slice(out, func(i, j int) bool { return out[i].Sort < out[j].Sort }) + return out + } + return build(0) +} + +func contains(s []string, target string) bool { + for _, v := range s { + if v == target { + return true + } + } + return false +} + +// MemoryCache 进程内权限缓存,适用于无 Redis 的本地 / 测试环境。 +// 多实例部署时各进程不共享,仅供单实例使用;生产可替换为 RedisCache。 +type MemoryCache struct { + mu sync.RWMutex + data map[uint64]memItem + ttl time.Duration + now func() time.Time +} + +type memItem struct { + roles []string + perms []string + expireAt time.Time +} + +// NewMemoryCache 创建内存权限缓存,ttl 为兜底过期时间(0 表示永不过期,仅靠失效)。 +func NewMemoryCache(ttl time.Duration) *MemoryCache { + return &MemoryCache{data: make(map[uint64]memItem), ttl: ttl, now: time.Now} +} + +// Get 读取缓存的权限集。 +func (m *MemoryCache) Get(_ context.Context, adminID uint64) (roles, perms []string, ok bool) { + m.mu.RLock() + e, found := m.data[adminID] + m.mu.RUnlock() + if !found { + return nil, nil, false + } + if m.ttl > 0 && m.now().After(e.expireAt) { + m.mu.Lock() + delete(m.data, adminID) + m.mu.Unlock() + return nil, nil, false + } + return e.roles, e.perms, true +} + +// Set 写入权限集。 +func (m *MemoryCache) Set(_ context.Context, adminID uint64, roles, perms []string) { + m.mu.Lock() + defer m.mu.Unlock() + e := memItem{roles: roles, perms: perms} + if m.ttl > 0 { + e.expireAt = m.now().Add(m.ttl) + } + m.data[adminID] = e +} + +// Invalidate 失效单个管理员缓存。 +func (m *MemoryCache) Invalidate(_ context.Context, adminID uint64) { + m.mu.Lock() + delete(m.data, adminID) + m.mu.Unlock() +} + +// InvalidateAll 清空全部缓存。 +func (m *MemoryCache) InvalidateAll(_ context.Context) { + m.mu.Lock() + m.data = make(map[uint64]memItem) + m.mu.Unlock() +} + +// NewRedisCache 预留:生产环境接入 Redis 时实现此构造, +// 满足 PermissionCache 接口即可在 NewProvider 处替换 MemoryCache。 +// 当前未启用(项目暂未引入 go-redis 依赖),保留接口以明确扩展点。 +func NewRedisCache(_ interface{}) PermissionCache { + // TODO: 接入 go-redis 后实现 Get/Set/Invalidate/InvalidateAll。 + return nil +} diff --git a/internal/service/admin/rbac/provider_test.go b/internal/service/admin/rbac/provider_test.go new file mode 100644 index 0000000..e4da395 --- /dev/null +++ b/internal/service/admin/rbac/provider_test.go @@ -0,0 +1,203 @@ +package rbac + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/errors" + adminrepo "portal-mini-go/internal/repository/admin" +) + +// ---- fakes ---- + +type fakeCache struct{ m map[uint64]memPair } + +type memPair struct{ roles, perms []string } + +func newFakeCache() *fakeCache { return &fakeCache{m: map[uint64]memPair{}} } + +func (f *fakeCache) Get(_ context.Context, id uint64) ([]string, []string, bool) { + p, ok := f.m[id] + return p.roles, p.perms, ok +} +func (f *fakeCache) Set(_ context.Context, id uint64, roles, perms []string) { + f.m[id] = memPair{roles: roles, perms: perms} +} +func (f *fakeCache) Invalidate(_ context.Context, id uint64) { delete(f.m, id) } +func (f *fakeCache) InvalidateAll(_ context.Context) { f.m = map[uint64]memPair{} } + +type fakeUserRepo struct { + roles map[uint64][]string + perms map[uint64][]string + count int64 + created *admin.User + setRoles map[uint64][]uint64 +} + +func (r *fakeUserRepo) GetByUsername(context.Context, string) (*admin.User, error) { + return nil, errors.ErrNotFound +} +func (r *fakeUserRepo) GetByID(context.Context, uint64) (*admin.User, error) { + return nil, errors.ErrNotFound +} +func (r *fakeUserRepo) Create(_ context.Context, u *admin.User) error { r.created = u; return nil } +func (r *fakeUserRepo) Count(context.Context) (int64, error) { return r.count, nil } +func (r *fakeUserRepo) List(context.Context, adminrepo.UserListFilter) ([]admin.User, int64, error) { + return nil, 0, nil +} +func (r *fakeUserRepo) Update(context.Context, uint64, map[string]interface{}) error { return nil } +func (r *fakeUserRepo) Delete(context.Context, uint64) error { return nil } +func (r *fakeUserRepo) SetRoles(_ context.Context, id uint64, roleIDs []uint64) error { + if r.setRoles == nil { + r.setRoles = map[uint64][]uint64{} + } + r.setRoles[id] = roleIDs + return nil +} +func (r *fakeUserRepo) RoleCodesOf(_ context.Context, id uint64) ([]string, error) { + return r.roles[id], nil +} +func (r *fakeUserRepo) PermissionCodesOf(_ context.Context, id uint64) ([]string, error) { + return r.perms[id], nil +} + +type fakeRoleRepo struct { + byCode map[string]*admin.Role + created *admin.Role +} + +func newFakeRoleRepo() *fakeRoleRepo { return &fakeRoleRepo{byCode: map[string]*admin.Role{}} } + +func (r *fakeRoleRepo) GetByID(context.Context, uint64) (*admin.Role, error) { + return nil, errors.ErrNotFound +} +func (r *fakeRoleRepo) GetByCode(_ context.Context, code string) (*admin.Role, error) { + if m, ok := r.byCode[code]; ok { + return m, nil + } + return nil, errors.ErrNotFound +} +func (r *fakeRoleRepo) Create(_ context.Context, m *admin.Role) error { + r.created = m + r.byCode[m.Code] = m + return nil +} +func (r *fakeRoleRepo) List(context.Context, adminrepo.RoleListFilter) ([]admin.Role, int64, error) { + return nil, 0, nil +} +func (r *fakeRoleRepo) Update(context.Context, uint64, map[string]interface{}) error { return nil } +func (r *fakeRoleRepo) Delete(context.Context, uint64) error { return nil } +func (r *fakeRoleRepo) SetPermissions(context.Context, uint64, []uint64) error { return nil } +func (r *fakeRoleRepo) PermissionCodesOf(context.Context, uint64) ([]string, error) { return nil, nil } + +type fakePermRepo struct { + menus []admin.Permission + codes []string +} + +func (r *fakePermRepo) GetByID(context.Context, uint64) (*admin.Permission, error) { + return nil, errors.ErrNotFound +} +func (r *fakePermRepo) Create(context.Context, *admin.Permission) error { return nil } +func (r *fakePermRepo) List(context.Context, adminrepo.PermListFilter) ([]admin.Permission, int64, error) { + return nil, 0, nil +} +func (r *fakePermRepo) Update(context.Context, uint64, map[string]interface{}) error { return nil } +func (r *fakePermRepo) Delete(context.Context, uint64) error { return nil } +func (r *fakePermRepo) AllMenuPermissions(context.Context) ([]admin.Permission, error) { + return r.menus, nil +} +func (r *fakePermRepo) AllCodes(context.Context) ([]string, error) { return r.codes, nil } + +// ---- tests ---- + +func TestProvider_Load_Normal(t *testing.T) { + ur := &fakeUserRepo{ + roles: map[uint64][]string{1: {"editor"}}, + perms: map[uint64][]string{1: {"user:view"}}, + } + pr := &fakePermRepo{codes: []string{"user:view", "user:manage", "mini:view"}} + p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache()) + + roles, perms, err := p.Load(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, []string{"editor"}, roles) + require.Equal(t, []string{"user:view"}, perms) +} + +func TestProvider_Load_SuperAdmin(t *testing.T) { + ur := &fakeUserRepo{ + roles: map[uint64][]string{1: {admin.SuperAdminRoleCode}}, + perms: map[uint64][]string{1: {}}, + } + pr := &fakePermRepo{codes: []string{"user:view", "user:manage"}} + p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache()) + + _, perms, err := p.Load(context.Background(), 1) + require.NoError(t, err) + // 超级管理员短路:权限集为全部权限 code(忽略聚合结果)。 + require.ElementsMatch(t, []string{"user:view", "user:manage"}, perms) +} + +func TestProvider_MenuTree_Normal(t *testing.T) { + menus := []admin.Permission{ + {ID: 1, Code: "dash", Type: admin.PermTypeMenu, ParentID: 0, Sort: 1}, + {ID: 2, Code: "user", Type: admin.PermTypeMenu, ParentID: 1, Sort: 1}, + {ID: 3, Code: "user:view", Type: admin.PermTypeMenu, ParentID: 2, Sort: 1}, + {ID: 4, Code: "sys", Type: admin.PermTypeMenu, ParentID: 0, Sort: 2}, // 无权限且无后代 + } + ur := &fakeUserRepo{ + roles: map[uint64][]string{1: {"editor"}}, + perms: map[uint64][]string{1: {"user:view"}}, + } + pr := &fakePermRepo{menus: menus, codes: nil} + p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache()) + + tree, err := p.MenuTree(context.Background(), 1) + require.NoError(t, err) + // 仅保留 dash -> user -> user:view;sys 被过滤。 + require.Len(t, tree, 1) + require.Equal(t, uint64(1), tree[0].ID) + require.Len(t, tree[0].Children, 1) + require.Equal(t, uint64(2), tree[0].Children[0].ID) + require.Len(t, tree[0].Children[0].Children, 1) + require.Equal(t, uint64(3), tree[0].Children[0].Children[0].ID) +} + +func TestProvider_MenuTree_SuperAdmin(t *testing.T) { + menus := []admin.Permission{ + {ID: 1, Code: "dash", Type: admin.PermTypeMenu, ParentID: 0}, + {ID: 4, Code: "sys", Type: admin.PermTypeMenu, ParentID: 0}, + } + ur := &fakeUserRepo{roles: map[uint64][]string{1: {admin.SuperAdminRoleCode}}} + pr := &fakePermRepo{menus: menus} + p := NewProvider(ur, newFakeRoleRepo(), pr, newFakeCache()) + + tree, err := p.MenuTree(context.Background(), 1) + require.NoError(t, err) + require.Len(t, tree, 2) // 超级管理员返回完整树 +} + +func TestProvider_Seed(t *testing.T) { + ur := &fakeUserRepo{count: 0} + rr := newFakeRoleRepo() + pr := &fakePermRepo{codes: nil} + p := NewProvider(ur, rr, pr, newFakeCache()) + + require.NoError(t, p.Seed(context.Background(), "admin", "Admin@123456")) + require.NotNil(t, rr.created) + require.Equal(t, admin.SuperAdminRoleCode, rr.created.Code) + require.NotNil(t, ur.created) + require.Equal(t, "admin", ur.created.Username) + require.NotEmpty(t, ur.created.PasswordHash) + require.Equal(t, []uint64{rr.created.ID}, ur.setRoles[ur.created.ID]) + + // 已有管理员时不再创建。 + ur2 := &fakeUserRepo{count: 3} + p2 := NewProvider(ur2, newFakeRoleRepo(), pr, newFakeCache()) + require.NoError(t, p2.Seed(context.Background(), "admin", "x")) + require.Nil(t, ur2.created) +} diff --git a/internal/service/admin/rbac/role_service.go b/internal/service/admin/rbac/role_service.go new file mode 100644 index 0000000..76dc5fe --- /dev/null +++ b/internal/service/admin/rbac/role_service.go @@ -0,0 +1,127 @@ +package rbac + +import ( + "context" + + "portal-mini-go/internal/model/admin" + "portal-mini-go/internal/pkg/errors" + adminrepo "portal-mini-go/internal/repository/admin" +) + +// RoleService 角色业务:CRUD 与权限分配。 +type RoleService struct { + repo RoleRepository + invalidator CacheInvalidator +} + +func NewRoleService(repo RoleRepository, invalidator CacheInvalidator) *RoleService { + return &RoleService{repo: repo, invalidator: invalidator} +} + +// CreateRoleReq 新建角色请求。 +type CreateRoleReq struct { + Code string `json:"code" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Status int8 `json:"status"` +} + +// Create 新建角色,code 唯一。 +func (s *RoleService) Create(ctx context.Context, req CreateRoleReq) (*admin.Role, error) { + if req.Status == 0 { + req.Status = admin.UserStatusEnabled + } + if req.Status != admin.UserStatusEnabled && req.Status != admin.UserStatusDisabled { + return nil, errors.New(40000, "非法的角色状态") + } + if _, err := s.repo.GetByCode(ctx, req.Code); err == nil { + return nil, errors.New(40900, "角色 code 已存在") + } else if !errors.Is(err, errors.ErrNotFound) { + return nil, err + } + r := &admin.Role{Code: req.Code, Name: req.Name, Description: req.Description, Status: req.Status} + if err := s.repo.Create(ctx, r); err != nil { + return nil, err + } + return r, nil +} + +// RoleListReq 角色列表过滤条件。 +type RoleListReq struct { + Keyword string + Status int8 + Page int + Size int +} + +// List 分页查询角色。 +func (s *RoleService) List(ctx context.Context, req RoleListReq) ([]admin.Role, int64, error) { + page, size := req.Page, req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + return s.repo.List(ctx, adminrepo.RoleListFilter{ + Keyword: req.Keyword, + Status: req.Status, + Page: page, + Size: size, + }) +} + +// GetByID 按 ID 查询角色。 +func (s *RoleService) GetByID(ctx context.Context, id uint64) (*admin.Role, error) { + return s.repo.GetByID(ctx, id) +} + +// UpdateRoleReq 编辑角色请求;指针区分“未提供”与“置空”。 +type UpdateRoleReq struct { + Name *string `json:"name"` + Description *string `json:"description"` + Status *int8 `json:"status"` +} + +// Update 局部更新角色。 +func (s *RoleService) Update(ctx context.Context, id uint64, req UpdateRoleReq) (*admin.Role, error) { + cols := map[string]interface{}{} + if req.Name != nil { + cols["name"] = *req.Name + } + if req.Description != nil { + cols["description"] = *req.Description + } + if req.Status != nil { + if *req.Status != admin.UserStatusEnabled && *req.Status != admin.UserStatusDisabled { + return nil, errors.New(40000, "非法的角色状态") + } + cols["status"] = *req.Status + } + if len(cols) == 0 { + return s.repo.GetByID(ctx, id) + } + if err := s.repo.Update(ctx, id, cols); err != nil { + return nil, err + } + s.invalidator.InvalidateAll(ctx) + return s.repo.GetByID(ctx, id) +} + +// Delete 删除角色(清理关联并失效全部缓存)。 +func (s *RoleService) Delete(ctx context.Context, id uint64) error { + if err := s.repo.Delete(ctx, id); err != nil { + return err + } + s.invalidator.InvalidateAll(ctx) + return nil +} + +// SetPermissions 为角色分配权限(全量替换)。 +func (s *RoleService) SetPermissions(ctx context.Context, id uint64, permIDs []uint64) error { + if err := s.repo.SetPermissions(ctx, id, permIDs); err != nil { + return err + } + s.invalidator.InvalidateAll(ctx) + return nil +} diff --git a/scripts/001_rbac.sql b/scripts/001_rbac.sql new file mode 100644 index 0000000..ae6726e --- /dev/null +++ b/scripts/001_rbac.sql @@ -0,0 +1,56 @@ +-- 后台 RBAC 权限管理建表脚本(admin_ 前缀,与小程序业务物理隔离)。 +-- 执行顺序:先建表,再建索引(GORM 已含唯一索引定义,此处显式列出便于审阅)。 +-- 种子管理员与 super_admin 角色由服务启动时自动初始化(见 rbac.Provider.Seed)。 + +CREATE TABLE IF NOT EXISTS `admin_users` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(64) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `nickname` VARCHAR(128) DEFAULT '', + `status` TINYINT NOT NULL DEFAULT 1, + `created_at` DATETIME(3) NULL, + `updated_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_users_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `admin_roles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `description` VARCHAR(512) DEFAULT '', + `status` TINYINT NOT NULL DEFAULT 1, + `created_at` DATETIME(3) NULL, + `updated_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_roles_code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `admin_permissions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `code` VARCHAR(128) NOT NULL, + `name` VARCHAR(128) NOT NULL, + `type` VARCHAR(16) NOT NULL DEFAULT 'api', + `method` VARCHAR(16) DEFAULT '', + `path` VARCHAR(255) DEFAULT '', + `parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `sort` INT NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_permissions_code` (`code`), + KEY `idx_admin_permissions_parent` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `admin_user_roles` ( + `admin_id` BIGINT UNSIGNED NOT NULL, + `role_id` BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (`admin_id`, `role_id`), + KEY `idx_admin_user_roles_role` (`role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `admin_role_permissions` ( + `role_id` BIGINT UNSIGNED NOT NULL, + `permission_id` BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (`role_id`, `permission_id`), + KEY `idx_admin_role_permissions_perm` (`permission_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;