8 Комити 9b25a00f26 ... e97e8bf349

Аутор SHA1 Порука Датум
  liyuliangjiazai e97e8bf349 自定义方案双侧穴位显示bug修复 пре 3 месеци
  liyuliangjiazai 75d347aed9 Merge branch 'jiapu_aijiuyi' into dev пре 3 месеци
  jiapu e9a2ab0033 APP用户新增和修改自定义方案时,创作人由账号ID修改为子用户ID пре 3 месеци
  jiapu 3087634598 编辑方案的时候,根据穴位的性质(单双穴)显示侧别 пре 3 месеци
  liyuliangjiazai 0d6b450328 音乐播放支持离开设备页面后继续播放 пре 3 месеци
  jiapu 1805963811 编辑方案的时候,根据穴位的性质(单双穴)显示侧别 пре 3 месеци
  jiapu afc0c91969 设备编号不允许出现空格,自动过滤 пре 3 месеци
  jiapu 15e6a85fb8 设备编号不允许出现空格,自动过滤 пре 3 месеци

+ 33 - 12
code/ajyApp/pages/device/acupointEdit/acupointEdit.vue

@@ -91,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>
@@ -212,7 +208,7 @@ export default {
 				temperature: 45,
 				duration: 15
 			},
-			sideOptions: ['双侧', '中心', '左侧', '右侧'],
+			sideOptions: [ '中心', '左侧', '右侧'],
 			methodOptions: ['温和灸', '雀啄灸', '回旋灸'],
 			
 			scrollTop: 0,
@@ -439,17 +435,41 @@ export default {
 
 		/** 将方案步骤参数回填到穴位列表 */
 		applyStepsToAcupoints(steps) {
-			const sideMap = { 1: '中心', 2: '左侧', 3: '右侧', 4: '双侧' }
+			const sideMap = { 1: '中心', 2: '左侧', 3: '右侧' }
 			const techniqueMap = { '2': '温和灸', '4': '回旋灸', '3': '雀啄灸' }
+			// step.side数字值到acupointSide英文值的映射
+			const sideToAcupointSide = { 1: 'center', 2: 'left', 3: 'right' }
+			
 			steps.forEach(step => {
-				const point = this.acupointList.find(p => p.acupointId === step.acupointId)
-				if (point) {
+				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
-				}
+				})
 			})
 		},
 
@@ -490,11 +510,12 @@ export default {
 
 			this.saving = true
 			try {
-				const sideMap = { '中心': 1, '左侧': 2, '右侧': 3, '双侧': 4 }
+				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 => ({

+ 2 - 1
code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue

@@ -408,7 +408,8 @@ export default {
 		}
 	},
 	onUnload() {
-		this.destroyAudio()
+		// 离开页面时解绑音频事件(音频继续播放,不销毁)
+		this.detachAudio()
 		// 离开页面时不断开蓝牙连接,仅停止扫描
 		// 下次进入相同设备可复用连接
 		this.cleanupOnLeave()

+ 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()
 		}
 	}
 }

+ 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')
+		}
+	}
+})

+ 43 - 19
code/app对接接口说明.md

@@ -17,15 +17,15 @@
 | 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` | 当前用户新增一个自定义模式方案(含穴位步骤),后端强制设置 modeType=3、authorType=2、authorId=当前用户 |
+| 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}` | 当前用户删除自己的自定义方案,仅允许删除创作人为当前用户、模式为自定义模式的方案 |
+| 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 账号修改指定群组下已有成员的用户档案信息,需校验群组归属 |
@@ -461,10 +461,11 @@ curl -X GET "http://localhost:18888/api/app/device/1001" \
 后端强制设置:
 - `modeType` = 3(自定义模式)
 - `authorType` = 2(用户创作)
-- `authorId` = 当前 APP 用户 ID
+- `authorId` = 子用户 ID(`user_profile.id`,由前端传入 `profileId` 指定)
+- `authorName` = 子用户名称(`user_profile.name`,后端根据 `profileId` 自动查询)
 - `planCode` = 系统自动生成(S + 6 位随机字符)
 
-前端无需传入这些字段,后端会忽略前端传值。
+`profileId` 为必传参数,用于指定方案归属的子用户。后端会校验该子用户是否属于当前 APP 账号。其余字段前端无需传入,后端会忽略前端传值。
 
 #### 3.5.2 请求信息
 
