Просмотр исходного кода

docs(spec): 限时特价(扫码闪购)设计规格 + PROJECT-OVERVIEW 登记

Sisyphus Agent 5 дней назад
Родитель
Сommit
3c163f86e2

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

@@ -1,7 +1,7 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.19
-**日期:** 2026-10-17
+**文档版本:** v2.20
+**日期:** 2026-10-20
 **状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)** + **关系三图重构(关键人拓展章鱼图/珍珠图/能力图,设计规格已完成)** + **注册零填写+信息按需补充(✅ 全栈完成:登录直接进首页 + 家庭邀请独立页真实姓名/家庭身份必填 + 功能→信息字段映射表 + 添加成员手机号冲突邀请加入)** + **健康报告归属重分配(✅ 后端接口 + 管理端页面 + 小程序入口已实现,见 `specs/2026-10-05-health-report-reassign-design.md`)**
 **指标变化曲线·指标速查·指标关联(✅ 全栈完成:后端11任务+小程序M1-M8+管理端W1-W6;HBuilderX人工验收待用户执行,见 `specs/2026-10-09-indicator-change-curve-design.md`)**
 **饮食/睡眠/运动打卡接入任务完成链路(✅ 全栈完成:实体taskId字段 + 三个Controller isDailyProgress分流回写 + RepeatTaskGenerator每日生成打卡任务 + tasks.vue跳转 + 三打卡页taskId透传 + API文档;见 `specs/2026-10-13-health-checkin-task-linkage-design.md` 与 `plans/2026-10-13-health-checkin-task-linkage.md`)**
@@ -206,6 +206,7 @@
 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) | Phase 4 | 🟡 设计稿 | `specs/2026-08-05-inventory-management-design.md` |
 | 商品可选赠品(按会员等级配置赠品池:赠品商品/CF值,购买时可多选) | Phase 4 | 🟡 设计稿 | `specs/2026-09-02-product-gift-selection-design.md` |
 | 供应商订单审核(修改价格/配送方式/添加优惠券/赠品) | Phase 4 | ✅ 已实施(后端 review/update 接口 + 前端 order-review 页面 + 列表审核入口,3 commits) | `specs/2026-09-12-vendor-order-review-design.md` + `plans/2026-09-12-vendor-order-review.md` |
+| 限时特价(扫码闪购) | Phase 4 | 🟡 设计稿(特殊价格活动表 + 专属小程序码 + 下单原子扣量/每人限购/券开关 + 回滚链路 + 管理端页面) | `specs/2026-10-20-limited-time-special-price-design.md` |
 
 ---
 

+ 245 - 0
docs/superpowers/specs/2026-10-20-limited-time-special-price-design.md

