Bladeren bron

重构为 ir-backend:新增认证、RBAC 权限、多数据源模块

ligb 3 weken geleden
bovenliggende
commit
7f6b91482e
42 gewijzigde bestanden met toevoegingen van 2872 en 11 verwijderingen
  1. 1 0
      .gitignore
  2. 381 0
      API.md
  3. 146 0
      SCHEMA.md
  4. 36 3
      pom.xml
  5. 4 4
      src/main/java/com/nylc/touyan/IrApplication.java
  6. 59 0
      src/main/java/com/nylc/touyan/auth/AuthAutoConfiguration.java
  7. 34 0
      src/main/java/com/nylc/touyan/auth/AuthContext.java
  8. 67 0
      src/main/java/com/nylc/touyan/auth/AuthController.java
  9. 21 0
      src/main/java/com/nylc/touyan/auth/AuthException.java
  10. 61 0
      src/main/java/com/nylc/touyan/auth/AuthFilter.java
  11. 38 0
      src/main/java/com/nylc/touyan/auth/AuthInterceptor.java
  12. 70 0
      src/main/java/com/nylc/touyan/auth/AuthProperties.java
  13. 50 0
      src/main/java/com/nylc/touyan/auth/AuthUser.java
  14. 99 0
      src/main/java/com/nylc/touyan/auth/JwtUtil.java
  15. 16 0
      src/main/java/com/nylc/touyan/auth/LoginRequest.java
  16. 20 0
      src/main/java/com/nylc/touyan/auth/RegisterRequest.java
  17. 17 0
      src/main/java/com/nylc/touyan/auth/RequireAuth.java
  18. 110 0
      src/main/java/com/nylc/touyan/auth/UserService.java
  19. 51 0
      src/main/java/com/nylc/touyan/common/GlobalExceptionHandler.java
  20. 1 1
      src/main/java/com/nylc/touyan/config/CorsConfig.java
  21. 1 1
      src/main/java/com/nylc/touyan/controller/HelloController.java
  22. 39 0
      src/main/java/com/nylc/touyan/datasource/DataSourceAutoConfiguration.java
  23. 41 0
      src/main/java/com/nylc/touyan/datasource/DataSourceClient.java
  24. 64 0
      src/main/java/com/nylc/touyan/datasource/DataSourceController.java
  25. 15 0
      src/main/java/com/nylc/touyan/datasource/DataSourceException.java
  26. 47 0
      src/main/java/com/nylc/touyan/datasource/DataSourceFactory.java
  27. 68 0
      src/main/java/com/nylc/touyan/datasource/DataSourceProperties.java
  28. 14 0
      src/main/java/com/nylc/touyan/datasource/DataSourceType.java
  29. 190 0
      src/main/java/com/nylc/touyan/datasource/SqliteDataSourceClient.java
  30. 91 0
      src/main/java/com/nylc/touyan/menu/MenuController.java
  31. 46 0
      src/main/java/com/nylc/touyan/menu/MenuRequest.java
  32. 298 0
      src/main/java/com/nylc/touyan/menu/MenuService.java
  33. 14 0
      src/main/java/com/nylc/touyan/rbac/IdListRequest.java
  34. 20 0
      src/main/java/com/nylc/touyan/rbac/PermissionRequest.java
  35. 45 0
      src/main/java/com/nylc/touyan/rbac/RbacAutoConfiguration.java
  36. 133 0
      src/main/java/com/nylc/touyan/rbac/RbacController.java
  37. 62 0
      src/main/java/com/nylc/touyan/rbac/RbacInterceptor.java
  38. 310 0
      src/main/java/com/nylc/touyan/rbac/RbacService.java
  39. 19 0
      src/main/java/com/nylc/touyan/rbac/RequirePermission.java
  40. 18 0
      src/main/java/com/nylc/touyan/rbac/RequireRole.java
  41. 20 0
      src/main/java/com/nylc/touyan/rbac/RoleRequest.java
  42. 35 2
      src/main/resources/application.yml

+ 1 - 0
.gitignore

@@ -3,3 +3,4 @@ target
 .idea
 *.iml
 .vscode
+data/

+ 381 - 0
API.md

@@ -0,0 +1,381 @@
+# 后端 API 文档
+
+微前端平台后端服务(Spring Boot 2.7 / Java 8 / SQLite)。本文档列出当前全部 HTTP 接口、鉴权要求与示例。
+
+- Base URL:`http://<host>:8080`
+- Content-Type:`application/json`
+- 统一错误响应格式:
+
+```json
+{ "code": "ERROR_CODE", "message": "错误描述" }
+```
+
+---
+
+## 鉴权说明
+
+| 级别 | 说明 |
+|---|---|
+| 免鉴权 | 无需 Token,见各接口标注"免鉴权" |
+| 登录态 | 请求头携带 `Authorization: Bearer <token>` 即可 |
+| 角色 | 需 `@RequireRole`,如 `ADMIN` |
+| 权限点 | 需 `@RequirePermission`,如 `user:list` |
+
+角色/权限随 JWT 下发,**修改角色或权限后需重新登录** 获取新 Token 生效。
+
+默认管理员:`admin / admin123`(配置于 `application.yml` 的 `mf.auth.admin`)。
+
+---
+
+## 1. 基础
+
+### GET /api/hello
+免鉴权。连通性验证。
+
+**响应 200**
+```json
+{ "message": "Hello from backend", "serverTime": "2026-09-22T14:00:00" }
+```
+
+---
+
+## 2. 数据源
+
+### GET /api/datasource/info
+免鉴权。当前数据源信息与健康检查。
+
+**响应 200**
+```json
+{ "type": "sqlite", "ping": true, "serverNow": "2026-09-22 14:00:00" }
+```
+
+### GET /api/datasource/users
+登录态。查询演示用户列表(表 `demo_user`)。
+
+**响应 200**
+```json
+[
+  { "id": 1, "name": "Alice", "created_at": "2026-09-22 14:00:00" }
+]
+```
+
+### POST /api/datasource/users
+登录态。新增演示用户。
+
+**请求体**
+```json
+{ "name": "Alice" }
+```
+
+**响应 200**
+```json
+{ "affected": 1, "name": "Alice" }
+```
+
+### POST /api/datasource/users/batch
+登录态。批量新增演示用户。
+
+**查询参数**:`prefix`(String,前缀)、`count`(int,数量)
+
+**响应 200**
+```json
+{ "affected": 100 }
+```
+
+---
+
+## 3. 认证 Auth
+
+### POST /api/auth/register
+免鉴权。注册,默认分配 `USER` 角色。
+
+**请求体**
+```json
+{ "username": "alice", "password": "123456", "nickname": "爱丽丝" }
+```
+
+**响应 200**(失败:`USER_EXISTS` 用户名已存在 / `BAD_REQUEST` 参数不合法)
+```json
+{
+  "token": "eyJhbGciOiJIUzI1NiJ9...",
+  "tokenType": "Bearer",
+  "expiresIn": 7200,
+  "id": 2,
+  "username": "alice",
+  "nickname": "爱丽丝",
+  "roles": ["USER"],
+  "permissions": []
+}
+```
+
+### POST /api/auth/login
+免鉴权。登录。
+
+**请求体**
+```json
+{ "username": "admin", "password": "admin123" }
+```
+
+**响应 200**(失败:`BAD_CREDENTIALS` 账号或密码错误 / `USER_DISABLED` 账号禁用)
+```json
+{
+  "token": "eyJhbGciOiJIUzI1NiJ9...",
+  "tokenType": "Bearer",
+  "expiresIn": 7200,
+  "id": 1,
+  "username": "admin",
+  "nickname": "管理员",
+  "roles": ["ADMIN"],
+  "permissions": ["user:list", "user:create", "role:assign", "..."]
+}
+```
+
+### GET /api/auth/me
+登录态(`@RequireAuth`)。返回当前登录用户信息。
+
+**响应 200**(未登录:401 `{"code":"UNAUTHORIZED","message":"请先登录"}`)
+```json
+{ "id": 1, "username": "admin", "nickname": "管理员", "roles": ["ADMIN"], "permissions": ["..."] }
+```
+
+---
+
+## 4. RBAC 用户权限管理
+
+> 以下接口均需 `ADMIN` 角色;权限收回时返回 403 `{"code":"FORBIDDEN","message":"无角色权限"}` 或 `无操作权限`。
+
+### 4.1 角色
+
+#### GET /api/rbac/roles
+权限点:`role:list`。角色列表(含用户数)。
+
+**响应 200**
+```json
+[
+  { "id": 1, "code": "ADMIN", "name": "超级管理员", "description": null, "created_at": "...", "user_count": 1 }
+]
+```
+
+#### POST /api/rbac/roles
+权限点:`role:create`。创建角色。
+
+**请求体**
+```json
+{ "code": "EDITOR", "name": "编辑", "description": "内容编辑" }
+```
+
+**响应 200**(`ROLE_EXISTS`:编码已存在)
+
+#### PUT /api/rbac/roles/{id}
+权限点:`role:update`。更新角色名称/描述。
+
+**请求体**
+```json
+{ "name": "资深编辑", "description": "..." }
+```
+
+**响应 200**(`NOT_FOUND`:角色不存在)
+
+#### DELETE /api/rbac/roles/{id}
+权限点:`role:delete`。删除角色(级联清理关联关系)。
+
+**响应 200** `{ "deleted": 3 }`
+
+#### GET /api/rbac/roles/{id}/permissions
+权限点:`role:assign`。查询角色拥有的权限列表。
+
+**响应 200**
+```json
+{
+  "role": { "id": 1, "code": "ADMIN", "name": "超级管理员" },
+  "permissions": [ { "id": 1, "code": "user:list", "name": "user:list" } ]
+}
+```
+
+#### POST /api/rbac/roles/{id}/permissions
+权限点:`role:assign`。**覆盖式**设置角色拥有的权限。
+
+**请求体**
+```json
+{ "ids": [1, 2, 3] }
+```
+
+**响应 200**:同 GET /api/rbac/roles/{id}/permissions
+
+### 4.2 权限
+
+#### GET /api/rbac/permissions
+权限点:`permission:list`。权限点列表。
+
+**响应 200**
+```json
+[
+  { "id": 1, "code": "user:list", "name": "user:list", "description": null, "created_at": "..." }
+]
+```
+
+#### POST /api/rbac/permissions
+权限点:`permission:create`。新增权限点。
+
+**请求体**
+```json
+{ "code": "report:export", "name": "导出报表", "description": "..." }
+```
+
+**响应 200**(`PERMISSION_EXISTS`:编码已存在)
+
+#### DELETE /api/rbac/permissions/{id}
+权限点:`permission:delete`。删除权限点(级联清理关联关系)。
+
+**响应 200** `{ "deleted": 5 }`
+
+### 4.3 用户授权
+
+#### POST /api/rbac/users/{id}/roles
+权限点:`role:assign`。**覆盖式**设置用户拥有的角色。
+
+**请求体**
+```json
+{ "ids": [1, 2] }
+```
+
+**响应 200**:同 GET /api/rbac/users/{id}/auth
+
+#### GET /api/rbac/users/{id}/auth
+权限点:`role:assign`。查询用户的角色与权限。
+
+**响应 200**
+```json
+{
+  "userId": 2,
+  "roles": [ { "id": 2, "code": "USER", "name": "普通用户" } ],
+  "permissions": [ { "id": 6, "code": "data:query", "name": "data:query" } ]
+}
+```
+
+### 4.4 当前用户
+
+#### GET /api/rbac/me
+登录态。当前登录用户的角色与权限(同 /api/auth/me 的 roles/permissions,从库中实时查)。
+
+**响应 200**
+```json
+{
+  "userId": 1,
+  "roles": [ { "id": 1, "code": "ADMIN", "name": "超级管理员" } ],
+  "permissions": [ "..." ]
+}
+```
+
+---
+
+## 5. 菜单权限 Menu
+
+> 独立菜单权限体系,**与接口权限(sys_permission)完全解耦**:菜单可见性只由“用户的角色 → 角色被授予的菜单”决定(`sys_role_menu`),不依赖任何权限点编码。
+>
+> 因此管理端接口仅校验 `ADMIN` 角色(**不使用** `@RequirePermission`);`/current` 实时查库返回,**修改菜单授权后无需重新登录**即可生效(这与角色/权限需重新登录的机制不同)。
+>
+> 用户可见菜单树 = 其所有角色被授予菜单的并集,下发时自动**向上补全父级**、**剔除空目录**。
+
+### GET /api/menu/current
+登录态(`@RequireAuth`)。当前用户可见菜单树。
+
+**响应 200**(未登录:401)
+```json
+[
+  {
+    "id": 1, "parent_id": null, "name": "工作台", "type": "menu",
+    "route": "/dashboard", "component": "dashboard/index", "icon": "HomeFilled",
+    "sort": 1, "visible": 1, "status": 1, "created_at": "...", "children": []
+  },
+  {
+    "id": 2, "parent_id": null, "name": "系统管理", "type": "dir",
+    "route": null, "component": null, "icon": "Setting",
+    "sort": 1, "visible": 1, "status": 1, "created_at": "...",
+    "children": [
+      {
+        "id": 3, "parent_id": 2, "name": "用户管理", "type": "menu",
+        "route": "/system/user", "component": "system/user/index", "icon": "User",
+        "sort": 1, "visible": 1, "status": 1, "created_at": "...", "children": []
+      }
+    ]
+  }
+]
+```
+
+### GET /api/menu/tree
+角色:`ADMIN`。全量菜单树(管理端,含停用/隐藏项)。
+
+**响应 200**:同上树形结构,返回全部节点。
+
+### POST /api/menu
+角色:`ADMIN`。新建菜单(**自动授权给 ADMIN 角色**)。
+
+**请求体**
+```json
+{
+  "parentId": null,
+  "name": "报表中心",
+  "type": "menu",
+  "route": "/report",
+  "component": "report/index",
+  "icon": "DataAnalysis",
+  "sort": 3,
+  "visible": 1,
+  "status": 1
+}
+```
+
+**响应 200**:新建后的菜单记录(`NOT_FOUND` 不存在 / `PARAM` 名称缺失)。
+
+### PUT /api/menu/{id}
+角色:`ADMIN`。更新菜单(缺失字段沿用原值)。
+
+**请求体**:同 POST(可只传需改字段)。
+
+**响应 200**:更新后的菜单记录(`NOT_FOUND`:菜单不存在)。
+
+### DELETE /api/menu/{id}
+角色:`ADMIN`。删除菜单,**级联删除其全部子菜单**并清理 `sys_role_menu` 关联。
+
+**响应 200** `{ "deleted": 6 }`
+
+### GET /api/menu/role/{roleId}
+角色:`ADMIN`。查询某角色被授予的菜单 id 列表。
+
+**响应 200**
+```json
+[ 1, 3, 7 ]
+```
+
+### POST /api/menu/role/{roleId}
+角色:`ADMIN`。**覆盖式**设置某角色拥有的菜单。
+
+**请求体**
+```json
+{ "ids": [1, 3, 7] }
+```
+
+**响应 200**
+```json
+{ "roleId": 3, "menuIds": [1, 3, 7] }
+```
+
+---
+
+## 6. 常见错误码
+
+| code | HTTP | 说明 |
+|---|---|---|
+| `TOKEN_INVALID` | 200 | Token 无效或已过期 |
+| `UNAUTHORIZED` | 401 | 未登录(见 /api/auth/me、拦截器) |
+| `FORBIDDEN` | 403 | 无角色/权限 |
+| `BAD_CREDENTIALS` | 200 | 用户名或密码错误 |
+| `USER_EXISTS` | 200 | 用户名已被注册 |
+| `USER_DISABLED` | 200 | 账号已禁用 |
+| `USER_NOT_FOUND` | 200 | 用户不存在 |
+| `ROLE_EXISTS` / `PERMISSION_EXISTS` | 200 | 编码重复 |
+| `NOT_FOUND` | 200 | 资源不存在 |
+| `BAD_REQUEST` | 200 | 请求参数不合法 |
+| `DB_ERROR` | 500 | 数据源异常 |
+| `INTERNAL_ERROR` | 500 | 服务器内部错误 |