@@ -478,7 +479,8 @@ curl -X GET "http://localhost:18888/api/app/device/1001" \
 
 | 参数名 | 类型 | 是否必填 | 说明 | 示例 |
 | --- | --- | --- | --- | --- |
-| `name` | `string` | 是 | 方案名称(最长 100 字符,不可重复) | `我的祛湿方案` |
+| `profileId` | `number` | 是 | 子用户档案 ID,对应 `user_profile.id`,用于指定方案归属的子用户 | `4` |
+| `name` | `string` | 是 | 方案名称(最长 100 字符,同一子用户下不可重复) | `我的祛湿方案` |
 | `symptoms` | `string` | 否 | 适用症状(逗号分隔) | `关节酸痛,乏力` |
 | `applicableGender` | `number` | 否 | 适用人群:`0=不限`,`1=仅男`,`2=仅女` | `0` |
 | `ageMin` | `number` | 否 | 适用年龄最小值(岁) | `18` |
@@ -511,6 +513,7 @@ Authorization: Bearer <token>
 Content-Type: application/json
 
 {
+  "profileId": 4,
   "name": "我的祛湿方案",
   "symptoms": "关节酸痛,乏力",
   "applicableGender": 0,
@@ -543,7 +546,7 @@ Content-Type: application/json
 curl -X POST "http://localhost:18888/api/app/custom-plan" \
   -H "Authorization: Bearer <token>" \
   -H "Content-Type: application/json" \
-  -d '{"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":""}]}'
+  -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 返回示例
@@ -578,10 +581,31 @@ curl -X POST "http://localhost:18888/api/app/custom-plan" \
 }
 ```
 
+子用户档案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` 错误。
+- 方案名称在同一子用户范围内不可重复,若重复返回 `1702` 错误。
 - 成功返回 `data` 为新方案的 ID,后续可用于详情查询、修改和删除。
 - `steps` 不能为空,至少包含 1 个穴位步骤。
 
@@ -603,7 +627,7 @@ curl -X POST "http://localhost:18888/api/app/custom-plan" \
 
 #### 3.6.3 请求参数
 
-无查询参数。后端通过 `Authorization` 请求头中的 Token 获取当前 APP 账号 ID,仅返回创作人类型为用户(`authorType=2`)、模式为自定义模式(`modeType=3`)且创作人 ID 等于当前账号 ID 的方案。
+无查询参数。后端通过 `Authorization` 请求头中的 Token 获取当前 APP 账号 ID,查询该账号下所有子用户(`user_profile`),返回创作人类型为用户(`authorType=2`)、模式为自定义模式(`modeType=3`)且创作人 ID 属于当前账号下子用户的方案。
 
 #### 3.6.4 调用示例
 
@@ -685,7 +709,7 @@ curl -X GET "http://localhost:18888/api/app/custom-plan/list" \
 | `ageMin` | `number/null` | 适用年龄最小值(岁) |
 | `ageMax` | `number/null` | 适用年龄最大值(岁) |
 | `description` | `string` | 方案描述 |
-| `authorId` | `number` | 创作人 ID,等于当前 APP 账号 ID |
+| `authorId` | `number` | 创作人 ID,等于子用户 ID(`user_profile.id`) |
 | `authorName` | `string` | 创作人名称(冗余字段) |
 | `authorType` | `number` | 创作人类型,本接口固定返回 `2`(用户创作) |
 | `useCount` | `number` | 使用人数 |
@@ -698,8 +722,8 @@ curl -X GET "http://localhost:18888/api/app/custom-plan/list" \
 #### 3.6.7 备注
 
 - 本接口**不返回**穴位步骤列表(`steps`),仅返回方案基本信息。如需查看方案详情和穴位步骤,请调用第 7 个接口 `GET /api/app/custom-plan/{id}`。
-- 本接口仅返回当前 APP 账号作为"创作人"的自定义模式方案,不会返回系统方案或其他用户创作的方案。
-- 后端根据 Token 解析当前账号 ID 作为 `authorId`,按 `authorType=2`、`modeType=3`、`authorId=当前用户ID` 严格过滤。
+- 本接口仅返回当前 APP 账号下子用户作为"创作人"的自定义模式方案,不会返回系统方案或其他账号子用户创作的方案。
+- 后端根据 Token 解析当前账号 ID,查询该账号下所有子用户 ID,按 `authorType=2`、`modeType=3`、`authorId` 为当前账号下子用户 ID 严格过滤。
 - `useCount`、`avgRating` 为方案被使用后的统计字段,新建方案的初始值为 `0`/`null`。
 - 结果按 `updateTime` 倒序返回,最近修改或创建的方案排在前。
 
@@ -809,7 +833,7 @@ curl -X GET "http://localhost:18888/api/app/custom-plan/1001" \
 
 #### 3.7.8 备注
 
-- 越权校验在 Service 层完成:仅当 `modeType=3`、`authorType=2`、`authorId=当前用户ID` 同时满足时才返回方案详情,否则按"方案不存在"处理。
+- 越权校验在 Service 层完成:仅当 `modeType=3`、`authorType=2`、`authorId` 为当前账号下子用户 ID 同时满足时才返回方案详情,否则按"方案不存在"处理。
 - 不会返回其他用户创作的方案、系统预设方案(`modeType=1/2/4`)的详情。
 - APP 在编辑方案前应先调用本接口获取最新数据。
 
@@ -1230,7 +1254,7 @@ curl -X GET "http://localhost:18888/api/app/master-plan/20001" \
 
 用于 APP 端让用户修改自己创建的自定义模式方案,包括方案基本信息和穴位步骤列表。
 
-请求体格式与"新增用户自定义方案"接口(`POST /api/app/custom-plan`,见 3.5 节)保持一致,仅多一个 `id` 字段。`steps` 结构、字段含义、必填/可选规则完全相同。
+请求体格式与"新增用户自定义方案"接口(`POST /api/app/custom-plan`,见 3.5 节)基本一致,区别如下:多一个 `id` 字段(必传);**不需要传 `profileId`**(方案归属在创建时已确定,修改时不变更归属子用户)。`steps` 结构、字段含义、必填/可选规则与新增接口完全相同。
 
 #### 3.12.2 请求信息
 
@@ -1245,7 +1269,7 @@ curl -X GET "http://localhost:18888/api/app/master-plan/20001" \
 | 参数名 | 类型 | 是否必填 | 说明 | 示例 |
 | --- | --- | --- | --- | --- |
 | `id` | `number` | 是 | 方案 ID,对应 `plan.id` | `1001` |
-| `name` | `string` | 是 | 方案名称(最长 100 字符,不可重复) | `我的祛湿方案` |
+| `name` | `string` | 是 | 方案名称(最长 100 字符,同一子用户下不可重复) | `我的祛湿方案` |
 | `symptoms` | `string` | 否 | 适用症状(逗号分隔) | `关节酸痛,乏力` |
 | `applicableGender` | `number` | 否 | 适用人群:`0=不限`,`1=仅男`,`2=仅女` | `0` |
 | `ageMin` | `number` | 否 | 适用年龄最小值(岁) | `18` |
@@ -1357,9 +1381,9 @@ curl -X PUT "http://localhost:18888/api/app/custom-plan" \
 #### 3.12.7 备注
 
 - 本接口需要 APP 登录后的 Token,不接受后台管理员 Token。
-- 后端会自动校验 `modeType=3`、`authorType=2`、`authorId=当前用户ID`,不满足时按"方案不存在"处理,避免越权。
+- 后端会自动校验 `modeType=3`、`authorType=2`、`authorId` 为当前账号下子用户 ID,不满足时按"方案不存在"处理,避免越权。
 - 修改方案时后端使用"先删后增"的方式更新穴位步骤:`steps` 传 `null` 时保留原步骤;传 `[]` 时清空所有步骤;传非空数组时整体替换为新步骤并重新计算步骤序号。
-- 方案名称全局唯一,修改时不能与其他方案(包含其他用户创建的方案)重名。
+- 方案名称在同一子用户范围内唯一,修改时不能与该子用户下其他方案重名。
 - 穴位必须处于启用状态(`acupoint.status=1`)才能被引用。
 - `modeType`、`effectType`、`planCode`、`authorId`、`authorName`、`authorType`、`useCount`、`avgRating` 等字段为系统字段,APP 传入也会被忽略,不允许用户修改。
 ### 3.13 删除用户自定义方案
@@ -1368,7 +1392,7 @@ curl -X PUT "http://localhost:18888/api/app/custom-plan" \
 
 用于 APP 端用户删除自己的自定义方案。
 
-后端校验方案归属(`modeType=3`、`authorType=2`、`authorId=当前用户ID`),仅允许删除自己的自定义方案。删除为物理删除,会同步删除方案下的穴位步骤。
+后端校验方案归属(`modeType=3`、`authorType=2`、`authorId` 为当前账号下子用户 ID),仅允许删除自己创建的自定义方案。删除为物理删除,会同步删除方案下的穴位步骤。
 
 #### 3.13.2 请求信息
 
@@ -1420,7 +1444,7 @@ curl -X DELETE "http://localhost:18888/api/app/custom-plan/2067138786800111618"
 #### 3.13.7 备注
 
 - 删除为物理删除,不可恢复,请谨慎操作。
-- 仅允许删除创作人为当前用户的自定义模式方案,不存在或不属于当前用户的方案均返回 `1700` 错误。
+- 仅允许删除创作人为当前账号下子用户的自定义模式方案,不存在或不属于当前账号的方案均返回 `1700` 错误。
 - 删除方案时会同步删除该方案下的所有穴位步骤。
 
 ### 3.14 查询群组成员列表

+ 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);

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

