Explorar o código

feat: 同步个人分支code目录到master

jiapu hai 5 meses
pai
achega
5d18855522
Modificáronse 100 ficheiros con 7292 adicións e 0 borrados
  1. 23 0
      code/.gitignore
  2. 306 0
      code/AGENTS.md
  3. 0 0
      code/README.md
  4. 127 0
      code/backend/pom.xml
  5. 19 0
      code/backend/src/main/java/com/aijiuyi/admin/AijiuyiAdminApplication.java
  6. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/common/annotation/Log.java
  7. 14 0
      code/backend/src/main/java/com/aijiuyi/admin/common/annotation/NoAuth.java
  8. 176 0
      code/backend/src/main/java/com/aijiuyi/admin/common/aspect/LogAspect.java
  9. 105 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/AcupointSchemaInitializer.java
  10. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/AsyncConfig.java
  11. 29 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/AuthProperties.java
  12. 29 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/MybatisPlusConfig.java
  13. 207 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/PlanDataInitializer.java
  14. 55 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/RedisConfig.java
  15. 47 0
      code/backend/src/main/java/com/aijiuyi/admin/common/config/WebMvcConfig.java
  16. 145 0
      code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java
  17. 108 0
      code/backend/src/main/java/com/aijiuyi/admin/common/context/RequestContext.java
  18. 89 0
      code/backend/src/main/java/com/aijiuyi/admin/common/entity/Result.java
  19. 57 0
      code/backend/src/main/java/com/aijiuyi/admin/common/entity/SysLog.java
  20. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/common/enums/OperationType.java
  21. 47 0
      code/backend/src/main/java/com/aijiuyi/admin/common/exception/BusinessException.java
  22. 91 0
      code/backend/src/main/java/com/aijiuyi/admin/common/exception/GlobalExceptionHandler.java
  23. 42 0
      code/backend/src/main/java/com/aijiuyi/admin/common/handler/MyMetaObjectHandler.java
  24. 162 0
      code/backend/src/main/java/com/aijiuyi/admin/common/interceptor/TokenInterceptor.java
  25. 47 0
      code/backend/src/main/java/com/aijiuyi/admin/common/service/SysLogService.java
  26. 102 0
      code/backend/src/main/java/com/aijiuyi/admin/common/service/impl/SysLogServiceImpl.java
  27. 275 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointLocationParser.java
  28. 216 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointYReference.java
  29. 57 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/IpUtil.java
  30. 97 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/LogUtil.java
  31. 327 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/RedisUtil.java
  32. 133 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/TokenUtil.java
  33. 144 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AcupointController.java
  34. 147 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AdminController.java
  35. 122 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserController.java
  36. 91 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AuthController.java
  37. 186 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/DeviceController.java
  38. 119 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/MoxibustionTechniqueController.java
  39. 154 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/PlanController.java
  40. 36 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/SimulationController.java
  41. 63 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/SysLogController.java
  42. 48 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/UserAcupointController.java
  43. 115 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/UserDeviceController.java
  44. 51 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/UserPlanController.java
  45. 140 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/UserProfileController.java
  46. 28 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AcupointQueryDTO.java
  47. 28 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AdminQueryDTO.java
  48. 28 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppUserQueryDTO.java
  49. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java
  50. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java
  51. 20 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/LoginDTO.java
  52. 25 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/MoxibustionTechniqueQueryDTO.java
  53. 37 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanQueryDTO.java
  54. 58 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanSaveDTO.java
  55. 63 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/SimulationResponse.java
  56. 32 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/SysLogQueryDTO.java
  57. 28 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserDeviceQueryDTO.java
  58. 19 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserPlanQueryDTO.java
  59. 68 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java
  60. 34 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileQueryDTO.java
  61. 27 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/YReferenceOptionDTO.java
  62. 126 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/Acupoint.java
  63. 41 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/AdminPermission.java
  64. 65 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/AppUser.java
  65. 70 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java
  66. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceUserVO.java
  67. 90 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/MoxibustionTechnique.java
  68. 90 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/Plan.java
  69. 64 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/PlanStep.java
  70. 52 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/SysUser.java
  71. 62 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserAcupoint.java
  72. 55 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserDevice.java
  73. 61 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserPlan.java
  74. 81 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserProfile.java
  75. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/AcupointMapper.java
  76. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/AdminPermissionMapper.java
  77. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/AppUserMapper.java
  78. 36 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceMapper.java
  79. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/MoxibustionTechniqueMapper.java
  80. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/PlanMapper.java
  81. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/PlanStepMapper.java
  82. 36 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/SysLogMapper.java
  83. 13 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/SysUserMapper.java
  84. 38 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointMapper.java
  85. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/UserDeviceMapper.java
  86. 22 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/UserPlanMapper.java
  87. 26 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/UserProfileMapper.java
  88. 65 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AcupointService.java
  89. 83 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AdminService.java
  90. 71 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AppUserService.java
  91. 99 0
      code/backend/src/main/java/com/aijiuyi/admin/service/DeviceService.java
  92. 50 0
      code/backend/src/main/java/com/aijiuyi/admin/service/MoxibustionTechniqueService.java
  93. 96 0
      code/backend/src/main/java/com/aijiuyi/admin/service/PlanService.java
  94. 19 0
      code/backend/src/main/java/com/aijiuyi/admin/service/SimulationService.java
  95. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/service/SysUserService.java
  96. 37 0
      code/backend/src/main/java/com/aijiuyi/admin/service/UserAcupointService.java
  97. 59 0
      code/backend/src/main/java/com/aijiuyi/admin/service/UserDeviceService.java
  98. 33 0
      code/backend/src/main/java/com/aijiuyi/admin/service/UserPlanService.java
  99. 67 0
      code/backend/src/main/java/com/aijiuyi/admin/service/UserProfileService.java
  100. 217 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java

+ 23 - 0
code/.gitignore

@@ -0,0 +1,23 @@
+# Java / Maven
+target/
+*.class
+*.jar
+*.war
+*.ear
+
+# IDE
+.idea/
+*.iml
+.classpath
+.project
+.settings/
+.vscode/
+
+# Node.js
+node_modules/
+dist/
+
+# OS
+.DS_Store
+Thumbs.db
+openspec/.qoder

+ 306 - 0
code/AGENTS.md

@@ -0,0 +1,306 @@
+# Agent Guidelines
+
+## 项目概述
+
+**艾灸椅智能穴位定位系统** — 基于用户体型数据,实现精准穴位定位,控制艾灸椅对准穴位进行理疗。
+
+### 核心文档
+
+| 文档 | 路径 | 说明 |
+|------|------|------|
+| 总需求文档 | `需求工作区/需求材料/艾灸椅功能定义-最新.xlsx` | 艾灸椅硬件+APP完整功能定义 |
+| 接口协议 | `需求工作区/需求材料/艾炙椅接口篇.docx` | APP与设备数据接口协议 |
+| 当前功能 | `需求工作区/需求材料/艾灸取穴智能穴位识别功能列表.xlsx` | 智能穴位识别功能清单 |
+| 设计文档 | `需求工作区/需求材料/design-docs/` | 功能提案、算法设计、用户流程 |
+
+### 项目结构
+
+```
+D:\AI工作台\ajy\
+├── AGENTS.md                    # 本文件 - Agent协作指南
+├── 需求工作区/                   # 需求分析工作区
+│   ├── 需求材料/                # 原始需求文档
+│   │   ├── 艾灸椅功能定义-最新.xlsx
+│   │   ├── 艾炙椅接口篇.docx
+│   │   ├── 艾灸取穴智能穴位识别功能列表.xlsx
+│   │   └── design-docs/        # 设计文档
+│   │       ├── 穴位定位功能提案.md
+│   │       ├── 穴位定位任务清单.md
+│   │       ├── 穴位定位算法设计.md
+│   │       └── 穴位定位用户流程.md
+│   ├── 模版文件/                # 文档模板
+│   └── 需求设计文档/            # 输出设计文档
+└── openspec/                    # 技术实现规范
+    └── aijiyiBackend/          # 后端项目
+```
+
+---
+
+## AI 角色分工
+
+本项目采用**多角色协作**模式,当前AI承担以下角色:
+
+### 角色定义
+
+| 角色 | 职责 | 触发场景 |
+|------|------|----------|
+| **产品经理** | 产品方向定义、优先级排序、业务价值分析 | 产品规划、需求评审 |
+| **需求分析师** | 需求挖掘、用户故事、验收标准定义 | 需求分析、需求澄清 |
+| **系统分析员** | 架构设计、技术方案、接口规范 | 技术选型、系统设计 |
+| **测试工程师** | 测试策略、用例设计、验收测试 | 测试计划、质量保障 |
+
+> **注意**:AI不负责编码实现(开发人员),如需编码请另外安排。
+
+### 协作流程
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│                        需求开发流程                                  │
+├─────────────────────────────────────────────────────────────────────┤
+│                                                                     │
+│  需求阶段          设计阶段          验证阶段          交付阶段      │
+│      │                │                │                │          │
+│      ▼                ▼                ▼                ▼          │
+│  ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐        │
+│  │产品经理  │───▶│系统分析员│───▶│测试工程师│───▶│产品经理  │        │
+│  │需求分析师│    │         │    │         │    │         │        │
+│  └─────────┘    └─────────┘    └─────────┘    └─────────┘        │
+│      │                │                │                │          │
+│      │ 1.需求分析     │ 2.技术方案     │ 3.测试用例     │ 4.验收    │
+│      │ 2.用户故事     │ 3.接口设计     │ 4.测试执行     │ 5.交付    │
+│      │ 3.验收标准     │ 4.数据模型     │                │          │
+│      │                │                │                │          │
+│      └────────────────┴────────────────┴────────────────┘          │
+│                                                                     │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+### 角色职责详解
+
+#### 产品经理
+
+**核心职责**:
+- 定义产品愿景和目标
+- 优先级排序和路线图规划
+- 业务价值分析和ROI评估
+- 用户体验和交互设计指导
+- 市场竞品分析
+
+**工作产出**:
+```
+需求工作区/
+├── 产品愿景.md           # 产品方向和目标
+├── 功能优先级.md         # 功能排序和版本规划
+└── 用户画像.md           # 目标用户分析
+```
+
+#### 需求分析师
+
+**核心职责**:
+- 分析原始需求文档,提取关键信息
+- 将业务需求转化为用户故事
+- 定义验收标准和边界条件
+- 澄清需求歧义,确认假设
+- 输出结构化的需求文档
+
+**工作产出**:
+```
+需求工作区/需求设计文档/
+├── {feature}-proposal.md         # 功能提案
+├── {feature}-user-stories.md     # 用户故事
+├── {feature}-acceptance.md       # 验收标准
+└── {feature}-edge-cases.md       # 边界条件
+```
+
+#### 系统分析员
+
+**核心职责**:
+- 技术选型和架构设计
+- 定义API接口规范
+- 设计数据模型和数据库schema
+- 识别技术风险和依赖
+- 输出技术设计方案
+
+**工作产出**:
+```
+需求工作区/需求设计文档/
+├── {feature}-design.md           # 技术设计方案
+├── {feature}-api-spec.yaml       # API接口定义
+├── {feature}-data-model.md       # 数据模型设计
+└── {feature}-tasks.md            # 开发任务拆解
+```
+
+#### 测试工程师
+
+**核心职责**:
+- 根据验收标准设计测试用例
+- 定义测试场景(正常流、异常流、边界条件)
+- 编写测试脚本
+- 执行测试并报告结果
+- 质量保障和风险评估
+
+**工作产出**:
+```
+需求工作区/需求设计文档/
+├── {feature}-test-strategy.md    # 测试策略
+├── {feature}-test-cases.md       # 测试用例
+└── {feature}-test-report.md      # 测试报告
+```
+
+### 角色切换规则
+
+在对话中通过角色声明切换:
+
+```
+[产品经理] 定义产品愿景和优先级...
+[需求分析师] 分析用户登录功能需求...
+[系统分析员] 设计用户认证架构方案...
+[测试工程师] 设计登录功能测试用例...
+```
+
+**默认角色**:根据任务类型自动识别
+- 产品规划 → 产品经理
+- 需求澄清 → 需求分析师
+- 架构讨论 → 系统分析员
+- 测试验收 → 测试工程师
+
+### 质量门禁
+
+每个阶段必须满足以下条件才能进入下一阶段:
+
+| 阶段 | 进入条件 | 输出物 |
+|------|----------|--------|
+| 需求 → 设计 | 需求文档已确认,验收标准已定义 | proposal.md, acceptance.md |
+| 设计 → 开发 | 技术方案已评审,任务已拆解 | design.md, tasks.md |
+| 开发 → 验收 | 代码实现完成,单元测试通过 | 源代码, 单元测试 |
+| 验收 → 完成 | 验收测试通过,无阻塞性缺陷 | 测试报告 |
+
+---
+
+## 当前功能:智能穴位定位
+
+### 功能概述
+
+基于用户体型数据(肩宽、身长、指寸),计算12个常用穴位的相对位置,生成穴位对照表,支持艾灸椅精准定位穴位进行理疗。
+
+### 核心算法
+
+1. **指寸估算**:当用户未提供指寸数据时,根据肩宽和身长估算
+   - 男性:肩宽权重 0.6,身长权重 0.4
+   - 女性:肩宽权重 0.4,身长权重 0.6
+
+2. **穴位计算**:以大椎穴为基准点,计算各穴位相对偏移
+   - 督脉穴位(正中):仅纵向偏移
+   - 膀胱经穴位(双侧):纵向+横向偏移
+
+3. **置信度评分**:根据数据完整度评估定位精度
+   - 仅肩宽+身长:50%置信度,±5cm精度
+   - 完整指寸数据:95%置信度,±2cm精度
+
+### 支持的穴位(12个)
+
+| 穴位 | 经络 | 相对大椎穴位置 | 功效 |
+|------|------|----------------|------|
+| 大椎穴 | 督脉 | 基准点 (0, 0) | 驱寒、祛湿、解表 |
+| 身柱穴 | 督脉 | 下3寸 | 止咳平喘、安神 |
+| 至阳穴 | 督脉 | 下7寸 | 宽胸理气 |
+| 命门穴 | 督脉 | 下14寸 | 补肾壮阳 |
+| 腰阳关穴 | 督脉 | 下17寸 | 强腰健肾 |
+| 风门穴(双) | 膀胱经 | 下1.5寸,旁开1.5寸 | 祛风解表 |
+| 秉风穴(双) | 小肠经 | 下3寸,旁开3寸 | 祛风散寒 |
+| 肺俞穴(双) | 膀胱经 | 下3寸,旁开1.5寸 | 化痰止咳 |
+| 膈俞穴(双) | 膀胱经 | 下7寸,旁开1.5寸 | 活血化瘀 |
+| 脾俞穴(双) | 膀胱经 | 下11寸,旁开1.5寸 | 健脾养血 |
+| 肾俞穴(双) | 膀胱经 | 下14寸,旁开1.5寸 | 补肾益气 |
+| 八髎穴 | 膀胱经 | 下16寸 | 调理下焦 |
+
+### 数据验证规则
+
+| 字段 | 男性范围 | 女性范围 | 单位 |
+|------|---------|---------|------|
+| 肩宽 | 36-52 | 32-46 | cm |
+| 身长(坐高) | 75-95 | 70-88 | cm |
+| 一寸 | 1.5-2.8 | 1.3-2.4 | cm |
+| 1.5寸 | 2.2-4.0 | 2.0-3.5 | cm |
+| 三寸 | 4.5-8.0 | 4.0-7.0 | cm |
+
+### 验收标准
+
+1. 穴位定位精度 ≤ 2cm(指寸数据完整时)
+2. 经络图支持点击、缩放交互
+3. 症状推荐覆盖率 80%+
+4. API响应时间 < 200ms
+
+---
+
+## 通信协议
+
+### 蓝牙BLE 5.0
+
+| 特征值UUID | 用途 | 方向 |
+|------------|------|------|
+| `0000FFF1-0000-1000-8000-00805F9B34FB` | 命令写入 | APP → 设备 |
+| `0000FFF2-0000-1000-8000-00805F9B34FB` | 数据通知 | 设备 → APP |
+| `0000FFF3-0000-1000-8000-00805F9B34FB` | 用户档案写入 | APP → 设备 |
+| `0000FFF4-0000-1000-8000-00805F9B34FB` | 穴位对照表通知 | 设备 → APP |
+
+### 命令类型
+
+| 命令码 | 名称 | 说明 |
+|--------|------|------|
+| `0x01` | AUTH_REQUEST | 连接认证请求 |
+| `0x02` | AUTH_RESPONSE | 认证响应 |
+| `0x10` | GET_USER_LIST | 获取用户列表 |
+| `0x11` | CREATE_USER | 创建用户 |
+| `0x12` | SELECT_USER | 选择用户 |
+| `0x13` | DELETE_USER | 删除用户 |
+| `0x20` | START_DETECTION | 开始检测 |
+| `0x21` | DAZHUI_POSITION | 大椎穴位置上报 |
+| `0x22` | ACUPOINT_TABLE_SYNC | 穴位对照表同步 |
+| `0x30` | START_TREATMENT | 开始治疗 |
+| `0x31` | PAUSE_TREATMENT | 暂停治疗 |
+| `0x32` | RESUME_TREATMENT | 恢复治疗 |
+| `0x33` | END_TREATMENT | 结束治疗 |
+| `0x40` | DEVICE_STATUS | 设备状态查询 |
+| `0x41` | ERROR_REPORT | 错误上报 |
+
+---
+
+## API接口设计
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| POST | `/api/users/:id/acupoint-table` | 生成用户穴位对照表 |
+| POST | `/api/users/:id/acupoint-table/validate` | 验证输入数据 |
+| GET | `/api/users/:id/acupoint-table` | 获取用户穴位对照表 |
+| POST | `/api/treatment-sessions` | 创建治疗会话 |
+| POST | `/api/treatment-sessions/:id/start` | 开始治疗 |
+| GET | `/api/acupoints` | 获取所有穴位基础数据 |
+| GET | `/api/acupoints/:id` | 获取单个穴位详情 |
+
+---
+
+## Git 规范
+
+- 使用功能分支开发
+- 提交信息格式:`type(scope): description`
+  - `feat`: 新功能
+  - `fix`: 修复bug
+  - `docs`: 文档更新
+  - `refactor`: 代码重构
+  - `test`: 测试相关
+- 推送前运行代码检查(配置后)
+
+## 安全规范
+
+- 不提交密钥、API密钥或凭证
+- 使用环境变量存储敏感配置
+- 验证和清理所有用户输入
+
+---
+
+## 参考标准
+
+- **GB/T 12346-2006**《腧穴名称与定位》
+- **GB/T 10000-1988**《中国成年人人体尺寸》
+- 《针灸学》教材(穴位定位方法)

+ 0 - 0
code/README.md


+ 127 - 0
code/backend/pom.xml

@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.aijiuyi</groupId>
+    <artifactId>aijiuyi-admin</artifactId>
+    <version>1.0.0</version>
+    <packaging>jar</packaging>
+    <name>aijiuyi-admin</name>
+    <description>艾灸椅管理后台</description>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <properties>
+        <java.version>1.8</java.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
+        <jwt.version>0.9.1</jwt.version>
+        <fastjson.version>1.2.83</fastjson.version>
+    </properties>
+
+    <dependencies>
+        <!-- Spring Boot Web -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- Spring Boot AOP -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Redis -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+
+        <!-- Redis 连接池 -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-pool2</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Validation -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <!-- MySQL Driver -->
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <version>8.0.33</version>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- MyBatis Plus -->
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-boot-starter</artifactId>
+            <version>${mybatis-plus.version}</version>
+        </dependency>
+
+        <!-- JWT -->
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt</artifactId>
+            <version>${jwt.version}</version>
+        </dependency>
+
+        <!-- FastJSON -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+            <version>${fastjson.version}</version>
+        </dependency>
+
+        <!-- Lombok -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- Commons Lang3 -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Test -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 19 - 0
code/backend/src/main/java/com/aijiuyi/admin/AijiuyiAdminApplication.java

@@ -0,0 +1,19 @@
+package com.aijiuyi.admin;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+/**
+ * 艾灸椅管理后台启动类
+ */
+@SpringBootApplication
+@MapperScan("com.aijiuyi.admin.mapper")
+@EnableAsync
+public class AijiuyiAdminApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(AijiuyiAdminApplication.class, args);
+    }
+}

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/annotation/Log.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.common.annotation;
+
+import com.aijiuyi.admin.common.enums.OperationType;
+
+import java.lang.annotation.*;
+
+/**
+ * 操作日志注解
+ * 标注在 Controller 方法上,记录接口说明、模块和操作类型到操作日志表
+ * 示例:@Log(value = "查询用户列表", module = "用户管理", operationType = OperationType.QUERY)
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface Log {
+
+    /**
+     * 接口说明(将记录到日志表的 api_desc 字段)
+     */
+    String value() default "";
+
+    /**
+     * 操作模块(将记录到日志表的 operation_module 字段)
+     */
+    String module() default "";
+
+    /**
+     * 操作类型,默认 OTHER
+     */
+    OperationType operationType() default OperationType.OTHER;
+}

+ 14 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/annotation/NoAuth.java

@@ -0,0 +1,14 @@
+package com.aijiuyi.admin.common.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * 免登录注解(不需要 Token 鉴权)
+ * 可标注在 Controller 类或方法上,标注后该接口不需要携带 Token 即可访问
+ * 示例:@NoAuth 标注在登录接口方法上
+ */
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface NoAuth {
+}

+ 176 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/aspect/LogAspect.java