+ 146 - 0
SCHEMA.md

@@ -0,0 +1,146 @@
+# 数据库表结构
+
+投研平台后端当前使用 SQLite(`data/ir.db`)。表结构在启动时通过代码自动创建:
+
+- 数据源客户端:`SqliteDataSourceClient#initDemoTable` → `demo_user`
+- 用户表:`UserService#initSchema` → `sys_user`
+- RBAC 表:`RbacService#init` → `sys_role` / `sys_permission` / `sys_user_role` / `sys_role_permission`
+- 菜单表:`MenuService#init` → `sys_menu` / `sys_role_menu`(独立菜单权限体系,与接口权限解耦)
+
+所有 DDL 均为 `CREATE TABLE IF NOT EXISTS`(幂等,重复启动不会重建已存在的表)。
+
+---
+
+## 1. demo_user — 演示用户表
+
+> 由 `init-demo-table` 配置控制是否创建,仅供数据源演示接口使用。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键,自增 |
+| name | TEXT | NOT NULL | 姓名 |
+| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
+
+---
+
+## 2. sys_user — 系统用户表
+
+> 由 `UserService#initSchema` 创建;`RbacService` 在启动时校验并创建内置管理员。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键,自增 |
+| username | TEXT | NOT NULL UNIQUE | 登录名(唯一) |
+| password | TEXT | NOT NULL | BCrypt 加密后的密码 |
+| nickname | TEXT | — | 昵称 |
+| enabled | INTEGER | NOT NULL DEFAULT 1 | 是否启用(0=禁用,1=启用) |
+| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
+
+---
+
+## 3. sys_role — 角色表
+
+> 内置种子角色:`ADMIN`(超级管理员)、`USER`(普通用户)。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键,自增 |
+| code | TEXT | NOT NULL UNIQUE | 角色编码(唯一) |
+| name | TEXT | NOT NULL | 角色名称 |
+| description | TEXT | — | 描述 |
+| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
+
+---
+
+## 4. sys_permission — 权限表
+
+> 内置种子权限:`user:list/create/update/delete`、`role:list/create/update/delete/assign`、`permission:list/create/delete`、`data:query/write`。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键,自增 |
+| code | TEXT | NOT NULL UNIQUE | 权限编码(唯一) |
+| name | TEXT | NOT NULL | 权限名称 |
+| description | TEXT | — | 描述 |
+| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
+
+---
+
+## 5. sys_user_role — 用户-角色关联表
+
+> 多对多,复合主键;删除用户/角色时对应关联一并清理。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| user_id | INTEGER | NOT NULL,PRIMARY KEY(user_id, role_id) | 用户 ID → `sys_user.id` |
+| role_id | INTEGER | NOT NULL,PRIMARY KEY(user_id, role_id) | 角色 ID → `sys_role.id` |
+
+---
+
+## 6. sys_role_permission — 角色-权限关联表
+
+> 多对多,复合主键;删除角色/权限时对应关联一并清理。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| role_id | INTEGER | NOT NULL,PRIMARY KEY(role_id, permission_id) | 角色 ID → `sys_role.id` |
+| permission_id | INTEGER | NOT NULL,PRIMARY KEY(role_id, permission_id) | 权限 ID → `sys_permission.id` |
+
+---
+
+## 7. sys_menu — 菜单表(独立菜单权限体系)
+
+> 由 `MenuService#init` 创建。菜单与接口权限(`sys_permission`)**完全解耦**:菜单自身是一棵树,通过 `sys_role_menu` 直接授予角色,不读取任何接口权限编码。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 主键,自增 |
+| parent_id | INTEGER | — | 父菜单 id → `sys_menu.id`;NULL = 顶级 |
+| name | TEXT | NOT NULL | 菜单名称 |
+| type | TEXT | NOT NULL DEFAULT 'menu' | 类型:`dir`=目录 / `menu`=菜单 |
+| route | TEXT | — | 前端路由 path |
+| component | TEXT | — | 前端组件路径 |
+| icon | TEXT | — | 菜单图标 |
+| sort | INTEGER | DEFAULT 0 | 排序号(升序) |
+| visible | INTEGER | DEFAULT 1 | 是否可见(0=隐藏,不参与用户菜单下发) |
+| status | INTEGER | DEFAULT 1 | 是否启用(0=停用) |
+| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
+
+## 8. sys_role_menu — 角色-菜单关联表
+
+> 多对多,复合主键;`RbacService#deleteRole` 与 `MenuService#deleteMenu` 会同步清理关联。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| role_id | INTEGER | NOT NULL,PRIMARY KEY(role_id, menu_id) | 角色 ID → `sys_role.id` |
+| menu_id | INTEGER | NOT NULL,PRIMARY KEY(role_id, menu_id) | 菜单 ID → `sys_menu.id` |
+
+---
+
+## 表关系
+
+```text
+┌─────────────── 接口权限(RBAC,sys_permission 管接口) ───────────────┐
+sys_user ──< sys_user_role >── sys_role ──< sys_role_permission >── sys_permission
+  (用户)        (多对多)         (角色)         (多对多)              (权限)
+
+┌─────────────── 菜单权限(sys_menu,独立体系,与接口权限解耦) ───────────────┐
+sys_user ──< sys_user_role >── sys_role ──< sys_role_menu >── sys_menu
+  (用户)        (多对多)         (角色)     (多对多)            (菜单树)
+```
+
+- 一个用户可拥有多个角色;一个角色可被多个用户持有。
+- 一个角色可拥有多个权限;一个权限可授予多个角色。
+- 用户的最终权限 = 其所有角色权限的并集(查询时已 `DISTINCT` 去重)。
+- 用户的可见菜单 = 其所有角色被授予菜单的并集;下发时向上补全父级、剔除空目录。
+
+## 种子数据(启动时自动灌入,幂等)
+
+| 类别 | 内容 |
+|---|---|
+| 角色 | `ADMIN`(超级管理员,拥有全部权限)、`USER`(普通用户,默认注册用户角色) |
+| 权限 | `user:list` `user:create` `user:update` `user:delete`、`role:list` `role:create` `role:update` `role:delete` `role:assign`、`permission:list` `permission:create` `permission:delete`、`data:query` `data:write` |
+| 菜单 | `工作台`、`系统管理(目录)→ 用户管理/角色管理/权限管理/菜单管理`、`数据源演示`;启动时全部授权给 `ADMIN` 角色,新建菜单也会自动授权给 `ADMIN` |
+| 管理员 | 由 `ir.auth.admin.username/password` 配置(默认 `admin / admin123`),创建并授予 `ADMIN` 角色 |
+
+新注册用户由 `UserService` 自动分配默认角色 `USER`。

+ 36 - 3
pom.xml

@@ -12,10 +12,10 @@
     </parent>
 
     <groupId>com.demo</groupId>
-    <artifactId>mf-backend</artifactId>
+    <artifactId>ir-backend</artifactId>
     <version>1.0.0</version>
     <packaging>war</packaging>
-    <name>mf-backend</name>
+    <name>ir-backend</name>
     <description>微前端平台后端服务(兼容东方通 TongWeb 部署)</description>
 
     <properties>
@@ -51,11 +51,44 @@
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
+
+        <!-- SQLite JDBC:本地数据源默认实现(3.45.x 仍兼容 Java 8) -->
+        <dependency>
+            <groupId>org.xerial</groupId>
+            <artifactId>sqlite-jdbc</artifactId>
+            <version>3.45.3.0</version>
+        </dependency>
+
+        <!-- JWT(jjwt 0.12.x 兼容 Java 8) -->
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-api</artifactId>
+            <version>0.12.5</version>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-impl</artifactId>
+            <version>0.12.5</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-jackson</artifactId>
+            <version>0.12.5</version>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- 仅取 BCryptPasswordEncoder(不引入完整 Spring Security) -->
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-crypto</artifactId>
+            <version>5.6.5</version>
+        </dependency>
     </dependencies>
 
     <build>
         <!-- WAR 包名称(部署到东方通时的应用名) -->
-        <finalName>mf-backend</finalName>
+        <finalName>ir-backend</finalName>
         <plugins>
             <!-- 无 web.xml 也可打 WAR(Servlet 3.0 注解方式,由 SpringBootServletInitializer 引导) -->
             <plugin>

+ 4 - 4
src/main/java/com/demo/mf/MfApplication.java → src/main/java/com/nylc/touyan/IrApplication.java

@@ -1,4 +1,4 @@
-package com.demo.mf;
+package com.nylc.touyan;
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -11,15 +11,15 @@ import org.springframework.boot.web.servlet.support.SpringBootServletInitializer
  * 同时保留 main 方法,支持本地以 java -jar 方式运行。
  */
 @SpringBootApplication
-public class MfApplication extends SpringBootServletInitializer {
+public class IrApplication extends SpringBootServletInitializer {
 
     @Override
     protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
         // WAR 部署时通过该方法正确加载主配置类
-        return builder.sources(MfApplication.class);
+        return builder.sources(IrApplication.class);
     }
 
     public static void main(String[] args) {
-        SpringApplication.run(MfApplication.class, args);
+        SpringApplication.run(IrApplication.class, args);
     }
 }

+ 59 - 0
src/main/java/com/nylc/touyan/auth/AuthAutoConfiguration.java

@@ -0,0 +1,59 @@
+package com.nylc.touyan.auth;
+
+import com.nylc.touyan.datasource.DataSourceClient;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import javax.annotation.PostConstruct;
+
+/**
+ * 鉴权模块装配入口:
+ * - 注册 JWT 过滤器(作用于 /api/**,兼顾 CORS);
+ * - 注册 {@link RequireAuth} 拦截器;
+ * - 启动时初始化 sys_user 表。
+ */
+@Order(0)
+@Configuration
+@EnableConfigurationProperties(AuthProperties.class)
+public class AuthAutoConfiguration implements WebMvcConfigurer {
+
+    private final UserService userService;
+
+    public AuthAutoConfiguration(UserService userService) {
+        this.userService = userService;
+    }
+
+    @PostConstruct
+    public void initSchema() {
+        userService.initSchema();
+    }
+
+    @Bean
+    public AuthFilter authFilter(JwtUtil jwtUtil, AuthProperties properties) {
+        return new AuthFilter(jwtUtil, properties);
+    }
+
+    @Bean
+    public FilterRegistrationBean<AuthFilter> authFilterRegistration(AuthFilter filter) {
+        FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>();
+        registration.setFilter(filter);
+        registration.addUrlPatterns("/api/*");
+        registration.setOrder(1);
+        return registration;
+    }
+
+    @Bean
+    public AuthInterceptor authInterceptor() {
+        return new AuthInterceptor();
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(authInterceptor()).addPathPatterns("/api/**");
+    }
+}

+ 34 - 0
src/main/java/com/nylc/touyan/auth/AuthContext.java