@@ -36,8 +36,8 @@ public class AppPlanController {
     @PostMapping("/custom-plan")
     @Log(value = "新增用户自定义方案", module = "App方案", operationType = OperationType.INSERT)
     public Result<Long> create(@RequestBody @Valid PlanSaveDTO dto) {
-        Long userId = RequestContext.getUserId();
-        return Result.success(planService.createUserPlan(dto, userId));
+        Long appUserId = RequestContext.getUserId();
+        return Result.success(planService.createUserPlan(dto, appUserId));
     }
 
     /**
@@ -49,8 +49,8 @@ public class AppPlanController {
     @GetMapping("/custom-plan/list")
     @Log(value = "查询用户自定义方案列表", module = "App方案", operationType = OperationType.QUERY)
     public Result<List<Plan>> list() {
-        Long userId = RequestContext.getUserId();
-        return Result.success(planService.listUserCustomPlans(userId));
+        Long appUserId = RequestContext.getUserId();
+        return Result.success(planService.listUserCustomPlans(appUserId));
     }
 
     /**
@@ -62,8 +62,8 @@ public class AppPlanController {
     @GetMapping("/custom-plan/{id}")
     @Log(value = "查询用户自定义方案详情", module = "App方案", operationType = OperationType.QUERY)
     public Result<AppPlanVO> detail(@PathVariable Long id) {
-        Long userId = RequestContext.getUserId();
-        return Result.success(planService.getUserPlanDetail(id, userId));
+        Long appUserId = RequestContext.getUserId();
+        return Result.success(planService.getUserPlanDetail(id, appUserId));
     }
 
     /**
@@ -75,8 +75,8 @@ public class AppPlanController {
     @PutMapping("/custom-plan")
     @Log(value = "修改用户自定义方案", module = "App方案", operationType = OperationType.UPDATE)
     public Result<Void> update(@RequestBody @Valid PlanSaveDTO dto) {
-        Long userId = RequestContext.getUserId();
-        planService.updateUserPlan(dto, userId);
+        Long appUserId = RequestContext.getUserId();
+        planService.updateUserPlan(dto, appUserId);
         return Result.success();
     }
 
@@ -89,8 +89,8 @@ public class AppPlanController {
     @DeleteMapping("/custom-plan/{id}")
     @Log(value = "删除用户自定义方案", module = "App方案", operationType = OperationType.DELETE)
     public Result<Void> delete(@PathVariable Long id) {
-        Long userId = RequestContext.getUserId();
-        planService.deleteUserPlan(id, userId);
+        Long appUserId = RequestContext.getUserId();
+        planService.deleteUserPlan(id, appUserId);
         return Result.success();
     }
 

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

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

+ 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;
 
     /** 创作人名称(冗余存储,加速查询) */

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

@@ -104,47 +104,47 @@ public interface PlanService extends IService<Plan> {
     List<String> listAuthors();
 
     /**
-     * 用户新增自定义方案(含步骤)
+     * 用户新增自定义方案(含步骤),author_id 取子用户ID(user_profile.id)
      *
-     * @param dto    方案信息
-     * @param userId 用户ID
+     * @param dto       方案信息(需包含 profileId 指定归属子用户)
+     * @param appUserId APP账号ID(app_user.id,用于校验子用户归属)
      * @return 方案ID
      */
-    Long createUserPlan(PlanSaveDTO dto, Long userId);
+    Long createUserPlan(PlanSaveDTO dto, Long appUserId);
 
     /**
-     * 查询用户的自定义模式方案列表(不含穴位步骤,仅返回方案基本信息)
+     * 查询APP账号下所有子用户的自定义模式方案列表(不含穴位步骤,仅返回方案基本信息)
      * 方案详情请通过 {@link #getUserPlanDetail(Long, Long)} 获取
      *
-     * @param userId 用户ID
+     * @param appUserId APP账号ID(app_user.id)
      * @return 方案列表
      */
-    List<Plan> listUserCustomPlans(Long userId);
+    List<Plan> listUserCustomPlans(Long appUserId);
 
     /**
-     * 查询用户自定义方案详情(含穴位步骤)
+     * 查询用户自定义方案详情(含穴位步骤),校验方案归属于当前APP账号的子用户
      *
-     * @param planId 方案ID
-     * @param userId 用户ID
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
      * @return 方案详情
      */
-    AppPlanVO getUserPlanDetail(Long planId, Long userId);
+    AppPlanVO getUserPlanDetail(Long planId, Long appUserId);
 
     /**
-     * 用户修改自己的自定义方案(含步骤)
+     * 用户修改自己的自定义方案(含步骤),校验方案归属于当前APP账号的子用户
      *
-     * @param dto    方案信息
-     * @param userId 用户ID
+     * @param dto       方案信息
+     * @param appUserId APP账号ID(app_user.id)
      */
-    void updateUserPlan(PlanSaveDTO dto, Long userId);
+    void updateUserPlan(PlanSaveDTO dto, Long appUserId);
 
     /**
-     * 用户删除自己的自定义方案
+     * 用户删除自己的自定义方案,校验方案归属于当前APP账号的子用户
      *
-     * @param planId 方案ID
-     * @param userId 用户ID
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
      */
-    void deleteUserPlan(Long planId, Long userId);
+    void deleteUserPlan(Long planId, Long appUserId);
 
     /**
      * 查询专业模式方案列表(modeType=2,所有用户共享,无差异)

+ 114 - 39
code/backend/src/main/java/com/aijiuyi/admin/service/impl/PlanServiceImpl.java

@@ -12,10 +12,12 @@ 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;
@@ -58,6 +60,9 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     @Autowired
     private AppUserService appUserService;
 
+    @Autowired
+    private UserProfileMapper userProfileMapper;
+
     /**
      * 分页查询方案列表
      *
@@ -453,12 +458,45 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
             step.setStepOrder(i + 1);
             step.setAcupointId(acupoint.getId());
             step.setAcupointName(acupoint.getName());
-            // 侧别跟随穴位,无侧别信息时默认为 1(中心)
-            step.setSide(acupoint.getSide() != null ? acupoint.getSide() : 1);
+            // 优先使用用户选择的侧别,并根据穴位单/双属性校验合法性
+            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);
@@ -482,23 +520,32 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     }
 
     /**
-     * 用户新增自定义方案(含步骤),强制设置 modeType=3、authorType=2、authorId=userId
+     * 用户新增自定义方案(含步骤),author_id 取子用户ID(user_profile.id)
      *
-     * @param dto    方案信息
-     * @param userId 用户ID
+     * @param dto       方案信息(需包含 profileId 指定归属子用户)
+     * @param appUserId APP账号ID(app_user.id,用于校验子用户归属)
      * @return 方案ID
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public Long createUserPlan(PlanSaveDTO dto, Long userId) {
+    public Long createUserPlan(PlanSaveDTO dto, Long appUserId) {
         if (dto.getSteps() == null || dto.getSteps().isEmpty()) {
             throw new BusinessException(ResultCode.PLAN_STEP_MIN_ERROR);
         }
-        // 方案名称仅在当前用户范围内做唯一性校验:不同用户可以起相同的方案名(方案 ID 各自不同)
+        // 校验子用户档案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, userId)
+                    .eq(Plan::getAuthorId, profileId)
                     .count();
             if (nameCount > 0) {
                 throw new BusinessException(ResultCode.PLAN_NAME_EXISTS);
@@ -507,56 +554,58 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
         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)
-        fillCurrentAuthor(plan);
-        // 安全兜底:避免 fillCurrentAuthor 未设置(异常情况)导致 authorId 缺失
-        plan.setAuthorId(userId);
-        // 强制设置 authorType=2(用户创作),与 APP 端"我的自定义方案"过滤条件一致
+        // 创作人信息取子用户档案(id、name、type)
+        plan.setAuthorId(profileId);
+        plan.setAuthorName(profile.getName());
         plan.setAuthorType(2);
         save(plan);
         saveSteps(plan.getId(), dto.getSteps());
-        LogUtil.info(PlanServiceImpl.class, "用户[{}]新增自定义方案[{}],编码[{}]", userId, plan.getName(), planCode);
+        LogUtil.info(PlanServiceImpl.class, "子用户[{}]新增自定义方案[{}],编码[{}]", profileId, plan.getName(), planCode);
         return plan.getId();
     }
 
     /**
-     * 查询用户的自定义模式方案列表(modeType=3,authorType=2,authorId=userId)
+     * 查询APP账号下所有子用户的自定义模式方案列表(modeType=3,authorType=2)
      * 仅返回方案基本信息,不含穴位步骤;如需步骤请调用 getUserPlanDetail
      *
-     * @param userId 用户ID
+     * @param appUserId APP账号ID(app_user.id)
      * @return 方案列表
      */
     @Override
-    public List<Plan> listUserCustomPlans(Long userId) {
+    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)
-                .eq(Plan::getAuthorId, userId)
+                .in(Plan::getAuthorId, profileIds)
                 .orderByDesc(Plan::getUpdateTime)
                 .list();
     }
 
     /**
-     * 查询用户自定义方案详情(含穴位步骤),校验方案归属
+     * 查询用户自定义方案详情(含穴位步骤),校验方案归属于当前APP账号的子用户
      *
-     * @param planId 方案ID
-     * @param userId 用户ID
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
      * @return 方案详情
      */
     @Override
-    public AppPlanVO getUserPlanDetail(Long planId, Long userId) {
+    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())
-                || !userId.equals(plan.getAuthorId())) {
+                || !isPlanOwnedByAppUser(plan, appUserId)) {
             throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
         }
         AppPlanVO vo = new AppPlanVO();
