#2 合并贾的修改到master

باز‌کردن
liaoxg قصد ادغام 56 تغییر را از liaoxg/jiapu_aijiuyi به liaoxg/master دارد
100فایلهای تغییر یافته به همراه7046 افزوده شده و 1512 حذف شده
  1. 0 61
      AGENTS.md
  2. 4 0
      code/AGENTS.md
  3. 2 2
      code/ajyApp/manifest.json
  4. 248 17
      code/ajyApp/pages/device/acupointEdit/acupointEdit.vue
  5. 114 88
      code/ajyApp/pages/device/bodyParams/bodyParams.nvue
  6. 68 3
      code/ajyApp/pages/device/detail/detail.nvue
  7. 113 18
      code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue
  8. 2 1
      code/ajyApp/pages/device/deviceInfo/mixins/acupoint-mixin.js
  9. 38 56
      code/ajyApp/pages/device/deviceInfo/mixins/audio-mixin.js
  10. 37 7
      code/ajyApp/pages/device/deviceInfo/mixins/ble-mixin.js
  11. 104 21
      code/ajyApp/pages/device/deviceInfo/mixins/plan-mixin.js
  12. 16 11
      code/ajyApp/pages/device/list/list.nvue
  13. 52 18
      code/ajyApp/pages/device/search/search.nvue
  14. 48 31
      code/ajyApp/pages/device/userManage/userManage.nvue
  15. 1 0
      code/ajyApp/pages/login/login.nvue
  16. 210 6
      code/ajyApp/pages/my/version/version.nvue
  17. 175 0
      code/ajyApp/stores/audio.js
  18. 175 20
      code/ajyApp/stores/ble.js
  19. 1 1
      code/ajyApp/utils/api/acupoint.js
  20. 1 2
      code/ajyApp/utils/api/article.js
  21. 81 0
      code/ajyApp/utils/api/care.js
  22. 32 0
      code/ajyApp/utils/api/device.js
  23. 44 0
      code/ajyApp/utils/api/group.js
  24. 19 0
      code/ajyApp/utils/api/version.js
  25. 5 3
      code/ajyApp/utils/ble/BleManager.js
  26. 27 3
      code/ajyApp/utils/ble/constants.js
  27. 3 2
      code/ajyApp/utils/http/config.js
  28. 7 1
      code/ajyApp/utils/http/request.js
  29. 2081 55
      code/app对接接口说明.md
  30. 151 19
      code/backend/src/main/java/com/aijiuyi/admin/common/config/PlanDataInitializer.java
  31. 6 2
      code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java
  32. 200 0
      code/backend/src/main/java/com/aijiuyi/admin/common/interceptor/TokenInterceptor.java
  33. 8 8
      code/backend/src/main/java/com/aijiuyi/admin/common/service/impl/SysLogServiceImpl.java
  34. 20 4
      code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointLocationParser.java
  35. 22 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java
  36. 1 1
      code/backend/src/main/java/com/aijiuyi/admin/controller/AcupointController.java
  37. 5 5
      code/backend/src/main/java/com/aijiuyi/admin/controller/AdminController.java
  38. 66 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppContentController.java
  39. 8 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppDeviceController.java
  40. 9 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppGroupController.java
  41. 146 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppPlanController.java
  42. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppUpdateController.java
  43. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserAcupointController.java
  44. 18 70
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserController.java
  45. 2 2
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserDeviceController.java
  46. 100 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppVersionController.java
  47. 32 50
      code/backend/src/main/java/com/aijiuyi/admin/controller/AuthController.java
  48. 15 15
      code/backend/src/main/java/com/aijiuyi/admin/controller/DeviceController.java
  49. 13 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java
  50. 17 7
      code/backend/src/main/java/com/aijiuyi/admin/controller/PlanController.java
  51. 0 12
      code/backend/src/main/java/com/aijiuyi/admin/controller/UserAcupointController.java
  52. 60 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppPlanVO.java
  53. 0 5
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppUserDeviceBindDTO.java
  54. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionCheckVO.java
  55. 25 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionQueryDTO.java
  56. 37 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionSaveDTO.java
  57. 1 1
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceQueryDTO.java
  58. 3 2
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java
  59. 4 1
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanSaveDTO.java
  60. 5 8
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java
  61. 58 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/AdminUser.java
  62. 5 31
      code/backend/src/main/java/com/aijiuyi/admin/entity/AppUser.java
  63. 58 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/AppVersion.java
  64. 3 3
      code/backend/src/main/java/com/aijiuyi/admin/entity/Device.java
  65. 5 5
      code/backend/src/main/java/com/aijiuyi/admin/entity/DeviceUserVO.java
  66. 1 1
      code/backend/src/main/java/com/aijiuyi/admin/entity/Plan.java
  67. 4 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserDevice.java
  68. 5 8
      code/backend/src/main/java/com/aijiuyi/admin/entity/UserProfile.java
  69. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/AdminUserMapper.java
  70. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/AppVersionMapper.java
  71. 4 3
      code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceMapper.java
  72. 26 6
      code/backend/src/main/java/com/aijiuyi/admin/service/AdminService.java
  73. 14 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AppDeviceGroupService.java
  74. 7 56
      code/backend/src/main/java/com/aijiuyi/admin/service/AppUserService.java
  75. 59 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AppVersionService.java
  76. 12 12
      code/backend/src/main/java/com/aijiuyi/admin/service/DeviceService.java
  77. 87 3
      code/backend/src/main/java/com/aijiuyi/admin/service/PlanService.java
  78. 1 2
      code/backend/src/main/java/com/aijiuyi/admin/service/UserDeviceService.java
  79. 216 83
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AdminServiceImpl.java
  80. 94 71
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppDeviceGroupServiceImpl.java
  81. 34 332
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppUserServiceImpl.java
  82. 204 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppVersionServiceImpl.java
  83. 5 5
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/ContentArticleServiceImpl.java
  84. 366 86
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/DeviceServiceImpl.java
  85. 389 13
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/PlanServiceImpl.java
  86. 7 8
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserCategoryServiceImpl.java
  87. 216 16
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserDeviceServiceImpl.java
  88. 56 2
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserPlanServiceImpl.java
  89. 49 78
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserProfileServiceImpl.java
  90. 2 2
      code/backend/src/main/resources/application-dev.yml
  91. 1 0
      code/backend/src/main/resources/application.yml
  92. 58 0
      code/backend/src/main/resources/db/migration_admin_user.sql
  93. 5 0
      code/backend/src/main/resources/mapper/AppVersionMapper.xml
  94. 105 30
      code/backend/src/main/resources/mapper/DeviceMapper.xml
  95. 8 0
      code/backend/src/main/resources/mapper/UserPlanMapper.xml
  96. 6 12
      code/backend/src/main/resources/mapper/UserProfileMapper.xml
  97. 15 0
      code/backend/src/main/resources/sql/migration_admin_permission.sql
  98. 19 0
      code/backend/src/main/resources/sql/migration_app_version.sql
  99. 23 0
      code/backend/src/test/java/com/aijiuyi/admin/common/util/AcupointLocationParserTest.java
  100. 4 5
      code/frontend/src/api/auth.js

+ 0 - 61
AGENTS.md

@@ -1,61 +0,0 @@
-# 项目协作规则
-
-## 基本原则
-
-- 修改代码前先阅读相关文件,遵循现有项目结构、命名和实现风格。
-- 保持改动聚焦,只处理当前任务相关内容,不做无关重构。
-- 不要覆盖或回滚用户已有改动,除非用户明确要求。
-- 不要提交日志、临时文件、构建产物或本地环境配置。
-
-## 运行与构建
-
-- 前端构建优先使用:
-  `npm run build`
-- 如果需要运行前端开发服务,先查看现有脚本和端口配置,再启动对应命令。
-- 如果需要运行后端服务或测试,先查看后端目录中的 Maven/Gradle 配置,再选择合适命令。
-- 运行命令失败时,需要说明失败原因和已尝试的验证方式。
-
-## 修改限制
-
-- 不要修改以下本地配置文件,除非用户明确要求:
-  `code/backend/src/main/resources/application-dev.yml`
-- 不要提交以下内容:
-  `code/backend/logs/**`
-  `logs/
-- 涉及用户数据、方案、穴位、经络、调理内容时,要优先保证已有数据兼容性。
-- 涉及中医术语、穴位定位和方案描述时,要保持表述准确、谨慎。
-
-## 测试与验证
-
-- 前端代码改动后,优先运行:
-  `npm run build`
-- 后端代码改动后,优先运行相关单元测试或项目已有验证命令。
-- 如果改动范围较小且没有合适测试,也需要进行静态检查或说明未运行测试的原因。
-
-## Git 规则
-
-- 不要自动执行 `git push`,除非用户明确要求。
-- 不要使用破坏性 Git 命令,例如 `git reset --hard`、`git checkout -- <file>`,除非用户明确要求。
-- 提交前检查暂存内容,避免把本地配置、日志或无关文件加入提交。
-- 如果用户要求提交,提交信息优先使用中文,并简洁描述本次改动。
-- 不要将.cursor文件夹和.idea文件夹提交到git
-- Git 仓库: http://git.iwintrue.com/liaoxg/ajy
-- 同事分支:liyu_2026_04_07
-- 我的分支:jiapu_aijiuyi
-- 项目路径(仓库内): code/aijiuyi
-- Git 用户名: jiapu
-- 分支: jiapu_aijiuyi
-- 拉取代码规则:将同事分支的openspec文件夹下的所有文件(不包括openspec目录)同步到我的code目录下。
-- git提交日志:在每次提交的时候要询问我,如果我选择空白,则你可以自己总结边写日志
-- 提交代码规则:application-dev.yml文件默认不参与git提交。
-
-## 回复要求
-
-- 完成修改后,简要说明改了哪些文件、做了什么验证。
-- 如果有未完成事项、测试无法运行或需要用户决策的地方,需要明确说明。
-- 语言:和我对话,思考的时候使用中文。
-
-## 项目信息
-- 项目名称: aijiuyi
-- 可以读取并使用application.yml、application-dev.yml中的datasource属性
-- 你可以随时查看我的dev环境数据库,但是没有编写权限。如果你要编辑数据库,需要征求我的同意

+ 4 - 0
code/AGENTS.md

@@ -1,5 +1,9 @@
 # Agent Guidelines
 
+## 本目录额外约束
+
+- 不允许修改 `ajyApp/` 文件夹下的任何内容,除非用户明确要求。
+
 ## 项目概述
 
 **艾灸椅智能穴位定位系统** — 基于用户体型数据,实现精准穴位定位,控制艾灸椅对准穴位进行理疗。

+ 2 - 2
code/ajyApp/manifest.json

@@ -2,8 +2,8 @@
     "name" : "ajyApp",
     "appid" : "__UNI__A3304B5",
     "description" : "",
-    "versionName" : "1.0.1",
-    "versionCode" : 101,
+    "versionName" : "1.0.2",
+    "versionCode" : 102,
     "transformPx" : false,
     /* 5+App特有相关 */
     "app-plus" : {

+ 248 - 17
code/ajyApp/pages/device/acupointEdit/acupointEdit.vue

@@ -6,12 +6,14 @@
 			<view class="nav-back" @click="goBack">
 				<image src="/static/public/back.png" mode="aspectFill" class="back-icon"></image>
 			</view>
-			<text class="nav-title">穴位定位</text>
+			<text class="nav-title">{{pageMode === 'edit' ? '编辑方案' : '新增方案'}}</text>
 			<view class="nav-right">
-				<text class="nav-count">{{acupointList.length}}穴位</text>
+				<text class="nav-save-btn" @click="savePlan">保存</text>
 			</view>
 		</view>
 
+
+
 		<!-- 人体模型 + 穴位标注 -->
 		<scroll-view class="body-scroll" :scroll-y="!isLandscape" :show-scrollbar="false" :scroll-top="scrollTop" @scroll="onScroll" scroll-with-animation>
 			<view class="body-content">
@@ -33,7 +35,8 @@
 						:key="point.id"
 						class="acupoint-dot"
 						:class="{
-							'acupoint-active': selectedIndex === index
+							'acupoint-active': selectedIndex === index,
+							'acupoint-edited': selectedIndex !== index && (point.customSide || point._inPlan)
 						}"
 						:style="{
 							left: point.screenX + 'px',
@@ -60,11 +63,12 @@
 						class="list-item"
 						:class="{
 							'list-item-active': selectedIndex === index,
+							'list-item-edited': selectedIndex !== index && (point.customSide || point._inPlan),
 							'list-item-full': point.isSingle
 						}"
 						@click="onListItemTap(index)"
 					>
-						<view class="item-index" :class="{'item-index-active': selectedIndex === index}">
+						<view class="item-index" :class="{'item-index-active': selectedIndex === index || point.customSide || point._inPlan}">
 							<text class="index-text">{{index + 1}}</text>
 						</view>
 						<text class="item-name">{{point.acupointName}}</text>
@@ -87,13 +91,9 @@
 						<text class="form-label">侧别</text>
 						<view class="tags-row">
 							<view 
-								class="tag-item" 
-								v-for="item in sideOptions" 
-								:key="item"
-								:class="{'tag-item-active': editForm.side === item}"
-								@click="editForm.side = item"
+								class="tag-item tag-item-active" 
 							>
-								{{item}}
+								{{editForm.side}}
 							</view>
 						</view>
 					</view>
@@ -150,13 +150,37 @@
 				<text class="loading-text">加载中...</text>
 			</view>
 		</view>
+
+		<!-- 新增方案名称弹窗 -->
+		<view class="popup-mask" v-if="showNamePopup" @click="showNamePopup = false">
+			<view class="popup-content" @click.stop>
+				<view class="popup-header">
+					<text class="popup-title">保存方案</text>
+				</view>
+				<view class="popup-body">
+					<view class="form-group">
+						<text class="form-label">方案名称</text>
+						<input class="name-popup-input" v-model="planName" placeholder="请输入方案名称" maxlength="100" focus />
+					</view>
+				</view>
+				<view class="popup-footer">
+					<view class="btn btn-cancel" @click="showNamePopup = false">取消</view>
+					<view class="btn btn-confirm" @click="confirmSavePlan">确认保存</view>
+				</view>
+			</view>
+		</view>
+
+		<ay-toast ref="ayToast" />
 	</view>
 </template>
 
 <script>
 import { getBackAcupoints } from '@/utils/api/acupoint.js'
+import { createCustomPlan, updateCustomPlan, getCustomPlanDetail } from '@/utils/api/care.js'
+import ayToast from '@/components/ay-toast/ay-toast.nvue'
 
 export default {
+	components: { ayToast },
 	data() {
 		return {
 			isLandscape: false,
@@ -168,7 +192,7 @@ export default {
 			// SVG viewBox: 0 0 300 400, 一体式实心剪影
 			containerWidth: 345,
 			containerHeight: 460,
-			// 大穴(0,0)在容器中的位置
+			// 大穴(0,0)在容器中的位置
 			// 颈根/斜方肌起点(C7)在SVG中 y=78 -> 按比例放大1.15倍
 			originX: 172.5,
 			originY: 89.7,
@@ -184,11 +208,19 @@ export default {
 				temperature: 45,
 				duration: 15
 			},
-			sideOptions: ['双侧', '中心', '左侧', '右侧'],
+			sideOptions: [ '中心', '左侧', '右侧'],
 			methodOptions: ['温和灸', '雀啄灸', '回旋灸'],
 			
 			scrollTop: 0,
-			oldScrollTop: 0
+			oldScrollTop: 0,
+			
+			// 方案相关
+			pageMode: 'create', // 'create' | 'edit'
+			planId: null,
+			planName: '',
+			planData: null, // 编辑模式下的原始方案数据
+			saving: false,
+			showNamePopup: false
 		}
 	},
 	computed: {
@@ -216,9 +248,12 @@ export default {
 			})
 		}
 	},
-	onLoad() {
+	onLoad(options) {
 		const sysInfo = uni.getSystemInfoSync()
 		this.statusBarHeight = sysInfo.statusBarHeight || 44
+		this.profileId = decodeURIComponent(options.profileId || '')
+		this.pageMode = options.mode || 'create'
+		this.planId = options.planId || null
 		this.checkOrientation(sysInfo)
 		this.loadAcupoints()
 	},
@@ -248,7 +283,7 @@ export default {
 		async loadAcupoints() {
 			this.loading = true
 			try {
-				const data = await getBackAcupoints(4)
+				const data = await getBackAcupoints(this.profileId)
 				if (Array.isArray(data)) {
 					// 按名称分组并计算平均 Y 坐标,以处理对称穴位
 					const nameMap = new Map()
@@ -291,7 +326,7 @@ export default {
 				}
 			} catch (e) {
 				console.error('加载穴位数据失败:', e)
-				uni.showToast({ title: '穴位数据加载失败', icon: 'none' })
+				this.$refs.ayToast.error('穴位数据加载失败')
 				this.acupointList = []
 			} finally {
 				this.loading = false
@@ -302,6 +337,10 @@ export default {
 					}
 				})
 			}
+			// 穴位数据加载完成后,编辑模式下加载方案详情并回填
+			if (this.pageMode === 'edit' && this.planId && this.acupointList.length > 0) {
+				this.loadPlanDetail()
+			}
 		},
 
 		/** 点击穴位点 */
@@ -367,7 +406,7 @@ export default {
 		saveEdit() {
 			if (this.selectedIndex >= 0) {
 				const point = this.acupointList[this.selectedIndex]
-				// 更新穴位列表中的对应数据(使用Vue的响应式需要特殊处理,但这里我们直接改属性,若无法触发视图更新可用$set或splice,但简单修改通常有效)
+				// 更新穴位列表中的对应数据
 				point.customSide = this.editForm.side
 				point.method = this.editForm.method
 				point.temperature = this.editForm.temperature
@@ -376,6 +415,142 @@ export default {
 			this.closePopup()
 		},
 
+		/** 加载方案详情(编辑模式) */
+		async loadPlanDetail() {
+			try {
+				const res = await getCustomPlanDetail(this.planId)
+				if (res) {
+					this.planData = res
+					this.planName = res.name || ''
+					// 穴位列表已加载完成,直接回填方案步骤参数
+					if (res.steps && res.steps.length > 0) {
+						this.applyStepsToAcupoints(res.steps)
+					}
+				}
+			} catch (e) {
+				console.error('加载方案详情失败:', e)
+				this.$refs.ayToast.error('加载方案失败')
+			}
+		},
+
+		/** 将方案步骤参数回填到穴位列表 */
+		applyStepsToAcupoints(steps) {
+			const sideMap = { 1: '中心', 2: '左侧', 3: '右侧' }
+			const techniqueMap = { '2': '温和灸', '4': '回旋灸', '3': '雀啄灸' }
+			// step.side数字值到acupointSide英文值的映射
+			const sideToAcupointSide = { 1: 'center', 2: 'left', 3: 'right' }
+			
+			steps.forEach(step => {
+				let matchedPoints = []
+				
+				if (step.side === 4) {
+					// 双侧:匹配同acupointId的所有穴位(左右两侧都标记)
+					matchedPoints = this.acupointList.filter(p => String(p.acupointId) === String(step.acupointId))
+				} else {
+					// 单侧:优先通过acupointId + acupointSide精确匹配
+					const targetSide = sideToAcupointSide[step.side]
+					const point = this.acupointList.find(p =>
+						String(p.acupointId) === String(step.acupointId) && p.acupointSide === targetSide
+					)
+					if (point) {
+						matchedPoints = [point]
+					} else {
+						// 兜底:仅按acupointId匹配未被回填过的穴位
+						const fallback = this.acupointList.find(p =>
+							String(p.acupointId) === String(step.acupointId) && !p._inPlan
+						)
+						if (fallback) matchedPoints = [fallback]
+					}
+				}
+				
+				matchedPoints.forEach(point => {
+					point.customSide = sideMap[step.side] || '中心'
+					point.method = techniqueMap[step.techniqueId] || '温和灸'
+					point.temperature = step.temperature || 45
+					point.duration = step.duration || 15
+					point._inPlan = true
+				})
+			})
+		},
+
+		/** 点击保存按钮 */
+		savePlan() {
+			if (this.saving) return
+			const steps = this.buildSteps()
+			if (steps.length === 0) {
+				this.$refs.ayToast.show('请至少编辑一个穴位参数')
+				return
+			}
+			if (this.pageMode === 'edit') {
+				// 编辑模式直接保存
+				this.doSavePlan()
+			} else {
+				// 新增模式弹出名称输入框
+				this.showNamePopup = true
+			}
+		},
+
+		/** 弹窗确认保存(新增模式) */
+		confirmSavePlan() {
+			if (!this.planName.trim()) {
+				this.$refs.ayToast.show('请输入方案名称')
+				return
+			}
+			this.showNamePopup = false
+			this.doSavePlan()
+		},
+
+		/** 执行保存方案 */
+		async doSavePlan() {
+			if (this.saving) return
+			if (!this.planName.trim()) {
+				this.$refs.ayToast.show('请输入方案名称')
+				return
+			}
+
+			this.saving = true
+			try {
+				const sideMap = { '中心': 1, '左侧': 2, '右侧': 3}
+				const techniqueMap = { '温和灸': '2', '回旋灸': '4', '雀啄灸': '3' }
+				const steps = this.buildSteps()
+
+				const planData = {
+					profileId: this.profileId,
+					name: this.planName.trim(),
+					status: 1,
+					steps: steps.map(point => ({
+						acupointId: point.acupointId,
+						side: sideMap[point.customSide] || 1,
+						temperature: Number(point.temperature) || 45,
+						duration: Number(point.duration) || 15,
+						techniqueId: techniqueMap[point.method] || '2'
+					}))
+				}
+
+				if (this.pageMode === 'edit' && this.planId) {
+					planData.id = this.planId
+					await updateCustomPlan(planData)
+					this.$refs.ayToast.success('修改成功')
+				} else {
+					await createCustomPlan(planData)
+					this.$refs.ayToast.success('新增成功')
+				}
+				setTimeout(() => {
+					uni.navigateBack()
+				}, 1000)
+			} catch (e) {
+				console.error('保存方案失败:', e)
+				this.$refs.ayToast.error(e.message || '保存失败')
+			} finally {
+				this.saving = false
+			}
+		},
+
+		/** 构建方案步骤(筛选已编辑的穴位) */
+		buildSteps() {
+			return this.acupointList.filter(point => point.customSide || point._inPlan)
+		},
+
 		/** 侧别标签 */
 		sideLabel(side) {
 			const map = { left: '左侧', right: '右侧', center: '中心' }
@@ -446,6 +621,50 @@ export default {
 	font-size: 12px;
 	color: rgba(255,255,255,0.8);
 }
+.nav-save-btn {
+	font-size: 14px;
+	color: #FFFFFF;
+	background: rgba(255,255,255,0.2);
+	padding: 5px 14px;
+	border-radius: 14px;
+	font-weight: 500;
+}
+
+/* 方案名称输入 */
+.plan-name-section {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin: 0 15px 10px;
+	background: rgba(255,255,255,0.9);
+	border-radius: 12px;
+	padding: 10px 14px;
+}
+.plan-name-label {
+	font-size: 13px;
+	color: #545F71;
+	font-weight: 500;
+	margin-right: 10px;
+	flex-shrink: 0;
+}
+.plan-name-input {
+	flex: 1;
+	font-size: 14px;
+	color: #333333;
+	height: 32px;
+}
+
+/* 名称弹窗输入框 */
+.name-popup-input {
+	width: 100%;
+	height: 40px;
+	border: 1px solid #E8ECF0;
+	border-radius: 8px;
+	padding: 0 12px;
+	font-size: 14px;
+	color: #333333;
+	box-sizing: border-box;
+}
 
 /* 人体模型容器 */
 .body-scroll {
@@ -524,6 +743,14 @@ export default {
 	box-shadow: 0 4px 12px rgba(246, 82, 82, 0.4);
 	transform: scale(1.1);
 }
+/* 已编辑态 */
+.acupoint-edited .dot-inner {
+	width: 11px;
+	height: 11px;
+	background: #F65252;
+	border: 1.5px solid #FFFFFF;
+	box-shadow: 0 2px 8px rgba(246, 82, 82, 0.3);
+}
 
 /* 穴位列表 */
 .list-section {
@@ -577,6 +804,10 @@ export default {
 	box-shadow: 0 4px 12px rgba(56, 149, 136, 0.1);
 	transform: scale(1);
 }
+.list-item-edited {
+	background: rgba(246, 82, 82, 0.05);
+	border-color: rgba(246, 82, 82, 0.2);
+}
 .list-item-full {
 	margin-right: 50%;
 }

+ 114 - 88
code/ajyApp/pages/device/bodyParams/bodyParams.nvue

@@ -43,12 +43,11 @@
 					</view>
 				</view>
 
-				<!-- 肩高 -->
+				<!-- 手机号 -->
 				<view class="formItem">
-					<text class="formLabel">肩高(地面到肩膀的高度)</text>
+					<text class="formLabel">手机号</text>
 					<view class="inputWrap">
-						<input class="formInput" v-model="form.shoulderHeight" type="digit" placeholder="例如:135" placeholder-class="pholder" />
-						<text class="unitTxt">cm</text>
+						<input class="formInput" v-model="form.phone" type="number" placeholder="请输入手机号" placeholder-class="pholder" maxlength="11" />
 					</view>
 				</view>
 
@@ -61,6 +60,15 @@
 					</view>
 				</view>
 
+				<!-- 身高 -->
+				<view class="formItem">
+					<text class="formLabel">身高</text>
+					<view class="inputWrap">
+						<input class="formInput" v-model="form.height" type="digit" placeholder="例如:175" placeholder-class="pholder" />
+						<text class="unitTxt">cm</text>
+					</view>
+				</view>
+
 				<!-- 体重 -->
 				<view class="formItem">
 					<text class="formLabel">体重</text>
@@ -70,6 +78,24 @@
 					</view>
 				</view>
 
+				<!-- 身长/坐高 -->
+				<view class="formItem">
+					<text class="formLabel">坐高(坐姿时头顶到坐面的高度)</text>
+					<view class="inputWrap">
+						<input class="formInput" v-model="form.bodyHeight" type="digit" placeholder="例如:85" placeholder-class="pholder" />
+						<text class="unitTxt">cm</text>
+					</view>
+				</view>
+
+				<!-- 脊柱长度 -->
+				<view class="formItem">
+					<text class="formLabel">C7-S4 脊柱长度</text>
+					<view class="inputWrap">
+						<input class="formInput" v-model="form.spineLength" type="digit" placeholder="例如:48" placeholder-class="pholder" />
+						<text class="unitTxt">cm</text>
+					</view>
+				</view>
+
 				<!-- 保存按钮 -->
 				<view class="saveBtn" @click="onSave">
 					<text class="saveTxt">保存</text>
@@ -85,6 +111,8 @@
 
 <script>
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
+import { addGroupMember, updateGroupMember, getGroupMembers } from '@/utils/api/group.js'
+import { getProfile, updateProfile } from '@/utils/api/user.js'
 
 export default {
 	components: {
@@ -94,14 +122,19 @@ export default {
 		return {
 			statusBarHeight: 44,
 			mode: 'add', // add / edit
-			userId: '',
+			source: '', // '' = group member, 'profile' = current user profile
+			groupId: '',
+			profileId: '',
 			form: {
 				name: '',
 				gender: 1,
 				age: '',
-				shoulderHeight: '',
+				phone: '',
 				shoulderWidth: '',
-				weight: ''
+				height: '',
+				weight: '',
+				bodyHeight: '',
+				spineLength: ''
 			}
 		}
 	},
@@ -109,8 +142,13 @@ export default {
 		const sysInfo = uni.getSystemInfoSync()
 		this.statusBarHeight = sysInfo.statusBarHeight || 44
 		this.mode = options.mode || 'add'
-		this.userId = options.userId || ''
-		if (this.mode === 'edit' && this.userId) {
+		this.source = options.source || ''
+		this.groupId = options.groupId || ''
+		this.profileId = options.profileId || ''
+		if (this.source === 'profile') {
+			// 公共设备:加载当前账号个人资料
+			this.loadProfileData()
+		} else if (this.mode === 'edit' && this.profileId) {
 			this.loadUserData()
 		}
 	},
@@ -118,96 +156,84 @@ export default {
 		goBack() {
 			uni.navigateBack()
 		},
-		/** 加载编辑数据 */
-		loadUserData() {
-			const list = uni.getStorageSync('user_manage_list') || []
-			const user = list.find(u => u.id === this.userId)
-			if (user) {
-				this.form.name = user.name || ''
-				this.form.gender = user.gender || 1
-				this.form.age = user.age ? String(user.age) : ''
-				this.form.shoulderHeight = user.shoulderHeight ? String(user.shoulderHeight) : ''
-				this.form.shoulderWidth = user.shoulderWidth ? String(user.shoulderWidth) : ''
-				this.form.weight = user.weight ? String(user.weight) : ''
+		/** 加载个人资料数据(公共设备模式) */
+		async loadProfileData() {
+			try {
+				const profile = await getProfile()
+				if (profile) {
+					this.form.name = profile.name || ''
+					this.form.gender = profile.gender || 1
+					this.form.age = profile.age ? String(profile.age) : ''
+					this.form.phone = profile.phone || ''
+					this.form.shoulderWidth = profile.shoulderWidth ? String(profile.shoulderWidth) : ''
+					this.form.height = profile.height ? String(profile.height) : ''
+					this.form.weight = profile.weight ? String(profile.weight) : ''
+					this.form.bodyHeight = profile.bodyHeight ? String(profile.bodyHeight) : ''
+					this.form.spineLength = profile.spineLength ? String(profile.spineLength) : ''
+				}
+			} catch (e) {
+				console.error('加载个人资料失败:', e)
+			}
+		},
+		/** 加载编辑数据 - 从后端获取群组成员列表中查找当前成员 */
+		async loadUserData() {
+			try {
+				const list = await getGroupMembers(this.groupId)
+				const user = (list || []).find(u => String(u.profileId) === String(this.profileId))
+				if (user) {
+					this.form.name = user.name || ''
+					this.form.gender = user.gender || 1
+					this.form.age = user.age ? String(user.age) : ''
+					this.form.phone = user.phone || ''
+					this.form.shoulderWidth = user.shoulderWidth ? String(user.shoulderWidth) : ''
+					this.form.height = user.height ? String(user.height) : ''
+					this.form.weight = user.weight ? String(user.weight) : ''
+					this.form.bodyHeight = user.bodyHeight ? String(user.bodyHeight) : ''
+					this.form.spineLength = user.spineLength ? String(user.spineLength) : ''
+				}
+			} catch (e) {
+				console.error('加载用户数据失败:', e)
 			}
 		},
 		/** 校验并保存 */
-		onSave() {
-			// 校验
+		async onSave() {
+			// 校验姓名
 			if (!this.form.name.trim()) {
 				this.$refs.ayToast.error('请输入姓名')
 				return
 			}
-			const height = Number(this.form.shoulderHeight)
-			if (!height || height < 50 || height > 250) {
-				this.$refs.ayToast.error('肩高范围为50-250cm')
-				return
-			}
-			const width = Number(this.form.shoulderWidth)
-			if (!width || width < 20 || width > 80) {
-				this.$refs.ayToast.error('肩宽范围为20-80cm')
-				return
-			}
-			const weight = Number(this.form.weight)
-			if (!weight || weight < 10 || weight > 300) {
-				this.$refs.ayToast.error('体重范围为10-300kg')
-				return
-			}
 
-			let list = uni.getStorageSync('user_manage_list') || []
+			// 构建请求参数
+			const params = {
+				name: this.form.name.trim(),
+				gender: this.form.gender
+			}
+			if (this.form.age) params.age = Number(this.form.age)
+			if (this.form.phone) params.phone = this.form.phone
+			if (this.form.shoulderWidth) params.shoulderWidth = Number(this.form.shoulderWidth)
+			if (this.form.height) params.height = Number(this.form.height)
+			if (this.form.weight) params.weight = Number(this.form.weight)
+			if (this.form.bodyHeight) params.bodyHeight = Number(this.form.bodyHeight)
+			if (this.form.spineLength) params.spineLength = Number(this.form.spineLength)
 
-			if (this.mode === 'add') {
-				// 再次检查是否超过10个
-				if (list.length >= 10) {
-					this.$refs.ayToast.error('最多可添加10个用户')
-					return
-				}
-				const newUser = {
-					id: 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6),
-					name: this.form.name.trim(),
-					gender: this.form.gender,
-					age: Number(this.form.age) || 0,
-					shoulderHeight: height,
-					shoulderWidth: width,
-					weight: weight,
-					createTime: Date.now()
-				}
-				list.push(newUser)
-				uni.setStorageSync('user_manage_list', list)
-				// 如果是第一个用户,自动设为当前用户
-				if (list.length === 1) {
-					uni.setStorageSync('current_manage_user_id', newUser.id)
-					uni.setStorageSync('current_manage_user', newUser)
+			try {
+				if (this.source === 'profile') {
+					// 公共设备:调用修改个人资料接口
+					await updateProfile(params)
+					this.$refs.ayToast.success('保存成功')
+				} else if (this.mode === 'add') {
+					await addGroupMember(this.groupId, params)
+					this.$refs.ayToast.success('添加成功')
+				} else {
+					await updateGroupMember(this.groupId, this.profileId, params)
+					this.$refs.ayToast.success('保存成功')
 				}
-				this.$refs.ayToast.success('添加成功')
-			} else {
-				// 编辑模式
-				const index = list.findIndex(u => u.id === this.userId)
-				if (index === -1) {
-					this.$refs.ayToast.error('用户信息已过期,请刷新')
-					return
-				}
-				list[index] = {
-					...list[index],
-					name: this.form.name.trim(),
-					gender: this.form.gender,
-					age: Number(this.form.age) || 0,
-					shoulderHeight: height,
-					shoulderWidth: width,
-					weight: weight
-				}
-				uni.setStorageSync('user_manage_list', list)
-				// 如果编辑的是当前用户,更新当前用户缓存
-				const curId = uni.getStorageSync('current_manage_user_id')
-				if (curId === this.userId) {
-					uni.setStorageSync('current_manage_user', list[index])
-				}
-				this.$refs.ayToast.success('保存成功')
+				setTimeout(() => {
+					uni.navigateBack()
+				}, 800)
+			} catch (e) {
+				console.error('保存失败:', e)
 			}
-
-			setTimeout(() => {
-				uni.navigateBack()
-			}, 800)
 		}
 	}
 }

+ 68 - 3
code/ajyApp/pages/device/detail/detail.nvue

@@ -23,8 +23,16 @@
 				<text class="gray">{{name}}</text>
 			</view>
 			<view class="menuitem">
-				<text>设备ID</text>
-				<text class="gray device-id-text">{{deviceId}}</text>
+				<text>设备编号</text>
+				<text class="gray device-id-text">{{deviceCode}}</text>
+			</view>
+			<view class="menuitem">
+				<text>设备型号</text>
+				<text class="gray">{{deviceModel || '未知'}}</text>
+			</view>
+			<view class="menuitem">
+				<text>序列号</text>
+				<text class="gray">{{serialNo || '未知'}}</text>
 			</view>
 			<view class="menuitem">
 				<text>信号强度</text>
@@ -42,10 +50,20 @@
 				<text>固件版本</text>
 				<text class="gray">{{firmwareVersion || '未获取'}}</text>
 			</view>
-			<view class="menuitem lastItem">
+			<view class="menuitem">
 				<text>连接状态</text>
 				<text class="gray">{{connected ? '已连接' : '未连接'}}</text>
 			</view>
+			<view class="menuitem">
+				<text>在线状态</text>
+				<text class="gray">{{onlineStatus === 1 ? '在线' : '离线'}}</text>
+			</view>
+			<view class="menuitem lastItem menuitem-address">
+				<text>设备地址</text>
+				<view class="address-wrap">
+					<text class="gray address-text">{{address || '未设置'}}</text>
+				</view>
+			</view>
 
 			<text class="txt">设备操作</text>
 			<view class="menuitem" @click="disconnectDevice" v-if="connected">
@@ -68,6 +86,7 @@
 <script>
 import { useBleStore } from '@/stores/ble'
 import { BLE_STATE } from '@/utils/ble/constants.js'
+import { getDeviceDetail } from '@/utils/api/device.js'
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
 
 export default {
@@ -79,7 +98,13 @@ export default {
 			statusBarHeight: 44,
 			name: '',
 			deviceId: '',
+			deviceCode: '',
+			deviceModel: '',
+			serialNo: '',
+			onlineStatus: 0,
+			address: '',
 			rssi: '',
+			id:'',
 			connected: false,
 			connecting: false,
 			firmwareVersion: '',
@@ -113,6 +138,11 @@ export default {
 		this.name = decodeURIComponent(options.name || '')
 		this.deviceId = decodeURIComponent(options.deviceId || '')
 		this.rssi = decodeURIComponent(options.rssi || '')
+		this.id = decodeURIComponent(options.id || '')
+		// 获取设备详情
+		if (this.id) {
+			this.fetchDeviceDetail()
+		}
 	},
 	async onShow() {
 		const bleStore = useBleStore()
@@ -158,6 +188,22 @@ export default {
 		this._cleanListeners()
 	},
 	methods: {
+		async fetchDeviceDetail() {
+			try {
+				const res = await getDeviceDetail(this.id)
+				if (res) {
+					this.name = res.deviceName || this.name
+					this.deviceModel = res.deviceModel || ''
+					this.serialNo = res.serialNo || ''
+					this.firmwareVersion = res.firmwareVersion || this.firmwareVersion
+					this.onlineStatus = res.onlineStatus || 0
+					this.address = res.address || ''
+					this.deviceCode=res.deviceCode
+				}
+			} catch (e) {
+				console.log('获取设备详情失败', e)
+			}
+		},
 		goBack() {
 			uni.navigateBack()
 		},
@@ -395,6 +441,25 @@ export default {
 	height: 40rpx;
 }
 
+.menuitem-address {
+	height: auto;
+	min-height: 104rpx;
+	padding-top: 24rpx;
+	padding-bottom: 24rpx;
+}
+
+.address-wrap {
+	flex: 1;
+	margin-left: 20rpx;
+}
+
+.address-text {
+	font-size: 28rpx;
+	color: #9BA5B7;
+	text-align: right;
+	lines: 0;
+}
+
 .delbtn {
 	width: 654rpx;
 	height: 96rpx;

+ 113 - 18
code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue

@@ -4,15 +4,17 @@
 		<view class="pageContent">
 			<view class="status-bar" :style="{height: statusBarHeight + 'px'}"></view>
 			<view class="customHead">
+				
 				<view class="back-wrap" @click="goBack">
 					<image src="/static/public/back.png" mode="aspectFill" class="backimg"></image>
 				</view>
-				<text class="customTitle">{{picMode===1?'我的艾灸椅':'穴位编辑'}}</text>
+				<text class="customTitle">调试{{deviceType}}{{picMode===1?'我的艾灸椅':'穴位编辑'}}</text>
 				<view class="headRight">
 					<view class="userSwitchBtn" @click="goUserManage">
 						<text class="userSwitchTxt">{{currentUserName || '选择用户'}}</text>
 					</view>
-					<navigator :url="'/pages/device/detail/detail?name='+deviceName+'&deviceId='+deviceId+'&rssi='+rssi"
+					
+					<navigator :url="'/pages/device/detail/detail?name='+deviceName+'&deviceId='+deviceId+'&rssi='+rssi+'&id='+id"
 						class="plus">
 						<image v-if="picMode===1" src="/static/public/view-list.png" mode="aspectFill" class="menuimg"></image>
 					</navigator>
@@ -49,7 +51,12 @@
 
 				<!-- 方案选择 -->
 				<view class="planSection" v-if="modeType != 0 && picMode == 1">
-					<text class="planSectionTitle">选择方案</text>
+					<view class="planSectionHeader">
+						<text class="planSectionTitle">选择方案</text>
+						<view class="planAddBtn" v-if="modeType == 2" @click="goAddCustomPlan">
+							<text class="planAddBtnTxt">+ 新增</text>
+						</view>
+					</view>
 					<view class="planCard" @click="showPlanDrawer">
 						<view class="planCardLeft">
 							<text class="planCardName">{{selectedPlan ? selectedPlan.name : '点击选择方案'}}</text>
@@ -89,9 +96,9 @@
 					<view class="modeBtn" v-if="picMode===2" @click="picModeChange(1)" :class="picMode===1?'curBtn':''">
 						<text class="modeBtnTxt" :style="{color:picMode===1?'#545F71':'#fff'}">设备</text>
 					</view>
-					<view class="modeBtn" @click="picModeChange(2)" :class="picMode===2?'curBtn':''">
+					<!--<view class="modeBtn" @click="picModeChange(2)" :class="picMode===2?'curBtn':''">
 						<text class="modeBtnTxt" :style="{color:picMode===2?'#545F71':'#fff'}">穴位</text>
-					</view>
+					</view>-->
 				</view>
 
 				<!-- 穴位图 -->
@@ -267,8 +274,8 @@
 					<text class="planEmptyTxt">加载中...</text>
 				</view>
 				<view class="planItem" :class="selectedPlan && selectedPlan.id === item.id ? 'planItemActive' : ''"
-					v-for="(item, index) in planList" :key="index" @click="selectPlan(item)">
-					<view class="planItemInfo">
+					v-for="(item, index) in planList" :key="index">
+					<view class="planItemInfo" @click="selectPlan(item)">
 						<text class="planItemName">{{item.name}}</text>
 						<text class="planItemDesc" v-if="item.description">{{item.description}}</text>
 						<view class="planItemMeta" v-if="item.steps || item.duration">
@@ -276,7 +283,11 @@
 							<text class="planMetaTxt" v-if="item.duration">约{{item.duration}}分钟</text>
 						</view>
 					</view>
-					<view class="planItemCheck">
+					<view class="planItemActions" v-if="modeType == 2">
+						<text class="planActionBtn planEditBtn" @click="goEditCustomPlan(item)">编辑</text>
+						<text class="planActionBtn planDeleteBtn" @click="confirmDeletePlan(item)">删除</text>
+					</view>
+					<view class="planItemCheck" v-else @click="selectPlan(item)">
 						<view class="checkboxCustom">
 							<view v-if="selectedPlan && selectedPlan.id === item.id" class="checkboxCircle"></view>
 						</view>
@@ -324,6 +335,7 @@ import acupointMixin from './mixins/acupoint-mixin.js'
 import planMixin from './mixins/plan-mixin.js'
 import { useBleStore } from '@/stores/ble'
 import { CHAIR_ANGLE } from '@/utils/ble/constants.js'
+import { getProfile } from '@/utils/api/user.js'
 
 export default {
 	components: {
@@ -337,10 +349,15 @@ export default {
 			statusBarHeight: 44,
 			deviceId: '',
 			deviceName: '',
+			deviceType:'',//1=家庭设备,2=公共设备'
+			id:'',
 			rssi: '',
+			advertisData: '',
 			isShowDrawer2: false,
 			modelist: ['无艾灸模式', '专业模式', '自定义模式', '延年圣手模式'],
-			currentUserName: ''
+			currentUserName: '',
+			groupId: '',
+			profileId: ''
 		}
 	},
 	computed: {
@@ -369,6 +386,11 @@ export default {
 		this.deviceId = decodeURIComponent(options.deviceId || '')
 		this.deviceName = decodeURIComponent(options.name || '')
 		this.rssi = decodeURIComponent(options.rssi || '')
+		this.id= decodeURIComponent(options.id || '')
+		this.deviceType=decodeURIComponent(options.deviceType || '')
+		this.advertisData = decodeURIComponent(options.advertisData || '')
+		this.groupId = decodeURIComponent(options.groupId || '')
+		this.profileId = decodeURIComponent(options.profileId || '')
 
 		// TODO: 后续接口确定后实现 - 下载穴位列表
 		// TODO: 后续接口确定后实现 - 下载专业模式推荐方案
@@ -380,9 +402,14 @@ export default {
 	onShow() {
 		// 返回页面时刷新当前用户
 		this.loadCurrentUser()
+		// 自定义模式下刷新方案列表(可能从新增/编辑页返回)
+		if (this.modeType === 2) {
+			this.loadPlanList(2)
+		}
 	},
 	onUnload() {
-		this.destroyAudio()
+		// 离开页面时解绑音频事件(音频继续播放,不销毁)
+		this.detachAudio()
 		// 离开页面时不断开蓝牙连接,仅停止扫描
 		// 下次进入相同设备可复用连接
 		this.cleanupOnLeave()
@@ -397,19 +424,48 @@ export default {
 			uni.navigateBack()
 		},
 		/** 加载当前用户信息 */
-		loadCurrentUser() {
-			const user = uni.getStorageSync('current_manage_user')
-			if (user && user.name) {
-				this.currentUserName = user.name
+		async loadCurrentUser() {
+			if (this.deviceType === '2') {
+				// 公共设备:通过接口获取当前账号个人资料
+				try {
+					const profile = await getProfile()
+					if (profile && profile.name) {
+						this.currentUserName = profile.name
+						this.profileId = profile.profileId || profile.id || ''
+					} else {
+						this.currentUserName = ''
+						this.profileId = ''
+					}
+				} catch (e) {
+					console.error('获取个人资料失败:', e)
+					this.currentUserName = ''
+					this.profileId = ''
+				}
 			} else {
-				this.currentUserName = ''
+				// 家庭设备:从本地缓存读取群组当前用户
+				const user = uni.getStorageSync('current_manage_user')
+				if (user && user.name) {
+					this.currentUserName = user.name
+					this.profileId = user.id || ''
+				} else {
+					this.currentUserName = ''
+					this.profileId = ''
+				}
 			}
 		},
 		/** 跳转用户管理页 */
 		goUserManage() {
-			uni.navigateTo({
-				url: '/pages/device/userManage/userManage'
-			})
+			if (this.deviceType === '2') {
+				// 公共设备:跳转体形参数页编辑个人资料
+				uni.navigateTo({
+					url: '/pages/device/bodyParams/bodyParams?mode=edit&source=profile'
+				})
+			} else {
+				// 家庭设备:跳转用户管理页
+				uni.navigateTo({
+					url: '/pages/device/userManage/userManage?groupId=' + encodeURIComponent(this.groupId || '')
+				})
+			}
 		},
 		// 座椅角度控制 - 来自 l-sliderRange 组件
 		onSliderAngleChange(val) {
@@ -869,8 +925,25 @@ export default {
 .planSectionTitle {
 	font-size: 28rpx;
 	color: #9BA5B7;
+}
+.planSectionHeader {
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
 	margin-bottom: 16rpx;
 }
+.planAddBtn {
+	background-color: #389588;
+	border-radius: 24rpx;
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+	padding-top: 8rpx;
+	padding-bottom: 8rpx;
+}
+.planAddBtnTxt {
+	font-size: 24rpx;
+	color: #FFFFFF;
+}
 .planCard {
 	flex-direction: row;
 	justify-content: space-between;
@@ -978,6 +1051,28 @@ export default {
 	width: 50rpx;
 	align-items: center;
 }
+/* 方案操作按钮 */
+.planItemActions {
+	flex-direction: row;
+	align-items: center;
+}
+.planActionBtn {
+	font-size: 24rpx;
+	padding-left: 16rpx;
+	padding-right: 16rpx;
+	padding-top: 8rpx;
+	padding-bottom: 8rpx;
+	border-radius: 16rpx;
+	margin-left: 10rpx;
+}
+.planEditBtn {
+	color: #389588;
+	background-color: rgba(56,149,136,0.1);
+}
+.planDeleteBtn {
+	color: #F65252;
+	background-color: rgba(246,82,82,0.1);
+}
 /* 弹框样式 */
 .tip1 {
 	font-size: 28rpx;

+ 2 - 1
code/ajyApp/pages/device/deviceInfo/mixins/acupoint-mixin.js

@@ -20,8 +20,9 @@ export default {
 		picModeChange(value) {
 			if (value === 2) {
 				// 穴位编辑跳转到独立vue页面(支持更灵活的坐标渲染)
+				const profileId = this.profileId || ''
 				uni.navigateTo({
-					url: '/pages/device/acupointEdit/acupointEdit'
+					url: '/pages/device/acupointEdit/acupointEdit?profileId=' + encodeURIComponent(profileId)
 				})
 				return
 			}

+ 38 - 56
code/ajyApp/pages/device/deviceInfo/mixins/audio-mixin.js

@@ -1,84 +1,66 @@
 /**
  * 音频播放 mixin
- * 负责:白噪音初始化、播放/暂停、切换音源
+ * 代理 audioStore,实现跨页面音频持久化播放
+ * 退出页面后音频继续播放,再次进入页面恢复播放状态
  */
-let innerAudioContext = null
+import { useAudioStore } from '@/stores/audio'
 
 export default {
-	data() {
-		return {
-			audiolist: [
-				{ name: '河流', posterimg: '/static/device/heliu.png', url: '/static/audio/audio1.mp3' },
-				{ name: '下雨', posterimg: '/static/device/heliu.png', url: '/static/audio/audio2.mp3' }
-			],
-			curAudioInx: 0,
-			totalTime: 0,
-			currentTime: 0,
-			ispause: true,
-			isShowDrawer3: false
+	computed: {
+		_audioStore() {
+			return useAudioStore()
+		},
+		audiolist() {
+			return this._audioStore.audiolist
+		},
+		curAudioInx: {
+			get() { return this._audioStore.curAudioInx },
+			set(val) { this._audioStore.curAudioInx = val }
+		},
+		totalTime() {
+			return this._audioStore.totalTime
+		},
+		currentTime() {
+			return this._audioStore.currentTime
+		},
+		ispause() {
+			return this._audioStore.ispause
+		},
+		isShowDrawer3: {
+			get() { return this._audioStore.isShowDrawer3 },
+			set(val) { this._audioStore.isShowDrawer3 = val }
 		}
 	},
 	methods: {
 		initAudio() {
-			innerAudioContext = uni.createInnerAudioContext()
-			innerAudioContext.src = '/static/audio/audio1.mp3'
-			innerAudioContext.loop = true
-			innerAudioContext.onPlay(() => {
-				this.totalTime = innerAudioContext.duration
-			})
-			innerAudioContext.onCanplay(() => {
-				this.totalTime = innerAudioContext.duration
-			})
-			innerAudioContext.onError((res) => {
-				console.log('播放错误', res.errMsg)
-			})
-			innerAudioContext.onTimeUpdate(() => {
-				this.currentTime = innerAudioContext.currentTime
-			})
+			this._audioStore.initAudio()
 		},
 
 		formatTime(totalSeconds) {
-			totalSeconds = Math.floor(totalSeconds)
-			const minutes = Math.floor(totalSeconds / 60)
-			const seconds = totalSeconds % 60
-			return minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0')
+			return this._audioStore.formatTime(totalSeconds)
 		},
 
 		changeAudio(url, inx) {
-			this.curAudioInx = inx
-			if (innerAudioContext) {
-				innerAudioContext.stop()
-				setTimeout(() => {
-					innerAudioContext.src = url
-					this.ispause = true
-					this.playAudio()
-				}, 500)
-			}
+			this._audioStore.changeAudio(url, inx)
 		},
 
 		playAudio() {
-			if (!innerAudioContext) return
-			if (this.ispause) {
-				innerAudioContext.play()
-			} else {
-				innerAudioContext.pause()
-			}
-			this.ispause = !this.ispause
+			this._audioStore.playAudio()
 		},
 
 		/** 供 ble-mixin 模式切换时调用 */
 		_stopAudioOnModeChange() {
-			if (innerAudioContext) {
-				innerAudioContext.stop()
-			}
+			this._audioStore.stopAudio()
+		},
+
+		/** 页面离开时解绑事件(音频继续播放) */
+		detachAudio() {
+			this._audioStore.detachEvents()
 		},
 
+		/** 彻底销毁(一般不调用,仅供极端场景) */
 		destroyAudio() {
-			if (innerAudioContext) {
-				innerAudioContext.stop()
-				innerAudioContext.destroy()
-				innerAudioContext = null
-			}
+			this._audioStore.destroyAudio()
 		}
 	}
 }

+ 37 - 7
code/ajyApp/pages/device/deviceInfo/mixins/ble-mixin.js

@@ -78,10 +78,12 @@ export default {
 			}
 
 			// 2. 判断当前是否已连接同一设备,如果是则跳过重连
+			// 使用 advertisData(= 后台 deviceCode)作为跨平台设备唯一标识
 			const currentDevice = this.bleStore.device
-			console.log('[BLE] 当前设备', currentDevice,this.bleStore.linked,this.deviceId)
-			if (this.bleStore.linked && currentDevice && currentDevice.deviceId === this.deviceId) {
-				console.log('[BLE] 已连接相同设备,跳过重连', this.deviceId)
+			
+			console.log('[BLE] 当前设备', currentDevice, this.bleStore.linked, this.advertisData)
+			if (this.bleStore.linked && currentDevice && this.advertisData && currentDevice.advertisData === this.advertisData) {
+				console.log('[BLE] 已连接相同设备(advertisData匹配),跳过重连', this.advertisData)
 				return
 			}
 
@@ -89,17 +91,45 @@ export default {
 			try { await this.bleStore.disconnect() } catch (_) {}
 			try { await this.bleStore.resetAdapter() } catch (_) {}
 
-			// 4. 扫描并连接设备(优先直连,跳过扫描避免 Android 限流)
+				// 4. 扫描并连接设备
+			// 注意:this.deviceId 是后台数据库ID,不是BLE设备ID,不能用于直连
+			// 优先从缓存中获取上次成功连接的真实BLE deviceId
 			try {
+				let realBleDeviceId = ''
+				if (this.advertisData) {
+					// 优先从bleStore当前设备获取(可能连接已断但device对象仍在)
+					if (currentDevice && currentDevice.advertisData === this.advertisData && currentDevice.deviceId) {
+						realBleDeviceId = currentDevice.deviceId
+					}
+					// 其次从本地缓存获取
+					if (!realBleDeviceId) {
+						try {
+							realBleDeviceId = uni.getStorageSync('ble_deviceId_' + this.advertisData) || ''
+							console.log("当前设备id",realBleDeviceId)
+						} catch (_) {}
+					}
+				}
+
 				const scanOpt = { timeout: 8000 }
-				// 已有 deviceId 时优先直连,不需要扫描
-				if (this.deviceId) {
-					scanOpt.deviceId = this.deviceId
+				// 用真实BLE deviceId直连,避免扫描(减少Android限流风险)
+				if (realBleDeviceId) {
+					scanOpt.deviceId = realBleDeviceId
 				}
 				if (this.deviceName) {
 					scanOpt.deviceName = this.deviceName
 				}
+				// 传递 advertisData 用于连接后存储到 device 对象
+				if (this.advertisData) {
+					scanOpt.advertisData = this.advertisData
+				}
+				console.log("当前连接蓝牙参数",scanOpt)
 				await this.bleStore.scanAndConnect(scanOpt)
+				// 连接成功后缓存真实BLE deviceId,供下次直连使用
+				if (this.advertisData && this.bleStore.device && this.bleStore.device.deviceId) {
+					try {
+						uni.setStorageSync('ble_deviceId_' + this.advertisData, this.bleStore.device.deviceId)
+					} catch (_) {}
+				}
 				// BLE连接建立后需短暂等待设备就绪再发首条指令
 				await new Promise(r => setTimeout(r, 1500))
 				this._sendCurrentState()

+ 104 - 21
code/ajyApp/pages/device/deviceInfo/mixins/plan-mixin.js

@@ -2,7 +2,7 @@
  * 方案管理 mixin
  * 负责:方案列表加载、子方案选择、模式切换时刷新方案
  */
-import { getCareList, getCareDetail } from '@/utils/api/care.js'
+import { getCustomPlanList, getCustomPlanDetail, deleteCustomPlan, getProfessionalPlanList, getProfessionalPlanDetail, getMasterPlanList, getMasterPlanDetail } from '@/utils/api/care.js'
 
 export default {
 	data() {
@@ -36,28 +36,36 @@ export default {
 
 		/**
 		 * 加载指定模式的方案列表
-		 * @param {Number} mode - 1专业 2自定义 3专家
+		 * @param {Number} mode - 1专业 2自定义 3延年圣手
 		 */
 		async loadPlanList(mode) {
 			this.planLoading = true
 			try {
-				// modeType映射为后端模式标识
-				const modeMap = {
-					1: 'PROFESSIONAL',
-					2: 'CUSTOM',
-					3: 'EXPERT'
-				}
-				const res = await getCareList({ modeType: modeMap[mode] || '' })
-				if (res && res.records) {
-					this.planList = res.records.map(item => ({
-						id: item.id,
-						name: item.title || item.name || '未命名方案',
-						description: item.description || item.remark || '',
-						steps: item.stepCount || item.steps || 0,
-						duration: item.totalDuration || 0
-					}))
-				} else {
-					this.planList = []
+				let res
+				if (mode === 2) {
+					// 自定义模式 - 使用自定义方案列表接口
+					res = await getCustomPlanList()
+					if (res && Array.isArray(res)) {
+						this.planList = res.map(item => ({
+							id: item.id,
+							name: item.name || '未命名方案',
+							description: item.description || '',
+							symptoms: item.symptoms || '',
+							status: item.status,
+							useCount: item.useCount || 0,
+							avgRating: item.avgRating
+						}))
+					} else {
+						this.planList = []
+					}
+				} else if (mode === 1) {
+					// 专业模式 - 使用专业模式方案列表接口
+					res = await getProfessionalPlanList()
+					this.planList = this._mapSystemPlanList(res)
+				} else if (mode === 3) {
+					// 延年圣手模式 - 使用延年圣手方案列表接口
+					res = await getMasterPlanList()
+					this.planList = this._mapSystemPlanList(res)
 				}
 			} catch (e) {
 				console.error('加载方案列表失败', e)
@@ -66,6 +74,30 @@ export default {
 			this.planLoading = false
 		},
 
+		/** 映射系统方案列表数据(专业模式/延年圣手模式通用) */
+		_mapSystemPlanList(res) {
+			if (res && Array.isArray(res)) {
+				return res.map(item => ({
+					id: item.id,
+					name: item.name || item.title || '未命名方案',
+					description: item.description || item.remark || '',
+					steps: item.stepCount || item.steps || 0,
+					duration: item.totalDuration || item.duration || 0
+				}))
+			}
+			// 兼容分页结构
+			if (res && res.records) {
+				return res.records.map(item => ({
+					id: item.id,
+					name: item.name || item.title || '未命名方案',
+					description: item.description || item.remark || '',
+					steps: item.stepCount || item.steps || 0,
+					duration: item.totalDuration || item.duration || 0
+				}))
+			}
+			return []
+		},
+
 		/** 打开方案选择弹窗 */
 		showPlanDrawer() {
 			if (this.planList.length === 0 && this.modeType !== 0) {
@@ -82,18 +114,69 @@ export default {
 			this._applyPlanToCase(plan)
 		},
 
+		/** 跳转新增自定义方案 */
+		goAddCustomPlan() {
+			uni.navigateTo({
+				url: `/pages/device/acupointEdit/acupointEdit?profileId=${this.profileId}&mode=create`
+			})
+		},
+
+		/** 跳转编辑自定义方案 */
+		goEditCustomPlan(plan) {
+			this.isShowPlanDrawer = false
+			uni.navigateTo({
+				url: `/pages/device/acupointEdit/acupointEdit?profileId=${this.profileId}&mode=edit&planId=${plan.id}`
+			})
+		},
+
+		/** 删除自定义方案 */
+		confirmDeletePlan(plan) {
+			uni.showModal({
+				title: '确认删除',
+				content: `确定删除方案「${plan.name}」?删除后不可恢复。`,
+				success: async (res) => {
+					if (res.confirm) {
+						try {
+							await deleteCustomPlan(plan.id)
+							uni.showToast({ title: '删除成功', icon: 'success' })
+							// 从列表中移除
+							this.planList = this.planList.filter(p => p.id !== plan.id)
+							// 如果删除的是当前选中的方案,清空选择
+							if (this.selectedPlan && this.selectedPlan.id === plan.id) {
+								this.selectedPlan = null
+							}
+						} catch (e) {
+							console.error('删除方案失败', e)
+							uni.showToast({ title: '删除失败', icon: 'none' })
+						}
+					}
+				}
+			})
+		},
+
 		/**
 		 * 将方案数据应用到当前穴位配置
-		 * TODO: 后续对接方案详情接口,获取具体步骤和穴位坐标
 		 */
 		async _applyPlanToCase(plan) {
 			if (!plan || !plan.id) return
 			try {
-				const res = await getCareDetail(plan.id)
+				let res
+				if (this.modeType === 2) {
+					// 自定义模式 - 使用自定义方案详情接口
+					res = await getCustomPlanDetail(plan.id)
+				} else if (this.modeType === 1) {
+					// 专业模式 - 使用专业模式方案详情接口
+					res = await getProfessionalPlanDetail(plan.id)
+				} else if (this.modeType === 3) {
+					// 延年圣手模式 - 使用延年圣手方案详情接口
+					res = await getMasterPlanDetail(plan.id)
+				}
 				if (res && res.steps) {
 					this.curCase = res.steps.map(step => ({
 						id: step.acupointId || step.id,
+						name: step.acupointName || '',
 						time: step.duration || 15,
+						temperature: step.temperature || 42,
 						_x: step.x || 0,
 						_y: step.y || 0
 					}))

+ 16 - 11
code/ajyApp/pages/device/list/list.nvue

@@ -12,8 +12,8 @@
 			<!-- 设备卡片 -->
 			<view class="device-card" v-for="(item, index) in list" :key="index" @click="goDeviceInfo(item)">
 				<image class="device-icon" src="/static/device/device1.png" mode="heightFix"></image>
-				<text class="device-name">{{item.name}}</text>
-				<text class="device-status"></text>
+				<text class="device-name">{{item.deviceName || item.name}}</text>
+
 			</view>
 			<!-- 添加设备卡片 -->
 			<view class="device-card add-card" @click="goAddDevice">
@@ -33,6 +33,7 @@
 <script>
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
 import { useBleStore } from '@/stores/ble'
+import { getDeviceList } from '@/utils/api/device'
 
 export default {
 	components: {
@@ -55,17 +56,21 @@ export default {
 		this.init()
 	},
 	methods: {
-		init() {
-			// 从本地存储读取设备列表
+		async init() {
 			try {
-				const data = uni.getStorageSync('deviceList')
-				if (data && data.length > 0) {
-					this.list = data
-				} else {
-					this.list = []
+				const  deviceList= await getDeviceList()
+				console.log("后台返回列表list",deviceList)
+				if(deviceList&&deviceList.length>0){
+					// for (var index = 0; index < deviceList.length; index++) {
+					// 	deviceList[index].deviceId=deviceList[index].deviceCode;
+					// }
+					console.log("后台返回列表list有值")
+					this.list = deviceList
 				}
+				// 同步到本地缓存,供其他页面使用
+				uni.setStorageSync('deviceList', this.list)
 			} catch (e) {
-				this.list = []
+				console.log('获取设备列表失败:', e)
 			}
 		},
 		goAddDevice() {
@@ -75,7 +80,7 @@ export default {
 		},
 		goDeviceInfo(item) {
 			uni.navigateTo({
-				url: '/pages/device/deviceInfo/deviceInfo?name=' + encodeURIComponent(item.name || '') + '&deviceId=' + encodeURIComponent(item.deviceId || '') + '&rssi=' + encodeURIComponent(item.RSSI || '')
+				url: '/pages/device/deviceInfo/deviceInfo?name=' + encodeURIComponent(item.deviceName || item.name || '') + '&deviceId=' + encodeURIComponent(item.deviceId || '') + '&advertisData=' + encodeURIComponent(item.deviceCode || '') + '&rssi=' + encodeURIComponent(item.RSSI || '')+'&id=' + encodeURIComponent(item.id || '')+'&deviceType='+encodeURIComponent(item.deviceType || '')+'&groupId='+encodeURIComponent(item.groupId || '')+'&profileId='+encodeURIComponent(item.profileId || '')
 			})
 		},
 		/** 如果设备已连接,实时获取RSSI并更新缓存 */

+ 52 - 18
code/ajyApp/pages/device/search/search.nvue

@@ -27,7 +27,8 @@
 			<view class="device-item" v-for="(item, index) in deviceList" :key="index" @click="onSelectDevice(item)">
 				<view class="device-info">
 					<text class="device-name">{{item.name || '未知设备'}}</text>
-					<text class="device-id">{{item.deviceId}}</text>
+					<!-- <text class="device-id">{{item.deviceId}}</text> -->
+					<text class="device-id">设备编码:{{item.advertisData}}</text>
 				</view>
 				<view class="device-right">
 					<!-- 信号强度图标 -->
@@ -53,6 +54,7 @@
 <script>
 import { useBleStore } from '@/stores/ble'
 import { ensureBlePrerequisite, openLocationSettings, openBluetoothSettings } from '@/utils/ble/permission.js'
+import { bindDeviceByCode, getDeviceDetail } from '@/utils/api/device.js'
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
 
 export default {
@@ -73,6 +75,7 @@ export default {
 			return this.bleStore.searching
 		},
 		deviceList() {
+			console.log("scannedDevices	",this.bleStore.scannedDevices)
 			return this.bleStore.scannedDevices
 		},
 		scanThrottled() {
@@ -97,6 +100,7 @@ export default {
 		},
 		async startScan() {
 			try {
+				console.log("重新扫描")
 				await ensureBlePrerequisite()
 				// startScan 内部自动初始化适配器,扫描结果自动更新 store.scannedDevices
 				await this.bleStore.startScan({ timeout: 15000, returnAll: true })
@@ -148,34 +152,64 @@ export default {
 			if (val >= -95) return 1
 			return 1
 		},
-		onSelectDevice(device) {
+		async onSelectDevice(device) {
+			console.log("要操作的设备",device)
 			let deviceList = []
 			try {
+				// uni.removeStorageSync('deviceList')
 				const data = uni.getStorageSync('deviceList')
+				console.log("deviceList",data)
 				if (data && data.length > 0) {
 					deviceList = data
 				}
 			} catch (e) {}
 
-			const exists = deviceList.find(item => item.deviceId === device.deviceId)
-			if (exists) {
-				this.$refs.ayToast.show('该设备已添加')
-				return
-			}
+			// 使用 advertisData/deviceCode 做重复判断(跨平台唯一标识)
+			// const exists = deviceList.find(item => {
+			// 	const itemCode = item.advertisData || item.deviceCode || ''
+			// 	return itemCode && itemCode === device.advertisData
+			// })
+			// if (exists) {
+			// 	this.$refs.ayToast.show('该设备已添加')
+			// 	return
+			// }
+
+			try {
+				// 调用后端接口绑定设备,advertisData作为deviceCode传递
+				const res = await bindDeviceByCode(device.advertisData)
+				console.log('绑定设备成功', res)
 
-			deviceList.push({
-				deviceId: device.deviceId,
-				name: device.name || '艾灸椅',
-				RSSI: device.RSSI
-			})
-			uni.setStorageSync('deviceList', deviceList)
-			this.$refs.ayToast.success('设备添加成功')
+				// 调用设备详情接口获取deviceType
+				let deviceType = ''
+				try {
+					const detail = await getDeviceDetail(res.deviceId)
+					deviceType = detail.deviceType || ''
+					console.log('获取设备类型', deviceType)
+				} catch (detailErr) {
+					console.error('获取设备详情失败', detailErr)
+				}
 
-			setTimeout(() => {
-				uni.redirectTo({
-					url: `/pages/device/deviceInfo/deviceInfo?deviceId=${encodeURIComponent(device.deviceId)}&name=${encodeURIComponent(device.name || '艾灸椅')}&rssi=${device.RSSI || ''}`
+				// 后端绑定成功后,保存到本地
+				deviceList.push({
+					deviceId: res.deviceId,
+					name: device.name || '艾灸椅',
+					RSSI: device.RSSI,
+					id: res.deviceId,
+					deviceType: deviceType,
+					advertisData: device.advertisData
 				})
-			}, 1000)
+				uni.setStorageSync('deviceList', deviceList)
+				this.$refs.ayToast.success('设备添加成功')
+
+				setTimeout(() => {
+					uni.redirectTo({
+						url: `/pages/device/deviceInfo/deviceInfo?deviceId=${encodeURIComponent(res.deviceId)}&advertisData=${encodeURIComponent(device.advertisData)}&name=${encodeURIComponent(device.name || '艾灸椅')}&rssi=${device.RSSI || ''}&deviceType=${deviceType}&id=${encodeURIComponent(res.deviceId)}`
+					})
+				}, 1000)
+			} catch (e) {
+				console.error('绑定设备失败', e)
+				// 错误提示由http模块统一处理,这里不再重复提示
+			}
 		}
 	}
 }

+ 48 - 31
code/ajyApp/pages/device/userManage/userManage.nvue

@@ -15,8 +15,8 @@
 			<text class="tipTxt">最多可添加10个用户,点击卡片切换用户</text>
 			<scroll-view class="userList" scroll-y="true">
 				<view class="listInner">
-				<view class="cardWrap" :class="{'curCardWrap': item.id === currentUserId}"
-					v-for="(item, index) in userList" :key="item.id" @click="switchUser(item)">
+				<view class="cardWrap" :class="{'curCardWrap': item.profileId === currentUserId}"
+					v-for="(item, index) in userList" :key="item.profileId" @click="switchUser(item)">
 					<view class="userCard">
 						<view class="userInfo">
 							<view class="avatarWrap">
@@ -29,7 +29,7 @@
 									<text class="userAge" v-if="item.age">{{item.age}}岁</text>
 								</view>
 								<view class="bodyRow">
-									<text class="bodyTxt">肩高:{{item.shoulderHeight || '--'}}cm</text>
+									<text class="bodyTxt">身高:{{item.height || '--'}}cm</text>
 									<text class="bodyTxt">肩宽:{{item.shoulderWidth || '--'}}cm</text>
 									<text class="bodyTxt">体重:{{item.weight || '--'}}kg</text>
 								</view>
@@ -40,7 +40,7 @@
 							<text class="editBtn" @click.stop="editUser(item)">编辑</text>
 							<text class="deleteBtn" @click.stop="confirmDelete(item)">删除</text>
 						</view>
-						<view class="curTag" v-if="item.id === currentUserId">
+						<view class="curTag" v-if="item.profileId === currentUserId">
 							<text class="curTagTxt">当前</text>
 						</view>
 					</view>
@@ -72,6 +72,7 @@
 <script>
 import customPopup from '@/components/customPopup/customPopup.nvue'
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
+import { getGroupMembers, deleteGroupMember } from '@/utils/api/group.js'
 
 export default {
 	components: {
@@ -81,15 +82,17 @@ export default {
 	data() {
 		return {
 			statusBarHeight: 44,
+			groupId: '',
 			userList: [],
 			currentUserId: '',
 			showDeletePopup: false,
 			deleteTarget: null
 		}
 	},
-	onLoad() {
+	onLoad(options) {
 		const sysInfo = uni.getSystemInfoSync()
 		this.statusBarHeight = sysInfo.statusBarHeight || 44
+		this.groupId = options.groupId || ''
 	},
 	onShow() {
 		this.loadUsers()
@@ -98,21 +101,30 @@ export default {
 		goBack() {
 			uni.navigateBack()
 		},
-		/** 从本地存储加载用户列表 */
-		loadUsers() {
-			const list = uni.getStorageSync('user_manage_list') || []
-			this.userList = list
-			this.currentUserId = uni.getStorageSync('current_manage_user_id') || ''
-			// 如果没有当前用户且列表不为空,默认选中第一个
-			if (!this.currentUserId && list.length > 0) {
-				this.currentUserId = list[0].id
-				uni.setStorageSync('current_manage_user_id', this.currentUserId)
+		/** 从后端加载群组成员列表 */
+		async loadUsers() {
+			if (!this.groupId) {
+				this.$refs.ayToast.error('缺少群组信息')
+				return
+			}
+			try {
+				const list = await getGroupMembers(this.groupId)
+				this.userList = list || []
+				// 读取本地缓存的当前用户
+				this.currentUserId = uni.getStorageSync('current_manage_user_id') || ''
+				// 如果没有当前用户且列表不为空,默认选中第一个
+				if (!this.currentUserId && this.userList.length > 0) {
+					this.currentUserId = this.userList[0].profileId
+					uni.setStorageSync('current_manage_user_id', this.currentUserId)
+				}
+			} catch (e) {
+				console.error('加载群组成员失败:', e)
 			}
 		},
 		/** 切换用户 */
 		switchUser(item) {
-			this.currentUserId = item.id
-			uni.setStorageSync('current_manage_user_id', item.id)
+			this.currentUserId = item.profileId
+			uni.setStorageSync('current_manage_user_id', item.profileId)
 			uni.setStorageSync('current_manage_user', item)
 			this.$refs.ayToast.success('已切换至用户:' + item.name)
 			setTimeout(() => {
@@ -126,13 +138,13 @@ export default {
 				return
 			}
 			uni.navigateTo({
-				url: '/pages/device/bodyParams/bodyParams?mode=add'
+				url: '/pages/device/bodyParams/bodyParams?mode=add&groupId=' + encodeURIComponent(this.groupId)
 			})
 		},
 		/** 编辑用户 */
 		editUser(item) {
 			uni.navigateTo({
-				url: '/pages/device/bodyParams/bodyParams?mode=edit&userId=' + item.id
+				url: '/pages/device/bodyParams/bodyParams?mode=edit&groupId=' + encodeURIComponent(this.groupId) + '&profileId=' + encodeURIComponent(item.profileId)
 			})
 		},
 		/** 确认删除 */
@@ -147,23 +159,28 @@ export default {
 		/** 录入指纹 */
 		enrollFingerprint(item) {
 			this.$refs.ayToast.loading("指纹录入中", { mask: false })
-
 		},
 		/** 执行删除 */
-		doDelete() {
+		async doDelete() {
 			if (!this.deleteTarget) return
-			const id = this.deleteTarget.id
-			this.userList = this.userList.filter(u => u.id !== id)
-			uni.setStorageSync('user_manage_list', this.userList)
-			// 如果删除的是当前用户,切换到第一个
-			if (this.currentUserId === id && this.userList.length > 0) {
-				this.currentUserId = this.userList[0].id
-				uni.setStorageSync('current_manage_user_id', this.currentUserId)
-				uni.setStorageSync('current_manage_user', this.userList[0])
+			const profileId = this.deleteTarget.profileId
+			try {
+				await deleteGroupMember(this.groupId, profileId)
+				// 删除成功,刷新列表
+				this.userList = this.userList.filter(u => u.profileId !== profileId)
+				// 如果删除的是当前用户,切换到第一个
+				if (this.currentUserId === profileId && this.userList.length > 0) {
+					this.currentUserId = this.userList[0].profileId
+					uni.setStorageSync('current_manage_user_id', this.currentUserId)
+					uni.setStorageSync('current_manage_user', this.userList[0])
+				}
+				this.showDeletePopup = false
+				this.deleteTarget = null
+				this.$refs.ayToast.success('删除成功')
+			} catch (e) {
+				this.showDeletePopup = false
+				console.error('删除成员失败:', e)
 			}
-			this.showDeletePopup = false
-			this.deleteTarget = null
-			this.$refs.ayToast.success('删除成功')
 		}
 	}
 }

+ 1 - 0
code/ajyApp/pages/login/login.nvue

@@ -215,6 +215,7 @@ export default {
 			toast.loading('登录中...')
 			try {
 				const data = await login(this.phone, this.code)
+				console.log("获取到的token",data.token)
 				// 存储 Token
 				http.setToken(data.token)
 				// 存储用户基本信息

+ 210 - 6
code/ajyApp/pages/my/version/version.nvue

@@ -16,16 +16,45 @@
 			<text class="version-code">V{{localVersion}}</text>
 			<text class="check-btn" @click="checkUpdate">检查更新</text>
 		</view>
+
+		<!-- 更新弹窗 -->
+		<view class="update-modal" v-if="showUpdateDialog">
+			<view class="update-mask" @click="closeUpdateDialog"></view>
+			<view class="update-dialog">
+				<text class="update-title">发现新版本</text>
+				<text class="update-version">V{{updateInfo.versionCode}}</text>
+				<text class="update-size" v-if="updateInfo.fileSize">{{updateInfo.fileSize}}</text>
+				<view class="update-content-box">
+					<text class="update-content">{{updateInfo.updateContent || '更新了一些功能和体验优化'}}</text>
+				</view>
+				<!-- 下载进度条 -->
+				<view class="progress-box" v-if="downloading">
+					<view class="progress-bar">
+						<view class="progress-fill" :style="{width: downloadProgress + '%'}"></view>
+					</view>
+					<text class="progress-text">{{downloadProgress}}%</text>
+				</view>
+				<text class="update-btn" @click="doUpdate" v-if="!downloading">立即更新</text>
+				<text class="update-btn update-btn-disabled" v-if="downloading">下载中...</text>
+				<text class="update-later" v-if="updateInfo.updateType !== 1 && !downloading" @click="closeUpdateDialog">稍后再说</text>
+			</view>
+		</view>
 	</view>
 </template>
 
 <script>
+import { checkUpdate } from '@/utils/api/version.js'
+
 export default {
 	data() {
 		return {
 			statusBarHeight: 44,
 			localVersion: '1.0.0',
-			platform: 'android'
+			platform: 'android',
+			showUpdateDialog: false,
+			updateInfo: {},
+			downloading: false,
+			downloadProgress: 0
 		}
 	},
 	onLoad() {
@@ -42,12 +71,95 @@ export default {
 		goBack() {
 			uni.navigateBack()
 		},
-		checkUpdate() {
-			// TODO: 后续对接后台接口检查更新
-			uni.showToast({
-				title: '已是最新版本',
-				icon: 'none'
+		async checkUpdate() {
+			const platformCode = this.platform === 'ios' ? 2 : 1
+			try {
+				const res = await checkUpdate(platformCode, this.localVersion)
+				console.log(res)
+				if (res && res.hasUpdate) {
+					this.updateInfo = res
+					this.showUpdateDialog = true
+					this.downloading = false
+					this.downloadProgress = 0
+				} else {
+					uni.showToast({
+						title: '已是最新版本',
+						icon: 'none'
+					})
+				}
+			} catch (e) {
+				uni.showToast({
+					title: '检查更新失败',
+					icon: 'none'
+				})
+			}
+		},
+		closeUpdateDialog() {
+			// 强制更新时不允许关闭
+			if (this.updateInfo.updateType === 1) {
+				return
+			}
+			this.showUpdateDialog = false
+		},
+		doUpdate() {
+			if (this.platform === 'ios') {
+				this.openAppStore()
+			} else {
+				this.downloadAndInstall()
+			}
+		},
+		// iOS: 跳转App Store
+		openAppStore() {
+			const url = this.updateInfo.downloadUrl
+			if (!url) {
+				uni.showToast({ title: '下载地址无效', icon: 'none' })
+				return
+			}
+			// #ifdef APP-PLUS
+			plus.runtime.openURL(url, (err) => {
+				uni.showToast({ title: '打开App Store失败', icon: 'none' })
+			})
+			// #endif
+		},
+		// Android: 下载APK并安装
+		downloadAndInstall() {
+			console.log("下载地址",this.updateInfo.downloadUrl)
+			const url = this.updateInfo.downloadUrl
+			if (!url) {
+				uni.showToast({ title: '下载地址无效', icon: 'none' })
+				return
+			}
+			this.downloading = true
+			this.downloadProgress = 0
+
+			// #ifdef APP-PLUS
+			const dtask = plus.downloader.createDownload(url, {
+				filename: '_downloads/update.apk'
+			}, (d, status) => {
+				if (status === 200) {
+					// 下载完成,安装APK
+					plus.runtime.install(d.filename, {
+						force: true
+					}, () => {
+						// 安装成功
+						plus.runtime.restart()
+					}, (err) => {
+						this.downloading = false
+						uni.showToast({ title: '安装失败,请重试', icon: 'none' })
+					})
+				} else {
+					this.downloading = false
+					uni.showToast({ title: '下载失败,请重试', icon: 'none' })
+				}
 			})
+			dtask.addEventListener('statechanged', (task, status) => {
+				// state=3 表示正在接收数据
+				if (task.state === 3 && task.totalSize > 0) {
+					this.downloadProgress = Math.round((task.downloadedSize / task.totalSize) * 100)
+				}
+			}, false)
+			dtask.start()
+			// #endif
 		}
 	}
 }
@@ -117,4 +229,96 @@ export default {
 	line-height: 88rpx;
 	text-align: center;
 }
+/* 更新弹窗 */
+.update-modal {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	justify-content: center;
+	align-items: center;
+}
+.update-mask {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+}
+.update-dialog {
+	width: 600rpx;
+	background-color: #FFFFFF;
+	border-radius: 24rpx;
+	padding: 48rpx 40rpx;
+	align-items: center;
+}
+.update-title {
+	font-size: 36rpx;
+	font-weight: bold;
+	color: #333333;
+	margin-bottom: 12rpx;
+}
+.update-version {
+	font-size: 28rpx;
+	color: #999999;
+	margin-bottom: 8rpx;
+}
+.update-size {
+	font-size: 24rpx;
+	color: #BBBBBB;
+	margin-bottom: 24rpx;
+}
+.update-content-box {
+	width: 520rpx;
+	max-height: 300rpx;
+	margin-bottom: 36rpx;
+}
+.update-content {
+	font-size: 28rpx;
+	color: #666666;
+	line-height: 44rpx;
+	lines: 0;
+}
+.progress-box {
+	width: 520rpx;
+	align-items: center;
+	margin-bottom: 24rpx;
+}
+.progress-bar {
+	width: 520rpx;
+	height: 12rpx;
+	background-color: #EEEEEE;
+	border-radius: 6rpx;
+	overflow: hidden;
+}
+.progress-fill {
+	height: 12rpx;
+	background-color: #389588;
+	border-radius: 6rpx;
+}
+.progress-text {
+	font-size: 24rpx;
+	color: #389588;
+	margin-top: 8rpx;
+}
+.update-btn {
+	width: 520rpx;
+	height: 80rpx;
+	background-color: #389588;
+	border-radius: 16rpx;
+	color: #FFFFFF;
+	font-size: 30rpx;
+	line-height: 80rpx;
+	text-align: center;
+}
+.update-btn-disabled {
+	background-color: #AAAAAA;
+}
+.update-later {
+	margin-top: 20rpx;
+	font-size: 26rpx;
+	color: #999999;
+}
 </style>

+ 175 - 0
code/ajyApp/stores/audio.js

@@ -0,0 +1,175 @@
+/**
+ * 音频播放 Pinia Store
+ * 跨页面共享音频播放状态,音频实例存放在 globalData 中确保单例
+ */
+import { defineStore } from 'pinia'
+
+const AUDIO_LIST = [
+	{ name: '河流', posterimg: '/static/device/heliu.png', url: '/static/audio/audio1.mp3' },
+	{ name: '下雨', posterimg: '/static/device/heliu.png', url: '/static/audio/audio2.mp3' }
+]
+
+// 通过 globalData 确保 InnerAudioContext 跨页面单例
+function _getAudioCtx() {
+	const app = getApp()
+	if (!app.globalData) app.globalData = {}
+	return app.globalData._audioContext || null
+}
+
+function _setAudioCtx(ctx) {
+	const app = getApp()
+	if (!app.globalData) app.globalData = {}
+	app.globalData._audioContext = ctx
+}
+
+export const useAudioStore = defineStore('audio', {
+	state: () => ({
+		audiolist: AUDIO_LIST,
+		curAudioInx: 0,
+		totalTime: 0,
+		currentTime: 0,
+		ispause: true, // true=暂停中, false=播放中
+		isShowDrawer3: false
+	}),
+
+	actions: {
+		/** 初始化或恢复音频上下文(页面进入时调用) */
+		initAudio() {
+			let ctx = _getAudioCtx()
+			if (ctx) {
+				// 已有实例,重新绑定事件监听
+				this._bindEvents(ctx)
+				// 同步当前播放状态
+				if (!ctx.paused && ctx.src) {
+					this.ispause = false
+					this.currentTime = ctx.currentTime || 0
+					this.totalTime = ctx.duration || 0
+				}
+				return
+			}
+			// 首次创建
+			ctx = uni.createInnerAudioContext()
+			ctx.src = this.audiolist[this.curAudioInx].url
+			ctx.loop = true
+			_setAudioCtx(ctx)
+			this._bindEvents(ctx)
+		},
+
+		/** 绑定事件监听 */
+		_bindEvents(ctx) {
+			// 先清除旧监听,避免重复绑定(部分平台新建ctx调用off会报错,需保护)
+			try {
+				ctx.offPlay && ctx.offPlay()
+				ctx.offCanplay && ctx.offCanplay()
+				ctx.offError && ctx.offError()
+				ctx.offTimeUpdate && ctx.offTimeUpdate()
+				ctx.offPause && ctx.offPause()
+				ctx.offStop && ctx.offStop()
+			} catch (e) {
+				// 忽略:新创建的ctx可能没有已注册的监听器
+			}
+
+			ctx.onPlay(() => {
+				this.totalTime = ctx.duration || 0
+				this.ispause = false
+			})
+			ctx.onCanplay(() => {
+				this.totalTime = ctx.duration || 0
+			})
+			ctx.onError((res) => {
+				console.log('播放错误', res.errMsg)
+			})
+			ctx.onTimeUpdate(() => {
+				this.currentTime = ctx.currentTime || 0
+			})
+			ctx.onPause(() => {
+				this.ispause = true
+			})
+			ctx.onStop(() => {
+				this.ispause = true
+				this.currentTime = 0
+			})
+		},
+
+		/** 页面离开时解绑事件(不销毁音频) */
+		detachEvents() {
+			const ctx = _getAudioCtx()
+			if (ctx) {
+				try {
+					ctx.offPlay && ctx.offPlay()
+					ctx.offCanplay && ctx.offCanplay()
+					ctx.offError && ctx.offError()
+					ctx.offTimeUpdate && ctx.offTimeUpdate()
+					ctx.offPause && ctx.offPause()
+					ctx.offStop && ctx.offStop()
+				} catch (e) {
+					// 忽略
+				}
+			}
+		},
+
+		/** 切换播放/暂停 */
+		playAudio() {
+			const ctx = _getAudioCtx()
+			if (!ctx) return
+			if (this.ispause) {
+				ctx.play()
+			} else {
+				ctx.pause()
+			}
+		},
+
+		/** 切换音源(切换后主动播放) */
+		changeAudio(url, inx) {
+			this.curAudioInx = inx
+			this.ispause = false
+			const ctx = _getAudioCtx()
+			if (ctx) {
+				ctx.stop()
+				setTimeout(() => {
+					ctx.src = url
+					ctx.play()
+				}, 300)
+			} else {
+				// 音频上下文不存在时,先初始化再播放
+				this.initAudio()
+				const newCtx = _getAudioCtx()
+				if (newCtx) {
+					newCtx.src = url
+					newCtx.play()
+				}
+			}
+		},
+
+		/** 停止播放(模式切换时调用) */
+		stopAudio() {
+			const ctx = _getAudioCtx()
+			if (ctx) {
+				ctx.stop()
+			}
+			this.ispause = true
+			this.currentTime = 0
+		},
+
+		/** 彻底销毁音频(APP退出等极端场景) */
+		destroyAudio() {
+			const ctx = _getAudioCtx()
+			if (ctx) {
+				ctx.stop()
+				ctx.destroy()
+				_setAudioCtx(null)
+			}
+			this.ispause = true
+			this.currentTime = 0
+			this.totalTime = 0
+		},
+
+		/** 格式化时间 */
+		formatTime(totalSeconds) {
+			totalSeconds = Math.floor(totalSeconds || 0)
+			const minutes = Math.floor(totalSeconds / 60)
+			const seconds = totalSeconds % 60
+			return minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0')
+		}
+	}
+})

+ 175 - 20
code/ajyApp/stores/ble.js

@@ -259,6 +259,20 @@ export const useBleStore = defineStore('ble', {
 			})
 			// 永久注册设备发现监听器(不可反复 on/off,否则多次后系统丢失监听)
 			uni.onBluetoothDeviceFound((res) => {
+				const deviceCount = res && res.devices ? res.devices.length : 0
+				console.log(`[BLE Global] onBluetoothDeviceFound 触发, 设备数=${deviceCount}, s.onDeviceFound=${!!s.onDeviceFound}`)
+				// 立即在系统回调内转换 advertisData,避免原生 ArrayBuffer 被平台回收
+				if (res && res.devices) {
+					res.devices.forEach(device => {
+						if (device.advertisData && device.advertisData.byteLength > 0) {
+							const bytes = new Uint8Array(device.advertisData)
+							device._advertisDataHex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
+							console.log(`[BLE Scan] 广播数据: ${device._advertisDataHex}`)
+						} else {
+							device._advertisDataHex = ''
+						}
+					})
+				}
 				if (s.onDeviceFound) s.onDeviceFound(res)
 			})
 			s.bound = true
@@ -290,20 +304,78 @@ export const useBleStore = defineStore('ble', {
 		// ============== 扫描 ==============
 		async startScan(opt = {}) {
 			const s = _S()
-			console.log(`[BLE Store] startScan() called, instanceId = ${s.instanceId}`)
+			const platform = uni.getSystemInfoSync().platform
+			console.log(`[BLE Scan] ====== startScan 开始 ====== platform=${platform}, instanceId=${s.instanceId}`)
+			console.log(`[BLE Scan] 当前状态: bleState=${this.bleState}, searching=${this.searching}, linked=${this.linked}, bound=${s.bound}`)
 			// 重置中止标记
 			s.scanAborted = false
 			this.scanThrottled = false
 			// 取消上一次残留扫描
-			if (s.cancelScan) { s.cancelScan(); s.cancelScan = null }
-			// 停止上一次发现
-			await _invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {})
+			if (s.cancelScan) {
+				console.log('[BLE Scan] 取消上一次残留扫描')
+				s.cancelScan(); s.cancelScan = null
+			}
+			// 停止上一次发现(不使用 await _invoke,因为 iOS 无活跃扫描时不回调会导致卡死)
+			console.log('[BLE Scan] 调用 stopBluetoothDevicesDiscovery 清除上一次扫描')
+			try {
+				uni.stopBluetoothDevicesDiscovery({
+					success: () => console.log('[BLE Scan] stopDiscovery 成功'),
+					fail: (e) => console.log('[BLE Scan] stopDiscovery 失败(无害):', JSON.stringify(e)),
+					complete: () => {}
+				})
+			} catch (e) {
+				console.log('[BLE Scan] stopDiscovery 异常(无害):', e)
+			}
+			// 给 iOS 一点时间处理 stop
+			await new Promise(r => setTimeout(r, 100))
+			console.log('[BLE Scan] stopDiscovery 处理完毕,继续...')
+
+			// iOS 专用修复:关闭并重新打开蓝牙适配器以清除 CoreBluetooth 外设缓存
+			// iOS 的 CBCentralManager 会缓存已发现的外设,单纯 stop/start 不会清除缓存
+			// 导致重新扫描时系统不再上报之前已发现的设备
+			// 只有 close + open adapter 才能让 CoreBluetooth 重置外设缓存
+			if (platform === 'ios' && !this.linked) {
+				console.log('[BLE Scan] [iOS] 开始回收适配器以清除 CoreBluetooth 缓存...')
+				s.intentionalDisconnect = true
+				s.onDeviceFound = null
+				try {
+					await _invoke(uni.closeBluetoothAdapter, {})
+					console.log('[BLE Scan] [iOS] closeBluetoothAdapter 成功')
+				} catch (e) {
+					console.log('[BLE Scan] [iOS] closeBluetoothAdapter 失败(无害):', JSON.stringify(e))
+				}
+				this._setState(BLE_STATE.IDLE)
+				s.intentionalDisconnect = false
+				// 等待 iOS BLE 栈完全释放资源
+				await new Promise(r => setTimeout(r, 300))
+				console.log('[BLE Scan] [iOS] 等待 300ms 后重新打开适配器...')
+				// 重新打开适配器
+				try {
+					await _invoke(uni.openBluetoothAdapter, {})
+					this.bleAdapterOff = false
+					this._setState(BLE_STATE.READY)
+					console.log('[BLE Scan] [iOS] openBluetoothAdapter 成功, state=READY')
+				} catch (e) {
+					console.log('[BLE Scan] [iOS] openBluetoothAdapter 失败:', JSON.stringify(e))
+					const code = e && (e.errCode || e.code)
+					if (code === 10001) {
+						this.bleAdapterOff = true
+						throw _err(BLE_ERROR.ADAPTER_OFF, e)
+					}
+					throw _err(BLE_ERROR.NOT_SUPPORT, e)
+				}
+				// 再等 200ms 让 adapter 完全就绪
+				await new Promise(r => setTimeout(r, 200))
+				console.log('[BLE Scan] [iOS] 适配器回收完成,准备开始扫描')
+			}
 
+			console.log('[BLE Scan] 调用 _ensureReady...')
 			await this._ensureReady()
+			console.log('[BLE Scan] _ensureReady 完成, bleState=', this.bleState)
 
 			// 如果在 await 期间页面已卸载并调用了 stopScan,直接中止
 			if (s.scanAborted) {
-				logger.info('startScan aborted (page already unloaded)')
+				console.log('[BLE Scan] 扫描已被中止(scanAborted=true),直接返回')
 				return opt.returnAll ? [] : null
 			}
 
@@ -317,9 +389,9 @@ export const useBleStore = defineStore('ble', {
 
 			// 清空上次扫描结果
 			this.scannedDevices = []
+			console.log(`[BLE Scan] 扫描参数: namePrefix=${namePrefix || '(无)'}, deviceName=${deviceName || '(无)'}, timeout=${timeout}, returnAll=${returnAll}`)
 
 			// Android 限流检测(30秒内最多5次,iOS 无此限制)
-			const platform = uni.getSystemInfoSync().platform
 			if (platform === 'android') {
 				const now = Date.now()
 				s.scanStartHistory = s.scanStartHistory.filter(t => now - t < 30000)
@@ -333,15 +405,43 @@ export const useBleStore = defineStore('ble', {
 			const devices = new Map()
 			const matched = []
 
+			// iOS 上使用 allowDuplicatesKey=true 确保系统持续上报所有设备
+			// 避免 CoreBluetooth 缓存导致重新扫描时不报告已知设备
+			const allowDuplicates = (platform === 'ios')
+			console.log(`[BLE Scan] allowDuplicatesKey=${allowDuplicates} (iOS=${platform === 'ios'})`)
+
 			return new Promise(async (resolve, reject) => {
 				let finished = false
+				let foundCount = 0
+				// 保存扫描前的连接状态,扫描结束后恢复,避免覆盖 READY_COMM
+				const wasConnected = (this.bleState === BLE_STATE.READY_COMM)
 
 				const onFound = (res) => {
 					if (finished) return
 					for (const d of res.devices) {
-						if (devices.has(d.deviceId)) continue
-						devices.set(d.deviceId, d)
+						const advertisDataHex = d._advertisDataHex || ''
 						const name = d.name || d.localName || ''
+
+						// 重复设备:如果本次带有 advertisData 但之前没有,补充更新
+						if (devices.has(d.deviceId)) {
+							if (advertisDataHex) {
+								const existing = this.scannedDevices.find(item => item.deviceId === d.deviceId)
+								if (existing && !existing.advertisData) {
+									existing.advertisData = advertisDataHex
+									console.log(`[BLE Scan] 补充广播数据: ${name || d.deviceId} -> ${advertisDataHex}`)
+								}
+							}
+							continue
+						}
+
+						devices.set(d.deviceId, d)
+						foundCount++
+						console.log(`[BLE Scan] 发现设备 #${foundCount}: name=${name}, deviceId=${d.deviceId}, RSSI=${d.RSSI}`)
+						// advertisData为空的设备跳过,无法区分同名设备
+						if (!advertisDataHex) {
+							console.log(`[BLE Scan] 跳过无广播数据的设备: ${name || d.deviceId}`)
+							continue
+						}
 						const hit =
 							(deviceName && name === deviceName) ||
 							(namePrefix && name.startsWith(namePrefix)) ||
@@ -350,10 +450,12 @@ export const useBleStore = defineStore('ble', {
 							matched.push(d)
 							const exists = this.scannedDevices.find(item => item.deviceId === d.deviceId)
 							if (!exists) {
+								console.log(`[BLE Scan] ✅ 设备匹配并加入列表: ${name || d.deviceId}, 广播数据: ${advertisDataHex}`)
 								this.scannedDevices.push({
 									deviceId: d.deviceId,
 									name: name,
-									RSSI: d.RSSI || ''
+									RSSI: d.RSSI || '',
+									advertisData: advertisDataHex
 								})
 							}
 							if (!returnAll) { finish(null, d); return }
@@ -362,6 +464,7 @@ export const useBleStore = defineStore('ble', {
 				}
 
 				const timer = setTimeout(() => {
+					console.log(`[BLE Scan] 扫描超时(${timeout}ms),共发现 ${foundCount} 个设备,匹配 ${matched.length} 个`)
 					if (returnAll) finish(null, matched)
 					else if (matched.length) finish(null, matched[0])
 					else finish(_err(BLE_ERROR.SCAN_FAIL, { msg: '扫描超时,未发现目标设备' }))
@@ -370,11 +473,20 @@ export const useBleStore = defineStore('ble', {
 				const finish = (err, data) => {
 					if (finished) return
 					finished = true
+					console.log(`[BLE Scan] finish 被调用, err=${err ? err.message || err.code : 'null'}, 设备数=${Array.isArray(data) ? data.length : (data ? 1 : 0)}, wasConnected=${wasConnected}`)
 					s.cancelScan = null
 					s.onDeviceFound = null
 					clearTimeout(timer)
-					_invoke(uni.stopBluetoothDevicesDiscovery, {}).catch(() => {})
-					this._setState(BLE_STATE.READY)
+					try {
+						uni.stopBluetoothDevicesDiscovery({ success() {}, fail() {}, complete() {} })
+					} catch (_) {}
+					// 如果扫描前是连接状态,恢复为 READY_COMM,不要降级为 READY
+					if (wasConnected) {
+						this._setState(BLE_STATE.READY_COMM)
+					} else {
+						this._setState(BLE_STATE.READY)
+					}
+					this.searching = false
 					err ? reject(err) : resolve(data)
 				}
 
@@ -382,15 +494,23 @@ export const useBleStore = defineStore('ble', {
 
 				// 通过共享回调分发事件(监听器已在 _bindSystemListeners 中永久注册)
 				s.onDeviceFound = onFound
-				this._setState(BLE_STATE.SCANNING)
+				// 如果当前已连接,不调用 _setState 避免覆盖 linked 状态
+				if (!wasConnected) {
+					this._setState(BLE_STATE.SCANNING)
+				} else {
+					this.searching = true
+				}
+				console.log(`[BLE Scan] s.onDeviceFound 已设置, s.bound=${s.bound}, wasConnected=${wasConnected}, 准备调用 startBluetoothDevicesDiscovery`)
 
 				try {
 					await _invoke(uni.startBluetoothDevicesDiscovery, {
-						allowDuplicatesKey: false,
+						allowDuplicatesKey: allowDuplicates,
 						interval: 0,
 						services
 					})
+					console.log('[BLE Scan] ✅ startBluetoothDevicesDiscovery 调用成功,等待设备回调...')
 				} catch (e) {
+					console.log('[BLE Scan] ❌ startBluetoothDevicesDiscovery 失败:', JSON.stringify(e))
 					finish(_err(BLE_ERROR.SCAN_FAIL, e))
 				}
 			})
@@ -427,6 +547,7 @@ export const useBleStore = defineStore('ble', {
 			if (this.bleState === BLE_STATE.CONNECTING) throw _err(BLE_ERROR.BUSY)
 			this._setState(BLE_STATE.CONNECTING)
 			this.device = { deviceId, name: '' }
+			console.log("连接蓝牙时的设备id",this.device)
 
 			try {
 				await _invoke(uni.createBLEConnection, {
@@ -446,6 +567,7 @@ export const useBleStore = defineStore('ble', {
 				this._setState(BLE_STATE.READY_COMM)
 				s.reconnectCount = 0
 			} catch (e) {
+				console.log("蓝牙连接失败",e)
 				this._setState(BLE_STATE.DISCONNECTED)
 				this.device = null // 连接失败,清空 device 防止延迟回调误触发重连
 				try { await _invoke(uni.closeBLEConnection, { deviceId }) } catch (_) {}
@@ -459,14 +581,20 @@ export const useBleStore = defineStore('ble', {
 
 		/** 扫描 + 连接一步到位(支持 deviceId 直连优先,跳过扫描) */
 		async scanAndConnect(opt = {}) {
-			const { deviceId: directId, deviceName, ...restOpt } = opt
-
+			const { deviceId: directId, deviceName, advertisData: passedAdvertisData, ...restOpt } = opt
+            console.log("连接opt",opt,directId,passedAdvertisData,restOpt,{ ...restOpt })
+			
 			// 策略:如果已有 deviceId,先尝试直连(不扫描),失败后回退扫描
 			if (directId) {
 				try {
 					logger.info(`directConnect attempt, deviceId=${directId}`)
 					await this.connectDevice(directId)
 					this.device.name = deviceName || this.device.name || ''
+					// 存储 advertisData 作为跨平台设备标识
+					if (passedAdvertisData) {
+						  console.log("连接passedAdvertisData",passedAdvertisData)
+						this.device.advertisData = passedAdvertisData
+					}
 					logger.info('directConnect success, scan skipped')
 					return this.device
 				} catch (e) {
@@ -477,10 +605,35 @@ export const useBleStore = defineStore('ble', {
 
 			// 回退:扫描 + 连接
 			const scanOpt = { ...restOpt }
-			if (deviceName) scanOpt.deviceName = deviceName
-			const device = await this.startScan(scanOpt)
+
+			let device = null
+			if (passedAdvertisData) {
+				// 有 advertisData 时:不传 deviceName 做名称过滤(后端名与蓝牙广播名可能不一致)
+				// 扫描所有设备,再通过 advertisData 精确定位目标
+				scanOpt.returnAll = true
+				const devices = await this.startScan(scanOpt)
+				console.log(`[BLE scanAndConnect] 扫描到 ${devices.length} 个设备,目标 advertisData=${passedAdvertisData}`)
+				// 精确匹配 advertisData
+				device = devices.find(d => {
+					const hex = d._advertisDataHex || d.advertisData || ''
+					return hex === passedAdvertisData
+				})
+				if (!device) {
+					throw _err(BLE_ERROR.SCAN_FAIL, { msg: '未找到目标设备(advertisData不匹配)' })
+				}
+				console.log(`[BLE scanAndConnect] ✅ 通过advertisData匹配到设备: name=${device.name || device.localName}, deviceId=${device.deviceId}`)
+			} else {
+				// 无 advertisData 时按设备名过滤,返回第一个匹配设备
+				if (deviceName) scanOpt.deviceName = deviceName
+				device = await this.startScan(scanOpt)
+			}
+
 			await this.connectDevice(device.deviceId)
 			this.device.name = device.name || device.localName || ''
+			console.log("连接device._advertisDataHex",device._advertisDataHex)
+			// 存储 advertisData(hex 字符串):优先用传入的(后台 deviceCode),其次用扫描结果的 hex
+			// 注意:device.advertisData 是原始 ArrayBuffer,不能直接存储,需使用 _advertisDataHex
+			this.device.advertisData = passedAdvertisData || device._advertisDataHex || device.advertisData || ''
 			return this.device
 		},
 
@@ -506,7 +659,8 @@ export const useBleStore = defineStore('ble', {
 			const s = _S()
 			const svcRes = await _invoke(uni.getBLEDeviceServices, { deviceId })
 			const services = svcRes.services || []
-			const targetSvc = services.find(sv => _uuidEq(sv.uuid, s.config.serviceId))
+			const strict = s.config.strictUUID
+			const targetSvc = (strict && services.find(sv => _uuidEq(sv.uuid, s.config.serviceId)))
 				|| services.find(sv => sv.isPrimary)
 				|| services[0]
 			if (!targetSvc) throw _err(BLE_ERROR.SERVICE_NOT_FOUND)
@@ -517,9 +671,9 @@ export const useBleStore = defineStore('ble', {
 			})
 			const chars = charRes.characteristics || []
 
-			const writeChar = chars.find(c => _uuidEq(c.uuid, s.config.writeCharId))
+			const writeChar = (strict && chars.find(c => _uuidEq(c.uuid, s.config.writeCharId)))
 				|| chars.find(c => c.properties && (c.properties.write || c.properties.writeNoResponse || c.properties.writeDefault))
-			const notifyChar = chars.find(c => _uuidEq(c.uuid, s.config.notifyCharId))
+			const notifyChar = (strict && chars.find(c => _uuidEq(c.uuid, s.config.notifyCharId)))
 				|| chars.find(c => c.properties && (c.properties.notify || c.properties.indicate))
 
 			if (!writeChar) throw _err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到写特征' })
@@ -650,6 +804,7 @@ export const useBleStore = defineStore('ble', {
 
 		// ============== 收包处理 ==============
 		_onFrame(decoded, frame) {
+			console.log("获取到消息",decoded)
 			logger.info('frame received', decoded.funcCode)
 			if (decoded.parsed) {
 				if (decoded.parsed.type === 'GROUP_1') {

+ 1 - 1
code/ajyApp/utils/api/acupoint.js

@@ -9,5 +9,5 @@ import http from '@/utils/http'
  * @returns {Promise<Array<{id, userId, acupointId, acupointCode, acupointName, acupointSide, bodyPart, productAcupoint, coordinateX, coordinateY, coordinateZ, confidence}>>}
  */
 export function getBackAcupoints(profileId) {
-	return http.get(`/user/acupoint/${profileId}/product-back-coordinates`)
+	return http.get(`/app/user/acupoint/${profileId}/product-back-coordinates`)
 }

+ 1 - 2
code/ajyApp/utils/api/article.js

@@ -9,8 +9,7 @@ import http from '@/utils/http'
  * @returns {Promise<{records, total, pages, current}>}
  */
 export function getPublishedArticles(params = {}) {
-	return http.get('/content/articles', {
-		status: 'PUBLISHED',
+	return http.get('/app/content/articles', {
 		pageNum: params.pageNum || 1,
 		pageSize: params.pageSize || 10
 	})

+ 81 - 0
code/ajyApp/utils/api/care.js

@@ -19,6 +19,24 @@ export function getCareList(params = {}) {
 	})
 }
 
+/**
+ * 获取当前用户的自定义方案列表
+ * 通过 Token 获取当前 APP 账号下创作人属于自己的自定义模式方案
+ * @returns {Promise<Array<{id, planCode, name, modeType, symptoms, applicableGender, ageMin, ageMax, description, authorId, authorName, authorType, useCount, avgRating, lastUseTime, status, createTime, updateTime}>>}
+ */
+export function getCustomPlanList() {
+	return http.get('/app/custom-plan/list')
+}
+
+/**
+ * 获取用户自定义方案详情
+ * @param {string|number} id - 方案ID
+ * @returns {Promise<Object>} 方案详情含steps
+ */
+export function getCustomPlanDetail(id) {
+	return http.get(`/app/custom-plan/${id}`)
+}
+
 /**
  * 获取调理方案详情
  * @param {string|number} id - 方案ID
@@ -27,3 +45,66 @@ export function getCareList(params = {}) {
 export function getCareDetail(id) {
 	return http.get('/userCare/careDetail', { data: { id } })
 }
+
+/**
+ * 获取专业模式方案列表
+ * 所有 APP 用户共享同一份方案,仅返回已发布方案
+ * @returns {Promise<Array>}
+ */
+export function getProfessionalPlanList() {
+	return http.get('/app/professional-plan/list')
+}
+
+/**
+ * 获取专业模式方案详情(含穴位步骤列表)
+ * @param {string|number} id - 方案ID
+ * @returns {Promise<Object>}
+ */
+export function getProfessionalPlanDetail(id) {
+	return http.get(`/app/professional-plan/${id}`)
+}
+
+/**
+ * 获取延年圣手模式方案列表
+ * 所有 APP 用户共享同一份方案,仅返回已发布方案
+ * @returns {Promise<Array>}
+ */
+export function getMasterPlanList() {
+	return http.get('/app/master-plan/list')
+}
+
+/**
+ * 获取延年圣手模式方案详情(含穴位步骤列表)
+ * @param {string|number} id - 方案ID
+ * @returns {Promise<Object>}
+ */
+export function getMasterPlanDetail(id) {
+	return http.get(`/app/master-plan/${id}`)
+}
+
+/**
+ * 新增用户自定义方案
+ * @param {Object} data - 方案数据 { name, symptoms, applicableGender, ageMin, ageMax, description, status, steps }
+ * @returns {Promise<number>} 新方案ID
+ */
+export function createCustomPlan(data) {
+	return http.post('/app/custom-plan', data)
+}
+
+/**
+ * 修改用户自定义方案
+ * @param {Object} data - 方案数据 { id, name, symptoms, applicableGender, ageMin, ageMax, description, status, steps }
+ * @returns {Promise}
+ */
+export function updateCustomPlan(data) {
+	return http.put('/app/custom-plan', data)
+}
+
+/**
+ * 删除用户自定义方案
+ * @param {string|number} id - 方案ID
+ * @returns {Promise}
+ */
+export function deleteCustomPlan(id) {
+	return http.delete(`/app/custom-plan/${id}`)
+}

+ 32 - 0
code/ajyApp/utils/api/device.js

@@ -0,0 +1,32 @@
+/**
+ * 设备模块 API
+ */
+import http from '@/utils/http'
+
+/**
+ * 获取当前账号下的设备列表
+ * 后端通过 Token 获取当前 APP 账号,返回该账号可使用的家庭设备和公共设备
+ * @returns {Promise<Array<{id, deviceId, deviceCode, deviceName, deviceModel, deviceType, deviceTypeName, serialNo, firmwareVersion, onlineStatus, lastOnlineTime, address, groupId, memberId, profileId, profileName, profileComplete}>>}
+ */
+export function getDeviceList() {
+	return http.get('/app/device/list',{showError:true})
+}
+
+/**
+ * 用户手动输入设备编号绑定设备
+ * @param {string} deviceCode - 设备编号
+ * @returns {Promise<{id, userId, deviceCode, deviceName, deviceModel, isPrimary, onlineStatus, status, bindTime, createTime, updateTime}>}
+ */
+export function bindDeviceByCode(deviceCode) {
+	console.log("请求参数",deviceCode)
+	return http.post('/app/user/device/bind-by-code', {deviceCode },{showError:true})
+}
+
+/**
+ * 获取设备详情
+ * @param {string|number} id - 设备ID
+ * @returns {Promise<{id, deviceCode, deviceName, deviceModel, deviceType, serialNo, firmwareVersion, onlineStatus, lastOnlineTime, provinceCode, cityCode, districtCode, address, remark, createTime, updateTime}>}
+ */
+export function getDeviceDetail(id) {
+	return http.get(`/app/device/${id}`, {showError: true})
+}

+ 44 - 0
code/ajyApp/utils/api/group.js

@@ -0,0 +1,44 @@
+/**
+ * 群组成员管理 API
+ */
+import http from '@/utils/http'
+
+/**
+ * 查询群组成员列表
+ * @param {number} groupId - 家庭设备群组 ID
+ * @returns {Promise<Array>} 成员列表
+ */
+export function getGroupMembers(groupId) {
+	return http.get(`/app/group/${groupId}/members`)
+}
+
+/**
+ * 新增群组成员
+ * @param {number} groupId - 家庭设备群组 ID
+ * @param {Object} data - 成员信息(name必填,gender/age/phone/shoulderWidth/bodyHeight/spineLength/height/weight/fingerWidth1/fingerWidth15/fingerWidth3等可选)
+ * @returns {Promise<Object>} 新增成员信息
+ */
+export function addGroupMember(groupId, data) {
+	return http.post(`/app/group/${groupId}/members`, data)
+}
+
+/**
+ * 修改群组成员
+ * @param {number} groupId - 家庭设备群组 ID
+ * @param {number} profileId - 成员的用户档案 ID
+ * @param {Object} data - 成员信息
+ * @returns {Promise<Object>} 修改后的成员信息
+ */
+export function updateGroupMember(groupId, profileId, data) {
+	return http.put(`/app/group/${groupId}/members/${profileId}`, data)
+}
+
+/**
+ * 删除群组成员(软删除)
+ * @param {number} groupId - 家庭设备群组 ID
+ * @param {number} profileId - 待移除成员的用户档案 ID
+ * @returns {Promise<null>}
+ */
+export function deleteGroupMember(groupId, profileId) {
+	return http.delete(`/app/group/${groupId}/members/${profileId}`)
+}

+ 19 - 0
code/ajyApp/utils/api/version.js

@@ -0,0 +1,19 @@
+/**
+ * 版本更新模块 API
+ */
+import http from '@/utils/http'
+
+/**
+ * 检查APP版本更新
+ * @param {number} platform - 平台:1=Android,2=iOS
+ * @param {string} versionCode - 当前版本号(如:1.0.0)
+ * @returns {Promise<{hasUpdate, versionCode, versionNumber, updateType, downloadUrl, updateContent, fileSize}>}
+ */
+export function checkUpdate(platform, versionCode) {
+	return http.get('/app/version/check', {
+		data: { platform, versionCode },
+		showLoading: false,
+		showError: false,
+		withToken: false
+	})
+}

+ 5 - 3
code/ajyApp/utils/ble/BleManager.js

@@ -285,7 +285,9 @@ class BleManager extends EventEmitter {
     // 1) 服务列表
     const svcRes = await this._invoke(uni.getBLEDeviceServices, { deviceId })
     const services = svcRes.services || []
-    const targetSvc = services.find(s => this._uuidEq(s.uuid, this.config.serviceId)) || services.find(s => s.isPrimary) || services[0]
+    const strict = this.config.strictUUID
+    const targetSvc = (strict && services.find(s => this._uuidEq(s.uuid, this.config.serviceId)))
+      || services.find(s => s.isPrimary) || services[0]
     if (!targetSvc) throw this._err(BLE_ERROR.SERVICE_NOT_FOUND)
     this._serviceId = targetSvc.uuid
 
@@ -295,9 +297,9 @@ class BleManager extends EventEmitter {
     })
     const chars = charRes.characteristics || []
 
-    const writeChar = chars.find(c => this._uuidEq(c.uuid, this.config.writeCharId))
+    const writeChar = (strict && chars.find(c => this._uuidEq(c.uuid, this.config.writeCharId)))
       || chars.find(c => c.properties && (c.properties.write || c.properties.writeNoResponse || c.properties.writeDefault))
-    const notifyChar = chars.find(c => this._uuidEq(c.uuid, this.config.notifyCharId))
+    const notifyChar = (strict && chars.find(c => this._uuidEq(c.uuid, this.config.notifyCharId)))
       || chars.find(c => c.properties && (c.properties.notify || c.properties.indicate))
 
     if (!writeChar)  throw this._err(BLE_ERROR.CHAR_NOT_FOUND, { msg: '未找到写特征' })

+ 27 - 3
code/ajyApp/utils/ble/constants.js

@@ -115,12 +115,14 @@ export const BLE_STATE = {
 export const DEFAULT_CONFIG = {
   // 目标设备名称前缀(扫描过滤,空字符串表示不过滤)
   deviceNamePrefix: '',
+  // 是否强制匹配下方配置的 UUID(false = 跳过 UUID 匹配,完全按属性自动发现服务和特征)
+  strictUUID: true,
   // 主服务 UUID
-  serviceId: '0000FFF0-0000-1000-8000-00805F9B34FB',
+  serviceId: '',
   // 写特征(APP -> 设备)
-  writeCharId: '0000FFF1-0000-1000-8000-00805F9B34FB',
+  writeCharId: '',
   // 通知特征(设备 -> APP)
-  notifyCharId: '0000FFF2-0000-1000-8000-00805F9B34FB',
+  notifyCharId: '',
   // 扫描超时
   scanTimeout: 10000,
   // 连接超时
@@ -132,3 +134,25 @@ export const DEFAULT_CONFIG = {
   // 是否打印日志
   debug: true
 }
+// export const DEFAULT_CONFIG = {
+//   // 目标设备名称前缀(扫描过滤,空字符串表示不过滤)
+//   deviceNamePrefix: '',
+//   // 是否强制匹配下方配置的 UUID(false = 跳过 UUID 匹配,完全按属性自动发现服务和特征)
+//   strictUUID: false,
+//   // 主服务 UUID
+//   serviceId: '0000FFF0-0000-1000-8000-00805F9B34FB',
+//   // 写特征(APP -> 设备)
+//   writeCharId: '0000FFF1-0000-1000-8000-00805F9B34FB',
+//   // 通知特征(设备 -> APP)
+//   notifyCharId: '0000FFF2-0000-1000-8000-00805F9B34FB',
+//   // 扫描超时
+//   scanTimeout: 10000,
+//   // 连接超时
+//   connectTimeout: 10000,
+//   // 自动重连
+//   autoReconnect: true,
+//   // 最大重连次数
+//   maxReconnect: 3,
+//   // 是否打印日志
+//   debug: true
+// }

+ 3 - 2
code/ajyApp/utils/http/config.js

@@ -11,9 +11,10 @@ baseUrl = '/api' // H5 走代理
 // #endif
 
 // #ifdef APP-PLUS || MP-WEIXIN || MP-ALIPAY
-baseUrl = 'http://110.177.148.159:18888/api' // TODO: 替换为实际接口域名
+// baseUrl = 'http://110.177.148.159:18888/api'
+// baseUrl = 'http://192.168.1.5:18888/api' // TODO: 替换为实际接口域名
 // #endif
- // baseUrl = 'http://ajy.iwintrue.com/api'
+ baseUrl = 'http://ajy.iwintrue.com/api'
 export default {
   // 接口基础地址
   baseUrl,

+ 7 - 1
code/ajyApp/utils/http/request.js

@@ -71,7 +71,11 @@ function hideLoading() {
 // ============== 错误处理 ==============
 
 function handleError(message, statusCode) {
-  toast.error(message || '请求失败')
+  console.log('handleError', message, statusCode)
+  // 延迟弹出,避免被 complete 回调中的 hideLoading (toast.hide) 立即关闭
+  setTimeout(() => {
+    toast.error(message || '请求失败')
+  }, 150)
 }
 
 function handleAuthError() {
@@ -193,6 +197,7 @@ function request(options = {}) {
     uni.request({
       ...reqConfig,
       success: (res) => {
+		  console.log(reqConfig,"返回数据",res)
         // 执行响应拦截器
         let response = res
         for (const handler of interceptors.response) {
@@ -200,6 +205,7 @@ function request(options = {}) {
         }
 
         const { statusCode, data: resData } = response
+		console.log("返回数据2",response)
 
         // HTTP 状态码判断
         if (statusCode >= 200 && statusCode < 300) {

+ 2081 - 55
code/app对接接口说明.md

@@ -6,17 +6,32 @@
 
 - 服务前缀:`/api`
 - 本地开发示例地址:`http://localhost:18888`
-- 鉴权方式:除登录等白名单接口外,请求头需携带后台登录后的 Token
+- 鉴权方式:除登录等白名单接口外,请求头需携带登录后的 Token;APP 接口使用 APP 登录返回的 Token
 - 通用返回结构:`code`、`message`、`data`
 
 ## 2. 接口目录
 
 | 序号 | 接口名称 | 方法 | URI | 说明 |
 | --- | --- | --- | --- | --- |
-| 1 | 查询用户背部产品穴位坐标列表 | `GET` | `/api/user/acupoint/{profileId}/product-back-coordinates` | 查询指定子用户身体部位为背部且产品穴位为是的穴位坐标 |
-| 2 | 查询用户拥有的设备列表 | `GET` | `/api/user/device/page` | 按 `userId` 查询用户当前已绑定设备,`status` 固定为 `1` |
-| 3 | 用户手动输入设备编号绑定设备 | `POST` | `/api/app/user/device/bind-by-code` | APP 端按设备编号绑定设备,编号不存在返回错误提示 |
-
+| 1 | 查询用户背部产品穴位坐标列表 | `GET` | `/api/app/user/acupoint/{profileId}/product-back-coordinates` | 查询指定子用户身体部位为背部且产品穴位为是的穴位坐标,仅供 APP 端调用 |
+| 2 | 查询当前账号下的设备列表 | `GET` | `/api/app/device/list` | 通过 Token 获取当前 APP 账号可使用的设备列表 |
+| 3 | 用户手动输入设备编号绑定设备 | `POST` | `/api/app/user/device/bind-by-code` | APP 端按设备编号绑定到当前 Token 账号下,编号不存在返回错误提示 |
+| 4 | APP 端查询设备详情 | `GET` | `/api/app/device/{id}` | APP 端查询当前账号可访问的设备详情(仅限已绑定的家庭/公共设备) |
+| 5 | 新增用户自定义方案 | `POST` | `/api/app/custom-plan` | APP 端为指定子用户新增一个自定义模式方案(含穴位步骤),需传入 `profileId` 指定子用户,后端强制设置 modeType=3、authorType=2、authorId=子用户ID(user_profile.id) |
+| 6 | 查询当前用户的自定义方案列表 | `GET` | `/api/app/custom-plan/list` | 通过 Token 获取当前 APP 账号下创作人属于自己的自定义模式方案基本信息列表(不含穴位步骤),如需详情请调用第 7 个接口 |
+| 7 | 查询用户自定义方案详情 | `GET` | `/api/app/custom-plan/{id}` | 查询当前用户自己的某个自定义方案详情,校验方案归属,含穴位步骤列表 |
+| 8 | 查询专业模式方案列表 | `GET` | `/api/app/professional-plan/list` | 通过 Token 获取专业模式(`modeType=2`)系统方案基本信息列表,所有 APP 用户共享同一份方案,仅返回已发布方案;如需详情请调用第 9 个接口 |
+| 9 | 查询专业模式方案详情 | `GET` | `/api/app/professional-plan/{id}` | 查询专业模式方案详情,校验模式为专业模式且已发布,含穴位步骤列表 |
+| 10 | 查询延年圣手模式方案列表 | `GET` | `/api/app/master-plan/list` | 通过 Token 获取延年圣手模式(`modeType=4`)系统方案基本信息列表,所有 APP 用户共享同一份方案,仅返回已发布方案;如需详情请调用第 11 个接口 |
+| 11 | 查询延年圣手模式方案详情 | `GET` | `/api/app/master-plan/{id}` | 查询延年圣手模式方案详情,校验模式为延年圣手模式且已发布,含穴位步骤列表 |
+| 12 | 修改用户自定义方案 | `PUT` | `/api/app/custom-plan` | 当前账号修改自己创建的自定义方案(含穴位步骤),仅允许修改创作人为当前账号下子用户、模式为自定义模式的方案 |
+| 13 | 删除用户自定义方案 | `DELETE` | `/api/app/custom-plan/{id}` | 当前账号删除自己的自定义方案,仅允许删除创作人为当前账号下子用户、模式为自定义模式的方案 |
+| 14 | 查询群组成员列表 | `GET` | `/api/app/group/{groupId}/members` | 查询指定家庭设备群组下的成员列表,仅返回当前 APP 账号所属群组的成员 |
+| 15 | 新增群组成员 | `POST` | `/api/app/group/{groupId}/members` | 当前 APP 账号向指定家庭设备群组添加新成员(包含用户档案信息),需校验群组归属 |
+| 16 | 修改群组成员 | `PUT` | `/api/app/group/{groupId}/members/{profileId}` | 当前 APP 账号修改指定群组下已有成员的用户档案信息,需校验群组归属 |
+| 17 | 删除群组成员 | `DELETE` | `/api/app/group/{groupId}/members/{profileId}` | 当前 APP 账号从指定家庭设备群组中移除成员(软删除),需校验群组归属且禁止删除自身 |
+| 18 | 查询当前账号个人资料 | `GET` | `/api/app/user/profile` | 通过 Token 获取当前 APP 账号对应的个人资料(昵称、头像、用户档案等),适用于家庭设备和公共设备 |
+| 19 | 修改当前账号个人资料 | `PUT` | `/api/app/user/profile` | 通过 Token 修改当前 APP 账号对应的个人资料,部分字段(昵称/头像)更新到 `app_user` 表,其余字段(体型、地址等)更新到 `user_profile` 表,适用于家庭设备和公共设备 |
 ## 3. 接口详情
 
 ### 3.1 查询用户背部产品穴位坐标列表
@@ -115,39 +130,34 @@ curl -X GET "http://localhost:18888/api/user/acupoint/123/product-back-coordinat
 - 如果该子用户尚未生成穴位坐标表,后端会先自动生成用户穴位坐标,再返回查询结果。
 - `profileId` 使用子用户档案 ID,即 `user_profile.id`,不是后台管理员 ID。
 
-### 3.2 查询用户拥有的设备列表
+### 3.2 查询当前账号下的设备列表
 
 #### 3.2.1 接口用途
 
-用于查询指定用户当前拥有的设备列表。
+用于根据请求头 Token 查询当前登录 APP 账号下可使用的设备列表。
 
-当前场景:APP 根据用户档案 ID 查询该用户已绑定且未解绑的设备,用于展示用户可使用的设备。
+当前场景:APP 登录后不再额外传 `userId`、`status` 等查询参数,后端从 Token 中解析当前账号 ID,返回该账号可使用的家庭设备和公共设备,用于展示当前账号可选择或可使用的设备。
 
 #### 3.2.2 请求信息
 
 - 请求方式:`GET`
-- URI:`/api/user/device/page`
-- 完整示例:`http://localhost:18888/api/user/device/page`
+- URI:`/api/app/device/list`
+- 完整示例:`http://localhost:18888/api/app/device/list`
 
 #### 3.2.3 请求参数
 
-| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
-| --- | --- | --- | --- | --- |
-| `userId` | `number` | 是 | 用户档案 ID,对应 `user_profile.id` | `123` |
-| `status` | `number` | 是 | 绑定状态,固定传 `1`,表示只查询当前已绑定设备 | `1` |
-| `pageNum` | `number` | 否 | 页码,默认 `1` | `1` |
-| `pageSize` | `number` | 否 | 每页条数,默认 `10` | `10` |
+无查询参数。后端通过 `Authorization` 请求头中的 Token 获取当前 APP 账号。
 
 #### 3.2.4 调用示例
 
 ```http
-GET /api/user/device/page?userId=123&status=1&pageNum=1&pageSize=10 HTTP/1.1
+GET /api/app/device/list HTTP/1.1
 Host: localhost:18888
 Authorization: Bearer <token>
 ```
 
 ```bash
-curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pageNum=1&pageSize=10" \
+curl -X GET "http://localhost:18888/api/app/device/list" \
   -H "Authorization: Bearer <token>"
 ```
 
@@ -157,27 +167,46 @@ curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pag
 {
   "code": 200,
   "message": "操作成功",
-  "data": {
-    "records": [
-      {
-        "id": 192837465,
-        "userId": 123,
-        "deviceCode": "AJY-0001",
-        "deviceName": "艾灸椅一号",
-        "deviceModel": "AJY-2026",
-        "isPrimary": 1,
-        "onlineStatus": 0,
-        "status": 1,
-        "bindTime": "2026-06-02 10:30:00",
-        "createTime": "2026-06-02 10:30:00",
-        "updateTime": "2026-06-02 10:30:00"
-      }
-    ],
-    "total": 1,
-    "size": 10,
-    "current": 1,
-    "pages": 1
-  }
+  "data": [
+    {
+      "id": 1001,
+      "deviceId": 1001,
+      "deviceCode": "AJY-0001",
+      "deviceName": "艾灸椅一号",
+      "deviceModel": "AJY-2026",
+      "deviceType": 1,
+      "deviceTypeName": "家庭设备",
+      "serialNo": "SN202606020001",
+      "firmwareVersion": "1.0.0",
+      "onlineStatus": 0,
+      "lastOnlineTime": "2026-06-02 10:30:00",
+      "address": "广东省深圳市",
+      "groupId": 2001,
+      "memberId": 3001,
+      "profileId": 123,
+      "profileName": "张三",
+      "profileComplete": true
+    },
+    {
+      "id": 1002,
+      "deviceId": 1002,
+      "deviceCode": "AJY-0002",
+      "deviceName": "共享艾灸椅",
+      "deviceModel": "AJY-2026",
+      "deviceType": 2,
+      "deviceTypeName": "公共设备",
+      "serialNo": "SN202606020002",
+      "firmwareVersion": "1.0.0",
+      "onlineStatus": 1,
+      "lastOnlineTime": "2026-06-02 10:35:00",
+      "address": "广东省深圳市",
+      "groupId": null,
+      "memberId": null,
+      "profileId": 123,
+      "profileName": "张三",
+      "profileComplete": true
+    }
+  ]
 }
 ```
 
@@ -185,31 +214,40 @@ curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pag
 
 | 字段名 | 类型 | 说明 |
 | --- | --- | --- |
-| `id` | `number` | 用户设备绑定记录 ID,对应 `user_device.id` |
-| `userId` | `number` | 用户档案 ID,对应 `user_profile.id` |
+| `id` | `number` | 设备 ID,对应 `device.id` |
+| `deviceId` | `number` | 设备 ID,对应 `device.id` |
 | `deviceCode` | `string` | 设备编号 |
 | `deviceName` | `string` | 设备名称 |
 | `deviceModel` | `string` | 设备型号 |
-| `isPrimary` | `number` | 是否主设备:`1=是`,`0=否` |
+| `deviceType` | `number` | 设备类型:`1=家庭设备`,`2=公共设备` |
+| `deviceTypeName` | `string` | 设备类型名称 |
+| `serialNo` | `string` | 设备序列号 |
+| `firmwareVersion` | `string` | 固件版本 |
 | `onlineStatus` | `number` | 在线状态:`1=在线`,`0=离线` |
-| `status` | `number` | 绑定状态:`1=已绑定`,`0=已解绑` |
-| `bindTime` | `string` | 绑定时间 |
+| `lastOnlineTime` | `string` | 最近在线时间 |
+| `address` | `string` | 设备地址 |
+| `groupId` | `number/null` | 家庭设备所属群组 ID,公共设备返回 `null` |
+| `memberId` | `number/null` | 当前账号在家庭群组中的成员 ID,公共设备返回 `null` |
+| `profileId` | `number/null` | 当前设备默认关联的用户档案 ID,对应 `user_profile.id` |
+| `profileName` | `string/null` | 当前设备默认关联的用户档案名称 |
+| `profileComplete` | `boolean` | 当前关联档案是否已补全设备使用所需资料 |
 
 #### 3.2.7 备注
 
-- APP 查询用户拥有的设备列表时,`status` 固定传 `1`,不查询已解绑设备。
-- `userId` 使用用户档案 ID,即 `user_profile.id`,不是后台管理员 ID。
-- 返回数据来自用户设备绑定记录表 `user_device`,用于展示该用户当前已绑定设备。
+- APP 查询设备列表时只需要携带 Token,不需要传 `userId`、`status`、`pageNum`、`pageSize`。
+- 后端会根据 Token 中的当前 APP 账号 ID 查询该账号可使用的设备,包含家庭设备和公共设备。
+- 家庭设备来源于当前账号已加入的设备群组;公共设备来源于当前账号下用户档案已绑定的启用设备。
+- 本接口返回数组,不返回分页结构。
 
 ### 3.3 用户手动输入设备编号绑定设备
 
 #### 3.3.1 接口用途
 
-用于 APP 端让用户手动输入设备编号后绑定设备
+用于 APP 端让用户手动输入设备编号后,将设备绑定到当前登录账号下
 
 后端会先根据设备编号查询设备主表 `device`:
 
-- 如果设备编号存在,则自动带出设备名称、设备型号等信息,并新增用户设备绑定记录。
+- 如果设备编号存在,则从 Token 中获取当前 APP 账号 ID,自动确认或创建当前账号档案,再带出设备名称、设备型号等信息,并新增用户设备绑定记录。
 - 如果设备编号不存在,则返回错误码和错误信息,APP 可直接弹窗提示。
 
 #### 3.3.2 请求信息
@@ -224,7 +262,6 @@ curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pag
 
 | 参数名 | 类型 | 是否必填 | 说明 | 示例 |
 | --- | --- | --- | --- | --- |
-| `userId` | `number` | 是 | 用户档案 ID,对应 `user_profile.id` | `123` |
 | `deviceCode` | `string` | 是 | 用户手动输入的设备编号,对应 `device.device_code` | `AJY-0001` |
 
 #### 3.3.4 调用示例
@@ -236,7 +273,6 @@ Authorization: Bearer <token>
 Content-Type: application/json
 
 {
-  "userId": 123,
   "deviceCode": "AJY-0001"
 }
 ```
@@ -245,7 +281,7 @@ Content-Type: application/json
 curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
   -H "Authorization: Bearer <token>" \
   -H "Content-Type: application/json" \
-  -d "{\"userId\":123,\"deviceCode\":\"AJY-0001\"}"
+  -d "{\"deviceCode\":\"AJY-0001\"}"
 ```
 
 #### 3.3.5 成功返回示例
@@ -256,6 +292,7 @@ curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
   "message": "操作成功",
   "data": {
     "id": 192837466,
+    "deviceId": 1001,
     "userId": 123,
     "deviceCode": "AJY-0001",
     "deviceName": "艾灸椅一号",
@@ -282,7 +319,7 @@ curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
 }
 ```
 
-该用户已绑定此设备:
+当前账号已绑定此设备:
 
 ```json
 {
@@ -294,7 +331,1996 @@ curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
 
 #### 3.3.7 备注
 
-- APP 端只需要传 `userId` 和 `deviceCode`,不需要传 `deviceName`、`deviceModel`。
+- APP 端只需要传 `deviceCode`,不需要传 `userId`、`deviceName`、`deviceModel`。
+- 后端从 Token 中获取当前 APP 账号 ID,并将设备绑定到当前账号对应的用户档案下。
+- 成功响应中的 `userId` 为后端自动关联的用户档案 ID,对应 `user_profile.id`。
 - 后端会用 `deviceCode` 查询设备主表,存在时自动写入 `deviceName`、`deviceModel`。
 - 绑定成功后,绑定记录默认 `status=1`、`isPrimary=0`。
-- APP 可根据失败响应中的 `message` 直接弹窗,例如设备编号不存在时提示“设备不存在”。
+- APP 可根据失败响应中的 `message` 直接弹窗,例如设备编号不存在时提示"设备不存在"。
+
+### 3.4 APP 端查询设备详情
+#### 3.4.1 接口用途
+
+用于 APP 端查询当前登录账号可访问的设备详情。
+
+后端会根据 Token 中的 APP 账号 ID 校验访问权限:
+- 家庭设备(`deviceType=1`):校验当前 APP 账号是否在该设备的家庭群组内(`device_group_member`)
+- 公共设备(`deviceType=2`):校验当前 APP 账号下的用户档案是否已绑定该设备(`user_device`)
+- 越权或设备不存在时返回 `403` 或 `1300` 错误
+
+#### 3.4.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/device/{id}`
+- 完整示例:`http://localhost:18888/api/app/device/1001`
+
+#### 3.4.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 设备 ID,对应 `device.id`,通过路径参数传递 | `1001` |
+
+#### 3.4.4 调用示例
+
+```http
+GET /api/app/device/1001 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/device/1001" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.4.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 1001,
+    "deviceCode": "AJY-0001",
+    "deviceName": "艾灸椅一号",
+    "deviceModel": "AJY-2026",
+    "deviceType": 1,
+    "serialNo": "SN202606020001",
+    "firmwareVersion": "1.0.0",
+    "onlineStatus": 0,
+    "lastOnlineTime": "2026-06-02 10:30:00",
+    "provinceCode": "440000",
+    "cityCode": "440300",
+    "districtCode": "440305",
+    "address": "广东省深圳市南山区",
+    "remark": "测试设备",
+    "deleted": 0,
+    "createTime": "2026-06-01 09:00:00",
+    "updateTime": "2026-06-02 10:30:00"
+  }
+}
+```
+
+#### 3.4.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 设备 ID,对应 `device.id` |
+| `deviceCode` | `string` | 设备编号 |
+| `deviceName` | `string` | 设备名称 |
+| `deviceModel` | `string` | 设备型号 |
+| `deviceType` | `number` | 设备类型:`1=家庭设备`,`2=公共设备` |
+| `serialNo` | `string` | 设备序列号 |
+| `firmwareVersion` | `string` | 固件版本 |
+| `onlineStatus` | `number` | 在线状态:`1=在线`,`0=离线` |
+| `lastOnlineTime` | `string` | 最后在线时间 |
+| `provinceCode` | `string` | 省级行政区划编码(GB/T 2260) |
+| `cityCode` | `string` | 市级行政区划编码 |
+| `districtCode` | `string` | 区/县级行政区划编码 |
+| `address` | `string` | 详细地址 |
+| `remark` | `string` | 备注 |
+| `deleted` | `number` | 逻辑删除:`0=未删除`,`1=已删除` |
+| `createTime` | `string` | 创建时间 |
+| `updateTime` | `string` | 更新时间 |
+
+#### 3.4.7 失败返回示例
+
+设备不存在:
+
+```json
+{
+  "code": 1300,
+  "message": "设备不存在",
+  "data": null
+}
+```
+
+当前账号无权限访问该设备:
+
+```json
+{
+  "code": 403,
+  "message": "无权访问该设备",
+  "data": null
+}
+```
+
+#### 3.4.8 备注
+
+- 本接口需要 APP 登录后的 Token,不接受后台管理员 Token。
+- 越权校验在 Service 层完成,家庭设备校验 `device_group_member.app_user_id`,公共设备校验 `user_device.user_id` 是否归属当前 APP 账号下的用户档案。
+- 后端不会返回 `boundUserCount`、`boundUserNames`、`userIds` 等聚合字段,避免暴露其他用户信息。
+- 路径参数 `id` 为 `device.id`(主键),不是设备编号 `deviceCode`,APP 调用时需先通过列表接口获取设备主键。
+
+### 3.5 新增用户自定义方案
+
+#### 3.5.1 接口用途
+
+用于 APP 端用户新增一个自定义模式方案(含穴位步骤)。
+
+后端强制设置:
+- `modeType` = 3(自定义模式)
+- `authorType` = 2(用户创作)
+- `authorId` = 子用户 ID(`user_profile.id`,由前端传入 `profileId` 指定)
+- `authorName` = 子用户名称(`user_profile.name`,后端根据 `profileId` 自动查询)
+- `planCode` = 系统自动生成(S + 6 位随机字符)
+
+`profileId` 为必传参数,用于指定方案归属的子用户。后端会校验该子用户是否属于当前 APP 账号。其余字段前端无需传入,后端会忽略前端传值。
+
+#### 3.5.2 请求信息
+
+- 请求方式:`POST`
+- URI:`/api/app/custom-plan`
+- 完整示例:`http://localhost:18888/api/app/custom-plan`
+
+#### 3.5.3 请求参数
+
+请求体格式:`application/json`
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `profileId` | `number` | 是 | 子用户档案 ID,对应 `user_profile.id`,用于指定方案归属的子用户 | `4` |
+| `name` | `string` | 是 | 方案名称(最长 100 字符,同一子用户下不可重复) | `我的祛湿方案` |
+| `symptoms` | `string` | 否 | 适用症状(逗号分隔) | `关节酸痛,乏力` |
+| `applicableGender` | `number` | 否 | 适用人群:`0=不限`,`1=仅男`,`2=仅女` | `0` |
+| `ageMin` | `number` | 否 | 适用年龄最小值(岁) | `18` |
+| `ageMax` | `number` | 否 | 适用年龄最大值(岁) | `60` |
+| `description` | `string` | 否 | 方案描述(最长 500 字符) | `针对湿气重的人群定制` |
+| `status` | `number` | 否 | 状态:`0=草稿`,`1=已发布`。不传默认为 `1` | `1` |
+| `steps` | `array` | 是 | 穴位步骤列表,至少 1 个 | 见下方 `steps` 字段说明 |
+
+`steps` 数组中每个元素字段:
+
+| 参数名 | 类型 | 是否必填 | 说明 |
+| --- | --- | --- | --- |
+| `acupointId` | `number` | 是 | 穴位 ID,对应 `acupoint.id` |
+| `side` | `number` | 否 | 侧别:`1=中心`,`2=左侧`,`3=右侧`,`4=双侧`。不传则跟随穴位默认侧别(一般为 `1`) |
+| `temperature` | `number` | 否 | 温度(°C,范围 35-50) |
+| `duration` | `number` | 否 | 时长(分钟,范围 1-30) |
+| `techniqueId` | `number` | 否 | 艾灸手法 ID(对应 `moxibustion_technique.id`) |
+| `remark` | `string` | 否 | 步骤备注(最长 100 字符) |
+
+> 说明:
+> - 请求体中**无需**传 `id`、`planId`、`stepOrder`、`acupointName`、`techniqueName` 等字段,后端会按 `steps` 数组顺序自动重排 `stepOrder`(从 1 开始),并通过 `acupointId` 自动带出 `acupointName`、`techniqueName`。
+> - 修改接口(`PUT /api/app/custom-plan`)与本接口共用同一份步骤结构,请求示例保持一致。
+
+#### 3.5.4 调用示例
+
+```http
+POST /api/app/custom-plan HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+Content-Type: application/json
+
+{
+  "profileId": 4,
+  "name": "我的祛湿方案",
+  "symptoms": "关节酸痛,乏力",
+  "applicableGender": 0,
+  "ageMin": 18,
+  "ageMax": 60,
+  "description": "针对湿气重的人群定制",
+  "status": 1,
+  "steps": [
+    {
+      "acupointId": 371,
+      "side": 1,
+      "temperature": 42.00,
+      "duration": 10,
+      "techniqueId": 1,
+      "remark": "核心穴位"
+    },
+    {
+      "acupointId": 362,
+      "side": 1,
+      "temperature": 42.00,
+      "duration": 10,
+      "techniqueId": 1,
+      "remark": ""
+    }
+  ]
+}
+```
+
+```bash
+curl -X POST "http://localhost:18888/api/app/custom-plan" \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"profileId":4,"name":"我的祛湿方案","symptoms":"关节酸痛,乏力","applicableGender":0,"ageMin":18,"ageMax":60,"description":"针对湿气重的人群定制","status":1,"steps":[{"acupointId":371,"side":1,"temperature":42.00,"duration":10,"techniqueId":1,"remark":"核心穴位"},{"acupointId":362,"side":1,"temperature":42.00,"duration":10,"techniqueId":1,"remark":""}]}'
+```
+
+#### 3.5.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": 2067138786800111618
+}
+```
+
+#### 3.5.6 失败返回示例
+
+方案名称重复:
+
+```json
+{
+  "code": 1702,
+  "message": "方案名称已存在",
+  "data": null
+}
+```
+
+步骤为空:
+
+```json
+{
+  "code": 1706,
+  "message": "方案步骤至少保留1个",
+  "data": null
+}
+```
+
+子用户档案ID为空:
+
+```json
+{
+  "code": 400,
+  "message": "子用户档案ID不能为空",
+  "data": null
+}
+```
+
+子用户档案不存在或不属于当前账号:
+
+```json
+{
+  "code": 400,
+  "message": "子用户档案不存在或不属于当前账号",
+  "data": null
+}
+```
+
+#### 3.5.7 备注
+
+- `profileId` 为必传参数,用于指定方案归属的子用户。后端会校验该子用户是否属于当前 APP 账号,不传或不属于当前账号均返回 `400` 错误。
+- `modeType`、`effectType`、`planCode`、`authorId`、`authorName`、`authorType` 等字段前端无需传入,后端统一设置。
+- 方案名称在同一子用户范围内不可重复,若重复返回 `1702` 错误。
+- 成功返回 `data` 为新方案的 ID,后续可用于详情查询、修改和删除。
+- `steps` 不能为空,至少包含 1 个穴位步骤。
+
+### 3.6 查询当前用户的自定义方案列表
+
+#### 3.6.1 接口用途
+
+用于 APP 端查询当前登录账号下,自己(作为创作人)创建的所有自定义模式(`modeType=3`)方案的基本信息。
+
+当前场景:APP 中"我的方案"页面展示用户自己创建的自定义方案列表,用于查看、复用或继续编辑。
+
+> 本接口**只返回方案基本信息**,不包含穴位步骤。查看方案详情(含穴位步骤)请使用第 7 个接口 `GET /api/app/custom-plan/{id}`。
+
+#### 3.6.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/custom-plan/list`
+- 完整示例:`http://localhost:18888/api/app/custom-plan/list`
+
+#### 3.6.3 请求参数
+
+无查询参数。后端通过 `Authorization` 请求头中的 Token 获取当前 APP 账号 ID,查询该账号下所有子用户(`user_profile`),返回创作人类型为用户(`authorType=2`)、模式为自定义模式(`modeType=3`)且创作人 ID 属于当前账号下子用户的方案。
+
+#### 3.6.4 调用示例
+
+```http
+GET /api/app/custom-plan/list HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/custom-plan/list" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.6.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": [
+    {
+      "id": 1001,
+      "planCode": "SA1B2C3",
+      "name": "我的颈椎调理方案",
+      "modeType": 3,
+      "effectType": "",
+      "symptoms": "肩颈酸痛,头晕",
+      "applicableGender": 0,
+      "ageMin": 18,
+      "ageMax": 60,
+      "description": "针对长期伏案人群的颈椎调理方案",
+      "authorId": 17320687382,
+      "authorName": "张三",
+      "authorType": 2,
+      "useCount": 12,
+      "avgRating": 4.80,
+      "lastUseTime": "2026-06-15 08:30:00",
+      "status": 1,
+      "createTime": "2026-06-10 09:00:00",
+      "updateTime": "2026-06-12 10:30:00"
+    },
+    {
+      "id": 1002,
+      "planCode": "S9X8Y7Z6",
+      "name": "我的祛湿方案",
+      "modeType": 3,
+      "effectType": "",
+      "symptoms": "关节酸痛,乏力",
+      "applicableGender": 0,
+      "ageMin": 20,
+      "ageMax": 60,
+      "description": "针对湿气重的人群定制",
+      "authorId": 17320687382,
+      "authorName": "张三",
+      "authorType": 2,
+      "useCount": 0,
+      "avgRating": null,
+      "lastUseTime": null,
+      "status": 1,
+      "createTime": "2026-06-13 14:00:00",
+      "updateTime": "2026-06-13 14:00:00"
+    }
+  ]
+}
+```
+
+#### 3.6.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 方案 ID,对应 `plan.id` |
+| `planCode` | `string` | 方案编码 |
+| `name` | `string` | 方案名称 |
+| `modeType` | `number` | 模式类型,本接口固定返回 `3`(自定义模式) |
+| `effectType` | `string` | 作用类型,自定义模式固定为空字符串 |
+| `symptoms` | `string` | 适用症状(逗号分隔) |
+| `applicableGender` | `number` | 适用人群:`0=不限`,`1=仅男`,`2=仅女` |
+| `ageMin` | `number/null` | 适用年龄最小值(岁) |
+| `ageMax` | `number/null` | 适用年龄最大值(岁) |
+| `description` | `string` | 方案描述 |
+| `authorId` | `number` | 创作人 ID,等于子用户 ID(`user_profile.id`) |
+| `authorName` | `string` | 创作人名称(冗余字段) |
+| `authorType` | `number` | 创作人类型,本接口固定返回 `2`(用户创作) |
+| `useCount` | `number` | 使用人数 |
+| `avgRating` | `number/null` | 平均评分 |
+| `lastUseTime` | `string/null` | 最后使用时间 |
+| `status` | `number` | 状态:`0=草稿`,`1=已发布`,`2=已下架` |
+| `createTime` | `string` | 创建时间 |
+| `updateTime` | `string` | 更新时间 |
+
+#### 3.6.7 备注
+
+- 本接口**不返回**穴位步骤列表(`steps`),仅返回方案基本信息。如需查看方案详情和穴位步骤,请调用第 7 个接口 `GET /api/app/custom-plan/{id}`。
+- 本接口仅返回当前 APP 账号下子用户作为"创作人"的自定义模式方案,不会返回系统方案或其他账号子用户创作的方案。
+- 后端根据 Token 解析当前账号 ID,查询该账号下所有子用户 ID,按 `authorType=2`、`modeType=3`、`authorId` 为当前账号下子用户 ID 严格过滤。
+- `useCount`、`avgRating` 为方案被使用后的统计字段,新建方案的初始值为 `0`/`null`。
+- 结果按 `updateTime` 倒序返回,最近修改或创建的方案排在前。
+
+### 3.7 查询用户自定义方案详情
+
+#### 3.7.1 接口用途
+
+用于 APP 端查询当前登录账号下,自己创建的某个自定义模式方案详情,校验方案归属权,避免越权访问。
+
+#### 3.7.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/custom-plan/{id}`
+- 完整示例:`http://localhost:18888/api/app/custom-plan/1001`
+
+#### 3.7.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 方案 ID,对应 `plan.id`,通过路径参数传递 | `1001` |
+
+#### 3.7.4 调用示例
+
+```http
+GET /api/app/custom-plan/1001 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/custom-plan/1001" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.7.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 1001,
+    "planCode": "SA1B2C3",
+    "name": "我的颈椎调理方案",
+    "modeType": 3,
+    "symptoms": "肩颈酸痛,头晕",
+    "applicableGender": 0,
+    "ageMin": 18,
+    "ageMax": 60,
+    "description": "针对长期伏案人群的颈椎调理方案",
+    "useCount": 12,
+    "avgRating": 4.80,
+    "status": 1,
+    "createTime": "2026-06-10 09:00:00",
+    "updateTime": "2026-06-12 10:30:00",
+    "steps": [
+      {
+        "id": 5001,
+        "planId": 1001,
+        "stepOrder": 1,
+        "acupointId": 371,
+        "acupointName": "大椎穴",
+        "side": 1,
+        "temperature": 42.00,
+        "duration": 10,
+        "techniqueId": 1,
+        "techniqueName": "温和灸",
+        "remark": ""
+      }
+    ]
+  }
+}
+```
+
+#### 3.7.6 字段说明
+
+方案基本信息字段(`id`、`planCode`、`name`、`modeType`、`effectType`、`symptoms`、`applicableGender`、`ageMin`、`ageMax`、`description`、`authorId`、`authorName`、`authorType`、`useCount`、`avgRating`、`lastUseTime`、`status`、`createTime`、`updateTime`)定义同 3.6.6 节。
+
+`steps` 穴位步骤列表字段:
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `steps` | `array` | 穴位步骤列表,按 `stepOrder` 升序 |
+| `steps[].id` | `number` | 步骤 ID,对应 `plan_step.id` |
+| `steps[].planId` | `number` | 所属方案 ID |
+| `steps[].stepOrder` | `number` | 步骤序号(从 1 开始) |
+| `steps[].acupointId` | `number` | 穴位 ID,对应 `acupoint.id` |
+| `steps[].acupointName` | `string` | 穴位名称(冗余字段) |
+| `steps[].side` | `number` | 侧别:`1=中心`,`2=左侧`,`3=右侧`,`4=双侧` |
+| `steps[].temperature` | `number` | 温度(°C,范围 35-50) |
+| `steps[].duration` | `number` | 时长(分钟,范围 1-30) |
+| `steps[].techniqueId` | `number/null` | 艾灸手法 ID |
+| `steps[].techniqueName` | `string/null` | 艾灸手法名称(冗余字段) |
+| `steps[].remark` | `string` | 步骤备注 |
+
+#### 3.7.7 失败返回示例
+
+方案不存在或非当前用户创建的自定义方案:
+
+```json
+{
+  "code": 1700,
+  "message": "方案不存在",
+  "data": null
+}
+```
+
+#### 3.7.8 备注
+
+- 越权校验在 Service 层完成:仅当 `modeType=3`、`authorType=2`、`authorId` 为当前账号下子用户 ID 同时满足时才返回方案详情,否则按"方案不存在"处理。
+- 不会返回其他用户创作的方案、系统预设方案(`modeType=1/2/4`)的详情。
+- APP 在编辑方案前应先调用本接口获取最新数据。
+
+### 3.8 查询专业模式方案列表
+
+#### 3.8.1 接口用途
+
+用于 APP 端查询专业模式(`modeType=2`)的系统方案列表。
+
+专业模式方案由后台预置并在所有 APP 用户间共享,**没有用户差异**:例如方案 ABC 存在时,所有登录的 APP 用户都能看到 ABC 三个方案,不会因为账号不同而出现不同结果。
+
+当前场景:APP 中"专业模式"页面展示系统预置的方案列表(驱寒、祛湿、祛风、化瘀、活血、化痰、养颜、扶阳等),供用户选择使用。
+
+> 本接口**只返回方案基本信息**,不包含穴位步骤。查看方案详情(含穴位步骤)请使用第 9 个接口 `GET /api/app/professional-plan/{id}`。
+
+#### 3.8.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/professional-plan/list`
+- 完整示例:`http://localhost:18888/api/app/professional-plan/list`
+
+#### 3.8.3 请求参数
+
+无查询参数。后端通过 `Authorization` 请求头中的 Token 校验登录态,并按 `modeType=2`、`status=1`(已发布)返回专业模式方案列表,不区分用户。
+
+#### 3.8.4 调用示例
+
+```http
+GET /api/app/professional-plan/list HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/professional-plan/list" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.8.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": [
+    {
+      "id": 10001,
+      "planCode": "S002",
+      "name": "专业驱寒方案",
+      "modeType": 2,
+      "effectType": "驱寒",
+      "symptoms": "怕冷,手脚冰凉,畏寒",
+      "applicableGender": 0,
+      "ageMin": 1,
+      "ageMax": 120,
+      "description": "针对阳虚畏寒人群的基础驱寒方案。",
+      "authorId": 1,
+      "authorName": "ADMIN",
+      "authorType": 1,
+      "useCount": 0,
+      "avgRating": null,
+      "lastUseTime": null,
+      "status": 1,
+      "createTime": "2026-06-01 09:00:00",
+      "updateTime": "2026-06-01 09:00:00"
+    },
+    {
+      "id": 10002,
+      "planCode": "S003",
+      "name": "专业祛湿方案",
+      "modeType": 2,
+      "effectType": "祛湿",
+      "symptoms": "困倦,湿重,食欲差",
+      "applicableGender": 0,
+      "ageMin": 1,
+      "ageMax": 120,
+      "description": "围绕脾胃与膀胱经调理的祛湿方案。",
+      "authorId": 1,
+      "authorName": "ADMIN",
+      "authorType": 1,
+      "useCount": 0,
+      "avgRating": null,
+      "lastUseTime": null,
+      "status": 1,
+      "createTime": "2026-06-01 09:00:00",
+      "updateTime": "2026-06-01 09:00:00"
+    }
+  ]
+}
+```
+
+#### 3.8.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 方案 ID,对应 `plan.id` |
+| `planCode` | `string` | 方案编码 |
+| `name` | `string` | 方案名称 |
+| `modeType` | `number` | 模式类型,本接口固定返回 `2`(专业模式) |
+| `effectType` | `string` | 作用类型:驱寒/祛湿/祛风/化瘀/活血/化痰/养颜/扶阳 |
+| `symptoms` | `string` | 适用症状(逗号分隔) |
+| `applicableGender` | `number` | 适用人群:`0=不限`,`1=仅男`,`2=仅女` |
+| `ageMin` | `number` | 适用年龄最小值(岁) |
+| `ageMax` | `number` | 适用年龄最大值(岁) |
+| `description` | `string` | 方案描述 |
+| `authorId` | `number` | 创作人 ID |
+| `authorName` | `string` | 创作人名称(系统预置为 `ADMIN`) |
+| `authorType` | `number` | 创作人类型:`1=管理员`,`2=用户`,`3=专家`,系统预置方案固定为 `1` |
+| `useCount` | `number` | 使用人数 |
+| `avgRating` | `number/null` | 平均评分 |
+| `lastUseTime` | `string/null` | 最后使用时间 |
+| `status` | `number` | 状态:本接口固定返回 `1`(已发布) |
+| `createTime` | `string` | 创建时间 |
+| `updateTime` | `string` | 更新时间 |
+
+#### 3.8.7 备注
+
+- 本接口**不返回**穴位步骤列表(`steps`),仅返回方案基本信息。如需查看方案详情和穴位步骤,请调用第 9 个接口 `GET /api/app/professional-plan/{id}`。
+- 本接口返回的方案对**所有 APP 用户共享**,不因账号不同而出现不同结果。
+- 后端按 `modeType=2`、`status=1`(已发布)、`deleted=0` 严格过滤,不会返回草稿、已下架或已删除的方案,也不会返回一键艾灸(`modeType=1`)、自定义模式(`modeType=3`)、延年圣手(`modeType=4`)的方案。
+- 结果按 `effectType` 升序、`id` 升序返回,相同作用类型下按 ID 顺序展示。
+
+### 3.9 查询专业模式方案详情
+
+#### 3.9.1 接口用途
+
+用于 APP 端查询专业模式(`modeType=2`)的某个系统方案详情,含穴位步骤列表。
+
+#### 3.9.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/professional-plan/{id}`
+- 完整示例:`http://localhost:18888/api/app/professional-plan/10001`
+
+#### 3.9.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 方案 ID,对应 `plan.id`,通过路径参数传递 | `10001` |
+
+#### 3.9.4 调用示例
+
+```http
+GET /api/app/professional-plan/10001 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/professional-plan/10001" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.9.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 10001,
+    "planCode": "S002",
+    "name": "专业驱寒方案",
+    "modeType": 2,
+    "symptoms": "怕冷,手脚冰凉,畏寒",
+    "applicableGender": 0,
+    "ageMin": 1,
+    "ageMax": 120,
+    "description": "针对阳虚畏寒人群的基础驱寒方案。",
+    "useCount": 0,
+    "avgRating": null,
+    "status": 1,
+    "createTime": "2026-06-01 09:00:00",
+    "updateTime": "2026-06-01 09:00:00",
+    "steps": [
+      {
+        "id": 20001,
+        "planId": 10001,
+        "stepOrder": 1,
+        "acupointId": 1,
+        "acupointName": "大椎穴",
+        "side": 1,
+        "temperature": 44.00,
+        "duration": 8,
+        "techniqueId": 3,
+        "techniqueName": "雀啄灸",
+        "remark": "驱寒起始"
+      }
+    ]
+  }
+}
+```
+
+#### 3.9.6 字段说明
+
+方案基本信息字段(`id`、`planCode`、`name`、`modeType`、`effectType`、`symptoms`、`applicableGender`、`ageMin`、`ageMax`、`description`、`authorId`、`authorName`、`authorType`、`useCount`、`avgRating`、`lastUseTime`、`status`、`createTime`、`updateTime`)定义同 3.8.6 节。
+
+`steps` 穴位步骤列表字段定义同 3.7.6 节。
+
+#### 3.9.7 失败返回示例
+
+方案不存在、非专业模式、未发布或已删除:
+
+```json
+{
+  "code": 1700,
+  "message": "方案不存在",
+  "data": null
+}
+```
+
+#### 3.9.8 备注
+
+- 越权校验在 Service 层完成:仅当 `modeType=2`、`status=1`(已发布)、`deleted=0` 同时满足时才返回方案详情,否则按"方案不存在"处理。
+- 本接口为只读,不会修改任何数据。
+- 方案对所有 APP 用户开放,任意登录用户均可查询。
+- 不会返回自定义模式(`modeType=3`)、一键艾灸(`modeType=1`)、延年圣手(`modeType=4`)的方案详情,防止越权访问。
+
+### 3.10 查询延年圣手模式方案列表
+
+#### 3.10.1 接口用途
+
+用于 APP 端查询延年圣手模式(`modeType=4`)的系统方案列表。
+
+延年圣手模式方案由后台预置并在所有 APP 用户间共享,**没有用户差异**:例如方案 XYZ 存在时,所有登录的 APP 用户都能看到 XYZ 三个方案。
+
+当前场景:APP 中"延年圣手"页面展示系统预置方案列表,供用户选择使用。
+
+> 本接口**只返回方案基本信息**,不包含穴位步骤。查看方案详情(含穴位步骤)请使用第 11 个接口 `GET /api/app/master-plan/{id}`。
+
+#### 3.10.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/master-plan/list`
+- 完整示例:`http://localhost:18888/api/app/master-plan/list`
+
+#### 3.10.3 请求参数
+
+无查询参数。后端通过 `Authorization` 请求头中的 Token 校验登录态,并按 `modeType=4`、`status=1`(已发布)返回延年圣手模式方案列表,不区分用户。
+
+#### 3.10.4 调用示例
+
+```http
+GET /api/app/master-plan/list HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/master-plan/list" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.10.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": [
+    {
+      "id": 20001,
+      "planCode": "M001",
+      "name": "延年圣手·扶阳固本",
+      "modeType": 4,
+      "effectType": "",
+      "symptoms": "腰膝酸软,畏寒肢冷",
+      "applicableGender": 0,
+      "ageMin": 18,
+      "ageMax": 80,
+      "description": "针对中老年体虚人群的延年圣手方案。",
+      "authorId": 1,
+      "authorName": "ADMIN",
+      "authorType": 1,
+      "useCount": 0,
+      "avgRating": null,
+      "lastUseTime": null,
+      "status": 1,
+      "createTime": "2026-06-01 09:00:00",
+      "updateTime": "2026-06-01 09:00:00"
+    }
+  ]
+}
+```
+
+#### 3.10.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | `number` | 方案 ID,对应 `plan.id` |
+| `planCode` | `string` | 方案编码 |
+| `name` | `string` | 方案名称 |
+| `modeType` | `number` | 模式类型,本接口固定返回 `4`(延年圣手) |
+| `effectType` | `string` | 作用类型,延年圣手模式可为空字符串 |
+| `symptoms` | `string` | 适用症状(逗号分隔) |
+| `applicableGender` | `number` | 适用人群:`0=不限`,`1=仅男`,`2=仅女` |
+| `ageMin` | `number/null` | 适用年龄最小值(岁) |
+| `ageMax` | `number/null` | 适用年龄最大值(岁) |
+| `description` | `string` | 方案描述 |
+| `authorId` | `number` | 创作人 ID |
+| `authorName` | `string` | 创作人名称 |
+| `authorType` | `number` | 创作人类型 |
+| `useCount` | `number` | 使用人数 |
+| `avgRating` | `number/null` | 平均评分 |
+| `lastUseTime` | `string/null` | 最后使用时间 |
+| `status` | `number` | 状态:本接口固定返回 `1`(已发布) |
+| `createTime` | `string` | 创建时间 |
+| `updateTime` | `string` | 更新时间 |
+
+#### 3.10.7 备注
+
+- 本接口**不返回**穴位步骤列表(`steps`),仅返回方案基本信息。如需查看方案详情和穴位步骤,请调用第 11 个接口 `GET /api/app/master-plan/{id}`。
+- 本接口返回的方案对**所有 APP 用户共享**,不因账号不同而出现不同结果。
+- 后端按 `modeType=4`、`status=1`(已发布)、`deleted=0` 严格过滤,不会返回草稿、已下架或已删除的方案,也不会返回一键艾灸(`modeType=1`)、专业模式(`modeType=2`)、自定义模式(`modeType=3`)的方案。
+- 结果按 `id` 升序返回。
+
+### 3.11 查询延年圣手模式方案详情
+
+#### 3.11.1 接口用途
+
+用于 APP 端查询延年圣手模式(`modeType=4`)的某个系统方案详情,含穴位步骤列表。
+
+#### 3.11.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/master-plan/{id}`
+- 完整示例:`http://localhost:18888/api/app/master-plan/20001`
+
+#### 3.11.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 方案 ID,对应 `plan.id`,通过路径参数传递 | `20001` |
+
+#### 3.11.4 调用示例
+
+```http
+GET /api/app/master-plan/20001 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/master-plan/20001" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.11.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "id": 20001,
+    "planCode": "M001",
+    "name": "延年圣手·扶阳固本",
+    "modeType": 4,
+    "symptoms": "腰膝酸软,畏寒肢冷",
+    "applicableGender": 0,
+    "ageMin": 18,
+    "ageMax": 80,
+    "description": "针对中老年体虚人群的延年圣手方案。",
+    "useCount": 0,
+    "avgRating": null,
+    "status": 1,
+    "createTime": "2026-06-01 09:00:00",
+    "updateTime": "2026-06-01 09:00:00",
+    "steps": [
+      {
+        "id": 30001,
+        "planId": 20001,
+        "stepOrder": 1,
+        "acupointId": 8,
+        "acupointName": "肾俞穴",
+        "side": 4,
+        "temperature": 44.00,
+        "duration": 10,
+        "techniqueId": 2,
+        "techniqueName": "温和灸",
+        "remark": "补肾扶阳"
+      }
+    ]
+  }
+}
+```
+
+#### 3.11.6 字段说明
+
+方案基本信息字段(`id`、`planCode`、`name`、`modeType`、`effectType`、`symptoms`、`applicableGender`、`ageMin`、`ageMax`、`description`、`authorId`、`authorName`、`authorType`、`useCount`、`avgRating`、`lastUseTime`、`status`、`createTime`、`updateTime`)定义同 3.10.6 节。
+
+`steps` 穴位步骤列表字段定义同 3.7.6 节。
+
+#### 3.11.7 失败返回示例
+
+方案不存在、非延年圣手模式、未发布或已删除:
+
+```json
+{
+  "code": 1700,
+  "message": "方案不存在",
+  "data": null
+}
+```
+
+#### 3.11.8 备注
+
+- 越权校验在 Service 层完成:仅当 `modeType=4`、`status=1`(已发布)、`deleted=0` 同时满足时才返回方案详情,否则按"方案不存在"处理。
+- 本接口为只读,不会修改任何数据。
+- 方案对所有 APP 用户开放,任意登录用户均可查询。
+- 不会返回自定义模式(`modeType=3`)、一键艾灸(`modeType=1`)、专业模式(`modeType=2`)的方案详情,防止越权访问。
+
+
+
+### 3.12 修改用户自定义方案
+
+#### 3.12.1 接口用途
+
+用于 APP 端让用户修改自己创建的自定义模式方案,包括方案基本信息和穴位步骤列表。
+
+请求体格式与"新增用户自定义方案"接口(`POST /api/app/custom-plan`,见 3.5 节)基本一致,区别如下:多一个 `id` 字段(必传);**不需要传 `profileId`**(方案归属在创建时已确定,修改时不变更归属子用户)。`steps` 结构、字段含义、必填/可选规则与新增接口完全相同。
+
+#### 3.12.2 请求信息
+
+- 请求方式:`PUT`
+- URI:`/api/app/custom-plan`
+- 完整示例:`http://localhost:18888/api/app/custom-plan`
+
+#### 3.12.3 请求参数
+
+请求体格式:`application/json`
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 方案 ID,对应 `plan.id` | `1001` |
+| `name` | `string` | 是 | 方案名称(最长 100 字符,同一子用户下不可重复) | `我的祛湿方案` |
+| `symptoms` | `string` | 否 | 适用症状(逗号分隔) | `关节酸痛,乏力` |
+| `applicableGender` | `number` | 否 | 适用人群:`0=不限`,`1=仅男`,`2=仅女` | `0` |
+| `ageMin` | `number` | 否 | 适用年龄最小值(岁) | `18` |
+| `ageMax` | `number` | 否 | 适用年龄最大值(岁) | `60` |
+| `description` | `string` | 否 | 方案描述(最长 500 字符) | `针对湿气重的人群定制` |
+| `status` | `number` | 否 | 状态:`0=草稿`,`1=已发布`,`2=已下架` | `1` |
+| `steps` | `array` | 是 | 穴位步骤列表,传 `[]` 表示清空所有步骤,传 `null` 表示保留原步骤 | 见下方 `steps` 字段说明 |
+
+`steps` 数组中每个元素字段(与 3.5 节"新增用户自定义方案"完全一致):
+
+| 参数名 | 类型 | 是否必填 | 说明 |
+| --- | --- | --- | --- |
+| `acupointId` | `number` | 是 | 穴位 ID,对应 `acupoint.id` |
+| `side` | `number` | 否 | 侧别:`1=中心`,`2=左侧`,`3=右侧`,`4=双侧`。不传则跟随穴位默认侧别(一般为 `1`) |
+| `temperature` | `number` | 否 | 温度(°C,范围 35-50) |
+| `duration` | `number` | 否 | 时长(分钟,范围 1-30) |
+| `techniqueId` | `number` | 否 | 艾灸手法 ID(对应 `moxibustion_technique.id`) |
+| `remark` | `string` | 否 | 步骤备注(最长 100 字符) |
+
+> 说明:请求体中**无需**传 `id`(步骤)、`planId`、`stepOrder`、`acupointName`、`techniqueName` 等字段,后端会按 `steps` 数组顺序自动重排 `stepOrder`(从 1 开始),并通过 `acupointId` 自动带出 `acupointName`、`techniqueName`。
+
+#### 3.12.4 调用示例
+
+```http
+PUT /api/app/custom-plan HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+Content-Type: application/json
+
+{
+  "id": 1001,
+  "name": "我的祛湿方案",
+  "symptoms": "关节酸痛,乏力",
+  "applicableGender": 0,
+  "ageMin": 18,
+  "ageMax": 60,
+  "description": "针对湿气重的人群定制",
+  "status": 1,
+  "steps": [
+    {
+      "acupointId": 371,
+      "side": 1,
+      "temperature": 42.00,
+      "duration": 10,
+      "techniqueId": 1,
+      "remark": "核心穴位"
+    },
+    {
+      "acupointId": 362,
+      "side": 1,
+      "temperature": 42.00,
+      "duration": 10,
+      "techniqueId": 1,
+      "remark": ""
+    }
+  ]
+}
+```
+
+```bash
+curl -X PUT "http://localhost:18888/api/app/custom-plan" \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"id":1001,"name":"我的祛湿方案","symptoms":"关节酸痛,乏力","applicableGender":0,"ageMin":18,"ageMax":60,"description":"针对湿气重的人群定制","status":1,"steps":[{"acupointId":371,"side":1,"temperature":42.00,"duration":10,"techniqueId":1,"remark":"核心穴位"},{"acupointId":362,"side":1,"temperature":42.00,"duration":10,"techniqueId":1,"remark":""}]}'
+```
+
+#### 3.12.5 成功返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": null
+}
+```
+
+#### 3.12.6 失败返回示例
+
+方案不存在或非当前用户创建的自定义方案:
+
+```json
+{
+  "code": 1700,
+  "message": "方案不存在",
+  "data": null
+}
+```
+
+方案名称已存在:
+
+```json
+{
+  "code": 1702,
+  "message": "方案名称已存在",
+  "data": null
+}
+```
+
+穴位不存在或已停用:
+
+```json
+{
+  "code": 1400,
+  "message": "穴位不存在",
+  "data": null
+}
+```
+
+#### 3.12.7 备注
+
+- 本接口需要 APP 登录后的 Token,不接受后台管理员 Token。
+- 后端会自动校验 `modeType=3`、`authorType=2`、`authorId` 为当前账号下子用户 ID,不满足时按"方案不存在"处理,避免越权。
+- 修改方案时后端使用"先删后增"的方式更新穴位步骤:`steps` 传 `null` 时保留原步骤;传 `[]` 时清空所有步骤;传非空数组时整体替换为新步骤并重新计算步骤序号。
+- 方案名称在同一子用户范围内唯一,修改时不能与该子用户下其他方案重名。
+- 穴位必须处于启用状态(`acupoint.status=1`)才能被引用。
+- `modeType`、`effectType`、`planCode`、`authorId`、`authorName`、`authorType`、`useCount`、`avgRating` 等字段为系统字段,APP 传入也会被忽略,不允许用户修改。
+### 3.13 删除用户自定义方案
+
+#### 3.13.1 接口用途
+
+用于 APP 端用户删除自己的自定义方案。
+
+后端校验方案归属(`modeType=3`、`authorType=2`、`authorId` 为当前账号下子用户 ID),仅允许删除自己创建的自定义方案。删除为物理删除,会同步删除方案下的穴位步骤。
+
+#### 3.13.2 请求信息
+
+- 请求方式:`DELETE`
+- URI:`/api/app/custom-plan/{id}`
+- 完整示例:`http://localhost:18888/api/app/custom-plan/2067138786800111618`
+
+#### 3.13.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `id` | `number` | 是 | 方案 ID,对应 `plan.id`,通过路径参数传递 | `2067138786800111618` |
+
+#### 3.13.4 调用示例
+
+```http
+DELETE /api/app/custom-plan/2067138786800111618 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X DELETE "http://localhost:18888/api/app/custom-plan/2067138786800111618" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.13.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": null
+}
+```
+
+#### 3.13.6 失败返回示例
+
+方案不存在或不属于当前用户:
+
+```json
+{
+  "code": 1700,
+  "message": "方案不存在",
+  "data": null
+}
+```
+
+#### 3.13.7 备注
+
+- 删除为物理删除,不可恢复,请谨慎操作。
+- 仅允许删除创作人为当前账号下子用户的自定义模式方案,不存在或不属于当前账号的方案均返回 `1700` 错误。
+- 删除方案时会同步删除该方案下的所有穴位步骤。
+
+### 3.14 查询群组成员列表
+
+#### 3.14.1 接口用途
+
+用于 APP 端查询指定家庭设备群组下的所有成员列表。
+
+后端会校验当前 APP 账号是否属于该家庭设备群组(`device_group_member.app_user_id = 当前账号ID`),仅返回当前账号所属群组的成员,越权或群组不存在时返回 `403` 错误。
+
+#### 3.14.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/group/{groupId}/members`
+- 完整示例:`http://localhost:18888/api/app/group/2001/members`
+
+#### 3.14.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `groupId` | `number` | 是 | 家庭设备群组 ID,对应 `device_group.id`,通过路径参数传递 | `2001` |
+
+#### 3.14.4 调用示例
+
+```http
+GET /api/app/group/2001/members HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/group/2001/members" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.14.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": [
+    {
+      "memberId": 3001,
+      "groupId": 2001,
+      "profileId": 123,
+      "appUserId": 17320687382,
+      "status": 1,
+      "joinTime": "2026-06-01 09:00:00",
+      "profileComplete": true,
+      "name": "张三",
+      "gender": 1,
+      "age": 35,
+      "phone": "13800138000",
+      "provinceCode": "440000",
+      "cityCode": "440300",
+      "districtCode": "440305",
+      "address": "广东省深圳市南山区",
+      "shoulderWidth": 42.50,
+      "bodyHeight": 85.00,
+      "spineLength": 48.00,
+      "height": 175.00,
+      "weight": 70.00,
+      "fingerWidth1": 2.20,
+      "fingerWidth15": 3.20,
+      "fingerWidth3": 6.50,
+      "acupointTableId": 5001,
+      "profile": {
+        "profileId": 123,
+        "id": 123,
+        "appUserId": 17320687382,
+        "name": "张三",
+        "gender": 1,
+        "age": 35,
+        "phone": "13800138000",
+        "provinceCode": "440000",
+        "cityCode": "440300",
+        "districtCode": "440305",
+        "address": "广东省深圳市南山区",
+        "shoulderWidth": 42.50,
+        "bodyHeight": 85.00,
+        "spineLength": 48.00,
+        "height": 175.00,
+        "weight": 70.00,
+        "fingerWidth1": 2.20,
+        "fingerWidth15": 3.20,
+        "fingerWidth3": 6.50,
+        "acupointTableId": 5001,
+        "profileComplete": true
+      }
+    }
+  ]
+}
+```
+
+#### 3.14.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `memberId` | `number` | 群组成员记录 ID,对应 `device_group_member.id` |
+| `groupId` | `number` | 所属群组 ID,对应 `device_group.id` |
+| `profileId` | `number` | 关联的用户档案 ID,对应 `user_profile.id` |
+| `appUserId` | `number/null` | 关联的 APP 账号 ID,对应 `app_user.id`,未关联账号时为 `null` |
+| `status` | `number` | 成员状态:`1=正常`,`0=已移除` |
+| `joinTime` | `string` | 加入时间 |
+| `profileComplete` | `boolean` | 用户档案是否已补全设备使用所需资料 |
+| `name` | `string` | 姓名 |
+| `gender` | `number` | 性别:`1=男`,`2=女` |
+| `age` | `number` | 年龄 |
+| `phone` | `string` | 手机号 |
+| `provinceCode` | `string` | 省级行政区划编码 |
+| `cityCode` | `string` | 市级行政区划编码 |
+| `districtCode` | `string` | 区/县级行政区划编码 |
+| `address` | `string` | 详细地址 |
+| `shoulderWidth` | `number` | 肩宽(cm) |
+| `bodyHeight` | `number` | 身长/坐高(cm) |
+| `spineLength` | `number` | C7-S4 脊柱长度(cm) |
+| `height` | `number` | 身高(cm) |
+| `weight` | `number` | 体重(kg) |
+| `fingerWidth1` | `number` | 一寸指宽(cm) |
+| `fingerWidth15` | `number` | 1.5 寸指宽(cm) |
+| `fingerWidth3` | `number` | 三寸指宽(cm) |
+| `acupointTableId` | `number/null` | 已生成的穴位对照表 ID,对应 `user_acupoint.id` |
+| `userCategoryId` | `number/null` | 用户分类 ID |
+| `profile` | `object` | 嵌套的完整用户档案对象,字段同上 |
+
+#### 3.14.7 失败返回示例
+
+群组不存在或当前账号无权访问:
+
+```json
+{
+  "code": 403,
+  "message": "无权访问该家庭群组",
+  "data": null
+}
+```
+
+#### 3.14.8 备注
+
+- 越权校验在 Service 层完成,仅当 `device_group_member.app_user_id = 当前账号ID` 且 `status = 1` 时才返回成员列表。
+- 本接口不返回分页结构,按 `joinTime` 升序返回所有启用成员。
+- 响应中同时包含扁平化的成员字段和嵌套的 `profile` 对象,内容一致。
+- 该接口只服务于家庭设备(`deviceType=1`)对应的群组,公共设备(`deviceType=2`)没有群组概念。
+- 不会返回 `status=0`(已移除)的成员记录。
+
+### 3.15 新增群组成员
+
+#### 3.15.1 接口用途
+
+用于 APP 端让当前登录账号向指定的家庭设备群组添加新成员。
+
+后端校验:
+- 当前 APP 账号必须属于该家庭设备群组(`device_group_member.app_user_id = 当前账号ID`)
+- 同一群组内手机号不可重复
+- 同一群组内同一 APP 账号不可重复
+- 指宽数据需符合性别对应的合理范围
+
+新增成员时:
+- 若手机号已注册 APP 账号,自动关联已有 APP 账号,并复用其用户档案或在该账号下创建新档案
+- 若手机号未注册,自动创建新 APP 账号和用户档案
+- 自动维护 `user_device` 绑定记录和 `user_acupoint` 穴位对照表(档案完整时)
+
+#### 3.15.2 请求信息
+
+- 请求方式:`POST`
+- URI:`/api/app/group/{groupId}/members`
+- 完整示例:`http://localhost:18888/api/app/group/2001/members`
+
+#### 3.15.3 请求参数
+
+请求体格式:`application/json`
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `name` | `string` | 是 | 姓名 | `李四` |
+| `gender` | `number` | 否 | 性别:`1=男`,`2=女` | `1` |
+| `age` | `number` | 否 | 年龄 | `30` |
+| `phone` | `string` | 否 | 手机号(11 位,1[3-9] 开头);未传时不会关联 APP 账号 | `13900139000` |
+| `userCategoryId` | `number` | 否 | 用户分类 ID,对应 `user_category.id` | `1` |
+| `provinceCode` | `string` | 否 | 省级行政区划编码 | `440000` |
+| `cityCode` | `string` | 否 | 市级行政区划编码 | `440300` |
+| `districtCode` | `string` | 否 | 区/县级行政区划编码 | `440305` |
+| `address` | `string` | 否 | 详细地址 | `广东省深圳市南山区` |
+| `shoulderWidth` | `number` | 否 | 肩宽(cm) | `42.50` |
+| `bodyHeight` | `number` | 否 | 身长/坐高(cm) | `85.00` |
+| `spineLength` | `number` | 否 | C7-S4 脊柱长度(cm) | `48.00` |
+| `height` | `number` | 否 | 身高(cm) | `175.00` |
+| `weight` | `number` | 否 | 体重(kg) | `70.00` |
+| `fingerWidth1` | `number` | 否 | 一寸指宽(cm),男 1.5~2.8,女 1.3~2.4 | `2.20` |
+| `fingerWidth15` | `number` | 否 | 1.5 寸指宽(cm),男 2.2~4.0,女 2.0~3.5 | `3.20` |
+| `fingerWidth3` | `number` | 否 | 三寸指宽(cm),男 4.5~8.0,女 4.0~7.0 | `6.50` |
+| `acupointTableId` | `number` | 否 | 已存在的穴位对照表 ID,一般由后端自动生成,无需传入 | `null` |
+
+#### 3.15.4 调用示例
+
+```http
+POST /api/app/group/2001/members HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+Content-Type: application/json
+
+{
+  "name": "李四",
+  "gender": 1,
+  "age": 30,
+  "phone": "13900139000",
+  "provinceCode": "440000",
+  "cityCode": "440300",
+  "districtCode": "440305",
+  "address": "广东省深圳市南山区",
+  "shoulderWidth": 42.50,
+  "bodyHeight": 85.00,
+  "spineLength": 48.00,
+  "height": 175.00,
+  "weight": 70.00,
+  "fingerWidth1": 2.20,
+  "fingerWidth15": 3.20,
+  "fingerWidth3": 6.50
+}
+```
+
+```bash
+curl -X POST "http://localhost:18888/api/app/group/2001/members" \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"name":"李四","gender":1,"age":30,"phone":"13900139000","provinceCode":"440000","cityCode":"440300","districtCode":"440305","address":"广东省深圳市南山区","shoulderWidth":42.50,"bodyHeight":85.00,"spineLength":48.00,"height":175.00,"weight":70.00,"fingerWidth1":2.20,"fingerWidth15":3.20,"fingerWidth3":6.50}'
+```
+
+#### 3.15.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "memberId": 3002,
+    "groupId": 2001,
+    "profileId": 124,
+    "appUserId": 17320687383,
+    "status": 1,
+    "joinTime": "2026-06-22 10:30:00",
+    "profileComplete": true,
+    "name": "李四",
+    "gender": 1,
+    "age": 30,
+    "phone": "13900139000",
+    "provinceCode": "440000",
+    "cityCode": "440300",
+    "districtCode": "440305",
+    "address": "广东省深圳市南山区",
+    "shoulderWidth": 42.50,
+    "bodyHeight": 85.00,
+    "spineLength": 48.00,
+    "height": 175.00,
+    "weight": 70.00,
+    "fingerWidth1": 2.20,
+    "fingerWidth15": 3.20,
+    "fingerWidth3": 6.50,
+    "acupointTableId": 5002,
+    "profileComplete": true
+  }
+}
+```
+
+#### 3.15.6 失败返回示例
+
+群组不存在或当前账号无权访问:
+
+```json
+{
+  "code": 403,
+  "message": "无权访问该家庭群组",
+  "data": null
+}
+```
+
+姓名不能为空:
+
+```json
+{
+  "code": 400,
+  "message": "姓名不能为空",
+  "data": null
+}
+```
+
+手机号格式不正确:
+
+```json
+{
+  "code": 1108,
+  "message": "手机号格式不正确",
+  "data": null
+}
+```
+
+同一群组内手机号重复:
+
+```json
+{
+  "code": 1202,
+  "message": "同一群组内手机号不能重复",
+  "data": null
+}
+```
+
+指宽数据超出性别对应范围:
+
+```json
+{
+  "code": 1203,
+  "message": "1寸指宽应在 1.5~2.8 cm 之间",
+  "data": null
+}
+```
+
+#### 3.15.7 备注
+
+- 越权校验在 Service 层完成,仅当 `device_group_member.app_user_id = 当前账号ID` 且 `status = 1` 时才允许新增。
+- 当 `phone` 已注册时,后端会自动关联已有 APP 账号;当 `phone` 未注册时,后端会自动创建新 APP 账号(`appId` 格式:`AJY` + 8 位数字)。
+- 当 `phone` 对应的 APP 账号已存在用户档案时,会复用该档案并更新字段;否则创建新档案。
+- 同一群组内手机号、同一 APP 账号均不可重复。
+- 当新增成员的用户档案完整(`name`、`gender`、`age`、`shoulderWidth`、`bodyHeight`、`spineLength` 都非空)时,后端会自动生成穴位对照表。
+- 后端会自动维护新增成员与群组对应设备的 `user_device` 绑定记录。
+- `name` 字段必填且不可为空字符串。
+
+### 3.16 修改群组成员
+
+#### 3.16.1 接口用途
+
+用于 APP 端让当前登录账号修改指定家庭设备群组下已有成员的用户档案信息。
+
+后端校验:
+- 当前 APP 账号必须属于该家庭设备群组
+- `profileId` 必须是该群组下的有效成员
+- 手机号、APP 账号在同一群组内不能与其他成员冲突
+- 指宽数据需符合性别对应的合理范围
+
+#### 3.16.2 请求信息
+
+- 请求方式:`PUT`
+- URI:`/api/app/group/{groupId}/members/{profileId}`
+- 完整示例:`http://localhost:18888/api/app/group/2001/members/124`
+
+#### 3.16.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `groupId` | `number` | 是 | 家庭设备群组 ID,对应 `device_group.id`,通过路径参数传递 | `2001` |
+| `profileId` | `number` | 是 | 成员的用户档案 ID,对应 `user_profile.id`,通过路径参数传递 | `124` |
+
+请求体格式:`application/json`,字段定义与 3.15.3 节"新增群组成员"完全一致,包括 `name`(必填)、`gender`、`age`、`phone`、`userCategoryId`、`address`、`shoulderWidth`、`bodyHeight`、`spineLength`、`height`、`weight`、`fingerWidth1`、`fingerWidth15`、`fingerWidth3`、`acupointTableId` 等字段。
+
+#### 3.16.4 调用示例
+
+```http
+PUT /api/app/group/2001/members/124 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+Content-Type: application/json
+
+{
+  "name": "李四",
+  "gender": 1,
+  "age": 31,
+  "phone": "13900139000",
+  "shoulderWidth": 42.50,
+  "bodyHeight": 85.00,
+  "spineLength": 48.00,
+  "height": 175.00,
+  "weight": 71.00,
+  "fingerWidth1": 2.20,
+  "fingerWidth15": 3.20,
+  "fingerWidth3": 6.50
+}
+```
+
+```bash
+curl -X PUT "http://localhost:18888/api/app/group/2001/members/124" \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"name":"李四","gender":1,"age":31,"phone":"13900139000","shoulderWidth":42.50,"bodyHeight":85.00,"spineLength":48.00,"height":175.00,"weight":71.00,"fingerWidth1":2.20,"fingerWidth15":3.20,"fingerWidth3":6.50}'
+```
+
+#### 3.16.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "memberId": 3002,
+    "groupId": 2001,
+    "profileId": 124,
+    "appUserId": 17320687383,
+    "status": 1,
+    "joinTime": "2026-06-22 10:30:00",
+    "profileComplete": true,
+    "name": "李四",
+    "gender": 1,
+    "age": 31,
+    "phone": "13900139000",
+    "shoulderWidth": 42.50,
+    "bodyHeight": 85.00,
+    "spineLength": 48.00,
+    "height": 175.00,
+    "weight": 71.00,
+    "fingerWidth1": 2.20,
+    "fingerWidth15": 3.20,
+    "fingerWidth3": 6.50,
+    "acupointTableId": 5002,
+    "profileComplete": true
+  }
+}
+```
+
+#### 3.16.6 失败返回示例
+
+群组不存在或当前账号无权访问:
+
+```json
+{
+  "code": 403,
+  "message": "无权访问该家庭群组",
+  "data": null
+}
+```
+
+成员不存在:
+
+```json
+{
+  "code": 1200,
+  "message": "子用户不存在",
+  "data": null
+}
+```
+
+手机号格式不正确:
+
+```json
+{
+  "code": 1108,
+  "message": "手机号格式不正确",
+  "data": null
+}
+```
+
+手机号与其他成员冲突:
+
+```json
+{
+  "code": 1202,
+  "message": "同一群组内手机号不能重复",
+  "data": null
+}
+```
+
+指宽数据超出性别对应范围:
+
+```json
+{
+  "code": 1203,
+  "message": "1.5寸指宽应在 2.2~4.0 cm 之间",
+  "data": null
+}
+```
+
+#### 3.16.7 备注
+
+- 越权校验在 Service 层完成,仅当 `device_group_member.app_user_id = 当前账号ID` 且 `status = 1` 时才允许修改。
+- 请求体字段语义与 3.15 节"新增群组成员"完全一致,区别在于"修改"语义:未传字段会以"覆盖式"方式写入数据库(`null` 即清空)。
+- 修改手机号时若该手机号已注册 APP 账号,会自动关联;未注册则保持原关联或自动创建。
+- 修改 `phone` 字段后,会同步更新 `device_group_member.app_user_id`。
+- 当修改后档案完整时,后端会自动重新生成穴位对照表。
+- 后端会自动维护成员与群组对应设备的 `user_device` 绑定记录。
+- `name` 字段必填。
+
+### 3.17 删除群组成员
+
+#### 3.17.1 接口用途
+
+用于 APP 端让当前登录账号从指定的家庭设备群组中移除成员。
+
+后端校验:
+- 当前 APP 账号必须属于该家庭设备群组
+- `profileId` 必须是该群组下的有效成员
+- **禁止删除当前账号自身的成员记录**(避免误操作把自己移除出群组)
+
+删除采用软删除方式:将 `device_group_member.status` 置为 `0`,保留历史数据。
+
+#### 3.17.2 请求信息
+
+- 请求方式:`DELETE`
+- URI:`/api/app/group/{groupId}/members/{profileId}`
+- 完整示例:`http://localhost:18888/api/app/group/2001/members/124`
+
+#### 3.17.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `groupId` | `number` | 是 | 家庭设备群组 ID,对应 `device_group.id`,通过路径参数传递 | `2001` |
+| `profileId` | `number` | 是 | 待移除成员的用户档案 ID,对应 `user_profile.id`,通过路径参数传递 | `124` |
+
+#### 3.17.4 调用示例
+
+```http
+DELETE /api/app/group/2001/members/124 HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X DELETE "http://localhost:18888/api/app/group/2001/members/124" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.17.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": null
+}
+```
+
+#### 3.17.6 失败返回示例
+
+群组不存在或当前账号无权访问:
+
+```json
+{
+  "code": 403,
+  "message": "无权访问该家庭群组",
+  "data": null
+}
+```
+
+成员不存在:
+
+```json
+{
+  "code": 1200,
+  "message": "子用户不存在",
+  "data": null
+}
+```
+
+尝试删除自身成员记录:
+
+```json
+{
+  "code": 403,
+  "message": "不能移除当前账号在群组中的成员记录",
+  "data": null
+}
+```
+
+#### 3.17.7 备注
+
+- 越权校验在 Service 层完成,仅当 `device_group_member.app_user_id = 当前账号ID` 且 `status = 1` 时才允许删除。
+- 禁止删除 `app_user_id = 当前账号ID` 的成员记录,避免误操作把当前账号从群组中移除。
+- 删除为软删除(`status = 0`),不会物理删除 `device_group_member` 表记录,便于审计和恢复。
+- 删除后该成员将不再出现在"查询群组成员列表"接口(3.14 节)的返回结果中。
+- 本接口不会级联删除用户的 `user_profile` 档案或 `user_device` 绑定记录;如需彻底清理用户档案,请使用其他档案管理接口。
+- 已删除的成员记录若需要重新加入群组,请使用"新增群组成员"接口(3.15 节)重新创建。
+
+### 3.18 查询当前账号个人资料
+
+#### 3.18.1 接口用途
+
+用于 APP 端通过 Token 获取当前登录 APP 账号对应的个人资料。
+
+后端从 Token 中解析当前账号 ID,若该账号下尚无用户档案,会自动创建一份空档案(`app_user_id = 当前账号ID`),并将该账号昵称、手机号写入档案,保证 APP 端"我的资料"页面始终有数据可展示。
+
+本接口**不区分家庭设备和公共设备**,所有登录后的 APP 账号均可调用。
+
+#### 3.18.2 请求信息
+
+- 请求方式:`GET`
+- URI:`/api/app/user/profile`
+- 完整示例:`http://localhost:18888/api/app/user/profile`
+
+#### 3.18.3 请求参数
+
+无路径参数、无查询参数、无请求体。请求头需携带登录后的 Token。
+
+#### 3.18.4 调用示例
+
+```http
+GET /api/app/user/profile HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+```
+
+```bash
+curl -X GET "http://localhost:18888/api/app/user/profile" \
+  -H "Authorization: Bearer <token>"
+```
+
+#### 3.18.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "userId": 17320687382,
+    "appUserId": 17320687382,
+    "profileId": 123,
+    "id": 123,
+    "nickname": "张三",
+    "avatar": "https://cdn.example.com/avatar/17320687382.png",
+    "appId": "AJY00000001",
+    "accountPhone": "13800138000",
+    "phone": "13800138000",
+    "name": "张三",
+    "gender": 1,
+    "age": 35,
+    "provinceCode": "440000",
+    "cityCode": "440300",
+    "districtCode": "440305",
+    "address": "广东省深圳市南山区",
+    "shoulderWidth": 42.50,
+    "bodyHeight": 85.00,
+    "spineLength": 48.00,
+    "height": 175.00,
+    "weight": 70.00,
+    "fingerWidth1": 2.20,
+    "fingerWidth15": 3.20,
+    "fingerWidth3": 6.50,
+    "acupointTableId": 5001,
+    "userCategoryId": 1,
+    "profileComplete": true
+  }
+}
+```
+
+#### 3.18.6 字段说明
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `userId` | `number` | 当前 APP 账号 ID,对应 `app_user.id`(同 `appUserId`) |
+| `appUserId` | `number` | 当前 APP 账号 ID,对应 `app_user.id` |
+| `profileId` | `number` | 用户档案 ID,对应 `user_profile.id`(同 `id`) |
+| `id` | `number` | 用户档案 ID,对应 `user_profile.id` |
+| `nickname` | `string` | APP 账号昵称,对应 `app_user.nickname` |
+| `avatar` | `string` | APP 账号头像 URL,对应 `app_user.avatar` |
+| `appId` | `string` | APP 账号的 AppId,对应 `app_user.app_id` |
+| `accountPhone` | `string` | APP 账号手机号,对应 `app_user.phone` |
+| `phone` | `string` | 用户档案手机号,对应 `user_profile.phone`(与 `accountPhone` 通常一致) |
+| `name` | `string` | 姓名 |
+| `gender` | `number/null` | 性别:`1=男`,`2=女` |
+| `age` | `number/null` | 年龄 |
+| `provinceCode` | `string/null` | 省级行政区划编码 |
+| `cityCode` | `string/null` | 市级行政区划编码 |
+| `districtCode` | `string/null` | 区/县级行政区划编码 |
+| `address` | `string/null` | 详细地址 |
+| `shoulderWidth` | `number/null` | 肩宽(cm) |
+| `bodyHeight` | `number/null` | 身长/坐高(cm) |
+| `spineLength` | `number/null` | C7-S4 脊柱长度(cm) |
+| `height` | `number/null` | 身高(cm) |
+| `weight` | `number/null` | 体重(kg) |
+| `fingerWidth1` | `number/null` | 一寸指宽(cm),男 1.5~2.8,女 1.3~2.4 |
+| `fingerWidth15` | `number/null` | 1.5 寸指宽(cm),男 2.2~4.0,女 2.0~3.5 |
+| `fingerWidth3` | `number/null` | 三寸指宽(cm),男 4.5~8.0,女 4.0~7.0 |
+| `acupointTableId` | `number/null` | 已生成的穴位对照表 ID,对应 `user_acupoint.id` |
+| `userCategoryId` | `number/null` | 用户分类 ID,对应 `user_category.id` |
+| `profileComplete` | `boolean` | 用户档案是否已补全设备使用所需资料(`name`/`gender`/`age`/`shoulderWidth`/`bodyHeight`/`spineLength` 均已填写) |
+
+#### 3.18.7 失败返回示例
+
+未登录或 Token 无效:
+
+```json
+{
+  "code": 401,
+  "message": "请先登录",
+  "data": null
+}
+```
+
+APP 账号不存在或已禁用:
+
+```json
+{
+  "code": 1302,
+  "message": "APP账号不存在",
+  "data": null
+}
+```
+
+#### 3.18.8 备注
+
+- 本接口不接受任何参数,仅通过 Token 识别当前账号;不允许通过 `userId`、`appUserId`、`profileId` 等参数指定他人档案。
+- 若当前账号下无用户档案(`user_profile`),后端会自动创建一份以 `app_user_id` 关联的空档案,保证返回数据完整。
+- 响应中 `profileId` 与 `id` 内容一致,便于前端按不同字段名取值。
+- 该接口与"修改个人资料"(3.19 节)配套使用,修改后建议重新调用本接口刷新页面数据。
+
+### 3.19 修改当前账号个人资料
+
+#### 3.19.1 接口用途
+
+用于 APP 端通过 Token 修改当前登录 APP 账号对应的个人资料。
+
+后端逻辑:
+- `nickname`、`avatar` 字段会更新到 `app_user` 表
+- 其余字段(`name`、`gender`、`age`、`phone`、省市区/地址、肩宽、身长、脊柱长、身高、体重、指宽等)会更新到当前账号对应的 `user_profile` 表
+- 若当前账号下尚无用户档案,后端会自动创建一份
+- 修改后若档案已补全(`name`/`gender`/`age`/`shoulderWidth`/`bodyHeight`/`spineLength` 均已填写),会自动重新生成穴位对照表
+
+本接口**不区分家庭设备和公共设备**,所有登录后的 APP 账号均可调用,是用户在公共设备场景下修改自己资料的主入口。
+
+#### 3.19.2 请求信息
+
+- 请求方式:`PUT`
+- URI:`/api/app/user/profile`
+- 完整示例:`http://localhost:18888/api/app/user/profile`
+
+#### 3.19.3 请求参数
+
+请求体格式:`application/json`
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
+| --- | --- | --- | --- | --- |
+| `nickname` | `string` | 否 | APP 账号昵称,传空字符串视为不更新 | `张三` |
+| `avatar` | `string` | 否 | APP 账号头像 URL,传空字符串视为不更新 | `https://cdn.example.com/avatar/xxx.png` |
+| `name` | `string` | 否 | 姓名,传空字符串视为不更新 | `张三` |
+| `gender` | `number` | 否 | 性别:`1=男`,`2=女` | `1` |
+| `age` | `number` | 否 | 年龄 | `35` |
+| `phone` | `string` | 否 | 手机号(11 位,1[3-9] 开头),传空字符串视为不更新 | `13800138000` |
+| `userCategoryId` | `number` | 否 | 用户分类 ID,对应 `user_category.id` | `1` |
+| `provinceCode` | `string/null` | 否 | 省级行政区划编码 | `440000` |
+| `cityCode` | `string/null` | 否 | 市级行政区划编码 | `440300` |
+| `districtCode` | `string/null` | 否 | 区/县级行政区划编码 | `440305` |
+| `address` | `string/null` | 否 | 详细地址 | `广东省深圳市南山区` |
+| `shoulderWidth` | `number/null` | 否 | 肩宽(cm) | `42.50` |
+| `bodyHeight` | `number/null` | 否 | 身长/坐高(cm) | `85.00` |
+| `spineLength` | `number/null` | 否 | C7-S4 脊柱长度(cm) | `48.00` |
+| `height` | `number/null` | 否 | 身高(cm) | `175.00` |
+| `weight` | `number/null` | 否 | 体重(kg) | `70.00` |
+| `fingerWidth1` | `number/null` | 否 | 一寸指宽(cm),男 1.5~2.8,女 1.3~2.4 | `2.20` |
+| `fingerWidth15` | `number/null` | 否 | 1.5 寸指宽(cm),男 2.2~4.0,女 2.0~3.5 | `3.20` |
+| `fingerWidth3` | `number/null` | 否 | 三寸指宽(cm),男 4.5~8.0,女 4.0~7.0 | `6.50` |
+| `acupointTableId` | `number/null` | 否 | 已存在的穴位对照表 ID,一般由后端自动生成,无需传入 | `null` |
+
+#### 3.19.4 调用示例
+
+```http
+PUT /api/app/user/profile HTTP/1.1
+Host: localhost:18888
+Authorization: Bearer <token>
+Content-Type: application/json
+
+{
+  "nickname": "张三",
+  "avatar": "https://cdn.example.com/avatar/17320687382.png",
+  "name": "张三",
+  "gender": 1,
+  "age": 35,
+  "phone": "13800138000",
+  "provinceCode": "440000",
+  "cityCode": "440300",
+  "districtCode": "440305",
+  "address": "广东省深圳市南山区",
+  "shoulderWidth": 42.50,
+  "bodyHeight": 85.00,
+  "spineLength": 48.00,
+  "height": 175.00,
+  "weight": 70.00,
+  "fingerWidth1": 2.20,
+  "fingerWidth15": 3.20,
+  "fingerWidth3": 6.50
+}
+```
+
+```bash
+curl -X PUT "http://localhost:18888/api/app/user/profile" \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{"nickname":"张三","avatar":"https://cdn.example.com/avatar/17320687382.png","name":"张三","gender":1,"age":35,"phone":"13800138000","shoulderWidth":42.50,"bodyHeight":85.00,"spineLength":48.00,"height":175.00,"weight":70.00,"fingerWidth1":2.20,"fingerWidth15":3.20,"fingerWidth3":6.50}'
+```
+
+#### 3.19.5 返回示例
+
+```json
+{
+  "code": 200,
+  "message": "操作成功",
+  "data": {
+    "userId": 17320687382,
+    "appUserId": 17320687382,
+    "profileId": 123,
+    "id": 123,
+    "nickname": "张三",
+    "avatar": "https://cdn.example.com/avatar/17320687382.png",
+    "appId": "AJY00000001",
+    "accountPhone": "13800138000",
+    "phone": "13800138000",
+    "name": "张三",
+    "gender": 1,
+    "age": 35,
+    "provinceCode": "440000",
+    "cityCode": "440300",
+    "districtCode": "440305",
+    "address": "广东省深圳市南山区",
+    "shoulderWidth": 42.50,
+    "bodyHeight": 85.00,
+    "spineLength": 48.00,
+    "height": 175.00,
+    "weight": 70.00,
+    "fingerWidth1": 2.20,
+    "fingerWidth15": 3.20,
+    "fingerWidth3": 6.50,
+    "acupointTableId": 5001,
+    "userCategoryId": 1,
+    "profileComplete": true
+  }
+}
+```
+
+#### 3.19.6 失败返回示例
+
+未登录或 Token 无效:
+
+```json
+{
+  "code": 401,
+  "message": "请先登录",
+  "data": null
+}
+```
+
+APP 账号不存在或已禁用:
+
+```json
+{
+  "code": 1302,
+  "message": "APP账号不存在",
+  "data": null
+}
+```
+
+手机号格式不正确:
+
+```json
+{
+  "code": 1108,
+  "message": "手机号格式不正确",
+  "data": null
+}
+```
+
+指宽数据超出性别对应范围:
+
+```json
+{
+  "code": 1203,
+  "message": "1.5寸指宽应在 2.2~4.0 cm 之间",
+  "data": null
+}
+```
+
+#### 3.19.7 备注
+
+- 本接口不接受路径参数与查询参数,仅通过 Token 识别当前账号;不允许通过 `userId`/`appUserId`/`profileId` 等参数修改他人档案。
+- 该接口与"修改群组成员"(3.16 节)的区别:
+  - 3.16 节需要 `groupId` + `profileId`,**仅服务家庭设备群组**,用于在群组维度编辑成员资料;
+  - 3.19 节只需要 Token,**同时适用于家庭设备和公共设备**,用于当前账号修改自己的资料;
+  - 公共设备场景下**不能**调用 3.16 节(无 `groupId`),应改用本接口。
+- 字段更新语义:
+  - `nickname` / `avatar`:仅在传入非空字符串时更新到 `app_user` 表;
+  - 其余字段:仅在传入非 `null` 时更新到 `user_profile` 表(`phone` 还会进行手机号格式校验)。
+- 当修改后档案完整时(`name`/`gender`/`age`/`shoulderWidth`/`bodyHeight`/`spineLength` 均已填写),后端会自动重新生成穴位对照表。
+- 不会影响 `device_group` 或 `user_device` 绑定关系,仅修改账号与档案自身数据。
+- 与"查询个人资料"(3.18 节)配套使用,修改成功后可直接复用返回结果刷新页面。
+

+ 151 - 19
code/backend/src/main/java/com/aijiuyi/admin/common/config/PlanDataInitializer.java

@@ -1,7 +1,9 @@
 package com.aijiuyi.admin.common.config;
 
+import com.aijiuyi.admin.entity.Acupoint;
 import com.aijiuyi.admin.entity.Plan;
 import com.aijiuyi.admin.entity.PlanStep;
+import com.aijiuyi.admin.mapper.AcupointMapper;
 import com.aijiuyi.admin.mapper.PlanMapper;
 import com.aijiuyi.admin.mapper.PlanStepMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -9,6 +11,7 @@ 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 org.springframework.util.StringUtils;
 
 import java.math.BigDecimal;
 import java.util.ArrayList;
@@ -27,10 +30,14 @@ public class PlanDataInitializer implements CommandLineRunner {
     @Autowired
     private PlanStepMapper planStepMapper;
 
+    @Autowired
+    private AcupointMapper acupointMapper;
+
     @Override
     public void run(String... args) {
         List<PlanSeed> seeds = buildSeeds();
         for (PlanSeed seed : seeds) {
+            boolean created = false;
             Plan plan = planMapper.selectOne(new LambdaQueryWrapper<Plan>()
                     .eq(Plan::getPlanCode, seed.planCode)
                     .last("limit 1"));
@@ -46,32 +53,156 @@ public class PlanDataInitializer implements CommandLineRunner {
                 plan.setUseCount(0);
                 fillPlan(plan, seed);
                 planMapper.insert(plan);
+                created = true;
             } else {
-                fillPlan(plan, seed);
-                planMapper.updateById(plan);
+                if (fillMissingPlanFields(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);
+            if (created || hasNoActiveSteps(plan.getId())) {
+                replaceStepsFromSeed(plan.getId(), seed);
+            } else {
+                repairDeletedAcupointReferences(plan.getId());
             }
         }
     }
 
+    private boolean hasNoActiveSteps(Long planId) {
+        Long count = planStepMapper.selectCount(new LambdaQueryWrapper<PlanStep>()
+                .eq(PlanStep::getPlanId, planId));
+        return count == null || count == 0;
+    }
+
+    private void replaceStepsFromSeed(Long planId, PlanSeed seed) {
+        planStepMapper.delete(new LambdaUpdateWrapper<PlanStep>()
+                .eq(PlanStep::getPlanId, planId));
+
+        for (int i = 0; i < seed.steps.size(); i++) {
+            StepSeed stepSeed = seed.steps.get(i);
+            Acupoint acupoint = resolveActiveAcupoint(stepSeed.acupointId, stepSeed.acupointName);
+            PlanStep step = new PlanStep();
+            step.setPlanId(planId);
+            step.setStepOrder(i + 1);
+            step.setAcupointId(acupoint != null ? acupoint.getId() : stepSeed.acupointId);
+            step.setAcupointName(acupoint != null ? acupoint.getName() : 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 repairDeletedAcupointReferences(Long planId) {
+        List<PlanStep> steps = planStepMapper.selectList(new LambdaQueryWrapper<PlanStep>()
+                .eq(PlanStep::getPlanId, planId));
+        for (PlanStep step : steps) {
+            Acupoint acupoint = findActiveById(step.getAcupointId());
+            if (acupoint != null) {
+                continue;
+            }
+            Acupoint replacement = resolveActiveAcupoint(null, step.getAcupointName());
+            if (replacement == null) {
+                continue;
+            }
+            PlanStep update = new PlanStep();
+            update.setId(step.getId());
+            update.setAcupointId(replacement.getId());
+            update.setAcupointName(replacement.getName());
+            planStepMapper.updateById(update);
+        }
+    }
+
+    private Acupoint resolveActiveAcupoint(Long acupointId, String acupointName) {
+        if (StringUtils.hasText(acupointName)) {
+            Acupoint byName = findActiveByName(acupointName);
+            if (byName != null) {
+                return byName;
+            }
+        }
+        return findActiveById(acupointId);
+    }
+
+    private Acupoint findActiveByName(String acupointName) {
+        String name = acupointName.trim();
+        Acupoint exact = selectActiveByName(name);
+        if (exact != null) {
+            return exact;
+        }
+        if (name.endsWith("\u7a74") && name.length() > 1) {
+            return selectActiveByName(name.substring(0, name.length() - 1));
+        }
+        return selectActiveByName(name + "\u7a74");
+    }
+
+    private Acupoint selectActiveByName(String acupointName) {
+        return acupointMapper.selectOne(new LambdaQueryWrapper<Acupoint>()
+                .eq(Acupoint::getName, acupointName)
+                .eq(Acupoint::getStatus, 1)
+                .orderByDesc(Acupoint::getUpdateTime)
+                .orderByDesc(Acupoint::getId)
+                .last("limit 1"));
+    }
+
+    private Acupoint findActiveById(Long acupointId) {
+        if (acupointId == null) {
+            return null;
+        }
+        Acupoint acupoint = acupointMapper.selectById(acupointId);
+        if (acupoint == null || !Integer.valueOf(1).equals(acupoint.getStatus())) {
+            return null;
+        }
+        return acupoint;
+    }
+
+    private boolean fillMissingPlanFields(Plan plan, PlanSeed seed) {
+        boolean changed = false;
+        if (!StringUtils.hasText(plan.getName())) {
+            plan.setName(seed.name);
+            changed = true;
+        }
+        if (plan.getModeType() == null) {
+            plan.setModeType(seed.modeType);
+            changed = true;
+        }
+        if (!StringUtils.hasText(plan.getEffectType()) && StringUtils.hasText(seed.effectType)) {
+            plan.setEffectType(seed.effectType);
+            changed = true;
+        }
+        if (plan.getApplicableGender() == null) {
+            plan.setApplicableGender(0);
+            changed = true;
+        }
+        if (plan.getAgeMin() == null) {
+            plan.setAgeMin(1);
+            changed = true;
+        }
+        if (plan.getAgeMax() == null) {
+            plan.setAgeMax(120);
+            changed = true;
+        }
+        if (plan.getAuthorType() == null) {
+            plan.setAuthorType(1);
+            changed = true;
+        }
+        if (!StringUtils.hasText(plan.getAuthorName())) {
+            plan.setAuthorName("ADMIN");
+            changed = true;
+        }
+        if (plan.getStatus() == null) {
+            plan.setStatus(1);
+            changed = true;
+        }
+        if (plan.getUseCount() == null) {
+            plan.setUseCount(0);
+            changed = true;
+        }
+        return changed;
+    }
+
     private void fillPlan(Plan plan, PlanSeed seed) {
         plan.setName(seed.name);
         plan.setModeType(seed.modeType);
@@ -115,6 +246,7 @@ public class PlanDataInitializer implements CommandLineRunner {
                 Arrays.asList(
                         new StepSeed(1L, "大椎穴", 1, 42.0, 7, 2L, "温和灸", "祛风首穴"),
                         new StepSeed(2L, "风门穴", 4, 43.0, 7, 3L, "雀啄灸", "散风解表"),
+                        new StepSeed(null, "秉风穴", 4, 42.0, 6, 3L, "雀啄灸", "祛风散寒"),
                         new StepSeed(3L, "肺俞穴", 4, 42.0, 7, 2L, "温和灸", "协同宣肺"),
                         new StepSeed(4L, "心俞穴", 4, 42.0, 6, 2L, "温和灸", "安神扶正")
                 )));

+ 6 - 2
code/backend/src/main/java/com/aijiuyi/admin/common/constant/ResultCode.java

@@ -107,7 +107,7 @@ public enum ResultCode {
     /** 方案名称已存在 */
     PLAN_NAME_EXISTS(1702, "方案名称已存在"),
     /** 已发布方案不可删除 */
-    PLAN_CANNOT_DELETE(1703, "已发布或下架方案不可删除"),
+    PLAN_CANNOT_DELETE(1703, "已发布方案不可删除,请先下架后再删除"),
     /** 方案步骤不存在 */
     PLAN_STEP_NOT_FOUND(1704, "方案步骤不存在"),
     /** 用户方案不存在 */
@@ -157,7 +157,11 @@ public enum ResultCode {
     /** 内置用户分类不可删除 */
     USER_CATEGORY_BUILTIN_FORBIDDEN(1973, "内置用户分类不可删除或修改编码"),
     /** 用户分类已被使用 */
-    USER_CATEGORY_IN_USE(1974, "用户分类已被用户使用,不可删除");
+    USER_CATEGORY_IN_USE(1974, "用户分类已被用户使用,不可删除"),
+
+    // ======================== 群组相关 2000-2099 ========================
+    /** 不能移除当前账号在群组中的成员记录 */
+    GROUP_CANNOT_REMOVE_SELF(2000, "不能移除当前账号在群组中的成员记录");
 
     // ======================== 其他业务错误码在此区间之后继续添加 ========================
 

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

@@ -5,10 +5,15 @@ 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.entity.AdminPermission;
+import com.aijiuyi.admin.entity.AdminUser;
+import com.aijiuyi.admin.mapper.AdminPermissionMapper;
+import com.aijiuyi.admin.mapper.AdminUserMapper;
 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 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import io.jsonwebtoken.Claims;
 import org.slf4j.MDC;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -21,6 +26,12 @@ import org.springframework.web.servlet.HandlerInterceptor;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
 import java.util.UUID;
 
 /**
@@ -33,6 +44,8 @@ import java.util.UUID;
 @Component
 public class TokenInterceptor implements HandlerInterceptor {
 
+    private static final int SUPER_ADMIN_ROLE = 1;
+
     /** Token 在请求头中的 key */
     private static final String TOKEN_HEADER = "Authorization";
     /** Redis 中 Token 的 key 前缀 */
@@ -42,12 +55,34 @@ public class TokenInterceptor implements HandlerInterceptor {
     /** MDC 中请求ID的 key */
     private static final String MDC_REQUEST_ID = "requestId";
 
+    /** 已登录管理员可直接访问的基础接口 */
+    private static final List<String> ADMIN_ALWAYS_ALLOWED_PATTERNS = Collections.unmodifiableList(Arrays.asList(
+            "/api/auth/logout",
+            "/api/auth/info",
+            "/api/auth/profile",
+            "/api/file/**"
+    ));
+
+    /** 仅超级管理员可访问的高风险接口 */
+    private static final List<String> SUPER_ADMIN_PATTERNS = Collections.unmodifiableList(Arrays.asList(
+            "/api/admin/**"
+    ));
+
+    /** 后台接口到前端页面权限的映射,靠前规则优先匹配 */
+    private static final List<RoutePermissionRule> ROUTE_PERMISSION_RULES = createRoutePermissionRules();
+
     @Autowired
     private RedisUtil redisUtil;
 
     @Autowired
     private AuthProperties authProperties;
 
+    @Autowired
+    private AdminUserMapper adminUserMapper;
+
+    @Autowired
+    private AdminPermissionMapper adminPermissionMapper;
+
     /**
      * 请求进入 Controller 前处理
      * 生成请求ID,验证 Token,解析用户信息
@@ -124,6 +159,10 @@ public class TokenInterceptor implements HandlerInterceptor {
         RequestContext.setUserName(username);
         RequestContext.setUserType(userType);
 
+        if (!checkAdminConsolePermission(request, response, requestUri, userId)) {
+            return false;
+        }
+
         LogUtil.debug(TokenInterceptor.class, "用户[{}]请求接口[{}]", username, requestUri);
         return true;
     }
@@ -159,4 +198,165 @@ public class TokenInterceptor implements HandlerInterceptor {
             writer.flush();
         }
     }
+
+    private boolean checkAdminConsolePermission(HttpServletRequest request, HttpServletResponse response,
+                                                String requestUri, Long userId) throws Exception {
+        if (!isAdminConsoleRequest(requestUri)) {
+            return true;
+        }
+
+        AdminUser admin = adminUserMapper.selectById(userId);
+        if (admin == null) {
+            writeErrorResponse(response, ResultCode.FORBIDDEN);
+            return false;
+        }
+        if (admin.getStatus() != null && admin.getStatus() == 0) {
+            writeErrorResponse(response, ResultCode.USER_DISABLED);
+            return false;
+        }
+
+        if (matchesAny(requestUri, ADMIN_ALWAYS_ALLOWED_PATTERNS)) {
+            return true;
+        }
+
+        boolean superAdmin = Integer.valueOf(SUPER_ADMIN_ROLE).equals(admin.getAdminRole());
+        if (matchesAny(requestUri, SUPER_ADMIN_PATTERNS)) {
+            if (superAdmin) {
+                return true;
+            }
+            writeErrorResponse(response, ResultCode.FORBIDDEN);
+            return false;
+        }
+
+        if (superAdmin) {
+            return true;
+        }
+
+        Set<String> routePermissions = getRoutePermissions(admin.getId());
+        for (RoutePermissionRule rule : ROUTE_PERMISSION_RULES) {
+            if (rule.matches(request.getMethod(), requestUri)) {
+                if (rule.isAllowed(routePermissions)) {
+                    return true;
+                }
+                writeErrorResponse(response, ResultCode.FORBIDDEN);
+                return false;
+            }
+        }
+
+        writeErrorResponse(response, ResultCode.FORBIDDEN);
+        return false;
+    }
+
+    private boolean isAdminConsoleRequest(String requestUri) {
+        return requestUri != null
+                && requestUri.startsWith("/api/")
+                && !PATH_MATCHER.match("/api/app/**", requestUri);
+    }
+
+    private boolean matchesAny(String requestUri, List<String> patterns) {
+        for (String pattern : patterns) {
+            if (PATH_MATCHER.match(pattern, requestUri)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Set<String> getRoutePermissions(Long adminId) {
+        AdminPermission permission = adminPermissionMapper.selectOne(
+                new LambdaQueryWrapper<AdminPermission>()
+                        .eq(AdminPermission::getAdminId, adminId)
+        );
+        if (permission == null || !StringUtils.hasText(permission.getRoutePaths())) {
+            return Collections.emptySet();
+        }
+        try {
+            List<String> routePaths = JSON.parseArray(permission.getRoutePaths(), String.class);
+            Set<String> result = new LinkedHashSet<>();
+            for (String routePath : routePaths) {
+                String normalized = normalizeRoutePath(routePath);
+                if (StringUtils.hasText(normalized)) {
+                    result.add(normalized);
+                }
+            }
+            return result;
+        } catch (Exception e) {
+            LogUtil.warn(TokenInterceptor.class, "管理员[{}]权限配置解析失败: {}", adminId, permission.getRoutePaths());
+            return Collections.emptySet();
+        }
+    }
+
+    private String normalizeRoutePath(String routePath) {
+        if (!StringUtils.hasText(routePath)) {
+            return null;
+        }
+        String path = routePath.trim();
+        if ("/admin".equals(path)) {
+            return "/system/admin";
+        }
+        if ("/log".equals(path)) {
+            return "/system/log";
+        }
+        return path;
+    }
+
+    private static List<RoutePermissionRule> createRoutePermissionRules() {
+        List<RoutePermissionRule> rules = new ArrayList<>();
+        rules.add(RoutePermissionRule.get("/api/user/profile/options", "/device", "/user/profile"));
+        rules.add(RoutePermissionRule.get("/api/user/profile/list-all", "/user/plan", "/user/profile"));
+        rules.add(RoutePermissionRule.get("/api/user/profile/page", "/user/profile", "/simulation"));
+        rules.add(RoutePermissionRule.get("/api/plan/page", "/plan", "/simulation"));
+        rules.add(RoutePermissionRule.get("/api/user-plan/page", "/user/plan", "/simulation"));
+        rules.add(RoutePermissionRule.get("/api/user/category/list", "/system/user-category", "/user/profile", "/user/app"));
+        rules.add(RoutePermissionRule.get("/api/acupoint/list", "/acupoint", "/plan"));
+        rules.add(RoutePermissionRule.get("/api/moxibustion-technique/list", "/moxibustion", "/plan"));
+        rules.add(RoutePermissionRule.any("/api/user/profile/**", "/user/profile"));
+        rules.add(RoutePermissionRule.any("/api/user/acupoint/**", "/user/profile"));
+        rules.add(RoutePermissionRule.any("/api/user/device/**", "/user/profile"));
+        rules.add(RoutePermissionRule.any("/api/user/app/**", "/user/app"));
+        rules.add(RoutePermissionRule.any("/api/user-plan/**", "/user/plan"));
+        rules.add(RoutePermissionRule.any("/api/user/category/**", "/system/user-category"));
+        rules.add(RoutePermissionRule.any("/api/acupoint/**", "/acupoint"));
+        rules.add(RoutePermissionRule.any("/api/device/**", "/device"));
+        rules.add(RoutePermissionRule.any("/api/moxibustion-technique/**", "/moxibustion"));
+        rules.add(RoutePermissionRule.any("/api/plan/**", "/plan"));
+        rules.add(RoutePermissionRule.any("/api/simulation/**", "/simulation"));
+        rules.add(RoutePermissionRule.any("/api/content/articles/**", "/content"));
+        rules.add(RoutePermissionRule.any("/api/log/**", "/system/log"));
+        return Collections.unmodifiableList(rules);
+    }
+
+    private static class RoutePermissionRule {
+        private final String method;
+        private final String pattern;
+        private final Set<String> routePaths;
+
+        private RoutePermissionRule(String method, String pattern, String... routePaths) {
+            this.method = method;
+            this.pattern = pattern;
+            this.routePaths = new LinkedHashSet<>(Arrays.asList(routePaths));
+        }
+
+        static RoutePermissionRule any(String pattern, String... routePaths) {
+            return new RoutePermissionRule(null, pattern, routePaths);
+        }
+
+        static RoutePermissionRule get(String pattern, String... routePaths) {
+            return new RoutePermissionRule("GET", pattern, routePaths);
+        }
+
+        boolean matches(String requestMethod, String requestUri) {
+            return (method == null || method.equalsIgnoreCase(requestMethod))
+                    && PATH_MATCHER.match(pattern, requestUri);
+        }
+
+        boolean isAllowed(Set<String> permissions) {
+            for (String routePath : routePaths) {
+                if (permissions.contains(routePath)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
 }

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

@@ -4,8 +4,8 @@ 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.entity.AdminUser;
+import com.aijiuyi.admin.mapper.AdminUserMapper;
 import com.aijiuyi.admin.mapper.SysLogMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -30,7 +30,7 @@ public class SysLogServiceImpl implements SysLogService {
     private SysLogMapper sysLogMapper;
 
     @Autowired
-    private AppUserMapper appUserMapper;
+    private AdminUserMapper adminUserMapper;
 
     /**
      * 异步保存操作日志
@@ -80,13 +80,13 @@ public class SysLogServiceImpl implements SysLogService {
      */
     @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<AdminUser> users = adminUserMapper.selectList(
+                new LambdaQueryWrapper<AdminUser>()
+                        .select(AdminUser::getId, AdminUser::getUsername, AdminUser::getNickname)
+                        .orderByAsc(AdminUser::getId)
         );
         List<Map<String, Object>> options = new ArrayList<>();
-        for (AppUser user : users) {
+        for (AdminUser user : users) {
             Map<String, Object> option = new HashMap<>();
             option.put("id", user.getId());
             // 优先展示昵称,无昵称则展示用户名

+ 20 - 4
code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointLocationParser.java

@@ -25,6 +25,8 @@ public final class AcupointLocationParser {
             Pattern.compile("(?:前|后|头)?正中线旁开\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*(?:寸|(?=凹陷))");
     private static final Pattern GENERAL_OFFSET =
             Pattern.compile("旁开\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*(?:寸|(?=凹陷))");
+    private static final Pattern DAZHUI_DOWN_OFFSET =
+            Pattern.compile("(?:相对)?(?:大椎穴?|C7)(?:向|直)?下\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*寸");
     private static final Pattern UNSUPPORTED_TOUCH =
             Pattern.compile("凹陷|肌腱|骨间|缝中|连线|赤白肉际|脉搏动|鼻唇沟|瞳孔|发际|骨间隙");
     private static final Pattern ABDOMEN_REFERENCE =
@@ -51,14 +53,17 @@ public final class AcupointLocationParser {
 
         String yTerm = AcupointYReference.inferTerm(text);
         AcupointYReference.Rule yRule = AcupointYReference.getRule(yTerm);
+        BigDecimal longitudeOffset = parseLongitudeOffset(text);
         BigDecimal lateralOffset = parseLateralOffset(text);
         Integer side = parseSide(acupoint, text, lateralOffset);
 
-        result.setYReferenceTerm(yTerm);
+        if (longitudeOffset == null) {
+            result.setYReferenceTerm(yTerm);
+        }
         result.setLateralOffset(lateralOffset);
         result.setSide(side);
 
-        boolean hasY = yRule != null;
+        boolean hasY = longitudeOffset != null || yRule != null;
         boolean needsLateral = Integer.valueOf(2).equals(side)
                 || Integer.valueOf(3).equals(side)
                 || Integer.valueOf(4).equals(side);
@@ -70,7 +75,10 @@ public final class AcupointLocationParser {
         boolean abdomenReference = ABDOMEN_REFERENCE.matcher(text).find();
 
         List<String> messages = new ArrayList<String>();
-        if (hasY) {
+        if (longitudeOffset != null) {
+            result.setLongitudeOffset(longitudeOffset);
+            messages.add("已解析相对大椎纵向偏移:" + longitudeOffset.toPlainString() + "寸");
+        } else if (yRule != null) {
             result.setNormalizedY(yRule.getNormalizedY());
             if (yRule.getNormalizedY() == null) {
                 result.setLongitudeOffset(yRule.getCunOffset());
@@ -89,7 +97,7 @@ public final class AcupointLocationParser {
 
         if (hasY && (!needsLateral || hasLateral)) {
             result.setParseStatus(STATUS_PARSED);
-            result.setParseConfidence(touchOnly ? CONFIDENCE_MEDIUM : CONFIDENCE_HIGH);
+            result.setParseConfidence(touchOnly || longitudeOffset != null ? CONFIDENCE_MEDIUM : CONFIDENCE_HIGH);
         } else if (hasY) {
             result.setParseStatus(STATUS_REVIEW_REQUIRED);
             result.setParseConfidence(CONFIDENCE_MEDIUM);
@@ -144,6 +152,14 @@ public final class AcupointLocationParser {
         return null;
     }
 
+    private static BigDecimal parseLongitudeOffset(String text) {
+        Matcher dazhuiDown = DAZHUI_DOWN_OFFSET.matcher(text);
+        if (dazhuiDown.find()) {
+            return new BigDecimal(dazhuiDown.group(1));
+        }
+        return null;
+    }
+
     private static Integer parseSide(Acupoint acupoint, String text, BigDecimal lateralOffset) {
         if (text != null) {
             if (text.contains("双侧") || text.contains("两侧") || text.contains("左右")) {

+ 22 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java

@@ -32,6 +32,9 @@ public class OssUtil {
     /** 允许的视频扩展名 */
     private static final List<String> VIDEO_EXTENSIONS = Arrays.asList("mp4", "mov", "avi", "wmv", "flv", "mkv");
 
+    /** 允许的安装包扩展名 */
+    private static final List<String> APK_EXTENSIONS = Arrays.asList("apk", "wgt");
+
     @Autowired
     private OssProperties ossProperties;
 
@@ -53,6 +56,25 @@ public class OssUtil {
         return doUpload(file, "image", ext);
     }
 
+    /**
+     * 上传APK/wgt安装包到 OSS
+     *
+     * @param file 上传文件
+     * @return 公网访问 URL
+     */
+    public String uploadApk(MultipartFile file) {
+        checkFile(file);
+        String ext = getExtension(file.getOriginalFilename());
+        if (!APK_EXTENSIONS.contains(ext.toLowerCase())) {
+            throw new BusinessException(ResultCode.FILE_TYPE_NOT_SUPPORTED);
+        }
+        // APK文件大小限制200MB
+        if (file.getSize() > 200 * 1024 * 1024L) {
+            throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED);
+        }
+        return doUpload(file, "apk", ext);
+    }
+
     /**
      * 上传视频到 OSS
      *

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

@@ -36,7 +36,7 @@ public class AcupointController {
         List<Acupoint> list = acupointService.list(
                 new LambdaQueryWrapper<Acupoint>()
                         .eq(Acupoint::getStatus, 1)
-                        .select(Acupoint::getId, Acupoint::getName)
+                        .select(Acupoint::getId, Acupoint::getName, Acupoint::getSingleDouble)
                         .orderByAsc(Acupoint::getId)
         );
         return Result.success(list);

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

@@ -4,7 +4,7 @@ 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.entity.AdminUser;
 import com.aijiuyi.admin.service.AdminService;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -33,7 +33,7 @@ public class AdminController {
      */
     @GetMapping("/page")
     @Log(value = "查询管理员列表", module = "管理员管理", operationType = OperationType.QUERY)
-    public Result<IPage<AppUser>> page(AdminQueryDTO queryDTO) {
+    public Result<IPage<AdminUser>> page(AdminQueryDTO queryDTO) {
         return Result.success(adminService.pageList(queryDTO));
     }
 
@@ -45,7 +45,7 @@ public class AdminController {
      */
     @GetMapping("/{id}")
     @Log(value = "查询管理员详情", module = "管理员管理", operationType = OperationType.QUERY)
-    public Result<AppUser> getById(@PathVariable Long id) {
+    public Result<AdminUser> getById(@PathVariable Long id) {
         return Result.success(adminService.getById(id));
     }
 
@@ -57,7 +57,7 @@ public class AdminController {
      */
     @PostMapping
     @Log(value = "新增管理员", module = "管理员管理", operationType = OperationType.INSERT)
-    public Result<Void> add(@RequestBody AppUser admin) {
+    public Result<Void> add(@RequestBody AdminUser admin) {
         adminService.addAdmin(admin);
         return Result.success();
     }
@@ -70,7 +70,7 @@ public class AdminController {
      */
     @PutMapping
     @Log(value = "修改管理员信息", module = "管理员管理", operationType = OperationType.UPDATE)
-    public Result<Void> update(@RequestBody AppUser admin) {
+    public Result<Void> update(@RequestBody AdminUser admin) {
         adminService.updateAdmin(admin);
         return Result.success();
     }

+ 66 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppContentController.java

@@ -0,0 +1,66 @@
+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.ContentArticle;
+import com.aijiuyi.admin.service.ContentArticleService;
+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.web.bind.annotation.*;
+
+/**
+ * App端内容(文章)Controller
+ * 仅提供已发布文章的查询接口,供APP用户使用
+ */
+@RestController
+@RequestMapping("/app/content")
+public class AppContentController {
+
+    @Autowired
+    private ContentArticleService articleService;
+
+    /**
+     * 分页查询已发布文章列表(APP端专用)
+     * 只返回状态为 PUBLISHED 的文章,按发布时间倒序
+     *
+     * @param pageNum  当前页码,默认1
+     * @param pageSize 每页条数,默认10
+     * @return 分页结果
+     */
+    @GetMapping("/articles")
+    @Log(value = "App查询已发布文章", module = "App内容", operationType = OperationType.QUERY)
+    public Result<IPage<ContentArticle>> publishedArticles(
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize) {
+        Page<ContentArticle> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<ContentArticle> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ContentArticle::getStatus, "PUBLISHED")
+               .orderByDesc(ContentArticle::getPublishTime);
+        return Result.success(articleService.page(page, wrapper));
+    }
+
+    /**
+     * 获取已发布文章详情(APP端专用)
+     *
+     * @param id 文章ID
+     * @return 文章详情(仅PUBLISHED状态可查看)
+     */
+    @GetMapping("/articles/{id}")
+    @Log(value = "App查询文章详情", module = "App内容", operationType = OperationType.QUERY)
+    public Result<ContentArticle> getPublishedArticle(@PathVariable Long id) {
+        ContentArticle article = articleService.getById(id);
+        if (article == null || !"PUBLISHED".equals(article.getStatus())) {
+            return Result.error("文章不存在或未发布");
+        }
+        // 浏览次数+1
+        ContentArticle update = new ContentArticle();
+        update.setId(id);
+        update.setViewCount(article.getViewCount() != null ? article.getViewCount() + 1 : 1);
+        articleService.updateById(update);
+        article.setViewCount(update.getViewCount());
+        return Result.success(article);
+    }
+}

+ 8 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppDeviceController.java

@@ -6,9 +6,11 @@ import com.aijiuyi.admin.common.entity.Result;
 import com.aijiuyi.admin.common.enums.OperationType;
 import com.aijiuyi.admin.controller.dto.AppDeviceBindDTO;
 import com.aijiuyi.admin.controller.dto.AppDeviceSelectDTO;
+import com.aijiuyi.admin.entity.Device;
 import com.aijiuyi.admin.service.AppDeviceGroupService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -45,4 +47,10 @@ public class AppDeviceController {
     public Result<Map<String, Object>> select(@RequestBody @Valid AppDeviceSelectDTO dto) {
         return Result.success(appDeviceGroupService.selectDevice(RequestContext.getUserId(), dto));
     }
+
+    @GetMapping("/{id}")
+    @Log(value = "App查询设备详情", module = "App设备", operationType = OperationType.QUERY)
+    public Result<Device> getDetail(@PathVariable Long id) {
+        return Result.success(appDeviceGroupService.getDeviceDetail(RequestContext.getUserId(), id));
+    }
 }

+ 9 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppGroupController.java

@@ -7,6 +7,7 @@ import com.aijiuyi.admin.common.enums.OperationType;
 import com.aijiuyi.admin.controller.dto.AppGroupMemberDTO;
 import com.aijiuyi.admin.service.AppDeviceGroupService;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -49,4 +50,12 @@ public class AppGroupController {
                                                     @RequestBody @Valid AppGroupMemberDTO dto) {
         return Result.success(appDeviceGroupService.updateGroupMember(RequestContext.getUserId(), groupId, profileId, dto));
     }
+
+    @DeleteMapping("/{groupId}/members/{profileId}")
+    @Log(value = "App删除群组成员", module = "App群组", operationType = OperationType.UPDATE)
+    public Result<Void> deleteMember(@PathVariable Long groupId,
+                                     @PathVariable Long profileId) {
+        appDeviceGroupService.deleteGroupMember(RequestContext.getUserId(), groupId, profileId);
+        return Result.success();
+    }
 }

+ 146 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppPlanController.java

@@ -0,0 +1,146 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+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.controller.dto.AppPlanVO;
+import com.aijiuyi.admin.controller.dto.PlanSaveDTO;
+import com.aijiuyi.admin.entity.Plan;
+import com.aijiuyi.admin.service.PlanService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * App端用户自定义方案 Controller
+ * 提供用户对自己自定义模式方案的增删改查接口
+ * 同时提供专业模式(modeType=2)和延年圣手模式(modeType=4)系统方案的查询接口,
+ * 这两类方案由后台预置并在所有用户间共享,无用户差异
+ */
+@RestController
+@RequestMapping("/app")
+public class AppPlanController {
+
+    @Autowired
+    private PlanService planService;
+
+    /**
+     * 新增用户自定义方案(含穴位步骤)
+     *
+     * @param dto 方案信息
+     * @return 方案ID
+     */
+    @PostMapping("/custom-plan")
+    @Log(value = "新增用户自定义方案", module = "App方案", operationType = OperationType.INSERT)
+    public Result<Long> create(@RequestBody @Valid PlanSaveDTO dto) {
+        Long appUserId = RequestContext.getUserId();
+        return Result.success(planService.createUserPlan(dto, appUserId));
+    }
+
+    /**
+     * 查询当前用户的自定义方案列表(仅方案基本信息,不含穴位步骤)
+     * 方案详情请通过 {@link #detail(Long)} 接口获取
+     *
+     * @return 方案列表
+     */
+    @GetMapping("/custom-plan/list")
+    @Log(value = "查询用户自定义方案列表", module = "App方案", operationType = OperationType.QUERY)
+    public Result<List<Plan>> list() {
+        Long appUserId = RequestContext.getUserId();
+        return Result.success(planService.listUserCustomPlans(appUserId));
+    }
+
+    /**
+     * 查询用户自定义方案详情(含穴位步骤)
+     *
+     * @param id 方案ID
+     * @return 方案详情
+     */
+    @GetMapping("/custom-plan/{id}")
+    @Log(value = "查询用户自定义方案详情", module = "App方案", operationType = OperationType.QUERY)
+    public Result<AppPlanVO> detail(@PathVariable Long id) {
+        Long appUserId = RequestContext.getUserId();
+        return Result.success(planService.getUserPlanDetail(id, appUserId));
+    }
+
+    /**
+     * 修改用户自己的自定义方案(含穴位步骤)
+     *
+     * @param dto 方案信息
+     * @return 操作结果
+     */
+    @PutMapping("/custom-plan")
+    @Log(value = "修改用户自定义方案", module = "App方案", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody @Valid PlanSaveDTO dto) {
+        Long appUserId = RequestContext.getUserId();
+        planService.updateUserPlan(dto, appUserId);
+        return Result.success();
+    }
+
+    /**
+     * 删除用户自己的自定义方案
+     *
+     * @param id 方案ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/custom-plan/{id}")
+    @Log(value = "删除用户自定义方案", module = "App方案", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        Long appUserId = RequestContext.getUserId();
+        planService.deleteUserPlan(id, appUserId);
+        return Result.success();
+    }
+
+    /**
+     * 查询专业模式方案列表(modeType=2)
+     * 所有用户共享,无用户差异,仅返回已发布状态、不含穴位步骤的方案基本信息
+     * 查看方案详情请调用 {@link #professionalPlanDetail(Long)}
+     *
+     * @return 专业模式方案列表
+     */
+    @GetMapping("/professional-plan/list")
+    @Log(value = "查询专业模式方案列表", module = "App方案", operationType = OperationType.QUERY)
+    public Result<List<Plan>> listProfessionalPlans() {
+        return Result.success(planService.listProfessionalPlans());
+    }
+
+    /**
+     * 查询专业模式方案详情(含穴位步骤)
+     *
+     * @param id 方案ID
+     * @return 专业模式方案详情
+     */
+    @GetMapping("/professional-plan/{id}")
+    @Log(value = "查询专业模式方案详情", module = "App方案", operationType = OperationType.QUERY)
+    public Result<AppPlanVO> professionalPlanDetail(@PathVariable Long id) {
+        return Result.success(planService.getProfessionalPlanDetail(id));
+    }
+
+    /**
+     * 查询延年圣手模式方案列表(modeType=4)
+     * 所有用户共享,无用户差异,仅返回已发布状态、不含穴位步骤的方案基本信息
+     * 查看方案详情请调用 {@link #masterPlanDetail(Long)}
+     *
+     * @return 延年圣手模式方案列表
+     */
+    @GetMapping("/master-plan/list")
+    @Log(value = "查询延年圣手模式方案列表", module = "App方案", operationType = OperationType.QUERY)
+    public Result<List<Plan>> listMasterPlans() {
+        return Result.success(planService.listMasterPlans());
+    }
+
+    /**
+     * 查询延年圣手模式方案详情(含穴位步骤)
+     *
+     * @param id 方案ID
+     * @return 延年圣手模式方案详情
+     */
+    @GetMapping("/master-plan/{id}")
+    @Log(value = "查询延年圣手模式方案详情", module = "App方案", operationType = OperationType.QUERY)
+    public Result<AppPlanVO> masterPlanDetail(@PathVariable Long id) {
+        return Result.success(planService.getMasterPlanDetail(id));
+    }
+}

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

@@ -0,0 +1,39 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.NoAuth;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.controller.dto.AppVersionCheckVO;
+import com.aijiuyi.admin.service.AppVersionService;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * APP端版本更新 Controller
+ * 提供APP客户端检查更新接口(无需Token)
+ */
+@RestController
+@RequestMapping("/app/version")
+public class AppUpdateController {
+
+    @Autowired
+    private AppVersionService appVersionService;
+
+    /**
+     * APP端检查更新
+     * 根据当前平台和版本号检查是否有新版本
+     *
+     * @param platform    平台:1=Android,2=iOS
+     * @param versionCode 当前版本号(如:1.0.0)
+     * @return 更新信息
+     */
+    @NoAuth
+    @GetMapping("/check")
+    public Result<AppVersionCheckVO> checkUpdate(
+            @RequestParam Integer platform,
+            @RequestParam String versionCode) {
+        return Result.success(appVersionService.checkUpdate(platform, versionCode));
+    }
+}

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

@@ -0,0 +1,39 @@
+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.UserAcupointCoordinateDTO;
+import com.aijiuyi.admin.service.UserAcupointService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * App 端子用户穴位坐标 Controller。
+ * 路径以 /api/app/ 开头,由 TokenInterceptor 识别为 App 请求,
+ * 不再校验 admin_user 表,避免 APP Token 触发"权限不足"。
+ */
+@RestController
+@RequestMapping("/app/user/acupoint")
+public class AppUserAcupointController {
+
+    @Autowired
+    private UserAcupointService userAcupointService;
+
+    /**
+     * 查询指定子用户的背部产品穴位坐标列表(App 端调用)
+     *
+     * @param profileId 子用户ID(user_profile.id)
+     * @return 背部产品穴位坐标列表
+     */
+    @GetMapping("/{profileId}/product-back-coordinates")
+    @Log(value = "查询用户背部产品穴位坐标", module = "App穴位管理", operationType = OperationType.QUERY)
+    public Result<List<UserAcupointCoordinateDTO>> productBackCoordinates(@PathVariable Long profileId) {
+        return Result.success(userAcupointService.listBackProductCoordinatesByUser(profileId));
+    }
+}

+ 18 - 70
code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserController.java

@@ -3,19 +3,18 @@ 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(管理员后台)
- * 提供应用用户的增删改查和状态管理接口
+ * APP用户账号 Controller(管理员后台)
+ * 提供APP用户账号的查询和状态管理接口
+ * 用户档案管理已迁移到 UserProfileController
  */
 @RestController
 @RequestMapping("/user/app")
@@ -25,98 +24,47 @@ public class AppUserController {
     private AppUserService appUserService;
 
     /**
-     * 获取应用用户选项列表(用于方案创作人选择框
-     * 返回所有启用状态的用户,前端根据 userType 区分角色
+     * 获取APP用户账号列表(用于下拉选择等场景
+     * 返回所有启用状态的APP用户账号
      *
-     * @param userType 可选过滤:1=用户,2=专家,3=管理员;null=全部
-     * @return 用户列表(id + nickname + username + userType)
+     * @return 用户列表(id + phone + nickname)
      */
     @GetMapping("/list")
-    @Log(value = "获取用户选项列表", module = "用户管理", operationType = OperationType.QUERY)
-    public Result<List<AppUser>> list(@RequestParam(required = false) Integer userType) {
+    @Log(value = "获取APP用户列表", module = "用户管理", operationType = OperationType.QUERY)
+    public Result<List<AppUser>> list() {
         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);
+                .select(AppUser::getId, AppUser::getPhone, AppUser::getNickname, AppUser::getAppId)
+                .orderByDesc(AppUser::getCreateTime);
         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));
-    }
-
-    /**
-     * 查询应用用户详情
+     * 查询APP用户账号详情
      *
      * @param id 用户ID
      * @return 用户详情
      */
     @GetMapping("/{id}")
-    @Log(value = "查询应用用户详情", module = "用户管理", operationType = OperationType.QUERY)
+    @Log(value = "查询APP用户详情", 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();
-    }
-
-    /**
-     * 修改应用用户状态(禁用/启用)
+     * 修改APP用户状态(禁用/启用)
      *
      * @param id     用户ID
      * @param status 目标状态:1=启用,0=禁用
      * @return 操作结果
      */
     @PutMapping("/{id}/status")
-    @Log(value = "修改应用用户状态", module = "用户管理", operationType = OperationType.UPDATE)
+    @Log(value = "修改APP用户状态", module = "用户管理", operationType = OperationType.UPDATE)
     public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
-        appUserService.updateStatus(id, status);
+        AppUser update = new AppUser();
+        update.setId(id);
+        update.setStatus(status);
+        appUserService.updateById(update);
         return Result.success();
     }
 }

+ 2 - 2
code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserDeviceController.java

@@ -25,13 +25,13 @@ public class AppUserDeviceController {
     /**
      * 按设备编号绑定设备。
      *
-     * @param dto 绑定请求,userId 为用户档案 ID,deviceCode 为设备编号
+     * @param dto 绑定请求,deviceCode 为设备编号
      * @return 新增的绑定记录
      */
     @PostMapping("/bind-by-code")
     @Log(value = "App按设备编号绑定设备", module = "App设备", operationType = OperationType.INSERT)
     public Result<UserDevice> bindByCode(@RequestBody @Valid AppUserDeviceBindDTO dto) {
         Long appUserId = RequestContext.getUserId();
-        return Result.success(userDeviceService.bindAppDeviceByCode(dto.getUserId(), dto.getDeviceCode(), appUserId));
+        return Result.success(userDeviceService.bindAppDeviceByCode(dto.getDeviceCode(), appUserId));
     }
 }

+ 100 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppVersionController.java

@@ -0,0 +1,100 @@
+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.AppVersionQueryDTO;
+import com.aijiuyi.admin.controller.dto.AppVersionSaveDTO;
+import com.aijiuyi.admin.entity.AppVersion;
+import com.aijiuyi.admin.service.AppVersionService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * APP版本管理 Controller(管理端)
+ * 提供版本的增删改查、启用/禁用接口
+ */
+@RestController
+@RequestMapping("/version")
+public class AppVersionController {
+
+    @Autowired
+    private AppVersionService appVersionService;
+
+    /**
+     * 分页查询版本列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询版本列表", module = "版本管理", operationType = OperationType.QUERY)
+    public Result<IPage<AppVersion>> page(AppVersionQueryDTO queryDTO) {
+        return Result.success(appVersionService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询版本详情
+     *
+     * @param id 版本ID
+     * @return 版本详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询版本详情", module = "版本管理", operationType = OperationType.QUERY)
+    public Result<AppVersion> getById(@PathVariable Long id) {
+        return Result.success(appVersionService.getById(id));
+    }
+
+    /**
+     * 新增版本
+     *
+     * @param dto 版本信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增版本", module = "版本管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody AppVersionSaveDTO dto) {
+        appVersionService.add(dto);
+        return Result.success();
+    }
+
+    /**
+     * 编辑版本
+     *
+     * @param dto 版本信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "编辑版本", module = "版本管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody AppVersionSaveDTO dto) {
+        appVersionService.update(dto);
+        return Result.success();
+    }
+
+    /**
+     * 删除版本(逻辑删除)
+     *
+     * @param id 版本ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除版本", module = "版本管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        appVersionService.delete(id);
+        return Result.success();
+    }
+
+    /**
+     * 切换版本启用/禁用状态
+     *
+     * @param id 版本ID
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/status")
+    @Log(value = "切换版本状态", module = "版本管理", operationType = OperationType.UPDATE)
+    public Result<Void> toggleStatus(@PathVariable Long id) {
+        appVersionService.toggleStatus(id);
+        return Result.success();
+    }
+}

+ 32 - 50
code/backend/src/main/java/com/aijiuyi/admin/controller/AuthController.java

@@ -5,10 +5,9 @@ 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.entity.AdminUser;
 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.*;
 
@@ -21,82 +20,73 @@ import org.springframework.util.StringUtils;
 
 /**
  * 认证管理 Controller
- * 提供登录、退出、获取当前用户信息接口
- * 统一使用应用用户表(app_user)进行身份验证
+ * 提供管理员登录、退出、获取当前用户信息接口
+ * 使用独立的 admin_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)
+    @Log(value = "管理员登录", module = "认证管理", operationType = OperationType.OTHER)
     public Result<Map<String, Object>> login(@RequestBody @Valid LoginDTO loginDTO) {
-        return appUserService.login(loginDTO);
+        return adminService.login(loginDTO);
     }
 
     /**
-     * 用户退出登录
+     * 管理员退出登录
      * 删除 Redis 中的 Token,使当前 Token 立即失效
      *
      * @param request HTTP 请求(用于获取 Authorization 头中的 Token)
      * @return 操作结果
      */
     @PostMapping("/logout")
-    @Log(value = "用户退出", module = "认证管理", operationType = OperationType.OTHER)
+    @Log(value = "管理员退出", module = "认证管理", operationType = OperationType.OTHER)
     public Result<Void> logout(HttpServletRequest request) {
         String token = request.getHeader("Authorization");
-        appUserService.logout(token);
+        adminService.logout(token);
         return Result.success();
     }
 
     /**
-     * 获取当前登录用户信息(含权限路由列表)
+     * 获取当前登录管理员信息(含权限路由列表)
      *
-     * @return 应用用户信息 + permissions 权限路由列表
+     * @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);
-        }
+        AdminUser admin = adminService.getById(userId);
         // 查询权限路由列表(超级管理员返回 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("phone", user != null ? user.getPhone() : null);
-        data.put("address", user != null ? user.getAddress() : null);
-        data.put("userType", user != null ? user.getUserType() : null);
-        data.put("adminRole", user != null ? user.getAdminRole() : null);
+        data.put("userId", admin != null ? admin.getId() : null);
+        data.put("username", admin != null ? admin.getUsername() : null);
+        data.put("nickname", admin != null ? admin.getNickname() : null);
+        data.put("avatar", admin != null ? admin.getAvatar() : null);
+        data.put("phone", admin != null ? admin.getPhone() : null);
+        data.put("userType", 3);
+        data.put("adminRole", admin != null ? admin.getAdminRole() : null);
         data.put("permissions", permissions);
         return Result.success(data);
     }
 
     /**
-     * 更新当前管理员个人信息(昵称、手机、地址、头像、密码)
+     * 更新当前管理员个人信息(昵称、手机、头像、密码)
      *
-     * @param payload 请求体(nickname/phone/address/avatar/password/oldPassword)
+     * @param payload 请求体(nickname/phone/avatar/password/oldPassword)
      * @return 操作结果
      */
     @PutMapping("/profile")
@@ -104,8 +94,8 @@ public class AuthController {
     public Result<Void> updateProfile(@RequestBody Map<String, String> payload,
                                       HttpServletRequest request) {
         Long userId = RequestContext.getUserId();
-        AppUser user = appUserService.getById(userId);
-        if (user == null) {
+        AdminUser admin = adminService.getById(userId);
+        if (admin == null) {
             return Result.error("用户不存在");
         }
 
@@ -117,51 +107,43 @@ public class AuthController {
                 return Result.error("请输入旧密码");
             }
             String oldPasswordMd5 = DigestUtils.md5DigestAsHex(oldPassword.getBytes());
-            if (!oldPasswordMd5.equals(user.getPassword())) {
+            if (!oldPasswordMd5.equals(admin.getPassword())) {
                 return Result.error("旧密码不正确");
             }
         }
 
         // 如果修改了手机号,校验唯一性
         String newPhone = payload.get("phone");
-        if (StringUtils.hasText(newPhone) && !newPhone.equals(user.getPhone())) {
-            long phoneCount = appUserService.lambdaQuery()
-                    .eq(AppUser::getPhone, newPhone)
-                    .ne(AppUser::getId, userId)
+        if (StringUtils.hasText(newPhone) && !newPhone.equals(admin.getPhone())) {
+            long phoneCount = adminService.lambdaQuery()
+                    .eq(AdminUser::getPhone, newPhone)
+                    .ne(AdminUser::getId, userId)
                     .count();
             if (phoneCount > 0) {
                 return Result.error("该手机号已注册,请更换手机号");
             }
         }
 
-        AppUser update = new AppUser();
+        AdminUser update = new AdminUser();
         update.setId(userId);
-        // 更新昵称
         if (payload.containsKey("nickname")) {
             update.setNickname(payload.get("nickname"));
         }
-        // 更新手机号
         if (payload.containsKey("phone")) {
             update.setPhone(payload.get("phone"));
         }
-        // 更新地址
-        if (payload.containsKey("address")) {
-            update.setAddress(payload.get("address"));
-        }
-        // 更新头像
         if (payload.containsKey("avatar")) {
             update.setAvatar(payload.get("avatar"));
         }
-        // 更新密码
         if (StringUtils.hasText(newPassword)) {
             update.setPassword(DigestUtils.md5DigestAsHex(newPassword.getBytes()));
         }
-        appUserService.updateById(update);
+        adminService.updateById(update);
 
-        // 修改密码后,从 Redis 删除当前 Token,使其立即失效,强制重新登录
+        // 修改密码后,使当前 Token 失效,强制重新登录
         if (StringUtils.hasText(newPassword)) {
             String token = request.getHeader("Authorization");
-            appUserService.logout(token);
+            adminService.logout(token);
         }
 
         return Result.success();

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

@@ -16,7 +16,7 @@ import java.util.List;
 
 /**
  * 设备管理 Controller
- * 提供艾灸椅设备的增删改查、批量导入、以及设备绑定用户管理接口
+ * 提供艾灸椅设备的增删改查、批量导入、以及设备关联用户/成员管理接口
  */
 @RestController
 @RequestMapping("/device")
@@ -26,7 +26,7 @@ public class DeviceController {
     private DeviceService deviceService;
 
     /**
-     * 分页查询设备列表(含绑定用户数和用户姓名)
+     * 分页查询设备列表(含关联用户/成员数量和姓名)
      *
      * @param queryDTO 查询条件
      * @return 分页结果
@@ -50,7 +50,7 @@ public class DeviceController {
     }
 
     /**
-     * 新增设备(可同时绑定初始用户
+     * 新增设备(可同时关联初始用户/成员
      *
      * @param dto 设备信息
      * @return 操作结果
@@ -114,50 +114,50 @@ public class DeviceController {
         return Result.success();
     }
 
-    // ======================== 设备用户管理 ========================
+    // ======================== 设备关联用户/成员管理 ========================
 
     /**
-     * 查询设备绑定的用户列表
+     * 查询设备关联用户/成员列表
      *
      * @param deviceId 设备ID
-     * @return 绑定用户列表
+     * @return 关联用户/成员列表
      */
     @GetMapping("/{deviceId}/users")
-    @Log(value = "查询设备绑定用户列表", module = "设备管理", operationType = OperationType.QUERY)
+    @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)
+    @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)
+    @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
@@ -171,14 +171,14 @@ public class DeviceController {
     }
 
     /**
-     * 批量解绑设备用户
+     * 批量移除设备成员/解绑设备用户
      *
      * @param deviceId 设备ID
-     * @param bindIds  绑定记录ID列表(user_device.id)
+     * @param bindIds  关联记录ID列表(家庭设备为 device_group_member.id,公共设备为 user_device.id)
      * @return 操作结果
      */
     @DeleteMapping("/{deviceId}/users/batch")
-    @Log(value = "批量解绑设备用户", module = "设备管理", operationType = OperationType.DELETE)
+    @Log(value = "批量移除设备关联用户", module = "设备管理", operationType = OperationType.DELETE)
     public Result<Void> batchRemoveDeviceUsers(@PathVariable Long deviceId, @RequestBody List<Long> bindIds) {
         deviceService.batchRemoveDeviceUsers(deviceId, bindIds);
         return Result.success();

+ 13 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java

@@ -51,6 +51,19 @@ public class FileController {
         return Result.success(buildResult(url));
     }
 
+    /**
+     * 上传APK安装包到 OSS
+     *
+     * @param file APK文件
+     * @return 公网访问 URL
+     */
+    @PostMapping("/apk")
+    @Log(value = "上传APK", module = "文件管理", operationType = OperationType.OTHER)
+    public Result<Map<String, String>> uploadApk(@RequestParam("file") MultipartFile file) {
+        String url = ossUtil.uploadApk(file);
+        return Result.success(buildResult(url));
+    }
+
     /**
      * 构造返回结果(同时返回 url 字段和前端富文本编辑器常用的 data 结构)
      *

+ 17 - 7
code/backend/src/main/java/com/aijiuyi/admin/controller/PlanController.java

@@ -37,6 +37,17 @@ public class PlanController {
         return Result.success(planService.pageList(queryDTO));
     }
 
+    /**
+     * 获取已有创作人列表(用于筛选下拉框)
+     *
+     * @return 创作人名称列表
+     */
+    @GetMapping("/authors")
+    @Log(value = "查询方案创作人列表", module = "方案管理", operationType = OperationType.QUERY)
+    public Result<List<String>> listAuthors() {
+        return Result.success(planService.listAuthors());
+    }
+
     /**
      * 查询方案详情(含步骤列表)
      *
@@ -50,16 +61,15 @@ public class PlanController {
     }
 
     /**
-     * 新增方案(保存草稿)
+     * 新增方案(保存草稿或直接发布
      *
      * @param dto 方案信息(含步骤)
-     * @return 操作结果
+     * @return 新增方案ID
      */
     @PostMapping
     @Log(value = "新增方案", module = "方案管理", operationType = OperationType.INSERT)
-    public Result<Void> add(@RequestBody @Valid PlanSaveDTO dto) {
-        planService.addPlan(dto);
-        return Result.success();
+    public Result<Long> add(@RequestBody @Valid PlanSaveDTO dto) {
+        return Result.success(planService.addPlan(dto));
     }
 
     /**
@@ -76,7 +86,7 @@ public class PlanController {
     }
 
     /**
-     * 删除方案(逻辑删除,草稿可删)
+     * 删除方案(逻辑删除,草稿或已下架可删)
      *
      * @param id 方案ID
      * @return 操作结果
@@ -89,7 +99,7 @@ public class PlanController {
     }
 
     /**
-     * 批量删除方案(草稿可删,需二次确认)
+     * 批量删除方案(草稿或已下架可删,需二次确认)
      *
      * @param ids 方案ID列表
      * @return 操作结果

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

@@ -3,7 +3,6 @@ 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.UserAcupointCoordinateDTO;
 import com.aijiuyi.admin.entity.UserAcupoint;
 import com.aijiuyi.admin.service.UserAcupointService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -47,15 +46,4 @@ public class UserAcupointController {
         return Result.success(userAcupointService.listByUser(profileId));
     }
 
-    /**
-     * 查询指定子用户的背部产品穴位坐标列表
-     *
-     * @param profileId 子用户ID
-     * @return 背部产品穴位坐标列表
-     */
-    @GetMapping("/{profileId}/product-back-coordinates")
-    @Log(value = "查询用户背部产品穴位坐标", module = "用户穴位管理", operationType = OperationType.QUERY)
-    public Result<List<UserAcupointCoordinateDTO>> productBackCoordinates(@PathVariable Long profileId) {
-        return Result.success(userAcupointService.listBackProductCoordinatesByUser(profileId));
-    }
 }

+ 60 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppPlanVO.java

@@ -0,0 +1,60 @@
+package com.aijiuyi.admin.controller.dto;
+
+import com.aijiuyi.admin.entity.PlanStep;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * App端方案响应 VO(含方案基本信息和穴位步骤列表)
+ */
+@Data
+public class AppPlanVO {
+
+    /** 方案ID */
+    private Long id;
+
+    /** 方案编码 */
+    private String planCode;
+
+    /** 方案名称 */
+    private String name;
+
+    /** 模式类型:1=一键艾灸,2=专业模式,3=自定义模式,4=延年圣手 */
+    private Integer modeType;
+
+    /** 适用症状 */
+    private String symptoms;
+
+    /** 适用人群:0=不限,1=仅男,2=仅女 */
+    private Integer applicableGender;
+
+    /** 适用年龄最小值 */
+    private Integer ageMin;
+
+    /** 适用年龄最大值 */
+    private Integer ageMax;
+
+    /** 方案描述 */
+    private String description;
+
+    /** 使用人数 */
+    private Integer useCount;
+
+    /** 平均评分 */
+    private BigDecimal avgRating;
+
+    /** 状态:0=草稿,1=已发布,2=已下架 */
+    private Integer status;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+
+    /** 穴位步骤列表 */
+    private List<PlanStep> steps;
+}

+ 0 - 5
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppUserDeviceBindDTO.java

@@ -3,7 +3,6 @@ package com.aijiuyi.admin.controller.dto;
 import lombok.Data;
 
 import javax.validation.constraints.NotBlank;
-import javax.validation.constraints.NotNull;
 import javax.validation.constraints.Size;
 
 /**
@@ -12,10 +11,6 @@ import javax.validation.constraints.Size;
 @Data
 public class AppUserDeviceBindDTO {
 
-    /** 用户档案 ID,对应 user_profile.id */
-    @NotNull(message = "用户ID不能为空")
-    private Long userId;
-
     /** 设备编号,对应 device.device_code */
     @NotBlank(message = "设备编号不能为空")
     @Size(max = 100, message = "设备编号不能超过100个字符")

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

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * APP端检查更新返回 VO
+ */
+@Data
+public class AppVersionCheckVO {
+
+    /** 是否有更新 */
+    private Boolean hasUpdate;
+
+    /** 最新版本号 */
+    private String versionCode;
+
+    /** 最新版本序号 */
+    private Integer versionNumber;
+
+    /** 更新类型:1=强制更新,2=推荐更新 */
+    private Integer updateType;
+
+    /** 下载地址 */
+    private String downloadUrl;
+
+    /** 更新内容描述 */
+    private String updateContent;
+
+    /** 安装包大小 */
+    private String fileSize;
+}

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

@@ -0,0 +1,25 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 版本管理列表查询 DTO
+ */
+@Data
+public class AppVersionQueryDTO {
+
+    /** 平台:1=Android,2=iOS,null=全部 */
+    private Integer platform;
+
+    /** 状态:0=禁用,1=启用,null=全部 */
+    private Integer status;
+
+    /** 版本号(模糊搜索) */
+    private String versionCode;
+
+    /** 当前页码(从1开始) */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

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

@@ -0,0 +1,37 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 版本新增/编辑请求 DTO
+ */
+@Data
+public class AppVersionSaveDTO {
+
+    /** 主键ID(新增时为null,编辑时必填) */
+    private Long id;
+
+    /** 版本号(如:1.0.1) */
+    private String versionCode;
+
+    /** 版本序号(用于比较大小,如:10001) */
+    private Integer versionNumber;
+
+    /** 平台:1=Android,2=iOS */
+    private Integer platform;
+
+    /** 更新类型:1=强制更新,2=推荐更新 */
+    private Integer updateType;
+
+    /** 下载地址(Android为APK的OSS链接,iOS为App Store链接) */
+    private String downloadUrl;
+
+    /** 更新内容描述 */
+    private String updateContent;
+
+    /** 安装包大小(如:"25.6MB") */
+    private String fileSize;
+
+    /** 状态:0=禁用,1=启用 */
+    private Integer status;
+}

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

@@ -14,7 +14,7 @@ public class DeviceQueryDTO {
     /** 设备名称(模糊搜索) */
     private String deviceName;
 
-    /** 绑定用户姓名(模糊搜索,匹配该设备下任意绑定用户) */
+    /** 关联用户/成员姓名(模糊搜索,家庭设备匹配群组成员,公共设备匹配绑定用户) */
     private String boundUserName;
 
     /** 设备类型:1=家庭设备,2=公共设备 */

+ 3 - 2
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/DeviceSaveDTO.java

@@ -47,8 +47,9 @@ public class DeviceSaveDTO {
     private String address;
 
     /**
-     * 绑定用户ID列表(user_profile.id,选填)
-     * 新增:绑定初始用户;编辑:与当前绑定用户做差集合并(null 表示不变更绑定关系,空列表表示清空所有绑定)
+     * 关联用户档案ID列表(user_profile.id,选填)
+     * 家庭设备:新增/同步群组成员;公共设备:新增/同步设备绑定用户。
+     * 编辑时 null 表示不变更关联关系,空列表表示清空所有关联用户/成员。
      */
     private List<Long> userIds;
 }

+ 4 - 1
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/PlanSaveDTO.java

@@ -52,9 +52,12 @@ public class PlanSaveDTO {
     /** 创作人名称 */
     private String authorName;
 
-    /** 状态:0=草稿,1=已发布,2=已下架(新增时默认草稿) */
+    /** 状态:0=草稿,1=已发布,2=已下架(新增时为空默认草稿,可传1直接发布) */
     private Integer status;
 
+    /** 子用户档案ID(user_profile.id),APP端创建自定义方案时必传,用于设置方案归属 */
+    private Long profileId;
+
     /** 治疗步骤列表 */
     private List<PlanStep> steps;
 }

+ 5 - 8
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/UserProfileDTO.java

@@ -10,16 +10,16 @@ import javax.validation.constraints.Pattern;
 import java.math.BigDecimal;
 
 /**
- * 用户新增/修改请求 DTO
+ * 用户档案新增/修改请求 DTO
  */
 @Data
 public class UserProfileDTO {
 
-    /** 用户ID(修改时必填,新增时不填) */
+    /** 用户档案ID(修改时必填,新增时不填) */
     private Long id;
 
-    /** 所属应用用户ID(新增时必填) */
-    private Long userId;
+    /** 关联APP用户ID(内部使用,不由前端传入) */
+    private Long appUserId;
 
     /** 姓名(必填) */
     @NotBlank(message = "姓名不能为空")
@@ -38,10 +38,7 @@ public class UserProfileDTO {
     @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
     private String phone;
 
-    /** 用户类型:保留兼容,用户列表统一按 1=用户 处理 */
-    private Integer userType;
-
-    /** 用户分类ID(仅 userType=1 用户有效,关联 app_user_category.id) */
+    /** 用户分类ID(关联 app_user_category.id) */
     private Long userCategoryId;
 
     /** 省级行政区划编码(GB/T 2260) */

+ 58 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/AdminUser.java

@@ -0,0 +1,58 @@
+package com.aijiuyi.admin.entity;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 后台管理员实体
+ * 对应数据库 admin_user 表
+ * 与 APP 用户完全隔离,仅用于后台管理系统登录和权限管理
+ */
+@Data
+@TableName("admin_user")
+public class AdminUser {
+
+    /** 主键ID */
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 用户名(唯一,登录用) */
+    private String username;
+
+    /** 密码(MD5加密存储,不序列化到响应) */
+    @JSONField(serialize = false)
+    private String password;
+
+    /** 昵称 */
+    private String nickname;
+
+    /** 手机号 */
+    private String phone;
+
+    /** 头像URL */
+    private String avatar;
+
+    /** 管理员角色:1=超级管理员,2=设备管理员,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;
+}

+ 5 - 31
code/backend/src/main/java/com/aijiuyi/admin/entity/AppUser.java

@@ -1,15 +1,14 @@
 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登录账号实体
  * 对应数据库 app_user 表
- * userType: 1=用户,2=专家,3=管理员
+ * 仅存储APP端手机号登录账号信息,与管理员完全隔离
  */
 @Data
 @TableName("app_user")
@@ -19,13 +18,6 @@ public class AppUser {
     @TableId(type = IdType.ASSIGN_ID)
     private Long id;
 
-    /** 用户名(唯一,用于后台管理员登录) */
-    private String username;
-
-    /** 密码(MD5加密存储,后台账号使用;不序列化到响应) */
-    @JSONField(serialize = false)
-    private String password;
-
     /** 手机号(唯一,用于App端登录) */
     private String phone;
 
@@ -35,39 +27,21 @@ public class AppUser {
     /** 头像URL */
     private String avatar;
 
-    /** 地址 */
-    private String address;
-
     /** 用户AppId(展示用唯一标识,如 AJY00000001) */
     private String appId;
 
-    /**
-     * 用户类型(角色):1=用户,2=专家,3=管理员
-     */
-    private Integer userType;
-
-    /**
-     * 用户分类ID(仅 userType=1 用户有效,关联 app_user_category.id)
-     */
-    private Long userCategoryId;
-
-    /**
-     * 管理员角色:1=超级管理员,2=设备管理员,3=圣手管理(仅 userType=3 时有效)
-     */
-    private Integer adminRole;
-
     /** 状态:1=正常,0=禁用 */
     private Integer status;
 
-    /** 最后登录时间 */
-    private LocalDateTime lastLoginTime;
-
     /**
      * 登录方式:1=手机号验证码,2=微信,3=苹果
      * 记录用户最近一次登录所用的方式
      */
     private Integer loginType;
 
+    /** 最后登录时间 */
+    private LocalDateTime lastLoginTime;
+
     /** 微信 OpenID(同一应用内唯一,微信登录时记录,预留字段) */
     private String wechatOpenId;
 

+ 58 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/AppVersion.java

@@ -0,0 +1,58 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP版本管理实体
+ * 对应数据库 app_version 表
+ */
+@Data
+@TableName("app_version")
+public class AppVersion {
+
+    /** 主键ID */
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 版本号(如:1.0.1) */
+    private String versionCode;
+
+    /** 版本序号(用于比较大小,如:10001表示1.0.1) */
+    private Integer versionNumber;
+
+    /** 平台:1=Android,2=iOS */
+    private Integer platform;
+
+    /** 更新类型:1=强制更新,2=推荐更新 */
+    private Integer updateType;
+
+    /** 下载地址(Android为APK链接,iOS为App Store链接) */
+    private String downloadUrl;
+
+    /** 更新内容描述 */
+    private String updateContent;
+
+    /** 安装包大小(如:"25.6MB") */
+    private String fileSize;
+
+    /** 状态:0=禁用,1=启用 */
+    private Integer status;
+
+    /** 发布时间 */
+    private LocalDateTime publishTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

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

@@ -71,15 +71,15 @@ public class Device {
 
     // ======================== 非数据库字段(联表查询填充)========================
 
-    /** 绑定用户数量(联表聚合) */
+    /** 关联用户/成员数量(联表聚合) */
     @TableField(exist = false)
     private Integer boundUserCount;
 
-    /** 绑定用户姓名列表(联表聚合,顿号分隔) */
+    /** 关联用户/成员姓名列表(联表聚合,顿号分隔) */
     @TableField(exist = false)
     private String boundUserNames;
 
-    /** 绑定的用户ID列表(新增/编辑表单使用) */
+    /** 关联的用户档案ID列表(新增/编辑表单使用) */
     @TableField(exist = false)
     private List<Long> userIds;
 }

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

@@ -5,13 +5,13 @@ import lombok.Data;
 import java.time.LocalDateTime;
 
 /**
- * 设备绑定用户视图对象
- * 用于设备用户管理页面,联表查询 user_device + user_profile 的结果
+ * 设备关联用户/成员视图对象
+ * 家庭设备对应 device_group_member + user_profile,公共设备对应 user_device + user_profile。
  */
 @Data
 public class DeviceUserVO {
 
-    /** 绑定记录ID(user_device.id) */
+    /** 关联记录ID(家庭设备为 device_group_member.id,公共设备为 user_device.id) */
     private Long bindId;
 
     /** 用户档案ID(user_profile.id) */
@@ -23,9 +23,9 @@ public class DeviceUserVO {
     /** 用户手机号 */
     private String phone;
 
-    /** 绑定时间 */
+    /** 加入/绑定时间 */
     private LocalDateTime bindTime;
 
-    /** 是否主用户:1=是,0=否 */
+    /** 是否主用户:1=是,0=否;家庭设备固定返回 0 */
     private Integer isPrimary;
 }

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

@@ -56,7 +56,7 @@ public class Plan {
      */
     private Integer authorType;
 
-    /** 创作人ID(对应 app_user.id) */
+    /** 创作人ID(后台创建对应 admin_user.id,APP自定义方案对应 user_profile.id 子用户ID) */
     private Long authorId;
 
     /** 创作人名称(冗余存储,加速查询) */

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

@@ -17,6 +17,10 @@ public class UserDevice {
     @TableId(type = IdType.ASSIGN_ID)
     private Long id;
 
+    /** 设备主键ID(关联 device.id,非持久化字段,仅在接口返回时填充) */
+    @TableField(exist = false)
+    private Long deviceId;
+
     /** 所属子用户ID(关联 user_profile.id) */
     private Long userId;
 

+ 5 - 8
code/backend/src/main/java/com/aijiuyi/admin/entity/UserProfile.java

@@ -7,8 +7,9 @@ import java.math.BigDecimal;
 import java.time.LocalDateTime;
 
 /**
- * 子用户信息实体
+ * 用户档案实体
  * 对应数据库 user_profile 表
+ * 作为后台用户列表的数据源,存储用户体型数据和基本信息
  */
 @Data
 @TableName("user_profile")
@@ -18,8 +19,8 @@ public class UserProfile {
     @TableId(type = IdType.ASSIGN_ID)
     private Long id;
 
-    /** 所属应用用户ID(关联 app_user.id) */
-    private Long userId;
+    /** 关联APP登录账号ID(app_user.id),NULL表示该用户不可登录APP */
+    private Long appUserId;
 
     /** 姓名 */
     private String name;
@@ -33,11 +34,7 @@ public class UserProfile {
     /** 手机号(11位,唯一) */
     private String phone;
 
-    /** 用户类型:1=用户,2=专家,3=管理员 */
-    private Integer userType;
-
-    /** 用户分类ID(非数据库字段,查询时从 app_user 关联填充) */
-    @TableField(exist = false)
+    /** 用户分类ID(关联 app_user_category.id) */
     private Long userCategoryId;
 
     /** 用户分类名称(非数据库字段,查询时从 app_user_category 关联填充) */

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

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AdminUser;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 后台管理员 Mapper
+ */
+@Mapper
+public interface AdminUserMapper extends BaseMapper<AdminUser> {
+}

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

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AppVersion;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * APP版本管理 Mapper
+ */
+@Mapper
+public interface AppVersionMapper extends BaseMapper<AppVersion> {
+}

+ 4 - 3
code/backend/src/main/java/com/aijiuyi/admin/mapper/DeviceMapper.java

@@ -18,7 +18,7 @@ import java.util.List;
 public interface DeviceMapper extends BaseMapper<Device> {
 
     /**
-     * 分页查询设备列表(联表聚合绑定用户数和用户姓名
+     * 分页查询设备列表(聚合家庭群组成员或公共设备绑定用户)
      *
      * @param page     分页参数
      * @param queryDTO 查询条件
@@ -27,10 +27,11 @@ public interface DeviceMapper extends BaseMapper<Device> {
     IPage<Device> pageWithUsers(Page<Device> page, @Param("q") DeviceQueryDTO queryDTO);
 
     /**
-     * 根据设备编号查询绑定的用户列表
+     * 根据设备编号查询用户列表。
+     * 家庭设备返回群组成员,公共设备返回设备绑定用户。
      *
      * @param deviceCode 设备编号
-     * @return 绑定用户列表
+     * @return 用户列表
      */
     List<DeviceUserVO> getUsersByDeviceCode(@Param("deviceCode") String deviceCode);
 }

+ 26 - 6
code/backend/src/main/java/com/aijiuyi/admin/service/AdminService.java

@@ -1,18 +1,38 @@
 package com.aijiuyi.admin.service;
 
+import com.aijiuyi.admin.common.entity.Result;
 import com.aijiuyi.admin.controller.dto.AdminQueryDTO;
-import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.controller.dto.LoginDTO;
+import com.aijiuyi.admin.entity.AdminUser;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.IService;
 
 import java.util.List;
+import java.util.Map;
 
 /**
  * 管理员管理 Service 接口
- * 对 app_user 表中 userType=3 的管理员进行管理,
+ * 操作独立的 admin_user 表,与 APP 用户完全隔离
  * 同时提供管理员权限配置的查询与更新
  */
-public interface AdminService extends IService<AppUser> {
+public interface AdminService extends IService<AdminUser> {
+
+    /**
+     * 管理员登录
+     * 通过用户名+密码登录,返回 Token 和用户信息
+     *
+     * @param loginDTO 登录参数(用户名、密码)
+     * @return 包含 token 和用户基本信息的 Result
+     */
+    Result<Map<String, Object>> login(LoginDTO loginDTO);
+
+    /**
+     * 退出登录
+     * 从 Redis 中删除 Token,使 Token 立即失效
+     *
+     * @param token 当前用户的 Token
+     */
+    void logout(String token);
 
     /**
      * 分页查询管理员列表
@@ -20,7 +40,7 @@ public interface AdminService extends IService<AppUser> {
      * @param queryDTO 查询参数
      * @return 分页结果
      */
-    IPage<AppUser> pageList(AdminQueryDTO queryDTO);
+    IPage<AdminUser> pageList(AdminQueryDTO queryDTO);
 
     /**
      * 新增管理员
@@ -28,7 +48,7 @@ public interface AdminService extends IService<AppUser> {
      *
      * @param admin 管理员信息
      */
-    void addAdmin(AppUser admin);
+    void addAdmin(AdminUser admin);
 
     /**
      * 修改管理员信息
@@ -36,7 +56,7 @@ public interface AdminService extends IService<AppUser> {
      *
      * @param admin 管理员信息(需携带 id)
      */
-    void updateAdmin(AppUser admin);
+    void updateAdmin(AdminUser admin);
 
     /**
      * 删除管理员(逻辑删除)

+ 14 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AppDeviceGroupService.java

@@ -4,6 +4,7 @@ import com.aijiuyi.admin.controller.dto.AppDeviceBindDTO;
 import com.aijiuyi.admin.controller.dto.AppDeviceSelectDTO;
 import com.aijiuyi.admin.controller.dto.AppGroupMemberDTO;
 import com.aijiuyi.admin.controller.dto.AppProfileDTO;
+import com.aijiuyi.admin.entity.Device;
 
 import java.util.List;
 import java.util.Map;
@@ -25,7 +26,20 @@ public interface AppDeviceGroupService {
 
     Map<String, Object> updateGroupMember(Long appUserId, Long groupId, Long profileId, AppGroupMemberDTO dto);
 
+    void deleteGroupMember(Long appUserId, Long groupId, Long profileId);
+
     Map<String, Object> getCurrentProfile(Long appUserId);
 
     Map<String, Object> updateCurrentProfile(Long appUserId, AppProfileDTO dto);
+
+    /**
+     * App端查询设备详情。
+     * 校验设备属于当前 APP 用户(家庭设备走 device_group_member,公共设备走 user_device),
+     * 越权时直接抛 FORBIDDEN。返回仅供 App 展示的设备基础信息。
+     *
+     * @param appUserId 当前登录的 APP 账号 ID
+     * @param deviceId  设备主键 ID(device.id)
+     * @return 设备详情
+     */
+    Device getDeviceDetail(Long appUserId, Long deviceId);
 }

+ 7 - 56
code/backend/src/main/java/com/aijiuyi/admin/service/AppUserService.java

@@ -2,37 +2,17 @@ package com.aijiuyi.admin.service;
 
 import com.aijiuyi.admin.common.entity.Result;
 import com.aijiuyi.admin.controller.dto.AppLoginDTO;
-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 区分
+ * APP用户账号 Service
+ * 仅处理APP端手机号登录账号相关逻辑
  */
 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);
-
     /**
      * App 端发送短信验证码
      * 60 秒内同一手机号不重复发送,验证码 5 分钟有效
@@ -43,7 +23,7 @@ public interface AppUserService extends IService<AppUser> {
 
     /**
      * App 端手机号验证码登录
-     * 手机号未注册时自动注册为用户(userType=1)
+     * 手机号未注册时自动注册
      * Token 永久有效(除非后台主动踢下线)
      *
      * @param loginDTO 手机号+验证码
@@ -52,39 +32,10 @@ public interface AppUserService extends IService<AppUser> {
     Result<Map<String, Object>> appLogin(AppLoginDTO loginDTO);
 
     /**
-     * 分页查询应用用户列表
-     *
-     * @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);
-
-    /**
-     * 删除应用用户(逻辑删除)
+     * APP用户退出登录
+     * 从 Redis 中删除 Token,使 Token 立即失效
      *
-     * @param id 用户ID
+     * @param token 当前用户的 Token(从请求头中获取)
      */
-    void deleteUser(Long id);
+    void logout(String token);
 }

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

@@ -0,0 +1,59 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.AppVersionCheckVO;
+import com.aijiuyi.admin.controller.dto.AppVersionQueryDTO;
+import com.aijiuyi.admin.controller.dto.AppVersionSaveDTO;
+import com.aijiuyi.admin.entity.AppVersion;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+/**
+ * APP版本管理 Service 接口
+ */
+public interface AppVersionService extends IService<AppVersion> {
+
+    /**
+     * 分页查询版本列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<AppVersion> pageList(AppVersionQueryDTO queryDTO);
+
+    /**
+     * 新增版本
+     *
+     * @param dto 版本信息
+     */
+    void add(AppVersionSaveDTO dto);
+
+    /**
+     * 编辑版本
+     *
+     * @param dto 版本信息
+     */
+    void update(AppVersionSaveDTO dto);
+
+    /**
+     * 删除版本(逻辑删除)
+     *
+     * @param id 版本ID
+     */
+    void delete(Long id);
+
+    /**
+     * 切换版本启用/禁用状态
+     *
+     * @param id 版本ID
+     */
+    void toggleStatus(Long id);
+
+    /**
+     * APP端检查更新
+     *
+     * @param platform    平台:1=Android,2=iOS
+     * @param versionCode 当前版本号(如:1.0.0)
+     * @return 更新信息
+     */
+    AppVersionCheckVO checkUpdate(Integer platform, String versionCode);
+}

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

@@ -15,7 +15,7 @@ import java.util.List;
 public interface DeviceService extends IService<Device> {
 
     /**
-     * 分页查询设备列表(联表聚合绑定用户信息
+     * 分页查询设备列表(聚合家庭群组成员或公共设备绑定用户)
      *
      * @param queryDTO 查询条件
      * @return 分页结果
@@ -23,28 +23,28 @@ public interface DeviceService extends IService<Device> {
     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列表
      */
@@ -58,15 +58,15 @@ public interface DeviceService extends IService<Device> {
     void batchImport(List<Device> list);
 
     /**
-     * 查询设备绑定的用户列表
+     * 查询设备关联用户/成员列表
      *
      * @param deviceId 设备ID
-     * @return 绑定用户列表
+     * @return 关联用户/成员列表
      */
     List<DeviceUserVO> getDeviceUsers(Long deviceId);
 
     /**
-     * 为设备添加绑定用户
+     * 为设备添加成员/绑定用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID(user_profile.id)
@@ -74,7 +74,7 @@ public interface DeviceService extends IService<Device> {
     void addDeviceUser(Long deviceId, Long profileId);
 
     /**
-     * 解绑设备用户
+     * 移除设备成员/解绑设备用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -82,7 +82,7 @@ public interface DeviceService extends IService<Device> {
     void removeDeviceUser(Long deviceId, Long profileId);
 
     /**
-     * 设为主用户(同一设备下只能有一个主用户
+     * 设为主用户(仅公共设备兼容旧绑定关系,家庭设备无主用户概念
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -90,10 +90,10 @@ public interface DeviceService extends IService<Device> {
     void setDeviceUserPrimary(Long deviceId, Long profileId);
 
     /**
-     * 批量解绑设备用户
+     * 批量移除设备成员/解绑设备用户
      *
      * @param deviceId 设备ID
-     * @param bindIds  绑定记录ID列表(user_device.id)
+     * @param bindIds  关联记录ID列表(家庭设备为 device_group_member.id,公共设备为 user_device.id)
      */
     void batchRemoveDeviceUsers(Long deviceId, List<Long> bindIds);
 }

+ 87 - 3
code/backend/src/main/java/com/aijiuyi/admin/service/PlanService.java

@@ -1,5 +1,6 @@
 package com.aijiuyi.admin.service;
 
+import com.aijiuyi.admin.controller.dto.AppPlanVO;
 import com.aijiuyi.admin.controller.dto.PlanQueryDTO;
 import com.aijiuyi.admin.controller.dto.PlanSaveDTO;
 import com.aijiuyi.admin.entity.Plan;
@@ -34,8 +35,9 @@ public interface PlanService extends IService<Plan> {
      * 新增方案(含步骤)
      *
      * @param dto 方案信息
+     * @return 新增方案ID
      */
-    void addPlan(PlanSaveDTO dto);
+    Long addPlan(PlanSaveDTO dto);
 
     /**
      * 修改方案(含步骤)
@@ -45,14 +47,14 @@ public interface PlanService extends IService<Plan> {
     void updatePlan(PlanSaveDTO dto);
 
     /**
-     * 删除方案(逻辑删除,草稿状态可删)
+     * 删除方案(逻辑删除,草稿或已下架状态可删)
      *
      * @param id 方案ID
      */
     void deletePlan(Long id);
 
     /**
-     * 批量删除方案(草稿状态可删)
+     * 批量删除方案(草稿或已下架状态可删)
      *
      * @param ids 方案ID列表
      */
@@ -93,4 +95,86 @@ public interface PlanService extends IService<Plan> {
      * @return 步骤列表
      */
     List<PlanStep> getStepsByPlanId(Long planId);
+
+    /**
+     * 查询数据库中已有的创作人列表(去重,按名称排序,用于筛选下拉框)
+     *
+     * @return 创作人名称列表
+     */
+    List<String> listAuthors();
+
+    /**
+     * 用户新增自定义方案(含步骤),author_id 取子用户ID(user_profile.id)
+     *
+     * @param dto       方案信息(需包含 profileId 指定归属子用户)
+     * @param appUserId APP账号ID(app_user.id,用于校验子用户归属)
+     * @return 方案ID
+     */
+    Long createUserPlan(PlanSaveDTO dto, Long appUserId);
+
+    /**
+     * 查询APP账号下所有子用户的自定义模式方案列表(不含穴位步骤,仅返回方案基本信息)
+     * 方案详情请通过 {@link #getUserPlanDetail(Long, Long)} 获取
+     *
+     * @param appUserId APP账号ID(app_user.id)
+     * @return 方案列表
+     */
+    List<Plan> listUserCustomPlans(Long appUserId);
+
+    /**
+     * 查询用户自定义方案详情(含穴位步骤),校验方案归属于当前APP账号的子用户
+     *
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
+     * @return 方案详情
+     */
+    AppPlanVO getUserPlanDetail(Long planId, Long appUserId);
+
+    /**
+     * 用户修改自己的自定义方案(含步骤),校验方案归属于当前APP账号的子用户
+     *
+     * @param dto       方案信息
+     * @param appUserId APP账号ID(app_user.id)
+     */
+    void updateUserPlan(PlanSaveDTO dto, Long appUserId);
+
+    /**
+     * 用户删除自己的自定义方案,校验方案归属于当前APP账号的子用户
+     *
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
+     */
+    void deleteUserPlan(Long planId, Long appUserId);
+
+    /**
+     * 查询专业模式方案列表(modeType=2,所有用户共享,无差异)
+     * 仅返回已发布(status=1)的方案;不含穴位步骤,如需步骤请调用 {@link #getProfessionalPlanDetail(Long)}
+     *
+     * @return 专业模式方案列表
+     */
+    List<Plan> listProfessionalPlans();
+
+    /**
+     * 查询专业模式方案详情(含穴位步骤),校验 modeType=2
+     *
+     * @param planId 方案ID
+     * @return 专业模式方案详情
+     */
+    AppPlanVO getProfessionalPlanDetail(Long planId);
+
+    /**
+     * 查询延年圣手模式方案列表(modeType=4,所有用户共享,无差异)
+     * 仅返回已发布(status=1)的方案;不含穴位步骤,如需步骤请调用 {@link #getMasterPlanDetail(Long)}
+     *
+     * @return 延年圣手模式方案列表
+     */
+    List<Plan> listMasterPlans();
+
+    /**
+     * 查询延年圣手模式方案详情(含穴位步骤),校验 modeType=4
+     *
+     * @param planId 方案ID
+     * @return 延年圣手模式方案详情
+     */
+    AppPlanVO getMasterPlanDetail(Long planId);
 }

+ 1 - 2
code/backend/src/main/java/com/aijiuyi/admin/service/UserDeviceService.java

@@ -30,12 +30,11 @@ public interface UserDeviceService extends IService<UserDevice> {
     /**
      * App 端按设备编号绑定设备。
      *
-     * @param userId     用户档案ID(user_profile.id)
      * @param deviceCode 设备编号
      * @param appUserId  当前登录 App 用户ID(app_user.id)
      * @return 新增的绑定记录
      */
-    UserDevice bindAppDeviceByCode(Long userId, String deviceCode, Long appUserId);
+    UserDevice bindAppDeviceByCode(String deviceCode, Long appUserId);
 
     /**
      * 解绑设备

+ 216 - 83
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AdminServiceImpl.java

@@ -2,14 +2,20 @@ package com.aijiuyi.admin.service.impl;
 
 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.exception.BusinessException;
+import com.aijiuyi.admin.common.interceptor.TokenInterceptor;
 import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.common.util.RedisUtil;
+import com.aijiuyi.admin.common.util.TokenUtil;
 import com.aijiuyi.admin.controller.dto.AdminQueryDTO;
+import com.aijiuyi.admin.controller.dto.LoginDTO;
 import com.aijiuyi.admin.entity.AdminPermission;
-import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.entity.AdminUser;
 import com.aijiuyi.admin.mapper.AdminPermissionMapper;
-import com.aijiuyi.admin.mapper.AppUserMapper;
+import com.aijiuyi.admin.mapper.AdminUserMapper;
 import com.aijiuyi.admin.service.AdminService;
+import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -20,70 +26,178 @@ import org.springframework.util.DigestUtils;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 /**
  * 管理员管理 Service 实现类
- * 操作 app_user 表中 userType=3 的管理员记录
+ * 操作独立的 admin_user 表,与 APP 用户完全隔离
  * 同时维护 admin_permission 表中的路由权限配置
  */
 @Service
-public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implements AdminService {
+public class AdminServiceImpl extends ServiceImpl<AdminUserMapper, AdminUser> implements AdminService {
 
     private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$";
 
+    /** Token 在 Redis 中的 key 前缀 */
+    private static final String TOKEN_REDIS_PREFIX = TokenInterceptor.REDIS_TOKEN_KEY_PREFIX;
+
+    /** Token 在 Redis 中的过期时间:3年(秒) */
+    private static final long TOKEN_EXPIRE_SECONDS = 3L * 365 * 24 * 60 * 60;
+
+    private static final Set<String> ASSIGNABLE_ROUTE_PATHS = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(
+            "/dashboard",
+            "/user/profile",
+            "/user/plan",
+            "/user/app",
+            "/acupoint",
+            "/device",
+            "/moxibustion",
+            "/plan",
+            "/simulation",
+            "/content",
+            "/system/user-category",
+            "/system/log"
+    )));
+
     @Autowired
     private AdminPermissionMapper adminPermissionMapper;
 
+    @Autowired
+    private RedisUtil redisUtil;
+
+    /**
+     * 管理员登录
+     * 通过用户名+密码校验身份,生成 JWT Token 并存入 Redis
+     */
+    @Override
+    public Result<Map<String, Object>> login(LoginDTO loginDTO) {
+        LogUtil.info(AdminServiceImpl.class, "管理员[{}]尝试登录", loginDTO.getUsername());
+
+        // 根据用户名查询管理员
+        AdminUser admin = lambdaQuery()
+                .eq(AdminUser::getUsername, loginDTO.getUsername())
+                .one();
+
+        if (admin == null) {
+            LogUtil.warn(AdminServiceImpl.class, "管理员[{}]不存在", loginDTO.getUsername());
+            throw new BusinessException(ResultCode.USER_NOT_FOUND);
+        }
+
+        // 验证密码(前端传明文,后端 MD5 加密后比对)
+        String md5Password = DigestUtils.md5DigestAsHex(loginDTO.getPassword().getBytes());
+        if (!md5Password.equals(admin.getPassword())) {
+            LogUtil.warn(AdminServiceImpl.class, "管理员[{}]密码错误", loginDTO.getUsername());
+            throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
+        }
+
+        // 验证状态
+        if (admin.getStatus() != null && admin.getStatus() == 0) {
+            LogUtil.warn(AdminServiceImpl.class, "管理员[{}]已被禁用", loginDTO.getUsername());
+            throw new BusinessException(ResultCode.USER_DISABLED);
+        }
+
+        // 生成 JWT Token(userType 固定为 3 代表管理员)
+        String token = TokenUtil.generateToken(admin.getId(), admin.getUsername(), 3);
+
+        // 将 Token 存入 Redis,3年过期
+        redisUtil.set(TOKEN_REDIS_PREFIX + token, admin.getId(), TOKEN_EXPIRE_SECONDS);
+
+        // 更新最后登录时间
+        AdminUser updateLogin = new AdminUser();
+        updateLogin.setId(admin.getId());
+        updateLogin.setLastLoginTime(LocalDateTime.now());
+        updateById(updateLogin);
+
+        // 登录成功后将用户信息写入 RequestContext
+        RequestContext.setUserId(admin.getId());
+        RequestContext.setUserName(admin.getUsername());
+        RequestContext.setUserType(3);
+
+        LogUtil.info(AdminServiceImpl.class, "管理员[{}]登录成功", admin.getUsername());
+
+        // 查询权限路由(超级管理员返回 null,代表全部权限)
+        String routePaths = null;
+        if (!Integer.valueOf(1).equals(admin.getAdminRole())) {
+            AdminPermission permission = adminPermissionMapper.selectOne(
+                    new LambdaQueryWrapper<AdminPermission>()
+                            .eq(AdminPermission::getAdminId, admin.getId())
+            );
+            routePaths = permission != null ? permission.getRoutePaths() : "[]";
+        }
+
+        // 组装返回数据
+        Map<String, Object> resultData = new HashMap<>(8);
+        resultData.put("token", token);
+        resultData.put("userId", admin.getId());
+        resultData.put("username", admin.getUsername());
+        resultData.put("nickname", admin.getNickname());
+        resultData.put("avatar", admin.getAvatar());
+        resultData.put("userType", 3);
+        resultData.put("adminRole", admin.getAdminRole());
+        resultData.put("permissions", routePaths);
+
+        return Result.success(resultData);
+    }
+
     /**
-     * 分页查询管理员列表(仅 userType=3)
-     *
-     * @param queryDTO 查询条件
-     * @return 分页结果
+     * 退出登录
      */
     @Override
-    public IPage<AppUser> pageList(AdminQueryDTO queryDTO) {
-        Page<AppUser> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
-        LambdaQueryWrapper<AppUser> wrapper = new LambdaQueryWrapper<AppUser>()
-                .eq(AppUser::getUserType, 3)
-                .eq(queryDTO.getAdminRole() != null, AppUser::getAdminRole, queryDTO.getAdminRole())
-                .eq(queryDTO.getStatus() != null, AppUser::getStatus, queryDTO.getStatus())
-                .orderByAsc(AppUser::getId);
+    public void logout(String token) {
+        if (StringUtils.hasText(token)) {
+            if (token.startsWith("Bearer ")) {
+                token = token.substring(7);
+            }
+            redisUtil.del(TOKEN_REDIS_PREFIX + token);
+            LogUtil.info(AdminServiceImpl.class, "管理员退出登录,Token 已失效");
+        }
+    }
+
+    /**
+     * 分页查询管理员列表
+     */
+    @Override
+    public IPage<AdminUser> pageList(AdminQueryDTO queryDTO) {
+        Page<AdminUser> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        LambdaQueryWrapper<AdminUser> wrapper = new LambdaQueryWrapper<AdminUser>()
+                .eq(queryDTO.getAdminRole() != null, AdminUser::getAdminRole, queryDTO.getAdminRole())
+                .eq(queryDTO.getStatus() != null, AdminUser::getStatus, queryDTO.getStatus())
+                .orderByAsc(AdminUser::getId);
         // 用户名/姓名 OR 模糊查询
         boolean hasKeyword = StringUtils.hasText(queryDTO.getUsername()) || StringUtils.hasText(queryDTO.getNickname());
         if (hasKeyword) {
             String kw = StringUtils.hasText(queryDTO.getUsername()) ? queryDTO.getUsername() : queryDTO.getNickname();
-            wrapper.and(w -> w.like(AppUser::getUsername, kw).or().like(AppUser::getNickname, kw));
+            wrapper.and(w -> w.like(AdminUser::getUsername, kw).or().like(AdminUser::getNickname, kw));
         }
         return page(page, wrapper);
     }
 
     /**
      * 新增管理员
-     * 校验用户名唯一性,对密码进行 MD5 加密,userType 强制设为 3
-     *
-     * @param admin 管理员信息
      */
     @Override
-    public void addAdmin(AppUser admin) {
+    public void addAdmin(AdminUser admin) {
         normalizePhone(admin);
-        // 校验用户名唯一性
         if (!StringUtils.hasText(admin.getUsername())) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
-        long usernameCount = lambdaQuery().eq(AppUser::getUsername, admin.getUsername()).count();
+        long usernameCount = lambdaQuery().eq(AdminUser::getUsername, admin.getUsername()).count();
         if (usernameCount > 0) {
             throw new BusinessException(ResultCode.USERNAME_EXISTS);
         }
         if (StringUtils.hasText(admin.getPhone())) {
-            long phoneCount = lambdaQuery().eq(AppUser::getPhone, admin.getPhone()).count();
+            long phoneCount = lambdaQuery().eq(AdminUser::getPhone, admin.getPhone()).count();
             if (phoneCount > 0) {
                 throw new BusinessException(ResultCode.PHONE_EXISTS);
             }
         }
-        // 强制 userType=3(管理员)
-        admin.setUserType(3);
         // 密码加密
         if (StringUtils.hasText(admin.getPassword())) {
             admin.setPassword(DigestUtils.md5DigestAsHex(admin.getPassword().getBytes()));
@@ -94,27 +208,24 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
 
     /**
      * 修改管理员信息
-     * 若传入密码则重新加密,否则保留原密码;不允许修改 userType
-     *
-     * @param admin 管理员信息(需携带 id)
      */
     @Override
-    public void updateAdmin(AppUser admin) {
-        AppUser exist = getById(admin.getId());
-        if (exist == null || !Integer.valueOf(3).equals(exist.getUserType())) {
+    public void updateAdmin(AdminUser admin) {
+        AdminUser exist = getById(admin.getId());
+        if (exist == null) {
             throw new BusinessException(ResultCode.ADMIN_NOT_FOUND);
         }
         boolean clearPhone = normalizePhone(admin);
         // 若修改了用户名,校验唯一性
         if (StringUtils.hasText(admin.getUsername()) && !admin.getUsername().equals(exist.getUsername())) {
-            long count = lambdaQuery().eq(AppUser::getUsername, admin.getUsername()).count();
+            long count = lambdaQuery().eq(AdminUser::getUsername, admin.getUsername()).count();
             if (count > 0) {
                 throw new BusinessException(ResultCode.USERNAME_EXISTS);
             }
         }
-        // 若修改了手机号,校验格式与唯一性;传空字符串表示清空手机号
+        // 若修改了手机号,校验唯一性
         if (StringUtils.hasText(admin.getPhone()) && !admin.getPhone().equals(exist.getPhone())) {
-            long count = lambdaQuery().eq(AppUser::getPhone, admin.getPhone()).count();
+            long count = lambdaQuery().eq(AdminUser::getPhone, admin.getPhone()).count();
             if (count > 0) {
                 throw new BusinessException(ResultCode.PHONE_EXISTS);
             }
@@ -125,22 +236,17 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         } else {
             admin.setPassword(null);
         }
-        // 强制 userType 不变
-        admin.setUserType(null);
         updateById(admin);
         if (clearPhone) {
-            lambdaUpdate().eq(AppUser::getId, admin.getId()).set(AppUser::getPhone, null).update();
+            lambdaUpdate().eq(AdminUser::getId, admin.getId()).set(AdminUser::getPhone, null).update();
         }
         LogUtil.info(AdminServiceImpl.class, "修改管理员[{}]", admin.getId());
     }
 
     /**
      * 手机号为选填项:有值时按中国大陆手机号校验,无值时规范为 null。
-     *
-     * @param admin 管理员实体
-     * @return true 表示本次请求显式清空手机号
      */
-    private boolean normalizePhone(AppUser admin) {
+    private boolean normalizePhone(AdminUser admin) {
         if (admin.getPhone() == null) {
             return false;
         }
@@ -158,14 +264,11 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
 
     /**
      * 删除管理员(逻辑删除)
-     * adminRole=1 超级管理员不可删除
-     *
-     * @param id 管理员ID
      */
     @Override
     public void deleteAdmin(Long id) {
-        AppUser admin = getById(id);
-        if (admin == null || !Integer.valueOf(3).equals(admin.getUserType())) {
+        AdminUser admin = getById(id);
+        if (admin == null) {
             throw new BusinessException(ResultCode.ADMIN_NOT_FOUND);
         }
         if (Integer.valueOf(1).equals(admin.getAdminRole())) {
@@ -177,20 +280,16 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
 
     /**
      * 批量删除管理员(逻辑删除)
-     * 超级管理员(adminRole=1)不可被批量删除
-     *
-     * @param ids 管理员ID列表
      */
     @Override
     public void batchDeleteAdmin(List<Long> ids) {
         if (ids == null || ids.isEmpty()) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
-        List<AppUser> admins = lambdaQuery()
-                .in(AppUser::getId, ids)
-                .eq(AppUser::getUserType, 3)
+        List<AdminUser> admins = lambdaQuery()
+                .in(AdminUser::getId, ids)
                 .list();
-        for (AppUser admin : admins) {
+        for (AdminUser admin : admins) {
             if (Integer.valueOf(1).equals(admin.getAdminRole())) {
                 throw new BusinessException(ResultCode.ADMIN_CANNOT_DELETE);
             }
@@ -203,34 +302,28 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
 
     private void logicDeleteAdminsAndReleaseUniqueFields(List<Long> ids) {
         lambdaUpdate()
-                .in(AppUser::getId, ids)
-                .eq(AppUser::getUserType, 3)
-                .set(AppUser::getUsername, null)
-                .set(AppUser::getPhone, null)
-                .set(AppUser::getDeleted, 1)
-                .set(AppUser::getUpdateTime, LocalDateTime.now())
+                .in(AdminUser::getId, ids)
+                .set(AdminUser::getUsername, null)
+                .set(AdminUser::getPhone, null)
+                .set(AdminUser::getDeleted, 1)
+                .set(AdminUser::getUpdateTime, LocalDateTime.now())
                 .update();
     }
 
     /**
      * 修改管理员状态(禁用/启用)
-     * 不允许禁用当前登录账号自身
-     *
-     * @param id     管理员ID
-     * @param status 目标状态:1=启用,0=禁用
      */
     @Override
     public void updateStatus(Long id, Integer status) {
-        // 不允许禁用自身
         Long currentUserId = RequestContext.getUserId();
         if (Integer.valueOf(0).equals(status) && id.equals(currentUserId)) {
             throw new BusinessException(ResultCode.ADMIN_CANNOT_DISABLE_SELF);
         }
-        AppUser admin = getById(id);
-        if (admin == null || !Integer.valueOf(3).equals(admin.getUserType())) {
+        AdminUser admin = getById(id);
+        if (admin == null) {
             throw new BusinessException(ResultCode.ADMIN_NOT_FOUND);
         }
-        AppUser update = new AppUser();
+        AdminUser update = new AdminUser();
         update.setId(id);
         update.setStatus(status);
         updateById(update);
@@ -239,17 +332,13 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
 
     /**
      * 查询管理员权限路由列表
-     *
-     * @param adminId 管理员ID
-     * @return 路由路径 JSON 数组字符串;超级管理员返回 null
      */
     @Override
     public String getPermissions(Long adminId) {
-        AppUser admin = getById(adminId);
+        AdminUser admin = getById(adminId);
         if (admin == null) {
             throw new BusinessException(ResultCode.ADMIN_NOT_FOUND);
         }
-        // 超级管理员返回 null,前端视为全部权限
         if (Integer.valueOf(1).equals(admin.getAdminRole())) {
             return null;
         }
@@ -257,26 +346,22 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
                 new LambdaQueryWrapper<AdminPermission>()
                         .eq(AdminPermission::getAdminId, adminId)
         );
-        return permission != null ? permission.getRoutePaths() : "[]";
+        return permission != null ? normalizeRoutePaths(permission.getRoutePaths(), false) : "[]";
     }
 
     /**
      * 更新管理员权限路由列表
-     * 超级管理员(adminRole=1)调用时直接忽略,不修改
-     *
-     * @param adminId    管理员ID
-     * @param routePaths 路由路径 JSON 数组字符串
      */
     @Override
     public void updatePermissions(Long adminId, String routePaths) {
-        AppUser admin = getById(adminId);
-        if (admin == null || !Integer.valueOf(3).equals(admin.getUserType())) {
+        AdminUser admin = getById(adminId);
+        if (admin == null) {
             throw new BusinessException(ResultCode.ADMIN_NOT_FOUND);
         }
-        // 超级管理员不设置权限
         if (Integer.valueOf(1).equals(admin.getAdminRole())) {
             return;
         }
+        String normalizedRoutePaths = normalizeRoutePaths(routePaths, true);
         AdminPermission exist = adminPermissionMapper.selectOne(
                 new LambdaQueryWrapper<AdminPermission>()
                         .eq(AdminPermission::getAdminId, adminId)
@@ -284,12 +369,60 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         if (exist == null) {
             AdminPermission permission = new AdminPermission();
             permission.setAdminId(adminId);
-            permission.setRoutePaths(routePaths);
+            permission.setRoutePaths(normalizedRoutePaths);
             adminPermissionMapper.insert(permission);
         } else {
-            exist.setRoutePaths(routePaths);
+            exist.setRoutePaths(normalizedRoutePaths);
             adminPermissionMapper.updateById(exist);
         }
-        LogUtil.info(AdminServiceImpl.class, "更新管理员[{}]权限:{}", adminId, routePaths);
+        LogUtil.info(AdminServiceImpl.class, "更新管理员[{}]权限:{}", adminId, normalizedRoutePaths);
+    }
+
+    private String normalizeRoutePaths(String routePaths, boolean strict) {
+        if (!StringUtils.hasText(routePaths)) {
+            return "[]";
+        }
+        List<String> parsed;
+        try {
+            parsed = JSON.parseArray(routePaths, String.class);
+        } catch (Exception e) {
+            if (!strict) {
+                return "[]";
+            }
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        if (parsed == null || parsed.isEmpty()) {
+            return "[]";
+        }
+
+        Set<String> normalized = new LinkedHashSet<>();
+        for (String routePath : parsed) {
+            String path = normalizeRoutePath(routePath);
+            if (!StringUtils.hasText(path)) {
+                continue;
+            }
+            if (!ASSIGNABLE_ROUTE_PATHS.contains(path)) {
+                if (strict) {
+                    throw new BusinessException(ResultCode.PARAM_ERROR);
+                }
+                continue;
+            }
+            normalized.add(path);
+        }
+        return JSON.toJSONString(new ArrayList<>(normalized));
+    }
+
+    private String normalizeRoutePath(String routePath) {
+        if (!StringUtils.hasText(routePath)) {
+            return null;
+        }
+        String path = routePath.trim();
+        if ("/log".equals(path)) {
+            return "/system/log";
+        }
+        if ("/admin".equals(path)) {
+            return "/system/admin";
+        }
+        return path;
     }
 }

+ 94 - 71
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppDeviceGroupServiceImpl.java

@@ -10,14 +10,12 @@ import com.aijiuyi.admin.entity.AppUser;
 import com.aijiuyi.admin.entity.Device;
 import com.aijiuyi.admin.entity.DeviceGroup;
 import com.aijiuyi.admin.entity.DeviceGroupMember;
-import com.aijiuyi.admin.entity.UserCategory;
 import com.aijiuyi.admin.entity.UserDevice;
 import com.aijiuyi.admin.entity.UserProfile;
 import com.aijiuyi.admin.mapper.AppUserMapper;
 import com.aijiuyi.admin.mapper.DeviceGroupMapper;
 import com.aijiuyi.admin.mapper.DeviceGroupMemberMapper;
 import com.aijiuyi.admin.mapper.DeviceMapper;
-import com.aijiuyi.admin.mapper.UserCategoryMapper;
 import com.aijiuyi.admin.mapper.UserDeviceMapper;
 import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.AppDeviceGroupService;
@@ -48,7 +46,6 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
 
     private static final int DEVICE_TYPE_FAMILY = 1;
     private static final int DEVICE_TYPE_PUBLIC = 2;
-    private static final int NORMAL_USER_TYPE = 1;
     private static final int STATUS_ENABLED = 1;
     private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$";
 
@@ -70,9 +67,6 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
     @Autowired
     private UserDeviceMapper userDeviceMapper;
 
-    @Autowired
-    private UserCategoryMapper userCategoryMapper;
-
     @Autowired
     private UserAcupointService userAcupointService;
 
@@ -136,7 +130,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         }
 
         List<UserProfile> profiles = userProfileMapper.selectList(
-                new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, appUserId)
+                new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getAppUserId, appUserId)
         );
         if (!CollectionUtils.isEmpty(profiles)) {
             List<Long> profileIds = profiles.stream().map(UserProfile::getId).collect(Collectors.toList());
@@ -187,7 +181,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         UserProfile selectedProfile = dto.getProfileId() != null
                 ? requireProfile(dto.getProfileId())
                 : ensureAccountProfile(requireAppUser(appUserId));
-        if (!Objects.equals(selectedProfile.getUserId(), appUserId)) {
+        if (!Objects.equals(selectedProfile.getAppUserId(), appUserId)) {
             throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "公共设备只能选择当前账号档案");
         }
         if (!hasActiveUserDevice(selectedProfile.getId(), device.getDeviceCode())) {
@@ -223,7 +217,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         String phone = normalizePhone(dto.getPhone());
         checkGroupPhoneAvailable(group.getId(), phone, null);
 
-        AppUser linkedAccount = ensureAccountForPhone(phone, dto.getName(), dto.getUserCategoryId());
+        AppUser linkedAccount = ensureAccountForPhone(phone, dto.getName());
         checkGroupAppUserAvailable(group.getId(), linkedAccount == null ? null : linkedAccount.getId(), null);
 
         UserProfile profile = resolveProfileForNewMember(dto, phone, linkedAccount);
@@ -248,7 +242,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
 
         String phone = normalizePhone(dto.getPhone());
         checkGroupPhoneAvailable(group.getId(), phone, profileId);
-        AppUser linkedAccount = ensureAccountForPhone(phone, dto.getName(), dto.getUserCategoryId());
+        AppUser linkedAccount = ensureAccountForPhone(phone, dto.getName());
         Long linkedAppUserId = linkedAccount == null ? null : linkedAccount.getId();
         checkGroupAppUserAvailable(group.getId(), linkedAppUserId, profileId);
 
@@ -262,6 +256,36 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         return buildMemberMap(member, updatedProfile);
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteGroupMember(Long appUserId, Long groupId, Long profileId) {
+        DeviceGroup group = assertGroupAccess(groupId, appUserId);
+        DeviceGroupMember member = findActiveMemberByProfileId(group.getId(), profileId);
+        if (member == null) {
+            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
+        }
+        // 不允许删除当前账号自己的成员记录
+        if (Objects.equals(member.getAppUserId(), appUserId)) {
+            throw new BusinessException(ResultCode.GROUP_CANNOT_REMOVE_SELF);
+        }
+        DeviceGroupMember update = new DeviceGroupMember();
+        update.setId(member.getId());
+        update.setStatus(0);
+        deviceGroupMemberMapper.updateById(update);
+
+        // 同步解绑 user_device:删除群组成员时,把该 profile 与该 device 的绑定置为已解绑。
+        // 否则管理端"用户列表"通过 LEFT JOIN user_device ud ON ud.status=1 仍能查到 deviceCodes,
+        // 且前端 user/device 页面也会继续显示该用户绑定了该设备,造成"删除不彻底"的错觉。
+        // 仅解绑本群关联的 deviceCode,不影响该 profile 与其他设备的绑定。
+        Device device = requireDevice(group.getDeviceCode());
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<UserDevice>()
+                .eq(UserDevice::getUserId, profileId)
+                .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                .set(UserDevice::getStatus, 0)
+                .set(UserDevice::getIsPrimary, 0);
+        userDeviceMapper.update(null, wrapper);
+    }
+
     @Override
     public Map<String, Object> getCurrentProfile(Long appUserId) {
         AppUser appUser = requireAppUser(appUserId);
@@ -300,6 +324,52 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         return buildCurrentProfileMap(appUser, profile);
     }
 
+    @Override
+    public Device getDeviceDetail(Long appUserId, Long deviceId) {
+        requireAppUser(appUserId);
+        if (deviceId == null) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "设备ID不能为空");
+        }
+        Device device = deviceMapper.selectById(deviceId);
+        if (device == null) {
+            throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
+        }
+        if (isFamilyDevice(device)) {
+            DeviceGroup group = findDeviceGroupByCode(device.getDeviceCode());
+            if (group == null) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+            DeviceGroupMember member = findActiveMemberByAppUserId(group.getId(), appUserId);
+            if (member == null) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+        } else if (isPublicDevice(device)) {
+            List<UserProfile> profiles = userProfileMapper.selectList(
+                    new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getAppUserId, appUserId)
+            );
+            if (CollectionUtils.isEmpty(profiles)) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+            List<Long> profileIds = profiles.stream().map(UserProfile::getId).collect(Collectors.toList());
+            long count = userDeviceMapper.selectCount(
+                    new LambdaQueryWrapper<UserDevice>()
+                            .in(UserDevice::getUserId, profileIds)
+                            .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                            .eq(UserDevice::getStatus, STATUS_ENABLED)
+            );
+            if (count <= 0) {
+                throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "无权访问该设备");
+            }
+        } else {
+            throw new BusinessException(ResultCode.FORBIDDEN.getCode(), "设备类型不支持");
+        }
+        // 非数据库字段(boundUserCount/boundUserNames/userIds)不需要返回给 APP,清空避免暴露
+        device.setBoundUserCount(null);
+        device.setBoundUserNames(null);
+        device.setUserIds(null);
+        return device;
+    }
+
     private AppUser requireAppUser(Long appUserId) {
         AppUser appUser = appUserMapper.selectById(appUserId);
         if (appUser == null) {
@@ -424,7 +494,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
     private UserProfile ensureAccountProfile(AppUser appUser) {
         UserProfile profile = userProfileMapper.selectOne(
                 new LambdaQueryWrapper<UserProfile>()
-                        .eq(UserProfile::getUserId, appUser.getId())
+                        .eq(UserProfile::getAppUserId, appUser.getId())
                         .orderByAsc(UserProfile::getCreateTime)
                         .last("LIMIT 1")
         );
@@ -437,31 +507,29 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
             profile = userProfileMapper.selectOne(
                     new LambdaQueryWrapper<UserProfile>()
                             .eq(UserProfile::getPhone, phone)
-                            .and(w -> w.isNull(UserProfile::getUserId).or().eq(UserProfile::getUserId, appUser.getId()))
+                            .and(w -> w.isNull(UserProfile::getAppUserId).or().eq(UserProfile::getAppUserId, appUser.getId()))
                             .orderByAsc(UserProfile::getCreateTime)
                             .last("LIMIT 1")
             );
             if (profile != null) {
                 UserProfile update = new UserProfile();
                 update.setId(profile.getId());
-                update.setUserId(appUser.getId());
+                update.setAppUserId(appUser.getId());
                 userProfileMapper.updateById(update);
-                profile.setUserId(appUser.getId());
+                profile.setAppUserId(appUser.getId());
                 return profile;
             }
         }
 
         profile = new UserProfile();
-        profile.setUserId(appUser.getId());
+        profile.setAppUserId(appUser.getId());
         profile.setPhone(phone);
         profile.setName(resolveProfileName(appUser.getNickname(), phone, appUser.getId()));
-        profile.setUserType(NORMAL_USER_TYPE);
-        profile.setAddress(appUser.getAddress());
         userProfileMapper.insert(profile);
         return profile;
     }
 
-    private AppUser ensureAccountForPhone(String phone, String name, Long userCategoryId) {
+    private AppUser ensureAccountForPhone(String phone, String name) {
         if (phone == null) {
             return null;
         }
@@ -475,16 +543,14 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
             if (Integer.valueOf(0).equals(user.getStatus())) {
                 throw new BusinessException(ResultCode.APP_USER_DISABLED);
             }
-            syncUserCategory(user, userCategoryId);
             return user;
         }
 
         user = new AppUser();
         user.setPhone(phone);
         user.setNickname(resolveProfileName(name, phone, null));
-        user.setUserType(NORMAL_USER_TYPE);
-        user.setUserCategoryId(userCategoryId != null ? validateUserCategory(userCategoryId) : getDefaultUserCategoryId());
         user.setStatus(STATUS_ENABLED);
+        user.setLoginType(1);
         appUserMapper.insert(user);
         AppUser update = new AppUser();
         update.setId(user.getId());
@@ -494,56 +560,14 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         return user;
     }
 
-    private void syncUserCategory(AppUser user, Long userCategoryId) {
-        if (userCategoryId == null || !Integer.valueOf(NORMAL_USER_TYPE).equals(user.getUserType())) {
-            return;
-        }
-        Long validCategoryId = validateUserCategory(userCategoryId);
-        if (validCategoryId.equals(user.getUserCategoryId())) {
-            return;
-        }
-        AppUser update = new AppUser();
-        update.setId(user.getId());
-        update.setUserCategoryId(validCategoryId);
-        appUserMapper.updateById(update);
-        user.setUserCategoryId(validCategoryId);
-    }
-
-    private Long validateUserCategory(Long userCategoryId) {
-        if (userCategoryId == null) {
-            return null;
-        }
-        long count = userCategoryMapper.selectCount(new LambdaQueryWrapper<UserCategory>()
-                .eq(UserCategory::getId, userCategoryId)
-                .eq(UserCategory::getUserType, NORMAL_USER_TYPE));
-        if (count <= 0) {
-            throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
-        }
-        return userCategoryId;
-    }
-
-    private Long getDefaultUserCategoryId() {
-        UserCategory category = userCategoryMapper.selectOne(new LambdaQueryWrapper<UserCategory>()
-                .eq(UserCategory::getUserType, NORMAL_USER_TYPE)
-                .eq(UserCategory::getName, "普通用户")
-                .last("LIMIT 1"));
-        if (category == null) {
-            category = userCategoryMapper.selectOne(new LambdaQueryWrapper<UserCategory>()
-                    .eq(UserCategory::getUserType, NORMAL_USER_TYPE)
-                    .orderByAsc(UserCategory::getId)
-                    .last("LIMIT 1"));
-        }
-        return category != null ? category.getId() : null;
-    }
-
     private UserProfile buildProfileFromGroupDto(AppGroupMemberDTO dto, String phone, Long appUserId) {
         UserProfile profile = new UserProfile();
-        profile.setUserId(appUserId);
+        profile.setAppUserId(appUserId);
         profile.setName(dto.getName().trim());
         profile.setGender(dto.getGender());
         profile.setAge(dto.getAge());
         profile.setPhone(phone);
-        profile.setUserType(NORMAL_USER_TYPE);
+        profile.setUserCategoryId(dto.getUserCategoryId());
         profile.setProvinceCode(trimToNull(dto.getProvinceCode()));
         profile.setCityCode(trimToNull(dto.getCityCode()));
         profile.setDistrictCode(trimToNull(dto.getDistrictCode()));
@@ -569,7 +593,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
 
         UserProfile profile = userProfileMapper.selectOne(
                 new LambdaQueryWrapper<UserProfile>()
-                        .eq(UserProfile::getUserId, linkedAccount.getId())
+                        .eq(UserProfile::getAppUserId, linkedAccount.getId())
                         .orderByAsc(UserProfile::getCreateTime)
                         .last("LIMIT 1")
         );
@@ -577,7 +601,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
             profile = userProfileMapper.selectOne(
                     new LambdaQueryWrapper<UserProfile>()
                             .eq(UserProfile::getPhone, phone)
-                            .isNull(UserProfile::getUserId)
+                            .isNull(UserProfile::getAppUserId)
                             .orderByAsc(UserProfile::getCreateTime)
                             .last("LIMIT 1")
             );
@@ -595,7 +619,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
     private void updateProfileFromGroupDto(Long profileId, AppGroupMemberDTO dto, String phone, Long appUserId) {
         LambdaUpdateWrapper<UserProfile> wrapper = new LambdaUpdateWrapper<UserProfile>()
                 .eq(UserProfile::getId, profileId)
-                .set(UserProfile::getUserId, appUserId)
+                .set(UserProfile::getAppUserId, appUserId)
                 .set(UserProfile::getName, dto.getName().trim())
                 .set(UserProfile::getGender, dto.getGender())
                 .set(UserProfile::getAge, dto.getAge())
@@ -880,12 +904,11 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         }
         data.put("profileId", profile.getId());
         data.put("id", profile.getId());
-        data.put("appUserId", profile.getUserId());
+        data.put("appUserId", profile.getAppUserId());
         data.put("name", profile.getName());
         data.put("gender", profile.getGender());
         data.put("age", profile.getAge());
         data.put("phone", profile.getPhone());
-        data.put("userType", profile.getUserType());
         data.put("provinceCode", profile.getProvinceCode());
         data.put("cityCode", profile.getCityCode());
         data.put("districtCode", profile.getDistrictCode());
@@ -912,7 +935,7 @@ public class AppDeviceGroupServiceImpl implements AppDeviceGroupService {
         data.put("accountPhone", appUser.getPhone());
         data.put("phone", profile.getPhone());
         data.put("appId", appUser.getAppId());
-        data.put("userCategoryId", appUser.getUserCategoryId());
+        data.put("userCategoryId", profile.getUserCategoryId());
         return data;
     }
 

+ 34 - 332
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppUserServiceImpl.java

@@ -10,22 +10,12 @@ import com.aijiuyi.admin.common.util.RedisUtil;
 import com.aijiuyi.admin.common.util.SmsUtil;
 import com.aijiuyi.admin.common.util.TokenUtil;
 import com.aijiuyi.admin.controller.dto.AppLoginDTO;
-import com.aijiuyi.admin.controller.dto.AppUserQueryDTO;
-import com.aijiuyi.admin.controller.dto.LoginDTO;
-import com.aijiuyi.admin.entity.AdminPermission;
 import com.aijiuyi.admin.entity.AppUser;
-import com.aijiuyi.admin.entity.UserCategory;
-import com.aijiuyi.admin.mapper.AdminPermissionMapper;
 import com.aijiuyi.admin.mapper.AppUserMapper;
-import com.aijiuyi.admin.mapper.UserCategoryMapper;
 import com.aijiuyi.admin.service.AppUserService;
-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.stereotype.Service;
-import org.springframework.util.DigestUtils;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
@@ -33,14 +23,12 @@ import java.util.HashMap;
 import java.util.Map;
 
 /**
- * 应用用户 Service 实现类
- * 统一用户中心:用户、专家、管理员均使用此表
+ * APP用户账号 Service 实现类
+ * 仅处理APP端手机号登录、短信验证码等逻辑
  */
 @Service
 public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implements AppUserService {
 
-    private static final int NORMAL_USER_TYPE = 1;
-
     /** Token 在 Redis 中的 key 前缀 */
     private static final String TOKEN_REDIS_PREFIX = TokenInterceptor.REDIS_TOKEN_KEY_PREFIX;
 
@@ -65,10 +53,7 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
     /** 短信发送限流:60秒内不重复发送 */
     private static final long SMS_LIMIT_SECONDS = 60L;
 
-    /**
-     * 短信模板 Code(阿里云短信控制台配置的模板)
-     * TODO: 替换为实际申请的验证码模板 Code
-     */
+    /** 短信模板 Code */
     private static final String SMS_TEMPLATE_CODE = "SMS_316430242";
 
     /** 开发调试用万能验证码(仅 dev/test 环境生效) */
@@ -80,297 +65,8 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
     @Autowired
     private RedisUtil redisUtil;
 
-    @Autowired
-    private AdminPermissionMapper adminPermissionMapper;
-
-    @Autowired
-    private UserCategoryMapper userCategoryMapper;
-
-    /**
-     * 管理员登录
-     * 通过用户名+密码校验身份,生成 JWT Token 并存入 Redis
-     *
-     * @param loginDTO 登录参数
-     * @return 包含 token 和用户基本信息的 Result
-     */
-    @Override
-    public Result<Map<String, Object>> login(LoginDTO loginDTO) {
-        LogUtil.info(AppUserServiceImpl.class, "用户[{}]尝试登录", loginDTO.getUsername());
-
-        // 根据用户名查询用户(仅管理员可通过用户名登录)
-        AppUser user = lambdaQuery()
-                .eq(AppUser::getUsername, loginDTO.getUsername())
-                .one();
-
-        // 用户不存在
-        if (user == null) {
-            LogUtil.warn(AppUserServiceImpl.class, "用户[{}]不存在", loginDTO.getUsername());
-            throw new BusinessException(ResultCode.USER_NOT_FOUND);
-        }
-
-        // 验证密码(前端传明文,后端 MD5 加密后比对)
-        String md5Password = DigestUtils.md5DigestAsHex(loginDTO.getPassword().getBytes());
-        if (!md5Password.equals(user.getPassword())) {
-            LogUtil.warn(AppUserServiceImpl.class, "用户[{}]密码错误", loginDTO.getUsername());
-            throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
-        }
-
-        // 验证用户状态
-        if (user.getStatus() != null && user.getStatus() == 0) {
-            LogUtil.warn(AppUserServiceImpl.class, "用户[{}]已被禁用", loginDTO.getUsername());
-            throw new BusinessException(ResultCode.USER_DISABLED);
-        }
-
-        // 验证角色(仅 userType=3=管理员 可登录后台)
-        if (user.getUserType() == null || user.getUserType() != 3) {
-            LogUtil.warn(AppUserServiceImpl.class, "用户[{}]无后台登录权限", loginDTO.getUsername());
-            throw new BusinessException(ResultCode.FORBIDDEN);
-        }
-
-        // 生成 JWT Token(携带 userType)
-        String token = TokenUtil.generateToken(user.getId(), user.getUsername(), user.getUserType());
-
-        // 将 Token 存入 Redis,3年过期
-        redisUtil.set(TOKEN_REDIS_PREFIX + token, user.getId(), TOKEN_EXPIRE_SECONDS);
-
-        // 更新最后登录时间
-        AppUser updateLogin = new AppUser();
-        updateLogin.setId(user.getId());
-        updateLogin.setLastLoginTime(LocalDateTime.now());
-        updateById(updateLogin);
-
-        // 登录成功后将用户信息写入 RequestContext,供 LogAspect 正确记录操作人
-        RequestContext.setUserId(user.getId());
-        RequestContext.setUserName(user.getUsername());
-        RequestContext.setUserType(user.getUserType());
-
-        LogUtil.info(AppUserServiceImpl.class, "用户[{}]登录成功", user.getUsername());
-
-        // 查询权限路由(超级管理员返回 null,代表全部权限)
-        String routePaths = null;
-        if (!Integer.valueOf(1).equals(user.getAdminRole())) {
-            AdminPermission permission = adminPermissionMapper.selectOne(
-                    new LambdaQueryWrapper<AdminPermission>()
-                            .eq(AdminPermission::getAdminId, user.getId())
-            );
-            routePaths = permission != null ? permission.getRoutePaths() : "[]";
-        }
-
-        // 组装返回数据
-        Map<String, Object> resultData = new HashMap<>(8);
-        resultData.put("token", token);
-        resultData.put("userId", user.getId());
-        resultData.put("username", user.getUsername());
-        resultData.put("nickname", user.getNickname());
-        resultData.put("avatar", user.getAvatar());
-        resultData.put("userType", user.getUserType());
-        resultData.put("adminRole", user.getAdminRole());
-        resultData.put("permissions", routePaths);
-
-        return Result.success(resultData);
-    }
-
-    /**
-     * 退出登录
-     * 从 Redis 中删除 Token,使 Token 立即失效
-     *
-     * @param token 当前用户的 Token
-     */
-    @Override
-    public void logout(String token) {
-        if (StringUtils.hasText(token)) {
-            // 去掉 Bearer 前缀
-            if (token.startsWith("Bearer ")) {
-                token = token.substring(7);
-            }
-            // 从 Redis 删除 Token
-            redisUtil.del(TOKEN_REDIS_PREFIX + token);
-            LogUtil.info(AppUserServiceImpl.class, "用户退出登录,Token 已失效");
-        }
-    }
-
-    /**
-     * 分页查询应用用户列表
-     *
-     * @param queryDTO 查询条件
-     * @return 分页结果
-     */
-    @Override
-    public IPage<AppUser> pageList(AppUserQueryDTO queryDTO) {
-        Page<AppUser> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
-        LambdaQueryWrapper<AppUser> wrapper = new LambdaQueryWrapper<>();
-        wrapper.like(StringUtils.hasText(queryDTO.getPhone()), AppUser::getPhone, queryDTO.getPhone())
-                .like(StringUtils.hasText(queryDTO.getNickname()), AppUser::getNickname, queryDTO.getNickname())
-                .eq(queryDTO.getStatus() != null, AppUser::getStatus, queryDTO.getStatus());
-        if (queryDTO.getUserCategoryId() != null) {
-            wrapper.eq(AppUser::getUserType, NORMAL_USER_TYPE)
-                    .eq(AppUser::getUserCategoryId, queryDTO.getUserCategoryId());
-        } else {
-            wrapper.eq(queryDTO.getUserType() != null, AppUser::getUserType, queryDTO.getUserType());
-        }
-        wrapper.orderByDesc(AppUser::getCreateTime);
-        return page(page, wrapper);
-    }
-
-    /**
-     * 修改用户状态(禁用/启用)
-     *
-     * @param id     用户ID
-     * @param status 目标状态:1=启用,0=禁用
-     */
-    @Override
-    public void updateStatus(Long id, Integer status) {
-        AppUser user = getById(id);
-        if (user == null) {
-            throw new BusinessException(ResultCode.APP_USER_NOT_FOUND);
-        }
-        AppUser update = new AppUser();
-        update.setId(id);
-        update.setStatus(status);
-        updateById(update);
-        LogUtil.info(AppUserServiceImpl.class, "应用用户[{}]状态修改为[{}]", id, status);
-    }
-
-    /**
-     * 新增应用用户
-     * 若为管理员(userType=3)则校验用户名唯一性并对密码做 MD5 加密
-     *
-     * @param appUser 用户信息
-     */
-    @Override
-    public void addUser(AppUser appUser) {
-        normalizeUserCategory(appUser.getUserType(), appUser);
-        // 手机号唯一性校验(手机号不为空时校验)
-        if (StringUtils.hasText(appUser.getPhone())) {
-            long phoneCount = lambdaQuery().eq(AppUser::getPhone, appUser.getPhone()).count();
-            if (phoneCount > 0) {
-                throw new BusinessException(ResultCode.PHONE_EXISTS);
-            }
-        }
-        // 管理员需校验用户名唯一性,并对密码加密
-        if (Integer.valueOf(3).equals(appUser.getUserType())) {
-            if (StringUtils.hasText(appUser.getUsername())) {
-                long usernameCount = lambdaQuery().eq(AppUser::getUsername, appUser.getUsername()).count();
-                if (usernameCount > 0) {
-                    throw new BusinessException(ResultCode.USERNAME_EXISTS);
-                }
-            }
-            // 密码 MD5 加密
-            if (StringUtils.hasText(appUser.getPassword())) {
-                appUser.setPassword(DigestUtils.md5DigestAsHex(appUser.getPassword().getBytes()));
-            }
-        }
-        save(appUser);
-        LogUtil.info(AppUserServiceImpl.class, "新增应用用户,手机号[{}],类型[{}]", appUser.getPhone(), appUser.getUserType());
-    }
-
-    /**
-     * 修改应用用户
-     *
-     * @param appUser 用户信息
-     */
-    @Override
-    public void updateUser(AppUser appUser) {
-        AppUser exist = getById(appUser.getId());
-        if (exist == null) {
-            throw new BusinessException(ResultCode.APP_USER_NOT_FOUND);
-        }
-        Integer targetUserType = appUser.getUserType() != null ? appUser.getUserType() : exist.getUserType();
-        normalizeUserCategory(targetUserType, appUser);
-        boolean shouldClearUserCategory = !Integer.valueOf(NORMAL_USER_TYPE).equals(targetUserType);
-        // 如果修改了手机号,校验唯一性
-        if (StringUtils.hasText(appUser.getPhone()) && !appUser.getPhone().equals(exist.getPhone())) {
-            long count = lambdaQuery().eq(AppUser::getPhone, appUser.getPhone()).count();
-            if (count > 0) {
-                throw new BusinessException(ResultCode.PHONE_EXISTS);
-            }
-        }
-        // 如果修改了用户名,校验唯一性
-        if (StringUtils.hasText(appUser.getUsername()) && !appUser.getUsername().equals(exist.getUsername())) {
-            long count = lambdaQuery().eq(AppUser::getUsername, appUser.getUsername()).count();
-            if (count > 0) {
-                throw new BusinessException(ResultCode.USERNAME_EXISTS);
-            }
-        }
-        // 如果修改了密码,重新加密
-        if (StringUtils.hasText(appUser.getPassword())) {
-            appUser.setPassword(DigestUtils.md5DigestAsHex(appUser.getPassword().getBytes()));
-        } else {
-            // 不修改密码字段
-            appUser.setPassword(null);
-        }
-        updateById(appUser);
-        if (shouldClearUserCategory) {
-            lambdaUpdate()
-                    .set(AppUser::getUserCategoryId, null)
-                    .eq(AppUser::getId, appUser.getId())
-                    .update();
-        }
-        LogUtil.info(AppUserServiceImpl.class, "修改应用用户[{}]", appUser.getId());
-    }
-
-    private void normalizeUserCategory(Integer userType, AppUser appUser) {
-        if (Integer.valueOf(NORMAL_USER_TYPE).equals(userType)) {
-            if (appUser.getUserCategoryId() == null) {
-                appUser.setUserCategoryId(getDefaultUserCategoryId());
-            } else {
-                validateUserCategory(appUser.getUserCategoryId());
-            }
-            return;
-        }
-        appUser.setUserCategoryId(null);
-    }
-
-    private Long getDefaultUserCategoryId() {
-        UserCategory category = userCategoryMapper.selectOne(new LambdaQueryWrapper<UserCategory>()
-                .eq(UserCategory::getUserType, NORMAL_USER_TYPE)
-                .eq(UserCategory::getName, "普通用户")
-                .last("LIMIT 1"));
-        if (category == null) {
-            category = userCategoryMapper.selectOne(new LambdaQueryWrapper<UserCategory>()
-                    .eq(UserCategory::getUserType, NORMAL_USER_TYPE)
-                    .orderByAsc(UserCategory::getId)
-                    .last("LIMIT 1"));
-        }
-        return category != null ? category.getId() : null;
-    }
-
-    private void validateUserCategory(Long userCategoryId) {
-        if (userCategoryId == null) {
-            return;
-        }
-        long count = userCategoryMapper.selectCount(new LambdaQueryWrapper<UserCategory>()
-                .eq(UserCategory::getId, userCategoryId)
-                .eq(UserCategory::getUserType, NORMAL_USER_TYPE));
-        if (count <= 0) {
-            throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
-        }
-    }
-
-    /**
-     * 删除应用用户(逻辑删除)
-     *
-     * @param id 用户ID
-     */
-    @Override
-    public void deleteUser(Long id) {
-        AppUser user = getById(id);
-        if (user == null) {
-            throw new BusinessException(ResultCode.APP_USER_NOT_FOUND);
-        }
-        removeById(id);
-        LogUtil.info(AppUserServiceImpl.class, "删除应用用户[{}]", id);
-    }
-
     /**
      * App 端发送短信验证码
-     * 处理逻辑:
-     * 1. 检60秒限流(同一手机号内不重复发送)
-     * 2. 生成6位随机验证码
-     * 3. 调用阿里云短信发送
-     * 4. 将验证码存入 Redis,有效期 5 分钟
-     *
-     * @param phone 手机号
      */
     @Override
     public void sendSmsCode(String phone) {
@@ -380,13 +76,13 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
             LogUtil.info(AppUserServiceImpl.class, "[开发模式] 跳过真实短信发送, phone={}, code={}", phone, DEV_BYPASS_CODE);
             return;
         }
-    
+
         // 检60秒内是否已发送(限流)
         String limitKey = SMS_LIMIT_PREFIX + phone;
         if (redisUtil.hasKey(limitKey)) {
             throw new BusinessException(ResultCode.SMS_SEND_TOO_FREQUENT);
         }
-    
+
         // 检查每日发送次数是否已达上限
         String dailyKey = SMS_DAILY_COUNT_PREFIX + phone;
         Object dailyCount = redisUtil.get(dailyKey);
@@ -394,8 +90,8 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
         if (count >= SMS_DAILY_MAX_COUNT) {
             throw new BusinessException(ResultCode.SMS_DAILY_LIMIT);
         }
-    
-        // 生6位随机验证码
+
+        // 生6位随机验证码
         String code = String.format("%06d", (int) (Math.random() * 1000000));
 
         // 发送短信
@@ -408,13 +104,12 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
 
         // 存入 Redis(5 分钟有效)
         redisUtil.set(SMS_CODE_PREFIX + phone, code, SMS_CODE_EXPIRE_SECONDS);
-        // 设置60秒限流(设置在发送成功后)
+        // 设置60秒限流
         redisUtil.set(limitKey, "1", SMS_LIMIT_SECONDS);
-        // 累加每日发送次数(过期时间到当天结束)
+        // 累加每日发送次数
         if (redisUtil.hasKey(dailyKey)) {
             redisUtil.set(dailyKey, String.valueOf(count + 1), redisUtil.getExpire(dailyKey));
         } else {
-            // 计算到当天23:59:59的剩余秒数
             long secondsUntilMidnight = java.time.Duration.between(
                     java.time.LocalDateTime.now(),
                     java.time.LocalDate.now().plusDays(1).atStartOfDay()
@@ -434,15 +129,6 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
 
     /**
      * App 端手机号验证码登录
-     * 处理逻辑:
-     * 1. 校验验证码(是否存在、是否正确)
-     * 2. 验证成功后删除 Redis 中的验证码(一次性)
-     * 3. 查询用户,不存在则自动注册
-     * 4. 生成 JWT Token 并存入 Redis(3 年有效)
-     * 5. 更新最后登录时间
-     *
-     * @param loginDTO 手机号+验证码
-     * @return 包含 token 和用户信息的 Result
      */
     @Override
     public Result<Map<String, Object>> appLogin(AppLoginDTO loginDTO) {
@@ -472,12 +158,10 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
             user = new AppUser();
             user.setPhone(phone);
             user.setNickname("用户" + phone.substring(7));
-            user.setUserType(1);
-            user.setUserCategoryId(getDefaultUserCategoryId());
             user.setStatus(1);
             user.setLoginType(1);
             save(user);
-            // 注册成功后生成 AppId(前缀AJY + 8位用户ID)
+            // 注册成功后生成 AppId
             user.setAppId("AJY" + String.format("%08d", user.getId()));
             updateById(user);
             LogUtil.info(AppUserServiceImpl.class, "App 新用户自动注册: phone={}, appId={}", phone, user.getAppId());
@@ -486,10 +170,16 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
             if (user.getStatus() != null && user.getStatus() == 0) {
                 throw new BusinessException(ResultCode.APP_USER_DISABLED);
             }
+            // 兼容历史数据:已有用户若未生成 app_id,登录时自动补生成
+            if (!StringUtils.hasText(user.getAppId())) {
+                user.setAppId("AJY" + String.format("%08d", user.getId()));
+                updateById(user);
+                LogUtil.info(AppUserServiceImpl.class, "App 登录时补生成 AppId: phone={}, appId={}", phone, user.getAppId());
+            }
         }
 
-        // 生成 JWT Token(username 字段存手机号)
-        String token = TokenUtil.generateToken(user.getId(), phone, user.getUserType());
+        // 生成 JWT Token(userType=1 表示APP用户
+        String token = TokenUtil.generateToken(user.getId(), phone, 1);
 
         // Token 存入 Redis(3 年有效)
         redisUtil.set(TOKEN_REDIS_PREFIX + token, user.getId(), TOKEN_EXPIRE_SECONDS);
@@ -501,10 +191,10 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
         updateLogin.setLoginType(1);
         updateById(updateLogin);
 
-        // 写入请求上下文(供 LogAspect 正确记录操作人)
+        // 写入请求上下文
         RequestContext.setUserId(user.getId());
         RequestContext.setUserName(phone);
-        RequestContext.setUserType(user.getUserType());
+        RequestContext.setUserType(1);
 
         LogUtil.info(AppUserServiceImpl.class, "App 用户[{}]登录成功", phone);
 
@@ -515,9 +205,21 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
         resultData.put("nickname", user.getNickname());
         resultData.put("avatar", user.getAvatar());
         resultData.put("appId", user.getAppId());
-        resultData.put("userType", user.getUserType());
-        resultData.put("userCategoryId", user.getUserCategoryId());
 
         return Result.success(resultData);
     }
+
+    /**
+     * APP用户退出登录
+     */
+    @Override
+    public void logout(String token) {
+        if (StringUtils.hasText(token)) {
+            if (token.startsWith("Bearer ")) {
+                token = token.substring(7);
+            }
+            redisUtil.del(TOKEN_REDIS_PREFIX + token);
+            LogUtil.info(AppUserServiceImpl.class, "APP用户退出登录,Token 已失效");
+        }
+    }
 }

+ 204 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppVersionServiceImpl.java

@@ -0,0 +1,204 @@
+package com.aijiuyi.admin.service.impl;
+
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.AppVersionCheckVO;
+import com.aijiuyi.admin.controller.dto.AppVersionQueryDTO;
+import com.aijiuyi.admin.controller.dto.AppVersionSaveDTO;
+import com.aijiuyi.admin.entity.AppVersion;
+import com.aijiuyi.admin.mapper.AppVersionMapper;
+import com.aijiuyi.admin.service.AppVersionService;
+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.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * APP版本管理 Service 实现类
+ */
+@Service
+public class AppVersionServiceImpl extends ServiceImpl<AppVersionMapper, AppVersion> implements AppVersionService {
+
+    @Override
+    public IPage<AppVersion> pageList(AppVersionQueryDTO queryDTO) {
+        Page<AppVersion> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        LambdaQueryWrapper<AppVersion> wrapper = new LambdaQueryWrapper<>();
+        if (queryDTO.getPlatform() != null) {
+            wrapper.eq(AppVersion::getPlatform, queryDTO.getPlatform());
+        }
+        if (queryDTO.getStatus() != null) {
+            wrapper.eq(AppVersion::getStatus, queryDTO.getStatus());
+        }
+        if (StringUtils.hasText(queryDTO.getVersionCode())) {
+            wrapper.like(AppVersion::getVersionCode, queryDTO.getVersionCode());
+        }
+        wrapper.orderByDesc(AppVersion::getVersionNumber);
+        return page(page, wrapper);
+    }
+
+    @Override
+    public void add(AppVersionSaveDTO dto) {
+        validateDTO(dto);
+        // 检查同平台同版本号是否已存在
+        long count = lambdaQuery()
+                .eq(AppVersion::getPlatform, dto.getPlatform())
+                .eq(AppVersion::getVersionCode, dto.getVersionCode())
+                .count();
+        if (count > 0) {
+            throw new BusinessException("该平台已存在相同版本号");
+        }
+        AppVersion version = buildFromDTO(dto);
+        version.setPublishTime(LocalDateTime.now());
+        save(version);
+        LogUtil.info(AppVersionServiceImpl.class, "新增版本[{}] 平台[{}]", dto.getVersionCode(), dto.getPlatform());
+    }
+
+    @Override
+    public void update(AppVersionSaveDTO dto) {
+        if (dto.getId() == null) {
+            throw new BusinessException("版本ID不能为空");
+        }
+        AppVersion exist = getById(dto.getId());
+        if (exist == null) {
+            throw new BusinessException("版本记录不存在");
+        }
+        validateDTO(dto);
+        // 检查同平台同版本号是否已被其他记录占用
+        long count = lambdaQuery()
+                .eq(AppVersion::getPlatform, dto.getPlatform())
+                .eq(AppVersion::getVersionCode, dto.getVersionCode())
+                .ne(AppVersion::getId, dto.getId())
+                .count();
+        if (count > 0) {
+            throw new BusinessException("该平台已存在相同版本号");
+        }
+        AppVersion version = buildFromDTO(dto);
+        version.setId(dto.getId());
+        updateById(version);
+        LogUtil.info(AppVersionServiceImpl.class, "编辑版本[{}]", dto.getId());
+    }
+
+    @Override
+    public void delete(Long id) {
+        AppVersion exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException("版本记录不存在");
+        }
+        removeById(id);
+        LogUtil.info(AppVersionServiceImpl.class, "删除版本[{}]", id);
+    }
+
+    @Override
+    public void toggleStatus(Long id) {
+        AppVersion exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException("版本记录不存在");
+        }
+        AppVersion update = new AppVersion();
+        update.setId(id);
+        update.setStatus(exist.getStatus() == 1 ? 0 : 1);
+        updateById(update);
+        LogUtil.info(AppVersionServiceImpl.class, "切换版本[{}]状态为[{}]", id, update.getStatus());
+    }
+
+    @Override
+    public AppVersionCheckVO checkUpdate(Integer platform, String versionCode) {
+        AppVersionCheckVO vo = new AppVersionCheckVO();
+        vo.setHasUpdate(false);
+
+        if (platform == null || !StringUtils.hasText(versionCode)) {
+            return vo;
+        }
+
+        int currentVersionNumber = parseVersionNumber(versionCode);
+
+        // 查找该平台下所有已启用的版本,通过解析versionCode进行比较
+        List<AppVersion> versions = lambdaQuery()
+                .eq(AppVersion::getPlatform, platform)
+                .eq(AppVersion::getStatus, 1)
+                .list();
+
+        AppVersion latest = null;
+        int latestNumber = currentVersionNumber;
+        for (AppVersion v : versions) {
+            int vNumber = parseVersionNumber(v.getVersionCode());
+            if (vNumber > latestNumber) {
+                latestNumber = vNumber;
+                latest = v;
+            }
+        }
+
+        if (latest == null) {
+            return vo;
+        }
+
+        vo.setHasUpdate(true);
+        vo.setVersionCode(latest.getVersionCode());
+        vo.setVersionNumber(latest.getVersionNumber());
+        vo.setUpdateType(latest.getUpdateType());
+        vo.setDownloadUrl(latest.getDownloadUrl());
+        vo.setUpdateContent(latest.getUpdateContent());
+        vo.setFileSize(latest.getFileSize());
+        return vo;
+    }
+
+    /**
+     * 将版本号字符串解析为整数(如 "1.0.1" -> 10001, "1.2.0" -> 10200)
+     * 规则:major * 10000 + minor * 100 + patch
+     */
+    private int parseVersionNumber(String versionCode) {
+        try {
+            String[] parts = versionCode.split("\\.");
+            int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0;
+            int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
+            int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
+            return major * 10000 + minor * 100 + patch;
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    /**
+     * 校验DTO
+     */
+    private void validateDTO(AppVersionSaveDTO dto) {
+        if (!StringUtils.hasText(dto.getVersionCode())) {
+            throw new BusinessException("版本号不能为空");
+        }
+        if (dto.getPlatform() == null || (dto.getPlatform() != 1 && dto.getPlatform() != 2)) {
+            throw new BusinessException("平台参数不合法");
+        }
+        if (dto.getUpdateType() == null || (dto.getUpdateType() != 1 && dto.getUpdateType() != 2)) {
+            throw new BusinessException("更新类型不合法");
+        }
+        if (!StringUtils.hasText(dto.getDownloadUrl())) {
+            throw new BusinessException("下载地址不能为空");
+        }
+    }
+
+    /**
+     * 从DTO构建实体
+     */
+    private AppVersion buildFromDTO(AppVersionSaveDTO dto) {
+        AppVersion version = new AppVersion();
+        version.setVersionCode(dto.getVersionCode());
+        // versionNumber非必填,未填时自动从versionCode计算
+        if (dto.getVersionNumber() != null && dto.getVersionNumber() > 0) {
+            version.setVersionNumber(dto.getVersionNumber());
+        } else {
+            version.setVersionNumber(parseVersionNumber(dto.getVersionCode()));
+        }
+        version.setPlatform(dto.getPlatform());
+        version.setUpdateType(dto.getUpdateType());
+        version.setDownloadUrl(dto.getDownloadUrl());
+        version.setUpdateContent(dto.getUpdateContent());
+        version.setFileSize(dto.getFileSize());
+        version.setStatus(dto.getStatus() != null ? dto.getStatus() : 1);
+        return version;
+    }
+}

+ 5 - 5
code/backend/src/main/java/com/aijiuyi/admin/service/impl/ContentArticleServiceImpl.java

@@ -6,10 +6,10 @@ import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.ArticleRejectDTO;
 import com.aijiuyi.admin.controller.dto.ContentArticleQueryDTO;
-import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.entity.AdminUser;
 import com.aijiuyi.admin.entity.ContentArticle;
 import com.aijiuyi.admin.mapper.ContentArticleMapper;
-import com.aijiuyi.admin.service.AppUserService;
+import com.aijiuyi.admin.service.AdminService;
 import com.aijiuyi.admin.service.ContentArticleService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -39,7 +39,7 @@ public class ContentArticleServiceImpl extends ServiceImpl<ContentArticleMapper,
     public static final String STATUS_OFFLINE = "OFFLINE";
 
     @Autowired
-    private AppUserService appUserService;
+    private AdminService adminService;
 
     /**
      * 分页查询文章列表
@@ -279,7 +279,7 @@ public class ContentArticleServiceImpl extends ServiceImpl<ContentArticleMapper,
         if (userId == null) {
             return false;
         }
-        AppUser user = appUserService.getById(userId);
-        return user != null && user.getAdminRole() != null && user.getAdminRole() == 1;
+        AdminUser admin = adminService.getById(userId);
+        return admin != null && admin.getAdminRole() != null && admin.getAdminRole() == 1;
     }
 }

+ 366 - 86
code/backend/src/main/java/com/aijiuyi/admin/service/impl/DeviceServiceImpl.java

@@ -6,10 +6,16 @@ import com.aijiuyi.admin.common.util.LogUtil;
 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.DeviceGroup;
+import com.aijiuyi.admin.entity.DeviceGroupMember;
 import com.aijiuyi.admin.entity.DeviceUserVO;
 import com.aijiuyi.admin.entity.UserDevice;
+import com.aijiuyi.admin.entity.UserProfile;
 import com.aijiuyi.admin.mapper.DeviceMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMemberMapper;
 import com.aijiuyi.admin.mapper.UserDeviceMapper;
+import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.DeviceService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
@@ -34,11 +40,24 @@ import java.util.stream.Collectors;
 @Service
 public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> implements DeviceService {
 
+    private static final int DEVICE_TYPE_FAMILY = 1;
+    private static final int DEVICE_TYPE_PUBLIC = 2;
+    private static final int STATUS_ENABLED = 1;
+
     @Autowired
     private UserDeviceMapper userDeviceMapper;
 
+    @Autowired
+    private DeviceGroupMapper deviceGroupMapper;
+
+    @Autowired
+    private DeviceGroupMemberMapper deviceGroupMemberMapper;
+
+    @Autowired
+    private UserProfileMapper userProfileMapper;
+
     /**
-     * 分页查询设备列表(联表聚合绑定用户信息)
+     * 分页查询设备列表(聚合家庭群组成员或公共设备绑定用户)
      *
      * @param queryDTO 查询条件
      * @return 分页结果
@@ -72,12 +91,12 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         Device device = buildDeviceFromDto(dto);
         save(device);
         // 绑定初始用户
-        bindUsers(device.getDeviceCode(), device.getDeviceName(), device.getDeviceModel(), dto.getUserIds());
+        bindUsers(device, dto.getUserIds());
         LogUtil.info(DeviceServiceImpl.class, "新增设备[{}],编号[{}]", dto.getDeviceName(), dto.getDeviceCode());
     }
 
     /**
-     * 修改设备信息(可同步更新绑定用户
+     * 修改设备信息(可同步更新关联用户/成员
      *
      * @param dto 设备信息
      */
@@ -88,6 +107,11 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (exist == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
+        Integer targetDeviceType = resolveDeviceType(dto.getDeviceType());
+        Integer currentDeviceType = resolveDeviceType(exist);
+        if (!targetDeviceType.equals(currentDeviceType) && hasActiveDeviceUsers(exist.getDeviceCode())) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "已有绑定用户或群组成员的设备不允许直接修改设备类型");
+        }
         // 若修改了序列号,校验唯一性
         if (StringUtils.hasText(dto.getSerialNo()) && !dto.getSerialNo().equals(exist.getSerialNo())) {
             long serialCount = lambdaQuery()
@@ -103,9 +127,11 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         // 设备编号不允许修改,清除该字段防止误更新
         update.setDeviceCode(null);
         updateById(update);
-        // 若传入了 userIds(包括空列表),则同步更新绑定用户
+        update.setDeviceCode(exist.getDeviceCode());
+        syncUserDeviceInfo(update);
+        // 若传入了 userIds(包括空列表),则同步更新关联用户/成员
         if (dto.getUserIds() != null) {
-            reconcileUserBindings(exist.getDeviceCode(), exist.getDeviceName(), exist.getDeviceModel(), dto.getUserIds());
+            reconcileDeviceUsers(update, dto.getUserIds());
         }
         LogUtil.info(DeviceServiceImpl.class, "修改设备[{}]", dto.getId());
     }
@@ -139,7 +165,7 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (CollectionUtils.isEmpty(ids)) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
-        // 先查出设备编号再删除,用于解绑用户
+        // 先查出设备编号再删除,用于移除/解绑关联用户
         List<Device> devices = listByIds(ids);
         removeByIds(ids);
         for (Device device : devices) {
@@ -171,10 +197,10 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
     }
 
     /**
-     * 查询设备绑定的用户列表
+     * 查询设备关联用户/成员列表
      *
      * @param deviceId 设备ID
-     * @return 绑定用户列表
+     * @return 关联用户/成员列表
      */
     @Override
     public List<DeviceUserVO> getDeviceUsers(Long deviceId) {
@@ -186,7 +212,7 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
     }
 
     /**
-     * 为设备添加绑定用户
+     * 为设备添加成员/绑定用户
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -197,27 +223,12 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (device == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
-        // 检查用户是否已绑定该设备
-        long count = userDeviceMapper.selectCount(
-                new LambdaQueryWrapper<UserDevice>()
-                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
-                        .eq(UserDevice::getUserId, profileId)
-                        .eq(UserDevice::getStatus, 1)
-        );
-        if (count > 0) {
-            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        if (isFamilyDevice(device)) {
+            addFamilyDeviceUser(device, profileId);
+        } else {
+            addPublicDeviceUser(device, profileId);
         }
-        UserDevice ud = new UserDevice();
-        ud.setUserId(profileId);
-        ud.setDeviceCode(device.getDeviceCode());
-        ud.setDeviceName(device.getDeviceName());
-        ud.setDeviceModel(device.getDeviceModel());
-        ud.setStatus(1);
-        ud.setIsPrimary(0);
-        ud.setOnlineStatus(0);
-        ud.setBindTime(LocalDateTime.now());
-        userDeviceMapper.insert(ud);
-        LogUtil.info(DeviceServiceImpl.class, "设备[{}]添加绑定用户[{}]", device.getDeviceCode(), profileId);
+        LogUtil.info(DeviceServiceImpl.class, "设备[{}]添加关联用户[{}]", device.getDeviceCode(), profileId);
     }
 
     /**
@@ -232,18 +243,16 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (device == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
-        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
-        wrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
-               .eq(UserDevice::getUserId, profileId)
-               .eq(UserDevice::getStatus, 1)
-               .set(UserDevice::getStatus, 0)
-               .set(UserDevice::getIsPrimary, 0);
-        userDeviceMapper.update(null, wrapper);
-        LogUtil.info(DeviceServiceImpl.class, "设备[{}]解绑用户[{}]", device.getDeviceCode(), profileId);
+        if (isFamilyDevice(device)) {
+            removeFamilyDeviceUser(device, profileId);
+        } else {
+            removePublicDeviceUser(device, profileId);
+        }
+        LogUtil.info(DeviceServiceImpl.class, "设备[{}]移除关联用户[{}]", device.getDeviceCode(), profileId);
     }
 
     /**
-     * 设为主用户(同一设备下只能有一个主用户
+     * 设为主用户(仅公共设备兼容旧绑定关系,家庭设备无主用户概念
      *
      * @param deviceId  设备ID
      * @param profileId 用户档案ID
@@ -255,27 +264,30 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (device == null) {
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
+        if (isFamilyDevice(device)) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "家庭设备没有主用户概念");
+        }
         // 先清除该设备下所有用户的主用户标记
         LambdaUpdateWrapper<UserDevice> clearWrapper = new LambdaUpdateWrapper<>();
         clearWrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
-                    .eq(UserDevice::getStatus, 1)
+                    .eq(UserDevice::getStatus, STATUS_ENABLED)
                     .set(UserDevice::getIsPrimary, 0);
         userDeviceMapper.update(null, clearWrapper);
         // 再将目标用户设为主用户
         LambdaUpdateWrapper<UserDevice> setWrapper = new LambdaUpdateWrapper<>();
         setWrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
                   .eq(UserDevice::getUserId, profileId)
-                  .eq(UserDevice::getStatus, 1)
+                  .eq(UserDevice::getStatus, STATUS_ENABLED)
                   .set(UserDevice::getIsPrimary, 1);
         userDeviceMapper.update(null, setWrapper);
         LogUtil.info(DeviceServiceImpl.class, "设备[{}]设置主用户[{}]", device.getDeviceCode(), profileId);
     }
 
     /**
-     * 批量解绑设备用户(通过绑定记录ID批量解绑
+     * 批量移除设备成员/解绑设备用户(通过关联记录ID批量处理
      *
      * @param deviceId 设备ID(校验用)
-     * @param bindIds  绑定记录ID列表(user_device.id)
+     * @param bindIds  关联记录ID列表(家庭设备为 device_group_member.id,公共设备为 user_device.id)
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
@@ -283,12 +295,21 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         if (CollectionUtils.isEmpty(bindIds)) {
             return;
         }
-        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
-        wrapper.in(UserDevice::getId, bindIds)
-               .set(UserDevice::getStatus, 0)
-               .set(UserDevice::getIsPrimary, 0);
-        userDeviceMapper.update(null, wrapper);
-        LogUtil.info(DeviceServiceImpl.class, "设备[{}]批量解绑用户,共[{}]条", deviceId, bindIds.size());
+        Device device = getById(deviceId);
+        if (device == null) {
+            throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
+        }
+        if (isFamilyDevice(device)) {
+            batchRemoveFamilyDeviceUsers(device, bindIds);
+        } else {
+            LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+            wrapper.in(UserDevice::getId, bindIds)
+                    .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                    .set(UserDevice::getStatus, 0)
+                    .set(UserDevice::getIsPrimary, 0);
+            userDeviceMapper.update(null, wrapper);
+        }
+        LogUtil.info(DeviceServiceImpl.class, "设备[{}]批量移除关联用户,共[{}]条", deviceId, bindIds.size());
     }
 
     /**
@@ -316,55 +337,62 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
 
     private Integer resolveDeviceType(Integer deviceType) {
         if (deviceType == null) {
-            return 1;
+            return DEVICE_TYPE_FAMILY;
         }
-        if (!Integer.valueOf(1).equals(deviceType) && !Integer.valueOf(2).equals(deviceType)) {
+        if (!Integer.valueOf(DEVICE_TYPE_FAMILY).equals(deviceType) && !Integer.valueOf(DEVICE_TYPE_PUBLIC).equals(deviceType)) {
             throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "设备类型不合法");
         }
         return deviceType;
     }
 
+    private Integer resolveDeviceType(Device device) {
+        return resolveDeviceType(device.getDeviceType());
+    }
+
+    private boolean isFamilyDevice(Device device) {
+        return Integer.valueOf(DEVICE_TYPE_FAMILY).equals(resolveDeviceType(device));
+    }
+
     /**
-     * 为设备批量绑定用户(新增时使用)
+     * 为设备批量关联用户(新增时使用)
      *
-     * @param deviceCode  设备编号
-     * @param deviceName  设备名称
-     * @param deviceModel 设备型号
-     * @param userIds     用户ID列表
+     * @param device  设备信息
+     * @param userIds 用户档案ID列表
      */
-    private void bindUsers(String deviceCode, String deviceName, String deviceModel, List<Long> userIds) {
+    private void bindUsers(Device device, List<Long> userIds) {
         if (CollectionUtils.isEmpty(userIds)) {
             return;
         }
         for (Long profileId : userIds) {
-            UserDevice ud = new UserDevice();
-            ud.setUserId(profileId);
-            ud.setDeviceCode(deviceCode);
-            ud.setDeviceName(deviceName);
-            ud.setDeviceModel(deviceModel);
-            ud.setStatus(1);
-            ud.setIsPrimary(0);
-            ud.setOnlineStatus(0);
-            ud.setBindTime(LocalDateTime.now());
-            userDeviceMapper.insert(ud);
+            if (isFamilyDevice(device)) {
+                addFamilyDeviceUser(device, profileId);
+            } else {
+                addPublicDeviceUser(device, profileId);
+            }
         }
     }
 
     /**
-     * 同步更新设备绑定用户(编辑时使用)
-     * 对比当前绑定用户和新列表,新增差集,解绑多余项
+     * 同步更新设备关联用户(编辑时使用)
+     * 家庭设备同步群组成员,公共设备同步 user_device 绑定关系。
      *
-     * @param deviceCode  设备编号
-     * @param deviceName  设备名称
-     * @param deviceModel 设备型号
-     * @param newUserIds  新的用户ID列表
+     * @param device     设备信息
+     * @param newUserIds 新的用户档案ID列表
      */
-    private void reconcileUserBindings(String deviceCode, String deviceName, String deviceModel, List<Long> newUserIds) {
+    private void reconcileDeviceUsers(Device device, List<Long> newUserIds) {
+        if (isFamilyDevice(device)) {
+            reconcileFamilyDeviceUsers(device, newUserIds);
+            return;
+        }
+        reconcilePublicDeviceUsers(device, newUserIds);
+    }
+
+    private void reconcilePublicDeviceUsers(Device device, List<Long> newUserIds) {
         // 查询当前已绑定的用户
         List<UserDevice> currentBindings = userDeviceMapper.selectList(
                 new LambdaQueryWrapper<UserDevice>()
-                        .eq(UserDevice::getDeviceCode, deviceCode)
-                        .eq(UserDevice::getStatus, 1)
+                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                        .eq(UserDevice::getStatus, STATUS_ENABLED)
         );
         Set<Long> currentIds = currentBindings.stream()
                 .map(UserDevice::getUserId)
@@ -383,20 +411,263 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
         // 新增不在当前绑定中的用户
         for (Long profileId : newUserIds) {
             if (!currentIds.contains(profileId)) {
-                UserDevice ud = new UserDevice();
-                ud.setUserId(profileId);
-                ud.setDeviceCode(deviceCode);
-                ud.setDeviceName(deviceName);
-                ud.setDeviceModel(deviceModel);
-                ud.setStatus(1);
-                ud.setIsPrimary(0);
-                ud.setOnlineStatus(0);
-                ud.setBindTime(LocalDateTime.now());
-                userDeviceMapper.insert(ud);
+                addPublicDeviceUser(device, profileId);
+            }
+        }
+    }
+
+    private void reconcileFamilyDeviceUsers(Device device, List<Long> newUserIds) {
+        DeviceGroup group = ensureDeviceGroup(device.getDeviceCode());
+        List<DeviceGroupMember> currentMembers = deviceGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+        );
+        Set<Long> currentIds = currentMembers.stream()
+                .map(DeviceGroupMember::getProfileId)
+                .collect(Collectors.toSet());
+        Set<Long> newIds = new HashSet<>(newUserIds);
+
+        for (DeviceGroupMember member : currentMembers) {
+            if (!newIds.contains(member.getProfileId())) {
+                removeFamilyDeviceUser(device, member.getProfileId());
+            }
+        }
+        for (Long profileId : newUserIds) {
+            if (!currentIds.contains(profileId)) {
+                addFamilyDeviceUser(device, profileId);
             }
         }
     }
 
+    private void addFamilyDeviceUser(Device device, Long profileId) {
+        UserProfile profile = requireProfile(profileId);
+        DeviceGroup group = ensureDeviceGroup(device.getDeviceCode());
+        DeviceGroupMember current = deviceGroupMemberMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getProfileId, profileId)
+                        .last("LIMIT 1")
+        );
+        if (current != null && Integer.valueOf(STATUS_ENABLED).equals(current.getStatus())) {
+            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        }
+        checkFamilyMemberUnique(group.getId(), profile, profileId);
+        if (current == null) {
+            DeviceGroupMember member = new DeviceGroupMember();
+            member.setGroupId(group.getId());
+            member.setProfileId(profileId);
+            member.setAppUserId(profile.getAppUserId());
+            member.setStatus(STATUS_ENABLED);
+            member.setJoinTime(LocalDateTime.now());
+            deviceGroupMemberMapper.insert(member);
+        } else {
+            LambdaUpdateWrapper<DeviceGroupMember> wrapper = new LambdaUpdateWrapper<>();
+            wrapper.eq(DeviceGroupMember::getId, current.getId())
+                    .set(DeviceGroupMember::getAppUserId, profile.getAppUserId())
+                    .set(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                    .set(DeviceGroupMember::getJoinTime, LocalDateTime.now());
+            deviceGroupMemberMapper.update(null, wrapper);
+        }
+        ensureUserDevice(device, profileId);
+    }
+
+    private void removeFamilyDeviceUser(Device device, Long profileId) {
+        DeviceGroup group = findDeviceGroup(device.getDeviceCode());
+        if (group != null) {
+            LambdaUpdateWrapper<DeviceGroupMember> memberWrapper = new LambdaUpdateWrapper<>();
+            memberWrapper.eq(DeviceGroupMember::getGroupId, group.getId())
+                    .eq(DeviceGroupMember::getProfileId, profileId)
+                    .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                    .set(DeviceGroupMember::getStatus, 0);
+            deviceGroupMemberMapper.update(null, memberWrapper);
+        }
+        removePublicDeviceUser(device, profileId);
+    }
+
+    private void batchRemoveFamilyDeviceUsers(Device device, List<Long> memberIds) {
+        DeviceGroup group = findDeviceGroup(device.getDeviceCode());
+        if (group == null) {
+            return;
+        }
+        List<DeviceGroupMember> members = deviceGroupMemberMapper.selectList(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .in(DeviceGroupMember::getId, memberIds)
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+        );
+        LambdaUpdateWrapper<DeviceGroupMember> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.in(DeviceGroupMember::getId, memberIds)
+                .eq(DeviceGroupMember::getGroupId, group.getId())
+                .set(DeviceGroupMember::getStatus, 0);
+        deviceGroupMemberMapper.update(null, wrapper);
+        for (DeviceGroupMember member : members) {
+            removePublicDeviceUser(device, member.getProfileId());
+        }
+    }
+
+    private void addPublicDeviceUser(Device device, Long profileId) {
+        requireProfile(profileId);
+        UserDevice current = userDeviceMapper.selectOne(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                        .eq(UserDevice::getUserId, profileId)
+                        .last("LIMIT 1")
+        );
+        if (current != null && Integer.valueOf(STATUS_ENABLED).equals(current.getStatus())) {
+            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        }
+        if (current == null) {
+            UserDevice ud = new UserDevice();
+            ud.setUserId(profileId);
+            ud.setDeviceCode(device.getDeviceCode());
+            ud.setDeviceName(device.getDeviceName());
+            ud.setDeviceModel(device.getDeviceModel());
+            ud.setStatus(STATUS_ENABLED);
+            ud.setIsPrimary(0);
+            ud.setOnlineStatus(0);
+            ud.setBindTime(LocalDateTime.now());
+            userDeviceMapper.insert(ud);
+            return;
+        }
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getId, current.getId())
+                .set(UserDevice::getDeviceName, device.getDeviceName())
+                .set(UserDevice::getDeviceModel, device.getDeviceModel())
+                .set(UserDevice::getStatus, STATUS_ENABLED)
+                .set(UserDevice::getIsPrimary, 0)
+                .set(UserDevice::getBindTime, LocalDateTime.now());
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private void ensureUserDevice(Device device, Long profileId) {
+        UserDevice current = userDeviceMapper.selectOne(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                        .eq(UserDevice::getUserId, profileId)
+                        .last("LIMIT 1")
+        );
+        if (current == null) {
+            UserDevice ud = new UserDevice();
+            ud.setUserId(profileId);
+            ud.setDeviceCode(device.getDeviceCode());
+            ud.setDeviceName(device.getDeviceName());
+            ud.setDeviceModel(device.getDeviceModel());
+            ud.setStatus(STATUS_ENABLED);
+            ud.setIsPrimary(0);
+            ud.setOnlineStatus(0);
+            ud.setBindTime(LocalDateTime.now());
+            userDeviceMapper.insert(ud);
+            return;
+        }
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getId, current.getId())
+                .set(UserDevice::getDeviceName, device.getDeviceName())
+                .set(UserDevice::getDeviceModel, device.getDeviceModel())
+                .set(UserDevice::getStatus, STATUS_ENABLED);
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private void removePublicDeviceUser(Device device, Long profileId) {
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                .eq(UserDevice::getUserId, profileId)
+                .eq(UserDevice::getStatus, STATUS_ENABLED)
+                .set(UserDevice::getStatus, 0)
+                .set(UserDevice::getIsPrimary, 0);
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private void syncUserDeviceInfo(Device device) {
+        LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                .eq(UserDevice::getStatus, STATUS_ENABLED)
+                .set(UserDevice::getDeviceName, device.getDeviceName())
+                .set(UserDevice::getDeviceModel, device.getDeviceModel());
+        userDeviceMapper.update(null, wrapper);
+    }
+
+    private UserProfile requireProfile(Long profileId) {
+        UserProfile profile = userProfileMapper.selectById(profileId);
+        if (profile == null) {
+            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
+        }
+        return profile;
+    }
+
+    private DeviceGroup ensureDeviceGroup(String deviceCode) {
+        DeviceGroup group = findDeviceGroup(deviceCode);
+        if (group != null) {
+            return group;
+        }
+        group = new DeviceGroup();
+        group.setDeviceCode(deviceCode);
+        group.setStatus(STATUS_ENABLED);
+        deviceGroupMapper.insert(group);
+        return group;
+    }
+
+    private DeviceGroup findDeviceGroup(String deviceCode) {
+        return deviceGroupMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroup>()
+                        .eq(DeviceGroup::getDeviceCode, deviceCode)
+                        .eq(DeviceGroup::getStatus, STATUS_ENABLED)
+                        .last("LIMIT 1")
+        );
+    }
+
+    private void checkFamilyMemberUnique(Long groupId, UserProfile profile, Long excludeProfileId) {
+        if (StringUtils.hasText(profile.getPhone())) {
+            List<DeviceGroupMember> members = deviceGroupMemberMapper.selectList(
+                    new LambdaQueryWrapper<DeviceGroupMember>()
+                            .eq(DeviceGroupMember::getGroupId, groupId)
+                            .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+            );
+            for (DeviceGroupMember member : members) {
+                if (member.getProfileId().equals(excludeProfileId)) {
+                    continue;
+                }
+                UserProfile memberProfile = userProfileMapper.selectById(member.getProfileId());
+                if (memberProfile != null && profile.getPhone().equals(memberProfile.getPhone())) {
+                    throw new BusinessException(ResultCode.PHONE_PROFILE_EXISTS.getCode(), "同一家庭群组内手机号不能重复");
+                }
+            }
+        }
+        if (profile.getAppUserId() != null) {
+            long count = deviceGroupMemberMapper.selectCount(
+                    new LambdaQueryWrapper<DeviceGroupMember>()
+                            .eq(DeviceGroupMember::getGroupId, groupId)
+                            .eq(DeviceGroupMember::getAppUserId, profile.getAppUserId())
+                            .ne(DeviceGroupMember::getProfileId, excludeProfileId)
+                            .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+            );
+            if (count > 0) {
+                throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND.getCode(), "该账号已在家庭群组中");
+            }
+        }
+    }
+
+    private boolean hasActiveDeviceUsers(String deviceCode) {
+        long publicCount = userDeviceMapper.selectCount(
+                new LambdaQueryWrapper<UserDevice>()
+                        .eq(UserDevice::getDeviceCode, deviceCode)
+                        .eq(UserDevice::getStatus, STATUS_ENABLED)
+        );
+        if (publicCount > 0) {
+            return true;
+        }
+        DeviceGroup group = findDeviceGroup(deviceCode);
+        if (group == null) {
+            return false;
+        }
+        long memberCount = deviceGroupMemberMapper.selectCount(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+        );
+        return memberCount > 0;
+    }
+
     /**
      * 解绑指定设备的所有用户
      *
@@ -405,9 +676,18 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, Device> impleme
     private void unbindAllUsers(String deviceCode) {
         LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
         wrapper.eq(UserDevice::getDeviceCode, deviceCode)
-               .eq(UserDevice::getStatus, 1)
+               .eq(UserDevice::getStatus, STATUS_ENABLED)
                .set(UserDevice::getStatus, 0)
                .set(UserDevice::getIsPrimary, 0);
         userDeviceMapper.update(null, wrapper);
+
+        DeviceGroup group = findDeviceGroup(deviceCode);
+        if (group != null) {
+            LambdaUpdateWrapper<DeviceGroupMember> memberWrapper = new LambdaUpdateWrapper<>();
+            memberWrapper.eq(DeviceGroupMember::getGroupId, group.getId())
+                    .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                    .set(DeviceGroupMember::getStatus, 0);
+            deviceGroupMemberMapper.update(null, memberWrapper);
+        }
     }
 }

+ 389 - 13
code/backend/src/main/java/com/aijiuyi/admin/service/impl/PlanServiceImpl.java

@@ -4,16 +4,24 @@ import com.aijiuyi.admin.common.constant.ResultCode;
 import com.aijiuyi.admin.common.context.RequestContext;
 import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.AppPlanVO;
 import com.aijiuyi.admin.controller.dto.PlanQueryDTO;
 import com.aijiuyi.admin.controller.dto.PlanSaveDTO;
+import com.aijiuyi.admin.entity.Acupoint;
 import com.aijiuyi.admin.entity.AppUser;
 import com.aijiuyi.admin.entity.Plan;
 import com.aijiuyi.admin.entity.PlanStep;
+import com.aijiuyi.admin.entity.UserPlan;
+import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.mapper.AcupointMapper;
 import com.aijiuyi.admin.mapper.PlanMapper;
 import com.aijiuyi.admin.mapper.PlanStepMapper;
+import com.aijiuyi.admin.mapper.UserPlanMapper;
+import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.AppUserService;
 import com.aijiuyi.admin.service.PlanService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -43,9 +51,18 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     @Autowired
     private PlanStepMapper planStepMapper;
 
+    @Autowired
+    private AcupointMapper acupointMapper;
+
+    @Autowired
+    private UserPlanMapper userPlanMapper;
+
     @Autowired
     private AppUserService appUserService;
 
+    @Autowired
+    private UserProfileMapper userProfileMapper;
+
     /**
      * 分页查询方案列表
      *
@@ -97,13 +114,14 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     }
 
     /**
-     * 新增方案(含步骤),状态默认为草稿
+     * 新增方案(含步骤),支持保存草稿或直接发布
      *
      * @param dto 方案信息
+     * @return 新增方案ID
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void addPlan(PlanSaveDTO dto) {
+    public Long addPlan(PlanSaveDTO dto) {
         // 校验步骤不为空
         if (dto.getSteps() == null || dto.getSteps().isEmpty()) {
             throw new BusinessException(ResultCode.PLAN_STEP_MIN_ERROR);
@@ -120,12 +138,13 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         applyAddPlanModeRules(plan);
         fillCurrentAuthor(plan);
         plan.setPlanCode(planCode);
-        plan.setStatus(0); // 草稿
+        plan.setStatus(resolveAddPlanStatus(dto.getStatus()));
         plan.setUseCount(0);
         save(plan);
         // 保存步骤
         saveSteps(plan.getId(), dto.getSteps());
         LogUtil.info(PlanServiceImpl.class, "新增方案[{}],编码[{}]", plan.getName(), planCode);
+        return plan.getId();
     }
 
     /**
@@ -184,14 +203,14 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         if (StringUtils.hasText(currentUser.getNickname())) {
             return currentUser.getNickname();
         }
-        if (StringUtils.hasText(currentUser.getUsername())) {
-            return currentUser.getUsername();
+        if (StringUtils.hasText(currentUser.getPhone())) {
+            return currentUser.getPhone();
         }
         return "ADMIN";
     }
 
     private Integer resolvePlanAuthorType(AppUser currentUser) {
-        Integer userType = currentUser != null ? currentUser.getUserType() : RequestContext.getUserType();
+        Integer userType = RequestContext.getUserType();
         if (Integer.valueOf(1).equals(userType)) {
             return 2;
         }
@@ -202,13 +221,22 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     }
 
     private void applyAddPlanModeRules(Plan plan) {
-        if (!Integer.valueOf(MODE_TYPE_CUSTOM).equals(plan.getModeType())
-                && !Integer.valueOf(MODE_TYPE_MASTER).equals(plan.getModeType())) {
+        if (!isEditableModeType(plan.getModeType())) {
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
         plan.setEffectType("");
     }
 
+    private int resolveAddPlanStatus(Integer status) {
+        if (status == null || Integer.valueOf(0).equals(status)) {
+            return 0;
+        }
+        if (Integer.valueOf(1).equals(status)) {
+            return 1;
+        }
+        throw new BusinessException(ResultCode.PARAM_ERROR);
+    }
+
     private void applyUpdatePlanModeRules(Plan plan, String originalPlanCode, String originalName, Integer originalStatus, boolean systemPresetPlan) {
         if (systemPresetPlan) {
             plan.setPlanCode(originalPlanCode);
@@ -223,9 +251,15 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
             plan.setEffectType(getProfessionalPresetEffectType(originalPlanCode));
             return;
         }
-        if (!Integer.valueOf(MODE_TYPE_PROFESSIONAL).equals(plan.getModeType())) {
-            plan.setEffectType("");
+        if (!isEditableModeType(plan.getModeType())) {
+            throw new BusinessException(ResultCode.PARAM_ERROR);
         }
+        plan.setEffectType("");
+    }
+
+    private boolean isEditableModeType(Integer modeType) {
+        return Integer.valueOf(MODE_TYPE_CUSTOM).equals(modeType)
+                || Integer.valueOf(MODE_TYPE_MASTER).equals(modeType);
     }
 
     private boolean isSystemPresetPlan(Plan plan) {
@@ -267,7 +301,7 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     }
 
     /**
-     * 删除方案(逻辑删除,草稿状态可删)
+     * 删除方案(逻辑删除,草稿或已下架状态可删)
      *
      * @param id 方案ID
      */
@@ -279,17 +313,18 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
             throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
         }
         checkSystemPresetPlanOperation(plan);
-        if (!Integer.valueOf(0).equals(plan.getStatus())) {
+        if (!Integer.valueOf(0).equals(plan.getStatus()) && !Integer.valueOf(2).equals(plan.getStatus())) {
             throw new BusinessException(ResultCode.PLAN_CANNOT_DELETE);
         }
         removeById(id);
         // 同步删除步骤
         planStepMapper.delete(new LambdaQueryWrapper<PlanStep>().eq(PlanStep::getPlanId, id));
+        hideMasterUserPlans(plan);
         LogUtil.info(PlanServiceImpl.class, "删除方案[{}]", id);
     }
 
     /**
-     * 批量删除方案(草稿状态可删)
+     * 批量删除方案(草稿或已下架状态可删)
      *
      * @param ids 方案ID列表
      */
@@ -386,6 +421,28 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         );
     }
 
+    /**
+     * 查询数据库中有效(未删除)方案已有的创作人列表(去重,仅返回非空值,按名称升序)
+     *
+     * @return 创作人名称列表
+     */
+    @Override
+    public List<String> listAuthors() {
+        return lambdaQuery()
+                .eq(Plan::getDeleted, 0)
+                .isNotNull(Plan::getAuthorName)
+                .ne(Plan::getAuthorName, "")
+                .select(Plan::getAuthorName)
+                .groupBy(Plan::getAuthorName)
+                .orderByAsc(Plan::getAuthorName)
+                .list()
+                .stream()
+                .map(Plan::getAuthorName)
+                .filter(name -> StringUtils.hasText(name))
+                .distinct()
+                .collect(java.util.stream.Collectors.toList());
+    }
+
     /**
      * 保存方案步骤(设置 planId 和步骤序号后批量插入)
      *
@@ -395,10 +452,329 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     private void saveSteps(Long planId, List<PlanStep> steps) {
         for (int i = 0; i < steps.size(); i++) {
             PlanStep step = steps.get(i);
+            Acupoint acupoint = getActiveAcupoint(step.getAcupointId());
             step.setId(null);
             step.setPlanId(planId);
             step.setStepOrder(i + 1);
+            step.setAcupointId(acupoint.getId());
+            step.setAcupointName(acupoint.getName());
+            // 优先使用用户选择的侧别,并根据穴位单/双属性校验合法性
+            step.setSide(resolveStepSide(step.getSide(), acupoint));
             planStepMapper.insert(step);
         }
     }
+
+    /**
+     * 解析并校验步骤侧别
+     * - 单穴(singleDouble 包含'单')只允许侧别 1(中心)
+     * - 双穴(singleDouble 包含'双')不允许侧别 1,仅允许 2/3/4
+     *
+     * @param requestSide 前端传入的侧别
+     * @param acupoint    穴位信息
+     * @return 合法的侧别值
+     */
+    private Integer resolveStepSide(Integer requestSide, Acupoint acupoint) {
+        String singleDouble = acupoint != null ? acupoint.getSingleDouble() : null;
+        boolean isSingle = singleDouble != null && singleDouble.contains("单");
+        boolean isDouble = singleDouble != null && singleDouble.contains("双");
+
+        if (requestSide != null) {
+            if (isSingle && !Integer.valueOf(1).equals(requestSide)) {
+                throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "单穴只能选择中心");
+            }
+            if (isDouble && Integer.valueOf(1).equals(requestSide)) {
+                throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "双穴不能选择中心");
+            }
+            if (isSingle || isDouble) {
+                return requestSide;
+            }
+        }
+
+        // 无传入或无法识别单双属性时,使用穴位表侧别兜底
+        if (acupoint != null && acupoint.getSide() != null) {
+            return acupoint.getSide();
+        }
+        return 1;
+    }
+
+    private Acupoint getActiveAcupoint(Long acupointId) {
+        if (acupointId == null) {
+            throw new BusinessException(ResultCode.ACUPOINT_NOT_FOUND);
+        }
+        Acupoint acupoint = acupointMapper.selectById(acupointId);
+        if (acupoint == null || !Integer.valueOf(1).equals(acupoint.getStatus())) {
+            throw new BusinessException(ResultCode.ACUPOINT_NOT_FOUND);
+        }
+        return acupoint;
+    }
+
+    private void hideMasterUserPlans(Plan plan) {
+        if (plan == null || !Integer.valueOf(MODE_TYPE_MASTER).equals(plan.getModeType())) {
+            return;
+        }
+        userPlanMapper.update(null, new LambdaUpdateWrapper<UserPlan>()
+                .eq(UserPlan::getPlanId, plan.getId())
+                .eq(UserPlan::getModeType, MODE_TYPE_MASTER)
+                .set(UserPlan::getDeleted, 1)
+                .set(UserPlan::getUpdateTime, LocalDateTime.now()));
+    }
+
+    /**
+     * 用户新增自定义方案(含步骤),author_id 取子用户ID(user_profile.id)
+     *
+     * @param dto       方案信息(需包含 profileId 指定归属子用户)
+     * @param appUserId APP账号ID(app_user.id,用于校验子用户归属)
+     * @return 方案ID
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long createUserPlan(PlanSaveDTO dto, Long appUserId) {
+        if (dto.getSteps() == null || dto.getSteps().isEmpty()) {
+            throw new BusinessException(ResultCode.PLAN_STEP_MIN_ERROR);
+        }
+        // 校验子用户档案ID必传且归属当前APP账号
+        Long profileId = dto.getProfileId();
+        if (profileId == null) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "子用户档案ID不能为空");
+        }
+        UserProfile profile = userProfileMapper.selectById(profileId);
+        if (profile == null || !appUserId.equals(profile.getAppUserId())) {
+            throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "子用户档案不存在或不属于当前账号");
+        }
+        // 方案名称仅在当前子用户范围内做唯一性校验:不同子用户可以起相同的方案名
+        if (StringUtils.hasText(dto.getName())) {
+            long nameCount = lambdaQuery()
+                    .eq(Plan::getName, dto.getName())
+                    .eq(Plan::getAuthorId, profileId)
+                    .count();
+            if (nameCount > 0) {
+                throw new BusinessException(ResultCode.PLAN_NAME_EXISTS);
+            }
+        }
+        String planCode = "S" + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
+        Plan plan = new Plan();
+        BeanUtils.copyProperties(dto, plan);
+        // 强制设置为自定义模式,创作人为当前子用户
+        plan.setModeType(MODE_TYPE_CUSTOM);
+        plan.setPlanCode(planCode);
+        plan.setStatus(dto.getStatus() != null ? dto.getStatus() : 1);
+        plan.setUseCount(0);
+        // 创作人信息取子用户档案(id、name、type)
+        plan.setAuthorId(profileId);
+        plan.setAuthorName(profile.getName());
+        plan.setAuthorType(2);
+        save(plan);
+        saveSteps(plan.getId(), dto.getSteps());
+        LogUtil.info(PlanServiceImpl.class, "子用户[{}]新增自定义方案[{}],编码[{}]", profileId, plan.getName(), planCode);
+        return plan.getId();
+    }
+
+    /**
+     * 查询APP账号下所有子用户的自定义模式方案列表(modeType=3,authorType=2)
+     * 仅返回方案基本信息,不含穴位步骤;如需步骤请调用 getUserPlanDetail
+     *
+     * @param appUserId APP账号ID(app_user.id)
+     * @return 方案列表
+     */
+    @Override
+    public List<Plan> listUserCustomPlans(Long appUserId) {
+        List<Long> profileIds = getProfileIdsByAppUserId(appUserId);
+        if (profileIds.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+        return lambdaQuery()
+                .eq(Plan::getModeType, MODE_TYPE_CUSTOM)
+                .eq(Plan::getAuthorType, 2)
+                .in(Plan::getAuthorId, profileIds)
+                .orderByDesc(Plan::getUpdateTime)
+                .list();
+    }
+
+    /**
+     * 查询用户自定义方案详情(含穴位步骤),校验方案归属于当前APP账号的子用户
+     *
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
+     * @return 方案详情
+     */
+    @Override
+    public AppPlanVO getUserPlanDetail(Long planId, Long appUserId) {
+        Plan plan = getById(planId);
+        if (plan == null) {
+            throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
+        }
+        if (!Integer.valueOf(MODE_TYPE_CUSTOM).equals(plan.getModeType())
+                || !Integer.valueOf(2).equals(plan.getAuthorType())
+                || !isPlanOwnedByAppUser(plan, appUserId)) {
+            throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
+        }
+        AppPlanVO vo = new AppPlanVO();
+        BeanUtils.copyProperties(plan, vo);
+        vo.setSteps(getStepsByPlanId(planId));
+        return vo;
+    }
+
+    /**
+     * 用户修改自己的自定义方案(含步骤),校验方案归属于当前APP账号的子用户
+     *
+     * @param dto       方案信息
+     * @param appUserId APP账号ID(app_user.id)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateUserPlan(PlanSaveDTO dto, Long appUserId) {
+        Plan plan = getById(dto.getId());
+        if (plan == null) {
+            throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
+        }
+        // 仅允许修改属于当前APP账号子用户的自定义模式方案
+        if (!Integer.valueOf(MODE_TYPE_CUSTOM).equals(plan.getModeType())
+                || !Integer.valueOf(2).equals(plan.getAuthorType())
+                || !isPlanOwnedByAppUser(plan, appUserId)) {
+            throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
+        }
+        // 更新方案基本信息(仅允许修改的可编辑字段)
+        if (StringUtils.hasText(dto.getName())) {
+            plan.setName(dto.getName());
+        }
+        plan.setSymptoms(dto.getSymptoms());
+        plan.setApplicableGender(dto.getApplicableGender());
+        plan.setAgeMin(dto.getAgeMin());
+        plan.setAgeMax(dto.getAgeMax());
+        plan.setDescription(dto.getDescription());
+        plan.setStatus(dto.getStatus() != null ? dto.getStatus() : plan.getStatus());
+        updateById(plan);
+        // 更新步骤(先删后增)
+        if (dto.getSteps() != null) {
+            planStepMapper.delete(new LambdaQueryWrapper<PlanStep>().eq(PlanStep::getPlanId, dto.getId()));
+            if (!dto.getSteps().isEmpty()) {
+                saveSteps(dto.getId(), dto.getSteps());
+            }
+        }
+        LogUtil.info(PlanServiceImpl.class, "APP账号[{}]修改自定义方案[{}]", appUserId, dto.getId());
+    }
+
+    /**
+     * 用户删除自己的自定义方案,校验方案归属于当前APP账号的子用户
+     *
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteUserPlan(Long planId, Long appUserId) {
+        Plan plan = getById(planId);
+        if (plan == null) {
+            throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
+        }
+        if (!Integer.valueOf(MODE_TYPE_CUSTOM).equals(plan.getModeType())
+                || !Integer.valueOf(2).equals(plan.getAuthorType())
+                || !isPlanOwnedByAppUser(plan, appUserId)) {
+            throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
+        }
+        removeById(planId);
+        planStepMapper.delete(new LambdaQueryWrapper<PlanStep>().eq(PlanStep::getPlanId, planId));
+        LogUtil.info(PlanServiceImpl.class, "APP账号[{}]删除自定义方案[{}]", appUserId, planId);
+    }
+
+    /**
+     * 查询APP账号下所有子用户档案ID
+     *
+     * @param appUserId APP账号ID
+     * @return 子用户档案ID列表
+     */
+    private List<Long> getProfileIdsByAppUserId(Long appUserId) {
+        List<UserProfile> profiles = userProfileMapper.selectList(
+                new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getAppUserId, appUserId)
+        );
+        return profiles.stream().map(UserProfile::getId).collect(java.util.stream.Collectors.toList());
+    }
+
+    /**
+     * 校验方案是否归属于当前APP账号的子用户
+     *
+     * @param plan     方案实体
+     * @param appUserId APP账号ID
+     * @return true=归属当前账号
+     */
+    private boolean isPlanOwnedByAppUser(Plan plan, Long appUserId) {
+        if (plan.getAuthorId() == null) {
+            return false;
+        }
+        List<Long> profileIds = getProfileIdsByAppUserId(appUserId);
+        return profileIds.contains(plan.getAuthorId());
+    }
+
+    /**
+     * 查询专业模式方案列表(modeType=2,已发布状态)
+     * 所有用户共享,不区分用户,按 effectType 升序保证驱寒/祛湿等顺序稳定
+     *
+     * @return 专业模式方案列表
+     */
+    @Override
+    public List<Plan> listProfessionalPlans() {
+        return lambdaQuery()
+                .eq(Plan::getModeType, MODE_TYPE_PROFESSIONAL)
+                .eq(Plan::getStatus, 1)
+                .orderByAsc(Plan::getEffectType)
+                .orderByAsc(Plan::getId)
+                .list();
+    }
+
+    /**
+     * 查询专业模式方案详情(含穴位步骤),校验 modeType=2 且状态为已发布
+     *
+     * @param planId 方案ID
+     * @return 专业模式方案详情
+     */
+    @Override
+    public AppPlanVO getProfessionalPlanDetail(Long planId) {
+        return getSystemPlanDetail(planId, MODE_TYPE_PROFESSIONAL);
+    }
+
+    /**
+     * 查询延年圣手模式方案列表(modeType=4,已发布状态)
+     * 所有用户共享,按 ID 升序保证顺序稳定
+     *
+     * @return 延年圣手模式方案列表
+     */
+    @Override
+    public List<Plan> listMasterPlans() {
+        return lambdaQuery()
+                .eq(Plan::getModeType, MODE_TYPE_MASTER)
+                .eq(Plan::getStatus, 1)
+                .orderByAsc(Plan::getId)
+                .list();
+    }
+
+    /**
+     * 查询延年圣手模式方案详情(含穴位步骤),校验 modeType=4 且状态为已发布
+     *
+     * @param planId 方案ID
+     * @return 延年圣手模式方案详情
+     */
+    @Override
+    public AppPlanVO getMasterPlanDetail(Long planId) {
+        return getSystemPlanDetail(planId, MODE_TYPE_MASTER);
+    }
+
+    /**
+     * 通用系统方案详情查询:校验 modeType 与已发布状态,避免 APP 通过其他端点越权访问
+     *
+     * @param planId   方案ID
+     * @param modeType 期望的模式类型(专业模式/延年圣手)
+     * @return 方案详情
+     */
+    private AppPlanVO getSystemPlanDetail(Long planId, int modeType) {
+        Plan plan = getById(planId);
+        if (plan == null
+                || !Integer.valueOf(modeType).equals(plan.getModeType())
+                || !Integer.valueOf(1).equals(plan.getStatus())) {
+            throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
+        }
+        AppPlanVO vo = new AppPlanVO();
+        BeanUtils.copyProperties(plan, vo);
+        vo.setSteps(getStepsByPlanId(planId));
+        return vo;
+    }
 }

+ 7 - 8
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserCategoryServiceImpl.java

@@ -4,10 +4,10 @@ import com.aijiuyi.admin.common.constant.ResultCode;
 import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.UserCategoryQueryDTO;
-import com.aijiuyi.admin.entity.AppUser;
 import com.aijiuyi.admin.entity.UserCategory;
-import com.aijiuyi.admin.mapper.AppUserMapper;
+import com.aijiuyi.admin.entity.UserProfile;
 import com.aijiuyi.admin.mapper.UserCategoryMapper;
+import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.UserCategoryService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -30,7 +30,7 @@ public class UserCategoryServiceImpl extends ServiceImpl<UserCategoryMapper, Use
     private static final int NORMAL_USER_TYPE = 1;
 
     @Autowired
-    private AppUserMapper appUserMapper;
+    private UserProfileMapper userProfileMapper;
 
     @Override
     public IPage<UserCategory> pageList(UserCategoryQueryDTO queryDTO) {
@@ -115,11 +115,10 @@ public class UserCategoryServiceImpl extends ServiceImpl<UserCategoryMapper, Use
         if (exist == null) {
             throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
         }
-        long appUserCount = appUserMapper.selectCount(
-                new LambdaQueryWrapper<AppUser>()
-                        .eq(AppUser::getUserType, NORMAL_USER_TYPE)
-                        .eq(AppUser::getUserCategoryId, id));
-        if (appUserCount > 0) {
+        long profileCount = userProfileMapper.selectCount(
+                new LambdaQueryWrapper<UserProfile>()
+                        .eq(UserProfile::getUserCategoryId, id));
+        if (profileCount > 0) {
             throw new BusinessException(ResultCode.USER_CATEGORY_IN_USE);
         }
         removeById(id);

+ 216 - 16
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserDeviceServiceImpl.java

@@ -4,9 +4,15 @@ import com.aijiuyi.admin.common.constant.ResultCode;
 import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.UserDeviceQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
 import com.aijiuyi.admin.entity.Device;
+import com.aijiuyi.admin.entity.DeviceGroup;
+import com.aijiuyi.admin.entity.DeviceGroupMember;
 import com.aijiuyi.admin.entity.UserDevice;
 import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.mapper.AppUserMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMapper;
+import com.aijiuyi.admin.mapper.DeviceGroupMemberMapper;
 import com.aijiuyi.admin.mapper.DeviceMapper;
 import com.aijiuyi.admin.mapper.UserDeviceMapper;
 import com.aijiuyi.admin.mapper.UserProfileMapper;
@@ -30,9 +36,22 @@ import java.util.List;
 @Service
 public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDevice> implements UserDeviceService {
 
+    private static final int DEVICE_TYPE_FAMILY = 1;
+    private static final int DEVICE_TYPE_PUBLIC = 2;
+    private static final int STATUS_ENABLED = 1;
+
+    @Autowired
+    private AppUserMapper appUserMapper;
+
     @Autowired
     private DeviceMapper deviceMapper;
 
+    @Autowired
+    private DeviceGroupMapper deviceGroupMapper;
+
+    @Autowired
+    private DeviceGroupMemberMapper deviceGroupMemberMapper;
+
     @Autowired
     private UserProfileMapper userProfileMapper;
 
@@ -81,21 +100,15 @@ public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDev
     /**
      * App 端按设备编号绑定设备。
      *
-     * @param userId     用户档案ID
      * @param deviceCode 设备编号
      * @param appUserId  当前登录 App 用户ID
      * @return 新增的绑定记录
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public UserDevice bindAppDeviceByCode(Long userId, String deviceCode, Long appUserId) {
-        UserProfile profile = userProfileMapper.selectById(userId);
-        if (profile == null) {
-            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
-        }
-        if (appUserId != null && !appUserId.equals(profile.getUserId())) {
-            throw new BusinessException(ResultCode.FORBIDDEN);
-        }
+    public UserDevice bindAppDeviceByCode(String deviceCode, Long appUserId) {
+        AppUser appUser = requireAppUser(appUserId);
+        UserProfile profile = ensureAccountProfile(appUser);
 
         String normalizedDeviceCode = deviceCode.trim();
         Device device = deviceMapper.selectOne(new LambdaQueryWrapper<Device>()
@@ -105,31 +118,218 @@ public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDev
             throw new BusinessException(ResultCode.DEVICE_NOT_FOUND);
         }
 
+        // 根据设备类型走不同绑定路径
+        if (isFamilyDevice(device)) {
+            bindFamilyDevice(device, profile, appUserId);
+        } else {
+            bindPublicDevice(device, profile);
+        }
+
+        // 确保 user_device 记录存在(两种类型都需要)
+        UserDevice userDevice = ensureUserDevice(profile, device);
+        // 补充设备主键ID,方便前端一次拿到 device.id,无需再查设备详情
+        userDevice.setDeviceId(device.getId());
+
+        LogUtil.info(UserDeviceServiceImpl.class,
+                "App用户[{}]为账号档案[{}]绑定设备[{}](类型:{})",
+                appUserId, profile.getId(), device.getDeviceCode(),
+                isFamilyDevice(device) ? "家庭" : "公共");
+        return userDevice;
+    }
+
+    /**
+     * 绑定家庭设备:创建/查找 DeviceGroup + DeviceGroupMember
+     */
+    private void bindFamilyDevice(Device device, UserProfile profile, Long appUserId) {
+        DeviceGroup group = ensureDeviceGroup(device.getDeviceCode());
+
+        // 检查是否已是该群组成员
+        DeviceGroupMember existingMember = deviceGroupMemberMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroupMember>()
+                        .eq(DeviceGroupMember::getGroupId, group.getId())
+                        .eq(DeviceGroupMember::getProfileId, profile.getId())
+                        .last("LIMIT 1")
+        );
+        if (existingMember != null && Integer.valueOf(STATUS_ENABLED).equals(existingMember.getStatus())) {
+            throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+        }
+
+        // 检查同一 appUserId 是否已在群组中(避免重复)
+        if (appUserId != null) {
+            DeviceGroupMember appUserMember = deviceGroupMemberMapper.selectOne(
+                    new LambdaQueryWrapper<DeviceGroupMember>()
+                            .eq(DeviceGroupMember::getGroupId, group.getId())
+                            .eq(DeviceGroupMember::getAppUserId, appUserId)
+                            .eq(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                            .last("LIMIT 1")
+            );
+            if (appUserMember != null) {
+                throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
+            }
+        }
+
+        if (existingMember == null) {
+            DeviceGroupMember member = new DeviceGroupMember();
+            member.setGroupId(group.getId());
+            member.setProfileId(profile.getId());
+            member.setAppUserId(appUserId);
+            member.setStatus(STATUS_ENABLED);
+            member.setJoinTime(LocalDateTime.now());
+            deviceGroupMemberMapper.insert(member);
+        } else {
+            // 重新激活已禁用的成员记录
+            LambdaUpdateWrapper<DeviceGroupMember> wrapper = new LambdaUpdateWrapper<>();
+            wrapper.eq(DeviceGroupMember::getId, existingMember.getId())
+                    .set(DeviceGroupMember::getAppUserId, appUserId)
+                    .set(DeviceGroupMember::getStatus, STATUS_ENABLED)
+                    .set(DeviceGroupMember::getJoinTime, LocalDateTime.now());
+            deviceGroupMemberMapper.update(null, wrapper);
+        }
+    }
+
+    /**
+     * 绑定公共设备:仅校验 user_device 是否已绑定
+     */
+    private void bindPublicDevice(Device device, UserProfile profile) {
         long count = lambdaQuery()
-                .eq(UserDevice::getUserId, userId)
-                .eq(UserDevice::getDeviceCode, normalizedDeviceCode)
+                .eq(UserDevice::getUserId, profile.getId())
+                .eq(UserDevice::getDeviceCode, device.getDeviceCode())
                 .eq(UserDevice::getStatus, 1)
                 .count();
         if (count > 0) {
             throw new BusinessException(ResultCode.DEVICE_USER_ALREADY_BOUND);
         }
+    }
+
+    /**
+     * 确保 user_device 记录存在并处于启用状态
+     */
+    private UserDevice ensureUserDevice(UserProfile profile, Device device) {
+        UserDevice existing = lambdaQuery()
+                .eq(UserDevice::getUserId, profile.getId())
+                .eq(UserDevice::getDeviceCode, device.getDeviceCode())
+                .last("LIMIT 1")
+                .one();
+        if (existing != null) {
+            if (!Integer.valueOf(STATUS_ENABLED).equals(existing.getStatus())) {
+                LambdaUpdateWrapper<UserDevice> wrapper = new LambdaUpdateWrapper<>();
+                wrapper.eq(UserDevice::getId, existing.getId())
+                        .set(UserDevice::getStatus, STATUS_ENABLED)
+                        .set(UserDevice::getDeviceName, device.getDeviceName())
+                        .set(UserDevice::getDeviceModel, device.getDeviceModel())
+                        .set(UserDevice::getOnlineStatus, device.getOnlineStatus() != null ? device.getOnlineStatus() : 0)
+                        .set(UserDevice::getBindTime, LocalDateTime.now());
+                update(wrapper);
+                existing.setStatus(STATUS_ENABLED);
+            }
+            return existing;
+        }
 
         UserDevice userDevice = new UserDevice();
-        userDevice.setUserId(userId);
+        userDevice.setUserId(profile.getId());
         userDevice.setDeviceCode(device.getDeviceCode());
         userDevice.setDeviceName(device.getDeviceName());
         userDevice.setDeviceModel(device.getDeviceModel());
-        userDevice.setStatus(1);
+        userDevice.setStatus(STATUS_ENABLED);
         userDevice.setIsPrimary(0);
         userDevice.setOnlineStatus(device.getOnlineStatus() != null ? device.getOnlineStatus() : 0);
         userDevice.setBindTime(LocalDateTime.now());
         save(userDevice);
-
-        LogUtil.info(UserDeviceServiceImpl.class,
-                "App用户[{}]为子用户[{}]绑定设备[{}]", appUserId, userId, device.getDeviceCode());
         return userDevice;
     }
 
+    /**
+     * 获取或创建设备对应的家庭群组
+     */
+    private DeviceGroup ensureDeviceGroup(String deviceCode) {
+        DeviceGroup group = deviceGroupMapper.selectOne(
+                new LambdaQueryWrapper<DeviceGroup>()
+                        .eq(DeviceGroup::getDeviceCode, deviceCode)
+                        .eq(DeviceGroup::getStatus, STATUS_ENABLED)
+                        .last("LIMIT 1")
+        );
+        if (group != null) {
+            return group;
+        }
+        group = new DeviceGroup();
+        group.setDeviceCode(deviceCode);
+        group.setStatus(STATUS_ENABLED);
+        deviceGroupMapper.insert(group);
+        return group;
+    }
+
+    private boolean isFamilyDevice(Device device) {
+        Integer type = device.getDeviceType();
+        return type == null || type == DEVICE_TYPE_FAMILY;
+    }
+
+    private AppUser requireAppUser(Long appUserId) {
+        AppUser appUser = appUserMapper.selectById(appUserId);
+        if (appUser == null) {
+            throw new BusinessException(ResultCode.APP_USER_NOT_FOUND);
+        }
+        if (Integer.valueOf(0).equals(appUser.getStatus())) {
+            throw new BusinessException(ResultCode.APP_USER_DISABLED);
+        }
+        return appUser;
+    }
+
+    private UserProfile ensureAccountProfile(AppUser appUser) {
+        UserProfile profile = userProfileMapper.selectOne(
+                new LambdaQueryWrapper<UserProfile>()
+                        .eq(UserProfile::getAppUserId, appUser.getId())
+                        .orderByAsc(UserProfile::getCreateTime)
+                        .last("LIMIT 1")
+        );
+        if (profile != null) {
+            return profile;
+        }
+
+        String phone = trimToNull(appUser.getPhone());
+        if (phone != null) {
+            profile = userProfileMapper.selectOne(
+                    new LambdaQueryWrapper<UserProfile>()
+                            .eq(UserProfile::getPhone, phone)
+                            .and(w -> w.isNull(UserProfile::getAppUserId).or().eq(UserProfile::getAppUserId, appUser.getId()))
+                            .orderByAsc(UserProfile::getCreateTime)
+                            .last("LIMIT 1")
+            );
+            if (profile != null) {
+                UserProfile update = new UserProfile();
+                update.setId(profile.getId());
+                update.setAppUserId(appUser.getId());
+                userProfileMapper.updateById(update);
+                profile.setAppUserId(appUser.getId());
+                return profile;
+            }
+        }
+
+        profile = new UserProfile();
+        profile.setAppUserId(appUser.getId());
+        profile.setPhone(phone);
+        profile.setName(resolveProfileName(appUser.getNickname(), phone, appUser.getId()));
+        userProfileMapper.insert(profile);
+        return profile;
+    }
+
+    private String trimToNull(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private String resolveProfileName(String preferredName, String phone, Long fallbackId) {
+        String name = trimToNull(preferredName);
+        if (name != null) {
+            return name;
+        }
+        if (phone != null && phone.length() >= 4) {
+            return "用户" + phone.substring(phone.length() - 4);
+        }
+        return "用户" + (fallbackId == null ? "" : fallbackId);
+    }
+
     /**
      * 解绑设备(设置状态为0)
      *

+ 56 - 2
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserPlanServiceImpl.java

@@ -30,11 +30,15 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.HashMap;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 @Service
 public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> implements UserPlanService {
 
+    private static final int MODE_TYPE_MASTER = 4;
+    private static final int PLAN_STATUS_PUBLISHED = 1;
+
     @Autowired
     private PlanMapper planMapper;
 
@@ -52,9 +56,10 @@ public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> i
         }
 
         ensureRequiredPlans(userId);
-        List<UserPlan> allPlans = lambdaQuery()
+        List<UserPlan> userPlans = lambdaQuery()
                 .eq(UserPlan::getUserId, userId)
-                .list()
+                .list();
+        List<UserPlan> allPlans = filterVisibleUserPlans(userPlans)
                 .stream()
                 .peek(this::normalizeCreator)
                 .collect(Collectors.toList());
@@ -99,6 +104,7 @@ public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> i
         if (userPlan == null) {
             throw new BusinessException(ResultCode.USER_PLAN_NOT_FOUND);
         }
+        checkUserPlanVisible(userPlan);
         normalizeCreator(userPlan);
         fillEffectType(userPlan);
         List<PlanStep> steps = getPlanSteps(userPlan.getPlanId());
@@ -107,6 +113,54 @@ public class UserPlanServiceImpl extends ServiceImpl<UserPlanMapper, UserPlan> i
         return userPlan;
     }
 
+    private List<UserPlan> filterVisibleUserPlans(List<UserPlan> userPlans) {
+        if (userPlans == null || userPlans.isEmpty()) {
+            return new ArrayList<>();
+        }
+        List<Long> masterPlanIds = userPlans.stream()
+                .filter(this::isMasterUserPlan)
+                .map(UserPlan::getPlanId)
+                .filter(id -> id != null)
+                .distinct()
+                .collect(Collectors.toList());
+        if (masterPlanIds.isEmpty()) {
+            return userPlans;
+        }
+
+        Set<Long> visibleMasterPlanIds = planMapper.selectList(new LambdaQueryWrapper<Plan>()
+                        .in(Plan::getId, masterPlanIds)
+                        .eq(Plan::getModeType, MODE_TYPE_MASTER)
+                        .eq(Plan::getStatus, PLAN_STATUS_PUBLISHED))
+                .stream()
+                .map(Plan::getId)
+                .collect(Collectors.toSet());
+
+        return userPlans.stream()
+                .filter(userPlan -> !isMasterUserPlan(userPlan)
+                        || (userPlan.getPlanId() != null && visibleMasterPlanIds.contains(userPlan.getPlanId())))
+                .collect(Collectors.toList());
+    }
+
+    private void checkUserPlanVisible(UserPlan userPlan) {
+        if (!isMasterUserPlan(userPlan)) {
+            return;
+        }
+        Plan plan = userPlan.getPlanId() == null ? null : planMapper.selectById(userPlan.getPlanId());
+        if (!isPublishedMasterPlan(plan)) {
+            throw new BusinessException(ResultCode.USER_PLAN_NOT_FOUND);
+        }
+    }
+
+    private boolean isMasterUserPlan(UserPlan userPlan) {
+        return userPlan != null && Integer.valueOf(MODE_TYPE_MASTER).equals(userPlan.getModeType());
+    }
+
+    private boolean isPublishedMasterPlan(Plan plan) {
+        return plan != null
+                && Integer.valueOf(MODE_TYPE_MASTER).equals(plan.getModeType())
+                && Integer.valueOf(PLAN_STATUS_PUBLISHED).equals(plan.getStatus());
+    }
+
     private void ensureRequiredPlans(Long userId) {
         List<UserPlan> existingPlans = lambdaQuery()
                 .eq(UserPlan::getUserId, userId)

+ 49 - 78
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserProfileServiceImpl.java

@@ -6,10 +6,8 @@ import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.UserProfileDTO;
 import com.aijiuyi.admin.controller.dto.UserProfileQueryDTO;
 import com.aijiuyi.admin.entity.AppUser;
-import com.aijiuyi.admin.entity.UserCategory;
 import com.aijiuyi.admin.entity.UserProfile;
 import com.aijiuyi.admin.mapper.AppUserMapper;
-import com.aijiuyi.admin.mapper.UserCategoryMapper;
 import com.aijiuyi.admin.mapper.UserProfileMapper;
 import com.aijiuyi.admin.service.UserAcupointService;
 import com.aijiuyi.admin.service.UserProfileService;
@@ -27,22 +25,17 @@ import java.math.BigDecimal;
 import java.util.List;
 
 /**
- * 子用户信息 Service 实现类
+ * 用户档案 Service 实现类
  */
 @Service
 public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserProfile> implements UserProfileService {
 
-    private static final int NORMAL_USER_TYPE = 1;
-
     @Autowired
     private UserAcupointService userAcupointService;
 
     @Autowired
     private AppUserMapper appUserMapper;
 
-    @Autowired
-    private UserCategoryMapper userCategoryMapper;
-
     @Override
     public IPage<UserProfile> pageList(UserProfileQueryDTO queryDTO) {
         Page<UserProfile> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
@@ -52,15 +45,19 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void addProfile(UserProfileDTO dto) {
+        if (!StringUtils.hasText(dto.getPhone())) {
+            throw new BusinessException("手机号不能为空");
+        }
         checkPhoneUnique(dto.getPhone(), null);
         validateFingerWidths(dto);
 
+        // 根据手机号自动创建或关联 app_user 账号
+        Long appUserId = ensureAppUserForPhone(dto.getPhone());
+
         UserProfile profile = buildProfileFromDto(dto);
+        profile.setAppUserId(appUserId);
         save(profile);
-        LogUtil.info(UserProfileServiceImpl.class, "新增子用户[{}],手机号[{}]", dto.getName(), dto.getPhone());
-
-        syncAppUserType(dto.getPhone(), NORMAL_USER_TYPE);
-        syncAppUserCategory(dto.getPhone(), dto.getUserCategoryId());
+        LogUtil.info(UserProfileServiceImpl.class, "新增用户档案[{}],手机号[{}],关联app_user[{}]", dto.getName(), dto.getPhone(), appUserId);
 
         regenerateUserAcupoints(profile.getId(), "新增");
     }
@@ -75,19 +72,21 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
 
         if (StringUtils.hasText(dto.getPhone()) && !dto.getPhone().equals(exist.getPhone())) {
             checkPhoneUnique(dto.getPhone(), dto.getId());
+            // 手机号变更,重新关联 app_user
+            Long appUserId = ensureAppUserForPhone(dto.getPhone());
+            dto.setAppUserId(appUserId);
         }
 
         validateFingerWidths(dto);
 
         UserProfile profile = buildProfileFromDto(dto);
         profile.setId(dto.getId());
+        // 如果手机号未变更,保留原有 appUserId
+        if (profile.getAppUserId() == null) {
+            profile.setAppUserId(exist.getAppUserId());
+        }
         updateById(profile);
-        LogUtil.info(UserProfileServiceImpl.class, "修改子用户[{}]", dto.getId());
-
-        // 优先以传入手机号同步;未传入时回退到已存记录的手机号
-        String syncPhone = StringUtils.hasText(dto.getPhone()) ? dto.getPhone() : exist.getPhone();
-        syncAppUserType(syncPhone, NORMAL_USER_TYPE);
-        syncAppUserCategory(syncPhone, dto.getUserCategoryId());
+        LogUtil.info(UserProfileServiceImpl.class, "修改用户档案[{}]", dto.getId());
 
         regenerateUserAcupoints(dto.getId(), "修改");
     }
@@ -99,7 +98,7 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
             throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
         }
         removeById(id);
-        LogUtil.info(UserProfileServiceImpl.class, "删除用户[{}]", id);
+        LogUtil.info(UserProfileServiceImpl.class, "删除用户档案[{}]", id);
     }
 
     @Override
@@ -108,7 +107,7 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
             throw new BusinessException(ResultCode.PARAM_ERROR);
         }
         removeByIds(ids);
-        LogUtil.info(UserProfileServiceImpl.class, "批量删除用户,共[{}]条", ids.size());
+        LogUtil.info(UserProfileServiceImpl.class, "批量删除用户档案,共[{}]条", ids.size());
     }
 
     @Override
@@ -134,7 +133,7 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
             save(profile);
         }
 
-        LogUtil.info(UserProfileServiceImpl.class, "批量导入用户,共[{}]条", list.size());
+        LogUtil.info(UserProfileServiceImpl.class, "批量导入用户档案,共[{}]条", list.size());
     }
 
     /**
@@ -182,6 +181,33 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         }
     }
 
+    /**
+     * 根据手机号查找或创建 app_user 账号,返回 app_user.id。
+     * 后台新增用户时手机号必填,自动为其创建 APP 登录账号。
+     */
+    private Long ensureAppUserForPhone(String phone) {
+        if (!StringUtils.hasText(phone)) {
+            return null;
+        }
+        AppUser existing = appUserMapper.selectOne(
+                new LambdaQueryWrapper<AppUser>().eq(AppUser::getPhone, phone));
+        if (existing != null) {
+            return existing.getId();
+        }
+        // 自动创建APP账号
+        AppUser newUser = new AppUser();
+        newUser.setPhone(phone);
+        newUser.setNickname(phone.substring(0, 3) + "****" + phone.substring(7));
+        newUser.setStatus(1);
+        newUser.setLoginType(1);
+        appUserMapper.insert(newUser);
+        // 生成展示用 AppId(格式 AJY00000001)
+        newUser.setAppId("AJY" + String.format("%08d", newUser.getId()));
+        appUserMapper.updateById(newUser);
+        LogUtil.info(UserProfileServiceImpl.class, "自动创建 app_user 账号,手机号[{}],id[{}],appId[{}]", phone, newUser.getId(), newUser.getAppId());
+        return newUser.getId();
+    }
+
     private void regenerateUserAcupoints(Long profileId, String action) {
         try {
             userAcupointService.generateForUser(profileId);
@@ -196,68 +222,13 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         }
     }
 
-    /**
-     * 同步用户类型到 app_user:若 user_profile.phone 与 app_user.phone 一致,同步修改其 userType。
-     */
-    private void syncAppUserType(String phone, Integer userType) {
-        if (!StringUtils.hasText(phone) || userType == null) {
-            return;
-        }
-        AppUser appUser = appUserMapper.selectOne(
-                new LambdaQueryWrapper<AppUser>().eq(AppUser::getPhone, phone));
-        if (appUser == null) {
-            return;
-        }
-        if (userType.equals(appUser.getUserType())) {
-            return;
-        }
-        AppUser update = new AppUser();
-        update.setId(appUser.getId());
-        update.setUserType(userType);
-        appUserMapper.updateById(update);
-        LogUtil.info(UserProfileServiceImpl.class,
-                "同步 app_user[{}] 用户类型为[{}](手机号[{}])", appUser.getId(), userType, phone);
-    }
-
-    /**
-     * 同步用户分类到 app_user:用户列表只维护身份为“用户”的档案,分类存储在 app_user.user_category_id。
-     */
-    private void syncAppUserCategory(String phone, Long userCategoryId) {
-        if (!StringUtils.hasText(phone) || userCategoryId == null) {
-            return;
-        }
-        long categoryCount = userCategoryMapper.selectCount(
-                new LambdaQueryWrapper<UserCategory>()
-                        .eq(UserCategory::getId, userCategoryId)
-                        .eq(UserCategory::getUserType, NORMAL_USER_TYPE));
-        if (categoryCount <= 0) {
-            throw new BusinessException(ResultCode.USER_CATEGORY_NOT_FOUND);
-        }
-
-        AppUser appUser = appUserMapper.selectOne(
-                new LambdaQueryWrapper<AppUser>().eq(AppUser::getPhone, phone));
-        if (appUser == null || !Integer.valueOf(NORMAL_USER_TYPE).equals(appUser.getUserType())) {
-            return;
-        }
-        if (userCategoryId.equals(appUser.getUserCategoryId())) {
-            return;
-        }
-        AppUser update = new AppUser();
-        update.setId(appUser.getId());
-        update.setUserCategoryId(userCategoryId);
-        appUserMapper.updateById(update);
-        LogUtil.info(UserProfileServiceImpl.class,
-                "同步 app_user[{}] 用户分类为[{}](手机号[{}])", appUser.getId(), userCategoryId, phone);
-    }
-
     private UserProfile buildProfileFromDto(UserProfileDTO dto) {
         UserProfile profile = new UserProfile();
-        profile.setUserId(dto.getUserId());
         profile.setName(dto.getName());
         profile.setGender(dto.getGender());
         profile.setAge(dto.getAge());
         profile.setPhone(dto.getPhone());
-        profile.setUserType(NORMAL_USER_TYPE);
+        profile.setUserCategoryId(dto.getUserCategoryId());
         profile.setProvinceCode(dto.getProvinceCode());
         profile.setCityCode(dto.getCityCode());
         profile.setDistrictCode(dto.getDistrictCode());
@@ -286,4 +257,4 @@ public class UserProfileServiceImpl extends ServiceImpl<UserProfileMapper, UserP
         wrapper.orderByAsc(UserProfile::getName).last("LIMIT 200");
         return list(wrapper);
     }
-}
+}

+ 2 - 2
code/backend/src/main/resources/application-dev.yml

@@ -15,10 +15,10 @@ spring:
 
   # ==================== Redis 配置 ====================
   redis:
-    host: 39.107.191.207
+    host: 117.72.153.201
     port: 6379
     # 密码(无密码留空,有密码填写)
-    password: bwy123$%^
+    password: 123456
     database: 0
     # 连接超时时间
     timeout: 10000ms

+ 1 - 0
code/backend/src/main/resources/application.yml

@@ -48,6 +48,7 @@ auth:
     - /api/auth/register
     - /api/app/auth/send-code
     - /api/app/auth/login
+    - /api/app/version/check
     - /api/article/view/**
     - /api/actuator/**
     - /api/doc.html

+ 58 - 0
code/backend/src/main/resources/db/migration_admin_user.sql

@@ -0,0 +1,58 @@
+-- ============================================================
+-- 用户表结构重构迁移脚本
+-- 目的:将管理员与APP用户隔离,明确表职责边界
+-- ============================================================
+
+-- 1. 创建 admin_user 表(管理员独立表)
+CREATE TABLE IF NOT EXISTS `admin_user` (
+  `id` bigint NOT NULL,
+  `username` varchar(50) NOT NULL COMMENT '用户名(唯一,登录用)',
+  `password` varchar(255) NOT NULL COMMENT '密码(MD5加密)',
+  `nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
+  `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
+  `avatar` varchar(500) DEFAULT NULL COMMENT '头像URL',
+  `admin_role` int DEFAULT NULL COMMENT '角色:1=超管,2=设备管理,3=圣手管理',
+  `status` int DEFAULT 1 COMMENT '状态:1=正常,0=禁用',
+  `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
+  `deleted` int DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_username` (`username`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='后台管理员表';
+
+-- 2. 从 app_user 迁移管理员数据到 admin_user
+INSERT INTO `admin_user` (`id`, `username`, `password`, `nickname`, `phone`, `avatar`, `admin_role`, `status`, `last_login_time`, `deleted`, `create_time`, `update_time`)
+SELECT `id`, `username`, `password`, `nickname`, `phone`, `avatar`, `admin_role`, `status`, `last_login_time`, `deleted`, `create_time`, `update_time`
+FROM `app_user`
+WHERE `user_type` = 3 AND `username` IS NOT NULL;
+
+-- 3. 迁移 admin_permission 数据(admin_id 对应关系保持不变,因为主键ID一致)
+
+-- 4. 修改 user_profile 表
+-- 4a. 将 user_id 重命名为 app_user_id
+ALTER TABLE `user_profile` CHANGE COLUMN `user_id` `app_user_id` bigint DEFAULT NULL COMMENT '关联APP账号ID(app_user.id),NULL表示不可登录APP';
+
+-- 4b. 添加 user_category_id 字段(从 app_user 表迁移过来)
+ALTER TABLE `user_profile` ADD COLUMN `user_category_id` bigint DEFAULT NULL COMMENT '用户分类ID' AFTER `phone`;
+
+-- 4c. 同步已有数据的 user_category_id(从关联的 app_user 中获取)
+UPDATE `user_profile` up
+INNER JOIN `app_user` au ON au.id = up.app_user_id AND au.deleted = 0
+SET up.user_category_id = au.user_category_id
+WHERE au.user_category_id IS NOT NULL;
+
+-- 4d. 删除 user_type 字段(不再需要区分角色)
+ALTER TABLE `user_profile` DROP COLUMN `user_type`;
+
+-- 5. 修改 app_user 表(删除管理员相关字段)
+-- 5a. 先删除管理员数据(已迁移到 admin_user)
+DELETE FROM `app_user` WHERE `user_type` = 3;
+
+-- 5b. 删除不需要的字段
+ALTER TABLE `app_user` DROP COLUMN `username`;
+ALTER TABLE `app_user` DROP COLUMN `password`;
+ALTER TABLE `app_user` DROP COLUMN `user_type`;
+ALTER TABLE `app_user` DROP COLUMN `user_category_id`;
+ALTER TABLE `app_user` DROP COLUMN `admin_role`;
+ALTER TABLE `app_user` DROP COLUMN `address`;

+ 5 - 0
code/backend/src/main/resources/mapper/AppVersionMapper.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.aijiuyi.admin.mapper.AppVersionMapper">
+
+</mapper>

+ 105 - 30
code/backend/src/main/resources/mapper/DeviceMapper.xml

@@ -2,12 +2,6 @@
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.aijiuyi.admin.mapper.DeviceMapper">
 
-    <!--
-        分页查询设备列表,LEFT JOIN user_device + user_profile 聚合绑定用户数和姓名
-        bound_user_count:已绑定且未删除的用户数
-        bound_user_names:已绑定用户姓名,顿号分隔
-        绑定用户姓名过滤使用子查询,避免 LEFT JOIN 过滤导致无用户设备丢失
-    -->
     <select id="pageWithUsers" resultType="com.aijiuyi.admin.entity.Device">
         SELECT
             d.id,
@@ -27,14 +21,53 @@
             d.deleted,
             d.create_time,
             d.update_time,
-            COUNT(DISTINCT ud.user_id) AS bound_user_count,
-            GROUP_CONCAT(DISTINCT up.name ORDER BY ud.bind_time DESC SEPARATOR '、') AS bound_user_names
+            CASE
+                WHEN COALESCE(d.device_type, 1) = 1 THEN (
+                    SELECT COUNT(DISTINCT dgm.profile_id)
+                    FROM device_group dg
+                    JOIN device_group_member dgm ON dgm.group_id = dg.id
+                        AND dgm.deleted = 0
+                        AND dgm.status = 1
+                    JOIN user_profile up_count ON up_count.id = dgm.profile_id
+                        AND up_count.deleted = 0
+                    WHERE dg.device_code = d.device_code
+                      AND dg.deleted = 0
+                      AND dg.status = 1
+                )
+                ELSE (
+                    SELECT COUNT(DISTINCT ud_count.user_id)
+                    FROM user_device ud_count
+                    JOIN user_profile up_count ON up_count.id = ud_count.user_id
+                        AND up_count.deleted = 0
+                    WHERE ud_count.device_code = d.device_code
+                      AND ud_count.deleted = 0
+                      AND ud_count.status = 1
+                )
+            END AS bound_user_count,
+            CASE
+                WHEN COALESCE(d.device_type, 1) = 1 THEN (
+                    SELECT GROUP_CONCAT(DISTINCT up_name.name ORDER BY dgm_name.join_time DESC SEPARATOR '、')
+                    FROM device_group dg_name
+                    JOIN device_group_member dgm_name ON dgm_name.group_id = dg_name.id
+                        AND dgm_name.deleted = 0
+                        AND dgm_name.status = 1
+                    JOIN user_profile up_name ON up_name.id = dgm_name.profile_id
+                        AND up_name.deleted = 0
+                    WHERE dg_name.device_code = d.device_code
+                      AND dg_name.deleted = 0
+                      AND dg_name.status = 1
+                )
+                ELSE (
+                    SELECT GROUP_CONCAT(DISTINCT up_name.name ORDER BY ud_name.bind_time DESC SEPARATOR '、')
+                    FROM user_device ud_name
+                    JOIN user_profile up_name ON up_name.id = ud_name.user_id
+                        AND up_name.deleted = 0
+                    WHERE ud_name.device_code = d.device_code
+                      AND ud_name.deleted = 0
+                      AND ud_name.status = 1
+                )
+            END AS bound_user_names
         FROM device d
-        LEFT JOIN user_device ud ON ud.device_code = d.device_code
-            AND ud.deleted = 0
-            AND ud.status = 1
-        LEFT JOIN user_profile up ON up.id = ud.user_id
-            AND up.deleted = 0
         WHERE d.deleted = 0
         <if test="q.deviceCode != null and q.deviceCode != ''">
             AND d.device_code LIKE CONCAT('%', #{q.deviceCode}, '%')
@@ -66,24 +99,63 @@
             AND d.district_code = #{q.districtCode}
         </if>
         <if test="q.boundUserName != null and q.boundUserName != ''">
-            AND d.device_code IN (
-                SELECT ud2.device_code FROM user_device ud2
-                JOIN user_profile up2 ON up2.id = ud2.user_id
-                WHERE up2.name LIKE CONCAT('%', #{q.boundUserName}, '%')
-                  AND ud2.status = 1
-                  AND ud2.deleted = 0
-                  AND up2.deleted = 0
+            AND (
+                (
+                    COALESCE(d.device_type, 1) = 1
+                    AND EXISTS (
+                        SELECT 1
+                        FROM device_group dg2
+                        JOIN device_group_member dgm2 ON dgm2.group_id = dg2.id
+                            AND dgm2.deleted = 0
+                            AND dgm2.status = 1
+                        JOIN user_profile up2 ON up2.id = dgm2.profile_id
+                            AND up2.deleted = 0
+                        WHERE dg2.device_code = d.device_code
+                          AND dg2.deleted = 0
+                          AND dg2.status = 1
+                          AND up2.name LIKE CONCAT('%', #{q.boundUserName}, '%')
+                    )
+                )
+                OR
+                (
+                    COALESCE(d.device_type, 1) = 2
+                    AND EXISTS (
+                        SELECT 1
+                        FROM user_device ud2
+                        JOIN user_profile up2 ON up2.id = ud2.user_id
+                            AND up2.deleted = 0
+                        WHERE ud2.device_code = d.device_code
+                          AND ud2.status = 1
+                          AND ud2.deleted = 0
+                          AND up2.name LIKE CONCAT('%', #{q.boundUserName}, '%')
+                    )
+                )
             )
         </if>
-        GROUP BY d.id
         ORDER BY d.create_time DESC
     </select>
 
-    <!--
-        根据设备编号查询绑定的用户列表(联表 user_device + user_profile)
-        返回 DeviceUserVO 视图对象:bindId/userId/userName/phone/bindTime/isPrimary
-    -->
     <select id="getUsersByDeviceCode" resultType="com.aijiuyi.admin.entity.DeviceUserVO">
+        SELECT
+            dgm.id       AS bind_id,
+            dgm.profile_id AS user_id,
+            up.name      AS user_name,
+            up.phone,
+            dgm.join_time AS bind_time,
+            0            AS is_primary
+        FROM device d
+        JOIN device_group dg ON dg.device_code = d.device_code
+            AND dg.deleted = 0
+            AND dg.status = 1
+        JOIN device_group_member dgm ON dgm.group_id = dg.id
+            AND dgm.deleted = 0
+            AND dgm.status = 1
+        JOIN user_profile up ON up.id = dgm.profile_id
+            AND up.deleted = 0
+        WHERE d.device_code = #{deviceCode}
+          AND d.deleted = 0
+          AND COALESCE(d.device_type, 1) = 1
+        UNION ALL
         SELECT
             ud.id        AS bind_id,
             ud.user_id,
@@ -91,13 +163,16 @@
             up.phone,
             ud.bind_time,
             ud.is_primary
-        FROM user_device ud
+        FROM device d
+        JOIN user_device ud ON ud.device_code = d.device_code
+            AND ud.deleted = 0
+            AND ud.status = 1
         JOIN user_profile up ON up.id = ud.user_id
             AND up.deleted = 0
-        WHERE ud.device_code = #{deviceCode}
-          AND ud.deleted = 0
-          AND ud.status = 1
-        ORDER BY ud.is_primary DESC, ud.bind_time ASC
+        WHERE d.device_code = #{deviceCode}
+          AND d.deleted = 0
+          AND COALESCE(d.device_type, 1) = 2
+        ORDER BY is_primary DESC, bind_time ASC
     </select>
 
 </mapper>

+ 8 - 0
code/backend/src/main/resources/mapper/UserPlanMapper.xml

@@ -19,6 +19,10 @@
         left join plan p on up.plan_id = p.id and p.deleted = 0
         <where>
             up.deleted = 0
+            and (
+                coalesce(up.mode_type, p.mode_type) &lt;&gt; 4
+                or (p.id is not null and p.mode_type = 4 and p.status = 1)
+            )
             <if test="query.userId != null">
                 and up.user_id = #{query.userId}
             </if>
@@ -43,6 +47,10 @@
         left join plan p on up.plan_id = p.id and p.deleted = 0
         where up.deleted = 0
         and up.user_id = #{userId}
+        and (
+            coalesce(up.mode_type, p.mode_type) &lt;&gt; 4
+            or (p.id is not null and p.mode_type = 4 and p.status = 1)
+        )
     </select>
 
 </mapper>

+ 6 - 12
code/backend/src/main/resources/mapper/UserProfileMapper.xml

@@ -3,20 +3,20 @@
 <mapper namespace="com.aijiuyi.admin.mapper.UserProfileMapper">
 
     <!--
-        分页查询用户列表,LEFT JOIN user_device 获取绑定设备编号
-        该列表固定展示身份为“用户”的数据,并关联 app_user_category 获取用户分类
+        分页查询用户档案列表,LEFT JOIN user_device 获取绑定设备编号
+        user_category_id 现在直接存储在 user_profile 表中,关联 app_user_category 获取分类名称
         device_codes: GROUP_CONCAT 逗号拼接已绑定设备编号
         设备编号过滤使用子查询,避免 LEFT JOIN 过滤导致无设备用户丢失
     -->
     <select id="pageWithDevices" resultType="com.aijiuyi.admin.entity.UserProfile">
         SELECT
             up.id,
-            up.user_id,
+            up.app_user_id,
             up.name,
             up.gender,
             up.age,
             up.phone,
-            up.user_type,
+            up.user_category_id,
             up.province_code,
             up.city_code,
             up.district_code,
@@ -33,18 +33,12 @@
             up.deleted,
             up.create_time,
             up.update_time,
-            MAX(COALESCE(au_id.user_category_id, au_phone.user_category_id)) AS user_category_id,
             MAX(uc.name) AS user_category_name,
             GROUP_CONCAT(ud.device_code ORDER BY ud.id SEPARATOR ',') AS device_codes
         FROM user_profile up
         LEFT JOIN user_device ud ON ud.user_id = up.id AND ud.status = 1
-        LEFT JOIN app_user au_id ON au_id.id = up.user_id AND au_id.deleted = 0
-        LEFT JOIN app_user au_phone ON au_id.id IS NULL AND au_phone.phone = up.phone AND au_phone.deleted = 0
-        LEFT JOIN app_user_category uc
-            ON uc.id = COALESCE(au_id.user_category_id, au_phone.user_category_id)
-            AND uc.deleted = 0
+        LEFT JOIN app_user_category uc ON uc.id = up.user_category_id AND uc.deleted = 0
         WHERE up.deleted = 0
-          AND up.user_type = 1
         <if test="q.keyword != null and q.keyword != ''">
             AND (up.name LIKE CONCAT('%', #{q.keyword}, '%')
               OR up.phone LIKE CONCAT('%', #{q.keyword}, '%'))
@@ -53,7 +47,7 @@
             AND up.gender = #{q.gender}
         </if>
         <if test="q.userCategoryId != null">
-            AND COALESCE(au_id.user_category_id, au_phone.user_category_id) = #{q.userCategoryId}
+            AND up.user_category_id = #{q.userCategoryId}
         </if>
         <if test="q.provinceCode != null and q.provinceCode != ''">
             AND up.province_code = #{q.provinceCode}

+ 15 - 0
code/backend/src/main/resources/sql/migration_admin_permission.sql

@@ -0,0 +1,15 @@
+-- ==================== 管理员页面权限配置表 ====================
+-- 执行数据库:aijiuyi_admin
+-- 作用:保存非超级管理员可访问的后台前端路由路径列表
+
+CREATE TABLE IF NOT EXISTS `admin_permission` (
+    `id` BIGINT NOT NULL COMMENT '主键ID',
+    `admin_id` BIGINT NOT NULL COMMENT '管理员ID,关联 app_user.id',
+    `route_paths` TEXT NULL COMMENT '可访问前端路由路径 JSON 数组,如 ["/dashboard","/device"]',
+    `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_admin_permission_admin_id` (`admin_id`),
+    KEY `idx_admin_permission_deleted` (`deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员页面权限配置表';

+ 19 - 0
code/backend/src/main/resources/sql/migration_app_version.sql

@@ -0,0 +1,19 @@
+-- APP版本管理表
+CREATE TABLE IF NOT EXISTS `app_version` (
+    `id` bigint NOT NULL COMMENT '主键ID',
+    `version_code` varchar(20) NOT NULL COMMENT '版本号(如:1.0.1)',
+    `version_number` int NOT NULL COMMENT '版本序号(用于比较大小,如:10001)',
+    `platform` tinyint NOT NULL COMMENT '平台:1=Android,2=iOS',
+    `update_type` tinyint NOT NULL DEFAULT 2 COMMENT '更新类型:1=强制更新,2=推荐更新',
+    `download_url` varchar(500) NOT NULL COMMENT '下载地址(Android为APK链接,iOS为App Store链接)',
+    `update_content` text DEFAULT NULL COMMENT '更新内容描述',
+    `file_size` varchar(20) DEFAULT NULL COMMENT '安装包大小(如:25.6MB)',
+    `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=启用',
+    `publish_time` datetime DEFAULT NULL COMMENT '发布时间',
+    `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+    `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_platform_status` (`platform`, `status`, `deleted`),
+    KEY `idx_version_number` (`version_number`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='APP版本管理表';

+ 23 - 0
code/backend/src/test/java/com/aijiuyi/admin/common/util/AcupointLocationParserTest.java

@@ -29,6 +29,29 @@ class AcupointLocationParserTest {
         assertEquals(new BigDecimal("550.00"), y);
     }
 
+    @Test
+    void parsesProductCoordinateRelativeToDazhui() {
+        String[] positions = new String[]{
+                "产品坐标换算:相对大椎下3寸,旁开3寸,双侧。",
+                "产品坐标换算:大椎下3寸,旁开3寸,双侧。",
+                "产品坐标换算:大椎穴下3寸,旁开3寸,双侧。"
+        };
+        for (String position : positions) {
+            Acupoint acupoint = acupoint("秉风", "双穴", "手太阳小肠经", "肩胛区",
+                    "在肩胛区,肩胛冈中点上方冈上窝中。");
+            acupoint.setAcupointCode("xiaochang-12");
+            acupoint.setRelativePosition(position);
+
+            AcupointLocationParser.ParseResult result = AcupointLocationParser.parse(acupoint);
+
+            assertEquals(AcupointLocationParser.STATUS_PARSED, result.getParseStatus());
+            assertEquals(0, new BigDecimal("3.00").compareTo(result.getLongitudeOffset()));
+            assertEquals(0, new BigDecimal("3.00").compareTo(result.getLateralOffset()));
+            assertEquals(4, result.getSide());
+            assertEquals(AcupointLocationParser.CONFIDENCE_MEDIUM, result.getParseConfidence());
+        }
+    }
+
     @Test
     void parsesAllBackAcupointsFromDataModel() {
         String[][] rows = new String[][]{

+ 4 - 5
code/frontend/src/api/auth.js

@@ -55,10 +55,9 @@ export function uploadImage(file) {
 }
 
 /**
- * 获取应用用户列表(用于方案创作人选择框)
- * @param {number} [userType] 可选过滤:1=用户,2=专家,3=管理员;不传则返回全部
- * @returns {Promise} 用户列表(id + nickname + username + userType)
+ * 获取APP用户列表(用于方案创作人选择框等场景)
+ * @returns {Promise} 用户列表(id + phone + nickname + appId)
  */
-export function getAppUserList(userType) {
-  return request.get('/user/app/list', { params: userType != null ? { userType } : {} })
+export function getAppUserList() {
+  return request.get('/user/app/list')
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است