@@ -0,0 +1,34 @@
+package com.nylc.touyan.auth;
+
+/**
+ * 请求级当前登录用户上下文(基于 ThreadLocal)。
+ * 由 {@link AuthFilter} 在请求开始时写入,请求结束时清理。
+ * 业务代码可通过 {@link #requireUser()} 获取;接口/Controller 可标注 {@link RequireAuth} 由拦截器统一兜底。
+ */
+public final class AuthContext {
+
+    private static final ThreadLocal<AuthUser> HOLDER = new ThreadLocal<>();
+
+    private AuthContext() {}
+
+    public static void setUser(AuthUser user) {
+        HOLDER.set(user);
+    }
+
+    public static AuthUser getUser() {
+        return HOLDER.get();
+    }
+
+    public static void clear() {
+        HOLDER.remove();
+    }
+
+    /** 未登录时抛 AuthException;用于业务主动校验。 */
+    public static AuthUser requireUser() {
+        AuthUser u = HOLDER.get();
+        if (u == null) {
+            throw new AuthException("UNAUTHORIZED", "请先登录");
+        }
+        return u;
+    }
+}

+ 67 - 0
src/main/java/com/nylc/touyan/auth/AuthController.java

@@ -0,0 +1,67 @@
+package com.nylc.touyan.auth;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 认证接口。
+ * login/register 为跳过鉴权(见 AuthProperties.skip-paths),/me 需要登录态。
+ */
+@RestController
+@RequestMapping("/api/auth")
+public class AuthController {
+
+    private final UserService userService;
+    private final JwtUtil jwtUtil;
+
+    public AuthController(UserService userService, JwtUtil jwtUtil) {
+        this.userService = userService;
+        this.jwtUtil = jwtUtil;
+    }
+
+    @PostMapping("/register")
+    public Map<String, Object> register(@RequestBody RegisterRequest req) {
+        AuthUser user = userService.register(req.getUsername(), req.getPassword(), req.getNickname());
+        String token = jwtUtil.issue(user.getId(), user.getUsername(), user.getRoles(), user.getPermissions());
+        return wrap(user, token);
+    }
+
+    @PostMapping("/login")
+    public Map<String, Object> login(@RequestBody LoginRequest req) {
+        AuthUser user = userService.login(req.getUsername(), req.getPassword());
+        String token = jwtUtil.issue(user.getId(), user.getUsername(), user.getRoles(), user.getPermissions());
+        return wrap(user, token);
+    }
+
+    @GetMapping("/me")
+    @RequireAuth
+    public Map<String, Object> me() {
+        AuthUser current = AuthContext.requireUser();
+        Map<String, Object> r = new HashMap<>();
+        r.put("id", current.getId());
+        r.put("username", current.getUsername());
+        r.put("nickname", current.getNickname());
+        r.put("roles", current.getRoles());
+        r.put("permissions", current.getPermissions());
+        return r;
+    }
+
+    private Map<String, Object> wrap(AuthUser user, String token) {
+        Map<String, Object> r = new HashMap<>();
+        r.put("token", token);
+        r.put("tokenType", "Bearer");
+        r.put("expiresIn", jwtUtil.getExpirationSeconds());
+        r.put("id", user.getId());
+        r.put("username", user.getUsername());
+        r.put("nickname", user.getNickname());
+        r.put("roles", user.getRoles());
+        r.put("permissions", user.getPermissions());
+        return r;
+    }
+}

+ 21 - 0
src/main/java/com/nylc/touyan/auth/AuthException.java

@@ -0,0 +1,21 @@
+package com.nylc.touyan.auth;
+
+/**
+ * 鉴权业务异常:登录失败、Token 无效、未登录等场景统一抛出。
+ */
+public class AuthException extends RuntimeException {
+
+    private final String code;
+
+    public AuthException(String code, String message) {
+        super(message);
+        this.code = code;
+    }
+
+    public AuthException(String code, String message, Throwable cause) {
+        super(message, cause);
+        this.code = code;
+    }
+
+    public String getCode() { return code; }
+}

+ 61 - 0
src/main/java/com/nylc/touyan/auth/AuthFilter.java

@@ -0,0 +1,61 @@
+package com.nylc.touyan.auth;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 基于 JWT 的鉴权过滤器:
+ * 1. 跳过白名单路径(登录/注册);
+ * 2. 从 Authorization 头解析 Bearer Token;
+ * 3. 有效则写入 {@link AuthContext},无效则仅清除上下文(不做拦截,拦截由
+ *    {@link AuthInterceptor} / {@link AuthContext#requireUser()} 负责),
+ *    保证 /api/auth/** 这类无需登录的接口在无 Token 时仍可访问。
+ */
+public class AuthFilter extends OncePerRequestFilter {
+
+    private static final Logger log = LoggerFactory.getLogger(AuthFilter.class);
+
+    private final JwtUtil jwtUtil;
+    private final List<String> skipPaths;
+
+    public AuthFilter(JwtUtil jwtUtil, AuthProperties properties) {
+        this.jwtUtil = jwtUtil;
+        this.skipPaths = Arrays.asList(properties.getSkipPaths());
+    }
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        String path = request.getServletPath();
+        if (path == null) path = request.getRequestURI();
+        return skipPaths.contains(path);
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        try {
+            String token = jwtUtil.extract(request.getHeader(jwtUtil.getHeader()));
+            if (token != null) {
+                AuthContext.setUser(jwtUtil.parseUser(token));
+            }
+        } catch (AuthException e) {
+            log.debug("[auth] Token 校验失败: {}", e.getMessage());
+            AuthContext.clear();
+        } finally {
+            try {
+                filterChain.doFilter(request, response);
+            } finally {
+                AuthContext.clear();
+            }
+        }
+    }
+}

+ 38 - 0
src/main/java/com/nylc/touyan/auth/AuthInterceptor.java

@@ -0,0 +1,38 @@
+package com.nylc.touyan.auth;
+
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+
+/**
+ * 登录态拦截器:仅校验标注了 {@link RequireAuth} 的方法。
+ * 通过 {@link AuthContext#getUser()} 判断是否已由 {@link AuthFilter} 解析出用户。
+ */
+public class AuthInterceptor implements HandlerInterceptor {
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
+                             Object handler) {
+        if (!(handler instanceof HandlerMethod)) {
+            return true;
+        }
+        Method method = ((HandlerMethod) handler).getMethod();
+        if (!method.isAnnotationPresent(RequireAuth.class)) {
+            return true;
+        }
+        AuthUser user = AuthContext.getUser();
+        if (user == null) {
+            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+            response.setContentType("application/json;charset=UTF-8");
+            try {
+                response.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"请先登录\"}");
+            } catch (java.io.IOException ignored) {
+            }
+            return false;
+        }
+        return true;
+    }
+}

+ 70 - 0
src/main/java/com/nylc/touyan/auth/AuthProperties.java

@@ -0,0 +1,70 @@
+package com.nylc.touyan.auth;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 鉴权配置。
+ * <pre>
+ * ir:
+ *   auth:
+ *     jwt:
+ *       secret: your-256bit-secret-key-here-change-me
+ *       expiration-seconds: 7200
+ *       header: Authorization
+ *       prefix: "Bearer "
+ *     skip-paths:
+ *       - /api/auth/login
+ *       - /api/auth/register
+ * </pre>
+ */
+@ConfigurationProperties(prefix = "ir.auth")
+public class AuthProperties {
+
+    private Jwt jwt = new Jwt();
+    private String[] skipPaths = new String[]{"/api/auth/login", "/api/auth/register"};
+    private Admin admin = new Admin();
+
+    public Jwt getJwt() { return jwt; }
+    public void setJwt(Jwt jwt) { this.jwt = jwt; }
+
+    public String[] getSkipPaths() { return skipPaths; }
+    public void setSkipPaths(String[] skipPaths) { this.skipPaths = skipPaths; }
+
+    public Admin getAdmin() { return admin; }
+    public void setAdmin(Admin admin) { this.admin = admin; }
+
+    /** 内置管理员账号(首次启动自动创建并授予 ADMIN 角色)。 */
+    public static class Admin {
+        private String username = "admin";
+        private String password = "admin123";
+
+        public String getUsername() { return username; }
+        public void setUsername(String username) { this.username = username; }
+
+        public String getPassword() { return password; }
+        public void setPassword(String password) { this.password = password; }
+    }
+
+    public static class Jwt {
+        /** HMAC 密钥(≥32 字节);生产请通过环境变量注入。 */
+        private String secret = "touyan-default-secret-please-change-in-production-256bit";
+        /** 有效期(秒),默认 2 小时。 */
+        private long expirationSeconds = 7200L;
+        /** HTTP 头名称。 */
+        private String header = "Authorization";
+        /** Token 前缀。 */
+        private String prefix = "Bearer ";
+
+        public String getSecret() { return secret; }
+        public void setSecret(String secret) { this.secret = secret; }
+
+        public long getExpirationSeconds() { return expirationSeconds; }
+        public void setExpirationSeconds(long expirationSeconds) { this.expirationSeconds = expirationSeconds; }
+
+        public String getHeader() { return header; }
+        public void setHeader(String header) { this.header = header; }
+
+        public String getPrefix() { return prefix; }
+        public void setPrefix(String prefix) { this.prefix = prefix; }
+    }
+}

+ 50 - 0
src/main/java/com/nylc/touyan/auth/AuthUser.java

@@ -0,0 +1,50 @@
+package com.nylc.touyan.auth;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 当前登录用户视图(不含密码),含其角色与权限编码集合,供鉴权拦截器使用。
+ */
+public class AuthUser {
+
+    private final Long id;
+    private final String username;
+    private final String nickname;
+    private final List<String> roles;
+    private final List<String> permissions;
+
+    public AuthUser(Long id, String username, String nickname) {
+        this(id, username, nickname, Collections.emptyList(), Collections.emptyList());
+    }
+
+    public AuthUser(Long id, String username, String nickname,
+                    List<String> roles, List<String> permissions) {
+        this.id = id;
+        this.username = username;
+        this.nickname = nickname;
+        this.roles = roles == null ? Collections.emptyList() : roles;
+        this.permissions = permissions == null ? Collections.emptyList() : permissions;
+    }
+
+    public Long getId() { return id; }
+    public String getUsername() { return username; }
+    public String getNickname() { return nickname; }
+    public List<String> getRoles() { return roles; }
+    public List<String> getPermissions() { return permissions; }
+
+    /** 是否包含指定角色中的任意一个。 */
+    public boolean hasAnyRole(String... required) {
+        for (String r : required) {
+            if (roles.contains(r)) return true;
+        }
+        return false;
+    }
+
+    public boolean hasAnyPermission(String... required) {
+        for (String p : required) {
+            if (permissions.contains(p)) return true;
+        }
+        return false;
+    }
+}

+ 99 - 0
src/main/java/com/nylc/touyan/auth/JwtUtil.java

@@ -0,0 +1,99 @@
+package com.nylc.touyan.auth;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+/**
+ * JWT 工具:基于 HMAC-SHA256 签发/校验。
+ * 工具方法对异常统一封装为 {@link AuthException},调用方按业务异常处理。
+ */
+@Component
+public class JwtUtil {
+
+    private final SecretKey key;
+    private final long expirationSeconds;
+    private final String header;
+    private final String prefix;
+
+    public JwtUtil(AuthProperties properties) {
+        byte[] keyBytes = properties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
+        if (keyBytes.length < 32) {
+            throw new IllegalStateException(
+                    "ir.auth.jwt.secret 长度不足 32 字节,请使用至少 32 字节的随机字符串");
+        }
+        this.key = Keys.hmacShaKeyFor(keyBytes);
+        this.expirationSeconds = properties.getJwt().getExpirationSeconds();
+        this.header = properties.getJwt().getHeader();
+        this.prefix = properties.getJwt().getPrefix();
+    }
+
+    public String getHeader() { return header; }
+    public String getPrefix() { return prefix; }
+    public long getExpirationSeconds() { return expirationSeconds; }
+
+    /** 签发 token:sub=username, uid=userId, 并携带角色/权限编码。 */
+    public String issue(Long userId, String username,
+                        java.util.List<String> roles,
+                        java.util.List<String> permissions) {
+        long now = System.currentTimeMillis();
+        return Jwts.builder()
+                .subject(username)
+                .claim("uid", userId)
+                .claim("roles", roles == null ? new java.util.ArrayList<>() : roles)
+                .claim("perms", permissions == null ? new java.util.ArrayList<>() : permissions)
+                .issuedAt(new Date(now))
+                .expiration(new Date(now + expirationSeconds * 1000L))
+                .signWith(key, Jwts.SIG.HS256)
+                .compact();
+    }
+
+    /** 解析 token 为 AuthUser(含角色/权限),失败抛 AuthException。 */
+    @SuppressWarnings("unchecked")
+    public AuthUser parseUser(String token) {
+        Claims claims = parse(token);
+        Long uid = ((Number) claims.get("uid")).longValue();
+        String username = claims.getSubject();
+        java.util.List<String> roles = toList(claims.get("roles"));
+        java.util.List<String> perms = toList(claims.get("perms"));
+        return new AuthUser(uid, username, null, roles, perms);
+    }
+
+    private java.util.List<String> toList(Object value) {
+        java.util.List<String> result = new java.util.ArrayList<>();
+        if (value instanceof java.util.List) {
+            for (Object o : (java.util.List<?>) value) {
+                if (o != null) result.add(String.valueOf(o));
+            }
+        }
+        return result;
+    }
+
+    /** 解析 token,失败抛 AuthException。 */
+    public Claims parse(String token) {
+        try {
+            return Jwts.parser()
+                    .verifyWith(key)
+                    .build()
+                    .parseSignedClaims(token)
+                    .getPayload();
+        } catch (JwtException | IllegalArgumentException e) {
+            throw new AuthException("TOKEN_INVALID", "Token 无效或已过期", e);
+        }
+    }
+
+    /** 从 Authorization 头中抽取 token;返回 null 表示不存在或格式错误。 */
+    public String extract(String authorizationHeader) {
+        if (authorizationHeader == null || !authorizationHeader.startsWith(prefix)) {
+            return null;
+        }
+        String token = authorizationHeader.substring(prefix.length()).trim();
+        return token.isEmpty() ? null : token;
+    }
+}

+ 16 - 0
src/main/java/com/nylc/touyan/auth/LoginRequest.java

@@ -0,0 +1,16 @@
+package com.nylc.touyan.auth;
+
+/**
+ * 登录请求体。
+ */
+public class LoginRequest {
+
+    private String username;
+    private String password;
+
+    public String getUsername() { return username; }
+    public void setUsername(String username) { this.username = username; }
+
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+}

+ 20 - 0
src/main/java/com/nylc/touyan/auth/RegisterRequest.java

@@ -0,0 +1,20 @@
+package com.nylc.touyan.auth;
+
+/**
+ * 注册请求体。
+ */
+public class RegisterRequest {
+
+    private String username;
+    private String password;
+    private String nickname;
+
+    public String getUsername() { return username; }
+    public void setUsername(String username) { this.username = username; }
+
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+
+    public String getNickname() { return nickname; }
+    public void setNickname(String nickname) { this.nickname = nickname; }
+}

+ 17 - 0
src/main/java/com/nylc/touyan/auth/RequireAuth.java

@@ -0,0 +1,17 @@
+package com.nylc.touyan.auth;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标注在 Controller 方法上,要求请求必须携带有效登录态。
+ * 由 {@link AuthInterceptor} 统一校验,未登录返回 401。
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface RequireAuth {
+}

+ 110 - 0
src/main/java/com/nylc/touyan/auth/UserService.java

@@ -0,0 +1,110 @@
+package com.nylc.touyan.auth;
+
+import com.nylc.touyan.datasource.DataSourceClient;
+import com.nylc.touyan.rbac.RbacService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * 用户业务:注册、登录、按用户名查询。
+ * 复用 {@link DataSourceClient} 访问底层数据源(当前为 SQLite),未来切换 MySQL 业务代码零改动。
+ * 登录/注册时通过 {@link RbacService} 加载角色与权限并随 token(AuthUser)下发。
+ */
+@Service
+public class UserService {
+
+    private static final Logger log = LoggerFactory.getLogger(UserService.class);
+
+    private final DataSourceClient dataSource;
+    private final RbacService rbacService;
+    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
+
+    public UserService(DataSourceClient dataSource, RbacService rbacService) {
+        this.dataSource = dataSource;
+        this.rbacService = rbacService;
+    }
+
+    public void initSchema() {
+        dataSource.execute(
+                "CREATE TABLE IF NOT EXISTS sys_user (" +
+                        "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+                        "username TEXT NOT NULL UNIQUE," +
+                        "password TEXT NOT NULL," +
+                        "nickname TEXT," +
+                        "enabled INTEGER NOT NULL DEFAULT 1," +
+                        "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+        log.info("[auth] 用户表 sys_user 已就绪");
+    }
+
+    public AuthUser register(String username, String rawPassword, String nickname) {
+        if (username == null || username.trim().isEmpty()) {
+            throw new AuthException("BAD_REQUEST", "用户名不能为空");
+        }
+        if (rawPassword == null || rawPassword.length() < 6) {
+            throw new AuthException("BAD_REQUEST", "密码长度至少 6 位");
+        }
+        Map<String, Object> exists = dataSource.queryForMap(
+                "SELECT id FROM sys_user WHERE username = ?", username);
+        if (exists != null) {
+            throw new AuthException("USER_EXISTS", "用户名已被注册");
+        }
+        String hash = encoder.encode(rawPassword);
+        int rows = dataSource.update(
+                "INSERT INTO sys_user(username, password, nickname) VALUES(?, ?, ?)",
+                username, hash, nickname);
+        if (rows <= 0) {
+            throw new AuthException("INTERNAL", "注册失败");
+        }
+        Long id = ((Number) dataSource.queryForObject(
+                "SELECT id FROM sys_user WHERE username = ?", username)).longValue();
+        assignDefaultRole(id);
+        return buildAuthUser(id, username, nickname);
+    }
+
+    public AuthUser login(String username, String rawPassword) {
+        Map<String, Object> row = dataSource.queryForMap(
+                "SELECT id, username, password, nickname, enabled FROM sys_user WHERE username = ?",
+                username);
+        if (row == null) {
+            throw new AuthException("BAD_CREDENTIALS", "用户名或密码错误");
+        }
+        Number enabled = (Number) row.get("enabled");
+        if (enabled != null && enabled.intValue() == 0) {
+            throw new AuthException("USER_DISABLED", "账号已被禁用");
+        }
+        String hash = String.valueOf(row.get("password"));
+        if (!encoder.matches(rawPassword, hash)) {
+            throw new AuthException("BAD_CREDENTIALS", "用户名或密码错误");
+        }
+        Long id = ((Number) row.get("id")).longValue();
+        String nickname = (String) row.get("nickname");
+        return buildAuthUser(id, username, nickname);
+    }
+
+    public AuthUser findByUsername(String username) {
+        Map<String, Object> row = dataSource.queryForMap(
+                "SELECT id, username, nickname FROM sys_user WHERE username = ?", username);
+        if (row == null) return null;
+        Long id = ((Number) row.get("id")).longValue();
+        return buildAuthUser(id, username, (String) row.get("nickname"));
+    }
+
+    private AuthUser buildAuthUser(Long id, String username, String nickname) {
+        RbacService.AuthSnapshot snapshot = rbacService.resolve(id);
+        return new AuthUser(id, username, nickname, snapshot.getRoles(), snapshot.getPermissions());
+    }
+
+    private void assignDefaultRole(Long userId) {
+        Map<String, Object> role = dataSource.queryForMap(
+                "SELECT id FROM sys_role WHERE code = 'USER'");
+        if (role != null) {
+            rbacService.assignUserRoles(userId, Collections.singletonList(
+                    ((Number) role.get("id")).longValue()));
+        }
+    }
+}

+ 51 - 0
src/main/java/com/nylc/touyan/common/GlobalExceptionHandler.java

@@ -0,0 +1,51 @@
+package com.nylc.touyan.common;
+
+import com.nylc.touyan.auth.AuthException;
+import com.nylc.touyan.datasource.DataSourceException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 全局异常处理:将业务异常统一转换为 {code, message} JSON。
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    @ExceptionHandler(AuthException.class)
+    @ResponseStatus(HttpStatus.OK)
+    public Map<String, Object> handleAuth(AuthException e) {
+        Map<String, Object> r = new HashMap<>();
+        r.put("code", e.getCode());
+        r.put("message", e.getMessage());
+        return r;
+    }
+
+    @ExceptionHandler(DataSourceException.class)
+    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+    public Map<String, Object> handleDataSource(DataSourceException e) {
+        log.error("数据源访问异常", e);
+        Map<String, Object> r = new HashMap<>();
+        r.put("code", "DB_ERROR");
+        r.put("message", e.getMessage());
+        return r;
+    }
+
+    @ExceptionHandler(Exception.class)
+    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+    public Map<String, Object> handleOther(Exception e) {
+        log.error("未处理异常", e);
+        Map<String, Object> r = new HashMap<>();
+        r.put("code", "INTERNAL_ERROR");
+        r.put("message", e.getMessage() == null ? "服务器内部错误" : e.getMessage());
+        return r;
+    }
+}

+ 1 - 1
src/main/java/com/demo/mf/config/CorsConfig.java → src/main/java/com/nylc/touyan/config/CorsConfig.java

@@ -1,4 +1,4 @@
-package com.demo.mf.config;
+package com.nylc.touyan.config;
 
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;

+ 1 - 1
src/main/java/com/demo/mf/controller/HelloController.java → src/main/java/com/nylc/touyan/controller/HelloController.java

@@ -1,4 +1,4 @@
-package com.demo.mf.controller;
+package com.nylc.touyan.controller;
 
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;

+ 39 - 0
src/main/java/com/nylc/touyan/datasource/DataSourceAutoConfiguration.java

@@ -0,0 +1,39 @@
+package com.nylc.touyan.datasource;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.annotation.PreDestroy;
+
+/**
+ * 数据源自动装配入口。
+ * 业务层通过注入 {@link DataSourceClient} 即可使用,无需感知底层实现。
+ */
+@Configuration
+@EnableConfigurationProperties(DataSourceProperties.class)
+public class DataSourceAutoConfiguration {
+
+    private DataSourceClient client;
+
+    @Bean
+    public DataSourceFactory dataSourceFactory(DataSourceProperties properties) {
+        return new DataSourceFactory(properties);
+    }
+
+    @Bean
+    public DataSourceClient dataSourceClient(DataSourceFactory factory) {
+        this.client = factory.create();
+        return this.client;
+    }
+
+    @PreDestroy
+    public void destroy() {
+        if (client != null) {
+            try {
+                client.close();
+            } catch (Exception ignored) {
+            }
+        }
+    }
+}

+ 41 - 0
src/main/java/com/nylc/touyan/datasource/DataSourceClient.java

@@ -0,0 +1,41 @@
+package com.nylc.touyan.datasource;
+
+import java.io.Closeable;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 统一的数据源客户端抽象。
+ * 业务层只依赖该接口,不关心底层是 SQLite / MySQL / 其他。
+ *
+ * 设计原则:
+ * 1. 入参/出参使用基础类型(String SQL + Object... params + List<Map>),保持通用;
+ * 2. 各实现自行保证线程安全与连接管理;
+ * 3. 新增数据源只需实现本接口并在 {@link DataSourceFactory} 注册。
+ */
+public interface DataSourceClient extends Closeable {
+
+    /** 当前数据源类型标识,用于日志/诊断。 */
+    String type();
+
+    /** 健康检查:连接可用返回 true。 */
+    boolean ping();
+
+    /** 查询列表;无结果返回空集合(不返回 null)。 */
+    List<Map<String, Object>> queryForList(String sql, Object... params);
+
+    /** 查询单条;无结果返回 null。 */
+    Map<String, Object> queryForMap(String sql, Object... params);
+
+    /** 单标量查询(例如 SELECT COUNT(*) / SELECT NOW()),无结果返回 null。 */
+    Object queryForObject(String sql, Object... params);
+
+    /** 执行 INSERT/UPDATE/DELETE,返回受影响行数。 */
+    int update(String sql, Object... params);
+
+    /** 批量执行同结构 SQL,返回每条受影响行数。 */
+    int[] batchUpdate(String sql, List<Object[]> paramsBatch);
+
+    /** 执行任意 SQL(DDL 等),无返回结果。 */
+    void execute(String sql);
+}

+ 64 - 0
src/main/java/com/nylc/touyan/datasource/DataSourceController.java

@@ -0,0 +1,64 @@
+package com.nylc.touyan.datasource;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 数据源演示接口:验证抽象后的客户端能否通过配置切换数据源。
+ * 仅依赖 {@link DataSourceClient} 接口,不耦合具体实现。
+ */
+@RestController
+@RequestMapping("/api/datasource")
+public class DataSourceController {
+
+    private final DataSourceClient client;
+
+    public DataSourceController(DataSourceClient client) {
+        this.client = client;
+    }
+
+    @GetMapping("/info")
+    public Map<String, Object> info() {
+        Map<String, Object> r = new HashMap<>();
+        r.put("type", client.type());
+        r.put("ping", client.ping());
+        r.put("serverNow", String.valueOf(client.queryForObject("SELECT CURRENT_TIMESTAMP")));
+        return r;
+    }
+
+    @GetMapping("/users")
+    public List<Map<String, Object>> listUsers() {
+        return client.queryForList("SELECT id, name, created_at FROM demo_user ORDER BY id DESC");
+    }
+
+    @PostMapping("/users")
+    public Map<String, Object> addUser(@RequestBody Map<String, Object> body) {
+        String name = String.valueOf(body.get("name"));
+        int rows = client.update("INSERT INTO demo_user(name) VALUES(?)", name);
+        Map<String, Object> r = new HashMap<>();
+        r.put("affected", rows);
+        r.put("name", name);
+        return r;
+    }
+
+    @PostMapping("/users/batch")
+    public Map<String, Object> batchAdd(@RequestParam("prefix") String prefix,
+                                        @RequestParam("count") int count) {
+        List<Object[]> batch = new java.util.ArrayList<>();
+        for (int i = 0; i < count; i++) {
+            batch.add(new Object[]{prefix + "-" + System.currentTimeMillis() + "-" + i});
+        }
+        int[] result = client.batchUpdate("INSERT INTO demo_user(name) VALUES(?)", batch);
+        Map<String, Object> r = new HashMap<>();
+        r.put("affected", result.length);
+        return r;
+    }
+}

+ 15 - 0
src/main/java/com/nylc/touyan/datasource/DataSourceException.java