@@ -566,26 +615,24 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
     }
 
     /**
-     * 用户修改自己的自定义方案(含步骤),仅允许修改自己的自定义模式方案
+     * 用户修改自己的自定义方案(含步骤),校验方案归属于当前APP账号的子用户
      *
-     * @param dto    方案信息
-     * @param userId 用户ID
+     * @param dto       方案信息
+     * @param appUserId APP账号ID(app_user.id)
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void updateUserPlan(PlanSaveDTO dto, Long userId) {
+    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())
-                || !userId.equals(plan.getAuthorId())) {
+                || !isPlanOwnedByAppUser(plan, appUserId)) {
             throw new BusinessException(ResultCode.PLAN_NOT_FOUND);
         }
-        // 修改接口不做名称重名校验:方案 ID 已经存在,用户在已有方案上"任意修改",
-        // 不同用户的自定义方案本就允许同名(方案 ID 不同即可区分)
         // 更新方案基本信息(仅允许修改的可编辑字段)
         if (StringUtils.hasText(dto.getName())) {
             plan.setName(dto.getName());
@@ -604,30 +651,58 @@ public class PlanServiceImpl extends ServiceImpl<PlanMapper, Plan> implements Pl
                 saveSteps(dto.getId(), dto.getSteps());
             }
         }
-        LogUtil.info(PlanServiceImpl.class, "用户[{}]修改自定义方案[{}]", userId, dto.getId());
+        LogUtil.info(PlanServiceImpl.class, "APP账号[{}]修改自定义方案[{}]", appUserId, dto.getId());
     }
 
     /**
-     * 用户删除自己的自定义方案(仅允许删除 modeType=3、authorType=2、authorId=userId 的方案)
+     * 用户删除自己的自定义方案,校验方案归属于当前APP账号的子用户
      *
-     * @param planId 方案ID
-     * @param userId 用户ID
+     * @param planId   方案ID
+     * @param appUserId APP账号ID(app_user.id)
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void deleteUserPlan(Long planId, Long userId) {
+    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())
-                || !userId.equals(plan.getAuthorId())) {
+                || !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, "用户[{}]删除自定义方案[{}]", userId, 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());
     }
 
     /**

+ 2 - 2
code/frontend/src/views/device/index.vue

@@ -694,7 +694,7 @@ async function submitForm() {
     const codes = deviceForm.regionCodes || []
     const payload = {
       id: deviceForm.id,
-      deviceCode: deviceForm.deviceCode,
+      deviceCode: deviceForm.deviceCode.replace(/\s+/g, ''),
       deviceName: deviceForm.deviceName,
       deviceModel: deviceForm.deviceModel,
       deviceType: deviceForm.deviceType || 1,
@@ -761,7 +761,7 @@ async function handleBatchDelete() {
  */
 async function handleImport(data) {
   const list = data.map(row => ({
-    deviceCode: row['设备编号'] || row.deviceCode,
+    deviceCode: (row['设备编号'] || row.deviceCode || '').replace(/\s+/g, ''),
     deviceName: row['设备名称'] || row.deviceName,
     deviceModel: row['设备型号'] || row.deviceModel,
     deviceType: parseDeviceType(row['设备类型'] || row.deviceType),

+ 39 - 5
code/frontend/src/views/plan/index.vue

@@ -294,10 +294,12 @@
             <el-col :span="8">
               <el-form-item :label="'侧别'" :prop="`steps.${index}.side`" :rules="stepRules.side">
                 <el-select v-model="step.side" placeholder="侧别" style="width:100%">
-                  <el-option label="中心" :value="1" />
-                  <el-option label="左侧" :value="2" />
-                  <el-option label="右侧" :value="3" />
-                  <el-option label="双侧" :value="4" />
+                  <el-option
+                    v-for="opt in getSideOptions(step.acupointId)"
+                    :key="opt.value"
+                    :label="opt.label"
+                    :value="opt.value"
+                  />
                 </el-select>
               </el-form-item>
             </el-col>
@@ -656,12 +658,44 @@ const stepRules = {
   techniqueId: [{ required: true, message: '请选择艾灸手法', trigger: 'change' }]
 }
 
+const allSideOptions = [
+  { label: '中心', value: 1 },
+  { label: '左侧', value: 2 },
+  { label: '右侧', value: 3 },
+  { label: '双侧', value: 4 }
+]
+
+/**
+ * 根据穴位的单/双穴属性返回可选的侧别选项
+ * - 单穴:只能选"中心"
+ * - 双穴:不能选"中心"(左侧/右侧/双侧)
+ */
+function getSideOptions(acupointId) {
+  if (!acupointId) return allSideOptions
+  const found = acupointOptions.value.find(a => a.id === acupointId)
+  if (!found || !found.singleDouble) return allSideOptions
+  if (found.singleDouble.includes('单')) {
+    return allSideOptions.filter(opt => opt.value === 1)
+  }
+  if (found.singleDouble.includes('双')) {
+    return allSideOptions.filter(opt => opt.value !== 1)
+  }
+  return allSideOptions
+}
+
 /**
- * 穴位选择变更:同步填充 acupointName
+ * 穴位选择变更:同步填充 acupointName,并根据单/双穴自动调整侧别
  */
 function onAcupointChange(id, index) {
   const found = acupointOptions.value.find(a => a.id === id)
   formData.steps[index].acupointName = found ? found.name : ''
+  if (found && found.singleDouble) {
+    if (found.singleDouble.includes('单') && formData.steps[index].side !== 1) {
+      formData.steps[index].side = 1
+    } else if (found.singleDouble.includes('双') && formData.steps[index].side === 1) {
+      formData.steps[index].side = 4
+    }
+  }
 }
 
 /**