@@ -0,0 +1,176 @@
+package com.aijiuyi.admin.common.aspect;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.context.RequestContext;
+import com.aijiuyi.admin.common.entity.SysLog;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.common.service.SysLogService;
+import com.aijiuyi.admin.common.util.IpUtil;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.alibaba.fastjson.JSON;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+import java.time.LocalDateTime;
+
+/**
+ * 操作日志 AOP 切面
+ * 拦截所有 @RestController 和 @Controller 类中的方法:
+ * 1. 自动记录请求URL、IP、参数、返回值、耗时到 sys_log 表
+ * 2. 如果方法上有 @Log 注解,还会记录接口说明、模块、操作类型
+ * 3. 没有 @Log 注解时,操作类型根据 HTTP Method 自动推断
+ * 日志保存为异步操作,不影响接口响应速度
+ */
+@Aspect
+@Component
+public class LogAspect {
+
+    @Autowired
+    private SysLogService sysLogService;
+
+    /**
+     * 切点:所有 @RestController 和 @Controller 类中的方法
+     */
+    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *) " +
+              "|| within(@org.springframework.stereotype.Controller *)")
+    public void controllerPointcut() {
+    }
+
+    /**
+     * 环绕通知:拦截所有 Controller 方法,记录操作日志
+     *
+     * @param point 连接点
+     * @return 方法执行结果
+     * @throws Throwable 异常
+     */
+    @Around("controllerPointcut()")
+    public Object around(ProceedingJoinPoint point) throws Throwable {
+        long startTime = System.currentTimeMillis();
+
+        // 获取当前 HTTP 请求
+        HttpServletRequest request = null;
+        try {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            if (attributes != null) {
+                request = attributes.getRequest();
+            }
+        } catch (Exception e) {
+            LogUtil.warn(LogAspect.class, "获取 HttpServletRequest 失败: {}", e.getMessage());
+        }
+
+        // 获取方法签名和 @Log 注解
+        MethodSignature signature = (MethodSignature) point.getSignature();
+        Method method = signature.getMethod();
+        Log logAnnotation = method.getAnnotation(Log.class);
+
+        // 构建日志对象
+        SysLog sysLog = new SysLog();
+        sysLog.setRequestId(RequestContext.getRequestId());
+        sysLog.setUserId(RequestContext.getUserId());
+        sysLog.setUserName(RequestContext.getUserName());
+        sysLog.setUserType(RequestContext.getUserType());
+        sysLog.setCreateTime(LocalDateTime.now());
+
+        // 设置请求URL和IP
+        if (request != null) {
+            sysLog.setRequestUrl(request.getRequestURI());
+            sysLog.setRequestIp(IpUtil.getIpAddr(request));
+        }
+
+        // 序列化请求参数(过滤掉文件、请求、响应等不可序列化的参数)
+        try {
+            Object[] args = point.getArgs();
+            if (args != null && args.length > 0) {
+                Object[] serializableArgs = java.util.Arrays.stream(args)
+                        .filter(arg -> !(arg instanceof MultipartFile)
+                                && !(arg instanceof HttpServletRequest)
+                                && !(arg instanceof HttpServletResponse))
+                        .toArray();
+                if (serializableArgs.length > 0) {
+                    sysLog.setRequestParams(JSON.toJSONString(serializableArgs));
+                }
+            }
+        } catch (Exception e) {
+            LogUtil.warn(LogAspect.class, "序列化请求参数失败: {}", e.getMessage());
+        }
+
+        // 填充 @Log 注解中的信息(如无注解,根据 HTTP Method 推断操作类型)
+        if (logAnnotation != null) {
+            // 使用注解中定义的信息
+            sysLog.setApiDesc(logAnnotation.value());
+            sysLog.setOperationModule(logAnnotation.module());
+            sysLog.setOperationType(logAnnotation.operationType().getDesc());
+        } else {
+            // 根据 HTTP Method 自动推断操作类型
+            if (request != null) {
+                sysLog.setOperationType(inferOperationType(request.getMethod()));
+            }
+        }
+
+        // 执行目标方法
+        Object result = null;
+        try {
+            result = point.proceed();
+            // 记录返回结果(截断过长的响应)
+            try {
+                String resultStr = JSON.toJSONString(result);
+                // 超过2000字符则截断
+                if (resultStr.length() > 2000) {
+                    resultStr = resultStr.substring(0, 2000) + "...(已截断)";
+                }
+                sysLog.setRequestResult(resultStr);
+            } catch (Exception e) {
+                LogUtil.warn(LogAspect.class, "序列化返回结果失败: {}", e.getMessage());
+            }
+        } catch (Throwable throwable) {
+            // 记录异常信息
+            sysLog.setRequestResult("异常: " + throwable.getMessage());
+            throw throwable;
+        } finally {
+            // 记录接口耗时日志
+            long costTime = System.currentTimeMillis() - startTime;
+            LogUtil.info(LogAspect.class, "接口[{}] 耗时[{}ms]", sysLog.getRequestUrl(), costTime);
+            // 异步保存日志(不阻塞主流程)
+            try {
+                sysLogService.saveLog(sysLog);
+            } catch (Exception e) {
+                LogUtil.error(LogAspect.class, "保存操作日志失败: {}", e.getMessage());
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * 根据 HTTP Method 推断操作类型
+     *
+     * @param httpMethod HTTP 请求方法(GET/POST/PUT/DELETE等)
+     * @return 操作类型描述
+     */
+    private String inferOperationType(String httpMethod) {
+        switch (httpMethod.toUpperCase()) {
+            case "GET":
+                return OperationType.QUERY.getDesc();
+            case "POST":
+                return OperationType.INSERT.getDesc();
+            case "PUT":
+            case "PATCH":
+                return OperationType.UPDATE.getDesc();
+            case "DELETE":
+                return OperationType.DELETE.getDesc();
+            default:
+                return OperationType.OTHER.getDesc();
+        }
+    }
+}

+ 105 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/AcupointSchemaInitializer.java

@@ -0,0 +1,105 @@
+package com.aijiuyi.admin.common.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+/**
+ * Keeps legacy acupoint tables compatible with the current relative-coordinate parser.
+ */
+@Component
+@Order(0)
+public class AcupointSchemaInitializer implements CommandLineRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(AcupointSchemaInitializer.class);
+    private static final String TABLE_NAME = "acupoint";
+
+    private final DataSource dataSource;
+
+    public AcupointSchemaInitializer(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void run(String... args) {
+        try (Connection connection = dataSource.getConnection()) {
+            if (!tableExists(connection, TABLE_NAME)) {
+                log.info("Skip acupoint schema initialization because table [{}] does not exist", TABLE_NAME);
+                return;
+            }
+
+            addColumnIfMissing(connection, "parse_status",
+                    "varchar(32) DEFAULT NULL COMMENT 'Acupoint parser status'");
+            addColumnIfMissing(connection, "parse_confidence",
+                    "varchar(16) DEFAULT NULL COMMENT 'Acupoint parser confidence'");
+            addColumnIfMissing(connection, "parse_message",
+                    "varchar(500) DEFAULT NULL COMMENT 'Acupoint parser message'");
+
+            executeUpdate(connection, "UPDATE acupoint SET location_type = 1 WHERE deleted = 0 AND (location_type IS NULL OR location_type <> 1)");
+            executeUpdate(connection, "UPDATE acupoint SET parse_status = 'parsed' WHERE deleted = 0 AND parse_status IS NULL AND y_reference_term IS NOT NULL AND normalized_y IS NOT NULL AND (side = 1 OR lateral_offset IS NOT NULL OR acupoint_code IN ('pangguang-31','pangguang-32','pangguang-33','pangguang-34') OR name IN ('上髎','次髎','中髎','下髎'))");
+            executeUpdate(connection, "UPDATE acupoint SET parse_status = 'review_required' WHERE deleted = 0 AND parse_status IS NULL");
+            executeUpdate(connection, "UPDATE acupoint SET parse_confidence = CASE WHEN parse_status = 'parsed' THEN 'medium' ELSE 'low' END WHERE deleted = 0 AND parse_confidence IS NULL");
+
+            log.info("Acupoint schema initialization completed");
+        } catch (SQLException e) {
+            throw new IllegalStateException("Failed to initialize acupoint schema", e);
+        }
+    }
+
+    private boolean tableExists(Connection connection, String tableName) throws SQLException {
+        DatabaseMetaData metaData = connection.getMetaData();
+        String catalog = connection.getCatalog();
+        try (ResultSet resultSet = metaData.getTables(catalog, null, tableName, new String[]{"TABLE"})) {
+            if (resultSet.next()) {
+                return true;
+            }
+        }
+        try (ResultSet resultSet = metaData.getTables(catalog, null, tableName.toUpperCase(), new String[]{"TABLE"})) {
+            return resultSet.next();
+        }
+    }
+
+    private boolean columnExists(Connection connection, String columnName) throws SQLException {
+        DatabaseMetaData metaData = connection.getMetaData();
+        String catalog = connection.getCatalog();
+        try (ResultSet resultSet = metaData.getColumns(catalog, null, TABLE_NAME, columnName)) {
+            if (resultSet.next()) {
+                return true;
+            }
+        }
+        try (ResultSet resultSet = metaData.getColumns(catalog, null, TABLE_NAME, columnName.toUpperCase())) {
+            return resultSet.next();
+        }
+    }
+
+    private void addColumnIfMissing(Connection connection, String columnName, String definition) throws SQLException {
+        if (columnExists(connection, columnName)) {
+            return;
+        }
+        try {
+            executeUpdate(connection, "ALTER TABLE acupoint ADD COLUMN " + columnName + " " + definition);
+        } catch (SQLException e) {
+            if (e.getErrorCode() == 1060) {
+                log.info("Acupoint column [{}] already exists", columnName);
+                return;
+            }
+            throw e;
+        }
+        log.info("Added missing acupoint column [{}]", columnName);
+    }
+
+    private void executeUpdate(Connection connection, String sql) throws SQLException {
+        try (Statement statement = connection.createStatement()) {
+            statement.executeUpdate(sql);
+        }
+    }
+}

+ 39 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/AsyncConfig.java

@@ -0,0 +1,39 @@
+package com.aijiuyi.admin.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * 异步线程池配置
+ * 供 @Async("asyncExecutor") 使用,主要用于异步保存操作日志
+ */
+@Configuration
+public class AsyncConfig {
+
+    /**
+     * 异步任务线程池
+     * 核心线程数5,最大线程数20,队列容量100
+     *
+     * @return 线程池执行器
+     */
+    @Bean("asyncExecutor")
+    public Executor asyncExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        // 核心线程数
+        executor.setCorePoolSize(5);
+        // 最大线程数
+        executor.setMaxPoolSize(20);
+        // 任务队列容量
+        executor.setQueueCapacity(100);
+        // 线程名前缀
+        executor.setThreadNamePrefix("async-task-");
+        // 拒绝策略:由调用线程执行,保证日志不丢失
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
+        return executor;
+    }
+}

+ 29 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/AuthProperties.java

@@ -0,0 +1,29 @@
+package com.aijiuyi.admin.common.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 鉴权配置属性
+ * 从 application.yml 中读取 auth 前缀的配置
+ * 配置不需要 Token 鉴权的接口白名单
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "auth")
+public class AuthProperties {
+
+    /**
+     * 不需要 Token 鉴权的接口白名单(支持 Ant 风格路径匹配)
+     * 在 application.yml 中配置:
+     * auth:
+     *   white-list:
+     *     - /api/auth/login
+     *     - /api/auth/register
+     */
+    private List<String> whiteList = new ArrayList<>();
+}

+ 29 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/MybatisPlusConfig.java

@@ -0,0 +1,29 @@
+package com.aijiuyi.admin.common.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * MyBatis Plus 配置类
+ * 注册分页插件等 MyBatis Plus 扩展
+ */
+@Configuration
+public class MybatisPlusConfig {
+
+    /**
+     * 注册 MyBatis Plus 拦截器
+     * 当前包含:MySQL 分页插件
+     *
+     * @return MybatisPlusInterceptor
+     */
+    @Bean
+    public MybatisPlusInterceptor mybatisPlusInterceptor() {
+        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+        // 添加 MySQL 分页插件
+        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+        return interceptor;
+    }
+}

+ 207 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/PlanDataInitializer.java

@@ -0,0 +1,207 @@
+package com.aijiuyi.admin.common.config;
+
+import com.aijiuyi.admin.entity.Plan;
+import com.aijiuyi.admin.entity.PlanStep;
+import com.aijiuyi.admin.mapper.PlanMapper;
+import com.aijiuyi.admin.mapper.PlanStepMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 启动时确保系统内置方案存在。
+ */
+@Component
+public class PlanDataInitializer implements CommandLineRunner {
+
+    @Autowired
+    private PlanMapper planMapper;
+
+    @Autowired
+    private PlanStepMapper planStepMapper;
+
+    @Override
+    public void run(String... args) {
+        List<PlanSeed> seeds = buildSeeds();
+        for (PlanSeed seed : seeds) {
+            Plan plan = planMapper.selectOne(new LambdaQueryWrapper<Plan>()
+                    .eq(Plan::getPlanCode, seed.planCode)
+                    .last("limit 1"));
+            if (plan == null) {
+                plan = new Plan();
+                plan.setPlanCode(seed.planCode);
+                plan.setApplicableGender(0);
+                plan.setAgeMin(1);
+                plan.setAgeMax(120);
+                plan.setAuthorType(1);
+                plan.setAuthorName("ADMIN");
+                plan.setStatus(1);
+                plan.setUseCount(0);
+                fillPlan(plan, seed);
+                planMapper.insert(plan);
+            } else {
+                fillPlan(plan, seed);
+                planMapper.updateById(plan);
+            }
+
+            planStepMapper.delete(new LambdaUpdateWrapper<PlanStep>()
+                    .eq(PlanStep::getPlanId, plan.getId()));
+
+            for (int i = 0; i < seed.steps.size(); i++) {
+                StepSeed stepSeed = seed.steps.get(i);
+                PlanStep step = new PlanStep();
+                step.setPlanId(plan.getId());
+                step.setStepOrder(i + 1);
+                step.setAcupointId(stepSeed.acupointId);
+                step.setAcupointName(stepSeed.acupointName);
+                step.setSide(stepSeed.side);
+                step.setTemperature(stepSeed.temperature);
+                step.setDuration(stepSeed.duration);
+                step.setTechniqueId(stepSeed.techniqueId);
+                step.setTechniqueName(stepSeed.techniqueName);
+                step.setRemark(stepSeed.remark);
+                planStepMapper.insert(step);
+            }
+        }
+    }
+
+    private void fillPlan(Plan plan, PlanSeed seed) {
+        plan.setName(seed.name);
+        plan.setModeType(seed.modeType);
+        plan.setEffectType(seed.effectType);
+        plan.setSymptoms(seed.symptoms);
+        plan.setDescription(seed.description);
+        plan.setAuthorType(1);
+        plan.setAuthorName("ADMIN");
+        plan.setStatus(1);
+    }
+
+    private List<PlanSeed> buildSeeds() {
+        List<PlanSeed> seeds = new ArrayList<>();
+        seeds.add(new PlanSeed(
+                "S001", "一键艾灸(默认)", 1, null, "日常调理,疲劳,手脚冰凉", "系统默认一键艾灸方案,覆盖基础督脉与膀胱经调理。",
+                Arrays.asList(
+                        new StepSeed(1L, "大椎穴", 1, 42.0, 6, 2L, "温和灸", "默认基准穴位"),
+                        new StepSeed(2L, "风门穴", 4, 43.0, 6, 2L, "温和灸", "双侧温养"),
+                        new StepSeed(3L, "肺俞穴", 4, 43.0, 6, 2L, "温和灸", "提升背部暖感"),
+                        new StepSeed(8L, "肾俞穴", 4, 44.0, 6, 2L, "温和灸", "补益下焦"),
+                        new StepSeed(9L, "命门穴", 1, 45.0, 6, 2L, "温和灸", "固本收尾")
+                )));
+        seeds.add(new PlanSeed(
+                "S002", "专业驱寒方案", 2, "驱寒", "怕冷,手脚冰凉,畏寒", "针对阳虚畏寒人群的基础驱寒方案。",
+                Arrays.asList(
+                        new StepSeed(1L, "大椎穴", 1, 44.0, 8, 3L, "雀啄灸", "驱寒起始"),
+                        new StepSeed(2L, "风门穴", 4, 44.0, 8, 3L, "雀啄灸", "祛寒散表"),
+                        new StepSeed(8L, "肾俞穴", 4, 45.0, 8, 2L, "温和灸", "温补肾阳"),
+                        new StepSeed(9L, "命门穴", 1, 45.0, 8, 2L, "温和灸", "扶阳收尾")
+                )));
+        seeds.add(new PlanSeed(
+                "S003", "专业祛湿方案", 2, "祛湿", "困倦,湿重,食欲差", "围绕脾胃与膀胱经调理的祛湿方案。",
+                Arrays.asList(
+                        new StepSeed(6L, "脾俞穴", 4, 43.0, 8, 2L, "温和灸", "健脾化湿"),
+                        new StepSeed(7L, "胃俞穴", 4, 43.0, 8, 2L, "温和灸", "和胃助运化"),
+                        new StepSeed(10L, "中脘穴", 1, 42.0, 8, 4L, "回旋灸", "中焦祛湿"),
+                        new StepSeed(11L, "气海穴", 1, 42.0, 6, 2L, "温和灸", "益气固本")
+                )));
+        seeds.add(new PlanSeed(
+                "S004", "专业祛风方案", 2, "祛风", "感冒,怕风,鼻塞", "针对风寒风邪引发不适的基础调理方案。",
+                Arrays.asList(
+                        new StepSeed(1L, "大椎穴", 1, 42.0, 7, 2L, "温和灸", "祛风首穴"),
+                        new StepSeed(2L, "风门穴", 4, 43.0, 7, 3L, "雀啄灸", "散风解表"),
+                        new StepSeed(3L, "肺俞穴", 4, 42.0, 7, 2L, "温和灸", "协同宣肺"),
+                        new StepSeed(4L, "心俞穴", 4, 42.0, 6, 2L, "温和灸", "安神扶正")
+                )));
+        seeds.add(new PlanSeed(
+                "S005", "专业化瘀方案", 2, "化瘀", "僵硬,酸痛,局部不适", "用于改善局部瘀滞与经络不畅。",
+                Arrays.asList(
+                        new StepSeed(4L, "心俞穴", 4, 43.0, 8, 4L, "回旋灸", "活络起始"),
+                        new StepSeed(5L, "肝俞穴", 4, 44.0, 8, 4L, "回旋灸", "疏肝化瘀"),
+                        new StepSeed(6L, "脾俞穴", 4, 43.0, 6, 2L, "温和灸", "调和中焦"),
+                        new StepSeed(9L, "命门穴", 1, 44.0, 6, 2L, "温和灸", "温阳通络")
+                )));
+        seeds.add(new PlanSeed(
+                "S006", "专业活血方案", 2, "活血", "疲劳,循环差,肩背紧张", "帮助提升背部循环与整体活力。",
+                Arrays.asList(
+                        new StepSeed(3L, "肺俞穴", 4, 42.0, 7, 4L, "回旋灸", "活血舒展"),
+                        new StepSeed(4L, "心俞穴", 4, 43.0, 7, 4L, "回旋灸", "促进循环"),
+                        new StepSeed(5L, "肝俞穴", 4, 43.0, 7, 4L, "回旋灸", "疏通经络"),
+                        new StepSeed(11L, "气海穴", 1, 42.0, 6, 2L, "温和灸", "补气收束")
+                )));
+        seeds.add(new PlanSeed(
+                "S007", "专业化痰方案", 2, "化痰", "咳嗽,痰多,胸闷", "偏肺俞与中焦调理的化痰方案。",
+                Arrays.asList(
+                        new StepSeed(3L, "肺俞穴", 4, 42.0, 8, 2L, "温和灸", "宣肺止咳"),
+                        new StepSeed(2L, "风门穴", 4, 42.0, 8, 2L, "温和灸", "协同宣散"),
+                        new StepSeed(10L, "中脘穴", 1, 41.0, 7, 4L, "回旋灸", "中焦运化"),
+                        new StepSeed(11L, "气海穴", 1, 41.0, 7, 2L, "温和灸", "益气化痰")
+                )));
+        seeds.add(new PlanSeed(
+                "S008", "专业养颜方案", 2, "养颜", "气色差,疲劳,睡眠一般", "通过脾胃与气海关元综合调理气色。",
+                Arrays.asList(
+                        new StepSeed(6L, "脾俞穴", 4, 42.0, 8, 2L, "温和灸", "健脾养颜"),
+                        new StepSeed(7L, "胃俞穴", 4, 42.0, 8, 2L, "温和灸", "和胃生化"),
+                        new StepSeed(11L, "气海穴", 1, 42.0, 7, 4L, "回旋灸", "补气养神"),
+                        new StepSeed(12L, "关元穴", 1, 42.0, 7, 2L, "温和灸", "养元收尾")
+                )));
+        seeds.add(new PlanSeed(
+                "S009", "专业扶阳方案", 2, "扶阳", "乏力,腰酸,手脚冰凉", "以命门肾俞关元为主的扶阳固本方案。",
+                Arrays.asList(
+                        new StepSeed(8L, "肾俞穴", 4, 44.0, 8, 2L, "温和灸", "补肾扶阳"),
+                        new StepSeed(9L, "命门穴", 1, 45.0, 8, 2L, "温和灸", "培补命门火"),
+                        new StepSeed(11L, "气海穴", 1, 44.0, 7, 2L, "温和灸", "益气助阳"),
+                        new StepSeed(12L, "关元穴", 1, 44.0, 7, 2L, "温和灸", "固本培元")
+                )));
+        return seeds;
+    }
+
+    private static class PlanSeed {
+        private final String planCode;
+        private final String name;
+        private final Integer modeType;
+        private final String effectType;
+        private final String symptoms;
+        private final String description;
+        private final List<StepSeed> steps;
+
+        private PlanSeed(String planCode, String name, Integer modeType, String effectType,
+                         String symptoms, String description, List<StepSeed> steps) {
+            this.planCode = planCode;
+            this.name = name;
+            this.modeType = modeType;
+            this.effectType = effectType;
+            this.symptoms = symptoms;
+            this.description = description;
+            this.steps = steps;
+        }
+    }
+
+    private static class StepSeed {
+        private final Long acupointId;
+        private final String acupointName;
+        private final Integer side;
+        private final BigDecimal temperature;
+        private final Integer duration;
+        private final Long techniqueId;
+        private final String techniqueName;
+        private final String remark;
+
+        private StepSeed(Long acupointId, String acupointName, Integer side, double temperature,
+                         Integer duration, Long techniqueId, String techniqueName, String remark) {
+            this.acupointId = acupointId;
+            this.acupointName = acupointName;
+            this.side = side;
+            this.temperature = BigDecimal.valueOf(temperature);
+            this.duration = duration;
+            this.techniqueId = techniqueId;
+            this.techniqueName = techniqueName;
+            this.remark = remark;
+        }
+    }
+}

+ 55 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/RedisConfig.java

@@ -0,0 +1,55 @@
+package com.aijiuyi.admin.common.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+/**
+ * Redis 配置类
+ * 自定义序列化方式,key 使用 String 序列化,value 使用 JSON 序列化
+ * 解决默认 JDK 序列化导致的 Redis 中数据不可读问题
+ */
+@Configuration
+public class RedisConfig {
+
+    /**
+     * 配置 RedisTemplate
+     * key/hashKey 使用 StringRedisSerializer,value/hashValue 使用 Jackson2JsonRedisSerializer
+     *
+     * @param connectionFactory Redis 连接工厂
+     * @return RedisTemplate
+     */
+    @Bean
+    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
+        RedisTemplate<String, Object> template = new RedisTemplate<>();
+        template.setConnectionFactory(connectionFactory);
+
+        // 配置 JSON 序列化器
+        Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
+        ObjectMapper mapper = new ObjectMapper();
+        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        // 启用默认类型,存储时记录类型信息,反序列化时可还原为原始对象
+        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
+                ObjectMapper.DefaultTyping.NON_FINAL);
+        jsonSerializer.setObjectMapper(mapper);
+
+        // key 和 hashKey 使用字符串序列化
+        StringRedisSerializer stringSerializer = new StringRedisSerializer();
+        template.setKeySerializer(stringSerializer);
+        template.setHashKeySerializer(stringSerializer);
+
+        // value 和 hashValue 使用 JSON 序列化
+        template.setValueSerializer(jsonSerializer);
+        template.setHashValueSerializer(jsonSerializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+}

+ 47 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/WebMvcConfig.java

@@ -0,0 +1,47 @@
+package com.aijiuyi.admin.common.config;
+
+import com.aijiuyi.admin.common.interceptor.TokenInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Web MVC 配置类
+ * 注册 Token 拦截器和跨域配置
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private TokenInterceptor tokenInterceptor;
+
+    /**
+     * 注册拦截器
+     * TokenInterceptor 拦截所有请求(/error 路径除外)
+     *
+     * @param registry 拦截器注册表
+     */
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(tokenInterceptor)
+                .addPathPatterns("/**")
+                .excludePathPatterns("/error");
+    }
+
+    /**
+     * 配置跨域规则(开发环境允许所有来源)
+     *
+     * @param registry 跨域注册表
+     */
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**")
+                .allowedOriginPatterns("*")
+                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+                .allowedHeaders("*")
+                .allowCredentials(true)
+                .maxAge(3600);
+    }
+}

+ 145 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java

@@ -0,0 +1,145 @@
+package com.aijiuyi.admin.common.constant;
+
+/**
+ * 统一返回码枚举
+ * 所有接口返回码和错误信息都在此定义,禁止在其他地方硬编码错误信息
+ * 新增业务错误码请在对应业务区间追加
+ */
+public enum ResultCode {
+
+    // ======================== 通用状态码 ========================
+    /** 操作成功 */
+    SUCCESS(200, "操作成功"),
+    /** 服务器内部错误 */
+    ERROR(500, "服务器内部错误"),
+    /** 参数错误 */
+    PARAM_ERROR(400, "参数错误"),
+    /** 未授权,请登录 */
+    UNAUTHORIZED(401, "未授权,请登录"),
+    /** 权限不足 */
+    FORBIDDEN(403, "权限不足"),
+    /** 资源不存在 */
+    NOT_FOUND(404, "资源不存在"),
+
+    // ======================== 认证/用户相关 1000-1099 ========================
+    /** 用户不存在 */
+    USER_NOT_FOUND(1001, "用户不存在"),
+    /** 用户名或密码错误 */
+    USER_PASSWORD_ERROR(1002, "用户名或密码错误"),
+    /** 用户已被禁用 */
+    USER_DISABLED(1003, "用户已被禁用"),
+    /** Token 已过期 */
+    TOKEN_EXPIRED(1004, "Token 已过期,请重新登录"),
+    /** Token 无效 */
+    TOKEN_INVALID(1005, "Token 无效,请重新登录"),
+    /** 用户名已存在 */
+    USERNAME_EXISTS(1006, "用户名已存在"),
+
+    // ======================== 应用用户相关 1100-1199 ========================
+    /** 应用用户不存在 */
+    APP_USER_NOT_FOUND(1100, "用户不存在"),
+    /** 手机号已注册 */
+    PHONE_EXISTS(1101, "手机号已注册"),
+    /** 应用用户已被禁用 */
+    APP_USER_DISABLED(1102, "用户已被禁用"),
+
+    // ======================== 子用户相关 1200-1299 ========================
+    /** 子用户不存在 */
+    PROFILE_NOT_FOUND(1200, "子用户不存在"),
+    /** 子用户数量超出限制 */
+    PROFILE_LIMIT_EXCEEDED(1201, "子用户数量已达上限"),
+    /** 子用户手机号已存在 */
+    PHONE_PROFILE_EXISTS(1202, "该手机号已存在"),
+    /** 指宽数值超出性别对应范围 */
+    FINGER_WIDTH_RANGE_ERROR(1203, "指宽数值超出该性别允许范围"),
+
+    // ======================== 设备相关 1300-1399 ========================
+    /** 设备不存在 */
+    DEVICE_NOT_FOUND(1300, "设备不存在"),
+    /** 设备已被绑定 */
+    DEVICE_ALREADY_BOUND(1301, "设备已被其他用户绑定"),
+    /** 设备编号已存在 */
+    DEVICE_CODE_EXISTS(1302, "设备编号已存在"),
+    /** 设备序列号已存在 */
+    DEVICE_SERIAL_EXISTS(1303, "设备序列号已存在"),
+    /** 用户已绑定该设备 */
+    DEVICE_USER_ALREADY_BOUND(1304, "该用户已绑定此设备"),
+
+    // ======================== 穴位相关 1400-1499 ========================
+    /** 穴位不存在 */
+    ACUPOINT_NOT_FOUND(1400, "穴位不存在"),
+    /** 穴位编码已存在 */
+    ACUPOINT_CODE_EXISTS(1401, "穴位编码已存在"),
+    /** 穴位名称已存在 */
+    ACUPOINT_NAME_EXISTS(1402, "穴位名称已存在"),
+
+    // ======================== 用户穴位坐标相关 1500-1599 ========================
+    /** 用户穴位坐标记录不存在 */
+    USER_ACUPOINT_NOT_FOUND(1500, "用户穴位坐标记录不存在"),
+    /** 用户穴位坐标生成失败 */
+    USER_ACUPOINT_GENERATE_FAILED(1501, "用户穴位坐标生成失败"),
+
+    // ======================== 艾灸手法相关 1600-1699 ========================
+    /** 艾灸手法不存在 */
+    TECHNIQUE_NOT_FOUND(1600, "艾灸手法不存在"),
+    /** 手法编码已存在 */
+    TECHNIQUE_CODE_EXISTS(1601, "手法编码已存在"),
+    /** 手法名称已存在 */
+    TECHNIQUE_NAME_EXISTS(1602, "手法名称已存在"),
+
+    // ======================== 方案相关 1700-1799 ========================
+    /** 方案不存在 */
+    PLAN_NOT_FOUND(1700, "方案不存在"),
+    /** 方案编码已存在 */
+    PLAN_CODE_EXISTS(1701, "方案编码已存在"),
+    /** 方案名称已存在 */
+    PLAN_NAME_EXISTS(1702, "方案名称已存在"),
+    /** 已发布方案不可删除 */
+    PLAN_CANNOT_DELETE(1703, "已发布或下架方案不可删除"),
+    /** 方案步骤不存在 */
+    PLAN_STEP_NOT_FOUND(1704, "方案步骤不存在"),
+    /** 用户方案不存在 */
+    USER_PLAN_NOT_FOUND(1705, "用户方案不存在"),
+    /** 方案步骤至少保留1个 */
+    PLAN_STEP_MIN_ERROR(1706, "方案步骤至少保留1个"),
+
+    // ======================== 管理员管理相关 1800-1899 ========================
+    /** 管理员不存在 */
+    ADMIN_NOT_FOUND(1800, "管理员不存在"),
+    /** 不能删除超级管理员 */
+    ADMIN_CANNOT_DELETE(1801, "超级管理员不可删除"),
+    /** 不能禁用自身账号 */
+    ADMIN_CANNOT_DISABLE_SELF(1802, "不能禁用当前登录账号"),
+    /** 管理员角色不合法 */
+    ADMIN_ROLE_INVALID(1803, "管理员角色不合法");
+
+    // ======================== 其他业务错误码在此区间之后继续添加 ========================
+
+    /** 返回码 */
+    private final int code;
+    /** 返回信息 */
+    private final String message;
+
+    ResultCode(int code, String message) {
+        this.code = code;
+        this.message = message;
+    }
+
+    /**
+     * 获取返回码
+     *
+     * @return 返回码
+     */
+    public int getCode() {
+        return code;
+    }
+
+    /**
+     * 获取返回信息
+     *
+     * @return 返回信息
+     */
+    public String getMessage() {
+        return message;
+    }
+}