@@ -0,0 +1,15 @@
+package com.nylc.touyan.datasource;
+
+/**
+ * 数据源访问异常,统一封装底层 SQLException / IO 异常等。
+ */
+public class DataSourceException extends RuntimeException {
+
+    public DataSourceException(String message) {
+        super(message);
+    }
+
+    public DataSourceException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 47 - 0
src/main/java/com/nylc/touyan/datasource/DataSourceFactory.java

@@ -0,0 +1,47 @@
+package com.nylc.touyan.datasource;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 数据源工厂:根据 {@link DataSourceProperties#getType()} 选择具体实现。
+ *
+ * 新增数据源步骤:
+ *   1) 在 DataSourceType 枚举添加类型;
+ *   2) 在本类 switch 增加分支并构造对应实现;
+ *   3) 实现 DataSourceClient 接口;
+ *   4) 在 application.yml 配置对应属性。
+ */
+public class DataSourceFactory {
+
+    private static final Logger log = LoggerFactory.getLogger(DataSourceFactory.class);
+
+    private final DataSourceProperties properties;
+
+    public DataSourceFactory(DataSourceProperties properties) {
+        this.properties = properties;
+    }
+
+    /**
+     * 根据配置创建数据源客户端实例。
+     * 工厂方法而非 @Bean 直接返回:避免在配置错误时 Spring 启动失败;
+     * 由 AutoConfiguration 在 @PostConstruct 中调用并缓存。
+     */
+    public DataSourceClient create() {
+        DataSourceType type = properties.getType();
+        if (type == null) {
+            type = DataSourceType.SQLITE;
+        }
+        log.info("[datasource] 初始化数据源 type={}", type);
+        switch (type) {
+            case SQLITE:
+                return new SqliteDataSourceClient(properties.getSqlite());
+            case MYSQL:
+                throw new DataSourceException(
+                        "MySQL 数据源尚未实现,请引入 mysql-connector-java 并新增实现类。" +
+                                " 当前配置 ir.datasource.type=mysql");
+            default:
+                throw new DataSourceException("不支持的数据源类型: " + type);
+        }
+    }
+}

+ 68 - 0
src/main/java/com/nylc/touyan/datasource/DataSourceProperties.java

@@ -0,0 +1,68 @@
+package com.nylc.touyan.datasource;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 数据源配置项。
+ * application.yml 示例:
+ * <pre>
+ * ir:
+ *   datasource:
+ *     type: sqlite
+ *     sqlite:
+ *       path: data/ir.db
+ *     mysql:
+ *       url: jdbc:mysql://localhost:3306/ir
+ *       username: root
+ *       password: root
+ * </pre>
+ */
+@ConfigurationProperties(prefix = "ir.datasource")
+public class DataSourceProperties {
+
+    /** 数据源类型,默认 sqlite */
+    private DataSourceType type = DataSourceType.SQLITE;
+
+    /** SQLite 专属配置 */
+    private Sqlite sqlite = new Sqlite();
+
+    /** MySQL 专属配置(预留) */
+    private Mysql mysql = new Mysql();
+
+    public DataSourceType getType() { return type; }
+    public void setType(DataSourceType type) { this.type = type; }
+
+    public Sqlite getSqlite() { return sqlite; }
+    public void setSqlite(Sqlite sqlite) { this.sqlite = sqlite; }
+
+    public Mysql getMysql() { return mysql; }
+    public void setMysql(Mysql mysql) { this.mysql = mysql; }
+
+    public static class Sqlite {
+        /** SQLite 数据库文件路径(相对工作目录或绝对路径) */
+        private String path = "data/ir.db";
+        /** 启动时是否自动初始化演示表 */
+        private boolean initDemoTable = true;
+
+        public String getPath() { return path; }
+        public void setPath(String path) { this.path = path; }
+
+        public boolean isInitDemoTable() { return initDemoTable; }
+        public void setInitDemoTable(boolean initDemoTable) { this.initDemoTable = initDemoTable; }
+    }
+
+    public static class Mysql {
+        private String url;
+        private String username;
+        private String password;
+
+        public String getUrl() { return url; }
+        public void setUrl(String url) { this.url = url; }
+
+        public String getUsername() { return username; }
+        public void setUsername(String username) { this.username = username; }
+
+        public String getPassword() { return password; }
+        public void setPassword(String password) { this.password = password; }
+    }
+}

+ 14 - 0
src/main/java/com/nylc/touyan/datasource/DataSourceType.java

@@ -0,0 +1,14 @@
+package com.nylc.touyan.datasource;
+
+/**
+ * 支持的数据源类型。
+ * 新增数据源时:在此枚举添加一项 + 在 {@link DataSourceFactory} 增加分支 + 新增对应实现类。
+ */
+public enum DataSourceType {
+
+    /** 本地 SQLite3 文件 */
+    SQLITE,
+
+    /** 预留:远程 MySQL(需自行引入 mysql-connector-java 并提供实现) */
+    MYSQL
+}

+ 190 - 0
src/main/java/com/nylc/touyan/datasource/SqliteDataSourceClient.java

@@ -0,0 +1,190 @@
+package com.nylc.touyan.datasource;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.io.File;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 基于本地 SQLite3 文件的数据源实现。
+ *
+ * 线程模型:SQLite 文件级写锁,单连接 + synchronized 串行化即可满足本地研发场景。
+ * 如未来接入并发写需求,建议替换为连接池(PooledDataSource)或迁移到 MySQL。
+ */
+public class SqliteDataSourceClient implements DataSourceClient {
+
+    private static final Logger log = LoggerFactory.getLogger(SqliteDataSourceClient.class);
+
+    private final String path;
+    private final boolean initDemoTable;
+    private Connection connection;
+
+    public SqliteDataSourceClient(DataSourceProperties.Sqlite cfg) {
+        this.path = cfg.getPath();
+        this.initDemoTable = cfg.isInitDemoTable();
+    }
+
+    @PostConstruct
+    public void init() throws SQLException {
+        File dbFile = new File(path);
+        File parent = dbFile.getAbsoluteFile().getParentFile();
+        if (parent != null && !parent.exists() && !parent.mkdirs()) {
+            throw new SQLException("无法创建 SQLite 父目录: " + parent.getAbsolutePath());
+        }
+        String url = "jdbc:sqlite:" + dbFile.getAbsolutePath();
+        this.connection = DriverManager.getConnection(url);
+        try (Statement st = connection.createStatement()) {
+            // WAL 提升并发读性能;外键约束开启
+            st.execute("PRAGMA journal_mode=WAL");
+            st.execute("PRAGMA foreign_keys=ON");
+        }
+        log.info("[datasource:sqlite] 已连接 {}", dbFile.getAbsolutePath());
+        if (initDemoTable) {
+            initDemoTable();
+        }
+    }
+
+    private void initDemoTable() throws SQLException {
+        try (Statement st = connection.createStatement()) {
+            st.execute("CREATE TABLE IF NOT EXISTS demo_user (" +
+                    "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+                    "name TEXT NOT NULL," +
+                    "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+            log.info("[datasource:sqlite] 演示表 demo_user 已就绪");
+        }
+    }
+
+    @PreDestroy
+    @Override
+    public void close() {
+        if (connection != null) {
+            try { connection.close(); } catch (SQLException ignored) { }
+            log.info("[datasource:sqlite] 连接已关闭");
+        }
+    }
+
+    @Override
+    public String type() { return "sqlite"; }
+
+    @Override
+    public boolean ping() {
+        try {
+            return connection != null && !connection.isClosed() && connection.isValid(1);
+        } catch (SQLException e) {
+            return false;
+        }
+    }
+
+    @Override
+    public synchronized List<Map<String, Object>> queryForList(String sql, Object... params) {
+        try (PreparedStatement ps = connection.prepareStatement(sql)) {
+            bind(ps, params);
+            try (ResultSet rs = ps.executeQuery()) {
+                return toList(rs);
+            }
+        } catch (SQLException e) {
+            throw new DataSourceException("SQL 执行失败: " + sql, e);
+        }
+    }
+
+    @Override
+    public synchronized Map<String, Object> queryForMap(String sql, Object... params) {
+        List<Map<String, Object>> list = queryForList(sql, params);
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    @Override
+    public synchronized Object queryForObject(String sql, Object... params) {
+        try (PreparedStatement ps = connection.prepareStatement(sql)) {
+            bind(ps, params);
+            try (ResultSet rs = ps.executeQuery()) {
+                return rs.next() ? rs.getObject(1) : null;
+            }
+        } catch (SQLException e) {
+            throw new DataSourceException("SQL 执行失败: " + sql, e);
+        }
+    }
+
+    @Override
+    public synchronized int update(String sql, Object... params) {
+        try (PreparedStatement ps = connection.prepareStatement(sql)) {
+            bind(ps, params);
+            return ps.executeUpdate();
+        } catch (SQLException e) {
+            throw new DataSourceException("SQL 执行失败: " + sql, e);
+        }
+    }
+
+    @Override
+    public synchronized int[] batchUpdate(String sql, List<Object[]> paramsBatch) {
+        if (paramsBatch == null || paramsBatch.isEmpty()) return new int[0];
+        try (PreparedStatement ps = connection.prepareStatement(sql)) {
+            for (Object[] params : paramsBatch) {
+                bind(ps, params);
+                ps.addBatch();
+            }
+            return ps.executeBatch();
+        } catch (SQLException e) {
+            throw new DataSourceException("批量 SQL 执行失败: " + sql, e);
+        }
+    }
+
+    @Override
+    public synchronized void execute(String sql) {
+        try (Statement st = connection.createStatement()) {
+            st.execute(sql);
+        } catch (SQLException e) {
+            throw new DataSourceException("SQL 执行失败: " + sql, e);
+        }
+    }
+
+    private static void bind(PreparedStatement ps, Object[] params) throws SQLException {
+        if (params == null) return;
+        for (int i = 0; i < params.length; i++) {
+            Object v = params[i];
+            if (v == null) {
+                ps.setObject(i + 1, null);
+            } else if (v instanceof java.time.LocalDateTime) {
+                ps.setTimestamp(i + 1, Timestamp.valueOf((java.time.LocalDateTime) v));
+            } else if (v instanceof java.time.LocalDate) {
+                ps.setDate(i + 1, java.sql.Date.valueOf((java.time.LocalDate) v));
+            } else {
+                ps.setObject(i + 1, v);
+            }
+        }
+    }
+
+    private static List<Map<String, Object>> toList(ResultSet rs) throws SQLException {
+        ResultSetMetaData md = rs.getMetaData();
+        int cols = md.getColumnCount();
+        List<String> labels = new ArrayList<>(cols);
+        for (int i = 1; i <= cols; i++) {
+            String label = md.getColumnLabel(i);
+            labels.add(label == null || label.isEmpty() ? md.getColumnName(i) : label);
+        }
+        List<Map<String, Object>> result = new ArrayList<>();
+        while (rs.next()) {
+            Map<String, Object> row = new LinkedHashMap<>(cols);
+            for (int i = 1; i <= cols; i++) {
+                row.put(labels.get(i - 1), rs.getObject(i));
+            }
+            result.add(row);
+        }
+        return result.isEmpty() ? Collections.emptyList() : result;
+    }
+}

+ 91 - 0
src/main/java/com/nylc/touyan/menu/MenuController.java

@@ -0,0 +1,91 @@
+package com.nylc.touyan.menu;
+
+import com.nylc.touyan.auth.AuthContext;
+import com.nylc.touyan.auth.RequireAuth;
+import com.nylc.touyan.rbac.IdListRequest;
+import com.nylc.touyan.rbac.RequireRole;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 独立菜单权限接口:
+ * - 管理端(仅 ADMIN):菜单树的维护、角色-菜单授权;
+ * - 用户端:/current 返回当前登录用户可见的菜单树(登录即可,不依赖接口权限码)。
+ * 本体系与 sys_permission/接口鉴权完全解耦,此处不使用 @RequirePermission。
+ */
+@RestController
+@RequestMapping("/api/menu")
+public class MenuController {
+
+    private final MenuService menuService;
+
+    public MenuController(MenuService menuService) {
+        this.menuService = menuService;
+    }
+
+    // ==================== 菜单管理(仅 ADMIN) ====================
+
+    @GetMapping("/tree")
+    @RequireRole("ADMIN")
+    public List<Map<String, Object>> tree() {
+        return menuService.tree();
+    }
+
+    @PostMapping
+    @RequireRole("ADMIN")
+    public Map<String, Object> create(@RequestBody MenuRequest body) {
+        return menuService.createMenu(body);
+    }
+
+    @PutMapping("/{id}")
+    @RequireRole("ADMIN")
+    public Map<String, Object> update(@PathVariable("id") Long id, @RequestBody MenuRequest body) {
+        return menuService.updateMenu(id, body);
+    }
+
+    @DeleteMapping("/{id}")
+    @RequireRole("ADMIN")
+    public Map<String, Object> delete(@PathVariable("id") Long id) {
+        menuService.deleteMenu(id);
+        Map<String, Object> r = new HashMap<>();
+        r.put("deleted", id);
+        return r;
+    }
+
+    // ==================== 角色-菜单授权(仅 ADMIN) ====================
+
+    @GetMapping("/role/{roleId}")
+    @RequireRole("ADMIN")
+    public List<Long> roleMenus(@PathVariable("roleId") Long roleId) {
+        return menuService.roleMenuIds(roleId);
+    }
+
+    @PostMapping("/role/{roleId}")
+    @RequireRole("ADMIN")
+    public Map<String, Object> assignRoleMenus(@PathVariable("roleId") Long roleId,
+                                               @RequestBody IdListRequest body) {
+        menuService.assignRoleMenus(roleId, body.getIds());
+        Map<String, Object> r = new HashMap<>();
+        r.put("roleId", roleId);
+        r.put("menuIds", menuService.roleMenuIds(roleId));
+        return r;
+    }
+
+    // ==================== 当前用户可见菜单 ====================
+
+    @GetMapping("/current")
+    @RequireAuth
+    public List<Map<String, Object>> currentMenus() {
+        return menuService.visibleTree(AuthContext.requireUser().getId());
+    }
+}

+ 46 - 0
src/main/java/com/nylc/touyan/menu/MenuRequest.java

@@ -0,0 +1,46 @@
+package com.nylc.touyan.menu;
+
+/**
+ * 菜单创建/更新请求体。
+ * 菜单权限体系独立于 sys_permission(接口权限):菜单自身是一棵树,
+ * 通过 sys_role_menu 直接授予角色,与接口权限互不影响。
+ */
+public class MenuRequest {
+
+    private Long parentId;
+    private String name;
+    private String type;
+    private String route;
+    private String component;
+    private String icon;
+    private Integer sort;
+    private Integer visible;
+    private Integer status;
+
+    public Long getParentId() { return parentId; }
+    public void setParentId(Long parentId) { this.parentId = parentId; }
+
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+    public String getType() { return type; }
+    public void setType(String type) { this.type = type; }
+
+    public String getRoute() { return route; }
+    public void setRoute(String route) { this.route = route; }
+
+    public String getComponent() { return component; }
+    public void setComponent(String component) { this.component = component; }
+
+    public String getIcon() { return icon; }
+    public void setIcon(String icon) { this.icon = icon; }
+
+    public Integer getSort() { return sort; }
+    public void setSort(Integer sort) { this.sort = sort; }
+
+    public Integer getVisible() { return visible; }
+    public void setVisible(Integer visible) { this.visible = visible; }
+
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+}

+ 298 - 0
src/main/java/com/nylc/touyan/menu/MenuService.java

@@ -0,0 +1,298 @@
+package com.nylc.touyan.menu;
+
+import com.nylc.touyan.auth.AuthException;
+import com.nylc.touyan.datasource.DataSourceClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 独立菜单权限服务:
+ * - 数据模型:sys_menu(菜单树)/ sys_role_menu(角色-菜单授权);
+ * - 与 sys_permission(接口权限)完全解耦:菜单可见性只由“用户的角色 → sys_role_menu”决定,
+ *   不读取任何接口权限编码,接口鉴权也不会因菜单授权而放行。
+ * - ADMIN 角色通过 sys_role_menu 被授予全部菜单(种子 + 新建菜单自动授予),保证超管全量可见。
+ */
+@Service
+public class MenuService {
+
+    private static final Logger log = LoggerFactory.getLogger(MenuService.class);
+
+    private final DataSourceClient dataSource;
+
+    public MenuService(DataSourceClient dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    /** 初始化菜单表并灌入种子(幂等)。 */
+    public void init() {
+        dataSource.execute(
+                "CREATE TABLE IF NOT EXISTS sys_menu (" +
+                        "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+                        "parent_id INTEGER," +
+                        "name TEXT NOT NULL," +
+                        "type TEXT NOT NULL DEFAULT 'menu'," +
+                        "route TEXT," +
+                        "component TEXT," +
+                        "icon TEXT," +
+                        "sort INTEGER DEFAULT 0," +
+                        "visible INTEGER DEFAULT 1," +
+                        "status INTEGER DEFAULT 1," +
+                        "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+        dataSource.execute(
+                "CREATE TABLE IF NOT EXISTS sys_role_menu (" +
+                        "role_id INTEGER NOT NULL," +
+                        "menu_id INTEGER NOT NULL," +
+                        "PRIMARY KEY (role_id, menu_id))");
+        seedMenus();
+        grantAllToAdmin();
+        log.info("[menu] 初始化完成:sys_menu / sys_role_menu");
+    }
+
+    // ==================== 菜单树(管理端) ====================
+
+    /** 全量菜单树(管理端,含停用/隐藏项)。 */
+    public List<Map<String, Object>> tree() {
+        return buildTree(dataSource.queryForList(
+                "SELECT * FROM sys_menu ORDER BY sort, id"));
+    }
+
+    public Map<String, Object> createMenu(MenuRequest req) {
+        if (req.getName() == null || req.getName().trim().isEmpty()) {
+            throw new AuthException("PARAM", "菜单名称不能为空");
+        }
+        long id = insertMenu(req);
+        // 新菜单自动授权给 ADMIN,保证超级管理员始终可管理
+        Long adminRoleId = roleIdByCode("ADMIN");
+        if (adminRoleId != null) {
+            dataSource.update("INSERT OR IGNORE INTO sys_role_menu(role_id, menu_id) VALUES(?, ?)",
+                    adminRoleId, id);
+        }
+        return dataSource.queryForMap("SELECT * FROM sys_menu WHERE id = ?", id);
+    }
+
+    public Map<String, Object> updateMenu(Long id, MenuRequest req) {
+        Map<String, Object> old = dataSource.queryForMap("SELECT * FROM sys_menu WHERE id = ?", id);
+        if (old == null) {
+            throw new AuthException("NOT_FOUND", "菜单不存在: " + id);
+        }
+        String name = (req.getName() == null || req.getName().trim().isEmpty())
+                ? String.valueOf(old.get("name")) : req.getName().trim();
+        String type = req.getType() != null ? req.getType() : String.valueOf(old.get("type"));
+        int sort = req.getSort() != null ? req.getSort() : intValue(old.get("sort"), 0);
+        int visible = req.getVisible() != null ? req.getVisible() : intValue(old.get("visible"), 1);
+        int status = req.getStatus() != null ? req.getStatus() : intValue(old.get("status"), 1);
+        dataSource.update(
+                "UPDATE sys_menu SET parent_id = ?, name = ?, type = ?, route = ?, component = ?, " +
+                        "icon = ?, sort = ?, visible = ?, status = ? WHERE id = ?",
+                req.getParentId(), name, type, req.getRoute(), req.getComponent(),
+                req.getIcon(), sort, visible, status, id);
+        return dataSource.queryForMap("SELECT * FROM sys_menu WHERE id = ?", id);
+    }
+
+    /** 删除菜单(含其子菜单与角色授权)。 */
+    public void deleteMenu(Long id) {
+        List<Map<String, Object>> children = dataSource.queryForList(
+                "SELECT id FROM sys_menu WHERE parent_id = ?", id);
+        for (Map<String, Object> child : children) {
+            deleteMenu(longValue(child.get("id")));
+        }
+        dataSource.update("DELETE FROM sys_role_menu WHERE menu_id = ?", id);
+        dataSource.update("DELETE FROM sys_menu WHERE id = ?", id);
+    }
+
+    // ==================== 角色-菜单授权 ====================
+
+    public List<Long> roleMenuIds(Long roleId) {
+        List<Map<String, Object>> rows = dataSource.queryForList(
+                "SELECT menu_id FROM sys_role_menu WHERE role_id = ?", roleId);
+        List<Long> ids = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            ids.add(longValue(row.get("menu_id")));
+        }
+        return ids;
+    }
+
+    /** 覆盖式设置某角色拥有的菜单。 */
+    public void assignRoleMenus(Long roleId, List<Long> menuIds) {
+        dataSource.update("DELETE FROM sys_role_menu WHERE role_id = ?", roleId);
+        if (menuIds != null) {
+            List<Object[]> batch = new ArrayList<>();
+            for (Long menuId : menuIds) {
+                if (menuId != null) batch.add(new Object[]{roleId, menuId});
+            }
+            dataSource.batchUpdate("INSERT INTO sys_role_menu(role_id, menu_id) VALUES(?, ?)", batch);
+        }
+    }
+
+    // ==================== 当前用户可见菜单 ====================
+
+    /**
+     * 某用户可见的菜单树:取其所有角色被授予的菜单,向上补齐父级后组装成树,
+     * 再剔除“自身未被授予且无可见子项”的目录(空目录不下发)。
+     */
+    public List<Map<String, Object>> visibleTree(Long userId) {
+        List<Map<String, Object>> grantedRows = dataSource.queryForList(
+                "SELECT DISTINCT m.* FROM sys_menu m " +
+                        "JOIN sys_role_menu rm ON rm.menu_id = m.id " +
+                        "JOIN sys_user_role ur ON ur.role_id = rm.role_id " +
+                        "WHERE ur.user_id = ? AND m.status = 1 AND m.visible = 1 ORDER BY m.sort, m.id",
+                userId);
+        if (grantedRows.isEmpty()) {
+            return new ArrayList<>();
+        }
+        Set<Long> grantSet = new HashSet<>();
+        for (Map<String, Object> row : grantedRows) {
+            grantSet.add(longValue(row.get("id")));
+        }
+        List<Map<String, Object>> all = dataSource.queryForList(
+                "SELECT * FROM sys_menu WHERE status = 1 AND visible = 1 ORDER BY sort, id");
+        if (all.isEmpty()) {
+            return new ArrayList<>();
+        }
+        Set<Long> visibleIds = collectWithAncestors(grantSet, all);
+        List<Map<String, Object>> filtered = new ArrayList<>();
+        for (Map<String, Object> row : all) {
+            if (visibleIds.contains(longValue(row.get("id")))) {
+                filtered.add(row);
+            }
+        }
+        return pruneEmptyDirs(buildTree(filtered), grantSet);
+    }
+
+    // ==================== 内部 ====================
+
+    private void seedMenus() {
+        if (countMenus() > 0) {
+            return;
+        }
+        long dashboard = insertSeed("工作台", "menu", null, "/dashboard", "dashboard/index", "HomeFilled", 1);
+        long system = insertSeed("系统管理", "dir", null, null, null, "Setting", 1);
+        insertSeed("用户管理", "menu", system, "/system/user", "system/user/index", "User", 1);
+        insertSeed("角色管理", "menu", system, "/system/role", "system/role/index", "Avatar", 2);
+        insertSeed("权限管理", "menu", system, "/system/permission", "system/permission/index", "Key", 3);
+        insertSeed("菜单管理", "menu", system, "/system/menu", "system/menu/index", "Menu", 4);
+        insertSeed("数据源演示", "menu", null, "/datasource", "datasource/index", "Coin", 2);
+        log.info("[menu] 已灌入种子菜单(工作台/系统管理 等 {} 个)", countMenus());
+    }
+
+    private void grantAllToAdmin() {
+        Long adminRoleId = roleIdByCode("ADMIN");
+        if (adminRoleId == null) {
+            return;
+        }
+        List<Map<String, Object>> menus = dataSource.queryForList("SELECT id FROM sys_menu");
+        for (Map<String, Object> menu : menus) {
+            dataSource.update("INSERT OR IGNORE INTO sys_role_menu(role_id, menu_id) VALUES(?, ?)",
+                    adminRoleId, longValue(menu.get("id")));
+        }
+    }
+
+    private long insertMenu(MenuRequest req) {
+        dataSource.update(
+                "INSERT INTO sys_menu(parent_id, name, type, route, component, icon, sort, visible, status) " +
+                        "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
+                req.getParentId(),
+                req.getName().trim(),
+                req.getType() != null ? req.getType() : "menu",
+                req.getRoute(),
+                req.getComponent(),
+                req.getIcon(),
+                req.getSort() != null ? req.getSort() : 0,
+                req.getVisible() != null ? req.getVisible() : 1,
+                req.getStatus() != null ? req.getStatus() : 1);
+        return ((Number) dataSource.queryForObject("SELECT last_insert_rowid()")).longValue();
+    }
+
+    private long insertSeed(String name, String type, Long parentId, String route,
+                            String component, String icon, int sort) {
+        dataSource.update(
+                "INSERT INTO sys_menu(parent_id, name, type, route, component, icon, sort, visible, status) " +
+                        "VALUES(?, ?, ?, ?, ?, ?, ?, 1, 1)",
+                parentId, name, type, route, component, icon, sort);
+        return ((Number) dataSource.queryForObject("SELECT last_insert_rowid()")).longValue();
+    }
+
+    private int countMenus() {
+        Object v = dataSource.queryForObject("SELECT COUNT(*) FROM sys_menu");
+        return v == null ? 0 : ((Number) v).intValue();
+    }
+
+    private Long roleIdByCode(String code) {
+        Map<String, Object> row = dataSource.queryForMap(
+                "SELECT id FROM sys_role WHERE code = ?", code);
+        return row == null ? null : longValue(row.get("id"));
+    }
+
+    /** 以 parent_id 组装菜单树,保证每个节点都带 children(叶节点为空列表)。 */
+    private List<Map<String, Object>> buildTree(List<Map<String, Object>> rows) {
+        Map<Long, Map<String, Object>> byId = new LinkedHashMap<>();
+        for (Map<String, Object> row : rows) {
+            row.put("children", new ArrayList<Map<String, Object>>());
+            byId.put(longValue(row.get("id")), row);
+        }
+        List<Map<String, Object>> roots = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            Long parentId = longValue(row.get("parent_id"));
+            Map<String, Object> parent = parentId == null ? null : byId.get(parentId);
+            if (parent == null) {
+                roots.add(row);
+            } else {
+                childrenOf(parent).add(row);
+            }
+        }
+        return roots;
+    }
+
+    /** 向上补齐全部祖先,保证“子菜单可见时其父级目录也出现在树中”。 */
+    private Set<Long> collectWithAncestors(Set<Long> grants, List<Map<String, Object>> all) {
+        Map<Long, Map<String, Object>> byId = new LinkedHashMap<>();
+        for (Map<String, Object> row : all) {
+            byId.put(longValue(row.get("id")), row);
+        }
+        Set<Long> result = new HashSet<>(grants);
+        for (Long g : grants) {
+            Map<String, Object> cur = byId.get(g);
+            while (cur != null) {
+                Long parentId = longValue(cur.get("parent_id"));
+                if (parentId == null) break;
+                result.add(parentId);
+                cur = byId.get(parentId);
+            }
+        }
+        return result;
+    }
+
+    /** 剔除“自身未直接被授予且无可见子项”的目录。 */
+    private List<Map<String, Object>> pruneEmptyDirs(List<Map<String, Object>> nodes, Set<Long> grantSet) {
+        List<Map<String, Object>> kept = new ArrayList<>();
+        for (Map<String, Object> node : nodes) {
+            List<Map<String, Object>> children = pruneEmptyDirs(childrenOf(node), grantSet);
+            node.put("children", children);
+            if (!children.isEmpty() || grantSet.contains(longValue(node.get("id")))) {
+                kept.add(node);
+            }
+        }
+        return kept;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> childrenOf(Map<String, Object> node) {
+        return (List<Map<String, Object>>) node.get("children");
+    }
+
+    private Long longValue(Object v) {
+        return v == null ? null : ((Number) v).longValue();
+    }
+
+    private int intValue(Object v, int def) {
+        return v == null ? def : ((Number) v).intValue();
+    }
+}

+ 14 - 0
src/main/java/com/nylc/touyan/rbac/IdListRequest.java

@@ -0,0 +1,14 @@
+package com.nylc.touyan.rbac;
+
+import java.util.List;
+
+/**
+ * ID 列表请求体(覆盖式授权使用)。
+ */
+public class IdListRequest {
+
+    private List<Long> ids;
+
+    public List<Long> getIds() { return ids; }
+    public void setIds(List<Long> ids) { this.ids = ids; }
+}

+ 20 - 0
src/main/java/com/nylc/touyan/rbac/PermissionRequest.java

@@ -0,0 +1,20 @@
+package com.nylc.touyan.rbac;
+
+/**
+ * 权限创建请求体。
+ */
+public class PermissionRequest {
+
+    private String code;
+    private String name;
+    private String description;
+
+    public String getCode() { return code; }
+    public void setCode(String code) { this.code = code; }
+
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+    public String getDescription() { return description; }
+    public void setDescription(String description) { this.description = description; }
+}

+ 45 - 0
src/main/java/com/nylc/touyan/rbac/RbacAutoConfiguration.java

@@ -0,0 +1,45 @@
+package com.nylc.touyan.rbac;
+
+import com.nylc.touyan.auth.UserService;
+import com.nylc.touyan.menu.MenuService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import javax.annotation.PostConstruct;
+
+/**
+ * RBAC + 菜单权限装配入口:
+ * — 启动时初始化 RBAC 表 + 种子(先建 sys_user,再建角色/权限相关表并灌入管理员);
+ * — 随后初始化菜单表 sys_menu / sys_role_menu 并授权给 ADMIN(顺序在 RBAC 之后,确保角色已存在);
+ * — 注册 {@link RbacInterceptor}(@Order 保证在 AuthInterceptor 之后执行)。
+ * 说明:菜单权限体系独立于 sys_permission(接口权限),此处仅保证建表顺序,不做逻辑耦合。
+ */
+@Order(1)
+@Configuration
+public class RbacAutoConfiguration implements WebMvcConfigurer {
+
+    @Autowired
+    private UserService userService;
+
+    @Autowired
+    private RbacService rbacService;
+
+    @Autowired
+    private MenuService menuService;
+
+    @PostConstruct
+    public void init() {
+        // 先建 sys_user,再建 RBAC 表并灌入种子/管理员(幂等,可重复启动),最后初始化菜单
+        userService.initSchema();
+        rbacService.init();
+        menuService.init();
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(new RbacInterceptor()).addPathPatterns("/api/**");
+    }
+}

+ 133 - 0
src/main/java/com/nylc/touyan/rbac/RbacController.java

@@ -0,0 +1,133 @@
+package com.nylc.touyan.rbac;
+
+import com.nylc.touyan.auth.AuthContext;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RBAC 管理接口(仅 ADMIN 可访问)。
+ * 权限点:角色 CRUD、权限 CRUD、角色授权、用户角色分配、用户权限查询。
+ */
+@RestController
+@RequestMapping("/api/rbac")
+public class RbacController {
+
+    private final RbacService rbacService;
+
+    public RbacController(RbacService rbacService) {
+        this.rbacService = rbacService;
+    }
+
+    // ==================== 角色 ====================
+
+    @GetMapping("/roles")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:list")
+    public List<Map<String, Object>> listRoles() {
+        return rbacService.listRoles();
+    }
+
+    @PostMapping("/roles")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:create")
+    public Map<String, Object> createRole(@RequestBody RoleRequest body) {
+        return rbacService.createRole(body.getCode(), body.getName(), body.getDescription());
+    }
+
+    @PutMapping("/roles/{id}")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:update")
+    public Map<String, Object> updateRole(@PathVariable("id") Long id, @RequestBody RoleRequest body) {
+        return rbacService.updateRole(id, body.getName(), body.getDescription());
+    }
+
+    @DeleteMapping("/roles/{id}")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:delete")
+    public Map<String, Object> deleteRole(@PathVariable("id") Long id) {
+        rbacService.deleteRole(id);
+        Map<String, Object> r = new HashMap<>();
+        r.put("deleted", id);
+        return r;
+    }
+
+    /** 覆盖式配置某角色拥有哪些权限。 */
+    @PostMapping("/roles/{id}/permissions")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:assign")
+    public Map<String, Object> assignRolePermissions(@PathVariable("id") Long id,
+                                                     @RequestBody IdListRequest body) {
+        rbacService.assignRolePermissions(id, body.getIds());
+        return rbacService.getRolePermissions(id);
+    }
+
+    @GetMapping("/roles/{id}/permissions")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:assign")
+    public Map<String, Object> getRolePermissions(@PathVariable("id") Long id) {
+        return rbacService.getRolePermissions(id);
+    }
+
+    // ==================== 权限 ====================
+
+    @GetMapping("/permissions")
+    @RequireRole("ADMIN")
+    @RequirePermission("permission:list")
+    public List<Map<String, Object>> listPermissions() {
+        return rbacService.listPermissions();
+    }
+
+    @PostMapping("/permissions")
+    @RequireRole("ADMIN")
+    @RequirePermission("permission:create")
+    public Map<String, Object> createPermission(@RequestBody PermissionRequest body) {
+        return rbacService.createPermission(body.getCode(), body.getName(), body.getDescription());
+    }
+
+    @DeleteMapping("/permissions/{id}")
+    @RequireRole("ADMIN")
+    @RequirePermission("permission:delete")
+    public Map<String, Object> deletePermission(@PathVariable("id") Long id) {
+        rbacService.deletePermission(id);
+        Map<String, Object> r = new HashMap<>();
+        r.put("deleted", id);
+        return r;
+    }
+
+    // ==================== 用户授权 ====================
+
+    /** 覆盖式设置某用户拥有的角色。 */
+    @PostMapping("/users/{id}/roles")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:assign")
+    public Map<String, Object> assignUserRoles(@PathVariable("id") Long id,
+                                               @RequestBody IdListRequest body) {
+        rbacService.assignUserRoles(id, body.getIds());
+        return rbacService.getUserAuth(id);
+    }
+
+    /** 查询某用户的角色与权限。 */
+    @GetMapping("/users/{id}/auth")
+    @RequireRole("ADMIN")
+    @RequirePermission("role:assign")
+    public Map<String, Object> getUserAuth(@PathVariable("id") Long id) {
+        return rbacService.getUserAuth(id);
+    }
+
+    /** 查询当前登录用户的角色与权限。 */
+    @GetMapping("/me")
+    public Map<String, Object> me() {
+        Long id = AuthContext.requireUser().getId();
+        return rbacService.getUserAuth(id);
+    }
+}

+ 62 - 0
src/main/java/com/nylc/touyan/rbac/RbacInterceptor.java

@@ -0,0 +1,62 @@
+package com.nylc.touyan.rbac;
+
+import com.nylc.touyan.auth.AuthContext;
+import com.nylc.touyan.auth.AuthUser;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+
+/**
+ * 权限拦截器:校验标注了 {@link RequireRole} / {@link RequirePermission} 的方法。
+ * 用户的角色与权限由 {@link AuthContext} 提供(来源于 JWT),无状态校验。
+ * ———————————————————————————————————
+ * 注意:角色/权限变更后需要重新登录取得新 token 才生效。
+ */
+public class RbacInterceptor implements HandlerInterceptor {
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
+                             Object handler) {
+        if (!(handler instanceof HandlerMethod)) {
+            return true;
+        }
+        Method method = ((HandlerMethod) handler).getMethod();
+        boolean needCheck = method.isAnnotationPresent(RequireRole.class)
+                || method.isAnnotationPresent(RequirePermission.class);
+        if (!needCheck) {
+            return true;
+        }
+
+        AuthUser user = AuthContext.getUser();
+        if (user == null) {
+            writeJson(response, 401, "UNAUTHORIZED", "请先登录");
+            return false;
+        }
+
+        RequireRole roleAnno = method.getAnnotation(RequireRole.class);
+        if (roleAnno != null && roleAnno.value().length > 0 && !user.hasAnyRole(roleAnno.value())) {
+            writeJson(response, 403, "FORBIDDEN", "无角色权限");
+            return false;
+        }
+
+        RequirePermission permAnno = method.getAnnotation(RequirePermission.class);
+        if (permAnno != null && permAnno.value().length > 0 && !user.hasAnyPermission(permAnno.value())) {
+            writeJson(response, 403, "FORBIDDEN", "无操作权限");
+            return false;
+        }
+
+        return true;
+    }
+
+    private void writeJson(HttpServletResponse response, int status, String code, String message) {
+        response.setStatus(status);
+        response.setContentType("application/json;charset=UTF-8");
+        try {
+            response.getWriter().write("{\"code\":\"" + code + "\",\"message\":\"" + message + "\"}");
+        } catch (java.io.IOException ignored) {
+        }
+    }
+}

+ 310 - 0
src/main/java/com/nylc/touyan/rbac/RbacService.java

@@ -0,0 +1,310 @@
+package com.nylc.touyan.rbac;
+
+import com.nylc.touyan.auth.AuthException;
+import com.nylc.touyan.datasource.DataSourceClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RBAC(基于角色的访问控制)服务:
+ * - 数据模型:sys_role / sys_permission / sys_user_role / sys_role_permission;
+ * - 启动时建表并灌入种子(内置 ADMIN/USER 角色、初始权限集、默认管理员);
+ * - 提供角色/权限管理与用户角色授权能力;登录后据此解析用户角色与权限。
+ */
+@Service
+public class RbacService {
+
+    private static final Logger log = LoggerFactory.getLogger(RbacService.class);
+
+    private final DataSourceClient dataSource;
+    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
+    private final String adminUsername;
+    private final String adminPassword;
+
+    public RbacService(DataSourceClient dataSource,
+                       com.nylc.touyan.auth.AuthProperties properties) {
+        this.dataSource = dataSource;
+        this.adminUsername = properties.getAdmin().getUsername();
+        this.adminPassword = properties.getAdmin().getPassword();
+    }
+
+    /** 初始化 RBAC 表并灌入种子数据(幂等)。 */
+    public void init() {
+        dataSource.execute(
+                "CREATE TABLE IF NOT EXISTS sys_role (" +
+                        "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+                        "code TEXT NOT NULL UNIQUE," +
+                        "name TEXT NOT NULL," +
+                        "description TEXT," +
+                        "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+        dataSource.execute(
+                "CREATE TABLE IF NOT EXISTS sys_permission (" +
+                        "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+                        "code TEXT NOT NULL UNIQUE," +
+                        "name TEXT NOT NULL," +
+                        "description TEXT," +
+                        "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+        dataSource.execute(
+                "CREATE TABLE IF NOT EXISTS sys_user_role (" +
+                        "user_id INTEGER NOT NULL," +
+                        "role_id INTEGER NOT NULL," +
+                        "PRIMARY KEY (user_id, role_id))");
+        dataSource.execute(
+                "CREATE TABLE IF NOT EXISTS sys_role_permission (" +
+                        "role_id INTEGER NOT NULL," +
+                        "permission_id INTEGER NOT NULL," +
+                        "PRIMARY KEY (role_id, permission_id))");
+
+        ensureRole("ADMIN", "超级管理员", "系统内置,拥有全部权限");
+        ensureRole("USER", "普通用户", "系统内置,默认用户角色");
+
+        List<String> seedPerms = Arrays.asList(
+                "user:list", "user:create", "user:update", "user:delete",
+                "role:list", "role:create", "role:update", "role:delete",
+                "role:assign", "permission:list", "permission:create", "permission:delete",
+                "data:query", "data:write");
+        for (String code : seedPerms) {
+            ensurePermission(code, code, null);
+        }
+
+        Long adminRoleId = roleIdByCode("ADMIN");
+        Long userRoleId = roleIdByCode("USER");
+        List<Long> allPermIds = allPermissionIds();
+        assignRolePermissions(adminRoleId, allPermIds);
+
+        ensureAdminUser();
+
+        log.info("[rbac] 初始化完成:角色 ADMIN/USER,管理员 [{}]", adminUsername);
+    }
+
+    // ==================== 角色 ====================
+
+    public List<Map<String, Object>> listRoles() {
+        return dataSource.queryForList(
+                "SELECT r.id, r.code, r.name, r.description, r.created_at, " +
+                        "(SELECT COUNT(*) FROM sys_user_role ur WHERE ur.role_id = r.id) AS user_count " +
+                        "FROM sys_role r ORDER BY r.id");
+    }
+
+    public Map<String, Object> createRole(String code, String name, String description) {
+        if (code == null || code.trim().isEmpty() || roleIdByCode(code) != null) {
+            throw new AuthException("ROLE_EXISTS", "角色编码为空或已存在: " + code);
+        }
+        int rows = dataSource.update(
+                "INSERT INTO sys_role(code, name, description) VALUES(?, ?, ?)",
+                code, name, description);
+        if (rows <= 0) throw new AuthException("INTERNAL", "创建角色失败");
+        return dataSource.queryForMap(
+                "SELECT id, code, name, description, created_at FROM sys_role WHERE code = ?", code);
+    }
+
+    public Map<String, Object> updateRole(Long roleId, String name, String description) {
+        int rows = dataSource.update(
+                "UPDATE sys_role SET name = ?, description = ? WHERE id = ?",
+                name, description, roleId);
+        if (rows <= 0) throw new AuthException("NOT_FOUND", "角色不存在: " + roleId);
+        return dataSource.queryForMap(
+                "SELECT id, code, name, description, created_at FROM sys_role WHERE id = ?", roleId);
+    }
+
+    public void deleteRole(Long roleId) {
+        dataSource.update("DELETE FROM sys_user_role WHERE role_id = ?", roleId);
+        dataSource.update("DELETE FROM sys_role_permission WHERE role_id = ?", roleId);
+        dataSource.update("DELETE FROM sys_role_menu WHERE role_id = ?", roleId);
+        dataSource.update("DELETE FROM sys_role WHERE id = ?", roleId);
+    }
+
+    // ==================== 权限 ====================
+
+    public List<Map<String, Object>> listPermissions() {
+        return dataSource.queryForList("SELECT id, code, name, description, created_at FROM sys_permission ORDER BY id");
+    }
+
+    public Map<String, Object> createPermission(String code, String name, String description) {
+        if (code == null || code.trim().isEmpty() || permissionIdByCode(code) != null) {
+            throw new AuthException("PERMISSION_EXISTS", "权限编码为空或已存在: " + code);
+        }
+        dataSource.update(
+                "INSERT INTO sys_permission(code, name, description) VALUES(?, ?, ?)",
+                code, name, description);
+        return dataSource.queryForMap(
+                "SELECT id, code, name, description, created_at FROM sys_permission WHERE code = ?", code);
+    }
+
+    public void deletePermission(Long permissionId) {
+        dataSource.update("DELETE FROM sys_role_permission WHERE permission_id = ?", permissionId);
+        dataSource.update("DELETE FROM sys_permission WHERE id = ?", permissionId);
+    }
+
+    // ==================== 授权关系 ====================
+
+    /** 覆盖式设置角色拥有的权限。 */
+    public void assignRolePermissions(Long roleId, List<Long> permissionIds) {
+        dataSource.update("DELETE FROM sys_role_permission WHERE role_id = ?", roleId);
+        if (permissionIds != null) {
+            List<Object[]> batch = new ArrayList<>();
+            for (Long pid : permissionIds) {
+                if (pid != null) batch.add(new Object[]{roleId, pid});
+            }
+            dataSource.batchUpdate("INSERT INTO sys_role_permission(role_id, permission_id) VALUES(?, ?)", batch);
+        }
+    }
+
+    /** 查询某角色拥有的权限。 */
+    public Map<String, Object> getRolePermissions(Long roleId) {
+        List<Map<String, Object>> perms = dataSource.queryForList(
+                "SELECT p.id, p.code, p.name FROM sys_permission p " +
+                        "JOIN sys_role_permission rp ON rp.permission_id = p.id " +
+                        "WHERE rp.role_id = ? ORDER BY p.id", roleId);
+        Map<String, Object> role = dataSource.queryForMap(
+                "SELECT id, code, name FROM sys_role WHERE id = ?", roleId);
+        Map<String, Object> r = new LinkedHashMap<>();
+        r.put("role", role);
+        r.put("permissions", perms);
+        return r;
+    }
+
+    /** 覆盖式设置用户拥有的角色。 */
+    public void assignUserRoles(Long userId, List<Long> roleIds) {
+        dataSource.update("DELETE FROM sys_user_role WHERE user_id = ?", userId);
+        if (roleIds != null) {
+            List<Object[]> batch = new ArrayList<>();
+            for (Long rid : roleIds) {
+                if (rid != null) batch.add(new Object[]{userId, rid});
+            }
+            dataSource.batchUpdate("INSERT INTO sys_user_role(user_id, role_id) VALUES(?, ?)", batch);
+        }
+    }
+
+    /** 查询用户角色(含权限)。 */
+    public Map<String, Object> getUserAuth(Long userId) {
+        List<Map<String, Object>> roles = dataSource.queryForList(
+                "SELECT r.id, r.code, r.name FROM sys_role r " +
+                        "JOIN sys_user_role ur ON ur.role_id = r.id WHERE ur.user_id = ? ORDER BY r.id", userId);
+        List<Map<String, Object>> perms = dataSource.queryForList(
+                "SELECT DISTINCT p.id, p.code, p.name FROM sys_permission p " +
+                        "JOIN sys_role_permission rp ON rp.permission_id = p.id " +
+                        "JOIN sys_user_role ur ON ur.role_id = rp.role_id " +
+                        "WHERE ur.user_id = ? ORDER BY p.id", userId);
+        Map<String, Object> r = new LinkedHashMap<>();
+        r.put("userId", userId);
+        r.put("roles", roles);
+        r.put("permissions", perms);
+        return r;
+    }
+
+    /** 解析某用户去重后的角色编码列表。 */
+    public List<String> roleCodesOf(Long userId) {
+        List<Map<String, Object>> rows = dataSource.queryForList(
+                "SELECT r.code FROM sys_role r " +
+                        "JOIN sys_user_role ur ON ur.role_id = r.id WHERE ur.user_id = ?", userId);
+        List<String> codes = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            codes.add(String.valueOf(row.get("code")));
+        }
+        return codes;
+    }
+
+    /** 解析某用户去重后的权限编码列表。 */
+    public List<String> permissionCodesOf(Long userId) {
+        List<Map<String, Object>> rows = dataSource.queryForList(
+                "SELECT DISTINCT p.code FROM sys_permission p " +
+                        "JOIN sys_role_permission rp ON rp.permission_id = p.id " +
+                        "JOIN sys_user_role ur ON ur.role_id = rp.role_id " +
+                        "WHERE ur.user_id = ?", userId);
+        List<String> codes = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            codes.add(String.valueOf(row.get("code")));
+        }
+        return codes;
+    }
+
+    // ==================== 内部 ====================
+
+    private void ensureRole(String code, String name, String description) {
+        if (roleIdByCode(code) == null) {
+            dataSource.update("INSERT INTO sys_role(code, name, description) VALUES(?, ?, ?)",
+                    code, name, description);
+        }
+    }
+
+    private void ensurePermission(String code, String name, String description) {
+        if (permissionIdByCode(code) == null) {
+            dataSource.update("INSERT INTO sys_permission(code, name, description) VALUES(?, ?, ?)",
+                    code, name, description);
+        }
+    }
+
+    private void ensureAdminUser() {
+        Map<String, Object> exists = dataSource.queryForMap(
+                "SELECT id FROM sys_user WHERE username = ?", adminUsername);
+        Long adminId;
+        if (exists == null) {
+            dataSource.update("INSERT INTO sys_user(username, password, nickname, enabled) VALUES(?, ?, ?, 1)",
+                    adminUsername, encoder.encode(adminPassword), "管理员");
+            adminId = ((Number) dataSource.queryForObject(
+                    "SELECT id FROM sys_user WHERE username = ?", adminUsername)).longValue();
+        } else {
+            adminId = ((Number) exists.get("id")).longValue();
+        }
+        Long adminRoleId = roleIdByCode("ADMIN");
+        if (adminRoleId != null && userHasRole(adminId, adminRoleId)) {
+            return;
+        }
+        assignUserRoles(adminId, java.util.Collections.singletonList(adminRoleId));
+    }
+
+    private boolean userHasRole(Long userId, Long roleId) {
+        return dataSource.queryForMap(
+                "SELECT 1 FROM sys_user_role WHERE user_id = ? AND role_id = ?",
+                userId, roleId) != null;
+    }
+
+    private Long roleIdByCode(String code) {
+        Map<String, Object> row = dataSource.queryForMap(
+                "SELECT id FROM sys_role WHERE code = ?", code);
+        return row == null ? null : ((Number) row.get("id")).longValue();
+    }
+
+    private Long permissionIdByCode(String code) {
+        Map<String, Object> row = dataSource.queryForMap(
+                "SELECT id FROM sys_permission WHERE code = ?", code);
+        return row == null ? null : ((Number) row.get("id")).longValue();
+    }
+
+    private List<Long> allPermissionIds() {
+        List<Map<String, Object>> rows = dataSource.queryForList("SELECT id FROM sys_permission");
+        List<Long> ids = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            ids.add(((Number) row.get("id")).longValue());
+        }
+        return ids;
+    }
+
+    /** 当前用户权限(供登录时嵌入 token)。 */
+    public AuthSnapshot resolve(Long userId) {
+        return new AuthSnapshot(roleCodesOf(userId), permissionCodesOf(userId));
+    }
+
+    /** 角色 + 权限编码集合快照。 */
+    public static class AuthSnapshot {
+        private final List<String> roles;
+        private final List<String> permissions;
+
+        public AuthSnapshot(List<String> roles, List<String> permissions) {
+            this.roles = roles == null ? new ArrayList<>() : roles;
+            this.permissions = permissions == null ? new ArrayList<>() : permissions;
+        }
+
+        public List<String> getRoles() { return roles; }
+        public List<String> getPermissions() { return permissions; }
+    }
+}

