From 170c16b2262e9d1a8fc1f2a5245787d0a2bcc6f6 Mon Sep 17 00:00:00 2001
From: luoweijian <1329394916@qq.com>
Date: Tue, 4 Aug 2026 10:47:02 +0800
Subject: [PATCH] =?UTF-8?q?refactor:=20ResourceController=20=E5=AF=B9?=
=?UTF-8?q?=E9=BD=90=E5=85=A8=E5=B1=80=E6=8E=A5=E5=8F=A3=E5=A5=91=E7=BA=A6?=
=?UTF-8?q?=20=E2=80=94=20POST=20+=20=E5=8A=A8=E4=BD=9C=E5=90=8E=E7=BC=80?=
=?UTF-8?q?=20+=20=E8=A1=A8=E5=8D=95=E5=8F=82=E6=95=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- GET /api/resources → GET /api/resources/list
- POST /api/resources + @RequestBody → POST /api/resources/saveOrUpdate + 表单字段
- DELETE /api/resources/{id} → POST /api/resources/delete + @RequestParam
- 前端对接文档同步更新
---
.../auth/controller/ResourceController.java | 11 +-
docs/frontend-integration-menu-permission.md | 617 ++++++++++++++++++
2 files changed, 623 insertions(+), 5 deletions(-)
create mode 100644 docs/frontend-integration-menu-permission.md
diff --git a/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java b/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java
index ca82cd9..ee26018 100644
--- a/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java
+++ b/crm-auth/src/main/java/com/crm/auth/controller/ResourceController.java
@@ -13,6 +13,7 @@ import java.util.List;
/**
* 统一权限资源树管理端接口(ADR-0011)
+ *
遵循全局接口契约:非严格 RESTful,写操作 POST + 动作后缀,表单字段收参
*/
@RestController
@RequestMapping("/api/resources")
@@ -23,20 +24,20 @@ public class ResourceController {
private final IResourceService resourceService;
/** 全量资源树(扁平数组,前端按 parentId 组树) */
- @GetMapping
+ @GetMapping("/list")
public Result> listAll() {
return Result.success(resourceService.listAll());
}
/** 新增或编辑节点(含校验) */
- @PostMapping
- public Result save(@RequestBody ResourceNode node) {
+ @PostMapping("/saveOrUpdate")
+ public Result saveOrUpdate(ResourceNode node) {
return Result.success(resourceService.save(node));
}
/** 删除节点(含子节点检查 + 级联清理) */
- @DeleteMapping("/{id}")
- public Result delete(@PathVariable Long id) {
+ @PostMapping("/delete")
+ public Result delete(@RequestParam Long id) {
resourceService.delete(id);
return Result.success();
}
diff --git a/docs/frontend-integration-menu-permission.md b/docs/frontend-integration-menu-permission.md
new file mode 100644
index 0000000..8276081
--- /dev/null
+++ b/docs/frontend-integration-menu-permission.md
@@ -0,0 +1,617 @@
+# 菜单/权限点管理 — 前端对接文档
+
+> 遵循《业务中台产品接口说明书》全局契约
+> 接口基础路径:`/api/resources`
+> 权限要求:**管理员角色**(`ROLE_ADMIN`)
+
+---
+
+## 1. 接口总览
+
+| 方法 | 路径 | 用途 |
+|------|------|------|
+| `GET` | `/api/resources/list` | 获取全量资源树(扁平数组) |
+| `POST` | `/api/resources/saveOrUpdate` | 新增或编辑节点 |
+| `POST` | `/api/resources/delete` | 删除节点 |
+| `POST` | `/api/resources/icon/upload` | 上传图标 |
+
+**认证方式**:所有接口需在请求头携带 `Authorization: Bearer {token}`,且 token 对应用户必须具有 `ROLE_ADMIN` 角色。
+
+**请求格式**:
+- `GET` 请求:参数放 URL query
+- `POST` 请求:`Content-Type: application/x-www-form-urlencoded`,参数为表单字段
+- 文件上传:`Content-Type: multipart/form-data`
+
+> ⚠️ 注意:POST 请求不使用 JSON body,复杂字段(对象/数组)各自 `JSON.stringify` 后作为普通表单字段传递。
+
+---
+
+## 2. 数据模型:ResourceNode
+
+```typescript
+interface ResourceNode {
+ id: string | null; // 节点 ID(字符串),新增时不传,编辑时必传
+ parentId: string | null; // 父节点 ID(字符串),根节点传 null 或 "0"
+ type: 'CATALOG' | 'MENU' | 'BUTTON'; // 节点类型
+ name: string; // 显示名称(必填)
+ sort: number; // 排序号,值越小越靠前(必填)
+ description?: string; // 描述/备注
+
+ // —— menu 类型专用 ——
+ route?: string; // 前端路由路径,menu 必填
+
+ // —— button 类型专用 ——
+ perms?: string; // 权限码,格式 crm::
+ denyBehavior?: string; // 无权限时表现:'hide' | 'disable'
+ apiUrl?: string; // 按钮对应的后端接口路径
+ status?: string; // 权限点状态:'enabled' | 'disabled'
+
+ // —— catalog/menu 专用 ——
+ icon?: string; // 图标预览 URL
+}
+```
+
+> ⚠️ **ID 一律按字符串收发**。后端返回的 id 是字符串(雪花 ID 超出 JS Number 安全范围),前端回传原样传字符串即可,不要转 Number。
+
+### 节点类型说明
+
+| 类型 | 枚举值 | 说明 | 可挂子节点 |
+|------|--------|------|-----------|
+| 菜单分组 | `CATALOG` | 收纳容器,不承载页面 | 只能挂 `MENU` |
+| 菜单页面 | `MENU` | 可导航的页面 | 只能挂 `BUTTON` |
+| 按钮/权限点 | `BUTTON` | 叶子节点,承载权限码 | 不可挂子节点 |
+
+### 层级约束
+
+```
+根节点(parentId=null/"0")
+ ├── CATALOG(菜单分组)
+ │ └── MENU(菜单页面)
+ │ └── BUTTON(按钮/权限点)
+ └── MENU(菜单页面,一级菜单)
+ └── BUTTON(按钮/权限点)
+```
+
+**规则**:
+- 根节点只能挂 `CATALOG` 或 `MENU`
+- `CATALOG` 下只能挂 `MENU`(不能嵌套分组)
+- `MENU` 下只能挂 `BUTTON`
+- `BUTTON` 是叶子节点,不可再挂子节点
+
+---
+
+## 3. 接口详情
+
+### 3.1 获取全量资源树
+
+```
+GET /api/resources/list
+Authorization: Bearer {token}
+```
+
+**响应示例**:
+
+```json
+{
+ "code": 0,
+ "success": true,
+ "message": "success",
+ "data": [
+ {
+ "id": "1",
+ "parentId": "0",
+ "type": "CATALOG",
+ "name": "系统管理",
+ "sort": 1,
+ "description": null,
+ "icon": "https://cdn.example.com/icons/system.png"
+ },
+ {
+ "id": "10",
+ "parentId": "1",
+ "type": "MENU",
+ "name": "用户管理",
+ "sort": 1,
+ "route": "/system/user",
+ "icon": null
+ },
+ {
+ "id": "100",
+ "parentId": "10",
+ "type": "BUTTON",
+ "name": "查询用户",
+ "sort": 1,
+ "perms": "crm:user:list",
+ "denyBehavior": "hide",
+ "apiUrl": "/api/system/users/page",
+ "status": "enabled",
+ "description": "查询用户列表"
+ }
+ ]
+}
+```
+
+**前端处理**:后端返回扁平数组,前端通过 `parentId` 自行组树。
+
+```typescript
+// 组树示例
+function buildTree(nodes: ResourceNode[]): TreeNode[] {
+ const map = new Map();
+ const roots: TreeNode[] = [];
+
+ nodes.forEach(node => {
+ map.set(node.id!, { ...node, children: [] });
+ });
+
+ nodes.forEach(node => {
+ const treeNode = map.get(node.id!)!;
+ const parentId = node.parentId || "0";
+ if (parentId === "0") {
+ roots.push(treeNode);
+ } else {
+ const parent = map.get(parentId);
+ parent?.children.push(treeNode);
+ }
+ });
+
+ return roots.sort((a, b) => a.sort - b.sort);
+}
+```
+
+---
+
+### 3.2 新增/编辑节点
+
+```
+POST /api/resources/saveOrUpdate
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer {token}
+```
+
+**表单参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| id | string | 编辑时必传 | 不传为新增,传为编辑 |
+| parentId | string | 是 | 父节点 ID,根节点传 `"0"` |
+| type | string | 是 | `CATALOG` / `MENU` / `BUTTON` |
+| name | string | 是 | 显示名称 |
+| sort | number | 是 | 排序号 |
+| description | string | 否 | 描述/备注 |
+| route | string | menu 必填 | 前端路由路径 |
+| perms | string | button 必填 | 权限码 |
+| denyBehavior | string | button 必填 | `hide` / `disable` |
+| apiUrl | string | button 必填 | 后端接口路径 |
+| status | string | button 必填 | `enabled` / `disabled` |
+| icon | string | catalog/menu 可选 | 图标预览 URL |
+
+**请求示例(新增菜单分组)**:
+
+```http
+POST /api/resources/saveOrUpdate
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer eyJhbGciOi...
+
+parentId=0&type=CATALOG&name=业务管理&sort=10&description=CRM核心业务模块
+```
+
+**请求示例(新增按钮/权限点)**:
+
+```http
+POST /api/resources/saveOrUpdate
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer eyJhbGciOi...
+
+parentId=1002&type=BUTTON&name=删除客户&sort=3&perms=crm:customer:delete&denyBehavior=disable&apiUrl=/api/crm/customer/delete&status=enabled&description=删除客户及其关联数据
+```
+
+**请求示例(编辑节点 — 改名)**:
+
+```http
+POST /api/resources/saveOrUpdate
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer eyJhbGciOi...
+
+id=10&parentId=1&type=MENU&name=客户管理改&sort=1&route=/customer/list
+```
+
+**响应**:返回保存后的完整节点。
+
+```json
+{
+ "code": 0,
+ "success": true,
+ "message": "success",
+ "data": {
+ "id": "11",
+ "parentId": "1",
+ "type": "MENU",
+ "name": "客户管理",
+ "sort": 2,
+ "route": "/customer/list"
+ }
+}
+```
+
+**字段必填规则**(按类型):
+
+| 字段 | CATALOG | MENU | BUTTON |
+|------|---------|------|--------|
+| name | ✅ 必填 | ✅ 必填 | ✅ 必填 |
+| sort | ✅ 必填 | ✅ 必填 | ✅ 必填 |
+| route | ❌ | ✅ 必填 | ❌ |
+| perms | ❌ | ❌ | ✅ 必填 |
+| denyBehavior | ❌ | ❌ | ✅ 必填 |
+| apiUrl | ❌ | ❌ | ✅ 必填 |
+| status | ❌ | ❌ | ✅ 必填 |
+| icon | 可选 | 可选 | ❌ |
+| description | 可选 | 可选 | 可选 |
+
+**编辑限制**:
+- `type` 字段创建后不可变更(编辑时传不同的 type 会被拒绝)
+- 编辑时 `route` 可不传,后端保留原有值(除非传新值覆盖)
+
+---
+
+### 3.3 删除节点
+
+```
+POST /api/resources/delete
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer {token}
+```
+
+**表单参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| id | string | 是 | 要删除的节点 ID |
+
+**请求示例**:
+
+```http
+POST /api/resources/delete
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer eyJhbGciOi...
+
+id=100
+```
+
+**校验规则**:
+- 节点必须存在
+- 节点下不能有子节点(有子节点会返回错误,需先删除子节点)
+- 删除时自动清理各角色对该节点的授权引用
+
+**成功响应**:
+
+```json
+{
+ "code": 0,
+ "success": true,
+ "message": "success"
+}
+```
+
+**失败响应示例**(有子节点):
+
+```json
+{
+ "code": 61009,
+ "success": false,
+ "message": "节点下有 3 个子节点,请先删除子节点"
+}
+```
+
+---
+
+### 3.4 上传图标
+
+```
+POST /api/resources/icon/upload
+Content-Type: multipart/form-data
+Authorization: Bearer {token}
+```
+
+**参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| file | File | ✅ | 图片文件 |
+
+**限制**:
+- 格式:`png` / `jpg` / `jpeg` / `gif` / `svg` / `webp`
+- 大小:≤ 500KB
+
+**请求示例**:
+
+```http
+POST /api/resources/icon/upload
+Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
+Authorization: Bearer eyJhbGciOi...
+
+------WebKitFormBoundary
+Content-Disposition: form-data; name="file"; filename="icon.png"
+Content-Type: image/png
+
+(二进制内容)
+------WebKitFormBoundary--
+```
+
+**响应**:
+
+```json
+{
+ "code": 0,
+ "success": true,
+ "message": "success",
+ "data": {
+ "fileId": "1234567890",
+ "originalName": "icon.png",
+ "size": 12345,
+ "contentType": "image/png",
+ "bizDomain": "resources/icon"
+ }
+}
+```
+
+**前端处理流程**:
+
+```typescript
+async function uploadIcon(file: File): Promise {
+ // 1. 上传图标,获取 fileId
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const uploadRes = await fetch('/api/resources/icon/upload', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ body: formData, // multipart/form-data 由浏览器自动设置
+ });
+ const { data: fileInfo } = await uploadRes.json();
+
+ // 2. 通过 fileId 获取预览 URL
+ const previewRes = await fetch(`/api/file/preview-url?fileId=${fileInfo.fileId}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const { data: previewUrl } = await previewRes.json();
+
+ // 3. 返回预览 URL,存入节点的 icon 字段
+ return previewUrl;
+}
+
+// 使用示例
+const iconUrl = await uploadIcon(selectedFile);
+// 然后在保存节点时将 iconUrl 作为 icon 表单字段传入
+await saveNode({ ...node, icon: iconUrl });
+```
+
+---
+
+## 4. 停用与启用权限点
+
+停用/启用权限点**没有单独的接口**,通过编辑节点(`POST /api/resources/saveOrUpdate`)更新 `status` 字段实现。
+
+### 操作方式
+
+```http
+POST /api/resources/saveOrUpdate
+Content-Type: application/x-www-form-urlencoded
+Authorization: Bearer eyJhbGciOi...
+
+id=1003&parentId=1002&type=BUTTON&name=删除客户&sort=3&perms=crm:customer:delete&denyBehavior=disable&apiUrl=/api/crm/customer/delete&status=disabled
+```
+
+| 操作 | status 值 | 说明 |
+|------|-----------|------|
+| 停用 | `disabled` | 权限点全局失效,所有用户均无法使用该权限 |
+| 启用 | `enabled` | 恢复权限点生效,原角色授权自动恢复 |
+
+### 业务语义
+
+- **停用**:即使角色已勾选授予该权限点,权限解析引擎也不将其 `perms` 纳入用户的权限码并集——相当于**全局断路**,无需逐角色取消授权
+- **启用**:重新启用后,原授权**自动恢复生效**,无需管理员重新到角色授权中勾选
+- **数据影响**:停用/启用只修改 `status` 字段,不涉及角色授权关系(`sys_role_menu`)的变动
+
+### 前端处理建议
+
+```typescript
+// 权限点状态切换
+async function togglePermissionStatus(nodeId: string, enabled: boolean) {
+ const node = await getNode(nodeId);
+ const params = new URLSearchParams();
+ params.append('id', node.id);
+ params.append('parentId', node.parentId);
+ params.append('type', node.type);
+ params.append('name', node.name);
+ params.append('sort', node.sort);
+ params.append('perms', node.perms);
+ params.append('denyBehavior', node.denyBehavior);
+ params.append('apiUrl', node.apiUrl);
+ params.append('status', enabled ? 'enabled' : 'disabled');
+
+ await fetch('/api/resources/saveOrUpdate', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: params,
+ });
+}
+
+// 在 UI 上显示停用状态
+ togglePermissionStatus(node.id, checked)}
+/>
+```
+
+---
+
+## 5. 错误处理
+
+所有错误响应格式统一:
+
+```json
+{
+ "code": 61009,
+ "success": false,
+ "message": "具体错误信息"
+}
+```
+
+**判定规则**:判成功只看 `success === true`;判失败后的分支处理(跳登录、回填表单等)再看 `code`。
+
+**HTTP 状态码**:业务失败 HTTP 状态码仍是 200,通过 `success`/`code` 判断。仅以下场景 HTTP 状态码非 200:
+
+| HTTP | 场景 | 响应体 code |
+|------|------|-------------|
+| 401 | 未登录 / 登录过期 | 40103 |
+| 403 | 已登录但无权限 | 40301 |
+| 404 | 接口路径不存在 | 40401 |
+| 500 | 系统内部异常 | 50001 |
+
+即使 HTTP 状态码非 200,响应体仍然是统一信封结构。
+
+**资源管理接口常见错误**:
+
+| 消息 | 原因 |
+|------|------|
+| `节点名称不能为空` | name 字段为空 |
+| `节点类型不能为空` | type 字段为空 |
+| `menu 类型必须提供路由(route)` | MENU 类型缺少 route |
+| `button 类型必须提供权限码(perms)` | BUTTON 类型缺少 perms |
+| `button 类型必须提供无权限行为(denyBehavior)` | BUTTON 类型缺少 denyBehavior |
+| `button 类型必须提供接口 URL(apiUrl)` | BUTTON 类型缺少 apiUrl |
+| `button 类型必须提供状态(status)` | BUTTON 类型缺少 status |
+| `根节点不允许添加 button 类型` | 违反层级约束 |
+| `catalog 节点下只能添加 menu 类型` | 违反层级约束 |
+| `menu 节点下只能添加 button 类型` | 违反层级约束 |
+| `button 是叶子节点,不可添加子节点` | 违反层级约束 |
+| `节点类型不可变更` | 编辑时修改了 type |
+| `节点下有 N 个子节点,请先删除子节点` | 删除非叶子节点 |
+| `不支持的图标格式` | 上传非法格式 |
+| `图标大小不能超过 500KB` | 文件过大 |
+
+---
+
+## 6. 联调指南
+
+### 6.1 前置条件
+
+1. 确保后端服务已启动(Base URL 由后端另行下发)
+2. 使用管理员账号通过钉钉扫码登录获取 token
+3. 请求头统一添加 `Authorization: Bearer {token}`
+4. token 由后端滑动续期,前端无需定时刷新;收到 401(code=40103)时清除 token 跳登录页
+
+### 6.2 快速验证
+
+```bash
+# 1. 获取全量资源树
+curl -X GET "{baseUrl}/api/resources/list" \
+ -H "Authorization: Bearer YOUR_TOKEN"
+
+# 2. 新增一个菜单分组
+curl -X POST "{baseUrl}/api/resources/saveOrUpdate" \
+ -H "Authorization: Bearer YOUR_TOKEN" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "parentId=0&type=CATALOG&name=测试分组&sort=99"
+
+# 3. 在分组下新增菜单
+curl -X POST "{baseUrl}/api/resources/saveOrUpdate" \
+ -H "Authorization: Bearer YOUR_TOKEN" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "parentId={上一步返回的id}&type=MENU&name=测试菜单&sort=1&route=/test/menu"
+
+# 4. 在菜单下新增按钮
+curl -X POST "{baseUrl}/api/resources/saveOrUpdate" \
+ -H "Authorization: Bearer YOUR_TOKEN" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "parentId={菜单id}&type=BUTTON&name=测试按钮&sort=1&perms=crm:test:action&denyBehavior=hide&apiUrl=/api/test/action&status=enabled"
+
+# 5. 删除节点
+curl -X POST "{baseUrl}/api/resources/delete" \
+ -H "Authorization: Bearer YOUR_TOKEN" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "id={节点id}"
+```
+
+### 6.3 前端开发建议
+
+1. **树形组件**:推荐使用 `parentId` 在前端组树,后端返回扁平数组性能更好
+2. **表单提交**:使用 `URLSearchParams` 或 `qs` 库构造表单字段,不要用 `JSON.stringify` 整体打包
+3. **ID 处理**:所有 id 字段按字符串处理,不要 `parseInt` / `Number()`
+4. **表单校验**:根据节点类型动态显示必填字段
+ - CATALOG:name, sort
+ - MENU:name, sort, route
+ - BUTTON:name, sort, perms, denyBehavior, apiUrl, status
+5. **类型锁定**:编辑时 type 字段应置灰不可修改
+6. **图标上传**:先上传获取 URL,再保存节点
+7. **权限码格式**:建议提供下拉选择或自动补全,格式为 `crm::`
+ - module: `lead`, `opportunity`, `customer`, `project`
+ - action: `add`, `edit`, `delete`, `export`, `transfer` 等
+
+### 6.4 调试技巧
+
+- 每个响应都带响应头 `X-Trace-Id`,报障时附上此 ID
+- 使用浏览器开发者工具 Network 面板查看请求/响应
+- 后端日志会打印保存的节点信息,便于排查
+- 403 错误检查 token 是否过期、用户是否有 ADMIN 角色
+- 61009 错误查看 message 字段获取具体原因
+
+---
+
+## 7. 权限码并集说明
+
+用户登录后,后端会自动计算其**权限码并集**(所有角色授权的启用状态按钮的 perms),注入到 Spring Security 上下文中。
+
+**前端使用**:
+- 登录后从 `GET /api/auth/me` 获取用户信息及 `authorities` 列表
+- 根据 `authorities` 控制按钮显示/隐藏
+
+```typescript
+// 示例:检查是否有某权限
+const hasPermission = (perm: string) => {
+ return userAuthorities.includes(perm);
+};
+
+// 使用
+{hasPermission('crm:customer:delete') && }
+```
+
+**停用权限点**:
+- 当按钮的 `status` 设为 `disabled` 时,即使角色已授权,该权限码也不会出现在用户权限并集中
+- 相当于全局断路,无需逐角色取消授权
+- 重新启用后原授权自动恢复
+
+---
+
+## 8. 与角色授权的集成
+
+资源树维护完成后,在**角色管理 → 授权**页面使用:
+
+1. 调用 `GET /api/resources/list` 获取全量资源树
+2. 渲染为勾选树(checkbox tree)
+3. 勾选节点即授予该角色对应权限
+4. 勾选菜单节点时,前端自动联动勾选其全部祖先节点(确保菜单可见)
+
+```typescript
+// 联动勾选祖先
+function collectAncestorIds(nodeId: string, tree: TreeNode[]): string[] {
+ const ancestors: string[] = [];
+ const findPath = (nodes: TreeNode[], target: string, path: string[]): boolean => {
+ for (const node of nodes) {
+ if (node.id === target) {
+ ancestors.push(...path);
+ return true;
+ }
+ if (findPath(node.children, target, [...path, node.id!])) {
+ return true;
+ }
+ }
+ return false;
+ };
+ findPath(tree, nodeId, []);
+ return ancestors;
+}
+```