+ 108 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/context/RequestContext.java

@@ -0,0 +1,108 @@
+package com.aijiuyi.admin.common.context;
+
+/**
+ * 请求上下文(ThreadLocal)
+ * 每个请求线程独立存储:请求ID、当前用户ID、当前用户名、操作者类型
+ * 请求开始时由 TokenInterceptor 设置,请求结束时调用 clear() 清理,防止内存泄漏
+ */
+public class RequestContext {
+
+    /** 请求唯一ID(UUID,无横线格式) */
+    private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
+
+    /** 当前登录用户ID */
+    private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
+
+    /** 当前登录用户名 */
+    private static final ThreadLocal<String> USER_NAME = new ThreadLocal<>();
+
+    /**
+     * 操作者类型(区分管理员和 App 用户)
+     * 1=系统管理员(sys_user),2=App用户(app_user)
+     */
+    private static final ThreadLocal<Integer> USER_TYPE = new ThreadLocal<>();
+
+    /**
+     * 设置请求ID
+     *
+     * @param requestId 请求ID
+     */
+    public static void setRequestId(String requestId) {
+        REQUEST_ID.set(requestId);
+    }
+
+    /**
+     * 获取请求ID
+     *
+     * @return 请求ID
+     */
+    public static String getRequestId() {
+        return REQUEST_ID.get();
+    }
+
+    /**
+     * 设置当前用户ID
+     *
+     * @param userId 用户ID
+     */
+    public static void setUserId(Long userId) {
+        USER_ID.set(userId);
+    }
+
+    /**
+     * 获取当前用户ID
+     *
+     * @return 用户ID
+     */
+    public static Long getUserId() {
+        return USER_ID.get();
+    }
+
+    /**
+     * 设置当前用户名
+     *
+     * @param userName 用户名
+     */
+    public static void setUserName(String userName) {
+        USER_NAME.set(userName);
+    }
+
+    /**
+     * 获取当前用户名
+     *
+     * @return 用户名
+     */
+    public static String getUserName() {
+        return USER_NAME.get();
+    }
+
+    /**
+     * 设置操作者类型
+     * 1=系统管理员(sys_user),2=App用户(app_user)
+     *
+     * @param userType 用户类型
+     */
+    public static void setUserType(Integer userType) {
+        USER_TYPE.set(userType);
+    }
+
+    /**
+     * 获取操作者类型
+     *
+     * @return 用户类型
+     */
+    public static Integer getUserType() {
+        return USER_TYPE.get();
+    }
+
+    /**
+     * 清理当前线程的所有上下文数据(防止内存泄漏)
+     * 在请求结束时由拦截器调用
+     */
+    public static void clear() {
+        REQUEST_ID.remove();
+        USER_ID.remove();
+        USER_NAME.remove();
+        USER_TYPE.remove();
+    }
+}

+ 89 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/entity/Result.java

@@ -0,0 +1,89 @@
+package com.aijiuyi.admin.common.entity;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import lombok.Data;
+
+/**
+ * 统一接口返回对象
+ * 所有 Controller 接口必须返回此对象,禁止直接返回业务对象
+ *
+ * @param <T> 业务数据类型
+ */
+@Data
+public class Result<T> {
+
+    /** 状态码(200=成功,其他=失败,详见 ResultCode) */
+    private int code;
+
+    /** 提示信息 */
+    private String message;
+
+    /** 业务数据 */
+    private T data;
+
+    /**
+     * 操作成功(无数据)
+     *
+     * @param <T> 数据类型
+     * @return Result
+     */
+    public static <T> Result<T> success() {
+        Result<T> result = new Result<>();
+        result.setCode(ResultCode.SUCCESS.getCode());
+        result.setMessage(ResultCode.SUCCESS.getMessage());
+        return result;
+    }
+
+    /**
+     * 操作成功(带数据)
+     *
+     * @param data 业务数据
+     * @param <T>  数据类型
+     * @return Result
+     */
+    public static <T> Result<T> success(T data) {
+        Result<T> result = success();
+        result.setData(data);
+        return result;
+    }
+
+    /**
+     * 操作失败(使用 ResultCode 枚举)
+     *
+     * @param resultCode 错误码枚举
+     * @param <T>        数据类型
+     * @return Result
+     */
+    public static <T> Result<T> error(ResultCode resultCode) {
+        Result<T> result = new Result<>();
+        result.setCode(resultCode.getCode());
+        result.setMessage(resultCode.getMessage());
+        return result;
+    }
+
+    /**
+     * 操作失败(自定义错误码和信息)
+     *
+     * @param code    错误码
+     * @param message 错误信息
+     * @param <T>     数据类型
+     * @return Result
+     */
+    public static <T> Result<T> error(int code, String message) {
+        Result<T> result = new Result<>();
+        result.setCode(code);
+        result.setMessage(message);
+        return result;
+    }
+
+    /**
+     * 操作失败(仅错误信息,错误码默认 500)
+     *
+     * @param message 错误信息
+     * @param <T>     数据类型
+     * @return Result
+     */
+    public static <T> Result<T> error(String message) {
+        return error(ResultCode.ERROR.getCode(), message);
+    }
+}

+ 57 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/entity/SysLog.java

@@ -0,0 +1,57 @@
+package com.aijiuyi.admin.common.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 系统操作日志实体
+ * 对应数据库 sys_log 表,记录所有通过 Controller 的用户操作
+ */
+@Data
+@TableName("sys_log")
+public class SysLog {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 请求用户ID */
+    private Long userId;
+
+    /** 请求用户名 */
+    private String userName;
+
+    /** 操作者类型:1=系统管理员(sys_user),2=App用户(app_user) */
+    private Integer userType;
+
+    /** 请求接口路径 */
+    private String requestUrl;
+
+    /** 请求参数(JSON格式) */
+    private String requestParams;
+
+    /** 请求结果(JSON格式) */
+    private String requestResult;
+
+    /** 接口说明(来自 @Log 注解 value 属性) */
+    private String apiDesc;
+
+    /** 操作方式(查询/新增/更新/删除/其他) */
+    private String operationType;
+
+    /** 请求IP */
+    private String requestIp;
+
+    /** 操作模块(来自 @Log 注解 module 属性) */
+    private String operationModule;
+
+    /** 请求唯一ID(同一请求的所有日志共享此ID) */
+    private String requestId;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+}

+ 39 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/enums/OperationType.java

@@ -0,0 +1,39 @@
+package com.aijiuyi.admin.common.enums;
+
+/**
+ * 操作类型枚举
+ * 用于 @Log 注解中标识接口的操作类型,便于日志查询和统计
+ */
+public enum OperationType {
+
+    /** 查询操作 */
+    QUERY("查询"),
+
+    /** 新增操作 */
+    INSERT("新增"),
+
+    /** 更新操作 */
+    UPDATE("更新"),
+
+    /** 删除操作 */
+    DELETE("删除"),
+
+    /** 其他操作(登录、导出等) */
+    OTHER("其他");
+
+    /** 操作类型描述 */
+    private final String desc;
+
+    OperationType(String desc) {
+        this.desc = desc;
+    }
+
+    /**
+     * 获取操作类型描述
+     *
+     * @return 描述文字
+     */
+    public String getDesc() {
+        return desc;
+    }
+}

+ 47 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/exception/BusinessException.java

@@ -0,0 +1,47 @@
+package com.aijiuyi.admin.common.exception;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import lombok.Getter;
+
+/**
+ * 业务异常类
+ * 业务逻辑中主动抛出的异常,会被全局异常处理器捕获并返回对应错误信息
+ * 示例:throw new BusinessException(ResultCode.USER_NOT_FOUND);
+ */
+@Getter
+public class BusinessException extends RuntimeException {
+
+    /** 错误码 */
+    private final int code;
+
+    /**
+     * 通过 ResultCode 枚举抛出异常(推荐方式)
+     *
+     * @param resultCode 错误码枚举
+     */
+    public BusinessException(ResultCode resultCode) {
+        super(resultCode.getMessage());
+        this.code = resultCode.getCode();
+    }
+
+    /**
+     * 通过自定义错误码和信息抛出异常
+     *
+     * @param code    错误码
+     * @param message 错误信息
+     */
+    public BusinessException(int code, String message) {
+        super(message);
+        this.code = code;
+    }
+
+    /**
+     * 通过自定义信息抛出异常(错误码默认 500)
+     *
+     * @param message 错误信息
+     */
+    public BusinessException(String message) {
+        super(message);
+        this.code = ResultCode.ERROR.getCode();
+    }
+}

+ 91 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/exception/GlobalExceptionHandler.java

@@ -0,0 +1,91 @@
+package com.aijiuyi.admin.common.exception;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.util.LogUtil;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import javax.validation.ConstraintViolationException;
+
+/**
+ * 全局异常处理器
+ * 统一捕获并处理所有 Controller 层异常,返回标准 Result 格式
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    /**
+     * 处理业务异常(主动抛出的可预期异常)
+     *
+     * @param e 业务异常
+     * @return 标准错误响应
+     */
+    @ExceptionHandler(BusinessException.class)
+    public Result<?> handleBusinessException(BusinessException e) {
+        LogUtil.warn(GlobalExceptionHandler.class, "业务异常: code={}, message={}", e.getCode(), e.getMessage());
+        return Result.error(e.getCode(), e.getMessage());
+    }
+
+    /**
+     * 处理请求体参数校验异常(@RequestBody 参数上 @Valid 校验失败)
+     *
+     * @param e 参数校验异常
+     * @return 标准错误响应
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+        String message = ResultCode.PARAM_ERROR.getMessage();
+        if (e.getBindingResult().hasFieldErrors()) {
+            message = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
+        }
+        LogUtil.warn(GlobalExceptionHandler.class, "参数校验异常: {}", message);
+        return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
+    }
+
+    /**
+     * 处理表单参数绑定异常
+     *
+     * @param e 绑定异常
+     * @return 标准错误响应
+     */
+    @ExceptionHandler(BindException.class)
+    public Result<?> handleBindException(BindException e) {
+        String message = ResultCode.PARAM_ERROR.getMessage();
+        if (e.getBindingResult().hasFieldErrors()) {
+            message = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
+        }
+        LogUtil.warn(GlobalExceptionHandler.class, "参数绑定异常: {}", message);
+        return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
+    }
+
+    /**
+     * 处理路径参数/方法参数约束校验异常(@RequestParam/@PathVariable 上 @NotNull 等校验失败)
+     *
+     * @param e 约束违反异常
+     * @return 标准错误响应
+     */
+    @ExceptionHandler(ConstraintViolationException.class)
+    public Result<?> handleConstraintViolationException(ConstraintViolationException e) {
+        String message = e.getConstraintViolations().stream()
+                .findFirst()
+                .map(v -> v.getMessage())
+                .orElse(ResultCode.PARAM_ERROR.getMessage());
+        LogUtil.warn(GlobalExceptionHandler.class, "约束校验异常: {}", message);
+        return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
+    }
+
+    /**
+     * 处理其他未知异常(系统级别异常)
+     *
+     * @param e 未知异常
+     * @return 标准错误响应
+     */
+    @ExceptionHandler(Exception.class)
+    public Result<?> handleException(Exception e) {
+        LogUtil.error(GlobalExceptionHandler.class, "系统未知异常: ", e);
+        return Result.error(ResultCode.ERROR);
+    }
+}

+ 42 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/handler/MyMetaObjectHandler.java

@@ -0,0 +1,42 @@
+package com.aijiuyi.admin.common.handler;
+
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
+import org.apache.ibatis.reflection.MetaObject;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/**
+ * MyBatis Plus 自动填充处理器
+ * 实体类字段使用 @TableField(fill = FieldFill.INSERT) 标注后,新增时自动填充 createTime
+ * 使用 @TableField(fill = FieldFill.INSERT_UPDATE) 标注后,新增和更新时自动填充 updateTime
+ */
+@Component
+public class MyMetaObjectHandler implements MetaObjectHandler {
+
+    /**
+     * 新增时自动填充
+     * 填充字段:createTime(创建时间)、updateTime(更新时间)
+     *
+     * @param metaObject 元对象
+     */
+    @Override
+    public void insertFill(MetaObject metaObject) {
+        // 新增时自动填充创建时间
+        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
+        // 新增时也填充更新时间
+        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+    }
+
+    /**
+     * 更新时自动填充
+     * 填充字段:updateTime(更新时间)
+     *
+     * @param metaObject 元对象
+     */
+    @Override
+    public void updateFill(MetaObject metaObject) {
+        // 更新时自动更新时间
+        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+    }
+}

+ 162 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/interceptor/TokenInterceptor.java

@@ -0,0 +1,162 @@
+package com.aijiuyi.admin.common.interceptor;
+
+import com.aijiuyi.admin.common.annotation.NoAuth;
+import com.aijiuyi.admin.common.config.AuthProperties;
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.context.RequestContext;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.common.util.RedisUtil;
+import com.aijiuyi.admin.common.util.TokenUtil;
+import com.alibaba.fastjson.JSON;
+import io.jsonwebtoken.Claims;
+import org.slf4j.MDC;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.util.StringUtils;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.util.UUID;
+
+/**
+ * Token 鉴权拦截器
+ * 所有请求在进入 Controller 之前经过此拦截器处理:
+ * 1. 生成唯一请求ID,存入 ThreadLocal 和 MDC(使所有日志自动携带请求ID)
+ * 2. 验证 Token 有效性(白名单、@NoAuth 注解的接口跳过验证)
+ * 3. 解析 Token,将用户信息存入 ThreadLocal
+ */
+@Component
+public class TokenInterceptor implements HandlerInterceptor {
+
+    /** Token 在请求头中的 key */
+    private static final String TOKEN_HEADER = "Authorization";
+    /** Redis 中 Token 的 key 前缀 */
+    public static final String REDIS_TOKEN_KEY_PREFIX = "token:";
+    /** Ant 路径匹配器 */
+    private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
+    /** MDC 中请求ID的 key */
+    private static final String MDC_REQUEST_ID = "requestId";
+
+    @Autowired
+    private RedisUtil redisUtil;
+
+    @Autowired
+    private AuthProperties authProperties;
+
+    /**
+     * 请求进入 Controller 前处理
+     * 生成请求ID,验证 Token,解析用户信息
+     *
+     * @param request  请求
+     * @param response 响应
+     * @param handler  处理器
+     * @return true=放行,false=拦截
+     */
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        // 生成唯一请求ID(UUID去横线)
+        String requestId = UUID.randomUUID().toString().replace("-", "");
+        // 存入 ThreadLocal,供业务代码使用
+        RequestContext.setRequestId(requestId);
+        // 存入 MDC,使所有 SLF4J 日志(包括 SQL 日志)自动携带请求ID
+        MDC.put(MDC_REQUEST_ID, requestId);
+
+        // 非 Handler 方法(如静态资源)直接放行
+        if (!(handler instanceof HandlerMethod)) {
+            return true;
+        }
+
+        HandlerMethod handlerMethod = (HandlerMethod) handler;
+
+        // 检查方法或类上是否有 @NoAuth 注解,有则跳过 Token 验证
+        if (handlerMethod.hasMethodAnnotation(NoAuth.class)
+                || handlerMethod.getBeanType().isAnnotationPresent(NoAuth.class)) {
+            return true;
+        }
+
+        // 检查是否在配置文件的白名单中
+        String requestUri = request.getRequestURI();
+        for (String pattern : authProperties.getWhiteList()) {
+            if (StringUtils.hasText(pattern) && PATH_MATCHER.match(pattern.trim(), requestUri)) {
+                return true;
+            }
+        }
+
+        // 从请求头获取 Token
+        String token = request.getHeader(TOKEN_HEADER);
+        if (!StringUtils.hasText(token)) {
+            writeErrorResponse(response, ResultCode.UNAUTHORIZED);
+            return false;
+        }
+
+        // 去掉 Bearer 前缀
+        if (token.startsWith("Bearer ")) {
+            token = token.substring(7);
+        }
+
+        // 验证 Token 是否在 Redis 中存在(已退出登录的 Token 会被删除)
+        String redisKey = REDIS_TOKEN_KEY_PREFIX + token;
+        if (!redisUtil.hasKey(redisKey)) {
+            writeErrorResponse(response, ResultCode.TOKEN_INVALID);
+            return false;
+        }
+
+        // 解析 Token,获取用户信息
+        Claims claims = TokenUtil.parseToken(token);
+        if (claims == null) {
+            writeErrorResponse(response, ResultCode.TOKEN_INVALID);
+            return false;
+        }
+
+        // 将用户信息存入 ThreadLocal,供 Service 层使用
+        Long userId = ((Number) claims.get("userId")).longValue();
+        String username = (String) claims.get("username");
+        // 从 JWT Claims 中读取 userType(兼容旧 token 默认为 3=管理员)
+        Integer userType = claims.get("userType") != null
+                ? ((Number) claims.get("userType")).intValue()
+                : 3;
+        RequestContext.setUserId(userId);
+        RequestContext.setUserName(username);
+        RequestContext.setUserType(userType);
+
+        LogUtil.debug(TokenInterceptor.class, "用户[{}]请求接口[{}]", username, requestUri);
+        return true;
+    }
+
+    /**
+     * 请求完成后清理 ThreadLocal 和 MDC,防止内存泄漏
+     *
+     * @param request  请求
+     * @param response 响应
+     * @param handler  处理器
+     * @param ex       异常(如有)
+     */
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
+                                Object handler, Exception ex) {
+        // 清理 ThreadLocal
+        RequestContext.clear();
+        // 清理 MDC
+        MDC.clear();
+    }
+
+    /**
+     * 向客户端写入统一的错误响应
+     *
+     * @param response   响应
+     * @param resultCode 错误码
+     */
+    private void writeErrorResponse(HttpServletResponse response, ResultCode resultCode) throws Exception {
+        response.setContentType("application/json;charset=UTF-8");
+        response.setStatus(HttpServletResponse.SC_OK);
+        try (PrintWriter writer = response.getWriter()) {
+            writer.write(JSON.toJSONString(Result.error(resultCode)));
+            writer.flush();
+        }
+    }
+}

+ 47 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/service/SysLogService.java

@@ -0,0 +1,47 @@
+package com.aijiuyi.admin.common.service;
+
+import com.aijiuyi.admin.common.entity.SysLog;
+import com.aijiuyi.admin.controller.dto.SysLogQueryDTO;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 系统日志 Service 接口
+ * 提供操作日志的保存、分页查询和全量导出功能
+ */
+public interface SysLogService {
+
+    /**
+     * 异步保存操作日志到数据库
+     * 由 LogAspect 切面调用,不影响主业务流程
+     *
+     * @param sysLog 操作日志对象
+     */
+    void saveLog(SysLog sysLog);
+
+    /**
+     * 分页查询操作日志列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<SysLog> pageList(SysLogQueryDTO queryDTO);
+
+    /**
+     * 查询全部操作日志(用于 Excel 导出)
+     *
+     * @param queryDTO 查询条件(不含分页参数)
+     * @return 日志列表
+     */
+    List<SysLog> listAll(SysLogQueryDTO queryDTO);
+
+    /**
+     * 获取系统管理员选项列表(用于前端操作人下拉框)
+     *
+     * @return id 和 nickname/username 组成的选项列表
+     */
+    List<Map<String, Object>> getSysUserOptions();
+}
+

+ 102 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/service/impl/SysLogServiceImpl.java