@@ -0,0 +1,245 @@
+# 限时特价(扫码闪购)设计
+
+- 日期:2026-10-20
+- 状态:待批准(已按用户确认的交互模型细化)
+- 涉及端:cfc-backend、cfc-frontend(小程序)、cfc-web(管理端)、生产库迁移
+
+## 1. 背景
+
+运营需要一种「扫码限时特价」玩法:给某个商品配置一个 **限时特价活动**,生成专属小程序码(二维码),用户扫码进入商品详情页后,可以按特价购买该商品。目的是线下渠道(海报、物料、社群)精准引流转化。
+
+与普通促销的区别:
+
+- **特价只对扫码进入的用户生效**(已确认的交互模型:仅限扫码进入,从商品列表/搜索/分享进入都是原价);
+- 特价活动可配置:**时限、销售数量、每人购买数量、是否可以使用优惠券**;
+- 需要一个管理端界面配置活动、查看进度、生成二维码。
+
+## 2. 目标
+
+1. 单个商品可配置多个限时特价活动(串行),每个活动独立二维码。
+2. 扫码进入商品详情页,活动进行中时按特价展示并可特价下单;未开始/已结束/已售罄时回落原价展示与购买。
+3. 下单时服务端强制校验:时间窗口、剩余供应量(原子扣减)、每人限购、优惠券开关。
+4. 取消 / 超时自动取消 / 退款完成后回滚供应量与限购额度。
+5. 管理端提供活动列表(进度/状态)、新建/编辑/启停/删除、二维码生成。
+
+## 3. 非目标
+
+- 不做手机端点直接创建活动(配置仅管理端)。
+- 不做多商品合并购物车特价(特价仅单商品单 SKU 直购路径)。
+- 不改变现有的会员价体系:特价直接覆盖会员价(扫码用户统一按特价结算)。
+- 不排除积分抵扣与赠品规则:特价订单**保留**积分抵扣与买赠(与普通订单一致),仅优惠券作为独立开关。
+- 不改动生产数据库既有表结构之外的内容(仅新增表 + `product_orders` 一列)。
+
+## 4. 现状对照(关键链路)
+
+| 环节 | 现状 | 对本功能的影响 |
+|---|---|---|
+| 商品计价 | `products.price`(分)存储,下单时 `getEligibleMemberPrice()` 计算会员价 | 特价分支需绕过会员价,直接按 `special_price` 结算 |
+| 下单入口 | `ProductOrderService.create(CreateProductOrderDTO, userId)`(POST `/api/product/order/create`),无 `@Transactional` | 特价分支需要独立 `@Transactional` 事务入口,原子完成「扣量 + 插单」 |
+| 优惠券 | `couponService.apply/reserve/markUsed/revert`,`family_coupon.status` AVAILABLE/RESERVED/USED | 特价订单同样走这套;`allow_coupon=0` 时下单直接拒绝 couponId |
+| 库存扣减 | 支付成功时 `productService.decreaseStock`(不是下单时扣) | **特价供应量独立于商品库存**:活动总量 `sold_quantity` 下单即扣(占坑),商品库存保持现有支付时扣减不干预 |
+| 超时取消 | `OrderTimeoutCancelTask` 每分钟扫描 pending 超时订单,直接置 `cancelled` + 还券 + 恢复库存,**不经过 `cancel()`** | 回滚特价扣量必须在**该任务内新增**,不能只挂在 `cancel()` |
+| 订单状态 | `pending / paid / cancelled / refunding / refund_pending / refunded / completed / closed` 等 | 回滚挂 `cancelled` 与 `refunded` 两个终态 |
+| 供应商改价 | `reviewUpdate()` 允许供应商修改 pending 订单单价 | 特价订单必须禁止改价,否则可绕过特价校验 |
+| 小程序码 | `WechatService.generateWxacode(scene, page)`,scene≤32字符;产品详情页 `onLoad` 已解析 `scene` 中 `id=(\d+)` | 复用;scene 扩展为 `id={商品id}&sp={活动id}`,前端扩展解析 `sp=` |
+| 产品详情页 | `pages/discover-detail/product-detail/product-detail.vue`,扫码/分享进入,`checkoutItems` 存 storage → 跳 checkout | 特价状态经 `checkoutSpecialDealId` storage 透传给结算页 |
+| 管理端商品 | `ProductManage.vue` + `ProductEdit.vue`(已近 1300 行) | 特价做成**独立页面**,嵌入会继续膨胀商品编辑页 |
+
+## 5. 方案
+
+### 5.1 数据模型
+
+新表 `special_price_deals`(DatabaseInitializer 迁移 + schema.sql 同步):
+
+```sql
+CREATE TABLE IF NOT EXISTS special_price_deals (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  product_id BIGINT NOT NULL COMMENT '商品ID',
+  name VARCHAR(100) NOT NULL COMMENT '活动名称',
+  special_price INT NOT NULL COMMENT '特价(分)',
+  start_time DATETIME COMMENT '开始时间',
+  end_time DATETIME COMMENT '结束时间',
+  total_quantity INT NOT NULL DEFAULT 0 COMMENT '特价总供应量',
+  sold_quantity INT NOT NULL DEFAULT 0 COMMENT '已售数量(下单即扣)',
+  per_user_limit INT NOT NULL DEFAULT 1 COMMENT '每人限购件数',
+  allow_coupon TINYINT NOT NULL DEFAULT 0 COMMENT '是否允许优惠券 1=允许 0=禁止',
+  status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled',
+  created_at DATETIME,
+  updated_at DATETIME,
+  KEY idx_product (product_id),
+  KEY idx_status (status, start_time, end_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时特价活动'
+```
+
+`product_orders` 增加一列:
+
+```sql
+ALTER TABLE product_orders ADD COLUMN special_deal_id BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)' 
+```
+
+**状态机说明**:存储状态仅 `draft / active / disabled`;「未开始 / 进行中 / 已结束 / 已售罄」是**推断的运行状态**(`runStatus`),由 `info` / 列表 / 下单校验按当前时间与 `sold_quantity` 实时计算,不做定时任务改状态:
+
+```
+runStatus 取值(集中在一个函数 computeRunStatus(deal, now)):
+  deal == null                                    → INVALID    (已被删除,前端回落原价)
+  status = draft                                  → DRAFT      (未启用,前端不展示特价)
+  status = disabled                               → DISABLED   (已停用,前端不展示特价)
+  status = active 且 now < start_time             → NOT_STARTED
+  status = active 且 start_time <= now <= end_time:
+     sold_quantity < total_quantity               → ACTIVE
+     sold_quantity >= total_quantity              → SOLD_OUT
+  status = active 且 now > end_time               → ENDED
+前端特价展示仅对 ACTIVE / NOT_STARTED 生效;其余一律回落原价。
+```
+
+**单商品并发活动约束**:同一商品同时只允许一个 `status=active`(新建/启用时后端校验拦截并给出提示;非 active 的活动不参加互斥)。
+
+### 5.2 后端接口
+
+#### 管理端(新 `AdminSpecialPriceController`,`@RequestMapping("/api/admin/special-price")`,随 `/api/admin/**` 走既有管理员鉴权)
+
+| 接口 | 请求 | 返回 | 说明 |
+|---|---|---|---|
+| `POST /list` | `{page,size,keyword?,status?}` | `Result<{records,total}>`,每条含 `productName/price/runStatus/remainingQuantity` | 分页;`runStatus` 为推断状态 |
+| `POST /create` | `{productId,name,specialPrice,startTime,endTime,totalQuantity,perUserLimit,allowCoupon}` | `Result<SpecialPriceDeal>` | 校验必填与互斥;创建为 `draft` |
+| `POST /update` | `{id,...同上}` | `Result<SpecialPriceDeal>` | 仅 `draft/disabled` 可改(active 中不允许改价/量/时间,避免与已售数据不一致) |
+| `POST /status` | `{id, action:enable/disable}` | `Result<String>` | enable 校验时间窗口、商品在架、同商品互斥 |
+| `POST /delete` | `{id}` | `Result<String>` | 仅无特价订单(`product_orders.special_deal_id` 无记录)可删除;有订单仅允许 disable |
+| `POST /qrcode` | `{id}` | `Result<{qrCodeBase64}>` | 调 `wechatService.generateWxacode(sceene, page)`,scene=`id={productId}&sp={id}`,page=`pages/discover-detail/product-detail/product-detail`;测试模式(返回空串)时管理端显示占位提示 |
+
+#### 小程序端(新 `SpecialPriceController`,`@RequestMapping("/api/special-price")`,`info` 加入 JwtInterceptor / WebConfig 公开白名单)
+
+| 接口 | 请求 | 返回 | 说明 |
+|---|---|---|---|
+| `POST /info` | `{dealId}` | `Result<{id,productId,specialPrice,startTime,endTime,runStatus,totalQuantity,soldQuantity,remainingQuantity,perUserLimit,allowCoupon,userPurchased,userRemaining}>` | 公开(匿名可查活动展示信息);登录时附加 `userPurchased`(该用户已购件数)与 `userRemaining`(还剩可购件数);活动不存在、已被删除返回 `runStatus=INVALID` |
+
+#### 下单改动
+
+- `CreateProductOrderDTO` 增加 `specialDealId`(`Long`)字段。
+- `ProductOrder` 实体增加 `specialDealId` 字段(`product_orders.special_deal_id`)。
+- `ProductOrderService.create()`:`dto.specialDealId != null` 时委托给新增的独立事务入口 `createSpecialDealOrder(dto, buyerId)`(`@Transactional(rollbackFor = Exception.class)`),**保持普通订单创建的事务语义不变**(当前 `create` 无事务,不改造)。
+
+`createSpecialDealOrder` 校验与结算流程:
+
+```
+1. items 非空(多商品购物车路径)→ 拒绝:"特价商品请单独购买"
+2. 查询特价活动,校验:
+   - 活动存在且 status=active
+   - now 在 [start_time, end_time] 内,否则按 runStatus 给出提示(未开始/已结束)
+   - 商品匹配(deal.product_id == productId)
+   - dto.quantity >= 1
+3. 原子扣量(防并发超卖,核心):
+   UPDATE special_price_deals SET sold_quantity = sold_quantity + #{qty}, updated_at = now()
+    WHERE id = #{dealId} AND status = 'active'
+      AND start_time <= now() AND now() <= end_time
+      AND sold_quantity + #{qty} <= total_quantity
+   影响行数 = 0 → 重查活动判定原因(已售罄 / 已结束 / 非活动期),返回明确错误
+4. 每人限购(先查再扣,与扣量同事务):
+   SELECT COALESCE(SUM(quantity),0) FROM product_orders
+    WHERE special_deal_id = #{dealId} AND buyer_id = #{buyerId}
+      AND status NOT IN ('cancelled','refunded')
+   已购 + dto.quantity > per_user_limit → 拒绝:"每人限购N件,您已购买X件"
+5. 优惠券开关:allow_coupon = 0 且 dto.couponId != null → 拒绝:"该特价商品不可使用优惠券"
+6. 复用 create() 单商品构建逻辑(商品在架/库存检查、validateFulfill、地址、赠品、购买信息等),
+   定价替换:
+     unitPrice = specialPrice(覆盖会员价)
+     totalAmount = specialPrice * quantity
+   order.specialDealId = dealId
+   order.setStatus("pending"), coupon(如允许) 按原逻辑 apply+reserve
+7. 同一事务内:订单 insert + 扣量 UPDATE 原子完成
+```
+
+**回滚(释放供应量与限购额度)** —— 集中封装 `SpecialPriceService.releaseQuota(dealId, quantity)`:
+
+```sql
+UPDATE special_price_deals SET sold_quantity = GREATEST(sold_quantity - #{qty}, 0), updated_at = now()
+ WHERE id = #{dealId}
+```
+
+挂载点(三处,**全部**必须加,否则数量泄漏):
+
+| 挂载点 | 位置 | 触发 |
+|---|---|---|
+| 用户取消订单 | `ProductOrderService.cancel()` 置 `cancelled` 处(约 L1020,与恢复商品库存并列) | 用户取消 |
+| 退款完成 | `ProductOrderService.approveRefund()` 置 `refunded` 处(约 L1295,与恢复库存并列) | 退款审核通过 |
+| 超时自动取消 | `OrderTimeoutCancelTask.cancelTimeoutOrders()` 置 `cancelled` 处(约 L72,与还券/恢复库存并列) | pending 超 30 分钟 |
+
+> 回滚只在订单进入 `cancelled / refunded` 终态时执行;`refunding/refund_pending` 中间态保留占坑,与商品库存回滚时机完全对齐。
+
+**供应商改价防护**:`reviewUpdate()` 对 `order.specialDealId != null` 的订单直接返回错误("特价订单不允许修改价格"),只放行配送方式等非价格项(实现时按需裁剪)。
+
+### 5.3 小程序前端(cfc-frontend)
+
+**`pages/discover-detail/product-detail/product-detail.vue`**
+- `onLoad` 扩展 scene 解析:现有 `id=(\d+)`、`ref=([^&]+)` 之外,新增 `sp=(\d+)` → `this.specialDealId`;
+- 有 `sp` 时调用新 API `getSpecialPriceInfo(dealId)`(`utils/api.js` 封装 `POST /api/special-price/info`);
+- 特价区块展示(按 `runStatus`):
+  - `ACTIVE`:显示特价(划线原价)、「限每人N件 · 剩余X件」进度、购买按钮按特价走;
+  - `NOT_STARTED`:展示特价与「距开始」倒计时,购买按钮禁用;
+  - `SOLD_OUT`:「已抢完」标签,回落原价展示与购买;
+  - `ENDED / INVALID`:「特价已结束」,回落原价展示与购买;
+- 进行中点击购买 → 原直购逻辑(`checkoutItems` storage),并额外 `uni.setStorageSync('checkoutSpecialDealId', dealId)`;
+- 倒计时等时间展示遵循 `utils/format.js` 的 `parseDate()` 规范,**不用** `new Date(string)`(iOS 兼容);
+- 遵守 API 去重规范:新增接口是独立 URL,不与 `loadDetail` 冲突;`onLoad/onShow` 双发防护沿用现有 `_onLoadFired` 模式。
+
+**`pages/shop/checkout/checkout.vue`**
+- `onLoad` 读取 `checkoutSpecialDealId`(无则清空);
+- 下单 payload 增加 `specialDealId`;
+- `allow_coupon=0`(特价信息带过来)时**隐藏优惠券选择区**;
+- 成功下单后清理 `checkoutSpecialDealId`(与 `checkoutItems` 等一同 `removeStorageSync`)。
+
+**`utils/api.js`**:新增唯一入口 `getSpecialPriceInfo = (dealId) => request('/api/special-price/info', 'POST', { dealId })`。
+
+### 5.4 Web 管理端(cfc-web)
+
+新建 `src/views/admin/SpecialPriceManage.vue`(视觉/交互风格对齐 `ProductManage.vue`):
+
+- **列表**:活动名 / 商品(名+ID)/ 特价 vs 原价 / 时间窗口 / 供应量(已售+剩余)/ 每人限购 / 券开关 / 运行状态 / 操作(编辑、启停、删除、二维码);
+- **新建/编辑弹窗**:选商品(复用 `getProductList` 搜索下拉)、活动名、特价、起止时间(`el-date-picker type="datetime"`)、供应量、每人限购、券开关;
+- **二维码弹窗**:调 `/api/admin/special-price/qrcode` 生成小程序码图片(download 保存);测试模式空串时显示「测试模式无法生成,请在生产环境操作」;
+- 路由 `src/router/index.js` 新增 `/special-price`(商城分组,`perm: 'commerce:products'`);
+- `src/api/admin.js` 增加封装:`getSpecialPriceList/createSpecialPrice/updateSpecialPrice/setSpecialPriceStatus/deleteSpecialPrice/getSpecialPriceQrcode`。
+
+### 5.5 JwtInterceptor / WebConfig 白名单
+
+`/api/special-price/info` 加入公开白名单(与 `/api/product/detail` 同级),匿名可查看活动展示信息;下单、购买仍走既有登录校验。
+
+## 6. 边界与错误处理
+
+| 场景 | 处理 |
+|---|---|
+| 匿名用户扫码 | `info` 公开可看;点购买触发既有登录流程 |
+| 活动已停用/删除后旧二维码扫码 | 页面回落原价;`info` 返回 `INVALID`;下单(若带 dealId)拒绝 |
+| 特价订单并发下单 | 原子 UPDATE 条件扣量,超卖被数据库层拦截 |
+| 用户挂多个 pending 单 | 每人限购按「非取消/退款」订单累计,pending 同样占额度(与 30 分钟占坑一致) |
+| 退款进行中 | 额度保留至 `refunded` 才释放(防「买了退、退了买」刷量) |
+| 商品库存不足 | 复用 create 既有库存检查;支付时 `decreaseStock` 失败走既有失败路径 |
+| 特价 > 原价 | 创建时校验 `specialPrice < product.price`(允许等于?不允许,必须低于原价) |
+| 同商品并发 active 活动 | 新建/启用互斥校验 |
+| 供应商改价 | 特价订单禁止 |
+| 时区 | 与全系统一致(服务器本地时间),管理端输入按 `yyyy-MM-dd HH:mm:ss` |
+
+## 7. 验收标准
+
+- [ ] `mvn clean compile` 通过;DatabaseInitializer 新增迁移(建表 + `product_orders.special_deal_id`)幂等,schema.sql 同步。
+- [ ] 管理端可创建/编辑/启停/删除活动;同一商品仅一个 active;特价必须低于原价。
+- [ ] 管理端生成的小程序码 scene 为 `id={productId}&sp={dealId}`(≤32 字符),页面路径正确。
+- [ ] 小程序扫码进入产品详情页:未开始显示倒计时且按钮禁用;进行中显示特价并可下单;已售罄/已结束回落原价。
+- [ ] 特价下单校验全通过:时间窗口、原子扣量(并发不超卖)、每人限购累计、券开关。
+- [ ] 取消 / 超时自动取消 / 退款完成后 `sold_quantity` 回滚(`GREATEST(x-? ,0)`),额度释放可再购。
+- [ ] 特价订单 `unitPrice = specialPrice`(覆盖会员价),保留积分抵扣与赠品;`allow_coupon=0` 时禁用优惠券。
+- [ ] 特价订单供应商改价被拒绝。
+- [ ] 文档更新:`docs/superpowers/api/API_REFERENCE.md` 记录新接口;`PROJECT-OVERVIEW.md` 登记本文档。
+
+## 8. 涉及文件清单
+
+**cfc-backend**
+- 新增:`entity/SpecialPriceDeal.java`、`mapper/SpecialPriceDealMapper.java`、`service/SpecialPriceService.java`、`controller/special/SpecialPriceController.java`、`controller/admin/AdminSpecialPriceController.java`
+- 修改:`config/DatabaseInitializer.java`(迁移)、`resources/schema.sql`、`service/ProductOrderService.java`(特价分支+回滚+改价防护)、`task/OrderTimeoutCancelTask.java`(回滚)、`entity/ProductOrder.java`、`dto/CreateProductOrderDTO.java`、`config/JwtInterceptor.java` + `config/WebConfig.java`(白名单)、`docs/superpowers/api/API_REFERENCE.md`
+
+**cfc-frontend(小程序)**
+- 修改:`pages/discover-detail/product-detail/product-detail.vue`、`pages/shop/checkout/checkout.vue`、`utils/api.js`
+
+**cfc-web(管理端)**
+- 新增:`src/views/admin/SpecialPriceManage.vue`
+- 修改:`src/router/index.js`、`src/api/admin.js`