فهرست منبع

Merge: 迁移246b 套餐商品组成明细表(冲突解决)

Xiaogang Liao 1 ماه پیش
والد
کامیت
d0126783a9

+ 18 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9123,7 +9123,7 @@ private void runMigration100() {
 			log.warn("初始化sys_menu种子数据失败: " + e.getMessage());
 		}
 
-		// 迁移245: 创建 notification_templates 表(通知模板管理 W3)
+// 迁移245: 创建 notification_templates 表(通知模板管理 W3)
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS notification_templates (" +
 					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -9156,7 +9156,23 @@ private void runMigration100() {
 			log.warn("初始化notification_templates种子数据失败: " + e.getMessage());
 		}
 
-		// 迁移246: 创建 product_bundle_items 表(套餐商品组成明细 BUNDLE-001)
+		// 迁移246: product_skus 存量价格 ×100(元→分),sys_config 幂等标记
+		try {
+			Integer migrated = jdbcTemplate.queryForObject(
+					"SELECT COUNT(*) FROM sys_config WHERE config_key = 'sku_price_to_cents_done'",
+					Integer.class);
+			if (migrated == null || migrated == 0) {
+				jdbcTemplate.update("UPDATE product_skus SET price = IFNULL(price, 0) * 100, original_price = IFNULL(original_price, 0) * 100");
+				jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('sku_price_to_cents_done', '1', 'SKU价格已迁移为分单位')");
+				log.info("迁移246: product_skus 价格已×100(元→分)");
+			} else {
+				log.info("迁移246: product_skus 价格已迁移过,跳过");
+			}
+		} catch (Exception e) {
+			log.warn("迁移246失败: {}", e.getMessage());
+		}
+
+		// 迁移246b: 创建 product_bundle_items 表(套餐商品组成明细 BUNDLE-001)
 		try {
 			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_bundle_items (" +
 					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +

+ 2 - 3
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java

@@ -4,7 +4,6 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.TypeReference;
 import com.etotem.cfc.entity.ProductSku;
 import lombok.Data;
-import java.math.BigDecimal;
 import java.util.Map;
 
 @Data
@@ -13,8 +12,8 @@ public class ProductSkuDTO {
     private Long productId;
     private String skuCode;
     private Map<String, String> specs;
-    private BigDecimal price;
-    private BigDecimal originalPrice;
+    private Integer price;
+    private Integer originalPrice;
     private Integer stock;
     private String image;
     private Boolean enabled;

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java

@@ -18,12 +18,12 @@ public class ProductSku implements Serializable {
     private Long productId;
     private String skuCode;
     private String specs;
-    private BigDecimal price;
+    private Integer price;
     private BigDecimal purchasePrice; // 进价(分)
     private Integer stock;
     private Integer minStockAlert;
     private Integer minQuantity; // 最小购买数量限制
-    private BigDecimal originalPrice;
+    private Integer originalPrice;
     private String image;
     private Integer enabled;
     private Date createTime;

+ 6 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -151,7 +151,11 @@ public class CartService {
         }
         if (cart.getSkuId() != null) {
             ProductSku sku = productSkuService.getById(cart.getSkuId());
-            if (sku != null && sku.getSpecs() != null) {
+            if (sku != null) {
+                if (sku.getPrice() != null) {
+                    dto.setUnitPrice(sku.getPrice());
+                }
+                if (sku.getSpecs() != null) {
                 try {
                     String specsStr = sku.getSpecs().toString().trim();
                     StringBuilder specDesc = new StringBuilder();
@@ -183,6 +187,7 @@ public class CartService {
                 } catch (Exception e) {
                     log.warn("解析SKU specs失败: skuId={}", cart.getSkuId(), e);
                 }
+                }
             }
         }
         return dto;

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -461,7 +461,7 @@ public class ProductOrderService {
                         return Result.error(product.getName() + " 该规格最低需购买" + sku.getMinQuantity() + "件");
                     }
                     if (sku.getPrice() != null) {
-                        unitPrice = sku.getPrice().multiply(new java.math.BigDecimal(100)).intValue();
+                        unitPrice = sku.getPrice();
                     }
                 }
             }

+ 5 - 15
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -127,9 +127,9 @@
       </view>
       <view class="action-right">
         <view class="action-extra">
-          <button class="btn-cart" :class="buyButtonDisabled ? 'btn-disabled' : ''" :disabled="buyButtonDisabled" @click="onAddToCart">{{ buyButtonDisabled ? '请选规格' : '加入购物车' }}</button>
+          <button class="btn-cart" @click="onAddToCart">加入购物车</button>
         </view>
-        <button class="btn-buy" :class="buyButtonDisabled ? 'btn-disabled' : ''" :disabled="buyButtonDisabled" @click="onBuy">{{ buyButtonText }}</button>
+          <button class="btn-buy" @click="onBuy">{{ buyButtonText }}</button>
       </view>
     </view>
 
@@ -275,10 +275,9 @@ export default {
       return this.product.stock || 0
     },
     buyButtonDisabled: function() {
-      return this.hasSku && !this.allSpecsSelected
+      return false
     },
     buyButtonText: function() {
-      if (this.hasSku && !this.allSpecsSelected) return '请选择完整规格'
       return '立即购买'
     }
   },
@@ -413,13 +412,12 @@ export default {
 
     selectSpec: function(groupId, option) {
       if (this.selectedSpecs[groupId] === option.id) {
-        delete this.selectedSpecs[groupId]
+        this.$delete(this.selectedSpecs, groupId)
         this.selectedSku = null
       } else {
-        this.selectedSpecs[groupId] = option.id
+        this.$set(this.selectedSpecs, groupId, option.id)
         this.matchSku()
       }
-      this.$forceUpdate()
     },
 
     matchSku: function() {
@@ -507,10 +505,6 @@ export default {
     },
 
     onBuy: function() {
-      if (this.buyButtonDisabled) {
-        uni.showToast({ title: '请选择完整规格', icon: 'none' })
-        return
-      }
       var userId = uni.getStorageSync('userId')
       if (!userId) {
         uni.navigateTo({ url: '/pages/login/login' })
@@ -540,10 +534,6 @@ export default {
       })
     },
     onAddToCart: function() {
-      if (this.buyButtonDisabled) {
-        uni.showToast({ title: '请选择完整规格', icon: 'none' })
-        return
-      }
       var userId = uni.getStorageSync('userId')
       if (!userId) {
         uni.navigateTo({ url: '/pages/login/login' })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-6341e77495878a2415f8b74f0ff359123154cdec
+aa76c31ef78f5ebd19bd493e0e4046bd3f9f3e55

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,43 @@
 
 ---
 
+## v1.0.1080 (2026-08-15)
+
+### 文档
+- 更新 PROJECT-OVERVIEW.md v2.8 — SKU价格单位统一分 + 规格选择响应式修复 (7 Tasks 已完成)
+- SKU价格统一分单位+规格按钮修复 实现计划
+- SKU规格价格统一分单位 + 详情页规格按钮响应式修复设计文档
+
+### Bug 修复
+- 详情页规格选择用/,按钮始终可点
+- ProductSkuEditor 价格显示/输入采用元,提交采用分 (×100)
+- 迁移245 product_skus存量价格×100(元→分),sys_config幂等标记
+- 购物车项单价有SKU时用规格价,修复购物车显示商品价而非规格价
+- ProductSku/ProductSkuDTO 价格字段 BigDecimal(元)→Integer(分),统一价格单位
+
+
+## v1.0.1079 (2026-08-15)
+
+### 文档
+- 商品套餐管理功能设计文档(建表/管理端配置/小程序展示)
+
+
+## v1.0.1078 (2026-08-15)
+
+### 新功能
+- 我的页面新增当前意图展示与重新选择入口
+
+### 重构
+- 统一家庭成员接口迁移到 getFamilyMemberList,修复 quick-survey :key 警告
+
+### 其他
+- - api.js 新增 getFamilyMemberList 唯一入口,移除 getFamilyMembers/getVisibleFamilyMembers/listFamilyMembers 废弃导出
+- - 迁移 24 处调用点(8 getFamilyMembers + 13 getVisibleFamilyMembers + 3 listFamilyMembers)
+- - quick-survey.vue 3 处 :key 表达式改为方法调用
+- - api.test.js 测试同步更新
+- 
+
+
 ## v1.0.1077 (2026-08-15)
 
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1077
+> 当前版本: v1.0.1080
 
 ## 历史版本
 
@@ -8,6 +8,43 @@
 
 ---
 
+## v1.0.1080 (2026-08-15)
+
+### 文档
+- 更新 PROJECT-OVERVIEW.md v2.8 — SKU价格单位统一分 + 规格选择响应式修复 (7 Tasks 已完成)
+- SKU价格统一分单位+规格按钮修复 实现计划
+- SKU规格价格统一分单位 + 详情页规格按钮响应式修复设计文档
+
+### Bug 修复
+- 详情页规格选择用/,按钮始终可点
+- ProductSkuEditor 价格显示/输入采用元,提交采用分 (×100)
+- 迁移245 product_skus存量价格×100(元→分),sys_config幂等标记
+- 购物车项单价有SKU时用规格价,修复购物车显示商品价而非规格价
+- ProductSku/ProductSkuDTO 价格字段 BigDecimal(元)→Integer(分),统一价格单位
+
+
+## v1.0.1079 (2026-08-15)
+
+### 文档
+- 商品套餐管理功能设计文档(建表/管理端配置/小程序展示)
+
+
+## v1.0.1078 (2026-08-15)
+
+### 新功能
+- 我的页面新增当前意图展示与重新选择入口
+
+### 重构
+- 统一家庭成员接口迁移到 getFamilyMemberList,修复 quick-survey :key 警告
+
+### 其他
+- - api.js 新增 getFamilyMemberList 唯一入口,移除 getFamilyMembers/getVisibleFamilyMembers/listFamilyMembers 废弃导出
+- - 迁移 24 处调用点(8 getFamilyMembers + 13 getVisibleFamilyMembers + 3 listFamilyMembers)
+- - quick-survey.vue 3 处 :key 表达式改为方法调用
+- - api.test.js 测试同步更新
+- 
+
+
 ## v1.0.1077 (2026-08-15)
 
 ### 新功能

+ 5 - 5
cfc-web/src/views/admin/components/ProductSkuEditor.vue

@@ -172,8 +172,8 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
         this.editingId = sku.id
         this.skuForm = {
           specs: this.formatSpecsForInput(sku.specs),
-          price: sku.price ? parseFloat(sku.price) : 0,
-          originalPrice: sku.originalPrice ? parseFloat(sku.originalPrice) : 0,
+          price: sku.price ? (sku.price / 100) : 0,
+          originalPrice: sku.originalPrice ? (sku.originalPrice / 100) : 0,
           stock: sku.stock || 0,
           image: sku.image || '',
           enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1
@@ -288,7 +288,7 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
     },
     formatPrice(val) {
       if (!val) return '¥0.00'
-      return '¥' + parseFloat(val).toFixed(2)
+      return '¥' + (parseFloat(val) / 100).toFixed(2)
     },
     async handleSave() {
       this.$refs.skuForm.validate(async (valid) => {
@@ -298,8 +298,8 @@ const baseURL = process.env.VUE_APP_BASE_API || ''
           const payload = {
             productId: this.productId,
             specs: this.normalizeSpecsToJson(this.skuForm.specs),
-            price: parseFloat(this.skuForm.price),
-            originalPrice: this.skuForm.originalPrice ? parseFloat(this.skuForm.originalPrice) : null,
+            price: Math.round(parseFloat(this.skuForm.price) * 100),
+            originalPrice: this.skuForm.originalPrice ? Math.round(parseFloat(this.skuForm.originalPrice) * 100) : null,
             stock: this.skuForm.stock,
             image: this.skuForm.image,
             enabled: this.skuForm.enabled

+ 6 - 3
docs/superpowers/PROJECT-OVERVIEW.md

@@ -1,8 +1,8 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.7
-**日期:** 2026-08-09
-**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间五维启动器,已提交)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)**
+**文档版本:** v2.8
+**日期:** 2026-08-15
+**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间五维启动器,已提交)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)**
 **维护:** 所有需求变更需更新本文档
 
 ---
@@ -192,6 +192,7 @@
 | 供应商销售体系(体系账期+留利) | Phase 2 | 🟡 设计稿 | `specs/2026-07-05-supply-distribution-system-design.md` + `plans/2026-07-05-supply-distribution-system.md` |
 | 商品规格最小购买数限制 | Phase 4 | ✅ 已实施(ProductSku.minQuantity + ProductOrderService校验 + schema.sql同步) | `plans/2026-07-13-supplier-system-gap-fix.md` |
 | 供应商订单修复 | Phase 4 | 🟡 计划中 | `plans/2026-06-20-danshop-order-fix.md` |
+| SKU 价格单位统一分 + 规格选择响应式修复 | Phase 4 | ✅ 已实施(7 Tasks:实体/DTO/订单/购物车/迁移/管理端/小程序,5 commits) | `plans/2026-08-15-sku-price-cents-and-spec-selector.md` |
 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) | Phase 4 | 🟡 设计稿 | `specs/2026-08-05-inventory-management-design.md` |
 
 ---
@@ -350,6 +351,7 @@
 | `2026-08-08-health-data-center-design.md` | ✅ 已实施(身维度首页改造+在线测评题库扩展+方案汇总页+health_plans后端全栈) | 健康数据中心 — 看见·主动健康闭环(Tab栏+四维雷达图+数据采集+数据查看+方案汇总+认知/心理/关系测评) |
 | `2026-08-08-growth-intervention-center-design.md` | ✅ 已实施(迁移208-209/2实体/2Mapper/SurveyService/SurveyController/AdminSurveyController/调研问卷页/调研历史页/growth-main重构/SurveyTemplates管理端) | 成长页面重构为干预中心(今日干预进度卡+持续追踪Tab+里程碑+定期调研+后端调研系统全栈) |
 | `2026-08-10-unlock-gates-design.md` | 🟡 设计稿 v2.1 | 通关解锁功能(三关递进:自检→个人沙盘、邀请家人→全家沙盘、创建家人→孩子微行动;沙盘个人/全家切换;增值关卡含购物/会员/上传报告;每关有奖;管理端关卡配置,数据驱动) |
+| `2026-08-15-sku-price-cents-and-spec-selector-design.md` | ✅ 已实施(7 Tasks:实体/DTO/订单/购物车/迁移/管理端/小程序,5 commits) | SKU 规格价格统一分单位 + 详情页规格按钮修复设计 |
 
 ### 实施计划(plans/)
 
@@ -418,6 +420,7 @@
 | `2026-08-08-growth-intervention-center.md` | ✅ 已实施(迁移208-209/2实体/2Mapper/SurveyService+Impl/SurveyController/AdminSurveyController/调研问卷+历史页/growth-main重构/SurveyTemplates管理端) | 干预中心实施计划(设计稿:2026-08-08-growth-intervention-center-design.md) |
 | `2026-08-10-unlock-gates-implementation.md` | 🟡 待实施 | 通关解锁功能实施计划(三关递进:自检→个人沙盘/邀请→全家沙盘/创建→微行动;沙盘个人全家切换;8种判定+奖励发放;增值关卡购物/会员/报告;管理端关卡配置) |
 | `2026-08-15-daily-task-overview.md` | 🟡 待实施 | 今日任务首页卡片实施计划(仅小程序接入 + 1个只读聚合接口 `/api/daily-task/overview`,聚合打卡/任务/阅读活动/测评报告四类,点击跳转+返回自动刷新;设计稿:2026-08-15-daily-task-overview-design.md) |
+| `2026-08-15-sku-price-cents-and-spec-selector.md` | ✅ 已实施(7 Tasks:实体/DTO/订单/购物车/迁移/管理端/小程序,5 commits) | SKU 规格价格统一分单位 + 详情页规格按钮修复实施计划 |
 
 ### 计划与设计文档(specs/)
 

+ 430 - 0
docs/superpowers/plans/2026-08-15-sku-price-cents-and-spec-selector.md

@@ -0,0 +1,430 @@
+# SKU 规格价格统一分单位 + 详情页规格按钮修复 实现计划
+
+> **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:** 修复规格价格显示缩小100倍与选规格后按钮不更新两个缺陷:SKU 价格统一为"分"(Integer),管理端录入元→内部×100/÷100,详情页按钮始终可点并按规格价购买。
+
+**Architecture:** 以 `product_skus.price` 列已有注释"分"为准绳,实体层 `BigDecimal`(元) → `Integer`(分);管理端和详情页的元↔分转换只在 UI 边界执行;数据迁移通过 `sys_config` 表做幂等标记(可重复执行)。
+
+**Tech Stack:** Spring Boot 2.7.18 (Java 8, MyBatis-Plus) / Vue 2 + Element UI (管理端) / uni-app Vue 2 Options API (小程序)
+
+## Global Constraints
+
+- 后端接口统一 `@PostMapping`;DI 用 `@Resource` 且字段名=Bean Name
+- 数据库迁移唯一入口 `DatabaseInitializer.runMigrations()`(L114),最新编号244,新增迁移编号245,幂等(可重复执行不报错)
+- 迁移后必须同步 `schema.sql`(product_skus 表注释已是"分",无需 DDL 变更)
+- 管理端录入价格单位统一为**元**(与 ProductEdit.vue 的 `price/100` 显示、`price*100` 提交模式一致)
+- 小程序禁止可选链 `?.`、禁止 `:key` 表达式、禁止 `new Date(string)`;打包必须用 HBuilderX(Agent 只做语法校验)
+- 所有修改文件需运行编译/语法校验通过才视为完成
+- 禁止 `as any`/`@ts-ignore`、禁止空 catch、禁止删除测试
+
+---
+
+### Task 1: 实体与 DTO 价格字段 BigDecimal → Integer
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java` (L21, L26)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java` (L16-17)
+
+**Interfaces:**
+- Consumes: 无
+- Produces: `ProductSku.price`/`originalPrice` 为 `Integer`(分);`ProductSkuDTO.price`/`originalPrice` 为 `Integer`(分) — Task 2/3 依赖此类型
+
+**说明**:`purchasePrice`(进价) 保持 `BigDecimal` 不动(schema 为 DECIMAL,且管理端不录入/不展示,不受影响)。
+
+- [ ] **Step 1: 修改 ProductSku.java**
+
+将 L8 无用的 `import java.math.BigDecimal;` 保留(L22 `purchasePrice` 仍用 BigDecimal),把 L21/L26 类型改为 Integer:
+
+```java
+    private Integer price;
+    private BigDecimal purchasePrice; // 进价(分)
+    private Integer stock;
+    private Integer minStockAlert;
+    private Integer minQuantity; // 最小购买数量限制
+    private Integer originalPrice;
+```
+
+- [ ] **Step 2: 修改 ProductSkuDTO.java**
+
+把 L16-17 类型改为:
+
+```java
+    private Integer price;
+    private Integer originalPrice;
+```
+
+(ProductSkuDTO 无 import 变更——BigDecimal import 如不再被使用需移除,若编译报 unused 警告可忽略;若 L1-10 有 `import java.math.BigDecimal;` 且全文件不再使用,删掉它。)
+
+- [ ] **Step 3: 编译验证**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS(若有 ProductSkuList/from 等转换代码引用 BigDecimal 方法报错,说明存在其他转换点,需在本任务内一并修正为直接赋值)
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java
+git commit -m "fix(backend): ProductSku/ProductSkuDTO 价格字段 BigDecimal(元)→Integer(分),统一价格单位"
+```
+
+---
+
+### Task 2: 订单单价去掉 ×100 转换(已是分)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java` (L463-465)
+
+**Interfaces:**
+- Consumes: Task 1 的 `ProductSku.getPrice()` 返回 `Integer`(分)
+- Produces: `createMultiItem` 中 `unitPrice` 直接为规格价(分),`totalAmount += unitPrice * quantity` 金额正确
+
+**说明**:原代码 `sku.getPrice().multiply(new BigDecimal(100)).intValue()` 是把"元"转"分";实体已变 Integer(分),此转换必须删除,否则规格价再×100 会放大100倍。
+
+- [ ] **Step 1: 修改 ProductOrderService.java**
+
+将 L463-465 替换为:
+
+```java
+                    if (sku.getPrice() != null) {
+                        unitPrice = sku.getPrice();
+                    }
+```
+
+- [ ] **Step 2: 编译验证**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
+git commit -m "fix(backend): 订单规格单价去掉×100转换(sku.getPrice()已是分)"
+```
+
+---
+
+### Task 3: 购物车单价使用规格价
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java` (L146-151, L152-187)
+
+**Interfaces:**
+- Consumes: Task 1 的 `ProductSku.getPrice()` 返回 `Integer`(分)
+- Produces: `CartItemDTO.unitPrice` 在有 skuId 时为规格价(分),无 skuId 时为商品价(分) — 购物车/结算页正确显示
+
+**说明**:`buildCartItemDTO` L152 已查询 `ProductSku sku`,在同一代码块内把 `unitPrice` 覆盖为 SKU 价即可,不重复查询。当前 `dto.setUnitPrice(product.getPrice())`(L149)只用了商品价,导致"选了规格,购物车仍显示商品价"。
+
+- [ ] **Step 1: 修改 CartService.java**
+
+L146-151 保持不动(商品价兜底)。在 L152-153 的 `if (cart.getSkuId() != null) { ProductSku sku = ... }` 块内,紧跟 `if (sku != null` 判断追加价格为 SKU 价:
+
+```java
+        if (cart.getSkuId() != null) {
+            ProductSku sku = productSkuService.getById(cart.getSkuId());
+            if (sku != null) {
+                if (sku.getPrice() != null) {
+                    dto.setUnitPrice(sku.getPrice());
+                }
+                if (sku.getSpecs() != null) {
+                    // ... 现有 specs 解析逻辑保持不动 ...
+                }
+            }
+        }
+```
+
+注意:只新增 `if (sku.getPrice() != null) { dto.setUnitPrice(sku.getPrice()); }` 并调整外层判断结构(原为 `if (sku != null && sku.getSpecs() != null)`),保持原 specs 解析逻辑不变。
+
+- [ ] **Step 2: 编译验证**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java
+git commit -m "fix(backend): 购物车项单价有SKU时用规格价,修复购物车显示商品价而非规格价"
+```
+
+---
+
+### Task 4: 存量 SKU 价格数据迁移 ×100(幂等)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` (runMigrations() 末尾 L9127 之前)
+
+**Interfaces:**
+- Consumes: 无(独立迁移)
+- Produces: 所有 `product_skus.price`/`original_price` 存量值 ×100(元→分);`sys_config` 写入 `sku_price_to_cents_done=1` 标记防重
+
+**说明**:
+- 迁移编号:现有最大编号 244,本迁移用 **245**
+- 幂等策略:先查 `sys_config` 有无 `sku_price_to_cents_done` 键;无则执行 UPDATE×100 后 INSERT 标记;每次重启检查一次,保证只执行一遍
+- `original_price` 同时 ×100(保持比例)
+- `purchase_price`(DECIMAL 进价)不动
+- schema.sql 无需变更(注释本就为"分"),但按 AGENTS.md 同步原则确认注释正确即可
+
+- [ ] **Step 1: 在 runMigrations() 末尾追加迁移245**
+
+定位 `runMigrations()` 方法体内最后一个 `}`(L9127 `log.warn("初始化sys_menu种子数据失败...")` 块之后、方法结束 `}` 之前,即 L9128 前),插入:
+
+```java
+		// 迁移245: product_skus 价格单位 元→分(×100),幂等(sys_config 标记防重)
+		try {
+			Integer migrated = jdbcTemplate.queryForObject(
+					"SELECT COUNT(*) FROM sys_config WHERE config_key = 'sku_price_to_cents_done'",
+					Integer.class);
+			if (migrated == null || migrated == 0) {
+				jdbcTemplate.update("UPDATE product_skus SET price = IFNULL(price, 0) * 100, original_price = IFNULL(original_price, 0) * 100");
+				jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('sku_price_to_cents_done', '1', 'SKU价格已迁移为分单位')");
+				log.info("迁移245: product_skus 价格已×100(元→分)");
+			} else {
+				log.info("迁移245: product_skus 价格已迁移过,跳过");
+			}
+		} catch (Exception e) {
+			log.warn("迁移245失败: {}", e.getMessage());
+		}
+```
+
+注意:`sys_config` 表结构为 `config_key VARCHAR(64) UNIQUE` / `config_value TEXT` / `description TEXT`(schema.sql L1328),INSERT 需匹配实际列名(无 `config_type` 时可省略)。
+
+- [ ] **Step 2: 核对 sys_config 实际列名**
+
+Run: `Select-String -Path "cfc-backend/src/main/resources/schema.sql" -Pattern "CREATE TABLE IF NOT EXISTS sys_config" -Context 0,8`
+Expected: 确认列名为 `id/config_key/config_value/description/updated_at`;若列名不同,调整 Step 1 的 INSERT 语句列名。
+
+- [ ] **Step 3: 编译验证**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "fix(backend): 迁移245 product_skus存量价格×100(元→分),sys_config幂等标记"
+```
+
+---
+
+### Task 5: 管理端 SKU 编辑器元↔分转换
+
+**Files:**
+- Modify: `cfc-web/src/views/admin/components/ProductSkuEditor.vue`
+  - L175-176(showDialog 回显)
+  - L289-292(formatPrice 表格显示)
+  - L301-302(handleSave 提交)
+
+**Interfaces:**
+- Consumes: 后端接口返回 `price`/`originalPrice` 为分(Integer);`createProductSku/updateProductSku` 接收分
+- Produces: 管理端表单始终保持**元**语义(`skuForm.price` 为元),边界转换:回显 `分/100`、提交 `元×100`、表格 `分/100`
+
+**说明**:与 ProductEdit.vue 模式一致(`price/100` 显示、`priceYuan*100` 提交)。`pendingSkus` 暂存路径(新建商品)存的是提交后的分,`flushPendingSkus` 直传不分叉,天然一致。
+
+- [ ] **Step 1: 修改 showDialog 回显(L175-176)**
+
+```js
+        this.skuForm = {
+          specs: this.formatSpecsForInput(sku.specs),
+          price: sku.price ? (sku.price / 100) : 0,
+          originalPrice: sku.originalPrice ? (sku.originalPrice / 100) : 0,
+          stock: sku.stock || 0,
+          ...
+```
+
+(原为 `parseFloat(sku.price)` 按元读取 → 改为分÷100得元)
+
+- [ ] **Step 2: 修改 formatPrice 表格显示(L289-292)**
+
+```js
+    formatPrice(val) {
+      if (!val) return '¥0.00'
+      return '¥' + (parseFloat(val) / 100).toFixed(2)
+    },
+```
+
+- [ ] **Step 3: 修改 handleSave 提交(L301-302)**
+
+```js
+          const payload = {
+            productId: this.productId,
+            specs: this.normalizeSpecsToJson(this.skuForm.specs),
+            price: this.skuForm.price ? Math.round(parseFloat(this.skuForm.price) * 100) : 0,
+            originalPrice: this.skuForm.originalPrice ? Math.round(parseFloat(this.skuForm.originalPrice) * 100) : null,
+            stock: this.skuForm.stock,
+            ...
+```
+
+(元→分 ×100,`Math.round` 处理 10.505 类浮点)
+
+- [ ] **Step 4: 语法校验**
+
+Run: `node --check` 提取的该文件 script 块,或 `npx eslint src/views/admin/components/ProductSkuEditor.vue`(如项目配置了 eslint)
+Expected: 无语法错误
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-web/src/views/admin/components/ProductSkuEditor.vue
+git commit -m "fix(web): SKU编辑器价格按分存储,回显/表格/提交边界做元↔分转换"
+```
+
+---
+
+### Task 6: 小程序详情页——$set/$delete 修复响应式 + 按钮始终可点
+
+**Files:**
+- Modify: `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue`
+  - L147(`selectedSpecs: {}` 保持)
+  - L260-266(buyButtonDisabled/buyButtonText computed)
+  - L393-401(selectSpec 方法)
+  - L488-492 + L521-525(onBuy/onAddToCart 拦截移除)
+  - 模板 L117/L119(按钮文字简化)
+
+**Interfaces:**
+- Consumes: specMap 返回 `option.price`(分)/`option.id`/`skuId`;`product.price`(分)
+- Produces: 选中规格后 `selectedSpecs` 响应式更新 → 按钮直接变为"加入购物车/立即购买";未选规格也可点击(无 skuId,后端按商品价结算)
+
+**小程序限制**:不能用可选链;`this.$set`/`this.$delete` 为 Vue 2 全局方法可用;不改打包,只做语法校验。
+
+- [ ] **Step 1: 修改 selectSpec 方法(L393-401)**
+
+```js
+    selectSpec: function(groupId, option) {
+      if (this.selectedSpecs[groupId] === option.id) {
+        this.$delete(this.selectedSpecs, groupId)
+        this.selectedSku = null
+      } else {
+        this.$set(this.selectedSpecs, groupId, option.id)
+        this.matchSku()
+      }
+    },
+```
+
+(删除 `this.$forceUpdate()`——$set/$delete 已触发响应式依赖重算,且 $forceUpdate 无法让已缓存 computed 失效)
+
+- [ ] **Step 2: 修改 buyButtonDisabled/buyButtonText(L260-266)**
+
+```js
+    buyButtonDisabled: function() {
+      return false
+    },
+    buyButtonText: function() {
+      return '立即购买'
+    }
+```
+
+(用户已确认:不选规格也可直接购买,未选按商品价、选了按规格价;后端 createMultiItem L444-448 对实物商品统一校验商品级库存,与是否选规格无关,无副作用)
+
+- [ ] **Step 3: 修改模板按钮文案(L117, L119)**
+
+L117:`<button class="btn-cart" :class="buyButtonDisabled ? 'btn-disabled' : ''" :disabled="buyButtonDisabled" @click="onAddToCart">{{ buyButtonDisabled ? '请选规格' : '加入购物车' }}</button>` → 移除禁用分支:
+
+```html
+<button class="btn-cart" :disabled="buyButtonDisabled" @click="onAddToCart">加入购物车</button>
+```
+
+L119:`<button class="btn-buy" :class="buyButtonDisabled ? 'btn-disabled' : ''" :disabled="buyButtonDisabled" @click="onBuy">{{ buyButtonText }}</button>` → 移除禁用样式分支:
+
+```html
+<button class="btn-buy" :disabled="buyButtonDisabled" @click="onBuy">{{ buyButtonText }}</button>
+```
+
+(`buyButtonDisabled` 恒 false,`:disabled` 保留无害;移除了灰色禁用视觉)
+
+- [ ] **Step 4: 移除 onBuy/onAddToCart 的规格拦截**
+
+L488-492 删除:
+
+```js
+    onBuy: function() {
+      if (this.buyButtonDisabled) {
+        uni.showToast({ title: '请选择完整规格', icon: 'none' })
+        return
+      }
+```
+
+L521-525 删除:
+
+```js
+    onAddToCart: function() {
+      if (this.buyButtonDisabled) {
+        uni.showToast({ title: '请选择完整规格', icon: 'none' })
+        return
+      }
+```
+
+(`buyButtonDisabled` 恒 false 后即使不删也无害,但删除更干净——保留方法体其余部分)
+
+- [ ] **Step 5: 语法校验**
+
+Run: 提取该文件 `<script>` 块内容到临时文件后 `node --check`(小程序禁止 Agent 自行打包,按 AGENTS.md 只做语法校验)
+Expected: 无语法错误
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
+git commit -m "fix(miniprogram): 规格选择用\$set/\$delete修复响应式+按钮始终可点按商品价购买"
+```
+
+---
+
+### Task 7: 端到端验证
+
+**Files:**
+- 无代码改动(验证任务)
+
+**Interfaces:**
+- Consumes: Task 1-6 全部产出
+
+- [ ] **Step 1: 后端全量编译 + 迁移幂等检查**
+
+Run: `cd cfc-backend && mvn clean compile`
+Expected: BUILD SUCCESS;重启两次(或检查日志),确认"迁移245: product_skus 价格已迁移过,跳过"只出现一次 ×100 执行
+
+- [ ] **Step 2: 抽查存量数据**
+
+Run: `mysql -h192.168.16.251 -ucfc -pcfc@123 cfc -e "SELECT id, product_id, price, original_price FROM product_skus LIMIT 10"`
+Expected: 迁移后 price 已 ×100(如原 10 → 1000 分)。若有值仍 <100 说明迁移未跑或该 SKU 为 0
+
+- [ ] **Step 3: 管理端验证清单(人工)**
+
+- 新建/编辑 SKU:售价输入 10.50 → 保存 → 列表显示 ¥10.50 → 重新编辑回显 ¥10.50
+- 数据库确认存的是 1050
+
+- [ ] **Step 4: 小程序验证清单(人工,HBuilderX 重新打包后)**
+
+- 详情页选中规格 → 价格显示 ¥10.50(原来是 ¥0.10)
+- 选完所有规格 → 底部按钮立即变"加入购物车/立即购买"(原来卡"请选规格")
+- 不选规格 → 按钮可点,加入购物车后购物车/结算按商品价
+- 选规格 → 购物车单价显示规格价(不再显示商品价)
+- 下单支付 → 订单金额 = 规格价 × 数量
+
+- [ ] **Step 5: 收尾提交(如有遗漏)**
+
+若验证发现遗漏改动,补提交后复查 `git status` 干净。
+
+---
+
+## 验证矩阵(对应设计文档)
+
+| 设计要求 | Task | 验证方式 |
+|----------|:----:|----------|
+| 实体 price/originalPrice → Integer(分) | 1 | mvn compile + 代码审查 |
+| 订单单价不再 ×100 | 2 | mvn compile |
+| 购物车单价用规格价 | 3 | mvn compile + 人工 |
+| 存量数据 ×100 + sys_config 幂等 | 4 | 双启动日志 + SQL 抽查 |
+| 管理端元↔分边界转换 | 5 | 语法校验 + 人工 |
+| $set/$delete 修复响应式 | 6 | 语法校验 + 人工 |
+| 按钮始终可点 | 6 | 人工 |
+| 详情页规格价正确显示 | 1+6 | 人工 |
+
+## 风险注意事项
+
+- **存量数据语义**:迁移假设现有 INT 值全部是"元"(唯一写入入口是管理端,元语义)。迁移前可先 `SELECT price FROM product_skus WHERE price > 0` 抽查确认无已为分的异常值。
+- **sys_config 表结构**:INSERT 前核对列名(`config_key/config_value/description`),不要用不存在的 `config_type` 列。
+- **小程序打包**:改完代码必须 HBuilderX 重新打包才生效(AGENTS.md 强制)。
+- **purchase_price 不动**:DECIMAL 进价,管理端不展示,保持 BigDecimal。

+ 115 - 0
docs/superpowers/specs/2026-08-15-sku-price-cents-and-spec-selector-design.md

@@ -0,0 +1,115 @@
+# SKU 规格价格单位统一(分)+ 详情页规格选择按钮逻辑修复 — 设计
+
+日期:2026-08-15
+状态:待评审
+
+## 背景与问题
+
+用户报告两个相互关联的缺陷:
+
+1. **规格价格显示错误**:小程序商品详情页选中规格后,规格价格显示为"后台填写值 ÷ 100"。例:管理端填 10(元),小程序显示 ¥0.10。
+2. **规格选择后按钮不变化**:有规格的商品,即便选择完所有规格,底部"加入购物车/立即购买"按钮仍保持"请选规格/请选择完整规格"的禁用状态。
+
+## 根因分析
+
+### 缺陷 1:SKU 价格单位混乱(元/分混用)
+
+| 层 | 现状 | 问题 |
+|----|------|------|
+| DB `product_skus.price` | `INT COMMENT 'SKU价格(分)'` | 列本身语义是**分** |
+| 实体 `ProductSku.price` | `BigDecimal`(**元**语义) | 与列注释冲突 |
+| 管理端 ProductSkuEditor | `el-input-number :precision="2"` 输入**元** | 元值直接写入 INT 列被截断 |
+| specMap 接口(ProductController L132) | `option.put("price", sku.getPrice())`(BigDecimal 元) | 前端拿到元 |
+| 小程序详情页 | `formatPriceWithSymbol(displayPrice)` 一律 ÷100 | 元 ÷100 → **显示缩小 100 倍** |
+| 订单 ProductOrderService L464 | `sku.getPrice().multiply(100)`(元→分转换) | 订单金额反而是对的 |
+
+**结论**:specMap 返回"元"、前端当"分"÷100 → 用户看到"后台价值÷100"。同时管理端录入 10.50 元会被 INT 列截断为 10(分),小数价格静默丢失。`Product.price`(商品价)是 Integer 分,与 SKU 不一致。
+
+### 缺陷 2:Vue 2 响应式失效导致按钮不更新
+
+`product-detail.vue` 的 `selectSpec` 方法:
+
+```js
+selectSpec(groupId, option) {
+  if (this.selectedSpecs[groupId] === option.id) {
+    delete this.selectedSpecs[groupId]   // 删除属性 — Vue2 不可侦测
+    this.selectedSku = null
+  } else {
+    this.selectedSpecs[groupId] = option.id  // 新增属性 — Vue2 不可侦测
+    this.matchSku()
+  }
+  this.$forceUpdate()
+}
+```
+
+- `selectedSpecs: {}` 初始为空对象,`this.selectedSpecs[groupId] = option.id` 是**对象新增属性**,Vue 2(Object.defineProperty)无法侦测 → 依赖它的 computed(`allSpecsSelected`、`buyButtonDisabled`、`buyButtonText`)不失效 → `$forceUpdate()` 只强制重渲染但不重算已缓存 computed → **按钮文案永远停留在"请选规格"**。
+
+## 方案设计
+
+### A. 后端:SKU 价格统一为"分"(Integer)
+
+1. **实体 `ProductSku`**:`price`/`originalPrice` 从 `BigDecimal` → `Integer`(分)。`purchasePrice`(进价)保持 DECIMAL 不动(管理端不进价,无显示问题)。
+2. **DTO `ProductSkuDTO`**:`price`/`originalPrice` 同步 `BigDecimal` → `Integer`。
+3. **`ProductController.specMap`**:`option.put("price", sku.getPrice())` 无需改代码——实体变 Integer 后天然返回分。前端 `formatPriceWithSymbol(分)` → 正确显示。
+4. **`ProductOrderService.createMultiItem` L463-465**:删除 `×100` 转换,直接 `unitPrice = sku.getPrice()`(已是分)。
+5. **`CartService.buildCartItemDTO` L149**:有 skuId 时 `unitPrice` 用 `sku.getPrice()`(分),无 skuId 用商品价——修复"购物车显示商品价而非规格价"。
+
+### B. 数据库迁移(DatabaseInitializer.runMigrations)
+
+- **存量数据 ×100**:现有 `product_skus.price` 列里存的是"元语义"(10 = 10 元,因 BigDecimal 写入 INT 截断)。统一为分后需 `price = price * 100, original_price = original_price * 100`。
+- **幂等标记**:`sys_config` 表(已有,config_key UNIQUE)写入 `sku_price_to_cents_done = 1`;迁移执行前先查标记,已迁移则跳过。
+- **schema.sql**:`price INT COMMENT 'SKU价格(分)'`、`original_price INT COMMENT '原价(分)'` 注释已是分 ✓ 无需改 DDL;仅追加注释说明数据语义(可选)。
+- 迁移编号:按 DatabaseInitializer 末尾最新 `// 迁移N` 递增。
+
+### C. 管理端 ProductSkuEditor.vue(元 ↔ 分转换)
+
+与 `ProductEdit.vue`(ActivityEdit/CouponManagement 同款模式)对齐——**录入保持元,边界 ×100/÷100**:
+
+- 保存 `handleSave`:`price: Math.round(parseFloat(this.skuForm.price) * 100)`;`originalPrice` 同。注意 `pendingSkus` 暂存路径(新建商品时)同样 ×100。
+- 回显 `showDialog`:`price: sku.price ? (sku.price / 100) : 0`(现在接口返回分)。
+- 表格 `formatPrice`:`'¥' + (parseFloat(val) / 100).toFixed(2)`。
+- 校验 `skuRules` 不变(仍输入元)。
+
+### D. 小程序详情页 product-detail.vue
+
+1. **修复响应式**:`selectSpec` 改用 `this.$set(this.selectedSpecs, groupId, option.id)` / `this.$delete(this.selectedSpecs, groupId)`,删除 `$forceUpdate()` → computed 自动更新,按钮实时变化。
+2. **按钮始终可点**(用户已确认"不选规格也可直接购买"):
+   - `buyButtonDisabled` 移除禁用逻辑(或恒 false)——但保留库存为 0 的禁用?见下。
+   - `buyButtonText` 固定"立即购买",不再显示"请选择完整规格"。
+   - 加购按钮文案固定"加入购物车"。
+3. **定价规则**:不选规格 → 商品价(`product.price`,分);选了规格 → 规格价(`selectedSku.price`,分)。已由 `originalPriceVal`/`actualPrice` computed 覆盖(SKU 优先)。
+4. **`onBuy`/`onAddToCart`**:去掉 `buyButtonDisabled` 拦截;`selectedSku` 存在才传 `skuId`。价格显示逻辑不变(`actualPrice` 已正确取规格价)。
+5. 库存边界:`displayStock` 已显示规格库存/商品库存;未选规格时按钮可点但若商品库存为 0 后端会拒绝——保持现状,不额外加禁用。
+
+### 影响范围(改动文件清单)
+
+| 文件 | 改动 |
+|------|------|
+| `cfc-backend/.../entity/ProductSku.java` | price/originalPrice: BigDecimal→Integer |
+| `cfc-backend/.../dto/ProductSkuDTO.java` | 同步类型 |
+| `cfc-backend/.../controller/product/ProductController.java` | specMap 无代码改动(类型传导) |
+| `cfc-backend/.../service/ProductOrderService.java` | L464 删 ×100 |
+| `cfc-backend/.../service/CartService.java` | buildCartItemDTO 用 SKU 价 |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 迁移:存量 ×100 + sys_config 标记 |
+| `cfc-backend/src/main/resources/schema.sql` | 注释微调(可选) |
+| `cfc-web/src/views/admin/components/ProductSkuEditor.vue` | 元↔分转换 |
+| `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue` | $set/$delete + 按钮逻辑 |
+
+**不改动**:`Product.price`(已是分)、商品管理端录入(已是元→×100 模式)、其他价格显示页面。
+
+### 验证方案
+
+1. `cd cfc-backend && mvn clean compile`
+2. 迁移幂等:重启两次,确认 ×100 只执行一次(sys_config 标记)。
+3. 管理端:新建 SKU 价格 10.50 元 → 保存 → 列表显示 ¥10.50;编辑回显 ¥10.50。
+4. 小程序:specMap 返回 price=1050 → 详情页显示 ¥10.50;选中规格后按钮变"加入购物车/立即购买"。
+5. 购物车:选规格加购 → 购物车单价显示规格价。
+6. 订单:选规格下单 → 订单金额 = 规格价 × 数量(分)。
+7. 存量数据:迁移后 10 → 1000(分),订单金额不变。
+
+### 风险与注意事项
+
+- **存量数据语义假设**:现有 INT 值全部视为"元"(×100 前)。若存在已按"分"手工录入的数据会翻 100 倍——经查唯一写入入口是管理端(元语义),风险可控。迁移前可先 `SELECT id, product_id, price FROM product_skus` 抽查。
+- **ProductSkuEditor 新建商品暂存路径**:`pendingSkus` 里 price 已 ×100,`flushPendingSkus` 直传——保持一致。
+- **小程序端不打包**:改代码后需用户在 HBuilderX 重新打包(AGENTS.md 约定)。
+- **无规格商品**:`hasSku=false` 时按钮行为不变(始终可点、商品价)。