Procházet zdrojové kódy

Merge branch 'jiapu_aijiuyi' into dev

liyuliangjiazai před 2 měsíci
rodič
revize
75d347aed9

+ 1 - 0
code/ajyApp/pages/device/acupointEdit/acupointEdit.vue

@@ -495,6 +495,7 @@ export default {
 				const steps = this.buildSteps()
 
 				const planData = {
+					profileId: this.profileId,
 					name: this.planName.trim(),
 					status: 1,
 					steps: steps.map(point => ({

+ 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
+    }
+  }
 }
 
 /**