+ 19 - 0
src/main/java/com/nylc/touyan/rbac/RequirePermission.java

@@ -0,0 +1,19 @@
+package com.nylc.touyan.rbac;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标注在 Controller 方法上:要求当前用户拥有任一指定权限编码。
+ * 例如 {@code @RequirePermission("user:create")}。
+ * 权限编码来自 sys_permission 表,通过角色授权给用户。
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface RequirePermission {
+    String[] value();
+}

+ 18 - 0
src/main/java/com/nylc/touyan/rbac/RequireRole.java

@@ -0,0 +1,18 @@
+package com.nylc.touyan.rbac;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标注在 Controller 方法上:要求当前用户拥有任一指定角色。
+ * 例如 {@code @RequireRole("ADMIN")} 或 {@code @RequireRole({"ADMIN","USER"})}。
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface RequireRole {
+    String[] value();
+}

+ 20 - 0
src/main/java/com/nylc/touyan/rbac/RoleRequest.java

@@ -0,0 +1,20 @@
+package com.nylc.touyan.rbac;
+
+/**
+ * 角色创建/更新请求体。
+ */
+public class RoleRequest {
+
+    private String code;
+    private String name;
+    private String description;
+
+    public String getCode() { return code; }
+    public void setCode(String code) { this.code = code; }
+
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+    public String getDescription() { return description; }
+    public void setDescription(String description) { this.description = description; }
+}

+ 35 - 2
src/main/resources/application.yml

@@ -5,7 +5,7 @@ server:
 
 spring:
   application:
-    name: mf-backend
+    name: ir-backend
   # 多应用部署时关闭依赖 JMX 的 MBean 冲突(东方通常见问题)
   jmx:
     enabled: false
@@ -13,6 +13,39 @@ spring:
     date-format: yyyy-MM-dd HH:mm:ss
     time-zone: GMT+8
 
+# 自定义:数据源配置(通过 type 切换:sqlite / mysql …)
+ir:
+  datasource:
+    type: sqlite
+    sqlite:
+      # 数据库文件路径,相对工作目录(也可写绝对路径)
+      path: data/ir.db
+      # 启动时自动创建演示表 demo_user(生产可置 false,由 Flyway/Liquibase 接管)
+      init-demo-table: true
+    mysql:
+      url: jdbc:mysql://localhost:3306/ir?useUnicode=true&characterEncoding=utf8&useSSL=false
+      username: root
+      password: root
+
+  # 鉴权配置(用户注册/登录,JWT 无状态鉴权)
+  auth:
+    jwt:
+      # HMAC 密钥,至少 32 字节;生产务必通过环境变量注入,勿写死
+      secret: touyan-default-secret-please-change-in-production-256bit
+      # Token 有效期(秒),默认 2 小时
+      expiration-seconds: 7200
+      header: Authorization
+      prefix: "Bearer "
+    # 内置管理员:首次启动自动创建并授予 ADMIN 角色(含全部权限)
+    admin:
+      username: admin
+      password: admin123
+    # 无需登录即可访问的路径
+    skip-paths:
+      - /api/auth/login
+      - /api/auth/register
+      - /api/datasource/info
+
 logging:
   level:
-    com.demo.mf: info
+    com.nylc.touyan: info