rbac
This commit is contained in:
@@ -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": "<JWT>" } }
|
||||
```
|
||||
|
||||
**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 <JWT>`
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{ "code": 0, "msg": "ok", "data": { "admin_id": "1" } }
|
||||
```
|
||||
|
||||
## 2. 备注
|
||||
|
||||
- **后台账号**:`POST /api/admin/login` 当前为固定示例账号,生产环境需接入数据库与密码哈希校验。
|
||||
@@ -0,0 +1,135 @@
|
||||
# 后台管理 · 小程序管理模块
|
||||
|
||||
> 本文件描述 `/api/admin` 下的**被纳管小程序**相关接口(增删改查)。公共约定(请求头、统一响应、鉴权中间件、错误码、配置)见 [admin-api.md](./admin-api.md)。
|
||||
|
||||
## 1. 接口列表
|
||||
|
||||
所有接口均需在请求头携带 `Authorization: Bearer <JWT>`(由 `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 <JWT>"
|
||||
```
|
||||
|
||||
### 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` 鉴权的数据来源。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 后台管理 · 用户管理模块
|
||||
|
||||
> 本文件描述 `/api/admin` 下的**小程序端用户**相关接口(列表、详情、编辑、禁用/启用)。公共约定(请求头、统一响应、鉴权中间件、错误码、配置)见 [admin-api.md](./admin-api.md)。
|
||||
|
||||
## 1. 接口列表
|
||||
|
||||
所有接口均需在请求头携带 `Authorization: Bearer <JWT>`(由 `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 <JWT>"
|
||||
```
|
||||
|
||||
### 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;
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user