@@ -0,0 +1,102 @@
+package com.aijiuyi.admin.common.service.impl;
+
+import com.aijiuyi.admin.common.entity.SysLog;
+import com.aijiuyi.admin.common.service.SysLogService;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.SysLogQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.mapper.AppUserMapper;
+import com.aijiuyi.admin.mapper.SysLogMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 系统日志 Service 实现类
+ * saveLog 方法使用 @Async 异步执行,不阻塞主请求线程
+ */
+@Service
+public class SysLogServiceImpl implements SysLogService {
+
+    @Autowired
+    private SysLogMapper sysLogMapper;
+
+    @Autowired
+    private AppUserMapper appUserMapper;
+
+    /**
+     * 异步保存操作日志
+     * 使用 asyncExecutor 线程池执行,与主请求线程解耦
+     *
+     * @param sysLog 操作日志对象
+     */
+    @Override
+    @Async("asyncExecutor")
+    public void saveLog(SysLog sysLog) {
+        try {
+            sysLogMapper.insert(sysLog);
+            LogUtil.debug(SysLogServiceImpl.class, "操作日志保存成功, requestId={}", sysLog.getRequestId());
+        } catch (Exception e) {
+            LogUtil.error(SysLogServiceImpl.class, "操作日志保存失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 分页查询操作日志列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @Override
+    public IPage<SysLog> pageList(SysLogQueryDTO queryDTO) {
+        Page<SysLog> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        return sysLogMapper.selectLogPage(page, queryDTO);
+    }
+
+    /**
+     * 查询全部操作日志(用于 Excel 导出,不分页)
+     *
+     * @param queryDTO 查询条件
+     * @return 日志列表
+     */
+    @Override
+    public List<SysLog> listAll(SysLogQueryDTO queryDTO) {
+        return sysLogMapper.selectLogList(queryDTO);
+    }
+
+    /**
+     * 获取用户选项列表(用于前端操作人下拉框)
+     * 返回所有未删除的用户的 id、username 和 nickname
+     *
+     * @return 选项列表,每项含 id、label(nickname 或 username)
+     */
+    @Override
+    public List<Map<String, Object>> getSysUserOptions() {
+        List<AppUser> users = appUserMapper.selectList(
+                new LambdaQueryWrapper<AppUser>()
+                        .select(AppUser::getId, AppUser::getUsername, AppUser::getNickname)
+                        .orderByAsc(AppUser::getId)
+        );
+        List<Map<String, Object>> options = new ArrayList<>();
+        for (AppUser user : users) {
+            Map<String, Object> option = new HashMap<>();
+            option.put("id", user.getId());
+            // 优先展示昵称,无昵称则展示用户名
+            String label = (user.getNickname() != null && !user.getNickname().isEmpty())
+                    ? user.getNickname() : user.getUsername();
+            option.put("label", label);
+            option.put("username", user.getUsername());
+            options.add(option);
+        }
+        return options;
+    }
+}
+

+ 275 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointLocationParser.java

@@ -0,0 +1,275 @@
+package com.aijiuyi.admin.common.util;
+
+import com.aijiuyi.admin.entity.Acupoint;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 从穴位定位原文中提取可计算的相对坐标参数。
+ */
+public final class AcupointLocationParser {
+
+    public static final String STATUS_PARSED = "parsed";
+    public static final String STATUS_REVIEW_REQUIRED = "review_required";
+    public static final String STATUS_UNSUPPORTED = "unsupported";
+
+    public static final String CONFIDENCE_HIGH = "high";
+    public static final String CONFIDENCE_MEDIUM = "medium";
+    public static final String CONFIDENCE_LOW = "low";
+
+    private static final Pattern MIDLINE_OFFSET =
+            Pattern.compile("(?:前|后|头)?正中线旁开\\s*([0-9]+(?:\\.[0-9]+)?)\\s*寸");
+    private static final Pattern GENERAL_OFFSET =
+            Pattern.compile("旁开\\s*([0-9]+(?:\\.[0-9]+)?)\\s*寸");
+    private static final Pattern UNSUPPORTED_TOUCH =
+            Pattern.compile("凹陷|肌腱|骨间|缝中|连线|赤白肉际|脉搏动|鼻唇沟|瞳孔|发际|骨间隙");
+    private static final Pattern ABDOMEN_REFERENCE =
+            Pattern.compile("脐中|脐上|脐下|剑胸结合|胸剑联合|肋间隙|乳头");
+
+    private AcupointLocationParser() {
+    }
+
+    public static ParseResult parse(Acupoint acupoint) {
+        String text = mergeText(acupoint);
+        ParseResult result = new ParseResult();
+
+        if (text.length() == 0) {
+            result.setParseStatus(STATUS_REVIEW_REQUIRED);
+            result.setParseConfidence(CONFIDENCE_LOW);
+            result.setParseMessage("缺少穴位定位描述,无法自动解析");
+            result.setSide(parseSide(acupoint, "", null));
+            return result;
+        }
+
+        String yTerm = AcupointYReference.inferTerm(text);
+        AcupointYReference.Rule yRule = AcupointYReference.getRule(yTerm);
+        BigDecimal lateralOffset = parseLateralOffset(text);
+        Integer side = parseSide(acupoint, text, lateralOffset);
+
+        result.setYReferenceTerm(yTerm);
+        result.setLateralOffset(lateralOffset);
+        result.setSide(side);
+
+        boolean hasY = yRule != null;
+        boolean needsLateral = Integer.valueOf(2).equals(side)
+                || Integer.valueOf(3).equals(side)
+                || Integer.valueOf(4).equals(side);
+        if (isBaliao(acupoint)) {
+            needsLateral = false;
+        }
+        boolean hasLateral = lateralOffset != null && lateralOffset.compareTo(BigDecimal.ZERO) > 0;
+        boolean touchOnly = UNSUPPORTED_TOUCH.matcher(text).find();
+        boolean abdomenReference = ABDOMEN_REFERENCE.matcher(text).find();
+
+        List<String> messages = new ArrayList<String>();
+        if (hasY) {
+            result.setNormalizedY(yRule.getNormalizedY());
+            result.setLongitudeOffset(yRule.getCunOffset());
+            messages.add("已解析Y轴术语:" + yTerm);
+        }
+        if (hasLateral) {
+            messages.add("已解析横向旁开:" + lateralOffset.toPlainString() + "寸");
+        }
+        if (touchOnly) {
+            messages.add("包含凹陷/肌腱/骨间/连线等触诊描述,需人工复核精确点位");
+        }
+        if (abdomenReference && !hasY) {
+            messages.add("包含胸腹体表标志,但当前用户模型缺少可稳定换算为大椎相对Y轴的基准");
+        }
+
+        if (hasY && (!needsLateral || hasLateral)) {
+            result.setParseStatus(STATUS_PARSED);
+            result.setParseConfidence(touchOnly ? CONFIDENCE_MEDIUM : CONFIDENCE_HIGH);
+        } else if (hasY) {
+            result.setParseStatus(STATUS_REVIEW_REQUIRED);
+            result.setParseConfidence(CONFIDENCE_MEDIUM);
+            messages.add("已找到Y轴基准,但缺少横向旁开距离");
+        } else if (hasLateral) {
+            result.setParseStatus(STATUS_REVIEW_REQUIRED);
+            result.setParseConfidence(CONFIDENCE_MEDIUM);
+            messages.add("已找到横向旁开距离,但缺少可计算Y轴基准");
+        } else {
+            result.setParseStatus(touchOnly || abdomenReference ? STATUS_REVIEW_REQUIRED : STATUS_UNSUPPORTED);
+            result.setParseConfidence(CONFIDENCE_LOW);
+            if (messages.isEmpty()) {
+                messages.add("未识别到可稳定计算的Y轴基准和横向旁开距离");
+            }
+        }
+
+        result.setParseMessage(joinMessages(messages));
+        return result;
+    }
+
+    private static String mergeText(Acupoint acupoint) {
+        if (acupoint == null) {
+            return "";
+        }
+        StringBuilder builder = new StringBuilder();
+        append(builder, acupoint.getLocationDescription());
+        append(builder, acupoint.getRelativePosition());
+        append(builder, acupoint.getAcupointMethod());
+        append(builder, acupoint.getBodyPart());
+        append(builder, acupoint.getBodyRegion());
+        return builder.toString().trim();
+    }
+
+    private static void append(StringBuilder builder, String value) {
+        if (value != null && value.trim().length() > 0) {
+            if (builder.length() > 0) {
+                builder.append(' ');
+            }
+            builder.append(value.trim());
+        }
+    }
+
+    private static BigDecimal parseLateralOffset(String text) {
+        Matcher midline = MIDLINE_OFFSET.matcher(text);
+        if (midline.find()) {
+            return new BigDecimal(midline.group(1));
+        }
+        Matcher general = GENERAL_OFFSET.matcher(text);
+        if (general.find()) {
+            return new BigDecimal(general.group(1));
+        }
+        return null;
+    }
+
+    private static Integer parseSide(Acupoint acupoint, String text, BigDecimal lateralOffset) {
+        if (text != null) {
+            if (text.contains("双侧") || text.contains("两侧") || text.contains("左右")) {
+                return 4;
+            }
+            if (text.contains("左") && !text.contains("右")) {
+                return 2;
+            }
+            if (text.contains("右") && !text.contains("左")) {
+                return 3;
+            }
+        }
+        String singleDouble = acupoint == null ? null : acupoint.getSingleDouble();
+        if (singleDouble != null && singleDouble.contains("双")) {
+            return 4;
+        }
+        if (singleDouble != null && singleDouble.contains("单")) {
+            return 1;
+        }
+        Integer currentSide = acupoint == null ? null : acupoint.getSide();
+        if (currentSide != null && currentSide > 1 && currentSide <= 4) {
+            return currentSide;
+        }
+        String meridian = acupoint == null ? null : acupoint.getMeridian();
+        if ("督脉".equals(meridian) || "任脉".equals(meridian)) {
+            return 1;
+        }
+        if (lateralOffset != null && lateralOffset.compareTo(BigDecimal.ZERO) > 0) {
+            return 4;
+        }
+        if (currentSide != null && currentSide >= 1 && currentSide <= 4) {
+            return currentSide;
+        }
+        return 1;
+    }
+
+    private static boolean isBaliao(Acupoint acupoint) {
+        if (acupoint == null) {
+            return false;
+        }
+        String code = acupoint.getAcupointCode();
+        if ("pangguang-31".equals(code) || "pangguang-32".equals(code)
+                || "pangguang-33".equals(code) || "pangguang-34".equals(code)) {
+            return true;
+        }
+        String name = acupoint.getName();
+        return "上髎".equals(name) || "次髎".equals(name) || "中髎".equals(name) || "下髎".equals(name);
+    }
+
+    private static String joinMessages(List<String> messages) {
+        StringBuilder builder = new StringBuilder();
+        for (String message : messages) {
+            if (builder.length() > 0) {
+                builder.append(";");
+            }
+            builder.append(message);
+        }
+        return builder.toString();
+    }
+
+    public static class ParseResult {
+        private String yReferenceTerm;
+        private BigDecimal normalizedY;
+        private BigDecimal longitudeOffset;
+        private BigDecimal lateralOffset;
+        private Integer side;
+        private String parseStatus;
+        private String parseConfidence;
+        private String parseMessage;
+
+        public String getYReferenceTerm() {
+            return yReferenceTerm;
+        }
+
+        public void setYReferenceTerm(String yReferenceTerm) {
+            this.yReferenceTerm = yReferenceTerm;
+        }
+
+        public BigDecimal getNormalizedY() {
+            return normalizedY;
+        }
+
+        public void setNormalizedY(BigDecimal normalizedY) {
+            this.normalizedY = normalizedY;
+        }
+
+        public BigDecimal getLongitudeOffset() {
+            return longitudeOffset;
+        }
+
+        public void setLongitudeOffset(BigDecimal longitudeOffset) {
+            this.longitudeOffset = longitudeOffset;
+        }
+
+        public BigDecimal getLateralOffset() {
+            return lateralOffset;
+        }
+
+        public void setLateralOffset(BigDecimal lateralOffset) {
+            this.lateralOffset = lateralOffset;
+        }
+
+        public Integer getSide() {
+            return side;
+        }
+
+        public void setSide(Integer side) {
+            this.side = side;
+        }
+
+        public String getParseStatus() {
+            return parseStatus;
+        }
+
+        public void setParseStatus(String parseStatus) {
+            this.parseStatus = parseStatus;
+        }
+
+        public String getParseConfidence() {
+            return parseConfidence;
+        }
+
+        public void setParseConfidence(String parseConfidence) {
+            this.parseConfidence = parseConfidence;
+        }
+
+        public String getParseMessage() {
+            return parseMessage;
+        }
+
+        public void setParseMessage(String parseMessage) {
+            this.parseMessage = parseMessage;
+        }
+    }
+}

+ 216 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointYReference.java

@@ -0,0 +1,216 @@
+package com.aijiuyi.admin.common.util;
+
+import com.aijiuyi.admin.controller.dto.YReferenceOptionDTO;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 穴位Y轴医学定位术语映射。
+ *
+ * <p>统一约定:Y轴向下为正。C7为0,S4为1,胸腰骶段使用C7-S4脊柱长度按
+ * Panjabi归一化系数换算;少数“直上N寸”术语使用最终指寸换算。</p>
+ */
+public final class AcupointYReference {
+
+    private static final Pattern C7_UP_2_CUN =
+            Pattern.compile("第7颈椎棘突直上2寸");
+    private static final Pattern C7 =
+            Pattern.compile("(?:横平)?第7颈椎棘突(?:下)?");
+    private static final Pattern THORACIC =
+            Pattern.compile("第([1-9]|1[0-2])胸椎棘突下");
+    private static final Pattern LUMBAR =
+            Pattern.compile("第([1-5])腰椎棘突下");
+    private static final Pattern SACRAL_CENTER =
+            Pattern.compile("正对第([1-4])骶后孔中?");
+    private static final Pattern SACRAL_LEVEL =
+            Pattern.compile("横平第([1-4])骶后孔");
+
+    private static final Map<String, Rule> RULES;
+
+    static {
+        Map<String, Rule> rules = new LinkedHashMap<>();
+
+        addNormalized(rules, "第7颈椎棘突", "0.0000");
+        addNormalized(rules, "第7颈椎棘突下", "0.0000");
+        addCunOffset(rules, "第7颈椎棘突直上2寸", "-2.00");
+
+        addNormalized(rules, "第1胸椎棘突下", "0.0340");
+        addNormalized(rules, "第2胸椎棘突下", "0.0780");
+        addNormalized(rules, "第3胸椎棘突下", "0.1200");
+        addNormalized(rules, "第4胸椎棘突下", "0.1620");
+        addNormalized(rules, "第5胸椎棘突下", "0.2030");
+        addNormalized(rules, "第6胸椎棘突下", "0.2430");
+        addNormalized(rules, "第7胸椎棘突下", "0.2830");
+        addNormalized(rules, "第8胸椎棘突下", "0.3220");
+        addNormalized(rules, "第9胸椎棘突下", "0.3610");
+        addNormalized(rules, "第10胸椎棘突下", "0.4000");
+        addNormalized(rules, "第11胸椎棘突下", "0.4380");
+        addNormalized(rules, "第12胸椎棘突下", "0.4760");
+
+        addNormalized(rules, "第1腰椎棘突下", "0.5140");
+        addNormalized(rules, "第2腰椎棘突下", "0.5520");
+        addNormalized(rules, "第3腰椎棘突下", "0.5900");
+        addNormalized(rules, "第4腰椎棘突下", "0.6280");
+        addNormalized(rules, "第5腰椎棘突下", "0.6660");
+
+        addSacral(rules, 1, "0.7500");
+        addSacral(rules, 2, "0.8340");
+        addSacral(rules, 3, "0.9170");
+        addSacral(rules, 4, "1.0000");
+
+        RULES = Collections.unmodifiableMap(rules);
+    }
+
+    private AcupointYReference() {
+    }
+
+    public static List<YReferenceOptionDTO> listOptions() {
+        List<YReferenceOptionDTO> list = new ArrayList<>();
+        for (Map.Entry<String, Rule> entry : RULES.entrySet()) {
+            Rule rule = entry.getValue();
+            YReferenceOptionDTO dto = new YReferenceOptionDTO();
+            dto.setValue(entry.getKey());
+            dto.setLabel(entry.getKey());
+            dto.setNormalizedY(rule.getNormalizedY());
+            dto.setCunOffset(rule.getCunOffset());
+            dto.setDescription(rule.getDescription());
+            list.add(dto);
+        }
+        return list;
+    }
+
+    public static boolean isSupported(String term) {
+        return getRule(term) != null;
+    }
+
+    public static Rule getRule(String term) {
+        String normalizedTerm = normalizeTerm(term);
+        return normalizedTerm == null ? null : RULES.get(normalizedTerm);
+    }
+
+    public static String normalizeTerm(String term) {
+        if (term == null || term.trim().isEmpty()) {
+            return null;
+        }
+        String trimmed = term.trim();
+        if (RULES.containsKey(trimmed)) {
+            return trimmed;
+        }
+        return inferTerm(trimmed);
+    }
+
+    /**
+     * 从 Excel 的“穴位定位”原文中提取可计算的Y轴术语。
+     */
+    public static String inferTerm(String locationDescription) {
+        if (locationDescription == null || locationDescription.trim().isEmpty()) {
+            return null;
+        }
+        String text = locationDescription.trim();
+
+        if (C7_UP_2_CUN.matcher(text).find()) {
+            return "第7颈椎棘突直上2寸";
+        }
+
+        Matcher sacralCenter = SACRAL_CENTER.matcher(text);
+        if (sacralCenter.find()) {
+            return "正对第" + sacralCenter.group(1) + "骶后孔中";
+        }
+
+        Matcher sacralLevel = SACRAL_LEVEL.matcher(text);
+        if (sacralLevel.find()) {
+            return "横平第" + sacralLevel.group(1) + "骶后孔";
+        }
+
+        Matcher thoracic = THORACIC.matcher(text);
+        if (thoracic.find()) {
+            return "第" + thoracic.group(1) + "胸椎棘突下";
+        }
+
+        Matcher lumbar = LUMBAR.matcher(text);
+        if (lumbar.find()) {
+            return "第" + lumbar.group(1) + "腰椎棘突下";
+        }
+
+        Matcher c7 = C7.matcher(text);
+        if (c7.find()) {
+            return text.contains("棘突下") ? "第7颈椎棘突下" : "第7颈椎棘突";
+        }
+
+        return null;
+    }
+
+    /**
+     * 按用户体型计算Y坐标(mm)。
+     *
+     * @return 无法计算时返回 null
+     */
+    public static BigDecimal calculateY(String term, BigDecimal spineLengthCm, BigDecimal finalCunMm) {
+        Rule rule = getRule(term);
+        if (rule == null) {
+            return null;
+        }
+        if (rule.getNormalizedY() != null) {
+            if (rule.getNormalizedY().compareTo(BigDecimal.ZERO) == 0) {
+                return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+            }
+            if (spineLengthCm == null || spineLengthCm.compareTo(BigDecimal.ZERO) <= 0) {
+                return null;
+            }
+            return spineLengthCm.multiply(BigDecimal.TEN)
+                    .multiply(rule.getNormalizedY())
+                    .setScale(2, RoundingMode.HALF_UP);
+        }
+        if (rule.getCunOffset() != null && finalCunMm != null) {
+            return rule.getCunOffset().multiply(finalCunMm).setScale(2, RoundingMode.HALF_UP);
+        }
+        return null;
+    }
+
+    private static void addNormalized(Map<String, Rule> rules, String term, String normalizedY) {
+        BigDecimal value = new BigDecimal(normalizedY);
+        rules.put(term, new Rule(value, null, "Y = 脊柱长度(C7-S4) × " + value.toPlainString()));
+    }
+
+    private static void addCunOffset(Map<String, Rule> rules, String term, String cunOffset) {
+        BigDecimal value = new BigDecimal(cunOffset);
+        rules.put(term, new Rule(null, value, "Y = " + value.toPlainString() + " × 最终指寸"));
+    }
+
+    private static void addSacral(Map<String, Rule> rules, int index, String normalizedY) {
+        addNormalized(rules, "横平第" + index + "骶后孔", normalizedY);
+        addNormalized(rules, "正对第" + index + "骶后孔中", normalizedY);
+    }
+
+    public static class Rule {
+        private final BigDecimal normalizedY;
+        private final BigDecimal cunOffset;
+        private final String description;
+
+        private Rule(BigDecimal normalizedY, BigDecimal cunOffset, String description) {
+            this.normalizedY = normalizedY;
+            this.cunOffset = cunOffset;
+            this.description = description;
+        }
+
+        public BigDecimal getNormalizedY() {
+            return normalizedY;
+        }
+
+        public BigDecimal getCunOffset() {
+            return cunOffset;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+    }
+}

+ 57 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/IpUtil.java

@@ -0,0 +1,57 @@
+package com.aijiuyi.admin.common.util;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * IP 地址获取工具类
+ * 支持从 Nginx、Apache、CDN 等代理场景下获取真实客户端 IP
+ */
+public class IpUtil {
+
+    /**
+     * 从请求中获取真实客户端 IP 地址
+     * 按优先级依次从以下请求头获取:
+     * X-Forwarded-For > Proxy-Client-IP > WL-Proxy-Client-IP >
+     * HTTP_CLIENT_IP > HTTP_X_FORWARDED_FOR > remoteAddr
+     * 若有多个 IP(经过多层代理),取第一个非 unknown 的 IP
+     *
+     * @param request HTTP 请求
+     * @return 客户端真实 IP 地址
+     */
+    public static String getIpAddr(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (isBlankOrUnknown(ip)) {
+            ip = request.getHeader("Proxy-Client-IP");
+        }
+        if (isBlankOrUnknown(ip)) {
+            ip = request.getHeader("WL-Proxy-Client-IP");
+        }
+        if (isBlankOrUnknown(ip)) {
+            ip = request.getHeader("HTTP_CLIENT_IP");
+        }
+        if (isBlankOrUnknown(ip)) {
+            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
+        }
+        if (isBlankOrUnknown(ip)) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (isBlankOrUnknown(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        // 多级代理时,X-Forwarded-For 可能是 "ip1,ip2,ip3",取第一个有效IP
+        if (ip != null && ip.contains(",")) {
+            ip = ip.split(",")[0].trim();
+        }
+        return ip;
+    }
+
+    /**
+     * 判断 IP 字符串是否为空或 unknown
+     *
+     * @param ip IP 字符串
+     * @return true=为空或unknown,false=有效IP
+     */
+    private static boolean isBlankOrUnknown(String ip) {
+        return ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip);
+    }
+}

+ 97 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/LogUtil.java

@@ -0,0 +1,97 @@
+package com.aijiuyi.admin.common.util;
+
+import com.aijiuyi.admin.common.context.RequestContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 统一日志工具类
+ * 封装 SLF4J,所有日志自动携带请求ID(格式:[requestId:xxx] 原始消息)
+ * 支持 info、debug、warn、error 四种级别
+ *
+ * 使用示例:
+ *   LogUtil.info(UserService.class, "查询用户: userId={}", userId);
+ *   LogUtil.error(UserService.class, "保存失败", e);
+ */
+public class LogUtil {
+
+    /**
+     * 获取指定类的 Logger
+     *
+     * @param clazz 调用类
+     * @return Logger 实例
+     */
+    private static Logger getLogger(Class<?> clazz) {
+        return LoggerFactory.getLogger(clazz);
+    }
+
+    /**
+     * 构建带请求ID前缀的日志消息
+     * 若无请求ID(如定时任务),则直接返回原始消息
+     *
+     * @param message 原始消息
+     * @return 带请求ID前缀的消息
+     */
+    private static String buildMessage(String message) {
+        String requestId = RequestContext.getRequestId();
+        if (requestId != null && !requestId.isEmpty()) {
+            return "[requestId:" + requestId + "] " + message;
+        }
+        return message;
+    }
+
+    /**
+     * INFO 级别日志
+     *
+     * @param clazz   调用类
+     * @param message 日志消息(支持 {} 占位符)
+     * @param args    占位符参数
+     */
+    public static void info(Class<?> clazz, String message, Object... args) {
+        getLogger(clazz).info(buildMessage(message), args);
+    }
+
+    /**
+     * DEBUG 级别日志
+     *
+     * @param clazz   调用类
+     * @param message 日志消息(支持 {} 占位符)
+     * @param args    占位符参数
+     */
+    public static void debug(Class<?> clazz, String message, Object... args) {
+        getLogger(clazz).debug(buildMessage(message), args);
+    }
+
+    /**
+     * WARN 级别日志(警告,不影响业务但需要关注)
+     *
+     * @param clazz   调用类
+     * @param message 日志消息(支持 {} 占位符)
+     * @param args    占位符参数
+     */
+    public static void warn(Class<?> clazz, String message, Object... args) {
+        getLogger(clazz).warn(buildMessage(message), args);
+    }
+
+    /**
+     * ERROR 级别日志(错误,有占位符参数)
+     *
+     * @param clazz   调用类
+     * @param message 日志消息(支持 {} 占位符)
+     * @param args    占位符参数
+     */
+    public static void error(Class<?> clazz, String message, Object... args) {
+        getLogger(clazz).error(buildMessage(message), args);
+    }
+
+    /**
+     * ERROR 级别日志(错误,带异常堆栈)
+     *
+     * @param clazz   调用类
+     * @param message 日志消息
+     * @param t       异常对象
+     */
+    public static void error(Class<?> clazz, String message, Throwable t) {
+        getLogger(clazz).error(buildMessage(message), t);
+    }
+}

+ 327 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/RedisUtil.java

@@ -0,0 +1,327 @@
+package com.aijiuyi.admin.common.util;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Redis 统一操作工具类
+ * 封装 RedisTemplate,提供 String、Hash、List、Set 等常用数据结构的操作方法
+ *
+ * 使用示例:
+ *   redisUtil.set("key", "value", 3600); // 设置一个小时过期的字符串
+ *   redisUtil.get("key");                // 获取值
+ */
+@Component
+public class RedisUtil {
+
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+
+    // ========================= 通用操作 =========================
+
+    /**
+     * 设置 key 的过期时间
+     *
+     * @param key  键
+     * @param time 过期时间(秒),负数或0表示永不过期
+     * @return true=设置成功,false=key不存在或设置失败
+     */
+    public boolean expire(String key, long time) {
+        if (time > 0) {
+            return Boolean.TRUE.equals(redisTemplate.expire(key, time, TimeUnit.SECONDS));
+        }
+        return false;
+    }
+
+    /**
+     * 获取 key 的剩余过期时间
+     *
+     * @param key 键
+     * @return 剩余过期时间(秒),-1表示永不过期,-2表示key不存在
+     */
+    public long getExpire(String key) {
+        Long expire = redisTemplate.getExpire(key, TimeUnit.SECONDS);
+        return expire == null ? -2 : expire;
+    }
+
+    /**
+     * 判断 key 是否存在
+     *
+     * @param key 键
+     * @return true=存在,false=不存在
+     */
+    public boolean hasKey(String key) {
+        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
+    }
+
+    /**
+     * 删除一个或多个 key
+     *
+     * @param keys 要删除的 key(可以传多个)
+     */
+    public void del(String... keys) {
+        if (keys != null && keys.length > 0) {
+            if (keys.length == 1) {
+                redisTemplate.delete(keys[0]);
+            } else {
+                redisTemplate.delete(Arrays.asList(keys));
+            }
+        }
+    }
+
+    // ========================= String 操作 =========================
+
+    /**
+     * 获取字符串类型的值
+     *
+     * @param key 键
+     * @return 值,key 不存在返回 null
+     */
+    public Object get(String key) {
+        return key == null ? null : redisTemplate.opsForValue().get(key);
+    }
+
+    /**
+     * 设置字符串类型的值(永不过期)
+     *
+     * @param key   键
+     * @param value 值
+     * @return 是否成功
+     */
+    public boolean set(String key, Object value) {
+        redisTemplate.opsForValue().set(key, value);
+        return true;
+    }
+
+    /**
+     * 设置字符串类型的值(带过期时间)
+     *
+     * @param key   键
+     * @param value 值
+     * @param time  过期时间(秒),time > 0 才设置过期
+     * @return 是否成功
+     */
+    public boolean set(String key, Object value, long time) {
+        if (time > 0) {
+            redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
+        } else {
+            set(key, value);
+        }
+        return true;
+    }
+
+    /**
+     * 递增(常用于计数器)
+     *
+     * @param key   键
+     * @param delta 递增步长(必须 > 0)
+     * @return 递增后的值
+     */
+    public long incr(String key, long delta) {
+        if (delta <= 0) {
+            throw new RuntimeException("递增步长必须大于0");
+        }
+        Long result = redisTemplate.opsForValue().increment(key, delta);
+        return result == null ? 0 : result;
+    }
+
+    /**
+     * 递减(常用于库存扣减)
+     *
+     * @param key   键
+     * @param delta 递减步长(必须 > 0)
+     * @return 递减后的值
+     */
+    public long decr(String key, long delta) {
+        if (delta <= 0) {
+            throw new RuntimeException("递减步长必须大于0");
+        }
+        Long result = redisTemplate.opsForValue().increment(key, -delta);
+        return result == null ? 0 : result;
+    }
+
+    // ========================= Hash 操作 =========================
+
+    /**
+     * 获取 Hash 中指定字段的值
+     *
+     * @param key   键
+     * @param field 字段名
+     * @return 字段值
+     */
+    public Object hget(String key, String field) {
+        return redisTemplate.opsForHash().get(key, field);
+    }
+
+    /**
+     * 获取 Hash 中所有字段和值
+     *
+     * @param key 键
+     * @return 所有字段和值的 Map
+     */
+    public Map<Object, Object> hmget(String key) {
+        return redisTemplate.opsForHash().entries(key);
+    }
+
+    /**
+     * 批量设置 Hash 字段
+     *
+     * @param key 键
+     * @param map 字段-值 Map
+     * @return 是否成功
+     */
+    public boolean hmset(String key, Map<String, Object> map) {
+        redisTemplate.opsForHash().putAll(key, map);
+        return true;
+    }
+
+    /**
+     * 设置 Hash 中单个字段的值
+     *
+     * @param key   键
+     * @param field 字段名
+     * @param value 字段值
+     * @return 是否成功
+     */
+    public boolean hset(String key, String field, Object value) {
+        redisTemplate.opsForHash().put(key, field, value);
+        return true;
+    }
+
+    /**
+     * 删除 Hash 中的字段
+     *
+     * @param key    键
+     * @param fields 要删除的字段(可多个)
+     */
+    public void hdel(String key, Object... fields) {
+        redisTemplate.opsForHash().delete(key, fields);
+    }
+
+    /**
+     * 判断 Hash 中某个字段是否存在
+     *
+     * @param key   键
+     * @param field 字段名
+     * @return true=存在,false=不存在
+     */
+    public boolean hHasKey(String key, String field) {
+        return redisTemplate.opsForHash().hasKey(key, field);
+    }
+
+    // ========================= List 操作 =========================
+
+    /**
+     * 获取 List 中指定范围的元素
+     *
+     * @param key   键
+     * @param start 开始索引(0表示第一个元素)
+     * @param end   结束索引(-1表示最后一个元素)
+     * @return 元素列表
+     */
+    public List<Object> lrange(String key, long start, long end) {
+        return redisTemplate.opsForList().range(key, start, end);
+    }
+
+    /**
+     * 获取 List 的长度
+     *
+     * @param key 键
+     * @return List 长度
+     */
+    public long lsize(String key) {
+        Long size = redisTemplate.opsForList().size(key);
+        return size == null ? 0 : size;
+    }
+
+    /**
+     * 向 List 右端追加元素
+     *
+     * @param key   键
+     * @param value 值
+     * @return 是否成功
+     */
+    public boolean rpush(String key, Object value) {
+        redisTemplate.opsForList().rightPush(key, value);
+        return true;
+    }
+
+    /**
+     * 向 List 右端追加元素(带过期时间)
+     *
+     * @param key   键
+     * @param value 值
+     * @param time  过期时间(秒)
+     * @return 是否成功
+     */
+    public boolean rpush(String key, Object value, long time) {
+        rpush(key, value);
+        if (time > 0) {
+            expire(key, time);
+        }
+        return true;
+    }
+
+    // ========================= Set 操作 =========================
+
+    /**
+     * 获取 Set 中所有元素
+     *
+     * @param key 键
+     * @return Set 元素集合
+     */
+    public Set<Object> smembers(String key) {
+        return redisTemplate.opsForSet().members(key);
+    }
+
+    /**
+     * 向 Set 中添加元素
+     *
+     * @param key    键
+     * @param values 要添加的元素(可多个)
+     * @return 成功添加的元素数量
+     */
+    public long sadd(String key, Object... values) {
+        Long count = redisTemplate.opsForSet().add(key, values);
+        return count == null ? 0 : count;
+    }
+
+    /**
+     * 获取 Set 的元素数量
+     *
+     * @param key 键
+     * @return 元素数量
+     */
+    public long ssize(String key) {
+        Long size = redisTemplate.opsForSet().size(key);
+        return size == null ? 0 : size;
+    }
+
+    /**
+     * 判断元素是否是 Set 的成员
+     *
+     * @param key   键
+     * @param value 元素
+     * @return true=是,false=否
+     */
+    public boolean sisMember(String key, Object value) {
+        return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(key, value));
+    }
+
+    /**
+     * 从 Set 中删除元素
+     *
+     * @param key    键
+     * @param values 要删除的元素
+     * @return 成功删除的元素数量
+     */
+    public long srem(String key, Object... values) {
+        Long count = redisTemplate.opsForSet().remove(key, values);
+        return count == null ? 0 : count;
+    }
+}

+ 133 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/TokenUtil.java

@@ -0,0 +1,133 @@
+package com.aijiuyi.admin.common.util;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import org.springframework.util.StringUtils;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * JWT Token 工具类
+ * 提供 Token 的生成、解析、验证功能
+ * Token 存储用户ID和用户名,默认3年有效期(有效期内可通过 Redis 控制失效)
+ */
+public class TokenUtil {
+
+    /** JWT 签名密钥(生产环境请修改为复杂密钥并从配置文件读取) */
+    private static final String SECRET = "aijiuyi_admin_jwt_secret_key_2024_change_in_prod";
+
+    /** Token 默认有效期:3年(毫秒) */
+    private static final long EXPIRATION = 3L * 365 * 24 * 60 * 60 * 1000;
+
+    /** Bearer 前缀 */
+    private static final String TOKEN_PREFIX = "Bearer ";
+
+    /**
+     * 生成 JWT Token(携带 userType)
+     *
+     * @param userId   用户ID
+     * @param username 用户名
+     * @param userType 用户类型(角色)
+     * @return JWT Token 字符串
+     */
+    public static String generateToken(Long userId, String username, Integer userType) {
+        Map<String, Object> claims = new HashMap<>(4);
+        claims.put("userId", userId);
+        claims.put("username", username);
+        claims.put("userType", userType);
+
+        return Jwts.builder()
+                .setClaims(claims)
+                .setIssuedAt(new Date())
+                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
+                .signWith(SignatureAlgorithm.HS256, SECRET)
+                .compact();
+    }
+
+    /**
+     * 生成 JWT Token
+     *
+     * @param userId   用户ID
+     * @param username 用户名
+     * @return JWT Token 字符串
+     */
+    public static String generateToken(Long userId, String username) {
+        Map<String, Object> claims = new HashMap<>(4);
+        claims.put("userId", userId);
+        claims.put("username", username);
+
+        return Jwts.builder()
+                .setClaims(claims)
+                .setIssuedAt(new Date())
+                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
+                .signWith(SignatureAlgorithm.HS256, SECRET)
+                .compact();
+    }
+
+    /**
+     * 解析 JWT Token,获取 Claims
+     * 若 Token 无效或已过期,返回 null
+     *
+     * @param token Token 字符串(可带 Bearer 前缀)
+     * @return Claims,解析失败返回 null
+     */
+    public static Claims parseToken(String token) {
+        if (!StringUtils.hasText(token)) {
+            return null;
+        }
+        // 去掉 Bearer 前缀
+        if (token.startsWith(TOKEN_PREFIX)) {
+            token = token.substring(TOKEN_PREFIX.length());
+        }
+        try {
+            return Jwts.parser()
+                    .setSigningKey(SECRET)
+                    .parseClaimsJws(token)
+                    .getBody();
+        } catch (Exception e) {
+            // Token 无效或已过期
+            return null;
+        }
+    }
+
+    /**
+     * 从 Token 中获取用户ID
+     *
+     * @param token Token 字符串
+     * @return 用户ID,解析失败返回 null
+     */
+    public static Long getUserId(String token) {
+        Claims claims = parseToken(token);
+        if (claims == null) {
+            return null;
+        }
+        return ((Number) claims.get("userId")).longValue();
+    }
+
+    /**
+     * 从 Token 中获取用户名
+     *
+     * @param token Token 字符串
+     * @return 用户名,解析失败返回 null
+     */
+    public static String getUsername(String token) {
+        Claims claims = parseToken(token);
+        if (claims == null) {
+            return null;
+        }
+        return (String) claims.get("username");
+    }
+
+    /**
+     * 验证 Token 是否有效(格式正确且未过期)
+     *
+     * @param token Token 字符串
+     * @return true=有效,false=无效
+     */
+    public static boolean validateToken(String token) {
+        return parseToken(token) != null;
+    }
+}

+ 144 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AcupointController.java

@@ -0,0 +1,144 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.AcupointQueryDTO;
+import com.aijiuyi.admin.controller.dto.YReferenceOptionDTO;
+import com.aijiuyi.admin.entity.Acupoint;
+import com.aijiuyi.admin.service.AcupointService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 穴位管理 Controller
+ * 提供穴位的增删改查、批量删除和批量导入接口
+ */
+@RestController
+@RequestMapping("/acupoint")
+public class AcupointController {
+
+    @Autowired
+    private AcupointService acupointService;
+
+    /**
+     * 获取所有启用穴位列表(用于选择框)
+     *
+     * @return 穴位列表(id + name)
+     */
+    @GetMapping("/list")
+    @Log(value = "获取穴位选项列表", module = "穴位管理", operationType = OperationType.QUERY)
+    public Result<List<Acupoint>> list() {
+        List<Acupoint> list = acupointService.list(
+                new LambdaQueryWrapper<Acupoint>()
+                        .eq(Acupoint::getStatus, 1)
+                        .select(Acupoint::getId, Acupoint::getName)
+                        .orderByAsc(Acupoint::getId)
+        );
+        return Result.success(list);
+    }
+
+    /**
+     * 获取Y轴医学定位术语选项(用于新增/编辑穴位时选择)
+     *
+     * @return Y轴定位术语选项
+     */
+    @GetMapping("/y-reference-options")
+    @Log(value = "获取穴位Y轴定位术语", module = "穴位管理", operationType = OperationType.QUERY)
+    public Result<List<YReferenceOptionDTO>> yReferenceOptions() {
+        return Result.success(acupointService.listYReferenceOptions());
+    }
+
+    /**
+     * 分页查询穴位列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询穴位列表", module = "穴位管理", operationType = OperationType.QUERY)
+    public Result<IPage<Acupoint>> page(AcupointQueryDTO queryDTO) {
+        return Result.success(acupointService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询穴位详情
+     *
+     * @param id 穴位ID
+     * @return 穴位详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询穴位详情", module = "穴位管理", operationType = OperationType.QUERY)
+    public Result<Acupoint> getById(@PathVariable Long id) {
+        return Result.success(acupointService.getById(id));
+    }
+
+    /**
+     * 新增穴位
+     *
+     * @param acupoint 穴位信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增穴位", module = "穴位管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody Acupoint acupoint) {
+        acupointService.addAcupoint(acupoint);
+        return Result.success();
+    }
+
+    /**
+     * 修改穴位信息
+     *
+     * @param acupoint 穴位信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改穴位信息", module = "穴位管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody Acupoint acupoint) {
+        acupointService.updateAcupoint(acupoint);
+        return Result.success();
+    }
+
+    /**
+     * 删除穴位(逻辑删除)
+     *
+     * @param id 穴位ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除穴位", module = "穴位管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        acupointService.deleteAcupoint(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量删除穴位(逻辑删除,需二次确认)
+     *
+     * @param ids 穴位ID列表
+     * @return 操作结果
+     */
+    @DeleteMapping("/batch")
+    @Log(value = "批量删除穴位", module = "穴位管理", operationType = OperationType.DELETE)
+    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
+        acupointService.batchDeleteAcupoint(ids);
+        return Result.success();
+    }
+
+    /**
+     * 批量导入穴位
+     *
+     * @param list 穴位信息列表
+     * @return 操作结果
+     */
+    @PostMapping("/import")
+    @Log(value = "批量导入穴位", module = "穴位管理", operationType = OperationType.INSERT)
+    public Result<Void> batchImport(@RequestBody List<Acupoint> list) {
+        acupointService.batchImport(list);
+        return Result.success();
+    }
+}

+ 147 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AdminController.java

@@ -0,0 +1,147 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.AdminQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.service.AdminService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理员管理 Controller
+ * 提供管理员账号的增删改查、状态管理及页面权限配置接口
+ */
+@RestController
+@RequestMapping("/admin")
+public class AdminController {
+
+    @Autowired
+    private AdminService adminService;
+
+    /**
+     * 分页查询管理员列表
+     *
+     * @param queryDTO 查询参数(用户名/姓名模糊、角色、状态)
+     * @return 分页管理员列表
+     */
+    @GetMapping("/page")
+    @Log(value = "查询管理员列表", module = "管理员管理", operationType = OperationType.QUERY)
+    public Result<IPage<AppUser>> page(AdminQueryDTO queryDTO) {
+        return Result.success(adminService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询管理员详情
+     *
+     * @param id 管理员ID
+     * @return 管理员信息
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询管理员详情", module = "管理员管理", operationType = OperationType.QUERY)
+    public Result<AppUser> getById(@PathVariable Long id) {
+        return Result.success(adminService.getById(id));
+    }
+
+    /**
+     * 新增管理员
+     *
+     * @param admin 管理员信息(username/password/nickname/adminRole/status 必填)
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增管理员", module = "管理员管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody AppUser admin) {
+        adminService.addAdmin(admin);
+        return Result.success();
+    }
+
+    /**
+     * 修改管理员信息
+     *
+     * @param admin 管理员信息(需携带 id;密码为空则不修改)
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改管理员信息", module = "管理员管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody AppUser admin) {
+        adminService.updateAdmin(admin);
+        return Result.success();
+    }
+
+    /**
+     * 删除管理员(逻辑删除,超级管理员不可删除)
+     *
+     * @param id 管理员ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除管理员", module = "管理员管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        adminService.deleteAdmin(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量删除管理员
+     *
+     * @param ids 管理员ID列表
+     * @return 操作结果
+     */
+    @DeleteMapping("/batch")
+    @Log(value = "批量删除管理员", module = "管理员管理", operationType = OperationType.DELETE)
+    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
+        adminService.batchDeleteAdmin(ids);
+        return Result.success();
+    }
+
+    /**
+     * 修改管理员状态(禁用/启用)
+     *
+     * @param id     管理员ID
+     * @param status 目标状态:1=启用,0=禁用
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/status")
+    @Log(value = "修改管理员状态", module = "管理员管理", operationType = OperationType.UPDATE)
+    public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
+        adminService.updateStatus(id, status);
+        return Result.success();
+    }
+
+    /**
+     * 查询管理员权限路由列表
+     * 超级管理员返回 null,前端视为全部权限
+     *
+     * @param id 管理员ID
+     * @return 路由路径 JSON 数组字符串
+     */
+    @GetMapping("/{id}/permission")
+    @Log(value = "查询管理员权限", module = "管理员管理", operationType = OperationType.QUERY)
+    public Result<Map<String, Object>> getPermission(@PathVariable Long id) {
+        String routePaths = adminService.getPermissions(id);
+        Map<String, Object> result = new HashMap<>(2);
+        result.put("routePaths", routePaths);
+        return Result.success(result);
+    }
+
+    /**
+     * 更新管理员权限路由列表
+     *
+     * @param id      管理员ID
+     * @param payload 请求体,包含 routePaths(JSON数组字符串)
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/permission")
+    @Log(value = "更新管理员权限", module = "管理员管理", operationType = OperationType.UPDATE)
+    public Result<Void> updatePermission(@PathVariable Long id, @RequestBody Map<String, String> payload) {
+        adminService.updatePermissions(id, payload.get("routePaths"));
+        return Result.success();
+    }
+}

+ 122 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserController.java

@@ -0,0 +1,122 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.AppUserQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.service.AppUserService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 应用用户管理 Controller(管理员后台)
+ * 提供应用用户的增删改查和状态管理接口
+ */
+@RestController
+@RequestMapping("/user/app")
+public class AppUserController {
+
+    @Autowired
+    private AppUserService appUserService;
+
+    /**
+     * 获取应用用户选项列表(用于方案创作人选择框)
+     * 返回所有启用状态的用户,前端根据 userType 区分角色
+     *
+     * @param userType 可选过滤:1=普通用户,2=专家,3=管理员;null=全部
+     * @return 用户列表(id + nickname + username + userType)
+     */
+    @GetMapping("/list")
+    @Log(value = "获取用户选项列表", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<List<AppUser>> list(@RequestParam(required = false) Integer userType) {
+        LambdaQueryWrapper<AppUser> wrapper = new LambdaQueryWrapper<AppUser>()
+                .eq(AppUser::getStatus, 1)
+                .eq(userType != null, AppUser::getUserType, userType)
+                .select(AppUser::getId, AppUser::getNickname, AppUser::getUsername, AppUser::getUserType)
+                .orderByAsc(AppUser::getUserType)
+                .orderByAsc(AppUser::getId);
+        return Result.success(appUserService.list(wrapper));
+    }
+
+    /**
+     * 分页查询应用用户列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询应用用户列表", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<IPage<AppUser>> page(AppUserQueryDTO queryDTO) {
+        return Result.success(appUserService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询应用用户详情
+     *
+     * @param id 用户ID
+     * @return 用户详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询应用用户详情", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<AppUser> getById(@PathVariable Long id) {
+        return Result.success(appUserService.getById(id));
+    }
+
+    /**
+     * 新增应用用户
+     *
+     * @param appUser 用户信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增应用用户", module = "用户管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody AppUser appUser) {
+        appUserService.addUser(appUser);
+        return Result.success();
+    }
+
+    /**
+     * 修改应用用户
+     *
+     * @param appUser 用户信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改应用用户", module = "用户管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody AppUser appUser) {
+        appUserService.updateUser(appUser);
+        return Result.success();
+    }
+
+    /**
+     * 删除应用用户(逻辑删除)
+     *
+     * @param id 用户ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除应用用户", module = "用户管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        appUserService.deleteUser(id);
+        return Result.success();
+    }
+
+    /**
+     * 修改应用用户状态(禁用/启用)
+     *
+     * @param id     用户ID
+     * @param status 目标状态:1=启用,0=禁用
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/status")
+    @Log(value = "修改应用用户状态", module = "用户管理", operationType = OperationType.UPDATE)
+    public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
+        appUserService.updateStatus(id, status);
+        return Result.success();
+    }
+}

+ 91 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AuthController.java

@@ -0,0 +1,91 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.annotation.NoAuth;
+import com.aijiuyi.admin.common.context.RequestContext;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.controller.dto.LoginDTO;
+import com.aijiuyi.admin.service.AdminService;
+import com.aijiuyi.admin.service.AppUserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 认证管理 Controller
+ * 提供登录、退出、获取当前用户信息接口
+ * 统一使用应用用户表(app_user)进行身份验证
+ */
+@RestController
+@RequestMapping("/auth")
+public class AuthController {
+
+    @Autowired
+    private AppUserService appUserService;
+
+    @Autowired
+    private AdminService adminService;
+
+    /**
+     * 用户登录接口
+     * 免鉴权(使用 @NoAuth 注解跳过 Token 验证)
+     * 仅 userType=3(管理员)账号可登录后台
+     *
+     * @param loginDTO 登录参数(用户名、密码)
+     * @return 包含 token 和用户信息的 Result
+     */
+    @PostMapping("/login")
+    @NoAuth
+    @Log(value = "用户登录", module = "认证管理", operationType = OperationType.OTHER)
+    public Result<Map<String, Object>> login(@RequestBody @Valid LoginDTO loginDTO) {
+        return appUserService.login(loginDTO);
+    }
+
+    /**
+     * 用户退出登录
+     * 删除 Redis 中的 Token,使当前 Token 立即失效
+     *
+     * @param request HTTP 请求(用于获取 Authorization 头中的 Token)
+     * @return 操作结果
+     */
+    @PostMapping("/logout")
+    @Log(value = "用户退出", module = "认证管理", operationType = OperationType.OTHER)
+    public Result<Void> logout(HttpServletRequest request) {
+        String token = request.getHeader("Authorization");
+        appUserService.logout(token);
+        return Result.success();
+    }
+
+    /**
+     * 获取当前登录用户信息(含权限路由列表)
+     *
+     * @return 应用用户信息 + permissions 权限路由列表
+     */
+    @GetMapping("/info")
+    @Log(value = "获取当前用户信息", module = "认证管理", operationType = OperationType.QUERY)
+    public Result<Map<String, Object>> getUserInfo() {
+        // 从 ThreadLocal 获取当前用户ID(由 TokenInterceptor 解析 Token 后设置)
+        Long userId = RequestContext.getUserId();
+        AppUser user = appUserService.getById(userId);
+        if (user != null) {
+            user.setPassword(null);
+        }
+        // 查询权限路由列表(超级管理员返回 null,前端视为全部权限)
+        String permissions = adminService.getPermissions(userId);
+        Map<String, Object> data = new HashMap<>(8);
+        data.put("userId", user != null ? user.getId() : null);
+        data.put("username", user != null ? user.getUsername() : null);
+        data.put("nickname", user != null ? user.getNickname() : null);
+        data.put("avatar", user != null ? user.getAvatar() : null);
+        data.put("userType", user != null ? user.getUserType() : null);
+        data.put("adminRole", user != null ? user.getAdminRole() : null);
+        data.put("permissions", permissions);
+        return Result.success(data);
+    }
+}

+ 186 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/DeviceController.java

@@ -0,0 +1,186 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.DeviceQueryDTO;
+import com.aijiuyi.admin.controller.dto.DeviceSaveDTO;
+import com.aijiuyi.admin.entity.Device;
+import com.aijiuyi.admin.entity.DeviceUserVO;
+import com.aijiuyi.admin.service.DeviceService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 设备管理 Controller
+ * 提供艾灸椅设备的增删改查、批量导入、以及设备绑定用户管理接口
+ */
+@RestController
+@RequestMapping("/device")
+public class DeviceController {
+
+    @Autowired
+    private DeviceService deviceService;
+
+    /**
+     * 分页查询设备列表(含绑定用户数和用户姓名)
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询设备列表", module = "设备管理", operationType = OperationType.QUERY)
+    public Result<IPage<Device>> page(DeviceQueryDTO queryDTO) {
+        return Result.success(deviceService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询设备详情
+     *
+     * @param id 设备ID
+     * @return 设备详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询设备详情", module = "设备管理", operationType = OperationType.QUERY)
+    public Result<Device> getById(@PathVariable Long id) {
+        return Result.success(deviceService.getById(id));
+    }
+
+    /**
+     * 新增设备(可同时绑定初始用户)
+     *
+     * @param dto 设备信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增设备", module = "设备管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody DeviceSaveDTO dto) {
+        deviceService.addDevice(dto);
+        return Result.success();
+    }
+
+    /**
+     * 修改设备信息
+     *
+     * @param dto 设备信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改设备信息", module = "设备管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody DeviceSaveDTO dto) {
+        deviceService.updateDevice(dto);
+        return Result.success();
+    }
+
+    /**
+     * 删除设备(逻辑删除)
+     *
+     * @param id 设备ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除设备", module = "设备管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        deviceService.deleteDevice(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量删除设备(逻辑删除,需二次确认)
+     *
+     * @param ids 设备ID列表
+     * @return 操作结果
+     */
+    @DeleteMapping("/batch")
+    @Log(value = "批量删除设备", module = "设备管理", operationType = OperationType.DELETE)
+    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
+        deviceService.batchDeleteDevices(ids);
+        return Result.success();
+    }
+
+    /**
+     * 批量导入设备
+     *
+     * @param list 设备信息列表
+     * @return 操作结果
+     */
+    @PostMapping("/import")
+    @Log(value = "批量导入设备", module = "设备管理", operationType = OperationType.INSERT)
+    public Result<Void> batchImport(@RequestBody List<Device> list) {
+        deviceService.batchImport(list);
+        return Result.success();
+    }
+
+    // ======================== 设备用户管理 ========================
+
+    /**
+     * 查询设备绑定的用户列表
+     *
+     * @param deviceId 设备ID
+     * @return 绑定用户列表
+     */
+    @GetMapping("/{deviceId}/users")
+    @Log(value = "查询设备绑定用户列表", module = "设备管理", operationType = OperationType.QUERY)
+    public Result<List<DeviceUserVO>> getDeviceUsers(@PathVariable Long deviceId) {
+        return Result.success(deviceService.getDeviceUsers(deviceId));
+    }
+
+    /**
+     * 为设备添加绑定用户
+     *
+     * @param deviceId  设备ID
+     * @param profileId 用户档案ID(user_profile.id)
+     * @return 操作结果
+     */
+    @PostMapping("/{deviceId}/users/{profileId}")
+    @Log(value = "添加设备绑定用户", module = "设备管理", operationType = OperationType.INSERT)
+    public Result<Void> addDeviceUser(@PathVariable Long deviceId, @PathVariable Long profileId) {
+        deviceService.addDeviceUser(deviceId, profileId);
+        return Result.success();
+    }
+
+    /**
+     * 解绑设备用户
+     *
+     * @param deviceId  设备ID
+     * @param profileId 用户档案ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{deviceId}/users/{profileId}")
+    @Log(value = "解绑设备用户", module = "设备管理", operationType = OperationType.DELETE)
+    public Result<Void> removeDeviceUser(@PathVariable Long deviceId, @PathVariable Long profileId) {
+        deviceService.removeDeviceUser(deviceId, profileId);
+        return Result.success();
+    }
+
+    /**
+     * 设为主用户(同一设备下只有一个主用户)
+     *
+     * @param deviceId  设备ID
+     * @param profileId 用户档案ID
+     * @return 操作结果
+     */
+    @PutMapping("/{deviceId}/users/{profileId}/primary")
+    @Log(value = "设置设备主用户", module = "设备管理", operationType = OperationType.UPDATE)
+    public Result<Void> setDeviceUserPrimary(@PathVariable Long deviceId, @PathVariable Long profileId) {
+        deviceService.setDeviceUserPrimary(deviceId, profileId);
+        return Result.success();
+    }
+
+    /**
+     * 批量解绑设备用户
+     *
+     * @param deviceId 设备ID
+     * @param bindIds  绑定记录ID列表(user_device.id)
+     * @return 操作结果
+     */
+    @DeleteMapping("/{deviceId}/users/batch")
+    @Log(value = "批量解绑设备用户", module = "设备管理", operationType = OperationType.DELETE)
+    public Result<Void> batchRemoveDeviceUsers(@PathVariable Long deviceId, @RequestBody List<Long> bindIds) {
+        deviceService.batchRemoveDeviceUsers(deviceId, bindIds);
+        return Result.success();
+    }
+}

+ 119 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/MoxibustionTechniqueController.java

@@ -0,0 +1,119 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.MoxibustionTechniqueQueryDTO;
+import com.aijiuyi.admin.entity.MoxibustionTechnique;
+import com.aijiuyi.admin.service.MoxibustionTechniqueService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 艾灸手法管理 Controller
+ * 提供手法的增删改查、批量删除接口
+ */
+@RestController
+@RequestMapping("/moxibustion-technique")
+public class MoxibustionTechniqueController {
+
+    @Autowired
+    private MoxibustionTechniqueService techniqueService;
+
+    /**
+     * 获取所有启用艾灸手法列表(用于选择框)
+     *
+     * @return 手法列表(id + name)
+     */
+    @GetMapping("/list")
+    @Log(value = "获取手法选项列表", module = "艾灸手法管理", operationType = OperationType.QUERY)
+    public Result<List<MoxibustionTechnique>> list() {
+        List<MoxibustionTechnique> list = techniqueService.list(
+                new LambdaQueryWrapper<MoxibustionTechnique>()
+                        .eq(MoxibustionTechnique::getStatus, 1)
+                        .select(MoxibustionTechnique::getId, MoxibustionTechnique::getName)
+                        .orderByAsc(MoxibustionTechnique::getId)
+        );
+        return Result.success(list);
+    }
+
+    /**
+     * 分页查询艾灸手法列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询艾灸手法列表", module = "艾灸手法管理", operationType = OperationType.QUERY)
+    public Result<IPage<MoxibustionTechnique>> page(MoxibustionTechniqueQueryDTO queryDTO) {
+        return Result.success(techniqueService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询艾灸手法详情
+     *
+     * @param id 手法ID
+     * @return 手法详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询艾灸手法详情", module = "艾灸手法管理", operationType = OperationType.QUERY)
+    public Result<MoxibustionTechnique> getById(@PathVariable Long id) {
+        return Result.success(techniqueService.getById(id));
+    }
+
+    /**
+     * 新增艾灸手法
+     *
+     * @param technique 手法信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增艾灸手法", module = "艾灸手法管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody MoxibustionTechnique technique) {
+        techniqueService.addTechnique(technique);
+        return Result.success();
+    }
+
+    /**
+     * 修改艾灸手法信息
+     *
+     * @param technique 手法信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改艾灸手法", module = "艾灸手法管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody MoxibustionTechnique technique) {
+        techniqueService.updateTechnique(technique);
+        return Result.success();
+    }
+
+    /**
+     * 删除艾灸手法(逻辑删除)
+     *
+     * @param id 手法ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除艾灸手法", module = "艾灸手法管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        techniqueService.deleteTechnique(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量删除艾灸手法(逻辑删除)
+     *
+     * @param ids 手法ID列表
+     * @return 操作结果
+     */
+    @DeleteMapping("/batch")
+    @Log(value = "批量删除艾灸手法", module = "艾灸手法管理", operationType = OperationType.DELETE)
+    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
+        techniqueService.batchDeleteTechnique(ids);
+        return Result.success();
+    }
+}

+ 154 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/PlanController.java

@@ -0,0 +1,154 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.PlanQueryDTO;
+import com.aijiuyi.admin.controller.dto.PlanSaveDTO;
+import com.aijiuyi.admin.entity.Plan;
+import com.aijiuyi.admin.service.PlanService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 方案管理 Controller
+ * 提供方案的新增、编辑、发布、下架、删除等接口
+ */
+@RestController
+@RequestMapping("/plan")
+public class PlanController {
+
+    @Autowired
+    private PlanService planService;
+
+    /**
+     * 分页查询方案列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询方案列表", module = "方案管理", operationType = OperationType.QUERY)
+    public Result<IPage<Plan>> page(PlanQueryDTO queryDTO) {
+        return Result.success(planService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询方案详情(含步骤列表)
+     *
+     * @param id 方案ID
+     * @return 方案详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询方案详情", module = "方案管理", operationType = OperationType.QUERY)
+    public Result<PlanSaveDTO> getById(@PathVariable Long id) {
+        return Result.success(planService.getDetail(id));
+    }
+
+    /**
+     * 新增方案(保存为草稿)
+     *
+     * @param dto 方案信息(含步骤)
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增方案", module = "方案管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody PlanSaveDTO dto) {
+        planService.addPlan(dto);
+        return Result.success();
+    }
+
+    /**
+     * 修改方案信息(含步骤)
+     *
+     * @param dto 方案信息(含步骤)
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改方案信息", module = "方案管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody PlanSaveDTO dto) {
+        planService.updatePlan(dto);
+        return Result.success();
+    }
+
+    /**
+     * 删除方案(逻辑删除,仅草稿可删)
+     *
+     * @param id 方案ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除方案", module = "方案管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        planService.deletePlan(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量删除方案(仅草稿可删,需二次确认)
+     *
+     * @param ids 方案ID列表
+     * @return 操作结果
+     */
+    @DeleteMapping("/batch")
+    @Log(value = "批量删除方案", module = "方案管理", operationType = OperationType.DELETE)
+    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
+        planService.batchDeletePlan(ids);
+        return Result.success();
+    }
+
+    /**
+     * 发布方案(草稿 → 已发布)
+     *
+     * @param id 方案ID
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/publish")
+    @Log(value = "发布方案", module = "方案管理", operationType = OperationType.UPDATE)
+    public Result<Void> publish(@PathVariable Long id) {
+        planService.publishPlan(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量发布方案
+     *
+     * @param ids 方案ID列表
+     * @return 操作结果
+     */
+    @PutMapping("/batch/publish")
+    @Log(value = "批量发布方案", module = "方案管理", operationType = OperationType.UPDATE)
+    public Result<Void> batchPublish(@RequestBody List<Long> ids) {
+        planService.batchPublishPlan(ids);
+        return Result.success();
+    }
+
+    /**
+     * 下架方案(已发布 → 已下架)
+     *
+     * @param id 方案ID
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/offshelf")
+    @Log(value = "下架方案", module = "方案管理", operationType = OperationType.UPDATE)
+    public Result<Void> offshelf(@PathVariable Long id) {
+        planService.offshelfPlan(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量下架方案
+     *
+     * @param ids 方案ID列表
+     * @return 操作结果
+     */
+    @PutMapping("/batch/offshelf")
+    @Log(value = "批量下架方案", module = "方案管理", operationType = OperationType.UPDATE)
+    public Result<Void> batchOffshelf(@RequestBody List<Long> ids) {
+        planService.batchOffshelfPlan(ids);
+        return Result.success();
+    }
+}

+ 36 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/SimulationController.java

@@ -0,0 +1,36 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.SimulationResponse;
+import com.aijiuyi.admin.service.SimulationService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 方案模拟测试 Controller
+ * 模拟小程序选择方案后,系统对穴位坐标的计算结果
+ */
+@RestController
+@RequestMapping("/simulation")
+public class SimulationController {
+
+    @Autowired
+    private SimulationService simulationService;
+
+    /**
+     * 组装方案模拟推送数据
+     *
+     * @param profileId 子用户ID
+     * @param planId    方案ID
+     * @return 推送数据(含体型、方案步骤、穴位坐标偏移)
+     */
+    @GetMapping("/push-data/{profileId}/{planId}")
+    @Log(value = "方案模拟测试", module = "模拟测试", operationType = OperationType.QUERY)
+    public Result<SimulationResponse> getPushData(
+            @PathVariable Long profileId,
+            @PathVariable Long planId) {
+        return Result.success(simulationService.assemblePushData(profileId, planId));
+    }
+}

+ 63 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/SysLogController.java

@@ -0,0 +1,63 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.entity.SysLog;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.common.service.SysLogService;
+import com.aijiuyi.admin.controller.dto.SysLogQueryDTO;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 日志管理 Controller
+ * 提供操作日志的分页查询、全量导出和管理员选项接口
+ */
+@RestController
+@RequestMapping("/log")
+public class SysLogController {
+
+    @Autowired
+    private SysLogService sysLogService;
+
+    /**
+     * 分页查询操作日志列表
+     *
+     * @param queryDTO 查询条件(操作人、操作类型、操作模块、时间范围、分页参数)
+     * @return 分页日志列表
+     */
+    @GetMapping("/page")
+    @Log(value = "查询操作日志列表", module = "日志管理", operationType = OperationType.QUERY)
+    public Result<IPage<SysLog>> page(SysLogQueryDTO queryDTO) {
+        return Result.success(sysLogService.pageList(queryDTO));
+    }
+
+    /**
+     * 导出全部操作日志(返回全量数据供前端 Excel 导出使用)
+     *
+     * @param queryDTO 查询条件(不含分页参数)
+     * @return 日志列表
+     */
+    @GetMapping("/export")
+    @Log(value = "导出操作日志", module = "日志管理", operationType = OperationType.OTHER)
+    public Result<List<SysLog>> export(SysLogQueryDTO queryDTO) {
+        return Result.success(sysLogService.listAll(queryDTO));
+    }
+
+    /**
+     * 获取系统管理员选项列表(用于前端操作人下拉框)
+     *
+     * @return 管理员选项列表(含 id、label、username)
+     */
+    @GetMapping("/user/options")
+    @Log(value = "获取管理员选项", module = "日志管理", operationType = OperationType.QUERY)
+    public Result<List<Map<String, Object>>> getSysUserOptions() {
+        return Result.success(sysLogService.getSysUserOptions());
+    }
+}

+ 48 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/UserAcupointController.java

@@ -0,0 +1,48 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.entity.UserAcupoint;
+import com.aijiuyi.admin.service.UserAcupointService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 用户穴位坐标 Controller(管理员后台)
+ * 提供手动触发生成、查询穴位坐标接口
+ */
+@RestController
+@RequestMapping("/user/acupoint")
+public class UserAcupointController {
+
+    @Autowired
+    private UserAcupointService userAcupointService;
+
+    /**
+     * 手动为指定子用户生成(或重新生成)穴位坐标表
+     *
+     * @param profileId 子用户ID(user_profile.id)
+     * @return 操作结果
+     */
+    @PostMapping("/{profileId}/generate")
+    @Log(value = "生成用户穴位坐标", module = "用户穴位管理", operationType = OperationType.INSERT)
+    public Result<Void> generate(@PathVariable Long profileId) {
+        userAcupointService.generateForUser(profileId);
+        return Result.success();
+    }
+
+    /**
+     * 查询指定子用户的穴位坐标列表
+     *
+     * @param profileId 子用户ID
+     * @return 穴位坐标列表
+     */
+    @GetMapping("/{profileId}/list")
+    @Log(value = "查询用户穴位坐标列表", module = "用户穴位管理", operationType = OperationType.QUERY)
+    public Result<List<UserAcupoint>> list(@PathVariable Long profileId) {
+        return Result.success(userAcupointService.listByUser(profileId));
+    }
+}

+ 115 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/UserDeviceController.java

@@ -0,0 +1,115 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.UserDeviceQueryDTO;
+import com.aijiuyi.admin.entity.UserDevice;
+import com.aijiuyi.admin.service.UserDeviceService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 用户设备管理 Controller(管理员后台)
+ * 提供设备绑定记录的查询、新增、解绑、设为主设备、批量解绑接口
+ */
+@RestController
+@RequestMapping("/user/device")
+public class UserDeviceController {
+
+    @Autowired
+    private UserDeviceService userDeviceService;
+
+    /**
+     * 分页查询设备列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询设备列表", module = "设备管理", operationType = OperationType.QUERY)
+    public Result<IPage<UserDevice>> page(UserDeviceQueryDTO queryDTO) {
+        return Result.success(userDeviceService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询设备详情
+     *
+     * @param id 设备记录ID
+     * @return 设备详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询设备详情", module = "设备管理", operationType = OperationType.QUERY)
+    public Result<UserDevice> getById(@PathVariable Long id) {
+        return Result.success(userDeviceService.getById(id));
+    }
+
+    /**
+     * 绑定设备(新增绑定记录)
+     *
+     * @param device 设备信息(userId为子用户ID,deviceCode为设备编号)
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "绑定设备", module = "设备管理", operationType = OperationType.INSERT)
+    public Result<Void> bind(@RequestBody UserDevice device) {
+        userDeviceService.bindDevice(device);
+        return Result.success();
+    }
+
+    /**
+     * 解绑设备(设置状态为已解绑)
+     *
+     * @param id 设备记录ID
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/unbind")
+    @Log(value = "解绑设备", module = "设备管理", operationType = OperationType.UPDATE)
+    public Result<Void> unbind(@PathVariable Long id) {
+        userDeviceService.unbindDevice(id);
+        return Result.success();
+    }
+
+    /**
+     * 设置主设备(同一用户下只能有一个主设备)
+     *
+     * @param id     设备记录ID
+     * @param userId 所属子用户ID
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/primary")
+    @Log(value = "设置主设备", module = "设备管理", operationType = OperationType.UPDATE)
+    public Result<Void> setPrimary(@PathVariable Long id, @RequestParam Long userId) {
+        userDeviceService.setPrimaryDevice(id, userId);
+        return Result.success();
+    }
+
+    /**
+     * 批量解绑设备
+     *
+     * @param ids 设备记录ID列表
+     * @return 操作结果
+     */
+    @PutMapping("/batch-unbind")
+    @Log(value = "批量解绑设备", module = "设备管理", operationType = OperationType.UPDATE)
+    public Result<Void> batchUnbind(@RequestBody List<Long> ids) {
+        userDeviceService.batchUnbindDevices(ids);
+        return Result.success();
+    }
+
+    /**
+     * 删除设备记录
+     *
+     * @param id 设备记录ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除设备记录", module = "设备管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        userDeviceService.deleteDevice(id);
+        return Result.success();
+    }
+}

+ 51 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/UserPlanController.java

@@ -0,0 +1,51 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.entity.UserPlan;
+import com.aijiuyi.admin.service.UserPlanService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 用户方案 Controller
+ * 提供查询指定子用户的方案列表及详情接口
+ */
+@RestController
+@RequestMapping("/user-plan")
+public class UserPlanController {
+
+    @Autowired
+    private UserPlanService userPlanService;
+
+    /**
+     * 分页查询指定子用户的方案列表
+     *
+     * @param userId   子用户ID
+     * @param pageNum  当前页码(默认1)
+     * @param pageSize 每页条数(默认10)
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询用户方案列表", module = "用户方案管理", operationType = OperationType.QUERY)
+    public Result<IPage<UserPlan>> page(
+            @RequestParam Long userId,
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize) {
+        return Result.success(userPlanService.pageByUserId(userId, pageNum, pageSize));
+    }
+
+    /**
+     * 查询用户方案详情
+     *
+     * @param id 用户方案ID
+     * @return 用户方案详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询用户方案详情", module = "用户方案管理", operationType = OperationType.QUERY)
+    public Result<UserPlan> getById(@PathVariable Long id) {
+        return Result.success(userPlanService.getDetail(id));
+    }
+}

+ 140 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/UserProfileController.java

@@ -0,0 +1,140 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.UserProfileDTO;
+import com.aijiuyi.admin.controller.dto.UserProfileQueryDTO;
+import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.service.UserProfileService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * 用户管理 Controller(管理员后台)
+ * 提供用户档案的增删改查、批量删除、批量导入接口
+ */
+@RestController
+@RequestMapping("/user/profile")
+public class UserProfileController {
+
+    @Autowired
+    private UserProfileService userProfileService;
+
+    /**
+     * 分页查询用户列表(联表获取设备编号)
+     *
+     * @param queryDTO 查询条件(关键字/设备编号/性别/注册时间范围/穴位表状态)
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询用户列表", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<IPage<UserProfile>> page(UserProfileQueryDTO queryDTO) {
+        return Result.success(userProfileService.pageList(queryDTO));
+    }
+
+
+
+    /**
+     * 查询用户详情
+     *
+     * @param id 用户ID
+     * @return 用户详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询用户详情", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<UserProfile> getById(@PathVariable Long id) {
+        return Result.success(userProfileService.getById(id));
+    }
+
+    /**
+     * 新增用户
+     *
+     * @param dto 用户信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增用户", module = "用户管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody @Valid UserProfileDTO dto) {
+        userProfileService.addProfile(dto);
+        return Result.success();
+    }
+
+    /**
+     * 修改用户
+     *
+     * @param dto 用户信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "修改用户", module = "用户管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody @Valid UserProfileDTO dto) {
+        userProfileService.updateProfile(dto);
+        return Result.success();
+    }
+
+    /**
+     * 删除用户
+     *
+     * @param id 用户ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除用户", module = "用户管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        userProfileService.deleteProfile(id);
+        return Result.success();
+    }
+
+    /**
+     * 批量删除用户
+     *
+     * @param ids 用户ID列表
+     * @return 操作结果
+     */
+    @DeleteMapping("/batch")
+    @Log(value = "批量删除用户", module = "用户管理", operationType = OperationType.DELETE)
+    public Result<Void> batchDelete(@RequestBody List<Long> ids) {
+        userProfileService.batchDelete(ids);
+        return Result.success();
+    }
+
+    /**
+     * 批量导入用户
+     *
+     * @param list 用户信息列表
+     * @return 操作结果
+     */
+    @PostMapping("/import")
+    @Log(value = "批量导入用户", module = "用户管理", operationType = OperationType.INSERT)
+    public Result<Void> batchImport(@RequestBody List<UserProfileDTO> list) {
+        userProfileService.batchImport(list);
+        return Result.success();
+    }
+
+    /**
+     * 查询用户档案选项列表(用于设备绑定用户下拉选择)
+     *
+     * @param keyword 搜索关键字(姓名或手机号,选填)
+     * @return 用户列表(id/name/phone/gender)
+     */
+    @GetMapping("/options")
+    @Log(value = "查询用户选项列表", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<List<UserProfile>> options(@RequestParam(required = false) String keyword) {
+        return Result.success(userProfileService.listOptions(keyword));
+    }
+
+    /**
+     * 获取所有用户列表(用于下拉选择)
+     */
+    @GetMapping("/list-all")
+    @Log(value = "获取所有用户列表", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<List<UserProfile>> listAll() {
+        return Result.success(userProfileService.list());
+    }
+}
+

+ 28 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AcupointQueryDTO.java

@@ -0,0 +1,28 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 穴位查询条件 DTO
+ */
+@Data
+public class AcupointQueryDTO {
+
+    /** 穴位名称(模糊搜索) */
+    private String name;
+
+    /** 所属经络 */
+    private String meridian;
+
+    /** 定位方式:当前固定为相对定位,保留字段兼容旧查询参数 */
+    private Integer locationType;
+
+    /** 状态:1=活跃,0=待完善 */
+    private Integer status;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 28 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AdminQueryDTO.java

@@ -0,0 +1,28 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 管理员分页查询参数 DTO
+ */
+@Data
+public class AdminQueryDTO {
+
+    /** 用户名(模糊查询) */
+    private String username;
+
+    /** 姓名/昵称(模糊查询) */
+    private String nickname;
+
+    /** 管理员角色:1=超级管理员,2=运营人员,3=只读用户 */
+    private Integer adminRole;
+
+    /** 状态:1=启用,0=禁用 */
+    private Integer status;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 28 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppUserQueryDTO.java

@@ -0,0 +1,28 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 应用用户查询请求 DTO
+ */
+@Data
+public class AppUserQueryDTO {
+
+    /** 手机号(模糊搜索) */
+    private String phone;
+
+    /** 昵称(模糊搜索) */
+    private String nickname;
+
+    /** 用户类型:1=普通用户,2=专家,3=管理员 */
+    private Integer userType;
+
+    /** 状态:1=正常,0=禁用 */
+    private Integer status;
+
+    /** 当前页码(从1开始) */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 设备管理列表查询请求 DTO
+ */
+@Data
+public class DeviceQueryDTO {
+
+    /** 设备编号(模糊搜索) */
+    private String deviceCode;
+
+    /** 设备名称(模糊搜索) */
+    private String deviceName;
+
+    /** 绑定用户姓名(模糊搜索,匹配该设备下任意绑定用户) */
+    private String boundUserName;
+
+    /** 在线状态:1=在线,0=离线,null=全部 */
+    private Integer onlineStatus;
+
+    /** 固件版本(精确匹配,null=全部) */
+    private String firmwareVersion;
+
+    /** 当前页码(从1开始) */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

+ 39 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java

@@ -0,0 +1,39 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 设备新增/编辑请求 DTO
+ */
+@Data
+public class DeviceSaveDTO {
+
+    /** 主键ID(新增时为 null,编辑时必填) */
+    private Long id;
+
+    /** 设备编号(唯一,新增必填,编辑时不可修改) */
+    private String deviceCode;
+
+    /** 设备名称(必填,最长100字符) */
+    private String deviceName;
+
+    /** 设备型号(必填,AJY-2026 / AJY-2025) */
+    private String deviceModel;
+
+    /** 设备序列号(唯一,必填,最长50字符) */
+    private String serialNo;
+
+    /** 固件版本(必填,如:v1.2.0) */
+    private String firmwareVersion;
+
+    /** 备注(选填,最长200字符) */
+    private String remark;
+
+    /**
+     * 绑定用户ID列表(user_profile.id,选填)
+     * 新增:绑定初始用户;编辑:与当前绑定用户做差集合并(null 表示不变更绑定关系,空列表表示清空所有绑定)
+     */
+    private List<Long> userIds;
+}

+ 20 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/LoginDTO.java

@@ -0,0 +1,20 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 登录请求参数 DTO
+ */
+@Data
+public class LoginDTO {
+
+    /** 用户名(不能为空) */
+    @NotBlank(message = "用户名不能为空")
+    private String username;
+
+    /** 密码(不能为空,前端传明文,后端做 MD5 加密后比对) */
+    @NotBlank(message = "密码不能为空")
+    private String password;
+}

+ 25 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/MoxibustionTechniqueQueryDTO.java

@@ -0,0 +1,25 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 艾灸手法查询条件 DTO
+ */
+@Data
+public class MoxibustionTechniqueQueryDTO {
+
+    /** 手法名称(模糊搜索) */
+    private String name;
+
+    /** 运动模式(精确匹配) */
+    private String motionMode;
+
+    /** 状态:1=启用,0=禁用 */
+    private Integer status;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 37 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanQueryDTO.java

@@ -0,0 +1,37 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 方案列表查询条件 DTO
+ */
+@Data
+public class PlanQueryDTO {
+
+    /** 方案名称(模糊搜索) */
+    private String name;
+
+    /** 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 */
+    private Integer modeType;
+
+    /** 功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 */
+    private String effectType;
+
+    /** 创作人名称(模糊搜索) */
+    private String authorName;
+
+    /** 状态:0=草稿,1=已发布,2=已下架 */
+    private Integer status;
+
+    /** 创建时间开始 */
+    private String createTimeStart;
+
+    /** 创建时间结束 */
+    private String createTimeEnd;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 58 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanSaveDTO.java

@@ -0,0 +1,58 @@
+package com.aijiuyi.admin.controller.dto;
+
+import com.aijiuyi.admin.entity.PlanStep;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 方案新增/编辑 DTO(含步骤列表)
+ */
+@Data
+public class PlanSaveDTO {
+
+    /** 方案ID(编辑时传递,新增时为空) */
+    private Long id;
+
+    /** 方案编码(新增时由系统生成,编辑时不可修改) */
+    private String planCode;
+
+    /** 方案名称(最长100字符) */
+    private String name;
+
+    /** 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 */
+    private Integer modeType;
+
+    /** 功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 */
+    private String effectType;
+
+    /** 适用症状(逗号分隔) */
+    private String symptoms;
+
+    /** 适用人群:0=不限,1=仅男,2=仅女 */
+    private Integer applicableGender;
+
+    /** 适用年龄最小值(岁) */
+    private Integer ageMin;
+
+    /** 适用年龄最大值(岁) */
+    private Integer ageMax;
+
+    /** 方案描述(最长200字符) */
+    private String description;
+
+    /** 创作人类型:1=管理员,2=普通用户,3=专家 */
+    private Integer authorType;
+
+    /** 创作人ID */
+    private Long authorId;
+
+    /** 创作人名称 */
+    private String authorName;
+
+    /** 状态:0=草稿,1=已发布,2=已下架(新增时默认草稿) */
+    private Integer status;
+
+    /** 治疗步骤列表 */
+    private List<PlanStep> steps;
+}

+ 63 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/SimulationResponse.java

@@ -0,0 +1,63 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 方案模拟测试响应 DTO
+ * 组装用户体型数据 + 方案步骤 + 穴位坐标,用于前端可视化展示
+ */
+@Data
+public class SimulationResponse {
+
+    private Long userId;
+    private String userName;
+    private Integer modeType;
+    private BodyData bodyData;
+    private SchemeData scheme;
+
+    @Data
+    public static class BodyData {
+        private Integer gender;
+        private Integer age;
+        private BigDecimal shoulderWidth;
+        private BigDecimal bodyHeight;
+        private BigDecimal spineLength;
+        private BigDecimal height;
+        private BigDecimal weight;
+        private FingerWidth fingerWidth;
+    }
+
+    @Data
+    public static class FingerWidth {
+        private BigDecimal cun1;
+        private BigDecimal cun1_5;
+        private BigDecimal cun3;
+    }
+
+    @Data
+    public static class SchemeData {
+        private Long planId;
+        private String planCode;
+        private String planName;
+        private String effectType;
+        private List<StepData> steps;
+    }
+
+    @Data
+    public static class StepData {
+        private Integer stepOrder;
+        private Long acupointId;
+        private String acupointName;
+        private String acupointSide;
+        private BigDecimal offsetX;
+        private BigDecimal offsetY;
+        private BigDecimal temperature;
+        private Integer duration;
+        private Long techniqueId;
+        private String techniqueName;
+        private String confidence;
+    }
+}

+ 32 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/SysLogQueryDTO.java

@@ -0,0 +1,32 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 日志查询请求参数 DTO
+ * 用于 /log/page 和 /log/export 接口的查询条件封装
+ */
+@Data
+public class SysLogQueryDTO {
+
+    /** 操作人ID(对应 sys_log.user_id) */
+    private Long userId;
+
+    /** 操作类型(查询/新增/更新/删除/其他) */
+    private String operationType;
+
+    /** 操作模块(用户管理/穴位管理/方案管理/设备管理等) */
+    private String operationModule;
+
+    /** 开始时间(格式:yyyy-MM-dd HH:mm:ss) */
+    private String startTime;
+
+    /** 结束时间(格式:yyyy-MM-dd HH:mm:ss) */
+    private String endTime;
+
+    /** 当前页(默认第1页) */
+    private Integer pageNum = 1;
+
+    /** 每页条数(默认10条) */
+    private Integer pageSize = 10;
+}

+ 28 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserDeviceQueryDTO.java

@@ -0,0 +1,28 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 用户设备查询请求 DTO
+ */
+@Data
+public class UserDeviceQueryDTO {
+
+    /** 所属应用用户ID */
+    private Long userId;
+
+    /** 设备编码(模糊搜索) */
+    private String deviceCode;
+
+    /** 设备名称(模糊搜索) */
+    private String deviceName;
+
+    /** 绑定状态:1=已绑定,0=已解绑 */
+    private Integer status;
+
+    /** 当前页码(从1开始) */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

+ 19 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserPlanQueryDTO.java

@@ -0,0 +1,19 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 用户方案查询请求 DTO
+ */
+@Data
+public class UserPlanQueryDTO {
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 当前页码(从1开始) */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

+ 68 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java

@@ -0,0 +1,68 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+import java.math.BigDecimal;
+
+/**
+ * 子用户新增/修改请求 DTO
+ */
+@Data
+public class UserProfileDTO {
+
+    /** 子用户ID(修改时必填,新增时不填) */
+    private Long id;
+
+    /** 所属应用用户ID(新增时必填) */
+    private Long userId;
+
+    /** 姓名(必填) */
+    @NotBlank(message = "姓名不能为空")
+    private String name;
+
+    /** 性别:1=男,2=女 */
+    @NotNull(message = "性别不能为空")
+    private Integer gender;
+
+    /** 年龄(必填,1-120) */
+    @NotNull(message = "年龄不能为空")
+    private Integer age;
+
+    /** 手机号(必填,11位格式) */
+    @NotBlank(message = "手机号不能为空")
+    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
+    private String phone;
+
+    /** 肩宽(cm,必填) */
+    @NotNull(message = "肩宽不能为空")
+    private BigDecimal shoulderWidth;
+
+    /** 身长(cm,必填) */
+    @NotNull(message = "身长不能为空")
+    private BigDecimal bodyHeight;
+
+    /** 脊柱长度C7-S4(cm,必填,30-60),用于按医学定位术语计算Y轴坐标 */
+    @NotNull(message = "脊柱长度不能为空")
+    private BigDecimal spineLength;
+
+    /** 身高(cm,选填,用于BMI推算指寸) */
+    private BigDecimal height;
+
+    /** 体重(kg,选填,用于BMI推算指寸) */
+    private BigDecimal weight;
+
+    /** 1寸指宽(cm,选填) */
+    private BigDecimal fingerWidth1;
+
+    /** 1.5寸指宽(cm,选填) */
+    private BigDecimal fingerWidth15;
+
+    /** 3寸指宽(cm,选填) */
+    private BigDecimal fingerWidth3;
+
+    /** 用户穴位表ID */
+    private Long acupointTableId;
+}

+ 34 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileQueryDTO.java

@@ -0,0 +1,34 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 子用户列表查询条件 DTO
+ */
+@Data
+public class UserProfileQueryDTO {
+
+    /** 姓名或手机号(模糊搜索) */
+    private String keyword;
+
+    /** 设备编号(模糊搜索) */
+    private String deviceCode;
+
+    /** 性别:1=男,2=女,不传则查全部 */
+    private Integer gender;
+
+    /** 注册时间开始(格式:yyyy-MM-dd HH:mm:ss) */
+    private String startTime;
+
+    /** 注册时间结束(格式:yyyy-MM-dd HH:mm:ss) */
+    private String endTime;
+
+    /** 穴位表状态:1=已生成,0=未生成,不传则查全部 */
+    private Integer acupointStatus;
+
+    /** 当前页码,默认第1页 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10条 */
+    private Integer pageSize = 10;
+}

+ 27 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/YReferenceOptionDTO.java

@@ -0,0 +1,27 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 穴位Y轴医学定位术语选项
+ */
+@Data
+public class YReferenceOptionDTO {
+
+    /** 下拉值 */
+    private String value;
+
+    /** 下拉展示文案 */
+    private String label;
+
+    /** 归一化Y系数(基于C7-S4脊柱长度),按指寸偏移计算的术语为空 */
+    private BigDecimal normalizedY;
+
+    /** 指寸偏移值(向下为正,向上为负),按脊柱长度计算的术语为空 */
+    private BigDecimal cunOffset;
+
+    /** 计算说明 */
+    private String description;
+}

+ 126 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/Acupoint.java

@@ -0,0 +1,126 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 穴位信息实体
+ * 对应数据库 acupoint 表
+ */
+@Data
+@TableName("acupoint")
+public class Acupoint {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 穴位编码(唯一标识,如 dazhui) */
+    private String acupointCode;
+
+    /** Excel 原始序号 */
+    private String excelSerialNo;
+
+    /** 穴位名称(如:大椎穴) */
+    private String name;
+
+    /** 单/双穴(来自 Excel 原始字段) */
+    private String singleDouble;
+
+    /** 所属经络(如:督脉、足太阳膀胱经) */
+    private String meridian;
+
+    /** 身体部位(来自 Excel) */
+    private String bodyPart;
+
+    /** 部位分区(来自 Excel) */
+    private String bodyRegion;
+
+    /** 穴位定位原文(来自 Excel) */
+    private String locationDescription;
+
+    /** 相对位置原文(来自 Excel) */
+    private String relativePosition;
+
+    /** 主管疾病/主治(来自 Excel) */
+    private String indications;
+
+    /** 产品症状覆盖(来自 Excel) */
+    private String productSymptoms;
+
+    /** 取穴说明(来自 Excel) */
+    private String acupointMethod;
+
+    /** 是否为艾灸椅产品穴位:1=是,0=否 */
+    private Integer productAcupoint;
+
+    /**
+     * 功效描述(逗号分隔,如:驱寒,祛湿)
+     * 可选值:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳
+     */
+    private String effects;
+
+    /** 定位方式:当前统一为相对定位(1) */
+    private Integer locationType;
+
+    /** 相对大椎穴纵向偏移(寸) */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private BigDecimal longitudeOffset;
+
+    /** 相对大椎穴横向偏移(寸,双侧穴位填写) */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private BigDecimal lateralOffset;
+
+    /** Panjabi归一化Y位置系数(0~1,基于C7-S4脊柱长度) */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private BigDecimal normalizedY;
+
+    /** Y轴医学定位术语(如:第3胸椎棘突下、正对第1骶后孔中) */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private String yReferenceTerm;
+
+    /** 定位描述自动解析状态:parsed=已解析,review_required=待复核,unsupported=暂不支持 */
+    private String parseStatus;
+
+    /** 定位描述自动解析置信度:high=高,medium=中,low=低 */
+    private String parseConfidence;
+
+    /** 定位描述自动解析说明 */
+    private String parseMessage;
+
+    /**
+     * 侧别:1=中心,2=左侧,3=右侧,4=双侧
+     */
+    private Integer side;
+
+    /**
+     * 指寸类型:1=一寸,2=1.5寸,3=三寸
+     */
+    @TableField(updateStrategy = FieldStrategy.IGNORED)
+    private Integer fingerWidthType;
+
+    /**
+     * 适用性别:0=不限,1=仅男,2=仅女
+     */
+    private Integer gender;
+
+    /**
+     * 状态:1=活跃,0=待完善
+     */
+    private Integer status;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 41 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/AdminPermission.java

@@ -0,0 +1,41 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 管理员权限配置实体
+ * 存储每个管理员可访问的前端路由路径列表(JSON格式)
+ * 超级管理员(adminRole=1)不受此表限制,默认拥有全部权限
+ */
+@Data
+@TableName("admin_permission")
+public class AdminPermission {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 管理员ID(关联 app_user.id) */
+    private Long adminId;
+
+    /**
+     * 可访问的路由路径(JSON数组字符串,如 ["/dashboard","/device","/plan"])
+     * 超级管理员此字段为 null 或不使用
+     */
+    private String routePaths;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 65 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/AppUser.java

@@ -0,0 +1,65 @@
+package com.aijiuyi.admin.entity;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 应用用户实体(统一用户表,通过 userType 区分角色)
+ * 对应数据库 app_user 表
+ * userType: 1=普通用户,2=专家,3=管理员
+ */
+@Data
+@TableName("app_user")
+public class AppUser {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户名(唯一,用于后台管理员登录) */
+    private String username;
+
+    /** 密码(MD5加密存储,后台账号使用;不序列化到响应) */
+    @JSONField(serialize = false)
+    private String password;
+
+    /** 手机号(唯一,用于App端登录) */
+    private String phone;
+
+    /** 昵称 */
+    private String nickname;
+
+    /** 头像URL */
+    private String avatar;
+
+    /**
+     * 用户类型(角色):1=普通用户,2=专家,3=管理员
+     */
+    private Integer userType;
+
+    /**
+     * 管理员角色:1=超级管理员,2=运营人员,3=只读用户(仅 userType=3 时有效)
+     */
+    private Integer adminRole;
+
+    /** 状态:1=正常,0=禁用 */
+    private Integer status;
+
+    /** 最后登录时间 */
+    private LocalDateTime lastLoginTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 70 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java

@@ -0,0 +1,70 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 艾灸椅设备实体
+ * 对应数据库 device 表
+ */
+@Data
+@TableName("device")
+public class Device {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 设备编号(唯一,如:AJY001) */
+    private String deviceCode;
+
+    /** 设备名称(如:艾灸椅-001) */
+    private String deviceName;
+
+    /** 设备型号(AJY-2026 / AJY-2025) */
+    private String deviceModel;
+
+    /** 设备序列号(唯一) */
+    private String serialNo;
+
+    /** 固件版本(如:v1.2.0) */
+    private String firmwareVersion;
+
+    /** 在线状态:1=在线,0=离线 */
+    private Integer onlineStatus;
+
+    /** 最后在线时间 */
+    private LocalDateTime lastOnlineTime;
+
+    /** 备注 */
+    private String remark;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+
+    // ======================== 非数据库字段(联表查询填充)========================
+
+    /** 绑定用户数量(联表聚合) */
+    @TableField(exist = false)
+    private Integer boundUserCount;
+
+    /** 绑定用户姓名列表(联表聚合,顿号分隔) */
+    @TableField(exist = false)
+    private String boundUserNames;
+
+    /** 绑定的用户ID列表(新增/编辑表单使用) */
+    @TableField(exist = false)
+    private List<Long> userIds;
+}

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceUserVO.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 设备绑定用户视图对象
+ * 用于设备用户管理页面,联表查询 user_device + user_profile 的结果
+ */
+@Data
+public class DeviceUserVO {
+
+    /** 绑定记录ID(user_device.id) */
+    private Long bindId;
+
+    /** 用户档案ID(user_profile.id) */
+    private Long userId;
+
+    /** 用户姓名 */
+    private String userName;
+
+    /** 用户手机号 */
+    private String phone;
+
+    /** 绑定时间 */
+    private LocalDateTime bindTime;
+
+    /** 是否主用户:1=是,0=否 */
+    private Integer isPrimary;
+}

+ 90 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/MoxibustionTechnique.java

@@ -0,0 +1,90 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 艾灸手法实体
+ * 对应数据库 moxibustion_technique 表
+ */
+@Data
+@TableName("moxibustion_technique")
+public class MoxibustionTechnique {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 手法编码(唯一标识,如 oneclick)
+     */
+    private String techniqueCode;
+
+    /**
+     * 手法名称(如:一键艾灸)
+     */
+    private String name;
+
+    /**
+     * 运动模式:
+     * default=默认工艺,fixed=固定不动,updown=上下移动,circular=圆周运动,dotted=点状移动
+     */
+    private String motionMode;
+
+    /**
+     * 温度范围最小值(°C,35-50)
+     */
+    private BigDecimal tempMin;
+
+    /**
+     * 温度范围最大值(°C,35-50)
+     */
+    private BigDecimal tempMax;
+
+    /**
+     * 时间范围最小值(分钟,1-30)
+     */
+    private Integer timeMin;
+
+    /**
+     * 时间范围最大值(分钟,1-30)
+     */
+    private Integer timeMax;
+
+    /**
+     * 运动幅度(mm,1-20)
+     * 运动模式非"固定不动"时必填
+     */
+    private BigDecimal motionAmplitude;
+
+    /**
+     * 运动速度(mm/s,1-10)
+     * 运动模式非"固定不动"时必填
+     */
+    private BigDecimal motionSpeed;
+
+    /**
+     * 状态:1=启用,0=禁用
+     */
+    private Integer status;
+
+    /**
+     * 备注(最长200字符)
+     */
+    private String remark;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 90 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/Plan.java

@@ -0,0 +1,90 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 方案实体
+ * 对应数据库 plan 表
+ */
+@Data
+@TableName("plan")
+public class Plan {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 方案编码(唯一标识,如 S001) */
+    private String planCode;
+
+    /** 方案名称(最长100字符) */
+    private String name;
+
+    /**
+     * 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手
+     */
+    private Integer modeType;
+
+    /**
+     * 功效类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳
+     */
+    private String effectType;
+
+    /** 适用症状(逗号分隔:怕冷,感冒,咳嗽,...) */
+    private String symptoms;
+
+    /**
+     * 适用人群:0=不限,1=仅男,2=仅女
+     */
+    private Integer applicableGender;
+
+    /** 适用年龄最小值(岁) */
+    private Integer ageMin;
+
+    /** 适用年龄最大值(岁) */
+    private Integer ageMax;
+
+    /** 方案描述(最长200字符) */
+    private String description;
+
+    /**
+     * 创作人类型:1=管理员,2=普通用户,3=专家
+     */
+    private Integer authorType;
+
+    /** 创作人ID(对应 app_user.id) */
+    private Long authorId;
+
+    /** 创作人名称(冗余存储,加速查询) */
+    private String authorName;
+
+    /**
+     * 状态:0=草稿,1=已发布,2=已下架
+     */
+    private Integer status;
+
+    /** 使用人数 */
+    private Integer useCount;
+
+    /** 平均评分 */
+    private BigDecimal avgRating;
+
+    /** 最后使用时间 */
+    private LocalDateTime lastUseTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 64 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/PlanStep.java

@@ -0,0 +1,64 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 方案步骤实体
+ * 对应数据库 plan_step 表
+ */
+@Data
+@TableName("plan_step")
+public class PlanStep {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 方案ID(关联 plan.id) */
+    private Long planId;
+
+    /** 步骤序号(从1开始) */
+    private Integer stepOrder;
+
+    /** 穴位ID(关联 acupoint.id) */
+    private Long acupointId;
+
+    /** 穴位名称(冗余存储) */
+    private String acupointName;
+
+    /**
+     * 侧别:1=中心,2=左侧,3=右侧,4=双侧
+     */
+    private Integer side;
+
+    /** 温度(°C,35-50) */
+    private BigDecimal temperature;
+
+    /** 时间(分钟,1-30) */
+    private Integer duration;
+
+    /** 艾灸手法ID(关联 moxibustion_technique.id) */
+    private Long techniqueId;
+
+    /** 艾灸手法名称(冗余存储) */
+    private String techniqueName;
+
+    /** 备注(最长100字符) */
+    private String remark;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 52 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/SysUser.java

@@ -0,0 +1,52 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 系统用户实体
+ * 对应数据库 sys_user 表
+ */
+@Data
+@TableName("sys_user")
+public class SysUser {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户名(唯一,用于登录) */
+    private String username;
+
+    /** 密码(MD5加密存储,不允许返回给前端) */
+    private String password;
+
+    /** 昵称(显示名称) */
+    private String nickname;
+
+    /** 邮箱 */
+    private String email;
+
+    /** 手机号 */
+    private String phone;
+
+    /** 头像URL */
+    private String avatar;
+
+    /** 状态:1=正常,0=禁用 */
+    private Integer status;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 62 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserAcupoint.java

@@ -0,0 +1,62 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 用户穴位坐标实体
+ * 对应数据库 user_acupoint 表
+ * 根据用户体型数据(指宽/肩宽/身长)与穴位定位参数计算生成
+ */
+@Data
+@TableName("user_acupoint")
+public class UserAcupoint {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 子用户ID(关联 user_profile.id) */
+    private Long userId;
+
+    /** 穴位ID(关联 acupoint.id) */
+    private Long acupointId;
+
+    /** 穴位名称(冗余存储,加速查询) */
+    private String acupointName;
+
+    /**
+     * 侧别:center=中心,left=左侧,right=右侧
+     * 双侧穴位(acupoint.side=4)会生成两条记录(left+right)
+     */
+    private String acupointSide;
+
+    /** 相对大椎穴纵向偏移(寸) */
+    private BigDecimal cunValue;
+
+    /** 指寸类型:1寸/1.5寸/3寸 */
+    private String cunType;
+
+    /** 计算后的X坐标(mm,相对大椎穴中心,向右为正) */
+    private BigDecimal coordinateX;
+
+    /** 计算后的Y坐标(mm,相对大椎穴中心,向下为正) */
+    private BigDecimal coordinateY;
+
+    /** 计算后的Z坐标(mm,体表=0) */
+    private BigDecimal coordinateZ;
+
+    /** 置信度:high=高/medium=中/low=低 */
+    private String confidence;
+
+    /** 首次生成时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 最后更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 55 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserDevice.java

@@ -0,0 +1,55 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户设备绑定实体
+ * 对应数据库 user_device 表
+ */
+@Data
+@TableName("user_device")
+public class UserDevice {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 所属子用户ID(关联 user_profile.id) */
+    private Long userId;
+
+    /** 设备唯一编码 */
+    private String deviceCode;
+
+    /** 设备名称 */
+    private String deviceName;
+
+    /** 设备型号 */
+    private String deviceModel;
+
+    /** 是否主设备:1=是,0=否 */
+    private Integer isPrimary;
+
+    /** 在线状态:1=在线,0=离线 */
+    private Integer onlineStatus;
+
+    /** 绑定状态:1=已绑定,0=已解绑 */
+    private Integer status;
+
+    /** 绑定时间 */
+    private LocalDateTime bindTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 61 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserPlan.java

@@ -0,0 +1,61 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户方案实体
+ * 对应数据库 user_plan 表
+ * 记录用户个性化工艺参数方案
+ */
+@Data
+@TableName("user_plan")
+public class UserPlan {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 子用户ID(关联 user_profile.id) */
+    private Long userId;
+
+    /** 方案ID(关联 plan.id) */
+    private Long planId;
+
+    /** 方案编码(冗余存储) */
+    private String planCode;
+
+    /** 方案名称(冗余存储) */
+    private String planName;
+
+    /**
+     * 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手
+     */
+    private Integer modeType;
+
+    /** 创作人名称(冗余存储) */
+    private String authorName;
+
+    /** 步骤数量 */
+    private Integer stepCount;
+
+    /** 使用次数 */
+    private Integer useCount;
+
+    /** 个性化工艺步骤(JSON格式,含坐标/温度/时间等) */
+    private String stepsJson;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 订阅/生成时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 81 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/UserProfile.java

@@ -0,0 +1,81 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 子用户信息实体
+ * 对应数据库 user_profile 表
+ */
+@Data
+@TableName("user_profile")
+public class UserProfile {
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 所属应用用户ID(关联 app_user.id) */
+    private Long userId;
+
+    /** 姓名 */
+    private String name;
+
+    /** 性别:1=男,2=女 */
+    private Integer gender;
+
+    /** 年龄 */
+    private Integer age;
+
+    /** 手机号(11位,唯一) */
+    private String phone;
+
+    /** 肩宽(cm) */
+    private BigDecimal shoulderWidth;
+
+    /** 身长(cm)(坐高) */
+    private BigDecimal bodyHeight;
+
+    /** 脊柱长度C7-S4(cm,30-60) */
+    private BigDecimal spineLength;
+
+    /** 身高(cm) */
+    private BigDecimal height;
+
+    /** 体重(kg) */
+    private BigDecimal weight;
+
+    /** 1寸指宽(cm,选填,提高穴位精度) */
+    @TableField("finger_width_1")
+    private BigDecimal fingerWidth1;
+
+    /** 1.5寸指宽(cm,选填) */
+    @TableField("finger_width_15")
+    private BigDecimal fingerWidth15;
+
+    /** 3寸指宽(cm,选填) */
+    @TableField("finger_width_3")
+    private BigDecimal fingerWidth3;
+
+    /** 用户穴位表ID */
+    private Long acupointTableId;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+
+    /** 绑定设备编号(逗号分隔,非数据库字段,查询时JOIN填充) */
+    @TableField(exist = false)
+    private String deviceCodes;
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/AcupointMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.Acupoint;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 穴位信息 Mapper 接口
+ */
+@Mapper
+public interface AcupointMapper extends BaseMapper<Acupoint> {
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/AdminPermissionMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AdminPermission;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 管理员权限配置 Mapper
+ */
+@Mapper
+public interface AdminPermissionMapper extends BaseMapper<AdminPermission> {
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/AppUserMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AppUser;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 应用用户 Mapper
+ */
+@Mapper
+public interface AppUserMapper extends BaseMapper<AppUser> {
+}

+ 36 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceMapper.java

@@ -0,0 +1,36 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.controller.dto.DeviceQueryDTO;
+import com.aijiuyi.admin.entity.Device;
+import com.aijiuyi.admin.entity.DeviceUserVO;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 设备管理 Mapper
+ */
+@Mapper
+public interface DeviceMapper extends BaseMapper<Device> {
+
+    /**
+     * 分页查询设备列表(联表聚合绑定用户数和用户姓名)
+     *
+     * @param page     分页参数
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<Device> pageWithUsers(Page<Device> page, @Param("q") DeviceQueryDTO queryDTO);
+
+    /**
+     * 根据设备编号查询绑定的用户列表
+     *
+     * @param deviceCode 设备编号
+     * @return 绑定用户列表
+     */
+    List<DeviceUserVO> getUsersByDeviceCode(@Param("deviceCode") String deviceCode);
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/MoxibustionTechniqueMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.MoxibustionTechnique;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 艾灸手法 Mapper 接口
+ */
+@Mapper
+public interface MoxibustionTechniqueMapper extends BaseMapper<MoxibustionTechnique> {
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/PlanMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.Plan;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 方案 Mapper 接口
+ */
+@Mapper
+public interface PlanMapper extends BaseMapper<Plan> {
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/PlanStepMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.PlanStep;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 方案步骤 Mapper 接口
+ */
+@Mapper
+public interface PlanStepMapper extends BaseMapper<PlanStep> {
+}

+ 36 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/SysLogMapper.java

@@ -0,0 +1,36 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.common.entity.SysLog;
+import com.aijiuyi.admin.controller.dto.SysLogQueryDTO;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 系统日志 Mapper
+ * 基本 CRUD 由 MyBatis Plus BaseMapper 提供,复杂查询在 SysLogMapper.xml 中定义
+ */
+@Mapper
+public interface SysLogMapper extends BaseMapper<SysLog> {
+
+    /**
+     * 分页查询操作日志(含多条件筛选)
+     *
+     * @param page 分页参数
+     * @param dto  查询条件
+     * @return 分页结果
+     */
+    IPage<SysLog> selectLogPage(Page<SysLog> page, @Param("dto") SysLogQueryDTO dto);
+
+    /**
+     * 查询全部操作日志(用于 Excel 导出)
+     *
+     * @param dto 查询条件
+     * @return 日志列表
+     */
+    List<SysLog> selectLogList(@Param("dto") SysLogQueryDTO dto);
+}

+ 13 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/SysUserMapper.java

@@ -0,0 +1,13 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.SysUser;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 系统用户 Mapper
+ * 基本 CRUD 由 MyBatis Plus BaseMapper 提供,复杂查询在 SysUserMapper.xml 中定义
+ */
+@Mapper
+public interface SysUserMapper extends BaseMapper<SysUser> {
+}

+ 38 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/UserAcupointMapper.java

@@ -0,0 +1,38 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.UserAcupoint;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 用户穴位坐标 Mapper
+ */
+@Mapper
+public interface UserAcupointMapper extends BaseMapper<UserAcupoint> {
+
+    /**
+     * 查询指定子用户的所有穴位坐标列表
+     *
+     * @param userId 子用户ID(user_profile.id)
+     * @return 穴位坐标列表
+     */
+    List<UserAcupoint> listByUserId(@Param("userId") Long userId);
+
+    /**
+     * 删除指定子用户的所有穴位坐标记录(物理删除,用于重新生成前清空)
+     *
+     * @param userId 子用户ID
+     */
+    void deleteByUserId(@Param("userId") Long userId);
+
+    /**
+     * 删除指定子用户下某穴位的坐标记录(物理删除,用于按穴位局部更新)
+     *
+     * @param userId     子用户ID
+     * @param acupointId 穴位ID
+     */
+    void deleteByUserIdAndAcupointId(@Param("userId") Long userId, @Param("acupointId") Long acupointId);
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/UserDeviceMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.UserDevice;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 用户设备绑定 Mapper
+ */
+@Mapper
+public interface UserDeviceMapper extends BaseMapper<UserDevice> {
+}

+ 22 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/UserPlanMapper.java

@@ -0,0 +1,22 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.controller.dto.UserPlanQueryDTO;
+import com.aijiuyi.admin.entity.UserPlan;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 用户方案 Mapper 接口
+ */
+@Mapper
+public interface UserPlanMapper extends BaseMapper<UserPlan> {
+
+    IPage<UserPlan> pageList(Page<UserPlan> page, @Param("query") UserPlanQueryDTO queryDTO);
+
+    List<UserPlan> getPlansByUserId(@Param("userId") Long userId);
+}

+ 26 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/UserProfileMapper.java

@@ -0,0 +1,26 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.controller.dto.UserProfileQueryDTO;
+import com.aijiuyi.admin.entity.UserProfile;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 子用户信息 Mapper
+ */
+@Mapper
+public interface UserProfileMapper extends BaseMapper<UserProfile> {
+
+    /**
+     * 分页查询子用户列表(联表获取绑定设备编号)
+     *
+     * @param page     分页对象
+     * @param queryDTO 查询条件
+     * @return 分页结果(含设备编号字段 deviceCodes)
+     */
+    IPage<UserProfile> pageWithDevices(Page<UserProfile> page, @Param("q") UserProfileQueryDTO queryDTO);
+}
+

+ 65 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AcupointService.java

@@ -0,0 +1,65 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.AcupointQueryDTO;
+import com.aijiuyi.admin.controller.dto.YReferenceOptionDTO;
+import com.aijiuyi.admin.entity.Acupoint;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 穴位管理 Service 接口
+ */
+public interface AcupointService extends IService<Acupoint> {
+
+    /**
+     * 分页查询穴位列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<Acupoint> pageList(AcupointQueryDTO queryDTO);
+
+    /**
+     * 获取可用于穴位Y轴计算的医学定位术语选项
+     *
+     * @return Y轴术语选项
+     */
+    List<YReferenceOptionDTO> listYReferenceOptions();
+
+    /**
+     * 新增穴位
+     *
+     * @param acupoint 穴位信息
+     */
+    void addAcupoint(Acupoint acupoint);
+
+    /**
+     * 修改穴位信息
+     *
+     * @param acupoint 穴位信息
+     */
+    void updateAcupoint(Acupoint acupoint);
+
+    /**
+     * 删除穴位(逻辑删除)
+     *
+     * @param id 穴位ID
+     */
+    void deleteAcupoint(Long id);
+
+    /**
+     * 批量删除穴位(逻辑删除)
+     *
+     * @param ids 穴位ID列表
+     */
+    void batchDeleteAcupoint(List<Long> ids);
+
+    /**
+     * 批量导入穴位
+     *
+     * @param list 穴位信息列表
+     */
+    void batchImport(List<Acupoint> list);
+}

+ 83 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AdminService.java

@@ -0,0 +1,83 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.AdminQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 管理员管理 Service 接口
+ * 对 app_user 表中 userType=3 的管理员进行管理,
+ * 同时提供管理员权限配置的查询与更新
+ */
+public interface AdminService extends IService<AppUser> {
+
+    /**
+     * 分页查询管理员列表
+     *
+     * @param queryDTO 查询参数
+     * @return 分页结果
+     */
+    IPage<AppUser> pageList(AdminQueryDTO queryDTO);
+
+    /**
+     * 新增管理员
+     * 对密码进行 MD5 加密,校验用户名唯一性
+     *
+     * @param admin 管理员信息
+     */
+    void addAdmin(AppUser admin);
+
+    /**
+     * 修改管理员信息
+     * 若传入密码则重新加密,否则保留原密码
+     *
+     * @param admin 管理员信息(需携带 id)
+     */
+    void updateAdmin(AppUser admin);
+
+    /**
+     * 删除管理员(逻辑删除)
+     * 超级管理员不可删除
+     *
+     * @param id 管理员ID
+     */
+    void deleteAdmin(Long id);
+
+    /**
+     * 批量删除管理员(逻辑删除)
+     * 超级管理员不可删除
+     *
+     * @param ids 管理员ID列表
+     */
+    void batchDeleteAdmin(List<Long> ids);
+
+    /**
+     * 修改管理员状态(禁用/启用)
+     * 不允许禁用当前登录账号自身
+     *
+     * @param id     管理员ID
+     * @param status 目标状态:1=启用,0=禁用
+     */
+    void updateStatus(Long id, Integer status);
+
+    /**
+     * 查询管理员权限路由列表
+     * 超级管理员返回 null(前端解释为全部权限)
+     *
+     * @param adminId 管理员ID
+     * @return 路由路径列表(JSON数组字符串,如 ["/dashboard","/device"]);超级管理员返回 null
+     */
+    String getPermissions(Long adminId);
+
+    /**
+     * 更新管理员权限路由列表
+     * 若超级管理员调用此方法,视为无效操作直接返回
+     *
+     * @param adminId    管理员ID
+     * @param routePaths 路由路径列表(JSON数组字符串)
+     */
+    void updatePermissions(Long adminId, String routePaths);
+}

+ 71 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AppUserService.java

@@ -0,0 +1,71 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.controller.dto.AppUserQueryDTO;
+import com.aijiuyi.admin.controller.dto.LoginDTO;
+import com.aijiuyi.admin.entity.AppUser;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.Map;
+
+/**
+ * 应用用户 Service
+ * 统一用户中心:普通用户、专家、管理员均使用此表,通过 userType 区分
+ */
+public interface AppUserService extends IService<AppUser> {
+
+    /**
+     * 管理员登录
+     * 通过用户名+密码登录,返回 Token 和用户信息
+     *
+     * @param loginDTO 登录参数(用户名、密码)
+     * @return 包含 token 和用户基本信息的 Result
+     */
+    Result<Map<String, Object>> login(LoginDTO loginDTO);
+
+    /**
+     * 退出登录
+     * 从 Redis 中删除 Token,使 Token 立即失效
+     *
+     * @param token 当前用户的 Token(从请求头中获取)
+     */
+    void logout(String token);
+
+    /**
+     * 分页查询应用用户列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<AppUser> pageList(AppUserQueryDTO queryDTO);
+
+    /**
+     * 修改用户状态(禁用/启用)
+     *
+     * @param id     用户ID
+     * @param status 目标状态:1=启用,0=禁用
+     */
+    void updateStatus(Long id, Integer status);
+
+    /**
+     * 新增应用用户
+     *
+     * @param appUser 用户信息
+     */
+    void addUser(AppUser appUser);
+
+    /**
+     * 修改应用用户
+     *
+     * @param appUser 用户信息
+     */
+    void updateUser(AppUser appUser);
+
+    /**
+     * 删除应用用户(逻辑删除)
+     *
+     * @param id 用户ID
+     */
+    void deleteUser(Long id);
+}

+ 99 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/DeviceService.java

@@ -0,0 +1,99 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.DeviceQueryDTO;
+import com.aijiuyi.admin.controller.dto.DeviceSaveDTO;
+import com.aijiuyi.admin.entity.Device;
+import com.aijiuyi.admin.entity.DeviceUserVO;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 设备管理 Service 接口
+ */
+public interface DeviceService extends IService<Device> {
+
+    /**
+     * 分页查询设备列表(联表聚合绑定用户信息)
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<Device> pageList(DeviceQueryDTO queryDTO);
+
+    /**
+     * 新增设备(可同时绑定初始用户)
+     *
+     * @param dto 设备信息
+     */
+    void addDevice(DeviceSaveDTO dto);
+
+    /**
+     * 修改设备信息(可同步更新绑定用户)
+     *
+     * @param dto 设备信息
+     */
+    void updateDevice(DeviceSaveDTO dto);
+
+    /**
+     * 删除设备(逻辑删除,同步解绑所有用户)
+     *
+     * @param id 设备ID
+     */
+    void deleteDevice(Long id);
+
+    /**
+     * 批量删除设备(逻辑删除,同步解绑所有用户)
+     *
+     * @param ids 设备ID列表
+     */
+    void batchDeleteDevices(List<Long> ids);
+
+    /**
+     * 批量导入设备(跳过编号已存在的记录)
+     *
+     * @param list 设备信息列表
+     */
+    void batchImport(List<Device> list);
+
+    /**
+     * 查询设备绑定的用户列表
+     *
+     * @param deviceId 设备ID
+     * @return 绑定用户列表
+     */
+    List<DeviceUserVO> getDeviceUsers(Long deviceId);
+
+    /**
+     * 为设备添加绑定用户
+     *
+     * @param deviceId  设备ID
+     * @param profileId 用户档案ID(user_profile.id)
+     */
+    void addDeviceUser(Long deviceId, Long profileId);
+
+    /**
+     * 解绑设备用户
+     *
+     * @param deviceId  设备ID
+     * @param profileId 用户档案ID
+     */
+    void removeDeviceUser(Long deviceId, Long profileId);
+
+    /**
+     * 设为主用户(同一设备下只能有一个主用户)
+     *
+     * @param deviceId  设备ID
+     * @param profileId 用户档案ID
+     */
+    void setDeviceUserPrimary(Long deviceId, Long profileId);
+
+    /**
+     * 批量解绑设备用户
+     *
+     * @param deviceId 设备ID
+     * @param bindIds  绑定记录ID列表(user_device.id)
+     */
+    void batchRemoveDeviceUsers(Long deviceId, List<Long> bindIds);
+}

+ 50 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/MoxibustionTechniqueService.java

@@ -0,0 +1,50 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.MoxibustionTechniqueQueryDTO;
+import com.aijiuyi.admin.entity.MoxibustionTechnique;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 艾灸手法管理 Service 接口
+ */
+public interface MoxibustionTechniqueService extends IService<MoxibustionTechnique> {
+
+    /**
+     * 分页查询艾灸手法列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<MoxibustionTechnique> pageList(MoxibustionTechniqueQueryDTO queryDTO);
+
+    /**
+     * 新增艾灸手法
+     *
+     * @param technique 手法信息
+     */
+    void addTechnique(MoxibustionTechnique technique);
+
+    /**
+     * 修改艾灸手法信息
+     *
+     * @param technique 手法信息
+     */
+    void updateTechnique(MoxibustionTechnique technique);
+
+    /**
+     * 删除艾灸手法(逻辑删除)
+     *
+     * @param id 手法ID
+     */
+    void deleteTechnique(Long id);
+
+    /**
+     * 批量删除艾灸手法(逻辑删除)
+     *
+     * @param ids 手法ID列表
+     */
+    void batchDeleteTechnique(List<Long> ids);
+}

+ 96 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/PlanService.java

@@ -0,0 +1,96 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.PlanQueryDTO;
+import com.aijiuyi.admin.controller.dto.PlanSaveDTO;
+import com.aijiuyi.admin.entity.Plan;
+import com.aijiuyi.admin.entity.PlanStep;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 方案管理 Service 接口
+ */
+public interface PlanService extends IService<Plan> {
+
+    /**
+     * 分页查询方案列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<Plan> pageList(PlanQueryDTO queryDTO);
+
+    /**
+     * 查询方案详情(含步骤列表)
+     *
+     * @param id 方案ID
+     * @return 方案详情
+     */
+    PlanSaveDTO getDetail(Long id);
+
+    /**
+     * 新增方案(含步骤)
+     *
+     * @param dto 方案信息
+     */
+    void addPlan(PlanSaveDTO dto);
+
+    /**
+     * 修改方案(含步骤)
+     *
+     * @param dto 方案信息
+     */
+    void updatePlan(PlanSaveDTO dto);
+
+    /**
+     * 删除方案(逻辑删除,仅草稿状态可删)
+     *
+     * @param id 方案ID
+     */
+    void deletePlan(Long id);
+
+    /**
+     * 批量删除方案(仅草稿状态可删)
+     *
+     * @param ids 方案ID列表
+     */
+    void batchDeletePlan(List<Long> ids);
+
+    /**
+     * 发布方案(草稿 → 已发布)
+     *
+     * @param id 方案ID
+     */
+    void publishPlan(Long id);
+
+    /**
+     * 批量发布方案
+     *
+     * @param ids 方案ID列表
+     */
+    void batchPublishPlan(List<Long> ids);
+
+    /**
+     * 下架方案(已发布 → 已下架)
+     *
+     * @param id 方案ID
+     */
+    void offshelfPlan(Long id);
+
+    /**
+     * 批量下架方案
+     *
+     * @param ids 方案ID列表
+     */
+    void batchOffshelfPlan(List<Long> ids);
+
+    /**
+     * 查询方案步骤列表
+     *
+     * @param planId 方案ID
+     * @return 步骤列表
+     */
+    List<PlanStep> getStepsByPlanId(Long planId);
+}

+ 19 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/SimulationService.java

@@ -0,0 +1,19 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.SimulationResponse;
+
+/**
+ * 方案模拟测试 Service 接口
+ * 组装推送数据:用户体型 + 方案步骤 + 穴位坐标偏移
+ */
+public interface SimulationService {
+
+    /**
+     * 组装方案模拟推送数据
+     *
+     * @param profileId 子用户ID(user_profile.id)
+     * @param planId    方案ID(plan.id)
+     * @return 模拟推送数据
+     */
+    SimulationResponse assemblePushData(Long profileId, Long planId);
+}

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/SysUserService.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.controller.dto.LoginDTO;
+import com.aijiuyi.admin.entity.SysUser;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.Map;
+
+/**
+ * 系统用户 Service 接口
+ */
+public interface SysUserService extends IService<SysUser> {
+
+    /**
+     * 用户登录
+     * 验证用户名和密码,生成 Token 并存入 Redis
+     *
+     * @param loginDTO 登录参数(用户名、密码)
+     * @return token 和用户基本信息
+     */
+    Result<Map<String, Object>> login(LoginDTO loginDTO);
+
+    /**
+     * 用户退出登录
+     * 从 Redis 中删除当前用户的 Token
+     *
+     * @param token 当前用户的 Token(从请求头中获取)
+     */
+    void logout(String token);
+}

+ 37 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/UserAcupointService.java

@@ -0,0 +1,37 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.entity.UserAcupoint;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 用户穴位坐标 Service 接口
+ * 提供穴位坐标生成、查询功能
+ */
+public interface UserAcupointService extends IService<UserAcupoint> {
+
+    /**
+     * 为指定子用户生成(或重新生成)全量穴位坐标
+     * 生成逻辑:先清除该用户所有旧记录,再根据当前全量活跃穴位重新计算并插入
+     *
+     * @param profileId 子用户ID(user_profile.id)
+     */
+    void generateForUser(Long profileId);
+
+    /**
+     * 当穴位管理新增一条穴位时,为所有子用户追加该穴位的坐标记录
+     * 如果该用户尚未生成过穴位表(user_profile.acupoint_table_id 为空),则跳过
+     *
+     * @param acupointId 新增的穴位ID
+     */
+    void generateForAllUsersByAcupoint(Long acupointId);
+
+    /**
+     * 查询指定子用户的穴位坐标列表
+     *
+     * @param profileId 子用户ID
+     * @return 穴位坐标列表
+     */
+    List<UserAcupoint> listByUser(Long profileId);
+}

+ 59 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/UserDeviceService.java

@@ -0,0 +1,59 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.UserDeviceQueryDTO;
+import com.aijiuyi.admin.entity.UserDevice;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 用户设备绑定 Service
+ */
+public interface UserDeviceService extends IService<UserDevice> {
+
+    /**
+     * 分页查询设备列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<UserDevice> pageList(UserDeviceQueryDTO queryDTO);
+
+    /**
+     * 绑定设备
+     *
+     * @param device 设备信息
+     */
+    void bindDevice(UserDevice device);
+
+    /**
+     * 解绑设备
+     *
+     * @param id 设备记录ID
+     */
+    void unbindDevice(Long id);
+
+    /**
+     * 删除设备记录
+     *
+     * @param id 设备记录ID
+     */
+    void deleteDevice(Long id);
+
+    /**
+     * 设置主设备(同一用户下只有一个主设备)
+     *
+     * @param id     设备记录ID
+     * @param userId 所属子用户ID
+     */
+    void setPrimaryDevice(Long id, Long userId);
+
+    /**
+     * 批量解绑设备
+     *
+     * @param ids 设备记录ID列表
+     */
+    void batchUnbindDevices(List<Long> ids);
+}
+

+ 33 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/UserPlanService.java

@@ -0,0 +1,33 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.UserPlanQueryDTO;
+import com.aijiuyi.admin.entity.UserPlan;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+/**
+ * 用户方案 Service 接口
+ */
+public interface UserPlanService extends IService<UserPlan> {
+
+    IPage<UserPlan> pageList(UserPlanQueryDTO queryDTO);
+
+    /**
+     * 根据用户ID分页查询方案列表
+     *
+     * @param userId   用户ID
+     * @param pageNum  当前页码(从1开始)
+     * @param pageSize 每页条数
+     * @return 分页结果
+     */
+    IPage<UserPlan> pageByUserId(Long userId, Integer pageNum, Integer pageSize);
+
+    /**
+     * 查询用户方案详情
+     *
+     * @param id 用户方案ID
+     * @return 用户方案详情
+     */
+    UserPlan getDetail(Long id);
+
+}

+ 67 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/UserProfileService.java

@@ -0,0 +1,67 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.UserProfileDTO;
+import com.aijiuyi.admin.controller.dto.UserProfileQueryDTO;
+import com.aijiuyi.admin.entity.UserProfile;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * 子用户信息 Service
+ */
+public interface UserProfileService extends IService<UserProfile> {
+
+    /**
+     * 分页查询子用户列表(联表获取设备编号)
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<UserProfile> pageList(UserProfileQueryDTO queryDTO);
+
+    /**
+     * 新增子用户
+     *
+     * @param dto 子用户信息 DTO
+     */
+    void addProfile(UserProfileDTO dto);
+
+    /**
+     * 修改子用户
+     *
+     * @param dto 子用户信息 DTO
+     */
+    void updateProfile(UserProfileDTO dto);
+
+    /**
+     * 删除子用户
+     *
+     * @param id 子用户ID
+     */
+    void deleteProfile(Long id);
+
+    /**
+     * 批量删除子用户
+     *
+     * @param ids 子用户ID列表
+     */
+    void batchDelete(List<Long> ids);
+
+    /**
+     * 批量导入子用户
+     *
+     * @param list 子用户信息列表
+     */
+    void batchImport(List<UserProfileDTO> list);
+
+    /**
+     * 查询用户档案列表(用于下拉选择,支持姓名/手机号模糊搜索,最多返回200条)
+     *
+     * @param keyword 搜索关键字(姓名或手机号,为空则返回全部)
+     * @return 用户档案列表
+     */
+    List<UserProfile> listOptions(String keyword);
+}
+

+ 217 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AcupointServiceImpl.java

@@ -0,0 +1,217 @@
+package com.aijiuyi.admin.service.impl;
+
+import com.aijiuyi.admin.common.constant.ResultCode;
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aijiuyi.admin.common.util.AcupointLocationParser;
+import com.aijiuyi.admin.common.util.AcupointYReference;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.AcupointQueryDTO;
+import com.aijiuyi.admin.controller.dto.YReferenceOptionDTO;
+import com.aijiuyi.admin.entity.Acupoint;
+import com.aijiuyi.admin.mapper.AcupointMapper;
+import com.aijiuyi.admin.service.AcupointService;
+import com.aijiuyi.admin.service.UserAcupointService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+/**
+ * 穴位管理 Service 实现类
+ */
+@Service
+public class AcupointServiceImpl extends ServiceImpl<AcupointMapper, Acupoint> implements AcupointService {
+
+    /** 用户穴位坐标服务(懒加载避免循环依赖) */
+    @Lazy
+    @Autowired
+    private UserAcupointService userAcupointService;
+
+    /**
+     * 分页查询穴位列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @Override
+    public IPage<Acupoint> pageList(AcupointQueryDTO queryDTO) {
+        Page<Acupoint> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        LambdaQueryWrapper<Acupoint> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(StringUtils.hasText(queryDTO.getName()), Acupoint::getName, queryDTO.getName())
+               .eq(StringUtils.hasText(queryDTO.getMeridian()), Acupoint::getMeridian, queryDTO.getMeridian())
+               .eq(queryDTO.getLocationType() != null, Acupoint::getLocationType, queryDTO.getLocationType())
+               .eq(queryDTO.getStatus() != null, Acupoint::getStatus, queryDTO.getStatus())
+               .orderByDesc(Acupoint::getCreateTime);
+        return page(page, wrapper);
+    }
+
+    @Override
+    public List<YReferenceOptionDTO> listYReferenceOptions() {
+        return AcupointYReference.listOptions();
+    }
+
+    /**
+     * 新增穴位,校验编码和名称唯一性
+     *
+     * @param acupoint 穴位信息
+     */
+    @Override
+    public void addAcupoint(Acupoint acupoint) {
+        normalizeLocationFields(acupoint);
+        // 穴位编码唯一性校验
+        long codeCount = lambdaQuery().eq(Acupoint::getAcupointCode, acupoint.getAcupointCode()).count();
+        if (codeCount > 0) {
+            throw new BusinessException(ResultCode.ACUPOINT_CODE_EXISTS);
+        }
+        // 穴位名称唯一性校验
+        long nameCount = lambdaQuery().eq(Acupoint::getName, acupoint.getName()).count();
+        if (nameCount > 0) {
+            throw new BusinessException(ResultCode.ACUPOINT_NAME_EXISTS);
+        }
+        save(acupoint);
+        LogUtil.info(AcupointServiceImpl.class, "新增穴位[{}],编码[{}]", acupoint.getName(), acupoint.getAcupointCode());
+        // 新增穴位后,异步为所有已生成穴位表的子用户追加该穴位坐标
+        try {
+            userAcupointService.generateForAllUsersByAcupoint(acupoint.getId());
+        } catch (Exception e) {
+            LogUtil.error(AcupointServiceImpl.class,
+                    "新增穴位[" + acupoint.getId() + "]触发用户坐标批量追加失败", e);
+        }
+    }
+
+    /**
+     * 修改穴位信息,校验穴位存在性及名称/编码唯一性
+     *
+     * @param acupoint 穴位信息
+     */
+    @Override
+    public void updateAcupoint(Acupoint acupoint) {
+        Acupoint exist = getById(acupoint.getId());
+        if (exist == null) {
+            throw new BusinessException(ResultCode.ACUPOINT_NOT_FOUND);
+        }
+        normalizeLocationFields(acupoint);
+        // 如果修改了编码,校验唯一性
+        if (StringUtils.hasText(acupoint.getAcupointCode())
+                && !acupoint.getAcupointCode().equals(exist.getAcupointCode())) {
+            long count = lambdaQuery().eq(Acupoint::getAcupointCode, acupoint.getAcupointCode()).count();
+            if (count > 0) {
+                throw new BusinessException(ResultCode.ACUPOINT_CODE_EXISTS);
+            }
+        }
+        // 如果修改了名称,校验唯一性
+        if (StringUtils.hasText(acupoint.getName())
+                && !acupoint.getName().equals(exist.getName())) {
+            long count = lambdaQuery().eq(Acupoint::getName, acupoint.getName()).count();
+            if (count > 0) {
+                throw new BusinessException(ResultCode.ACUPOINT_NAME_EXISTS);
+            }
+        }
+        updateById(acupoint);
+        LogUtil.info(AcupointServiceImpl.class, "修改穴位[{}]", acupoint.getId());
+        try {
+            userAcupointService.generateForAllUsersByAcupoint(acupoint.getId());
+        } catch (Exception e) {
+            LogUtil.error(AcupointServiceImpl.class,
+                    "修改穴位[" + acupoint.getId() + "]触发用户坐标批量更新失败", e);
+        }
+    }
+
+    /**
+     * 删除穴位(逻辑删除)
+     *
+     * @param id 穴位ID
+     */
+    @Override
+    public void deleteAcupoint(Long id) {
+        Acupoint acupoint = getById(id);
+        if (acupoint == null) {
+            throw new BusinessException(ResultCode.ACUPOINT_NOT_FOUND);
+        }
+        removeById(id);
+        LogUtil.info(AcupointServiceImpl.class, "删除穴位[{}]", id);
+    }
+
+    /**
+     * 批量删除穴位(逻辑删除)
+     *
+     * @param ids 穴位ID列表
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void batchDeleteAcupoint(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        removeByIds(ids);
+        LogUtil.info(AcupointServiceImpl.class, "批量删除穴位,共[{}]条", ids.size());
+    }
+
+    /**
+     * 批量导入穴位(跳过已存在编码)
+     *
+     * @param list 穴位信息列表
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void batchImport(List<Acupoint> list) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        for (Acupoint acupoint : list) {
+            if (!StringUtils.hasText(acupoint.getAcupointCode())) {
+                continue;
+            }
+            normalizeLocationFields(acupoint);
+            Acupoint exist = lambdaQuery()
+                    .eq(Acupoint::getAcupointCode, acupoint.getAcupointCode())
+                    .one();
+            if (exist == null) {
+                save(acupoint);
+            } else {
+                acupoint.setId(exist.getId());
+                updateById(acupoint);
+            }
+        }
+        LogUtil.info(AcupointServiceImpl.class, "批量导入穴位[{}]条", list.size());
+    }
+
+    /**
+     * 统一从穴位定位描述中解析相对坐标参数,避免管理员直接维护算法字段。
+     */
+    private void normalizeLocationFields(Acupoint acupoint) {
+        if (acupoint == null) {
+            return;
+        }
+
+        AcupointLocationParser.ParseResult parsed = AcupointLocationParser.parse(acupoint);
+        acupoint.setLocationType(1);
+        acupoint.setYReferenceTerm(parsed.getYReferenceTerm());
+        acupoint.setNormalizedY(parsed.getNormalizedY());
+        acupoint.setLongitudeOffset(parsed.getLongitudeOffset());
+        acupoint.setLateralOffset(parsed.getLateralOffset());
+        acupoint.setSide(parsed.getSide());
+        acupoint.setParseStatus(parsed.getParseStatus());
+        acupoint.setParseConfidence(parsed.getParseConfidence());
+        acupoint.setParseMessage(parsed.getParseMessage());
+        acupoint.setFingerWidthType(null);
+
+        if (acupoint.getGender() == null) {
+            acupoint.setGender(0);
+        }
+        acupoint.setStatus(AcupointLocationParser.STATUS_PARSED.equals(parsed.getParseStatus()) ? 1 : 0);
+        if (acupoint.getDeleted() == null) {
+            acupoint.setDeleted(0);
+        }
+        if (acupoint.getProductAcupoint() == null) {
+            acupoint.setProductAcupoint(0);
+        }
+    }
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio