Przeglądaj źródła

merge: 同步 cfclub 主线(CI auto-bump + 接口冒烟报告 + SKU/定价 docs)

Agnes Agent 1 tydzień temu
rodzic
commit
39f0242804

+ 1 - 1
INTERFACE_TEST_REPORT.md

@@ -1,4 +1,4 @@
-# CFC 接口冒烟测试报告 (20261031_091137)
+# CFC 接口冒烟测试报告 (20261031_104311)
 
 
 - 目标: cfc.iwintrue.com  用例总数: **549**
 - 目标: cfc.iwintrue.com  用例总数: **549**
 - 业务成功率(BIZ_OK): **244/549 = 44%**
 - 业务成功率(BIZ_OK): **244/549 = 44%**

+ 47 - 13
cfc-backend/src/main/java/com/etotem/cfc/dto/DanAssessmentResultDTO.java

@@ -9,21 +9,55 @@ public class DanAssessmentResultDTO {
     private Long memberId;
     private Long memberId;
     private Long teacherId;
     private Long teacherId;
     private Long appointmentId;
     private Long appointmentId;
-    private Integer attentionScore;
-    private Integer focusScore;
-    private Integer memoryScore;
-    private Integer logicScore;
-    private Integer overallScore;
+
+    // === 认知六维(COG,百分位 0-100) ===
+    private Integer attentionScore;        // 注意力得分
+    private Integer focusScore;            // 专注力得分
+    private Integer memoryScore;           // 记忆力得分
+    private Integer logicScore;            // 逻辑思维得分
+    private Integer perceptionScore;       // 感知觉得分
+    private Integer spatialScore;          // 空间思维得分
+    private Integer processingSpeedScore;  // 加工速度得分
+    private Integer languageScore;         // 语言表达得分
+
+    // === EMI 心理子维度(0-100) ===
+    private Integer emotionScore;          // 情绪商数
+    private Integer resilienceScore;       // 心理韧性
+    private Integer stressCopingScore;     // 压力应对
+    private Integer selfAwarenessScore;    // 自我认知
+
+    // === 大五人格维度(Big Five / OCEAN,0-100) ===
+    private Integer opennessScore;         // 开放性
+    private Integer conscientiousnessScore; // 尽责性
+    private Integer extraversionScore;     // 外向性
+    private Integer agreeablenessScore;    // 宜人性
+    private Integer neuroticismScore;      // 神经质
+    private Integer bigFiveOverallScore;   // 大五人格综合分
+
+    // === EMI 情绪商数评估子维度(0-100) ===
+    private Integer emotionManagementScore;  // 情绪管理
+    private Integer empathyScore;            // 同理心
+    private Integer socialAdaptabilityScore; // 社会适应
+    private Integer selfMotivationScore;     // 自我激励
+
+    private Integer overallScore;          // 综合得分
+
     private Date assessmentDate;
     private Date assessmentDate;
-    private Integer durationMinutes;
-    private String gameRecords;
-    private String analysisReport;
-    private String growthSuggestions;
-    private Date nextReviewDate;
-    private String status;
+    private Integer durationMinutes;       // 测评时长
+    private String gameRecords;            // 游戏记录详情(JSON)
+    private String analysisReport;         // 分析报告
+    private String growthSuggestions;      // 成长建议
+    private Date nextReviewDate;           // 下次复测日期
+    private String status;                 // draft/completed/archived
+
+    // === 报告外壳字段(与 DanReportUpload 对齐,供 8 类报告页复用) ===
+    private String dimension;              // mind(心-A2)/wisdom(智-B4)/cognition(A1)/learning(B3)/behavior(B2)/career(B6)/campus(C1)/adolescent(B5)
+    private String danLevel;               // DAN 等级 A/B/C/D/U
+    private String structuredAnalysis;     // 结构化分析JSON(summary/dimensions/strengths/concerns/suggestions)
+
     private Date createdAt;
     private Date createdAt;
-    
-    // 关联信息
+
+    // === 关联信息 ===
     private String childName;
     private String childName;
     private String childNickname;
     private String childNickname;
     private String teacherName;
     private String teacherName;

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TeacherService.java

@@ -292,6 +292,28 @@ public class TeacherService {
         dto.setFocusScore(result.getFocusScore());
         dto.setFocusScore(result.getFocusScore());
         dto.setMemoryScore(result.getMemoryScore());
         dto.setMemoryScore(result.getMemoryScore());
         dto.setLogicScore(result.getLogicScore());
         dto.setLogicScore(result.getLogicScore());
+        // 认知六维补全(COG)
+        dto.setPerceptionScore(result.getPerceptionScore());
+        dto.setSpatialScore(result.getSpatialScore());
+        dto.setProcessingSpeedScore(result.getProcessingSpeedScore());
+        dto.setLanguageScore(result.getLanguageScore());
+        // EMI 心理子维度
+        dto.setEmotionScore(result.getEmotionScore());
+        dto.setResilienceScore(result.getResilienceScore());
+        dto.setStressCopingScore(result.getStressCopingScore());
+        dto.setSelfAwarenessScore(result.getSelfAwarenessScore());
+        // 大五人格维度(OCEAN)
+        dto.setOpennessScore(result.getOpennessScore());
+        dto.setConscientiousnessScore(result.getConscientiousnessScore());
+        dto.setExtraversionScore(result.getExtraversionScore());
+        dto.setAgreeablenessScore(result.getAgreeablenessScore());
+        dto.setNeuroticismScore(result.getNeuroticismScore());
+        dto.setBigFiveOverallScore(result.getBigFiveOverallScore());
+        // EMI 情绪商数子维度
+        dto.setEmotionManagementScore(result.getEmotionManagementScore());
+        dto.setEmpathyScore(result.getEmpathyScore());
+        dto.setSocialAdaptabilityScore(result.getSocialAdaptabilityScore());
+        dto.setSelfMotivationScore(result.getSelfMotivationScore());
         dto.setOverallScore(result.getOverallScore());
         dto.setOverallScore(result.getOverallScore());
         dto.setAssessmentDate(result.getAssessmentDate());
         dto.setAssessmentDate(result.getAssessmentDate());
         dto.setDurationMinutes(result.getDurationMinutes());
         dto.setDurationMinutes(result.getDurationMinutes());
@@ -300,6 +322,10 @@ public class TeacherService {
         dto.setGrowthSuggestions(result.getGrowthSuggestions());
         dto.setGrowthSuggestions(result.getGrowthSuggestions());
         dto.setNextReviewDate(result.getNextReviewDate());
         dto.setNextReviewDate(result.getNextReviewDate());
         dto.setStatus(result.getStatus());
         dto.setStatus(result.getStatus());
+        // 报告外壳字段(对齐 DanReportUpload,供 8 类报告页复用)
+        dto.setDimension(result.getDimension());
+        dto.setDanLevel(result.getDanLevel());
+        dto.setStructuredAnalysis(result.getStructuredAnalysis());
         dto.setCreatedAt(result.getCreatedAt());
         dto.setCreatedAt(result.getCreatedAt());
         
         
         // 补充关联信息
         // 补充关联信息

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-f8f8bb8614b038ac6ccf76dd165a6a4f4bd0d01d
+906e6b08745dbf4507de5f8c6151550d4d9f627f

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1874",
+  "version": "1.0.1878",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1874",
+      "version": "1.0.1878",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1875",
+  "version": "1.0.1879",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

+ 38 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,44 @@
 
 
 ---
 ---
 
 
+## v1.0.1879 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 10:48 — 通过率 44% (fix=0 reg=0)
+
+
+## v1.0.1878 (2026-10-31)
+
+### 文档
+- SKU商品组合设计文档与实施计划
+- 订单套餐自动定价实施计划
+- 订单套餐自动定价设计 v2(吸收 builder check)
+- 订单套餐自动定价设计(product bundle auto-pricing)
+
+### 其他
+- Task4 MemberPriceResolver 共享口径;Task5 购物车 bundlePromo;Task6 后端权威重算;
+- Task7 前端展示;Task8 部署验证。
+- 
+- - 节省额按下单结算口径(会员调整价/SKU 价)计算
+- - 明确移除一口价范围:仅 computeGiftPromo;保留送会员权益读取
+- - 匹配身份对齐 linked-SKU 解析;不随迁移发布套餐种子数据
+- 
+- 凑整+单品兜底、多套餐选省最多、废弃一口价。
+- 
+
+
+## v1.0.1877 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 09:35 — 通过率 44% (fix=0 reg=0)
+
+
+## v1.0.1876 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 09:15 — 通过率 44% (fix=0 reg=0)
+
+
 ## v1.0.1875 (2026-10-31)
 ## v1.0.1875 (2026-10-31)
 
 
 ### 测试
 ### 测试

+ 39 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1875
+> 当前版本: v1.0.1879
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,44 @@
 
 
 ---
 ---
 
 
+## v1.0.1879 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 10:48 — 通过率 44% (fix=0 reg=0)
+
+
+## v1.0.1878 (2026-10-31)
+
+### 文档
+- SKU商品组合设计文档与实施计划
+- 订单套餐自动定价实施计划
+- 订单套餐自动定价设计 v2(吸收 builder check)
+- 订单套餐自动定价设计(product bundle auto-pricing)
+
+### 其他
+- Task4 MemberPriceResolver 共享口径;Task5 购物车 bundlePromo;Task6 后端权威重算;
+- Task7 前端展示;Task8 部署验证。
+- 
+- - 节省额按下单结算口径(会员调整价/SKU 价)计算
+- - 明确移除一口价范围:仅 computeGiftPromo;保留送会员权益读取
+- - 匹配身份对齐 linked-SKU 解析;不随迁移发布套餐种子数据
+- 
+- 凑整+单品兜底、多套餐选省最多、废弃一口价。
+- 
+
+
+## v1.0.1877 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 09:35 — 通过率 44% (fix=0 reg=0)
+
+
+## v1.0.1876 (2026-10-31)
+
+### 测试
+- 接口冒烟闭环 2026-10-31 09:15 — 通过率 44% (fix=0 reg=0)
+
+
 ## v1.0.1875 (2026-10-31)
 ## v1.0.1875 (2026-10-31)
 
 
 ### 测试
 ### 测试

+ 374 - 0
docs/design/DAN报告显示页面设计.md

@@ -0,0 +1,374 @@
+# DAN 报告显示页面设计
+
+> 配套可交互原型:`docs/design/dan-report-pages.html`(左侧切换 8 类报告预览)
+
+---
+
+## 0. 结论先行
+
+### 0.1 口径纠正(易错点)
+
+文档里的「DAN 四大评估维度」与代码里的「报告类别」是**两套口径**,设计时不可混淆:
+
+| 口径 | 内容 | 用途 |
+|---|---|---|
+| 文档四大维度 | COG 认知 / EMI 情绪心理 / SOC 社会关系 / PHY 身体健康 | **能量沙盘积分**口径,不是报告类别 |
+| 代码报告类别 | `mind`/`wisdom`/`cognition`/`learning`/`behavior`/`career`/`campus`/`adolescent` | **本报告设计的对象**,共 8 类 |
+
+依据:`docs/需求分析/健康能量设计.md:737-744`(四大维度)vs `DanReportUpload.java:79-86`(8 个 `DIM_*` 常量)、`DanReportParseService.java:67-83`(8 路分发)。
+
+### 0.2 现状问题
+
+当前**只有 1 个通用页** `pages/dan-assessment/report-result.vue`(859 行),靠 `genericBlocks` + `resolveMax()` 量纲推断兜底渲染全部 8 类。专项渲染只覆盖 4 个区块(认知/大五/情绪/体质),其余 21 个 category 全部退化为扁平指标条 —— 8 类报告同质化严重,专业测评的差异化价值没有表达出来。
+
+### 0.3 本方案
+
+**统一外壳 + 每类专属区块序列**:
+
+- **外壳统一**:Hero(姓名 / 维度徽章 / 等级徽章 / 日期 / 综合分 + 百分位 / 百分位进度条)+ 卡片流 + 免责脚注 —— 保证品牌一致性
+- **区块按需**:8 类各自拥有固定的 category 序列(`ReportBlockAssembler.DAN_CATEGORY_TITLES` 决定顺序),每类按数据形态选配最合适的渲染器
+
+---
+
+## 1. 八类报告清单
+
+| # | dimension | reportType | 报告名 | 解析器 | API |
+|---|---|---|---|---|---|
+| 1 | `mind` | **A2** | 心维度(核心素养评估) | `parseA2Report` `:89` | `/api/dan-report/list` `dimension=mind` |
+| 2 | `wisdom` | **B4** | 智维度 | `parseB4Report` `:211` | 同上 |
+| 3 | `cognition` | **A1** | 儿童核心认知 | `parseA1Report` `:305` | 同上 |
+| 4 | `learning` | **B3** | 核心学习能力 | `parseB3Report` `:442` | 同上 |
+| 5 | `behavior` | **B2** | 儿童自我与家庭教养 | `parseB2Report` `:541` | 同上 |
+| 6 | `career` | **B6** | 职业发展 | `parseB6Report` `:660` | 同上 |
+| 7 | `campus` | **C1** | 校园标准版 | `parseC1Report` `:793` | 同上 |
+| 8 | `adolescent` | **B5** | 青春期挑战 | `parseB5Report` `:918` | 同上 |
+
+统一取数:`POST /api/dan-report/{id}` → `DanReportUploadVO`(含 `blocks` + `result`)。
+对比形态已存在:`POST /api/dan-report/compare` + `pages/dan-assessment/comparison.vue`。
+
+---
+
+## 2. 统一外壳(Report Shell)
+
+### 2.1 Hero
+
+```
+┌─────────────────────────────────────┐
+│ 陈乐乐                               │  姓名
+│ [心维度] [女 · 9岁] [等级 B] [日期]   │  徽章行
+│                                     │
+│  78分              72%              │  综合分 + 百分位(双大数字)
+│  综合得分          百分位            │
+│  ▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░               │  百分位进度条
+│  超过 72% 的同龄人                   │
+└─────────────────────────────────────┘
+```
+
+- 背景:`linear-gradient(160deg, #4C3F91, #6B5BD2 55%, #8B7BE8)`(沿用现有紫罗兰主题)
+- 等级徽章 `danLevel`(A/B/C/D/U)用暖色强调 `.bd-strong`
+- 装饰圆:`rgba(255,255,255,.06)` 双圆定位,不干扰文字
+
+### 2.2 卡片流与脚注
+
+- 卡片:白底 + `radius 16px` + 极浅阴影,`margin 11px 12px 0`
+- 卡片头:左侧标题(紫罗兰 `#5B4BA8`)+ 右侧 note(量纲/口径说明,灰)
+- 脚注:「本报告由 DAN 测评系统生成,结果仅供成长参考」
+
+---
+
+## 3. 区块渲染器注册表
+
+共 10 个渲染器,按 category 数据形态选配:
+
+| 渲染器 | 适用 category | 数据形态 | 视觉 |
+|---|---|---|---|
+| `radar` | cognitive / bigFive / selfConcept / selfDriving / executiveFunction / hollandInterest / socialAbility / multipleIntelligence | ≥3 个同质维度 | 雷达图 + 下方指标条明细 |
+| `bars` | 单组同质维度(<3 维或不宜雷达) | 一维数值 | 指标条 + 参考线 + 需关注徽章 |
+| `chips` | emotion | 0-15 分测验 | 紧凑条 + 分数 |
+| `grid` | body / 基本信息 | 带单位数值 | 三列数值格 + 习惯文本行 |
+| `matrix` | social(社会关系依恋) | 二维矩阵 | 行×列 + 迷你条 |
+| `duration` | sleep / exercise / internet | 时长类 | 时长卡 + 达标状态徽章 |
+| `compass` | compass(人际指南针) | 象限距离 | 四象限圆点图 |
+| `two` | 优势/待提升 | 两组文本 | 双色对照卡 |
+| `rec` | 职业方向推荐 | 标签集 | chip 组 |
+| `text` | 评语 / 成长建议 | 长文本 | >160 字折叠 + 遮罩渐变 |
+
+---
+
+## 4. 量纲映射表(**最大的设计陷阱**)
+
+不同 category 的量纲完全不同,直接统一按 0-100 渲染必然出错。现有 `report-result.vue:539-546` 的 `resolveMax()` 已实现推断,**新页面必须复用或显式声明**:
+
+| category | 量纲 | 参考线 | 阈值(需关注) |
+|---|---|---|---|
+| `overall` / `cognitive` | 百分位 0-100 | P50 | < 50 |
+| `bigFive` | 1-5 | — | ≤ 2.5(低)/ ≥ 4(高亮优势) |
+| `multipleIntelligence` | 1-15 | — | < 8 |
+| `learningMotivation` / `learningStrategy` | 0-10 | — | < 5 |
+| `selfConcept` / `behavior` / `family` / `selfDriving` / `socialAbility` | 0-10(或 1-5) | — | < 5 |
+| `emotion`(A2 分测验) | 0-15 | — | ≥ 6(反向:越高越需关注) |
+| `emotionRegulation` | 1-7 | — | 表达抑制偏高需关注 |
+| `academicStress` / `interpersonal` | 1-5 | — | ≥ 4(压力源反向) |
+| `sleep` / `exercise` / `internet` | 时长 | — | 年龄作息标准 |
+
+> **设计约定**:量纲必须在卡片右侧 note 里显式写出(如「1-5 分制」「0-15 分制,分数越高越需关注」),避免家长误读反向计分项。
+
+---
+
+## 5. 八类页面模板
+
+### 5.1 A2 · 心维度(mind)
+
+`overall` → `cognitive` → `bigFive` → `emotion` → `social` → `body` → `ability` → `text`
+
+```
+Hero(综合分 78 / 百分位 72 / 等级 B)
+认知能力     radar  [感知觉 82, 记忆力 74, 注意力 68]      百分位
+大五人格     radar  [开放性 4.2, 尽责性 3.8, 外向性 3.1, 宜人性 4.5, 神经质 2.4]  1-5
+情绪状态     chips  [自卑感 4, 抑郁 3, 焦虑 6, 无力感 3]    0-15
+社会关系     matrix 母亲/父亲/同伴 × 信任/沟通/亲近
+体质健康     grid   身高 132cm | 体重 28kg | BMI 16.1
+             habit 饮食 规律 / 睡眠 约9小时 / 运动 每周3次
+综合能力     bars   [语言表达 8.1, 逻辑思维 7.4, 创造力 7.8]
+成长建议     text   折叠
+```
+
+**设计要点**:A2 是唯一含「社会关系依恋矩阵」的报告,matrix 是为其专属设计的渲染器。情绪分测验为**反向计分**,需在 note 明示。
+
+### 5.2 B4 · 智维度(wisdom)
+
+`cognitive` → `selfConcept` → `growthMindset` → `selfDriving` → `text`
+
+```
+Hero
+认知能力      radar  [推理能力 88, 空间能力 76, 加工速度 71]
+自我概念      bars   [学业自我 7.6, 身体自我 7.1, 社会自我 8.0, 情绪自我 6.8]
+成长型思维    bars   [努力信念 8.2, 挑战接纳 7.3, 失败归因 6.4]
+自驱力        bars   [自主性 7.8, 胜任感 7.2, 坚持性 6.6]
+成长建议      text   折叠
+```
+
+### 5.3 A1 · 儿童核心认知(cognition)
+
+`overall` → `cognitive`(六维)→ `two` → `text`
+
+```
+Hero
+认知能力      radar  六维百分位(感知觉/注意力/记忆力/推理/空间/加工速度)
+认知能力      bars   六维明细 + P50 标记线
+优势与待提升  two    优势:推理88 感知觉85 记忆力80
+                    待提升:加工速度69 注意力72
+成长建议      text   折叠
+```
+
+**设计要点**:A1 是**纯认知报告**,六维雷达是页面视觉主体。六维明细条必须带 **P50 同龄中位标记线**(复用 `cognitive-report.vue` 的 `.norm-marker` 做法),否则家长无法判断高低。
+
+### 5.4 B3 · 核心学习能力(learning)
+
+`executiveFunction` → `learningMotivation` → `learningStrategy` → `text`
+
+```
+Hero
+执行功能      radar  [抑制控制 68, 工作记忆 74, 认知灵活性 71]  百分位
+学习动机      bars   [深层动机 7.2, 表面动机 5.4, 自我效能感 6.8]  十分制
+学习策略      bars   [深层方法 6.1, 表面方法 5.8, 自我调节 5.2]  十分制
+成长建议      text   折叠
+```
+
+**设计要点**:B3 的动机/策略是**十分制**(不是百分位),note 必须标注。深层 vs 表面动机成对出现,条相邻排布便于对比。
+
+### 5.5 B2 · 儿童自我与家庭教养(behavior)
+
+`selfConcept` → `behavior` → `family` → `text`
+
+```
+Hero
+自我概念      radar  六维(行为表现/能力与学校/躯体外貌/情绪状态/合群/幸福与满足)
+行为表现      bars   八维(品行/情绪/学习/社交/生活习惯/多动倾向/刻板行为/拖延行为)
+家庭环境      bars   十维(亲密/情感表达/矛盾/独立/成就/文化/娱乐/道德宗教/组织/控制)
+教养建议      text   折叠
+```
+
+**设计要点**:B2 的 `behavior` 中「多动倾向/刻板行为/拖延行为」为**反向计分**(分数越低越好),note 必须明示,否则家长会把低分误读为问题。8 维与 10 维项目多,用 bars 而非 radar 保证可读性。
+
+### 5.6 B6 · 职业发展(career)
+
+`hollandInterest` → `multipleIntelligence` → `careerValue` → `rec` → `text`
+
+```
+Hero
+职业兴趣      radar  Holland 六型(艺术/社会/企业/常规/现实/研究)  百分位
+多元智能      bars   八维 1-15 分制(内省12 空间10 音乐8 人际13 自然9 运动7 语言11 逻辑12)
+职业价值观    bars   [成就感 8.4, 助人 8.8, 独立性 7.2, 创造性 8.1]
+适配方向      rec    [教育/培训★] [心理咨询★] [内容创作] [科研] [公共关系]
+成长建议      text   折叠
+```
+
+**设计要点**:B6 是唯一产出**行动建议标签**的报告,`rec` 渲染器为其专属。Holland 六边形是职业测评的经典表达,用 radar 承载。多元智能 **1-15 分制**量纲特殊,必须显式声明。
+
+### 5.7 C1 · 校园标准版(campus)
+
+`cognitive` → `bigFive` → `selfDriving` → `selfConcept` → `text`
+
+```
+Hero
+认知能力      radar  六维百分位
+大五人格      radar  五维 1-5(开放性/宜人性/责任心/外倾性/神经质)
+自驱力        bars   三维(自主性 6.8 / 胜任感 7.2 / 归属感 7.6)
+自我概念      radar  六维 十分制
+校园适应建议  text   折叠
+```
+
+**设计要点**:C1 是 A1 + B2 + B4 的**校园场景组合包**,含 2 个雷达图,页面偏长,卡片顺序严格按 `DAN_CATEGORY_TITLES` 排列。
+
+### 5.8 B5 · 青春期挑战(adolescent)
+
+`emotionRegulation` → `academicStress` → `interpersonal` → `socialAbility` → 生活节律 → `compass` → `text`
+
+```
+Hero
+情绪调节      bars   [认知重评 5.2, 表达抑制 4.6]   1-7
+学业压力      bars   五源 1-5(学业负担3.4 家庭期望3.8 师生2.6 自我期望4.1 同伴竞争3.2)
+人际关系      bars   四维 1-5(母亲4.2 父亲3.9 师生3.4 同伴4.0)
+社交能力      radar  五维 1-5
+生活节律      duration [睡眠 7.2h 偏少] [运动 45min 达标] [网络 3.5h 偏多]
+人际指南针    compass  四象限(母亲/父亲/同伴/师生,距圆心越近关系越紧密)
+成长建议      text   折叠
+```
+
+**设计要点**:B5 是最特殊的一类 —— 含**唯一的三张时长卡**与**唯一的人际指南针**。`duration` 与 `compass` 是为其专属设计的渲染器。压力源、表达抑制为反向/需关注指标。
+
+---
+
+## 6. 落地指引
+
+### 6.1 目录与组件
+
+```
+cfc-frontend/
+├── components/dan-report/              # 新增:DAN 报告专用组件
+│   ├── DanReportHero.vue               # 统一 Hero(提取自现有 report-result.vue)
+│   ├── DanBlockRadar.vue               # radar 渲染器
+│   ├── DanBlockBars.vue                # bars 渲染器
+│   ├── DanBlockChips.vue               # chips 渲染器
+│   ├── DanBlockGrid.vue                # grid 渲染器
+│   ├── DanBlockMatrix.vue              # matrix 渲染器(A2 专属)
+│   ├── DanBlockDuration.vue            # duration 渲染器(B5 专属)
+│   ├── DanBlockCompass.vue             # compass 渲染器(B5 专属)
+│   └── danBlockRegistry.js             # category → 渲染器映射表
+├── pages/dan-assessment/
+│   ├── report-result.vue               # 保留:作为 8 类统一入口(按 dimension 分派)
+│   └── comparison.vue                  # 保留:对比报告
+```
+
+### 6.2 category → 渲染器映射表(`danBlockRegistry.js`)
+
+```js
+export const DAN_BLOCK_RENDERERS = {
+  overall:          'bars',
+  cognitive:        'radar',
+  bigFive:          'radar',
+  selfConcept:      'radar',
+  selfDriving:      'bars',
+  executiveFunction:'radar',
+  hollandInterest:  'radar',
+  multipleIntelligence:'bars',
+  socialAbility:    'radar',
+  learningMotivation:'bars',
+  learningStrategy: 'bars',
+  growthMindset:    'bars',
+  behavior:         'bars',
+  family:           'bars',
+  careerValue:      'bars',
+  emotion:          'chips',
+  emotionRegulation:'bars',
+  academicStress:   'bars',
+  interpersonal:    'bars',
+  social:           'matrix',
+  body:             'grid',
+  ability:          'bars',
+  sleep:            'duration',
+  exercise:         'duration',
+  internet:         'duration',
+  compass:          'compass'
+};
+
+// 量纲表(复用 resolveMax 的推断逻辑,显式化)
+export const DAN_CATEGORY_SCALE = {
+  overall:{max:100,ref:50}, cognitive:{max:100,ref:50},
+  bigFive:{max:5}, multipleIntelligence:{max:15},
+  learningMotivation:{max:10}, learningStrategy:{max:10},
+  selfConcept:{max:10}, behavior:{max:10}, family:{max:10},
+  selfDriving:{max:10}, socialAbility:{max:5},
+  emotion:{max:15,reverse:true},
+  emotionRegulation:{max:7}, academicStress:{max:5,reverse:true},
+  interpersonal:{max:5}
+};
+```
+
+### 6.3 API 映射
+
+| 用途 | API | 说明 |
+|---|---|---|
+| 报告详情 | `POST /api/dan-report/{id}` | 返回 `blocks`(已按 category 分组)+ `result` |
+| 成员报告列表 | `POST /api/dan-report/list` `{memberId, dimension}` | 8 类分别过滤 |
+| 成员某维度 | `POST /api/dan-assessment/report/child` `{memberId, dimension}` | 仅 `draftStatus=confirmed` |
+| 结构化分析 | `POST /api/dan-assessment/results/{id}/structured` | `{structuredAnalysis}` |
+| 两版对比 | `POST /api/dan-report/compare` `{uploadIdA, uploadIdB}` | 复用 `comparison.vue` |
+| 认知趋势 | `POST /api/cognitive/trend` | 目前仅认知维度有 |
+
+> 页面传参沿用现有约定:`/pages/dan-assessment/report-result?reportId=` 。
+
+### 6.4 小程序技术约束(必守)
+
+| 约束 | 做法 |
+|---|---|
+| 禁可选链 `?.` | 用 `a && a.b` |
+| 禁 CSS Grid | 用 flexbox(`.grid` 用 `flex-wrap` + `flex:0 0 calc(33.333% - 6px)`) |
+| 禁 `:key` 表达式 | 用方法调用 `getItemKey(item)` |
+| 禁 `new Date(string)` | 用 `utils/format.js` 的 `parseDate()` |
+| Vue 2 Options API | 禁 Composition API |
+| 禁 `:class` 内带参方法调用 | 用内联表达式 / 计算属性 |
+| 禁 `uni.getSystemInfoSync()` | 用 `uni.getWindowInfo()` |
+| 新页面须注册 `pages.json` | 现有两页已注册(root `pages/dan-assessment`) |
+| 同 URL 首次加载只允许一次请求 | 沿用 `_onLoadFired` 模式 |
+| 时间格式 | `yyyy-MM-dd`,禁 `toLocale*String` |
+
+图表沿用自研 canvas 组件 `components/RadarChart.vue` / `TrendChart.vue`(项目**无** echarts / uCharts)。
+
+### 6.5 提交前自检
+
+```bash
+node scripts/verify-vue-compile.js
+node scripts/audit-mp-antipatterns.js     # ERROR 归零
+node scripts/audit-duplicate-api-calls.js
+```
+
+---
+
+## 7. 与现有实现的关系
+
+| 项 | 现状 | 本方案 |
+|---|---|---|
+| 页面数 | 1 个通用页兜底 8 类 | 保留 1 个入口页 + 10 个区块组件,按 dimension 分派渲染器 |
+| 专项渲染 | 仅 4 个区块(认知/大五/情绪/体质) | 全部 25 个 category 均有对应渲染器 |
+| 量纲 | `resolveMax()` 运行时推断 | 显式 `DAN_CATEGORY_SCALE` 表,可维护可校验 |
+| 视觉差异 | 8 类几乎同质 | 每类有专属主体(A1 六维雷达 / B6 Holland 六边形 / B5 时长卡+指南针 / A2 关系矩阵) |
+
+**建议改造路径**(非破坏式):
+
+1. 先抽取 `DanReportHero.vue` + 10 个区块组件 + 注册表
+2. `report-result.vue` 改为「按 `dimension` 查注册表 → 分派区块组件」,保留 `genericBlocks` 作为兜底
+3. 数据缺失/解析失败时,区块自动降级为 `bars` 通用条,不白屏
+4. 逐类验证(建议顺序:A1 → A2 → B5 → B6,数据形态最典型)
+
+---
+
+## 8. 遗留待确认项
+
+| 项 | 状态 |
+|---|---|
+| `DanAssessmentResultDTO` 为残缺 DTO(仅 attention/focus/memory/logic/overall,缺 perception/spatial/EMI) | 新页面若走该 DTO 需先补齐,或直接用实体 `DanAssessmentResult` |
+| B5 的 `compass`(人际指南针)为图像解析项 | 需确认后端是否返回可渲染的坐标数据,否则降级为文本 |
+| 对比/趋势报告目前仅认知维度有趋势数据 | 其余 7 类的趋势能力需后端补齐后才可设计趋势页 |
+| 家长端无 DAN 数据(文档 `:812` 明确家长维度走任务完成率 + 先天属性分) | 报告页仅面向成员(孩子),不做家长版本 |

+ 125 - 0
docs/design/DAN报告页面开发计划.md

@@ -0,0 +1,125 @@
+# DAN 所有类别报告显示页面 · 开发计划
+
+> 设计依据:`docs/design/DAN报告显示页面设计.md`(含 8 类交互原型 `dan-report-pages.html`)
+> 代码依据:`DanReportUpload.java`(8 个 `DIM_*` 常量)、`DanReportParseService.java`(8 路分发)、`DanReportUploadVO.blocks`、`DanAssessmentResult` / `DanAssessmentResultDTO`
+> 关联分支:`cfclub` 关联需求:DAN 8 类报告显示页面
+
+---
+
+## 0. 已完成前置(本次提交)
+
+| 项 | 状态 | 说明 |
+|---|---|---|
+| DTO 补齐 | ✅ 已完成 | `DanAssessmentResultDTO` 原仅含 `attention/focus/memory/logic/overall`,已补齐:认知六维(`perception/spatial/processingSpeed/language`)、EMI 心理四维、大五 OCEAN 五维 + 综合、EMI 情绪商数四维,及报告外壳字段 `dimension/danLevel/structuredAnalysis`;`TeacherService.toAssessmentResultDTO` 同步补全赋值 |
+| 交互原型 | ✅ 已完成 | `docs/design/dan-report-pages.html`(左侧切换 8 类报告预览) |
+| 设计规范 | ✅ 已完成 | `docs/design/DAN报告显示页面设计.md`(8 类清单、区块渲染器注册表、量纲映射表、技术约束) |
+
+---
+
+## 1. 目标与范围
+
+- **目标**:将当前 1 个通用兜底页 `pages/dan-assessment/report-result.vue` 升级为「统一外壳 + 每类专属区块序列」,使 8 类 DAN 报告差异化呈现,专业测评价值可表达。
+- **范围(8 类报告,共 25+ category)**:
+
+  | dimension | reportType | 报告名 | 区块序列(来自设计 §5) |
+  |---|---|---|---|
+  | `mind` | A2 | 心维度 | overall→cognitive→bigFive→emotion→social→body→ability→text |
+  | `wisdom` | B4 | 智维度 | cognitive→selfConcept→growthMindset→selfDriving→text |
+  | `cognition` | A1 | 儿童核心认知 | overall→cognitive(六维)→two→text |
+  | `learning` | B3 | 核心学习能力 | executiveFunction→learningMotivation→learningStrategy→text |
+  | `behavior` | B2 | 儿童自我与家庭教养 | selfConcept→behavior→family→text |
+  | `career` | B6 | 职业发展 | hollandInterest→multipleIntelligence→careerValue→rec→text |
+  | `campus` | C1 | 校园标准版 | cognitive→bigFive→selfDriving→selfConcept→text |
+  | `adolescent` | B5 | 青春期挑战 | emotionRegulation→academicStress→interpersonal→socialAbility→duration→compass→text |
+
+- **非范围**:家长端报告(文档明确家长维度走任务完成率 + 先天属性分,不作报告页);其余 7 类趋势页(目前仅认知维度有趋势数据,待后端补齐)。
+
+---
+
+## 2. 阶段划分(里程碑)
+
+### P0 · 基础设施(约 2 天)
+1. **抽取统一外壳** `components/dan-report/DanReportHero.vue`
+   - 输入:`result`(含 `dimension`、`danLevel`、`overallScore`、百分位、`assessmentDate`、成员快照姓名/性别/年龄)
+   - 产出:Hero(姓名 / 维度徽章 / 等级徽章 / 日期 / 综合分 + 百分位 + 进度条)+ 免责脚注
+   - 迁移自现有 `report-result.vue` 的 Hero 逻辑,紫罗兰主题 `linear-gradient(160deg,#4C3F91,#6B5BD2 55%,#8B7BE8)`
+2. **区块渲染器注册表** `components/dan-report/danBlockRegistry.js`
+   - 落地设计 §6.2 的 `DAN_BLOCK_RENDERERS` + `DAN_CATEGORY_SCALE`(量纲表)
+3. **通用区块组件**(先实现 `bars`/`radar`/`text`/`grid` 四个,其余在 P1)
+   - `DanBlockBars.vue`:指标条 + 参考线 + 需关注徽章(复用 `resolveMax()` 量纲推断,并显式标注 note)
+   - `DanBlockRadar.vue`:自研 `RadarChart.vue`(项目无 echarts/uCharts)承载 ≥3 维雷达
+   - `DanBlockText.vue`:>160 字折叠 + 遮罩渐变
+   - `DanBlockGrid.vue`:三列数值格 + 习惯文本行(`flex-wrap` 实现,禁 CSS Grid)
+
+### P1 · 专属渲染器(约 2.5 天)
+4. `DanBlockChips.vue`(emotion 0-15 分测验,反向计分 note)
+5. `DanBlockMatrix.vue`(A2 社会关系依恋:母亲/父亲/同伴 × 信任/沟通/亲近)
+6. `DanBlockDuration.vue`(B5 睡眠/运动/网络时长卡 + 达标徽章)
+7. `DanBlockCompass.vue`(B5 人际指南针四象限;若后端无坐标,降级为 text)
+8. `DanBlockTwo.vue`(优势/待提升双色对照卡)
+9. `DanBlockRec.vue`(B6 职业方向推荐标签)
+
+### P2 · 入口装配与降级(约 1.5 天)
+10. 改造 `pages/dan-assessment/report-result.vue`:
+    - 保留为 8 类统一入口,按 `dimension` 查 `DAN_BLOCK_RENDERERS` 分派区块组件
+    - `blocks` 顺序严格按 `DAN_CATEGORY_TITLES` 排列
+    - 数据缺失/解析失败时,区块自动降级为 `bars` 通用条,**不白屏**
+11. 复用现有 API:`POST /api/dan-report/{id}`(`blocks` + `result`);沿用 `_onLoadFired` 单次加载模式
+
+### P3 · 验证与上线(约 1.5 天)
+12. 提交前自检(设计 §6.5):
+    - `node scripts/verify-vue-compile.js`
+    - `node scripts/audit-mp-antipatterns.js`(ERROR 归零:禁可选链/禁 CSS Grid/禁 `:key` 表达式/禁 `new Date(string)`)
+    - `node scripts/audit-duplicate-api-calls.js`
+13. 逐类真机验证,建议顺序 **A1 → A2 → B5 → B6**(数据形态最典型)
+14. Code Review + 合入 `cfclub`
+
+---
+
+## 3. 任务清单(可跟踪)
+
+| ID | 任务 | 阶段 | 依赖 | 验收标准 |
+|---|---|---|---|---|
+| T01 | 补齐 `DanAssessmentResultDTO` 及 mapper | P0 | — | ✅ 已提交;字段覆盖实体全部评分维度 |
+| T02 | 抽 `DanReportHero.vue` | P0 | 设计 §2 | Hero 视觉与现有页一致,参数可注入 |
+| T03 | 落地 `danBlockRegistry.js`(渲染器 + 量纲表) | P0 | 设计 §6.2 | 8 类 25 category 全部有映射 |
+| T04 | `DanBlockBars/Radar/Text/Grid` | P0 | T03 | 反向/同向量纲 note 显式;雷达用自研组件 |
+| T05 | `DanBlockChips/Matrix` | P1 | T03 | A2 情绪反向 + 关系矩阵正确渲染 |
+| T06 | `DanBlockDuration/Compass` | P1 | T03 | B5 时长卡 + 指南针;无坐标降级 text |
+| T07 | `DanBlockTwo/Rec` | P1 | T03 | B2 对照卡 / B6 推荐标签 |
+| T08 | 改造 `report-result.vue` 入口分派 + 降级 | P2 | T04–T07 | 8 类均可渲染;缺数据降级不白屏 |
+| T09 | 小程序技术约束审计 + 真机验证 | P3 | T08 | 审计 ERROR=0;8 类真机通过 |
+| T10 | 合入 `cfclub` + 回归 | P3 | T09 | CR 通过,无回归 |
+
+---
+
+## 4. 关键技术约束(必守,设计 §6.4)
+
+| 约束 | 做法 |
+|---|---|
+| 禁可选链 `?.` | 用 `a && a.b` |
+| 禁 CSS Grid | 用 flexbox(`flex-wrap` + `flex:0 0 calc(33.333% - 6px)`) |
+| 禁 `:key` 表达式 | 用 `getItemKey(item)` 方法 |
+| 禁 `new Date(string)` | 用 `utils/format.js` 的 `parseDate()` |
+| Vue 2 Options API | 禁 Composition API;禁 `:class` 内带参方法;禁 `uni.getSystemInfoSync()` |
+| 图表 | 自研 `RadarChart.vue` / `TrendChart.vue`(无 echarts/uCharts) |
+| 首次加载 | 同 URL 仅一次请求(`_onLoadFired`);时间格式 `yyyy-MM-dd` |
+
+---
+
+## 5. 风险与遗留项
+
+| 项 | 状态 / 处置 |
+|---|---|
+| `DanReportParseService` 的 `compass`(B5 人际指南针)为图像解析 | 需确认后端是否返回可渲染坐标;否则 `compass` 降级为 text(T06 含降级分支) |
+| 对比/趋势报告仅认知维度有趋势数据 | 其余 7 类趋势页暂缓,列入二期 |
+| 家长端无 DAN 数据 | 报告页仅面向成员(孩子),不做家长版本 |
+| 量纲误读风险 | 所有反向/特殊量纲必须在卡片 note 显式标注(`DAN_CATEGORY_SCALE` 驱动) |
+
+---
+
+## 6. 交付物
+
+- 代码:`components/dan-report/*`(1 Hero + 10 区块 + 注册表)、改造后的 `pages/dan-assessment/report-result.vue`
+- 文档:`docs/design/dan-report-pages.html`(原型)、`docs/design/DAN报告显示页面设计.md`(规范)、本计划
+- 数据契约:补齐后的 `DanAssessmentResultDTO`(后端)、`DanReportUploadVO.blocks`(既有)

+ 585 - 0
docs/design/dan-report-pages.html

@@ -0,0 +1,585 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>DAN 报告显示页面设计 · 8 类报告</title>
+<style>
+  :root{
+    --bg:#F3F2FA;
+    --card:#FFFFFF;
+    --violet:#6B5BD2;
+    --violet-deep:#4C3F91;
+    --violet-mid:#7A6ADB;
+    --violet-soft:#A99CF0;
+    --violet-text:#5B4BA8;
+    --violet-grid:#EDE8FB;
+    --violet-wash:#F7F5FE;
+    --ink:#33334A;
+    --ink-2:#6E6E85;
+    --ink-3:#9A9AB0;
+    --line:#EFEDF8;
+    --orange:#F0A05A;
+    --orange-wash:#FDF3E7;
+    --cyan:#67E8F9;
+    --green:#5FC08A;
+    --red:#E8776B;
+    --radius:16px;
+  }
+  *{box-sizing:border-box;margin:0;padding:0}
+  html,body{height:100%}
+  body{
+    font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",sans-serif;
+    background:#E7E4F3;color:var(--ink);-webkit-font-smoothing:antialiased;
+  }
+  .app{display:flex;min-height:100vh}
+
+  /* ============ 侧边栏 ============ */
+  .sidebar{
+    width:272px;flex:0 0 272px;background:#fff;padding:26px 20px;
+    border-right:1px solid #E4E0F0;position:sticky;top:0;height:100vh;overflow-y:auto;
+  }
+  .sidebar h1{font-size:19px;font-weight:700;color:var(--violet-deep);letter-spacing:.5px}
+  .sidebar .sub{font-size:12px;color:var(--ink-3);margin-top:5px;line-height:1.6}
+  .sidebar .meta{
+    margin-top:14px;padding:10px 12px;background:var(--violet-wash);
+    border-radius:10px;font-size:11.5px;color:var(--violet-text);line-height:1.7
+  }
+  .sidebar .meta b{color:var(--violet-deep)}
+  nav{margin-top:20px;display:flex;flex-direction:column;gap:6px}
+  .tab{
+    display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:11px;
+    cursor:pointer;border:1px solid transparent;transition:.16s;background:transparent;text-align:left;
+    font-family:inherit;width:100%;
+  }
+  .tab:hover{background:var(--violet-wash)}
+  .tab.active{background:linear-gradient(100deg,#F0EDFD,#F7F5FE);border-color:#DDD5F7}
+  .tab-code{
+    flex:0 0 30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;
+    font-size:11.5px;font-weight:700;background:var(--violet-grid);color:var(--violet-text)
+  }
+  .tab.active .tab-code{background:var(--violet);color:#fff}
+  .tab-name{font-size:13.5px;font-weight:600;color:var(--ink)}
+  .tab-desc{font-size:11px;color:var(--ink-3);margin-top:1px}
+  .legend{margin-top:22px;padding-top:16px;border-top:1px solid var(--line)}
+  .legend h3{font-size:12px;color:var(--ink-2);margin-bottom:10px;font-weight:600}
+  .lg-row{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--ink-2);margin-bottom:7px}
+  .lg-dot{width:11px;height:11px;border-radius:3px;flex:0 0 11px}
+
+  /* ============ 舞台 ============ */
+  .stage{flex:1;display:flex;flex-direction:column;align-items:center;padding:30px 24px 50px;overflow-y:auto;height:100vh}
+  .stage-head{text-align:center;margin-bottom:18px}
+  .stage-head .t{font-size:15px;font-weight:600;color:var(--violet-deep)}
+  .stage-head .d{font-size:12px;color:var(--ink-3);margin-top:4px}
+  .phone{
+    width:392px;background:var(--bg);border-radius:30px;overflow:hidden;
+    box-shadow:0 18px 48px rgba(60,45,120,.20),0 2px 8px rgba(60,45,120,.10);
+    border:7px solid #2E2A44;
+  }
+  .screen{min-height:640px;padding-bottom:18px}
+
+  /* ============ Hero ============ */
+  .hero{
+    position:relative;overflow:hidden;color:#fff;padding:22px 20px 20px;
+    background:linear-gradient(160deg,#4C3F91 0%,#6B5BD2 55%,#8B7BE8 100%);
+    border-bottom-left-radius:22px;border-bottom-right-radius:22px;
+  }
+  .hero-c1{position:absolute;width:190px;height:190px;border-radius:50%;background:rgba(255,255,255,.06);right:-70px;top:-78px}
+  .hero-c2{position:absolute;width:112px;height:112px;border-radius:50%;background:rgba(255,255,255,.05);left:-42px;bottom:-34px}
+  .hero-top{position:relative;z-index:1}
+  .hero-name{font-size:21px;font-weight:700;letter-spacing:.3px}
+  .hero-badges{display:flex;align-items:center;gap:6px;margin-top:8px;flex-wrap:wrap}
+  .bd{font-size:10.5px;padding:3px 9px;border-radius:20px;background:rgba(255,255,255,.20);backdrop-filter:blur(2px)}
+  .bd-strong{background:#FFD9A8;color:#7A4A12;font-weight:700}
+  .hero-scores{display:flex;align-items:flex-end;gap:26px;margin-top:16px;position:relative;z-index:1}
+  .hs{display:flex;flex-direction:column;gap:2px}
+  .hs-num{font-size:32px;font-weight:800;line-height:1;letter-spacing:-.5px}
+  .hs-num small{font-size:13px;font-weight:600;opacity:.8;margin-left:2px}
+  .hs-lab{font-size:10.5px;opacity:.82}
+  .pct-wrap{margin-top:14px;position:relative;z-index:1}
+  .pct-track{height:7px;border-radius:4px;background:rgba(255,255,255,.24);position:relative;overflow:hidden}
+  .pct-fill{height:100%;border-radius:4px;background:linear-gradient(90deg,#A5F3FC,#67E8F9,#FFD9A8)}
+  .pct-txt{margin-top:6px;font-size:10.5px;opacity:.9}
+
+  /* ============ 卡片 ============ */
+  .card{background:var(--card);border-radius:var(--radius);margin:11px 12px 0;padding:15px 15px 16px;box-shadow:0 1px 3px rgba(80,60,140,.05)}
+  .card-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:2px}
+  .card-title{font-size:14.5px;font-weight:700;color:var(--violet-text)}
+  .card-note{font-size:10.5px;color:var(--ink-3)}
+  .card-sub{font-size:11px;color:var(--ink-3);margin-top:3px;line-height:1.6}
+
+  /* 雷达 + 指标条 */
+  .radar-box{display:flex;justify-content:center;margin:6px 0 2px}
+  .m-list{margin-top:10px}
+  .metric{display:flex;align-items:center;gap:9px;padding:6px 0}
+  .metric-lab{flex:0 0 88px;font-size:11.5px;color:var(--ink-2);line-height:1.3}
+  .metric-track{flex:1;height:8px;border-radius:4px;background:var(--violet-grid);position:relative;overflow:hidden}
+  .metric-fill{height:100%;border-radius:4px;background:linear-gradient(90deg,var(--violet-soft),var(--violet))}
+  .metric-fill.warn{background:linear-gradient(90deg,#F7C08A,var(--orange))}
+  .metric-fill.good{background:linear-gradient(90deg,#8FD9B0,var(--green))}
+  .metric-ref{position:absolute;top:-2px;width:1.5px;height:12px;background:#B9B4D4;border-radius:1px}
+  .metric-val{flex:0 0 46px;text-align:right;font-size:11.5px;font-weight:700;color:var(--ink)}
+  .metric-val.warn{color:var(--orange)}
+  .tag-mini{font-size:9px;padding:1px 5px;border-radius:4px;background:var(--orange-wash);color:#B4702A;margin-left:4px;font-weight:600}
+  .tag-mini.good{background:#E9F7EF;color:#2E8B5C}
+  .metric-val .tag-mini{display:inline-block}
+
+  /* chips */
+  .chip-row{display:flex;align-items:center;gap:9px;padding:6px 0}
+  .chip-lab{flex:0 0 60px;font-size:11.5px;color:var(--violet-text);font-weight:600}
+  .chip-track{flex:1;height:7px;border-radius:4px;background:var(--violet-grid);overflow:hidden}
+  .chip-fill{height:100%;border-radius:4px;background:linear-gradient(90deg,#B9AEF5,var(--violet))}
+  .chip-fill.warn{background:linear-gradient(90deg,#F7C08A,var(--orange))}
+  .chip-val{flex:0 0 40px;text-align:right;font-size:11px;color:var(--ink-2)}
+
+  /* grid */
+  .grid{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
+  .gi{flex:0 0 calc(33.333% - 6px);background:var(--violet-wash);border-radius:11px;padding:11px 6px;text-align:center}
+  .gi-v{font-size:16px;font-weight:800;color:var(--violet-deep);line-height:1}
+  .gi-v small{font-size:10px;font-weight:600;color:var(--ink-3);margin-left:1px}
+  .gi-l{font-size:10px;color:var(--ink-3);margin-top:5px}
+  .habit{margin-top:10px;border-top:1px solid var(--line);padding-top:9px}
+  .habit-row{display:flex;justify-content:space-between;font-size:11.5px;color:var(--ink-2);padding:4px 0}
+  .habit-row b{color:var(--ink);font-weight:600}
+
+  /* matrix(社会关系依恋) */
+  .mx-head,.mx-row{display:flex;align-items:center;padding:7px 0;font-size:11.5px}
+  .mx-head{border-bottom:1px solid var(--line);color:var(--ink-3);font-size:10.5px;font-weight:600}
+  .mx-c0{flex:0 0 52px;color:var(--ink-2)}
+  .mx-c{flex:1;text-align:center}
+  .mx-bar{height:6px;border-radius:3px;background:var(--violet-grid);overflow:hidden;margin:0 8px}
+  .mx-bar i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,var(--violet-soft),var(--violet))}
+  .mx-num{font-weight:700;color:var(--violet-text)}
+
+  /* duration(时长卡) */
+  .dur{display:flex;gap:8px;margin-top:10px}
+  .du{flex:1;background:var(--violet-wash);border-radius:12px;padding:11px 9px;text-align:center}
+  .du-v{font-size:18px;font-weight:800;color:var(--violet-deep);line-height:1}
+  .du-v small{font-size:10px;color:var(--ink-3);font-weight:600;margin-left:1px}
+  .du-l{font-size:10px;color:var(--ink-3);margin-top:5px}
+  .du-s{font-size:9.5px;margin-top:6px;padding:2px 6px;border-radius:5px;display:inline-block;font-weight:600}
+  .du-s.ok{background:#E9F7EF;color:#2E8B5C}
+  .du-s.warn{background:var(--orange-wash);color:#B4702A}
+  .du-s.bad{background:#FDECEA;color:#C25A4E}
+
+  /* compass */
+  .compass-note{font-size:11px;color:var(--ink-2);line-height:1.7;margin-top:8px}
+
+  /* text */
+  .txt{font-size:12px;color:var(--ink-2);line-height:1.85;margin-top:8px}
+  .txt.collapsed{
+    display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;
+    -webkit-mask-image:linear-gradient(180deg,#000 55%,transparent 100%);
+  }
+  .more{font-size:11.5px;color:var(--violet);margin-top:6px;font-weight:600;cursor:pointer;background:none;border:none;padding:0;font-family:inherit}
+
+  /* 优势/待提升 */
+  .two{display:flex;gap:8px;margin-top:10px}
+  .two-col{flex:1;background:var(--violet-wash);border-radius:12px;padding:11px}
+  .two-col.warn{background:var(--orange-wash)}
+  .two-h{font-size:11px;font-weight:700;margin-bottom:7px}
+  .two-col .two-h{color:#2E8B5C}
+  .two-col.warn .two-h{color:#B4702A}
+  .two-item{font-size:11px;color:var(--ink-2);padding:2.5px 0}
+
+  /* 推荐 chips */
+  .rec{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
+  .rec-chip{font-size:11px;padding:5px 10px;border-radius:20px;background:var(--violet-wash);color:var(--violet-text);font-weight:600}
+  .rec-chip.hot{background:linear-gradient(100deg,#F0EDFD,#E7E1FB);color:var(--violet-deep)}
+
+  .foot{text-align:center;font-size:10px;color:#B0AEC4;padding:18px 26px 4px;line-height:1.7}
+  .hint{font-size:11px;color:var(--ink-3);margin-top:14px;text-align:center}
+</style>
+</head>
+<body>
+<div class="app">
+  <aside class="sidebar">
+    <h1>DAN 报告体系</h1>
+    <div class="sub">8 类报告 · 显示页面设计原型</div>
+    <div class="meta">
+      数据来源 <b>DanReportUpload.DIM_*</b><br>
+      区块顺序 <b>ReportBlockAssembler<br>.DAN_CATEGORY_TITLES</b><br>
+      渲染口径 <b>score / indicator / text</b>
+    </div>
+    <nav id="tabs"></nav>
+    <div class="legend">
+      <h3>区块渲染器图例</h3>
+      <div class="lg-row"><span class="lg-dot" style="background:var(--violet)"></span>雷达图(≥3 维)</div>
+      <div class="lg-row"><span class="lg-dot" style="background:var(--violet-soft)"></span>指标条(带参考线)</div>
+      <div class="lg-row"><span class="lg-dot" style="background:var(--orange)"></span>需关注(低于阈值)</div>
+      <div class="lg-row"><span class="lg-dot" style="background:var(--green)"></span>优势项</div>
+      <div class="lg-row"><span class="lg-dot" style="background:#D7D2EC"></span>数值格 / 时长卡</div>
+    </div>
+  </aside>
+
+  <main class="stage">
+    <div class="stage-head">
+      <div class="t" id="stageTitle"></div>
+      <div class="d" id="stageDesc"></div>
+    </div>
+    <div class="phone"><div class="screen" id="screen"></div></div>
+    <div class="hint">切换左侧报告类别查看各自专属页面布局</div>
+  </main>
+</div>
+
+<script>
+/* ===================== 雷达图(SVG) ===================== */
+function radarSVG(items, opt){
+  opt = opt || {};
+  var size=250, cx=125, cy=121, r=82, n=items.length, rings='', axes='', i, a, pts;
+  for(var g=1; g<=4; g++){
+    pts=[];
+    for(i=0;i<n;i++){ a=-Math.PI/2+i*2*Math.PI/n; pts.push((cx+r*g/4*Math.cos(a)).toFixed(1)+','+(cy+r*g/4*Math.sin(a)).toFixed(1)); }
+    rings+='<polygon points="'+pts.join(' ')+'" fill="'+(g===4?'#F7F5FE':'none')+'" stroke="#E6E1F7" stroke-width="1"/>';
+  }
+  for(i=0;i<n;i++){
+    a=-Math.PI/2+i*2*Math.PI/n;
+    axes+='<line x1="'+cx+'" y1="'+cy+'" x2="'+(cx+r*Math.cos(a)).toFixed(1)+'" y2="'+(cy+r*Math.sin(a)).toFixed(1)+'" stroke="#E6E1F7" stroke-width="1"/>';
+  }
+  var dpts=items.map(function(it,i){
+    var a=-Math.PI/2+i*2*Math.PI/n, ratio=Math.max(0,Math.min(1,it.value/it.max));
+    return (cx+r*ratio*Math.cos(a)).toFixed(1)+','+(cy+r*ratio*Math.sin(a)).toFixed(1);
+  });
+  var data='<polygon points="'+dpts.join(' ')+'" fill="rgba(107,91,210,.20)" stroke="#6B5BD2" stroke-width="2" stroke-linejoin="round"/>';
+  var dots=items.map(function(it,i){
+    var a=-Math.PI/2+i*2*Math.PI/n, ratio=Math.max(0,Math.min(1,it.value/it.max));
+    var warn = it.max && it.value/it.max < 0.5;
+    return '<circle cx="'+(cx+r*ratio*Math.cos(a)).toFixed(1)+'" cy="'+(cy+r*ratio*Math.sin(a)).toFixed(1)+'" r="3" fill="'+(warn?'#F0A05A':'#6B5BD2')+'"/>';
+  }).join('');
+  var labels=items.map(function(it,i){
+    var a=-Math.PI/2+i*2*Math.PI/n, lx=cx+(r+24)*Math.cos(a), ly=cy+(r+24)*Math.sin(a);
+    var cos=Math.cos(a), anchor = Math.abs(cos)<0.25 ? 'middle' : (cos>0?'start':'end');
+    var dy = Math.sin(a)>0.6 ? 8 : (Math.sin(a)<-0.6 ? -2 : 4);
+    return '<text x="'+lx.toFixed(1)+'" y="'+(ly+dy).toFixed(1)+'" text-anchor="'+anchor+'" font-size="10.5" fill="#6E6E85">'+it.label+'</text>';
+  }).join('');
+  return '<svg viewBox="0 0 '+size+' '+size+'" width="100%" style="max-width:250px;display:block">'+rings+axes+data+dots+labels+'</svg>';
+}
+
+/* ===================== 区块渲染器 ===================== */
+function pct(v,m){ return Math.round(Math.max(0,Math.min(1,v/m))*100); }
+
+function rBars(b){
+  var h='<div class="m-list">';
+  b.items.forEach(function(it){
+    var w=pct(it.value,it.max), warn = it.value/it.max < 0.5, good = it.value/it.max >= 0.8;
+    h+='<div class="metric">'
+      +'<div class="metric-lab">'+it.label+'</div>'
+      +'<div class="metric-track"><div class="metric-fill '+(warn?'warn':(good?'good':''))+'" style="width:'+w+'%"></div>'
+        +(it.ref?'<div class="metric-ref" style="left:'+pct(it.ref,it.max)+'%"></div>':'')+'</div>'
+      +'<div class="metric-val'+(warn?' warn':'')+'">'+it.value
+        +(b.maxLabel?'<span style="font-weight:500;color:#9A9AB0;font-size:10px">/'+it.max+'</span>':'')
+        +(warn?'<span class="tag-mini">需关注</span>':'')+'</div>'
+      +'</div>';
+  });
+  return h+'</div>';
+}
+function rRadar(b){
+  return '<div class="radar-box">'+radarSVG(b.items)+'</div>'+rBars(b);
+}
+function rChips(b){
+  var h='<div class="m-list">';
+  b.items.forEach(function(it){
+    var w=pct(it.value,it.max), warn=it.value/it.max>=0.5;
+    h+='<div class="chip-row"><div class="chip-lab">'+it.label+'</div>'
+      +'<div class="chip-track"><div class="chip-fill '+(warn?'warn':'')+'" style="width:'+w+'%"></div></div>'
+      +'<div class="chip-val">'+it.value+'/'+it.max+'</div></div>';
+  });
+  return h+'</div>';
+}
+function rGrid(b){
+  var h='<div class="grid">';
+  b.items.forEach(function(it){
+    h+='<div class="gi"><div class="gi-v">'+it.value+'<small>'+(it.unit||'')+'</small></div><div class="gi-l">'+it.label+'</div></div>';
+  });
+  h+='</div>';
+  if(b.habits){
+    h+='<div class="habit">';
+    b.habits.forEach(function(x){ h+='<div class="habit-row"><span>'+x.k+'</span><b>'+x.v+'</b></div>'; });
+    h+='</div>';
+  }
+  return h;
+}
+function rMatrix(b){
+  var h='<div class="mx-head"><div class="mx-c0"></div>';
+  b.cols.forEach(function(c){ h+='<div class="mx-c">'+c+'</div>'; });
+  h+='</div>';
+  b.rows.forEach(function(r){
+    h+='<div class="mx-row"><div class="mx-c0">'+r.label+'</div>';
+    r.vals.forEach(function(v){
+      h+='<div class="mx-c"><div class="mx-bar"><i style="width:'+v+'%"></i></div><div class="mx-num" style="font-size:10.5px;margin-top:3px">'+v+'</div></div>';
+    });
+    h+='</div>';
+  });
+  return h;
+}
+function rDuration(b){
+  var h='<div class="dur">';
+  b.items.forEach(function(it){
+    h+='<div class="du"><div class="du-v">'+it.value+'<small>'+it.unit+'</small></div>'
+      +'<div class="du-l">'+it.label+'</div>'
+      +'<div class="du-s '+it.status+'">'+it.note+'</div></div>';
+  });
+  return h+'</div>';
+}
+function rCompass(b){
+  var svg='<svg viewBox="0 0 220 190" width="100%" style="max-width:240px;display:block;margin:4px auto 0">'
+    +'<circle cx="110" cy="95" r="78" fill="#F7F5FE" stroke="#E6E1F7"/>'
+    +'<circle cx="110" cy="95" r="52" fill="none" stroke="#EDE8FB"/>'
+    +'<circle cx="110" cy="95" r="26" fill="none" stroke="#EDE8FB"/>'
+    +'<line x1="32" y1="95" x2="188" y2="95" stroke="#E6E1F7"/>'
+    +'<line x1="110" y1="17" x2="110" y2="173" stroke="#E6E1F7"/>';
+  var pos=[{x:110,y:35},{x:110,y:155},{x:47,y:95},{x:173,y:95}];
+  b.items.forEach(function(it,i){
+    var p=pos[i], rr=(1-it.value/it.max)*72;
+    var ang=Math.atan2(p.y-95,p.x-110);
+    var dx=110+rr*Math.cos(ang), dy=95+rr*Math.sin(ang);
+    svg+='<line x1="110" y1="95" x2="'+dx.toFixed(1)+'" y2="'+dy.toFixed(1)+'" stroke="#C9C1EE" stroke-dasharray="3 3"/>';
+    svg+='<circle cx="'+dx.toFixed(1)+'" cy="'+dy.toFixed(1)+'" r="'+(6+it.value/it.max*9).toFixed(1)+'" fill="rgba(107,91,210,.28)" stroke="#6B5BD2" stroke-width="1.6"/>';
+    var lx = p.x===110 ? 110 : (p.x<110? 40 : 180), ly = p.y<95 ? 22 : 172;
+    var anchor = p.x===110 ? 'middle' : (p.x<110?'start':'end');
+    svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="'+anchor+'" font-size="11" fill="#5B4BA8" font-weight="600">'+it.label+'</text>';
+    svg+='<text x="'+lx+'" y="'+(ly+13)+'" text-anchor="'+anchor+'" font-size="9.5" fill="#9A9AB0">'+it.value+'/'+it.max+'</text>';
+  });
+  svg+='</svg>';
+  return svg+'<div class="compass-note">'+b.note+'</div>';
+}
+function rText(b){
+  var cls = b.collapsible ? 'txt collapsed' : 'txt';
+  var h='<div class="'+cls+'" id="'+b._id+'">'+b.content+'</div>';
+  if(b.collapsible) h+='<button class="more" data-t="'+b._id+'">展开全文 ›</button>';
+  return h;
+}
+function rTwo(b){
+  var h='<div class="two">';
+  h+='<div class="two-col"><div class="two-h">优势维度</div>';
+  b.good.forEach(function(x){ h+='<div class="two-item">· '+x+'</div>'; });
+  h+='</div><div class="two-col warn"><div class="two-h">待提升</div>';
+  b.warn.forEach(function(x){ h+='<div class="two-item">· '+x+'</div>'; });
+  h+='</div></div>';
+  return h;
+}
+function rRec(b){
+  var h='<div class="rec">';
+  b.items.forEach(function(it){ h+='<span class="rec-chip'+(it.hot?' hot':'')+'">'+it.label+'</span>'; });
+  return h+'</div>';
+}
+
+var RENDER={radar:rRadar,bars:rBars,chips:rChips,grid:rGrid,matrix:rMatrix,duration:rDuration,compass:rCompass,text:rText,two:rTwo,rec:rRec};
+
+/* ===================== 8 类报告数据 ===================== */
+var MEMBER={name:'陈乐乐',gender:'女',age:'9岁'};
+var REPORTS=[
+{
+  id:'mind',code:'A2',name:'心维度',desc:'核心素养评估 · mind',
+  total:78,percentile:72,level:'B',date:'2026-10-18',
+  blocks:[
+    {type:'radar',title:'认知能力',note:'百分位',maxLabel:true,items:[
+      {label:'感知觉',value:82,max:100},{label:'记忆力',value:74,max:100},{label:'注意力',value:68,max:100}]},
+    {type:'radar',title:'大五人格',note:'1-5 分制',maxLabel:true,items:[
+      {label:'开放性',value:4.2,max:5},{label:'尽责性',value:3.8,max:5},{label:'外向性',value:3.1,max:5},
+      {label:'宜人性',value:4.5,max:5},{label:'神经质',value:2.4,max:5}]},
+    {type:'chips',title:'情绪状态',note:'0-15 分制,分数越高越需关注',items:[
+      {label:'自卑感',value:4,max:15},{label:'抑郁',value:3,max:15},{label:'焦虑',value:6,max:15},{label:'无力感',value:3,max:15}]},
+    {type:'matrix',title:'社会关系',note:'依恋质量 · 母亲 / 父亲 / 同伴',
+      cols:['信任','沟通','亲近'],
+      rows:[
+        {label:'母亲',vals:[86,78,82]},
+        {label:'父亲',vals:[71,64,69]},
+        {label:'同伴',vals:[74,80,77]}]},
+    {type:'grid',title:'体质健康',items:[
+      {label:'身高',value:132,unit:'cm'},{label:'体重',value:28,unit:'kg'},{label:'BMI',value:16.1,unit:''}],
+      habits:[{k:'饮食',v:'规律'},{k:'睡眠',v:'约 9 小时/天'},{k:'运动',v:'每周 3 次'}]},
+    {type:'bars',title:'综合能力',maxLabel:true,items:[
+      {label:'语言表达',value:8.1,max:10},{label:'逻辑思维',value:7.4,max:10},{label:'创造力',value:7.8,max:10}]},
+    {type:'text',title:'成长建议',collapsible:true,content:'孩子在宜人性与同伴信任维度表现突出,说明其社会性发展良好,建议继续提供稳定的同伴交往场景。注意力百分位 68 略低于认知整体水平,可在家庭学习中引入"分段专注 + 即时反馈"的训练方式,每次专注时长从 15 分钟起步逐步延长。父亲维度的沟通与亲近得分相对偏低,建议增加父子单独相处的高质量陪伴时间。焦虑分项 6/15 处于中等水平,可通过睡前情绪复盘帮助孩子外化压力。'}
+  ]
+},
+{
+  id:'wisdom',code:'B4',name:'智维度',desc:'认知与发展评估 · wisdom',
+  total:82,percentile:76,level:'A',date:'2026-10-18',
+  blocks:[
+    {type:'radar',title:'认知能力',note:'推理 / 空间 / 加工速度',maxLabel:true,items:[
+      {label:'推理能力',value:88,max:100},{label:'空间能力',value:76,max:100},{label:'加工速度',value:71,max:100}]},
+    {type:'bars',title:'自我概念',maxLabel:true,items:[
+      {label:'学业自我',value:7.6,max:10},{label:'身体自我',value:7.1,max:10},{label:'社会自我',value:8.0,max:10},{label:'情绪自我',value:6.8,max:10}]},
+    {type:'bars',title:'成长型思维',maxLabel:true,items:[
+      {label:'努力信念',value:8.2,max:10},{label:'挑战接纳',value:7.3,max:10},{label:'失败归因',value:6.4,max:10}]},
+    {type:'bars',title:'自驱力',maxLabel:true,items:[
+      {label:'自主性',value:7.8,max:10},{label:'胜任感',value:7.2,max:10},{label:'坚持性',value:6.6,max:10}]},
+    {type:'text',title:'成长建议',collapsible:true,content:'推理能力百分位 88 是显著优势,适合提供开放性、探究式的学习任务(如科学实验、编程启蒙)。加工速度 71 相对偏弱,在限时测验场景中可能吃亏,建议日常做"限时但不计分"的练习降低速度焦虑。失败归因得分 6.4 偏低,孩子容易把挫折归为能力不足,家长反馈时应更多描述过程与策略而非评价天赋。'}
+  ]
+},
+{
+  id:'cognition',code:'A1',name:'儿童核心认知',desc:'认知六维评估 · cognition',
+  total:82,percentile:78,level:'A',date:'2026-10-12',
+  blocks:[
+    {type:'radar',title:'认知能力',note:'六维百分位(P50 为同龄中位)',maxLabel:true,items:[
+      {label:'感知觉',value:85,max:100},{label:'注意力',value:72,max:100},{label:'记忆力',value:80,max:100},
+      {label:'推理能力',value:88,max:100},{label:'空间能力',value:76,max:100},{label:'加工速度',value:69,max:100}]},
+    {type:'bars',title:'认知能力',note:'明细 · 灰线为同龄 P50',maxLabel:true,items:[
+      {label:'感知觉',value:85,max:100,ref:50},{label:'注意力',value:72,max:100,ref:50},
+      {label:'记忆力',value:80,max:100,ref:50},{label:'推理能力',value:88,max:100,ref:50},
+      {label:'空间能力',value:76,max:100,ref:50},{label:'加工速度',value:69,max:100,ref:50}]},
+    {type:'two',title:'优势与待提升',good:['推理能力(88)','感知觉(85)','记忆力(80)'],warn:['加工速度(69)','注意力(72)']},
+    {type:'text',title:'成长建议',collapsible:true,content:'整体认知水平位于同龄前 22%,推理与感知觉是双优势维度。加工速度是唯一低于 P50+20 的维度,表现为信息摄取与反应偏慢,建议通过速读、视听反应类小游戏做专项训练,每次 10 分钟、每周 4 次。注意力 72 尚可,但与推理能力的落差提示"会做但不够稳",考试场景下易因分心丢分,可配合番茄钟与错题归因习惯。'}
+  ]
+},
+{
+  id:'learning',code:'B3',name:'核心学习能力',desc:'执行功能 · 动机 · 策略 · learning',
+  total:74,percentile:70,level:'B',date:'2026-10-15',
+  blocks:[
+    {type:'radar',title:'执行功能',note:'百分位',maxLabel:true,items:[
+      {label:'抑制控制',value:68,max:100},{label:'工作记忆',value:74,max:100},{label:'认知灵活性',value:71,max:100}]},
+    {type:'bars',title:'学习动机',note:'十分制',maxLabel:true,items:[
+      {label:'深层动机',value:7.2,max:10},{label:'表面动机',value:5.4,max:10},{label:'自我效能感',value:6.8,max:10}]},
+    {type:'bars',title:'学习策略',note:'十分制',maxLabel:true,items:[
+      {label:'深层方法与策略',value:6.1,max:10},{label:'表面方法与策略',value:5.8,max:10},{label:'学习自我调节',value:5.2,max:10}]},
+    {type:'text',title:'成长建议',collapsible:true,content:'执行功能三项均在 68-74 的中上区间,工作记忆相对最好,能支撑多步指令的完成。深层动机 7.2 高于表面动机 5.4,说明孩子 intrinsic 学习兴趣健康,应减少外在奖励依赖。学习自我调节 5.2 是三项策略中最低,表现为计划性差、不会复盘,建议引入"学习计划表 + 周回顾"双件套,家长只做陪伴不做代劳。'}
+  ]
+},
+{
+  id:'behavior',code:'B2',name:'儿童自我与家庭教养',desc:'自我概念 · 行为 · 家庭环境 · behavior',
+  total:76,percentile:68,level:'B',date:'2026-10-16',
+  blocks:[
+    {type:'radar',title:'自我概念',note:'六维 · 十分制',maxLabel:true,items:[
+      {label:'行为表现',value:7.5,max:10},{label:'能力与学校',value:6.8,max:10},{label:'躯体外貌',value:7.2,max:10},
+      {label:'情绪状态',value:6.1,max:10},{label:'合群',value:7.8,max:10},{label:'幸福与满足',value:7.0,max:10}]},
+    {type:'bars',title:'行为表现',note:'八维 · 十分制(多动/刻板/拖延为反向计分)',maxLabel:true,items:[
+      {label:'品行',value:8.2,max:10},{label:'情绪',value:6.4,max:10},{label:'学习',value:6.0,max:10},{label:'社交',value:7.6,max:10},
+      {label:'生活习惯',value:6.8,max:10},{label:'多动倾向',value:4.5,max:10},{label:'刻板行为',value:3.2,max:10},{label:'拖延行为',value:5.6,max:10}]},
+    {type:'bars',title:'家庭环境',note:'十维 · 十分制',maxLabel:true,items:[
+      {label:'亲密度',value:7.2,max:10},{label:'情感表达',value:6.4,max:10},{label:'矛盾性',value:4.1,max:10},
+      {label:'独立性',value:6.8,max:10},{label:'成就性',value:7.0,max:10},{label:'文化性',value:6.2,max:10},
+      {label:'娱乐性',value:6.6,max:10},{label:'道德宗教',value:6.9,max:10},{label:'组织性',value:7.3,max:10},{label:'控制性',value:5.8,max:10}]},
+    {type:'text',title:'教养建议',collapsible:true,content:'合群 7.8 与品行 8.2 是孩子的社交与品行优势,同伴关系稳定。刻板行为 3.2、多动倾向 4.5 均处低位,无需过度担忧。家庭环境中"情感表达 6.4"与"控制性 5.8"是两处可优化点:控制性偏高叠加情感表达偏弱,容易让孩子在遇到挫折时不主动求助,建议家长每天固定 10 分钟"只倾听不给建议"的对话时段。学习维度 6.0 与自我概念中的"能力与学校 6.8"相互印证,可通过降低比较、强调进步来提升学业自我效能。'}
+  ]
+},
+{
+  id:'career',code:'B6',name:'职业发展',desc:'兴趣 · 多元智能 · 价值观 · career',
+  total:80,percentile:74,level:'A',date:'2026-10-20',
+  blocks:[
+    {type:'radar',title:'职业兴趣',note:'Holland 六型 · 百分位',maxLabel:true,items:[
+      {label:'艺术型',value:72,max:100},{label:'社会型',value:85,max:100},{label:'企业型',value:63,max:100},
+      {label:'常规型',value:55,max:100},{label:'现实型',value:48,max:100},{label:'研究型',value:78,max:100}]},
+    {type:'bars',title:'多元智能',note:'1-15 分制',maxLabel:true,items:[
+      {label:'内省智能',value:12,max:15},{label:'空间智能',value:10,max:15},{label:'音乐智能',value:8,max:15},{label:'人际智能',value:13,max:15},
+      {label:'自然观察',value:9,max:15},{label:'运动智能',value:7,max:15},{label:'语言智能',value:11,max:15},{label:'逻辑智能',value:12,max:15}]},
+    {type:'bars',title:'职业价值观',maxLabel:true,items:[
+      {label:'成就感',value:8.4,max:10},{label:'助人',value:8.8,max:10},{label:'独立性',value:7.2,max:10},{label:'创造性',value:8.1,max:10}]},
+    {type:'rec',title:'适配方向',note:'基于兴趣 × 智能 × 价值观',items:[
+      {label:'教育 / 培训',hot:true},{label:'心理咨询',hot:true},{label:'内容创作'},{label:'科研'},{label:'公共关系'}]},
+    {type:'text',title:'成长建议',collapsible:true,content:'社会型 85 与人际智能 13 形成强共振,叠加"助人"价值观 8.8,指向明显的服务与教育导向。研究型 78 与逻辑智能 12、内省智能 12 构成第二优势轴,适合做需要深度思考的工作。现实型 48 与运动智能 7 是相对短板,动手操作类活动需更多鼓励而非回避。建议在中学阶段提供志愿服务、同伴辅导类实践机会,让优势轴在真实场景中得到验证。'}
+  ]
+},
+{
+  id:'campus',code:'C1',name:'校园标准版',desc:'认知 · 人格 · 自驱 · 自我概念 · campus',
+  total:79,percentile:73,level:'B',date:'2026-10-19',
+  blocks:[
+    {type:'radar',title:'认知能力',note:'六维百分位',maxLabel:true,items:[
+      {label:'感知觉',value:83,max:100},{label:'注意力',value:70,max:100},{label:'记忆力',value:79,max:100},
+      {label:'推理能力',value:84,max:100},{label:'空间能力',value:74,max:100},{label:'加工速度',value:68,max:100}]},
+    {type:'radar',title:'大五人格',note:'1-5 分制',maxLabel:true,items:[
+      {label:'开放性',value:4.0,max:5},{label:'宜人性',value:4.3,max:5},{label:'责任心',value:3.6,max:5},
+      {label:'外倾性',value:3.2,max:5},{label:'神经质',value:2.7,max:5}]},
+    {type:'bars',title:'自驱力',note:'三分维度 · 十分制',maxLabel:true,items:[
+      {label:'自主性',value:6.8,max:10},{label:'胜任感',value:7.2,max:10},{label:'归属感',value:7.6,max:10}]},
+    {type:'radar',title:'自我概念',note:'六维 · 十分制',maxLabel:true,items:[
+      {label:'行为表现',value:7.4,max:10},{label:'能力与学校',value:6.9,max:10},{label:'躯体外貌',value:7.0,max:10},
+      {label:'情绪状态',value:6.3,max:10},{label:'合群',value:7.9,max:10},{label:'幸福与满足',value:7.1,max:10}]},
+    {type:'text',title:'校园适应建议',collapsible:true,content:'归属感 7.6 与合群 7.9 说明孩子在学校的人际关系适应良好,是校园适应的保护性因素。责任心 3.6 与加工速度 68 是需要关注的两处:前者影响作业完成的一致性,后者影响课堂与考试的节奏,建议家校配合建立"作业清单 + 时间盒"机制。神经质 2.7 处于低位,情绪稳定性好。'}
+  ]
+},
+{
+  id:'adolescent',code:'B5',name:'青春期挑战',desc:'情绪 · 压力 · 人际 · 生活节律 · adolescent',
+  total:71,percentile:64,level:'C',date:'2026-10-22',
+  blocks:[
+    {type:'bars',title:'情绪调节',note:'1-7 分制',maxLabel:true,items:[
+      {label:'认知重评',value:5.2,max:7},{label:'表达抑制',value:4.6,max:7}]},
+    {type:'bars',title:'学业压力',note:'压力源五维 · 1-5 分制',maxLabel:true,items:[
+      {label:'学业负担',value:3.4,max:5},{label:'家庭期望',value:3.8,max:5},{label:'师生关系',value:2.6,max:5},
+      {label:'自我期望',value:4.1,max:5},{label:'同伴竞争',value:3.2,max:5}]},
+    {type:'bars',title:'人际关系',note:'四维 · 1-5 分制',maxLabel:true,items:[
+      {label:'母亲',value:4.2,max:5},{label:'父亲',value:3.9,max:5},{label:'师生',value:3.4,max:5},{label:'同伴',value:4.0,max:5}]},
+    {type:'radar',title:'社交能力',note:'五维 · 1-5 分制',maxLabel:true,items:[
+      {label:'主动交往',value:3.6,max:5},{label:'情感支持',value:4.1,max:5},{label:'情感表达',value:3.5,max:5},
+      {label:'表达影响',value:3.8,max:5},{label:'冲突解决',value:3.3,max:5}]},
+    {type:'duration',title:'生活节律',note:'睡眠 / 运动 / 网络',items:[
+      {label:'睡眠',value:7.2,unit:'h/天',status:'warn',note:'偏少'},
+      {label:'运动',value:45,unit:'min/天',status:'ok',note:'达标'},
+      {label:'网络',value:3.5,unit:'h/天',status:'bad',note:'偏多'}]},
+    {type:'compass',title:'人际指南针',note:'圆心为自我,点越靠近圆心表示关系越紧密。当前同伴与母亲维度最紧密,师生维度距离最远。',
+      items:[
+        {label:'母亲',value:4.2,max:5},{label:'父亲',value:3.9,max:5},
+        {label:'同伴',value:4.0,max:5},{label:'师生',value:3.4,max:5}]},
+    {type:'text',title:'成长建议',collapsible:true,content:'自我期望 4.1 是全部压力源中最高的一项,说明压力主要来自内部而非外部,需警惕完美主义倾向。网络使用 3.5 小时/天明显偏多,且睡眠仅 7.2 小时,两者高度相关——建议先从不带电子设备进卧室这一条规则开始,而不是全面禁网。表达抑制 4.6 偏高、认知重评 5.2 尚可,孩子更倾向于憋住情绪而非重新解读,可通过写情绪日记逐步建立重评习惯。师生关系 3.4 与社交能力中的冲突解决 3.3 均为相对短板,可鼓励孩子主动向老师提问,从低风险的学业求助开始练习。'}
+  ]
+}
+];
+
+/* ===================== 渲染 ===================== */
+var tabsEl=document.getElementById('tabs'), screenEl=document.getElementById('screen');
+var stageTitle=document.getElementById('stageTitle'), stageDesc=document.getElementById('stageDesc');
+var activeId=REPORTS[0].id;
+
+function buildTabs(){
+  tabsEl.innerHTML = REPORTS.map(function(r){
+    return '<button class="tab'+(r.id===activeId?' active':'')+'" data-id="'+r.id+'">'
+      +'<span class="tab-code">'+r.code+'</span>'
+      +'<span><span class="tab-name">'+r.name+'</span><br><span class="tab-desc">'+r.desc.split(' · ')[0]+'</span></span>'
+      +'</button>';
+  }).join('');
+  tabsEl.querySelectorAll('.tab').forEach(function(b){
+    b.addEventListener('click',function(){ activeId=b.getAttribute('data-id'); buildTabs(); render(); });
+  });
+}
+
+function render(){
+  var r=REPORTS.filter(function(x){return x.id===activeId;})[0];
+  var uid=0;
+  var h='';
+
+  /* Hero */
+  h+='<div class="hero"><div class="hero-c1"></div><div class="hero-c2"></div>'
+    +'<div class="hero-top"><div class="hero-name">'+MEMBER.name+'</div>'
+    +'<div class="hero-badges">'
+      +'<span class="bd">'+r.name+'</span>'
+      +'<span class="bd">'+MEMBER.gender+' · '+MEMBER.age+'</span>'
+      +'<span class="bd bd-strong">等级 '+r.level+'</span>'
+      +'<span class="bd">'+r.date+'</span>'
+    +'</div></div>'
+    +'<div class="hero-scores">'
+      +'<div class="hs"><div class="hs-num">'+r.total+'<small>分</small></div><div class="hs-lab">综合得分</div></div>'
+      +'<div class="hs"><div class="hs-num">'+r.percentile+'<small>%</small></div><div class="hs-lab">百分位</div></div>'
+    +'</div>'
+    +'<div class="pct-wrap"><div class="pct-track"><div class="pct-fill" style="width:'+r.percentile+'%"></div></div>'
+      +'<div class="pct-txt">超过 '+r.percentile+'% 的同龄人</div></div>'
+    +'</div>';
+
+  /* Blocks */
+  r.blocks.forEach(function(b){
+    b._id='txt'+(uid++);
+    h+='<div class="card"><div class="card-hd"><div class="card-title">'+b.title+'</div>'
+      +(b.note?'<div class="card-note">'+b.note+'</div>':'')+'</div>'
+      +(RENDER[b.type]?RENDER[b.type](b):'')+'</div>';
+  });
+
+  h+='<div class="foot">本报告由 DAN 测评系统生成,结果仅供成长参考,不构成医学或教育诊断结论。</div>';
+  screenEl.innerHTML=h;
+  stageTitle.textContent=r.code+' · '+r.name;
+  stageDesc.textContent=r.desc;
+
+  /* 展开/收起 */
+  screenEl.querySelectorAll('.more').forEach(function(btn){
+    btn.addEventListener('click',function(){
+      var el=document.getElementById(btn.getAttribute('data-t'));
+      if(el.classList.contains('collapsed')){ el.classList.remove('collapsed'); btn.textContent='收起 ‹'; }
+      else { el.classList.add('collapsed'); btn.textContent='展开全文 ›'; }
+    });
+  });
+}
+
+buildTabs();
+render();
+</script>
+</body>
+</html>

+ 198 - 0
docs/superpowers/plans/2026-10-31-product-bundle-autopricing.md

@@ -0,0 +1,198 @@
+# 实施计划:订单套餐自动定价(Product Bundle Auto-Pricing)
+
+- 日期:2026-10-31
+- 上游文档:`docs/superpowers/specs/2026-10-30-product-bundle-autopricing-design.md`(已获用户批准)
+- 目标分支:`cfclub`
+- 影响:cfc-backend(结算/迁移/权威重算)、cfc-frontend(小程序展示,需用户自行构建上传)
+- 不包含:cfc-web 管理端 bundle_sku_id 配置 UI(设计文档标为可选,本期不下沉);DAN 示例数据(不随迁移发布)
+
+## 最终业务规则(用户已拍板)
+
+1. 套餐定义:`product_bundle_items` 表新增 `bundle_sku_id` 列(套餐 SKU 锚点,套餐价 = 该 SKU 的 `price`)。`bundle_sku_id` 非空 = 定价套餐行;为空 = 既有出库联动行(`InventoryService` 不动)。
+2. 结算时扫描下单商品:基础款(`child_product_id` + 可选 `child_sku_id`,`quantity` 为组成数量)数量凑够 → 该部分自动按套餐价结算。
+3. 凑整 + 单品兜底:买 3 个、套餐组成 2 个 → 1 套套餐 + 1 个单品。
+4. 多套餐可凑时**贪心选节省额最大**(每轮取最大节省额套餐,扣减组成数量后重试)。
+5. **节省额 = Σ(消费的下单口径 unitPrice×qty) − 套餐价,仅当节省额 > 0 才触发**(避免会员价低于套餐价时“按套餐更贵”)。
+6. **后端权威**:下单金额由 `ProductOrderService` 服务端重算套餐优惠计入 `moneyAmount`;前端传入的 `discountAmount` 语义改为“规则优惠(不含套餐)”,套餐部分一律服务端算。
+7. 废弃“送会员商品原价达标 → 一口价 1980”:仅移除 `CartService.computeGiftPromo` 的读取与结算;**保留** `ProductOrderService` 基于 `gift_membership_original_threshold` 的单笔订单送会员权益**与** `gift_membership_threshold` 累计送会员(均不动)。
+8. 页面只提示优惠(不做换购);适用范围:所有配置了定价套餐行的商品。
+
+## 关键代码事实(勘察结论,写码前勿再翻查)
+
+### 后端
+- **CartItemDTO.GiftPromo**(`dto/CartItemDTO.java:58-71`):`giftOriginalTotal/threshold/payPrice/discount/active/remaining`。本次**新增** `BundlePromo` 嵌套类,`giftPromo` 字段保留但置空(避免旧小程序读 `giftPromo.active` 出错)。
+- **CartService.computeGiftPromo**(`service/CartService.java:411-490`):读取 `gift_membership_original_threshold` / `gift_membership_pay_price`,算一口价。**整段替换**为套餐定价。调用点在 `CartService` 购物车列表方法(约 130-135,`dto.setGiftPromo(...)`)。依赖注入在 45-51 行(`productSkuService/productGiftRuleService/sysConfigService` 等)。
+- **CartService.buildCartItemDTO**(320-405):unitPrice 解析 = linked→linked.price / sku→sku.price / 否则 product.price;`memberPrice` 仅展示不参与 unitPrice。购物车当前**非会员口径**。
+- **ProductOrderService.createMultiItem(dto, buyerId)**(740-870):
+  - 循环 742-819:行项解析——`unitPrice = getEligibleMemberPrice(product, buyerId)`;有 sku 则 linked→换 product 用 linked.price,legacy→sku.price;`totalAmount += unitPrice*qty`;`OrderItem` 记录 `product.getId()`(**解析后** id)、`item.getSkuId()`、unitPrice。
+  - 820 后:`multiDiscountAmount = dto.getDiscountAmount()`(客户端)→ `+couponDiscount`(`couponService.apply`,服务端校验)→ `moneyAmount = max(0, totalAmount − pointsCost − multiDiscountAmount)`。
+  - **改造点**:循环内收集 LineItem(解析后 productId、skuId、qty、unitPrice),循环后 `bundleDiscount = bundlePricingService.compute(lines).getDiscount()`,`multiDiscountAmount += bundleDiscount`。
+- **ProductOrderService.getEligibleMemberPrice(product, buyerId)**(2213-2260):会员折扣逻辑(memberPrice>0 且等级 >= minDiscountLevel → memberPrice,否则 price)。**抽出**为共享组件,供 CartService 展示口径对齐。
+- **ProductOrderService** 送会员权益读取(约 1157,`gift_membership_original_threshold`)**不碰**。
+- **ProductBundleItemMapper**:纯 `BaseMapper<ProductBundleItem>`(无自定义方法)。加载定价行用 `selectList(new QueryWrapper<ProductBundleItem>().isNotNull("bundle_sku_id"))`。
+- **ProductBundleItem**(entity):`id/bundleProductId/childProductId/childSkuId/quantity/createdAt`。加 `bundleSkuId`。
+- **ProductSku**(entity):`id/productId/skuCode/specs/price/.../label/...`。套餐锚点名取 `label`(非空)否则 `specs`;价取 `price`。
+- **Migration 模式**:`DatabaseInitializer` 有 `columnExists(table, column)`(5373)辅助;`ALTER TABLE ... ADD COLUMN ...` 示例见 158-202。
+- **单测**:`src/test/java/com/etotem/cfc/unit/*Test.java`,JUnit5 + Mockito(config 测试已用)。纯算法类直接实例化。
+
+### 前端(cfc-frontend,微信小程序 uni-app)
+- **cart.vue**:`this.giftPromo = result.giftPromo`(约 316);`totalAmount()`(约 177-184)`if giftPromo.active → originalTotalAmount - giftPromo.discount`;gift-promo-section(96-112);写 storage `checkoutGiftPromo`(约 424)。
+- **checkout.vue**:onLoad 读 `checkoutGiftPromo`(648);gift-promo-section(229-240);`giftPromoDiscount` computed(576-580);提交 `discountAmount: discountInfo.discountAmount + giftPromoDiscount`(约 1152)。
+- **jest ^26.6.3**,测试放 `__tests__/utils/*.test.js`(纯函数)。
+
+---
+
+## Task 1:迁移 `product_bundle_items.bundle_sku_id`
+
+**TDD 说明**:迁移幂等性无法纯单测;沿用现有 `DatabaseInitializer` 迁移模式(列存在检查),部署后验证“重复执行不报错”。
+
+**改动**:
+- `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`:新增幂等迁移块(参照现有 `columnExists` 模式):
+  ```java
+  if (!columnExists("product_bundle_items", "bundle_sku_id")) {
+      jdbcTemplate.execute("ALTER TABLE product_bundle_items ADD COLUMN bundle_sku_id BIGINT NULL COMMENT '套餐SKU锚点:非空=定价套餐行(套餐价取该SKU.price);空=出库联动行'");
+  }
+  ```
+- 同步 `schema.sql`(cfc-backend 资源目录,找到 `product_bundle_items` 建表处补列)。
+- `entity/ProductBundleItem.java` 加 `private Long bundleSkuId;`。
+
+**提交**:`feat(bundle): product_bundle_items 新增 bundle_sku_id 列(定价套餐锚点,迁移幂等)`
+
+## Task 2:纯算法 `BundlePricingCalculator`(TDD 核心)
+
+新建 `cfc-backend/src/main/java/com/etotem/cfc/pricing/BundlePricingCalculator.java`(无状态、可实例化)。
+
+**数据结构**(放同包 `bundle` 下或作为嵌套静态类,随实现定):
+- `LineItem`:`{ Long productId; Long skuId; Integer quantity; Integer unitPrice; }`
+- `BundleChild`:`{ Long childProductId; Long childSkuId; Integer quantity; }`
+- `BundleDef`:`{ Long bundleSkuId; String bundleName; Integer bundlePrice; List<BundleChild> children; }`
+- `BundleHit`:`{ Long bundleSkuId; String bundleName; Integer times; Integer originalTotal; Integer bundlePrice; Integer saving; }`
+- `BundleDiscountResult`:`{ boolean active; Integer originalTotal; Integer payPrice; Integer discount; List<BundleHit> bundleList; }`
+
+**公开方法**:`BundleDiscountResult compute(List<LineItem> lines, List<BundleDef> defs)`
+
+**算法(贪心省最多)**:
+1. 聚合池:按 `(productId, skuId)` 归总 quantity;unitPrice 取该组行内(相同 sku 的行 unitPrice 一致;`skuId==null` 组取 product 级 unitPrice)。
+2. 每轮:对每个 def,判定可凑 1 套——每个 child 的池供应量(`childSkuId!=null` 精确匹配 sku;`==null` 按 productId 聚合所有 sku 行)≥ child.quantity。可凑时算节省额:按“消费顺序”(遍历池中匹配行、从前往后扣减)取 `child.quantity` 个的 `Σ(unitPrice)` 之和,再对全体 child 求和后减 `bundlePrice`。**仅节省额 > 0 记候选**。
+3. 取节省额最大候选中标;扣减池;累计 `hit.times++`、`discount += saving`、`originalTotal += 原价合计`、`payPrice += bundlePrice`。
+4. 无候选即终止。`active = discount > 0`;空 defs/空 lines → inactive。
+
+**单元测试** `src/test/java/com/etotem/cfc/unit/BundlePricingCalculatorTest.java`(先写测试):
+1. 单套餐命中:2×699(unitPrice 69900)vs def(128000, child product10 qty2) → active、discount 11800、originalTotal 139800、payPrice 128000。
+2. 凑整+兜底:3×699 → 1 hit(times=1)、剩余 1×699 单算 → discount 11800。
+3. 多套餐省最多:同 child 两个 def(节省 11800 vs 29800)→ 选 29800。
+4. 节省≤0 不触发:member 价 55900×2=111800 < 128000 → inactive。
+5. childSkuId 空按商品聚合:两行不同 sku(product10 skuA 69900×1、skuB 59900×1)配 product10 qty2 的 def → 命中,原价合计 129800(消费顺序扣减)。
+6. 空 defs / 空 lines → inactive。
+7. bundlePrice > 原价合计 → 不触发。
+8. 两个独立套餐(不同 child、不同基础款)同单各自命中。
+
+**提交**:`feat(bundle): BundlePricingCalculator 贪心省最多套餐匹配算法 + 单测`
+
+## Task 3:`BundlePricingService`(Spring 装载定义 + 委托)
+
+新建 `cfc-backend/src/main/java/com/etotem/cfc/service/BundlePricingService.java`(`@Service`)。
+
+**注入**:`ProductBundleItemMapper`、`ProductSkuService`、`ProductMapper`(供 sku 不存在时的降级表现)。
+
+**方法**:
+- `BundleDiscountResult compute(List<LineItem> lines)`:
+  1. `productBundleItemMapper.selectList(new QueryWrapper<ProductBundleItem>().isNotNull("bundle_sku_id"))`。
+  2. 按 `bundleSkuId` 分组 → `BundleDef`;`bundlePrice = sku.price`;`bundleName = sku.label 非空 ? label : specs`;skus 不存在该 id 则跳过该 def(防御)。
+  3. 委托 `BundlePricingCalculator.compute(lines, defs)`。
+
+**单元测试**(Mockito mock mapper + skuService):
+- 组装正确(def 数量/children/价格/名称)并委托 calculator;bundle sku 缺失跳过。
+
+**提交**:`feat(bundle): BundlePricingService 装载定价套餐定义并委托计算`
+
+## Task 4:抽共享价格口径 `MemberPriceResolver`
+
+新建 `cfc-backend/src/main/java/com/etotem/cfc/service/MemberPriceResolver.java`(`@Service`),把 `ProductOrderService.getEligibleMemberPrice`(2213-2260)原样抽出:注入 `UserMapper`、`MembershipService`。
+
+**方法**:
+- `int resolveEligibleMemberPrice(Product product, Long buyerId)`(原逻辑,行为不变)。
+- `ResolvedLine resolve(Product product, ProductSku sku, Long buyerId)`:返回 `{ Product product(解析后); int unitPrice; }`——linked sku → 换 product 用 `linkedProduct.price`;legacy sku → `sku.price`;无 sku → `resolveEligibleMemberPrice(product, buyerId)`。**与 `createMultiItem` 767-782 同口径**。
+
+**改造**:`ProductOrderService` 的 `getEligibleMemberPrice` 私有方法改为委托 resolver(行为保持,多处调用点不变);`createMultiItem` 的中间解析逻辑改调 `resolver.resolve(...)`(保持同行为)。
+
+**单元测试** `MemberPriceResolverTest`:memberPrice+等级达标/不达标、minDiscountLevel、linked sku、legacy sku.price、无 sku。
+
+**提交**:`refactor(order): 抽出 MemberPriceResolver 共享下单价格口径(行为不变)`
+
+## Task 5:CartService 购物车返回 `bundlePromo`(替换一口价)
+
+**改动 `dto/CartItemDTO.java`**:新增嵌套类 `BundleItemHit`(`bundleSkuId/bundleName/times/originalTotal/bundlePrice/saving`)与 `BundlePromo`(`active/originalTotal/payPrice/discount/bundleList`)+ 字段 `private BundlePromo bundlePromo;`。`GiftPromo` 保留。
+
+**改动 `service/CartService.java`**:
+- 删除 `computeGiftPromo`(411-490)中读取 `gift_membership_original_threshold` / `gift_membership_pay_price` 及一口价计算,整体替换为 `computeBundlePromo(List<Cart> cartList, Map<Long,Product> productMap, Long userId)`:
+  1. 逐购物车项构建 `LineItem`:resolver 解析 sku/linked/member 得到**下单口径 unitPrice**(保证展示=实付),`skuId` 传原始 `cart.getSkuId()`(bundle 匹配用原始身份;linked 转发产品才用解析后 id——与下单一致,取 resolver 返回的解析后 `product.getId()` 作 productId、`skuId` 用解析后的 skuId:linked sku 在订单里 skuId 仍是原 skuId,故 LineItem.skuId = cart.getSkuId(),productId = 解析后 product id)。
+  2. `BundleDiscountResult r = bundlePricingService.compute(lines)` → `dto.setBundlePromo(...)`;`dto.setGiftPromo(buildEmptyGiftPromo())`。
+- 购物车列表方法(约 130-135)调用点改为 `computeBundlePromo`。需要 `userId`(列表方法已有当前用户上下文)。
+- `sysConfigService` 若不再使用可保留注入(或剔除,随实现)。
+
+**单元测试**:可用 Mockito 对 CartService 最小装配验证 `bundlePromo` 绑定与 `giftPromo` 置空(mock mapper/resolver/bundlePricingService)。
+
+**提交**:`feat(bundle): 购物车返回 bundlePromo,移除一口价(threshold/payPrice)结算`
+
+## Task 6:ProductOrderService 后端权威(核心)
+
+**改动 `service/ProductOrderService.java`**:
+- `createMultiItem`(742-819 循环内):收集 `List<LineItem> lines`(`new LineItem(product.getId(), item.getSkuId(), item.getQuantity(), unitPrice)`,product 为解析后)。
+- 循环后、`multiDiscountAmount` 处(约 838):`int bundleDiscount = bundlePricingService.compute(lines).getDiscount();` → `multiDiscountAmount = (dto.getDiscountAmount() ?? 0) + couponDiscount + bundleDiscount;`(**语义:`dto.getDiscountAmount()` 现仅含规则优惠**)。
+- 单件 `create()`(227-310):同样收集单行(解析后 productId、`dto.getSkuId()`、`dto.getQuantity()`、最终 unitPrice)并在 `discountAmount` 处 `+ bundleDiscount`,保证“商品页直接购买多件”也享受套餐价。
+- 注入 `BundlePricingService`。
+
+**单元测试**(新 `OrderBundlePricingTest`,Mockito mock bundlePricingService + 现有依赖桩):
+1. 多件下单 `moneyAmount = totalAmount − pointsCost − 规则优惠 − 套餐优惠`(套餐优惠=服务端计算值)。
+2. 抗伪造:客户端传的 `discountAmount` 若含“套餐部分”,服务端以其**重算值**为准(mock 返回固定 bundleDiscount,断言只计入一次)。
+3. 无套餐可匹配(bundlePricingService 返回 inactive/discount=0)→ moneyAmount 不含套餐优惠,且**不再产生一口价 1980**。
+4. 送会员权益读取(`gift_membership_original_threshold` 单笔达标赠会员)仍生效(该逻辑未触碰,作回归断言)。
+
+**提交**:`feat(bundle): 下单后端权威重算套餐优惠,前端折扣仅规则优惠`
+
+## Task 7:前端(cart.vue / checkout.vue)
+
+**新建 `utils/bundle-pricing.js`**(纯函数,供展示汇总与单测):
+- `summarizeBundlePromo(bundlePromo)` → `{ originalTotal, payPrice, saving, rows: [{name, times, saving, ...}] }`(防御空值)。
+
+**新建 `__tests__/utils/bundle-pricing.test.js`**:空值/正常/多行汇总。
+
+**cart.vue**:
+- 读取 `result.bundlePromo`(替换 316 行 `giftPromo`)。
+- `totalAmount()`(177-184):`if bundlePromo && bundlePromo.active && discount>0 → originalTotalAmount − bundlePromo.discount`。
+- gift-promo-section(96-112)→ **bundle-promo-section**:命中明细行(`bundleList`:套餐名 ×times,原价合计 → 套餐价,省 X 元)+ 合计“已按套餐价结算,共优惠 Y 元”。无命中不显示。
+- storage 写入 `checkoutBundlePromo`(424 行替换 `checkoutGiftPromo`)。
+
+**checkout.vue**:
+- onLoad 读 `checkoutBundlePromo`(648 行)。
+- gift-promo-section(229-240)→ bundle-promo-section(同上文案)。
+- `giftPromoDiscount` (576) → `bundlePromoDiscount`(读 `bundlePromo.discount`)。
+- 提交(1152):`discountAmount: (discountInfo ? discountInfo.discountAmount : 0)`——**去掉 `+ giftPromoDiscount`**(套餐优惠由后端权威重算)。
+- 确认 `calcAmount`/合计展示扣减 `bundlePromoDiscount`。
+
+**注意**:小程序改动需你自行构建(`npm run build:mp-weixin` 或既有流程)后在微信开发者工具上传;后端部署即可生效(旧小程序在过渡期看到 `giftPromo.active=false`、不吃亏,见设计“过渡安全”说明)。
+
+**提交**(在远程 cfc-frontend 内):`feat(bundle): 购物车/结算页套餐优惠展示,提交仅传规则优惠`
+
+## Task 8:验证与部署(go/no-go 确认后执行)
+
+1. **本地/远程构建**:`cd /app/cfc/cfc-backend && mvn -q test`(跑全部单测,含新增);`mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true`。
+2. **预检 + 备份**:按 AGENTS.md——`git status`、`git fetch && git pull --rebase origin cfclub`、备份 `/app/pro/cfc`、健康基线 curl。
+3. **部署**:jar 到位 → `systemctl restart cfc-backend` → 日志检查(Tomcat started、0 ERROR)。
+4. **验证**:
+   - `/api/cart/list` 返回 `bundlePromo`;`giftPromo.active=false`。
+   - 配一行临时套餐数据(如一条 bundle_sku_id 定义)→ 购物车放足量基础款 → 页面展示套餐价与节省。
+   - 下单 `moneyAmount` = 服务端重算值(与展示一致);篡改前端 discountAmount 无效。
+   - 送会员(单笔/累计)触发不受影响。
+   - 迁移幂等:重启/重复执行不报错(列已存在忽略)。
+5. **留档**:迁移编号/时间、验证结果记入本计划文件末尾 `## 验证记录` 或口头汇报。
+
+## 部署顺序与过渡说明
+
+- **后端先行**(本次 deploy 即生效):`cart/list` 开始返回 `bundlePromo`、`giftPromo` 置空;下单后端权威重算。旧小程序:`giftPromo.active=false` → 不显示一口价、total 不减 → 提交的 `discountAmount` 无套餐部分 → 服务端补套餐优惠,实付=服务端值(安全,不会出现“旧前端多打折”)→ **请尽快构建上传新小程序**以保证展示一致。
+- **cfc-web(管理端)**:本期不做 UI;`bundle_sku_id` 配置走 SQL(示例见设计文档 §6)。
+
+## 不做(明确排除)
+
+- 不做换购交互、不做管理端 UI、不发布 DAN 种子数据、不删 `attachBundleMatches`(技术债保留)、不改 `InventoryService` 出库联动、不动送会员权益触发。

+ 1312 - 0
docs/superpowers/plans/2026-10-31-sku-combo.md

@@ -0,0 +1,1312 @@
+# SKU 商品组合 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让商品 SKU 的「关联商品」升级为「商品组合」——一个 SKU 关联多个商品(各自数量、有 SKU 的商品必须选具体 SKU),手填组合售价,整体售卖,支付时按组成商品扣库存、退款时对称回补。
+
+**Architecture:** 新增子表 `product_sku_combo_items` 存组合明细;`ProductSku.comboItems` 为非表字段,由 Service 统一回填;判定优先级固定为 comboItems → linkedProductId → legacy specs。`product_orders` 增 `sku_id`,支付/退款据此识别组合订单,扣减/回补严格对称(child_sku_id 优先,否则 child_product_id)。
+
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8.0.34(JUnit 5 + Mockito);Vue 2 + Element UI + Jest。
+
+**Spec:** `docs/superpowers/specs/2026-10-31-sku-combo-design.md`
+
+## Global Constraints
+
+- 所有代码改动在远程 `/app/cfc`(SSH `root@111.228.6.214`)执行;本地 `/root/cfc` 只是壳。
+- 分支 `cfclub`;每任务完成即提交(`git fetch origin && git pull --rebase origin cfclub` 后提交)。
+- 迁移编号固定 **432**,并同步 `cfc-backend/src/main/resources/schema.sql`。
+- 不改动 `product_bundle_items`(套餐)逻辑。
+- 不改动现有非组合、非 linked、非 bundle 订单的任何路径。
+- 组合订单:**不扣父商品库存**;取消/退款/后台关闭 3 处必须回补组成商品。
+- 组成商品有启用 SKU 时,`child_sku_id` 必填;否则拒绝保存。
+- `ProductSku.update` 清除 `linked_product_id` 必须用 `LambdaUpdateWrapper.set(..., null)`(MyBatis-Plus `updateById` 忽略 null)。
+- 后端编译:`cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`。
+- 后端单测:`mvn -q test -Dtest=<Class>`。
+- 前端测试:`cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage`。
+- 部署严格按 `AGENTS.md`:备份 → 部署 → 健康检查;禁止未确认改动 `/app/pro/cfc`。
+
+## Review Focus
+
+1. **不双扣**:组合订单支付成功时跳过父商品扣减,只扣组合明细。
+2. **对称回补**:`cancel` / `refund` / `adminClose` 3 处都走组合回补,且不补父商品。
+3. **旧路径零回归**:`linked_product_id` SKU 与 legacy SKU 的加购、下单、详情、退款行为不变。
+4. **有 SKU 必选 SKU**:组成商品存在启用 SKU 时 `childSkuId` 必须非空。
+5. **无可用 SKU 的商品不可选**:某商品的启用 SKU 全部是组合 SKU 时,不得被当作「无 SKU 商品」按 `product.stock` 扣减。
+6. **父商品库存不拦截组合下单**:`createMultiItem` 的父商品 `stock` 校验必须对组合行跳过。
+
+---
+
+### Task 1: 迁移 432 + schema.sql
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(`runMigrations()` 末尾 + 新增 `migrateSkuCombo()`)
+- Modify: `cfc-backend/src/main/resources/schema.sql`(`product_orders` 加 `sku_id`;`product_bundle_items` 后新增组合表)
+
+**Interfaces:**
+- Produces: 表 `product_sku_combo_items(id, sku_id, child_product_id, child_sku_id, quantity, created_at)`;列 `product_orders.sku_id`。
+
+- [ ] **Step 1: 在 `runMigrations()` 末尾(`migrateFamilyNameBackfill();` 之后)追加调用**
+
+```java
+        // 迁移432: SKU商品组合明细表 + 订单记录下单SKU(组合SKU识别)
+        migrateSkuCombo();
+```
+
+- [ ] **Step 2: 在 `createCouponGiftTransferTable()` 之后新增迁移方法(幂等;参照迁移 428 写法)**
+
+```java
+    // ==================== 迁移432:SKU 商品组合 ====================
+
+    void migrateSkuCombo() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_sku_combo_items` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`sku_id` BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)', "
+                    + "`child_product_id` BIGINT NOT NULL COMMENT '组成商品ID', "
+                    + "`child_sku_id` BIGINT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)', "
+                    + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "INDEX `idx_sku` (`sku_id`), "
+                    + "INDEX `idx_child_product` (`child_product_id`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细'");
+            log.info("迁移432: 已创建 product_sku_combo_items 表");
+        } catch (Exception e) {
+            log.warn("迁移432: 创建 product_sku_combo_items 表失败(可能已存在): {}", e.getMessage());
+        }
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'product_orders' AND COLUMN_NAME = 'sku_id'",
+                    Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN sku_id BIGINT NULL COMMENT '下单SKU ID(组合SKU识别用)' AFTER product_type");
+                log.info("迁移432: 已为 product_orders 增加 sku_id 列");
+            } else {
+                log.info("迁移432: product_orders.sku_id 列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("迁移432: 检查/添加 product_orders.sku_id 列失败: {}", e.getMessage());
+        }
+    }
+```
+
+- [ ] **Step 3: `schema.sql` 中 `product_orders` 的 `product_type` 后加列**
+
+```sql
+    sku_id BIGINT COMMENT '下单SKU ID(组合SKU识别用)',
+```
+
+- [ ] **Step 4: `schema.sql` 中 `product_bundle_items` 之后新增表**
+
+```sql
+-- 商品SKU组合明细表(组合SKU = 多个商品/子SKU + 数量,整体手填售价售卖)
+CREATE TABLE IF NOT EXISTS product_sku_combo_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    sku_id BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)',
+    child_product_id BIGINT NOT NULL COMMENT '组成商品ID',
+    child_sku_id BIGINT DEFAULT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)',
+    quantity INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_sku (sku_id),
+    INDEX idx_child_product (child_product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细';
+```
+
+- [ ] **Step 5: 编译验证**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
+Expected: BUILD SUCCESS(无输出错误)
+
+- [ ] **Step 6: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(backend): 迁移432 SKU商品组合明细表与订单sku_id"
+```
+
+---
+
+### Task 2: 实体 / Mapper / DTO
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java`
+
+**Interfaces:**
+- Consumes: 表结构(Task 1)。
+- Produces: `ProductSkuComboItem`(字段 `id, skuId, childProductId, childSkuId, quantity, createdAt` + 非表字段 `childProductName, childProductImage, childProductPrice, childSkuSpecs`);`ProductSku.getComboItems()/setComboItems(List)`;`ProductOrder.getSkuId()/setSkuId(Long)`;`ProductSkuDTO.getComboItems()` 与 `getLinkedProductId()`。
+
+- [ ] **Step 1: 创建实体 `ProductSkuComboItem.java`**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("product_sku_combo_items")
+public class ProductSkuComboItem implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long skuId;
+    private Long childProductId;
+    private Long childSkuId;
+    private Integer quantity;
+    private Date createdAt;
+
+    @TableField(exist = false)
+    private String childProductName;
+    @TableField(exist = false)
+    private String childProductImage;
+    @TableField(exist = false)
+    private Integer childProductPrice;
+    @TableField(exist = false)
+    private String childSkuSpecs;
+}
+```
+
+- [ ] **Step 2: 创建 Mapper**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+
+public interface ProductSkuComboItemMapper extends BaseMapper<ProductSkuComboItem> {
+}
+```
+
+- [ ] **Step 3: `ProductSku.java` 末尾(`updateTime` 后)增加**
+
+```java
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private java.util.List<ProductSkuComboItem> comboItems;
+
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String linkedProductName;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer linkedProductPrice;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String linkedProductImage;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer linkedProductStock;
+```
+
+- [ ] **Step 4: `ProductOrder.java` 的 `productType` 后增加**
+
+```java
+    private Long skuId;
+```
+
+- [ ] **Step 5: `ProductSkuDTO.java` 增加字段,并在 `from()` 的 `return dto;` 前拷贝**
+
+```java
+    private Long linkedProductId;
+    private String linkedProductName;
+    private Integer linkedProductPrice;
+    private String linkedProductImage;
+    private Integer linkedProductStock;
+    private java.util.List<com.etotem.cfc.entity.ProductSkuComboItem> comboItems;
+```
+
+```java
+        dto.setLinkedProductId(sku.getLinkedProductId());
+        dto.setLinkedProductName(sku.getLinkedProductName());
+        dto.setLinkedProductPrice(sku.getLinkedProductPrice());
+        dto.setLinkedProductImage(sku.getLinkedProductImage());
+        dto.setLinkedProductStock(sku.getLinkedProductStock());
+        dto.setComboItems(sku.getComboItems());
+```
+
+- [ ] **Step 6: 编译 + 提交**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
+Expected: BUILD SUCCESS
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java
+git commit -m "feat(backend): 组合SKU实体/DTO与Mapper"
+```
+
+---
+
+### Task 3: ProductSkuService 组合读写 + 单测(TDD)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java`
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java`
+
+**Interfaces:**
+- Consumes: `ProductSkuComboItemMapper`、`ProductSku.getComboItems()`、`ProductMapper.selectBatchIds`、`ProductSkuMapper.selectBatchIds`。
+- Produces:
+  - `boolean hasComboItems(ProductSku sku)`
+  - `List<ProductSkuComboItem> listComboItems(Long skuId)`
+  - `void fillComboItems(List<ProductSku> skus)`
+  - `String buildComboSummary(List<ProductSkuComboItem> items)`(格式 `商品A×2;商品B×1`)
+  - `create/update` 支持 `comboItems`(null=不改,空数组=清除,非空=整表替换)
+
+- [ ] **Step 1: 先写失败测试 `ProductSkuComboServiceTest`**
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuComboItemMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.service.ProductSkuService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class ProductSkuComboServiceTest {
+
+    private ProductSkuService service;
+    private ProductSkuMapper skuMapper;
+    private ProductMapper productMapper;
+    private ProductSkuComboItemMapper comboItemMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new ProductSkuService();
+        skuMapper = mock(ProductSkuMapper.class);
+        productMapper = mock(ProductMapper.class);
+        comboItemMapper = mock(ProductSkuComboItemMapper.class);
+        ReflectionTestUtils.setField(service, "skuMapper", skuMapper);
+        ReflectionTestUtils.setField(service, "productMapper", productMapper);
+        ReflectionTestUtils.setField(service, "comboItemMapper", comboItemMapper);
+    }
+
+    private ProductSkuComboItem item(Long productId, Long skuId, int qty) {
+        ProductSkuComboItem it = new ProductSkuComboItem();
+        it.setChildProductId(productId);
+        it.setChildSkuId(skuId);
+        it.setQuantity(qty);
+        return it;
+    }
+
+    @Test
+    void create_withComboItems_persistsRowsWithSkuId() {
+        ProductSku sku = new ProductSku();
+        sku.setProductId(1L);
+        sku.setComboItems(Arrays.asList(item(10L, 100L, 2), item(11L, null, 1)));
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.emptyList());
+        when(skuMapper.findByProductId(11L)).thenReturn(Collections.emptyList());
+        doAnswer(inv -> { sku.setId(55L); return 1; }).when(skuMapper).insert(any(ProductSku.class));
+
+        service.create(sku);
+
+        verify(comboItemMapper, times(2)).insert(any(ProductSkuComboItem.class));
+        assertEquals(55L, sku.getComboItems().get(0).getSkuId());
+    }
+
+    @Test
+    void update_emptyComboItems_deletesRows() {
+        ProductSku sku = new ProductSku();
+        sku.setId(55L);
+        sku.setComboItems(Collections.emptyList());
+
+        service.update(sku);
+
+        verify(comboItemMapper).delete(any());
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void update_comboItems_clearsLinkedProductId() {
+        ProductSku sku = new ProductSku();
+        sku.setId(55L);
+        sku.setComboItems(Collections.singletonList(item(10L, 100L, 1)));
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.emptyList());
+
+        service.update(sku);
+
+        verify(skuMapper).update(eq(null), any());
+    }
+
+    @Test
+    void create_childProductHasEnabledSku_withoutChildSkuId_throws() {
+        ProductSku childSku = new ProductSku();
+        childSku.setId(100L);
+        childSku.setEnabled(1);
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.singletonList(childSku));
+
+        ProductSku sku = new ProductSku();
+        sku.setProductId(1L);
+        sku.setComboItems(Collections.singletonList(item(10L, null, 1)));
+
+        assertThrows(IllegalArgumentException.class, () -> service.create(sku));
+        verify(skuMapper, never()).insert(any(ProductSku.class));
+    }
+
+    @Test
+    void buildComboSummary_formatsNameAndQuantity() {
+        ProductSkuComboItem a = item(10L, null, 2);
+        a.setChildProductName("商品A");
+        ProductSkuComboItem b = item(11L, null, 1);
+        b.setChildProductName("商品B");
+
+        assertEquals("商品A×2;商品B×1", service.buildComboSummary(Arrays.asList(a, b)));
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductSkuComboServiceTest`
+Expected: 编译失败 / 方法不存在(`hasComboItems` 等未定义)
+
+- [ ] **Step 3: 实现 Service**
+
+注入:
+
+```java
+    @Resource
+    private ProductSkuComboItemMapper comboItemMapper;
+```
+
+`create`:在 `skuMapper.insert(sku);` 前调用 `validateComboItems(sku.getComboItems());`,insert 之后调用 `saveComboItems(sku.getId(), sku.getComboItems());`。
+
+`update`:入口 `validateComboItems(sku.getComboItems());`;随后:
+
+```java
+        if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+            sku.setLinkedProductId(null);
+        }
+        sku.setUpdateTime(new Date());
+        skuMapper.updateById(sku);
+        saveComboItems(sku.getId(), sku.getComboItems());
+        if (sku.getLinkedProductId() == null) {
+            skuMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProductSku>()
+                    .eq(ProductSku::getId, sku.getId())
+                    .set(ProductSku::getLinkedProductId, null));
+        }
+```
+
+`delete`:先
+
+```java
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
+                .eq(ProductSkuComboItem::getSkuId, id));
+```
+
+`getById`:查询后 `fillComboItems(java.util.Collections.singletonList(sku));`;`listByProductId`/`listByProductIdAll`:查询后 `fillComboItems(list);`。
+
+私有/公开方法按 Task 3 Interfaces 的签名实现:
+
+```java
+    private void saveComboItems(Long skuId, List<ProductSkuComboItem> items) {
+        if (items == null) return;
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
+                .eq(ProductSkuComboItem::getSkuId, skuId));
+        if (items.isEmpty()) return;
+        for (ProductSkuComboItem item : items) {
+            item.setId(null);
+            item.setSkuId(skuId);
+            if (item.getQuantity() == null || item.getQuantity() < 1) item.setQuantity(1);
+            comboItemMapper.insert(item);
+        }
+    }
+
+    private void validateComboItems(List<ProductSkuComboItem> items) {
+        if (items == null || items.isEmpty()) return;
+        for (ProductSkuComboItem item : items) {
+            if (item.getChildProductId() == null) throw new IllegalArgumentException("组合商品不能为空");
+            List<ProductSku> childSkus = skuMapper.findByProductId(item.getChildProductId());
+            boolean hasEnabledSku = childSkus != null
+                    && childSkus.stream().anyMatch(s -> s.getEnabled() != null && s.getEnabled() == 1);
+            if (hasEnabledSku && item.getChildSkuId() == null) {
+                throw new IllegalArgumentException("组合商品存在 SKU,必须选择具体 SKU");
+            }
+        }
+    }
+```
+
+`fillComboItems`(批量回填子商品名/图/价、子 SKU 规格):
+
+```java
+    public void fillComboItems(List<ProductSku> skus) {
+        if (skus == null || skus.isEmpty()) return;
+        List<Long> skuIds = skus.stream().map(ProductSku::getId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toList());
+        if (skuIds.isEmpty()) return;
+        List<ProductSkuComboItem> all = comboItemMapper.selectList(
+                new LambdaQueryWrapper<ProductSkuComboItem>().in(ProductSkuComboItem::getSkuId, skuIds));
+        if (all.isEmpty()) {
+            skus.forEach(s -> s.setComboItems(java.util.Collections.emptyList()));
+            return;
+        }
+        java.util.Set<Long> productIds = all.stream().map(ProductSkuComboItem::getChildProductId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toSet());
+        java.util.Map<Long, Product> productMap = productIds.isEmpty() ? java.util.Collections.emptyMap()
+                : productMapper.selectBatchIds(productIds).stream()
+                    .collect(java.util.stream.Collectors.toMap(Product::getId, p -> p, (a, b) -> a));
+        java.util.Set<Long> childSkuIds = all.stream().map(ProductSkuComboItem::getChildSkuId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toSet());
+        java.util.Map<Long, ProductSku> childSkuMap = childSkuIds.isEmpty() ? java.util.Collections.emptyMap()
+                : skuMapper.selectBatchIds(childSkuIds).stream()
+                    .collect(java.util.stream.Collectors.toMap(ProductSku::getId, s -> s, (a, b) -> a));
+        for (ProductSkuComboItem item : all) {
+            Product p = productMap.get(item.getChildProductId());
+            if (p != null) {
+                item.setChildProductName(p.getName());
+                item.setChildProductImage(p.getCoverImage());
+                item.setChildProductPrice(p.getPrice());
+            }
+            ProductSku cs = item.getChildSkuId() == null ? null : childSkuMap.get(item.getChildSkuId());
+            if (cs != null) item.setChildSkuSpecs(cs.getSpecs());
+        }
+        java.util.Map<Long, List<ProductSkuComboItem>> grouped = all.stream()
+                .collect(java.util.stream.Collectors.groupingBy(ProductSkuComboItem::getSkuId));
+        for (ProductSku sku : skus) {
+            sku.setComboItems(grouped.getOrDefault(sku.getId(), java.util.Collections.emptyList()));
+        }
+    }
+
+    public boolean hasComboItems(ProductSku sku) {
+        return sku != null && sku.getComboItems() != null && !sku.getComboItems().isEmpty();
+    }
+
+    public List<ProductSkuComboItem> listComboItems(Long skuId) {
+        ProductSku tmp = new ProductSku();
+        tmp.setId(skuId);
+        fillComboItems(java.util.Collections.singletonList(tmp));
+        return tmp.getComboItems() == null ? java.util.Collections.emptyList() : tmp.getComboItems();
+    }
+
+    public String buildComboSummary(List<ProductSkuComboItem> items) {
+        if (items == null || items.isEmpty()) return "";
+        StringBuilder sb = new StringBuilder();
+        for (ProductSkuComboItem item : items) {
+            if (sb.length() > 0) sb.append(";");
+            String name = item.getChildProductName() != null ? item.getChildProductName()
+                    : ("商品#" + item.getChildProductId());
+            sb.append(name).append("×").append(item.getQuantity() == null ? 1 : item.getQuantity());
+        }
+        return sb.toString();
+    }
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductSkuComboServiceTest`
+Expected: Tests run: 5, Failures: 0, Errors: 0
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java
+git commit -m "feat(backend): 组合SKU服务层读写与校验"
+```
+
+---
+
+### Task 4: 管理端选择器接口(跨分类 + 子 SKU + hasSkus)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java`
+
+**Interfaces:**
+- Consumes: `ProductSkuMapper.findByProductIdAll`、`ProductSkuComboItemMapper`。
+- Produces: `POST /api/admin/product/linked/list` 新增参数 `crossCategory`;`data.list[]` 在 `crossCategory=true` 时增加 `skus`(已启用且非组合 SKU)与 `hasSkus`(该商品是否存在任何启用 SKU)。
+
+- [ ] **Step 1: 注入依赖**
+
+```java
+    @Resource
+    private com.etotem.cfc.mapper.ProductSkuMapper productSkuMapper;
+    @Resource
+    private com.etotem.cfc.mapper.ProductSkuComboItemMapper comboItemMapper;
+```
+
+- [ ] **Step 2: 参数解析与查询条件**
+
+在方法开头追加:
+
+```java
+        boolean crossCategory = Boolean.parseBoolean(String.valueOf(params.getOrDefault("crossCategory", "false")));
+```
+
+把现有 wrapper 条件替换为:
+
+```java
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Product::getStatus, "on_shelf");
+        if (!crossCategory) {
+            if (categoryId == null && productId != null) {
+                Product product = productMapper.selectById(productId);
+                if (product != null) categoryId = product.getCategoryId();
+            }
+            if (categoryId != null) wrapper.eq(Product::getCategoryId, categoryId);
+        } else {
+            if (productId != null) wrapper.ne(Product::getId, productId);
+            wrapper.ne(Product::getProductType, "bundle");
+        }
+        if (keyword != null && !keyword.trim().isEmpty()) wrapper.like(Product::getName, keyword.trim());
+```
+
+- [ ] **Step 3: 记录转 Map,并在跨分类时附加 `skus` / `hasSkus`**
+
+```java
+        java.util.List<java.util.Map<String, Object>> list = new java.util.ArrayList<>();
+        for (Product p : pageResult.getRecords()) {
+            java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
+            m.put("id", p.getId());
+            m.put("name", p.getName());
+            m.put("coverImage", p.getCoverImage());
+            m.put("price", p.getPrice());
+            m.put("stock", p.getStock());
+            m.put("categoryId", p.getCategoryId());
+            m.put("productType", p.getProductType());
+            if (crossCategory) {
+                java.util.List<ProductSku> allSkus = productSkuMapper.findByProductIdAll(p.getId()).stream()
+                        .filter(s -> s.getEnabled() != null && s.getEnabled() == 1)
+                        .collect(java.util.stream.Collectors.toList());
+                m.put("hasSkus", !allSkus.isEmpty());
+                java.util.Set<Long> comboSkuIds = java.util.Collections.emptySet();
+                if (!allSkus.isEmpty()) {
+                    java.util.List<Long> ids = allSkus.stream().map(ProductSku::getId)
+                            .collect(java.util.stream.Collectors.toList());
+                    comboSkuIds = comboItemMapper.selectList(
+                                    new LambdaQueryWrapper<ProductSkuComboItem>()
+                                            .in(ProductSkuComboItem::getSkuId, ids)).stream()
+                            .map(ProductSkuComboItem::getSkuId)
+                            .collect(java.util.stream.Collectors.toSet());
+                }
+                java.util.List<java.util.Map<String, Object>> skuList = new java.util.ArrayList<>();
+                for (ProductSku s : allSkus) {
+                    if (comboSkuIds.contains(s.getId())) continue;
+                    java.util.Map<String, Object> sm = new java.util.LinkedHashMap<>();
+                    sm.put("id", s.getId());
+                    sm.put("specs", s.getSpecs());
+                    sm.put("price", s.getPrice());
+                    sm.put("stock", s.getStock());
+                    skuList.add(sm);
+                }
+                m.put("skus", skuList);
+            }
+            list.add(m);
+        }
+        result.put("list", list);
+```
+
+补 import:`com.etotem.cfc.entity.ProductSku`、`com.etotem.cfc.entity.ProductSkuComboItem`。
+
+- [ ] **Step 4: 编译 + 提交**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
+Expected: BUILD SUCCESS
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java
+git commit -m "feat(backend): 商品选择器支持跨分类与子SKU列表"
+```
+
+---
+
+### Task 5: 消费点(购物车 / 下单 / 支付扣减 / 退款回补 / 商品详情)+ 单测
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java`(约 350 行「Linked SKU」分支)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java`(`createMultiItem` 约 740-870;`doHandlePaymentSuccess` 约 1030;回补 3 处约 1266/1714/1932)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java`(约 120「Linked SKU」分支前)
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderComboStockTest.java`(与 `ProductOrderService` 同包,才能访问包级方法)
+
+**Interfaces:**
+- Consumes: `ProductSkuService.hasComboItems/getById/buildComboSummary`、`ProductSkuComboItem`、`ProductOrder.getSkuId()`。
+- Produces: `ProductOrderService.deductComboStock(ProductSku, ProductOrder, String)`、`ProductOrderService.restockComboOrder(ProductOrder) -> boolean`(包级,供单测直接调用)。
+
+- [ ] **Step 1: 先写失败测试 `ProductOrderComboStockTest`**
+
+```java
+package com.etotem.cfc.service;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+import com.etotem.cfc.service.InventoryService;
+import com.etotem.cfc.service.ProductOrderService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ProductOrderComboStockTest {
+
+    private ProductOrderService orderService;
+    private ProductSkuService skuService;
+    private ProductService productService;
+    private InventoryService inventoryService;
+
+    @BeforeEach
+    void setUp() {
+        orderService = new ProductOrderService();
+        skuService = mock(ProductSkuService.class);
+        productService = mock(ProductService.class);
+        inventoryService = mock(InventoryService.class);
+        ReflectionTestUtils.setField(orderService, "productSkuService", skuService);
+        ReflectionTestUtils.setField(orderService, "productService", productService);
+        ReflectionTestUtils.setField(orderService, "inventoryService", inventoryService);
+    }
+
+    private ProductSku combo() {
+        ProductSkuComboItem a = new ProductSkuComboItem();
+        a.setChildProductId(10L); a.setChildSkuId(100L); a.setQuantity(2);
+        ProductSkuComboItem b = new ProductSkuComboItem();
+        b.setChildProductId(11L); b.setChildSkuId(null); b.setQuantity(1);
+        ProductSku sku = new ProductSku();
+        sku.setId(9L);
+        sku.setComboItems(Arrays.asList(a, b));
+        return sku;
+    }
+
+    private ProductOrder order() {
+        ProductOrder o = new ProductOrder();
+        o.setId(77L);
+        o.setOrderNo("PO1");
+        o.setSkuId(9L);
+        o.setQuantity(3);
+        return o;
+    }
+
+    @Test
+    void deductComboStock_childSku_decreasesSkuAndRecordsOutbound() {
+        when(skuService.decreaseStock(100L, 6)).thenReturn(true);
+        when(productService.decreaseStock(11L, 3)).thenReturn(true);
+
+        orderService.deductComboStock(combo(), order(), "PO1");
+
+        verify(skuService).decreaseStock(100L, 6);
+        verify(inventoryService).recordOutbound(10L, 100L, 6, "order", 77L, null, "组合出库:PO1");
+        verify(productService).decreaseStock(11L, 3);
+        verify(inventoryService).recordOutbound(11L, null, 3, "order", 77L, null, "组合出库:PO1");
+    }
+
+    @Test
+    void deductComboStock_insufficient_throws() {
+        when(skuService.decreaseStock(100L, 6)).thenReturn(false);
+
+        assertThrows(RuntimeException.class, () -> orderService.deductComboStock(combo(), order(), "PO1"));
+    }
+
+    @Test
+    void restockComboOrder_nonCombo_returnsFalse() {
+        when(skuService.getById(9L)).thenReturn(new ProductSku());
+        when(skuService.hasComboItems(any())).thenReturn(false);
+
+        assertFalse(orderService.restockComboOrder(order()));
+        verify(productService, never()).increaseStock(anyLong(), anyInt());
+    }
+
+    @Test
+    void restockComboOrder_combo_restocksChildrenOnly() {
+        ProductSku sku = combo();
+        when(skuService.getById(9L)).thenReturn(sku);
+        when(skuService.hasComboItems(sku)).thenReturn(true);
+
+        assertTrue(orderService.restockComboOrder(order()));
+
+        verify(skuService).increaseStock(100L, 6);
+        verify(inventoryService).recordRefundInbound(10L, 100L, 6, "order", 77L, null, "组合退款入库:PO1");
+        verify(productService).increaseStock(11L, 3);
+        // 父商品(order.productId)不得回补:本用例未设置 productId,仍断言未调用
+        verify(productService, never()).increaseStock(null, 3);
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductOrderComboStockTest`
+Expected: 编译失败(`deductComboStock` / `restockComboOrder` 不存在)
+
+- [ ] **Step 3: CartService 组合分支**
+
+在 `if (sku.getLinkedProductId() != null)` 之前插入:
+
+```java
+                if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+                    if (sku.getPrice() != null) dto.setUnitPrice(sku.getPrice());
+                    dto.setSpecDesc(productSkuService.buildComboSummary(sku.getComboItems()));
+                } else if (sku.getLinkedProductId() != null) {
+```
+
+- [ ] **Step 4: `createMultiItem` 组合支持**
+
+循环开头:
+
+```java
+            ProductSku itemSku = item.getSkuId() != null ? productSkuService.getById(item.getSkuId()) : null;
+            boolean comboItem = productSkuService.hasComboItems(itemSku);
+```
+
+父商品库存校验加 `!comboItem`:
+
+```java
+            if (!comboItem && "physical".equals(product.getProductType())
+                    && product.getStock() != null
+                    && product.getStock() < item.getQuantity()) {
+                return Result.error("库存不足: " + product.getName());
+            }
+```
+
+bundle 校验后追加组合库存校验:
+
+```java
+            if (comboItem) {
+                for (ProductSkuComboItem ci : itemSku.getComboItems()) {
+                    int need = (ci.getQuantity() == null ? 1 : ci.getQuantity()) * item.getQuantity();
+                    if (ci.getChildSkuId() != null) {
+                        if (!productSkuService.hasStock(ci.getChildSkuId(), need)) {
+                            return Result.error("组合商品库存不足: " + ci.getChildProductName());
+                        }
+                    } else {
+                        Product cp = productMapper.selectById(ci.getChildProductId());
+                        if (cp == null || cp.getStock() == null || cp.getStock() < need) {
+                            return Result.error("组合商品库存不足: "
+                                    + (cp == null ? ci.getChildProductId() : cp.getName()));
+                        }
+                    }
+                }
+            }
+```
+
+SKU 价格分支改为:
+
+```java
+            if (itemSku != null) {
+                if (comboItem) {
+                    if (itemSku.getPrice() != null) unitPrice = itemSku.getPrice();
+                    if (itemSku.getMinQuantity() != null && item.getQuantity() < itemSku.getMinQuantity()) {
+                        return Result.error(product.getName() + " 该组合最低需购买" + itemSku.getMinQuantity() + "件");
+                    }
+                } else if (itemSku.getLinkedProductId() != null) {
+                    // ... 原有 linked 逻辑保持不变 ...
+                } else {
+                    // ... 原有 legacy 逻辑保持不变 ...
+                }
+            }
+```
+
+明细行:
+
+```java
+            itemRecord.setSkuSpecs(comboItem
+                    ? productSkuService.buildComboSummary(itemSku.getComboItems())
+                    : item.getSpecOptionIds());
+```
+
+订单头(`order.setProductType(...)` 后):
+
+```java
+        order.setSkuId(firstItem.getSkuId());
+```
+
+- [ ] **Step 5: 支付成功扣减**
+
+```java
+        Product paidProduct = productMapper.selectById(order.getProductId());
+        ProductSku paidSku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
+        if (productSkuService.hasComboItems(paidSku)) {
+            deductComboStock(paidSku, order, orderNo);
+        } else if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
+            // ... 原有 physical 逻辑保持不变 ...
+        } else if (paidProduct != null && "bundle".equals(paidProduct.getProductType())) {
+            // ... 原有 bundle 逻辑保持不变 ...
+        }
+```
+
+新增方法:
+
+```java
+    void deductComboStock(ProductSku combo, ProductOrder order, String orderNo) {
+        for (ProductSkuComboItem ci : combo.getComboItems()) {
+            int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
+            int need = unit * order.getQuantity();
+            if (ci.getChildSkuId() != null) {
+                if (!productSkuService.decreaseStock(ci.getChildSkuId(), need)) {
+                    throw new RuntimeException("组合商品库存不足: skuId=" + ci.getChildSkuId());
+                }
+                inventoryService.recordOutbound(ci.getChildProductId(), ci.getChildSkuId(), need,
+                        "order", order.getId(), null, "组合出库:" + orderNo);
+            } else {
+                if (!productService.decreaseStock(ci.getChildProductId(), need)) {
+                    throw new RuntimeException("组合商品库存不足: productId=" + ci.getChildProductId());
+                }
+                inventoryService.recordOutbound(ci.getChildProductId(), null, need,
+                        "order", order.getId(), null, "组合出库:" + orderNo);
+            }
+        }
+    }
+```
+
+- [ ] **Step 6: 3 处回补改为组合优先**
+
+```java
+    boolean restockComboOrder(ProductOrder order) {
+        ProductSku sku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
+        if (!productSkuService.hasComboItems(sku)) return false;
+        for (ProductSkuComboItem ci : sku.getComboItems()) {
+            int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
+            int need = unit * order.getQuantity();
+            if (ci.getChildSkuId() != null) {
+                productSkuService.increaseStock(ci.getChildSkuId(), need);
+                inventoryService.recordRefundInbound(ci.getChildProductId(), ci.getChildSkuId(), need,
+                        "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
+            } else {
+                productService.increaseStock(ci.getChildProductId(), need);
+                inventoryService.recordRefundInbound(ci.getChildProductId(), null, need,
+                        "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
+            }
+        }
+        return true;
+    }
+```
+
+三处原逻辑包一层:
+
+```java
+        if (!restockComboOrder(order)) {
+            productService.increaseStock(order.getProductId(), order.getQuantity());
+            inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
+                    "order", order.getId(), null, "<原 remark>");
+        }
+```
+
+(`<原 remark>` 分别沿用 `"取消订单:" + orderNo`、退款处、关闭处原文案。)
+
+- [ ] **Step 7: ProductController 商品详情增加组合选项**
+
+在 `boolean isLinked = sku.getLinkedProductId() != null;` 之前插入组合分支(`skus` 已由 Service 回填):
+
+```java
+                if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+                    String optionName = (sku.getLabel() != null && !sku.getLabel().trim().isEmpty())
+                            ? sku.getLabel() : "商品组合";
+                    String groupName = "商品组合";
+                    if (!nameToGroupId.containsKey(groupName)) {
+                        nameToGroupId.put(groupName, groupId++);
+                        Map<String, Object> grp = new HashMap<>();
+                        grp.put("groupId", nameToGroupId.get(groupName));
+                        grp.put("groupName", groupName);
+                        grp.put("options", new ArrayList<Map<String, Object>>());
+                        groups.add(grp);
+                    }
+                    List<Map<String, Object>> comboList = new ArrayList<>();
+                    for (ProductSkuComboItem ci : sku.getComboItems()) {
+                        Map<String, Object> cm = new HashMap<>();
+                        cm.put("productId", ci.getChildProductId());
+                        cm.put("skuId", ci.getChildSkuId());
+                        cm.put("name", ci.getChildProductName());
+                        cm.put("image", ci.getChildProductImage());
+                        cm.put("price", ci.getChildProductPrice());
+                        cm.put("quantity", ci.getQuantity());
+                        comboList.add(cm);
+                    }
+                    Map<String, Object> option = new HashMap<>();
+                    option.put("id", sku.getId());
+                    option.put("groupId", nameToGroupId.get(groupName));
+                    option.put("name", optionName);
+                    option.put("skuId", sku.getId());
+                    option.put("isCombo", true);
+                    option.put("price", sku.getPrice());
+                    option.put("comboItems", comboList);
+                    for (Map<String, Object> g : groups) {
+                        if (g.get("groupId").equals(nameToGroupId.get(groupName))) {
+                            @SuppressWarnings("unchecked")
+                            List<Map<String, Object>> opts = (List<Map<String, Object>>) g.get("options");
+                            opts.add(option);
+                        }
+                    }
+                    continue;
+                }
+```
+
+- [ ] **Step 8: 运行测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductOrderComboStockTest`
+Expected: Tests run: 4, Failures: 0, Errors: 0
+
+- [ ] **Step 9: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java cfc-backend/src/test/java/com/etotem/cfc/unit/ProductOrderComboStockTest.java
+git commit -m "feat(backend): 组合SKU购物车/下单/扣库/回补与详情展示"
+```
+
+---
+
+### Task 6: 前端 ProductPicker 多选 + 数量 + SKU + 跨分类(TDD 工具函数)
+
+**Files:**
+- Create: `cfc-web/src/utils/skuCombo.js`
+- Create: `cfc-web/src/__tests__/utils/skuCombo.test.js`
+- Rewrite: `cfc-web/src/views/admin/components/ProductPicker.vue`
+
+**Interfaces:**
+- Consumes: `linkedProductList(params)`(`@/api/admin`),参数新增 `crossCategory`;响应 `list[].skus` / `hasSkus`(Task 4)。
+- Produces:
+  - `formatComboSummary(items, max=3) -> string`
+  - `normalizeComboPayload(items) -> [{childProductId, childSkuId, quantity}]`
+  - 组件 props `{ productId, crossCategory, multiple, selected }`;events `selected`(单选对象)、`confirm`(多选数组)。
+
+- [ ] **Step 1: 先写工具函数测试**
+
+```js
+import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'
+
+describe('skuCombo utils', () => {
+  test('formatComboSummary empty', () => {
+    expect(formatComboSummary([])).toBe('')
+    expect(formatComboSummary(null)).toBe('')
+  })
+
+  test('formatComboSummary names and quantities', () => {
+    expect(formatComboSummary([
+      { childProductName: '商品A', quantity: 2 },
+      { childProductName: '商品B', quantity: 1 }
+    ])).toBe('商品A×2;商品B×1')
+  })
+
+  test('formatComboSummary over max', () => {
+    const items = [1, 2, 3, 4].map(i => ({ childProductName: '商品' + i, quantity: 1 }))
+    expect(formatComboSummary(items, 3)).toBe('商品1×1;商品2×1;商品3×1 等4项')
+  })
+
+  test('normalizeComboPayload defaults', () => {
+    expect(normalizeComboPayload([{ id: 7, childSkuId: 8 }]))
+      .toEqual([{ childProductId: 7, childSkuId: 8, quantity: 1 }])
+  })
+})
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__/utils/skuCombo.test.js --no-coverage`
+Expected: FAIL(Cannot find module '@/utils/skuCombo')
+
+- [ ] **Step 3: 实现 `src/utils/skuCombo.js`**
+
+```js
+export function formatComboSummary(items, max = 3) {
+  if (!items || !items.length) return ''
+  const shown = items.slice(0, max).map(it => {
+    const name = it.childProductName || it.name || ('商品#' + (it.childProductId || it.productId))
+    const qty = it.quantity == null ? 1 : it.quantity
+    return name + '×' + qty
+  })
+  return shown.join(';') + (items.length > max ? ' 等' + items.length + '项' : '')
+}
+
+export function normalizeComboPayload(items) {
+  return (items || []).map(it => ({
+    childProductId: it.childProductId || it.id,
+    childSkuId: it.childSkuId || null,
+    quantity: it.quantity == null ? 1 : it.quantity
+  }))
+}
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__/utils/skuCombo.test.js --no-coverage`
+Expected: PASS(4 个用例)
+
+- [ ] **Step 5: 重写 `ProductPicker.vue`**
+
+要求:
+- 模板结构修正:`el-pagination` 与 footer 放在 `el-table` 之外。
+- 单选模式(`multiple=false`)保留 radio + `@selected` 旧行为。
+- 多选模式(`multiple=true`):`type="selection"`,每行 SKU 下拉与数量;`row.hasSkus && !row.skus.length` 时禁用该行(`:selectable` 返回 false)并显示「无可用SKU」。
+- `open()` 用 `selected` 预填 `picked`,`load()` 后对已选行 `toggleRowSelection(row, true)`。
+- `confirm()` 校验:多选时每个已选且有 `skus` 的行必须有 `childSkuId`;输出 `[{id, childProductId, childSkuId, name, price, coverImage, stock, quantity}]`,`$emit('confirm', result)`。
+- 组件根加 `ref="table"` 到 `el-table`。
+
+- [ ] **Step 6: 运行前端相关测试 + 构建**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage`
+Expected: 全部 PASS
+
+Run: `npm run build`
+Expected: 构建成功
+
+- [ ] **Step 7: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/utils/skuCombo.js cfc-web/src/__tests__/utils/skuCombo.test.js cfc-web/src/views/admin/components/ProductPicker.vue
+git commit -m "feat(web): 商品选择器支持多选/数量/SKU/跨分类"
+```
+
+---
+
+### Task 7: 前端 ProductSkuEditor 组合模式
+
+**Files:**
+- Modify: `cfc-web/src/views/admin/components/ProductSkuEditor.vue`
+
+**Interfaces:**
+- Consumes: `formatComboSummary`、`normalizeComboPayload`(Task 6);`ProductPicker` 的 `multiple/crossCategory/selected` 与 `@confirm`;未选 SKU 商品的 `hasSkus`。
+- Produces: SKU create/update payload 增加 `comboItems`(`[]` 表示非组合);组合模式 payload 为 `{productId, comboItems, price, stock, label, enabled, linkedProductId: null}`。
+
+- [ ] **Step 1: 三模式改造**
+
+`data`:`isLinkedMode` → `skuMode: 'spec'`,新增 `comboItems: []`。
+
+模板 radio:
+
+```html
+        <el-radio-group v-model="skuMode" size="small" class="mb-16">
+          <el-radio-button label="spec">规格变体</el-radio-button>
+          <el-radio-button label="linked">关联商品</el-radio-button>
+          <el-radio-button label="combo">商品组合</el-radio-button>
+        </el-radio-group>
+```
+
+把 `v-if="isLinkedMode"` 改为 `v-if="skuMode === 'linked'"`,原有规格区 `v-else` 改为 `v-else-if="skuMode === 'spec'"`。
+
+- [ ] **Step 2: 新增组合区模板**
+
+```html
+        <template v-else-if="skuMode === 'combo'">
+          <el-form-item label="显示标签">
+            <el-input v-model="skuForm.label" placeholder="默认显示“商品组合”" />
+          </el-form-item>
+          <el-form-item label="组合商品">
+            <el-button size="small" type="primary" plain icon="el-icon-plus" @click="openProductPicker">选择商品</el-button>
+            <div v-if="comboItems.length" class="combo-list">
+              <div v-for="(it, idx) in comboItems" :key="(it.childProductId || it.id) + '-' + (it.childSkuId || '')" class="combo-row">
+                <img v-if="it.coverImage" :src="it.coverImage" class="combo-thumb" />
+                <span v-else class="combo-thumb combo-thumb-placeholder"></span>
+                <div class="combo-main">
+                  <div>{{ it.name }}</div>
+                  <div class="combo-meta">
+                    <span v-if="it.childSkuSpecs">规格:{{ parseSpecs(it.childSkuSpecs) }}</span>
+                    <span>单价 ¥{{ ((it.price || 0)/100).toFixed(2) }}</span>
+                  </div>
+                </div>
+                <el-input-number v-model="it.quantity" :min="1" :precision="0" size="mini" class="combo-qty" />
+                <el-button type="text" class="combo-remove" @click="comboItems.splice(idx, 1)">删除</el-button>
+              </div>
+            </div>
+            <div v-else class="combo-empty">尚未添加组合商品</div>
+          </el-form-item>
+          <el-form-item label="组合售价(元)" prop="price">
+            <el-input-number v-model="skuForm.price" :min="0" :precision="2" class="full-width" />
+          </el-form-item>
+          <el-form-item label="展示库存">
+            <el-input-number v-model="skuForm.stock" :min="0" :precision="0" class="full-width" />
+          </el-form-item>
+        </template>
+```
+
+- [ ] **Step 3: ProductPicker 挂载更新**
+
+```html
+    <ProductPicker
+      ref="productPicker"
+      :product-id="productId"
+      :cross-category="skuMode === 'combo'"
+      :multiple="skuMode === 'combo'"
+      :selected="comboItems.map(i => ({ childProductId: i.childProductId || i.id, childSkuId: i.childSkuId, quantity: i.quantity }))"
+      @selected="onProductSelected"
+      @confirm="onComboConfirm"
+    />
+```
+
+- [ ] **Step 4: 回显与确认**
+
+`showDialog(sku)`:
+
+```js
+      const comboRaw = sku.comboItems || []
+      this.comboItems = comboRaw.map(it => ({
+        childProductId: it.childProductId,
+        childSkuId: it.childSkuId,
+        quantity: it.quantity || 1,
+        name: it.childProductName || ('商品#' + it.childProductId),
+        coverImage: it.childProductImage,
+        price: it.childProductPrice || 0,
+        childSkuSpecs: it.childSkuSpecs
+      }))
+      this.skuMode = comboRaw.length ? 'combo' : (sku.linkedProductId != null ? 'linked' : 'spec')
+```
+
+`onComboConfirm(items)`:
+
+```js
+    onComboConfirm(items) {
+      this.comboItems = (items || []).map(it => ({
+        childProductId: it.childProductId || it.id,
+        childSkuId: it.childSkuId || null,
+        quantity: it.quantity || 1,
+        name: it.name,
+        coverImage: it.coverImage,
+        price: it.price || 0,
+        childSkuSpecs: it.childSkuSpecs || null
+      }))
+    }
+```
+
+- [ ] **Step 5: `handleSave` 增加组合分支,并给另外两分支补 `comboItems`**
+
+```js
+      if (this.skuMode === 'combo') {
+        if (!this.comboItems.length) { this.$message.warning('请至少添加一个组合商品'); return }
+        if (!(this.skuForm.price > 0)) { this.$message.warning('请填写组合售价'); return }
+        const payload = {
+          productId: this.productId,
+          comboItems: normalizeComboPayload(this.comboItems),
+          price: Math.round(parseFloat(this.skuForm.price) * 100),
+          stock: this.skuForm.stock || 0,
+          label: this.skuForm.label || null,
+          enabled: this.skuForm.enabled,
+          linkedProductId: null
+        }
+        // 与 linked 分支相同的「新建暂存 / update / create + 刷新列表」流程
+        return
+      }
+```
+
+- linked 分支 payload 追加 `comboItems: []`;
+- spec 分支 payload 追加 `comboItems: []`、`linkedProductId: null`;
+- `resetForm()` 重置 `skuMode='spec'`、`comboItems=[]`。
+
+- [ ] **Step 6: 列表规格列显示组合摘要**
+
+```html
+      <el-table-column label="规格/组合" min-width="160">
+        <template slot-scope="{ row }">
+          <span v-if="row.comboItems && row.comboItems.length">组合:{{ formatComboSummary(row.comboItems) }}</span>
+          <span v-else>{{ parseSpecs(row.specs) }}</span>
+        </template>
+      </el-table-column>
+```
+
+并 `import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'`;补充组合相关样式。
+
+- [ ] **Step 7: 测试 + 构建**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage && npm run build`
+Expected: 测试全 PASS,构建成功
+
+- [ ] **Step 8: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/components/ProductSkuEditor.vue
+git commit -m "feat(web): SKU编辑器支持商品组合(多商品+数量+手填售价)"
+```
+
+---
+
+### Task 8: 全量回归
+
+**Files:** 无新增;运行全部测试与手工回归清单。
+
+- [ ] **Step 1: 后端全量单测**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test`
+Expected: BUILD SUCCESS,0 failures
+
+- [ ] **Step 2: 前端全量测试 + 构建**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage && npm run build`
+Expected: 全 PASS,构建成功
+
+- [ ] **Step 3: 手工回归(本地/预发,逐条记录结果)**
+
+- 旧 linked SKU:加购显示关联商品名/价;下单金额 = 关联商品价。
+- legacy 规格 SKU:加购/下单金额 = SKU 价。
+- 组合 SKU:加购显示父商品 + 组合摘要 + 手填价;下单总额 = 手填价 × 数量。
+- 组合 SKU:支付成功(可用 testMode)后各组成 SKU/商品按 `quantity × 订单数量` 扣减,**父商品库存不变**。
+- 组合 SKU:取消/退款后各组成库存回补,父商品库存不变。
+- 商品详情:组合 SKU 返回 `isCombo=true` 与 `comboItems`。
+- 管理端:有 SKU 的商品作为组成项时强制选 SKU;无可用 SKU 的商品不可勾选。
+
+- [ ] **Step 4: 提交回归记录(如无代码变更可跳过)**
+
+```bash
+cd /app/cfc && git status
+```
+
+---
+
+### Task 9: 部署(需用户 go/no-go)
+
+**Files:** 无代码变更;运行部署脚本。
+
+- [ ] **Step 1: 同步与备份**
+
+```bash
+ssh root@111.228.6.214
+cd /app/cfc && git status && git fetch origin && git pull --rebase origin cfclub
+tar -czf /app/pro/cfc_backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /app pro/cfc
+```
+
+- [ ] **Step 2: 后端部署**
+
+```bash
+cd /app/cfc/cfc-backend
+mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true
+cp target/cfc-backend-1.0.0.jar /app/pro/cfc/backend/
+systemctl restart cfc-backend
+```
+
+- [ ] **Step 3: 前端部署**
+
+```bash
+cd /app/cfc/cfc-web && npm run build
+rsync -a --delete dist/ /app/pro/cfc/frontend/
+```
+
+- [ ] **Step 4: 验证**
+
+```bash
+systemctl status cfc-backend --no-pager | head -5
+ss -tlnp | grep 9082
+curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:9082/api/cart/list
+curl -s -o /dev/null -w "%{http_code}\n" -k https://cfc.etotem.com.cn/admin/
+grep "迁移432" /app/pro/cfc/backend/cfc-backend.log | tail -5
+curl -s -X POST http://localhost:9082/api/admin/product/linked/list -H 'Content-Type: application/json' -d '{"crossCategory":true,"page":1,"pageSize":3}'
+```
+
+Expected: 服务 active;端口 9082 监听;两个 URL 200;日志含「迁移432」;选择器返回 `code=200` 且含 `skus`/`hasSkus`。
+
+- [ ] **Step 5: 失败回滚**
+
+```bash
+tar -xzf /app/pro/cfc_backup_<ts>.tar.gz -C /app && systemctl restart cfc-backend
+```
+
+- [ ] **Step 6: git 收尾**
+
+```bash
+cd /app/cfc && git status
+# npm run build 可能改动 package.json / .last_build_commit / CHANGELOG*,按 AGENTS.md 提交推送(冲突时取远程版本号)
+```

+ 163 - 0
docs/superpowers/specs/2026-10-30-product-bundle-autopricing-design.md

@@ -0,0 +1,163 @@
+# 订单套餐自动定价设计(Product Bundle Auto-Pricing)
+
+- 日期:2026-10-30(修订 v2,吸收 builder check 意见)
+- 状态:待用户批准
+- 分支:cfclub
+- 影响范围:cfc-backend(结算/迁移)、cfc-frontend(小程序结算展示)、cfc-web(管理端配置,可选增强)
+
+## 意图与背景(Intent & Why)
+
+现有「送会员一口价」机制(`gift_membership_original_threshold` 达标 → 送会员商品实付一口价 1980 元)结算方式粗暴且易误导。实际案例:购物车含还原水机(40800 元)+ 肠道菌群检测套装(1980 元)时,页面显示"商品原价 42780 → 实付 1980、已优惠 40800",用户误读为"商品白送"。
+
+用户希望:改为**订单内商品组合自动匹配套餐、按套餐价结算并提示优惠**——买 2 个基础款(如 2×699=1398)时,若系统配置了"2 个基础款 = 1280 元套餐",则自动按 1280 结算,页面提示优惠。
+
+## 目标(Goals)
+
+1. 结算/购物车时,扫描订单内基础款商品数量,凑够某套餐组成即自动按套餐 SKU 价结算该部分。
+2. 同一订单多个套餐同时可凑时,自动选**让用户省最多**的组合。
+3. 超出套餐组成数量的部分按单品价兜底(凑整 + 兜底)。
+4. 页面提示优惠金额与命中套餐明细(**不做换购交互**)。
+5. 废弃现有「送会员商品原价达标 → 一口价 1980」的**结算价**逻辑。
+6. **优惠金额由后端权威计算并校验**:下单扣款(moneyAmount)必须与展示一致,前端传入的折扣金额不被盲目采信。
+
+## 非目标(Non-goals)
+
+- 不做换购/弹窗交互(不引导用户手动换成套餐 SKU)。
+- 不改变「送会员权益」触发逻辑:`ProductOrderService` 中基于 `gift_membership_original_threshold` 的**单笔订单送会员权益**读取**保留不动**;`gift_membership_threshold` 累计送会员逻辑保留原样。本次只替换"结算价"算法。
+- 不改变现有 `product_bundle_items` 的**出库联动**语义(`InventoryService` 用 `bundle_product_id` 关联套餐商品出库,保持不动)。
+- 不改变套餐 SKU 的既有直接购买路径(商品页仍可直接买"套餐一"等 SKU)。
+- 不删除现有 `attachBundleMatches`/`BundleMatch` 代码(保留为技术债,见"我的决定")。
+
+## 约束(Constraints)
+
+- 复用 `product_bundle_items` 表与既有管理端套餐组成配置 UI(`ProductEdit.vue` / `AdminInventoryController`)。
+- 生产库 MySQL 8.0.34(迁移须幂等,注意 8.0 语法,不可在索引定义用函数表达式)。
+- 前端为微信小程序(cfc-frontend),改动后需用户自行在微信开发者工具构建上传;后端/管理端走标准部署(systemctl 重启)。
+- **结算价计算在后端(服务端权威)**,前端只做展示与 total 扣减;下单接口服务端重算套餐优惠,前端 `discountAmount` 仅作展示、不参与最终扣款判定。
+
+## 已确认决策(用户拍板)
+
+| 决策点 | 结论 |
+|---|---|
+| 套餐定义数据源 | `product_bundle_items` 表 |
+| 套餐价锚点 | **套餐 SKU 作锚点**:给 `product_bundle_items` 新增 `bundle_sku_id` 列,套餐价取该 SKU 的 `price`(改表) |
+| 适用范围 | **所有商品**(只要配了套餐定义即生效,不限"送会员商品") |
+| 匹配算法 | **凑整套餐 + 单品兜底**:买 3 个、套餐为 2 个 → 1 套套餐 + 1 个单品 |
+| 多套餐选择 | **自动选省最多**(比较各套餐节省额) |
+| 无套餐可匹配 | **废弃一口价**:移除"达标→一口价 1980"结算逻辑,无套餐时按正常价(会员价/优惠券等)结算 |
+| 优惠落地 | **后端权威校验**:下单时服务端重算套餐优惠并计入扣款,前端 `discountAmount` 不采信 |
+| 交互 | 只提示优惠,不做换购 |
+
+## 设计(How)
+
+### 1. 数据模型(迁移)
+
+`product_bundle_items` 新增一列:
+
+```sql
+-- 幂等迁移(DatabaseInitializer 内实现,先查 INFORMATION_SCHEMA.COLUMNS 是否存在)
+ALTER TABLE product_bundle_items
+  ADD COLUMN bundle_sku_id BIGINT NULL COMMENT '套餐SKU锚点:非空表示该行用于【定价套餐匹配】,套餐价取该SKU.price;为空表示该行仅用于【出库联动】(bundle_product_id 语义)';
+```
+
+**行语义区分**(关键,避免与既有出库联动冲突):
+- `bundle_sku_id IS NOT NULL` → 定价套餐行(本设计新增用途)。
+- `bundle_sku_id IS NULL` → 既有出库联动行(`InventoryService` 现状,不改)。
+
+字段用途(定价套餐行):
+| 字段 | 用途 |
+|---|---|
+| `bundle_sku_id` | 套餐锚点 SKU(套餐价 = `product_skus.price`) |
+| `child_product_id` | 基础款商品(必填) |
+| `child_sku_id` | 基础款 SKU(可空;空 = 按商品聚合,见算法) |
+| `quantity` | 组成该套餐所需的基础款数量(正整数) |
+| `bundle_product_id` | 定价套餐行置 NULL(避免误触发出库联动) |
+
+### 2. 结算定价算法(后端权威)
+
+输入:**下单/结算口径的商品行**(每行含解析后的 `product_id`、`sku_id`、`quantity`、`unitPrice`)。其中:
+- `product_id`/`sku_id` 为 **linked-SKU 解析后**的身份(与下单结算 `createMultiItem` 的解析一致:SKU 存在 `linkedProductId` 时转发到关联商品)。
+- `unitPrice` 为**下单结算同口径单价**:有 SKU 用 `sku.price`,无 SKU 用 `getEligibleMemberPrice(product)`(会员调整价),避免显示与实际扣款口径不一致。
+
+输出:命中套餐列表(每套:锚点 SKU、命中套数、套餐总价、原价合计、节省额)+ 优惠总额。
+
+**算法(贪心省最多)**:
+
+1. 聚合数量:按 `(product_id, sku_id)` 归总;`sku_id` 为空的行按 `(product_id, NULL)` 归总。
+2. 取所有 `bundle_sku_id IS NOT NULL` 的定价套餐行。
+3. 对每个套餐,判定"可凑 1 套":其每个 child 组成(按 `child_product_id` + `child_sku_id` 匹配聚合行)的数量 ≥ `quantity`。
+4. 计算每个可凑套餐的**节省额**:`Σ(该套餐各 child 对应的下单口径 unitPrice × quantity) − 套餐价(SKU.price)`。
+   - **仅当节省额 > 0 才视为可触发**(确保按套餐价结算确实更省,避免会员价低于套餐价时反而更贵)。
+5. 贪心循环:每轮在"当前可凑套餐"中取**节省额最大**者,命中 1 套 → 扣减其各 child 对应聚合数量(每个扣 `quantity`),累计节省额;重复直至无可凑套餐。
+6. 剩余数量按下单口径 `unitPrice` 结算(单品兜底)。
+7. 返回命中套餐明细 + 优惠总额 = Σ节省额。
+
+**关键口径**:
+- 节省额基于**下单结算单价**(会员调整价/SKU 价),非展示价、非配置原价。
+- 套餐价取锚点 SKU 的 `price`。
+- 贪心为近似最优;对"同一基础款多套餐"场景已能选省最多;跨套餐共享基础款的全局最优可后续用 DP 增强(本期不要求)。
+
+### 3. 后端改造
+
+- **迁移**:`DatabaseInitializer` 新增幂等迁移(列存在检查 + `ALTER TABLE`),MySQL 8 语法,同步 `schema.sql`。**不随迁移发布示例套餐数据**(见 Q5 决定)。
+- **定价逻辑**:新增 `BundlePricingService`(纯计算:入参下单口径商品行 + 套餐定义,出参优惠明细/总额),供购物车展示与下单扣款共用,保证口径一致。
+- **购物车展示**:`CartService.computeGiftPromo` 改造为套餐定价(复用/新增 DTO 字段 `giftPromo` → `BundlePromo`:`active`、`originalTotal`、`payPrice`、`discount`、`bundleList` 明细),`/api/cart/list` 返回。
+- **下单扣款(后端权威,本次改造重点)**:
+  - `ProductOrderService.createMultiItem()` 与单件 `create()` 在下单时**服务端重算套餐优惠**:基于行项按第 2 节算法算出 `bundleDiscount`,计入 `discountAmount`/`moneyAmount`(`moneyAmount = totalAmount − 积分 − 优惠券 − 套餐优惠`)。
+  - **前端传入的 `discountAmount` 不再盲目采信**:服务端以行项重算为准,仅将前端值作为参考或直接忽略;防止伪造折扣,保证实付 = 展示。
+  - 与优惠券/积分/佣金的叠加顺序沿用现有 `discountAmount` 聚合模式(套餐优惠加入折扣合计)。
+- **移除一口价(范围明确)**:
+  - **删除** `CartService.computeGiftPromo` 中基于 `gift_membership_original_threshold` / `gift_membership_pay_price` 的一口价读取与结算。
+  - **保留** `ProductOrderService` 中基于 `gift_membership_original_threshold` 的**单笔订单送会员权益**读取(送会员触发,非结算)。
+  - 两个配置 key 保留值不删,仅在结算路径不再产生"一口价 1980"。
+- **送会员权益触发(`gift_membership_threshold` 累计)**:保持不动。
+
+### 4. 前端(cfc-frontend 小程序)
+
+- `cart.vue`:`giftPromo` 展示区改造为"套餐优惠"提示(命中套餐明细 + 合计节省);total 扣减 `discount` 逻辑保持。
+- `checkout.vue`:`gift-promo-section` 文案改为套餐优惠("已按套餐价结算:xxx,省 Y 元");`giftPromoDiscount` 计算复用;**提交流程仍传 `discountAmount`,但后端以重算为准**(前端值仅展示)。
+- storage `checkoutGiftPromo` 结构沿用(字段扩展),`cart.vue:424` 写入点不变。
+- 纯算法(节省额/聚合/贪心)可抽 `utils` 纯函数并单测(如 `utils/bundle-pricing.js`),保证前端展示与后端口径一致。
+
+### 5. 管理端配置(cfc-web,可选增强)
+
+- 现有 `ProductEdit.vue` 的"套餐组成"区块(`productType='bundle'`)已能配 `child_product_id/child_sku_id/quantity`。
+- **增强**:该区块增加"套餐锚点 SKU(`bundle_sku_id`)"选择,存库走 `saveBundleItems`。
+- **本期不下沉为必做**:允许先用 SQL 配置数据(用户已确认"用 product_bundle_items 表来配");管理端增强列为后续可选项。
+
+### 6. 示例数据(DAN 测评场景,供业务后续配置,不随迁移发布)
+
+```sql
+-- 套餐:2 个基础款(DAN测评 商品10) = 套餐SKU"套餐一"(product_skus.id=3, 1280元)
+INSERT INTO product_bundle_items (bundle_product_id, bundle_sku_id, child_product_id, child_sku_id, quantity)
+VALUES (NULL, 3, 10, NULL, 2);
+-- 效果:买 2 个 DAN测评(下单口径 699×2=1398) → 命中,按 1280 结算,省 118 元
+```
+
+(注:基础款口径由业务按实际销售形态决定——`child_sku_id` 指定则按 SKU,空则按商品;节省额始终以下单口径 `unitPrice` 计算。)
+
+## 我的决定(Builder 可直接沿用)
+
+- **后端权威**:下单扣款由 `ProductOrderService` 服务端重算套餐优惠,前端 `discountAmount` 不采信(防伪造、保一致)。
+- 节省额/可触发判断均用**下单结算口径单价**(会员调整价/SKU 价),保证"省"是真实结算口径的省。
+- 匹配身份 = **linked-SKU 解析后**身份,与下单结算一致。
+- 「移除一口价」范围 = 仅 `CartService.computeGiftPromo` 结算路径;`ProductOrderService` 送会员权益读取保留。
+- 定价套餐行 `bundle_product_id` 置 NULL(避免误触发出库联动)。
+- 贪心(每轮取节省额最大者)为算法实现;全局最优 DP 列为可扩展点,本期不做。
+- **不随迁移发布套餐种子数据**:示例 SQL 提供,由业务确认后配置(避免依赖生产 `product_skus.id=3` 等行号)。
+- 现有 `attachBundleMatches`/`BundleMatch` 死代码**保留不动**(记技术债:与新增定价算法并存,后续统一清理)。
+
+## 测试策略(TDD)
+
+- 后端单测 `BundlePricingService`:单套餐命中 / 凑整+兜底(买3套1+单品1)/ 多套餐省最多 / 节省额≤0 不触发(会员价低于套餐价场景)/ linked-SKU 解析匹配 / 无套餐定义不触发 / 一口价不再产生。
+- 后端单测 `ProductOrderService`:下单 `moneyAmount` 含套餐优惠;前端 `discountAmount` 伪造值被服务端重算覆盖(照实收);送会员权益(`gift_membership_original_threshold`)不受影响。
+- 前端纯函数(如 `utils/bundle-pricing.js`):聚合、节省额、贪心选择。
+- 迁移幂等:重复执行不报错(列已存在忽略)。
+
+## 验证(部署后)
+
+- 购物车放 2 个基础款(配了套餐定义)→ 页面提示"按套餐价 X,省 Y"。
+- 下单扣款金额 = 套餐价 + 单品兜底,与显示一致(服务端重算)。
+- 篡改前端折扣参数 → 实际扣款仍按服务端重算值(防伪造)。
+- 无套餐可匹配 → 正常价结算,无一口价 1980。
+- 送会员权益(单笔订单/累计达标)仍正常触发。

+ 163 - 0
docs/superpowers/specs/2026-10-31-sku-combo-design.md

@@ -0,0 +1,163 @@
+# SKU 商品组合功能设计
+
+日期:2026-10-31
+状态:设计已确认,待写实施计划
+
+## 1. 背景
+
+管理端商品编辑页(`cfc-web/src/views/admin/ProductEdit.vue`)的 SKU 编辑(`ProductSkuEditor.vue`)中,"关联商品"模式目前只支持**单个**关联商品(`product_skus.linked_product_id BIGINT`)。购物车、下单、商品详情均把整个 SKU 替换为该关联商品(名称、价格、库存取自关联商品)。
+
+业务需要:一个 SKU 可以关联**多个商品组成组合**,每个组成商品带数量;组合 SKU 由运营**手填售价**;下单整体售卖,后台按各组成商品扣库存。选择商品时支持多选并确认。
+
+## 2. 已确认决策
+
+### 用户决定
+1. 组合形态:多商品 + 各自数量(如 商品A×2 + 商品B×1)
+2. 交互:点击"选择商品"弹出多选窗口,勾选多个商品并设置数量后点"确认"回填
+3. 定价:**手填售价**(写入 `product_skus.price`)
+4. 组合 SKU 保留自身 `stock` 字段,但仅用于展示,不参与实际扣减
+5. 购物车/下单:组合 SKU **整体售卖**(一行,名称=父商品/标签,价格=手填售价),后台记明细用于扣库存
+6. **库存口径**:组成商品有 SKU 的必须选到具体 SKU,按 SKU 库存扣;无 SKU 的按商品库存扣
+7. **父商品与退款**:组合订单**不扣父商品库存**,只扣各组成商品;取消/退款时**反向回补各组成商品**(改 3 处回补逻辑)
+8. **跨分类**:组合选择器允许跨分类;旧的单选"关联商品"保持同分类不变
+
+### 实施者决定(builder calls,可复核)
+- 保留旧"关联商品"单选模式,新增"商品组合"作为第三种模式(radio 三选一:规格变体 / 关联商品 / 商品组合)。生产当前 0 个 linked SKU,风险低。
+- 组合组成商品**禁止**:选中商品自身、`productType=bundle` 的商品、以及其他组合 SKU 的商品(避免递归扣减)。
+- `comboItems` 语义:**非空数组**=整表替换;**空数组**=清除组合;**字段省略(null)**=保持不变(避免部分更新误清空)。
+- 切换模式时显式清理旧数据(切到组合→清 `linkedProductId`;切到关联/规格→删组合明细)。
+- 支付/退款识别组合订单:**给 `product_orders` 增加 `sku_id` 列**(迁移 432 一并做),下单时记录;比改造 `order_items` 写入路径更直接。
+- 支付扣减只对**组合行**做逐项扣减,不改变现有非组合订单(含多行订单)的扣减行为,避免扩大回归面。
+
+## 3. 目标
+
+1. 管理端 SKU 编辑支持组合:多商品 + 数量(有 SKU 的商品需选 SKU)、手填售价
+2. `ProductPicker` 支持多选 + 每项数量(有 SKU 商品带 SKU 选择)+ 确认 + 跨分类
+3. 组合 SKU 加购/下单:整体一行,价格 = `sku.price`,展示父商品名 + 组合摘要
+4. 支付成功按组合明细扣各组成 SKU/商品库存,并写出库流水;取消/退款反向回补
+5. 旧单选 `linked_product_id` SKU 行为**完全不变**
+
+## 4. 非目标
+
+- 不修改套餐(`productType=bundle`,`product_bundle_items`)逻辑
+- 不自动计算组合 SKU 的可用库存(`stock` 仅展示,运营手填)
+- 不做小程序端/其他消费端 UI 改造(实现前 grep 确认消费方,保证接口字段兼容)
+- 不新增组合订单明细快照表
+
+## 5. 数据模型
+
+### 5.1 新表 `product_sku_combo_items`
+
+```sql
+CREATE TABLE IF NOT EXISTS product_sku_combo_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    sku_id BIGINT NOT NULL COMMENT '组合SKU ID',
+    child_product_id BIGINT NOT NULL COMMENT '组成商品ID',
+    child_sku_id BIGINT DEFAULT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)',
+    quantity INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_sku (sku_id),
+    INDEX idx_child_product (child_product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细';
+```
+
+### 5.2 `product_orders` 增加 `sku_id`
+
+```sql
+ALTER TABLE product_orders ADD COLUMN sku_id BIGINT NULL COMMENT '下单SKU ID(组合SKU识别用)';
+```
+
+- 迁移编号 **432**,放在 `DatabaseInitializer.runMigrations()` 末尾(当前最大 431)
+- 同步 `schema.sql`
+
+### 5.3 `product_skus`
+
+结构不变。`linked_product_id` 保留供旧单选关联。
+
+**判定优先级**(后端所有消费点统一):
+1. `comboItems` 非空 → 组合 SKU(新逻辑)
+2. `linked_product_id` 非空 → 旧单选关联(原逻辑)
+3. 否则 → legacy specs SKU(原逻辑)
+
+## 6. 后端改动
+
+### 6.1 实体与 Mapper
+
+- 新增 `entity/ProductSkuComboItem.java`(`@TableName("product_sku_combo_items")`)
+- 新增 `mapper/ProductSkuComboItemMapper.java`(`BaseMapper` + 按 skuId 查询/删除)
+- `entity/ProductSku.java` 增加 `@TableField(exist = false) private List<ProductSkuComboItem> comboItems;`
+- `entity/ProductOrder.java` 增加 `private Long skuId;`
+
+### 6.2 Service
+
+- `ProductSkuService`:
+  - `create`/`update`:`comboItems != null` 时先删旧明细再批量插入(空数组=删除);组合模式下 `specs` 走 `"[]"` 兜底
+  - `splitComboItems`/`fillComboItems`:查询返回前填充
+  - `getById` / `listByProductIdAll` 返回前填充 `comboItems`
+- `ProductSkuDTO`:增加 `comboItems`,`from()` 回填(mapper 注入或 controller 组装)
+
+### 6.3 Controller
+
+- `ProductSkuController.create/update`:`ProductSku` 直接接收 `comboItems`(`@TableField(exist=false)` 不影响 Jackson);异常则改显式 DTO
+- `ProductSkuController.list/get`:返回 `comboItems`
+- `AdminProductPickerController.linkedList`:增加参数 `crossCategory`(Boolean,默认 false)。true 时不按分类过滤、并排除自身/bundle/组合商品;同时返回每个商品的 SKU 列表概要(供有 SKU 商品选择 SKU)
+
+### 6.4 消费点
+
+| 位置 | 现状 | 组合分支 |
+|---|---|---|
+| `CartService`(约 350 行) | `linkedProductId` 非空 → 替换为关联商品 | `comboItems` 非空 → **不替换商品**;显示父商品名/标签 + 组合摘要;`unitPrice = sku.price` |
+| `ProductOrderService` 下单(约 765 行) | `linkedProductId` 非空 → 替换商品 + 价格 | `comboItems` 非空 → 不替换;`unitPrice = sku.price`;记录 `order.skuId`;校验各组成 SKU/商品库存(可选) |
+| `ProductOrderService.doHandlePaymentSuccess`(约 1035 行) | physical → 扣父商品;bundle → 穿透子商品 | 若 `order.skuId` 是组合 SKU:**跳过父商品扣减**,按 combo items 扣减 `childSkuId`(有值)或 `childProductId`,数量=`combo.quantity × order.quantity`,`recordOutbound(... "组合出库:" + orderNo)` |
+| `ProductOrderService` 回补 3 处(约 1266/1714/1932 行) | 无条件 `increaseStock(order.productId, order.quantity)` | 若 `order.skuId` 是组合 SKU:按 combo items 反向回补各组成 SKU/商品(`increaseStock`),不补父商品 |
+| `ProductController`(约 151 行) | 返回 `linkedProductId` + `linked` 摘要 | 组合 SKU 返回 `comboItems` 摘要 + `isCombo = true` |
+
+### 6.5 库存与事务
+
+- 扣减复用 `ProductSkuService.decreaseStock` / `ProductService.decreaseStock`
+- 支付成功已在 `@Transactional` + Redis 分布式锁内,任一组成库存不足抛异常整体回滚
+- 退款/取消回补与扣减严格对称(同一份 comboItems 解析)
+
+## 7. 前端改动
+
+### 7.1 `ProductPicker.vue`
+
+- 表格首列 radio → **checkbox 多选**
+- 每个已选行提供**数量输入**(`el-input-number`,min=1)
+- **有 SKU 的商品**:行内出现 SKU 下拉(必选),提交 `childSkuId`
+- 新增 prop `crossCategory`(组合模式传 true → 调 linked/list 带 `crossCategory: true`)
+- 底部"确认选择"emit 数组:`[{ childProductId, childSkuId, name, price, coverImage, stock, quantity }]`
+- 支持 prop 传入已选列表用于编辑回显
+
+### 7.2 `ProductSkuEditor.vue`
+
+- radio 三选一:规格变体 / 关联商品 / 商品组合
+- 组合模式:
+  - 已选组合列表:图片/名称/单价/**数量(可编辑)**/删除
+  - "选择商品"打开 `ProductPicker`(多选 + 跨分类 + SKU 子选)
+  - 新增**组合售价(元)** 输入(手填)→ `skuForm.price`
+  - 保留"显示标签"与"启用"
+- `handleSave` payload 增加 `comboItems: [{ childProductId, childSkuId, quantity }]`;校验:≥1 个组成商品、手填售价 > 0、有 SKU 的商品必须选 SKU
+- 编辑回显:根据返回的 `comboItems` 渲染
+- 列表"规格"列对组合 SKU 展示组合摘要
+
+### 7.3 API
+
+`createProductSku/updateProductSku` 已是 POST,body 增加 `comboItems` 即可。
+
+## 8. 验证与风险
+
+- **金额**:组合 SKU 必须用 `sku.price`,不能被 linked 替换逻辑覆盖
+- **库存**:仅支付成功扣一次(已有幂等锁);组合扣减失败整体回滚;取消/退款对称回补
+- **兼容**:旧 `linked_product_id` SKU 回归(加购、下单、详情)
+- **测试**:后端单测(combo 保存/查询/扣减/回补);前端组件测试(ProductPicker 多选+SKU、SKU 编辑器组合保存);`npx jest src/__tests__/api` + 后端 `mvn test`
+- **部署**:备份 → 后端 jar 重启 → 前端构建 → 健康检查(遵循 AGENTS.md)
+
+## 9. 实施顺序建议
+
+1. 迁移 432(新表 + product_orders.sku_id)+ schema.sql
+2. 实体/Mapper/DTO/Service/Controller(组合 CRUD)
+3. 消费点改造(Cart → Order 下单 → 支付扣减 → 退款回补 → 商品详情)
+4. 前端 ProductPicker 多选/SKU/跨分类
+5. 前端 ProductSkuEditor 组合模式
+6. 测试 + 构建 + 部署