Ver Fonte

docs(plans): 限时特价(扫码闪购)实现计划 v1 + PROJECT-OVERVIEW 登记 v2.20

Sisyphus Agent há 5 dias atrás
pai
commit
43965217d0

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

@@ -6,6 +6,7 @@
 **指标变化曲线·指标速查·指标关联(✅ 全栈完成:后端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`)**
 **健康方案删除(✅ 全栈完成:后端级联删除接口含每日实例二次删除 + 小程序/管理端删除按钮 + 确认弹窗;见 `specs/2026-10-17-health-plan-deletion-design.md` 与 `plans/2026-10-17-health-plan-deletion.md`)**
+**限时特价(扫码闪购)(🟡 实现计划就绪待执行:设计规格已确认 + 7 任务实现计划(迁移389/390 → SpecialPriceService → 双控制器 → 特价下单+三处回滚 → 小程序 → 管理端 → 验收),5 个测试类;见 `specs/2026-10-20-limited-time-special-price-design.md` 与 `plans/2026-10-20-limited-time-special-price.md`)**
 **菌群知识并入 dan_knowledge_base(🔄 实施中:809 篇文章以 source_type='article' 并入统一知识表 + 后端 sourceType/excludeSourceType 过滤 + langgraph 三源重建 + 管理端统一展示,见 `specs/2026-10-20-knowledge-unify-design.md` 与 `plans/2026-10-20-knowledge-unify.md`)**
 **维护:** 所有需求变更需更新本文档
 
@@ -206,7 +207,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` |
+| 限时特价(扫码闪购) | Phase 4 | 🟡 实现计划就绪待执行(特殊价格活动表 + 专属小程序码 + 下单原子扣量/每人限购/券开关 + 回滚链路 + 管理端页面) | `specs/2026-10-20-limited-time-special-price-design.md` + `plans/2026-10-20-limited-time-special-price.md` |
 
 ---
 

+ 1222 - 0
docs/superpowers/plans/2026-10-20-limited-time-special-price.md

@@ -0,0 +1,1222 @@
+# 限时特价(扫码闪购)实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 为 CFC 商城增加「限时特价(扫码闪购)」功能:运营在管理端创建特价活动(定价、时间、数量、每人限购、是否允许优惠券),生成专属小程序码;用户扫码进入商品详情页后,活动进行中时按特价结算下单;下单原子扣量、支持取消/退款/超时回滚;管理端看进度、下载二维码。
+
+**架构:** 新增一张 `special_price_deals` 表记录活动;`product_orders` 加 `special_deal_id` 列标识特价单;下单入口在 `ProductOrderService.createSpecialDealOrder` 独立事务内做原子扣量+插入订单;回滚由取消/退款/超时取消三个挂载点统一调用 `releaseQuota`。商品详情页按活动 `runStatus` 展示特价;`/api/special-price/info` 公开(匿名可查看);管理端 `/api/admin/special-price/*` 用既有管理员鉴权。
+
+**技术栈:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / JWT / com.google.zxing(现有小程序码)/ Vue 2 Options API / Element UI / uni-app 微信小程序。
+
+**规格:** 本计划所实现的规格为 `docs/superpowers/specs/2026-10-20-limited-time-special-price-design.md`(设计文档与项目级约束、数据模型、接口契约、状态机定义都来自该文件,执行前必须先读完)。
+
+## 全局约束
+
+- **分层禁止跨层**:Controller → Service → Mapper,禁止 Controller 直调 Mapper 或 Service 跳过 Mapper。
+- **ORM**:MyBatis-Plus `@TableName` + `@TableId(type = IdType.AUTO)`;新建实体必须用 `@Data`(沿用现有产品实体的 Lombok 风格),但显式定义 getter/setter(遵循 `ProductOrder.java:85-111` 明确 setter 的既有模式,因为 Lombok 字段冲突问题)。
+- **DI**:`@Resource`,字段名与类型默认 Bean Name 一致(如 `SpecialPriceDealMapper specialPriceDealMapper`)。
+- **接口方法**:所有后端接口统一 `@PostMapping`,**禁止** `@GetMapping`/`@PutMapping`/`@DeleteMapping`。
+- **响应包装**:统一 `Result<T>`(`Result.success(data)` / `Result.error(msg)` / `Result.error(code, msg)`),不允许裸 map。
+- **数据库迁移**:DDL 必须在 `DatabaseInitializer.runMigrations()` 通过 `ensureColumn`/`try-jdbcTemplate.execute` 加;schema.sql 同步;迁移编号从当前最大 `迁移N` 递增(见 Migration 章节)。
+- **schema.sql 同步**:每次 DDL 增改(含新表 + 加列)同步追加/修改 schema.sql。
+- **时间显示**:后端返回 ISO 8601(`yyyy-MM-ddTHH:mm:ss`);小程序前端按 `yyyy-MM-dd HH:mm:ss` / `yyyy-MM-dd` 格式化;**禁止** `new Date(string)`(iOS JS 兼容),统一 `utils/format.js` 的 `parseDate()`;小程序倒计时用 `setInterval` + `parseDate`。
+- **API 去重**:前端 `utils/api.js` 中,同一个后端 URL 只能有一个 `request()` 封装入口;语义相同的别名叫 `export const foo = (...) => request(...)` 委托。
+- **API 文档更新**:实现完毕后在 `docs/superpowers/api/API_REFERENCE.md` 补充新接口条目(按现有 4.x 格式)。
+- **项目规范**:严格遵守 cfc-backend / cfc-web / cfc-frontend 各自的 AGENTS.md(特别是 Bean 命名冲突、时间格式、禁止新引入 `childId`、POST 统一等)。
+
+## 审查重点(Review Focus)
+
+> 以下五项是规格未完全钉死、最容易踩坑的边界。每条对应一个任务的测试覆盖点(见每个任务的验收步骤):
+
+1. **并发超卖**:同一 `special_deal_id` 同时多笔下单时,`sold_quantity` 必须不超 `total_quantity`。验收:`SpecialPriceService.consumeQuota` 的 SQL 必须带 `AND sold_quantity + ? <= total_quantity` 且用 `UPDATE ... WHERE id=?` 原子完成;测试验证影响行数=0 时退回错误。
+2. **每人限购按订单终态累计**:只统计非 `cancelled`/`refunded` 的订单;pending 同样占额度。验收:`userPurchased` 查询 `WHERE special_deal_id=? AND buyer_id=? AND status NOT IN ('cancelled','refunded')`;`releaseQuota` 只在终态调用。
+3. **回滚不泄漏**:超时取消(`OrderTimeoutCancelTask`)、用户取消、退款完成三处必须都调用 `releaseQuota`,否则已售量永久泄漏。用户取消/退款由 `ProductOrderServiceSpecialDealTest` 用 mock 直接验证;超时取消任务因开头有 `DatabaseInitializer.isMigrationsComplete()` 静态开关(单元测试默认 false,直接调方法会提前返回),主循环不可单测——因此把释放逻辑抽成 package-private 方法 `releaseSpecialDealQuotaIfNeeded(ProductOrder)` 由 `OrderTimeoutCancelTaskSpecialDealTest` 验证(见任务 4)。验收:`cancelTimeoutOrders()` 循环内归还优惠券/恢复库存之后调用该方法;测试验证有 `specialDealId` 时调 `releaseQuota`、无则不调。
+4. **特价覆盖会员价但保留积分和赠品**:特价单 `unitPrice=specialPrice` 不走 `getEligibleMemberPrice`;但积分抵扣、赠品逻辑与原 `create()` 完全一致。验收:`createSpecialDealOrder` 分支定价路径;订单 DTO 返回 `unitPrice` 等于 `specialPrice`。
+5. **活动停用后旧二维码失效**:`status=disabled/draft` 时 `info` 返回 `runStatus=INVALID` 或 `DISABLED`/`DRAFT`,详情页回落原价;已生成的订单不受影响。验收:测试 `computeRunStatus` 对 `draft/disabled` 的返回,控制器 `info` 对非 active 拒绝特价。
+
+---
+
+## 文件清单
+
+**新建**
+- `cfc-backend/src/main/java/com/etotem/cfc/entity/SpecialPriceDeal.java`
+- `cfc-backend/src/main/java/com/etotem/cfc/mapper/SpecialPriceDealMapper.java`
+- `cfc-backend/src/main/resources/mapper/SpecialPriceDealMapper.xml`
+- `cfc-backend/src/main/java/com/etotem/cfc/service/SpecialPriceService.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/service/SpecialPriceServiceTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderServiceSpecialDealTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/task/OrderTimeoutCancelTaskSpecialDealTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/integration/controller/SpecialPriceControllerTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/integration/controller/AdminSpecialPriceControllerTest.java`
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/special/SpecialPriceController.java`
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSpecialPriceController.java`
+- `cfc-web/src/views/admin/SpecialPriceManage.vue`
+- `cfc-web/src/api/admin.js`(新增 6 个封装函数)
+- `docs/superpowers/plans/2026-10-20-limited-time-special-price.md`(本文件)
+
+**修改**
+- `cfc-backend/src/main/resources/schema.sql`(第 1357 行之后加 `special_price_deals` 建表 + product_orders 加列)
+- `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(迁移 389 + 390)
+- `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java`(加 `specialDealId` + 显式 setter)
+- `cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java`(加 `specialDealId`)
+- `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java`(加特价分支 + 三处回滚 + reviewUpdate 防护)
+- `cfc-backend/src/main/java/com/etotem/cfc/task/OrderTimeoutCancelTask.java`(加回滚调用)
+- `cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java`(`/api/special-price/info` 白名单)
+- `cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java`(`jwtInterceptor` 与 `familyAccessInterceptor` 的 `excludePathPatterns` 各加一条)
+- `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue`(scene 解析 `sp=` + 特价区块 + 购买透传)
+- `cfc-frontend/pages/shop/checkout/checkout.vue`(读取 checkoutSpecialDealId + payload 透传 + 隐藏优惠券)
+- `cfc-frontend/utils/api.js`(新增 `getSpecialPriceInfo`)
+- `cfc-web/src/router/index.js`(加 `/special-price` 路由)
+- `docs/superpowers/api/API_REFERENCE.md`(补新接口条目)
+
+---
+
+## 任务分解
+
+### 任务 1:数据库迁移 + 实体 + Mapper
+
+**文件:**
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/entity/SpecialPriceDeal.java`
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/SpecialPriceDealMapper.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(迁移 389 + 390)
+- 修改:`cfc-backend/src/main/resources/schema.sql`(product_orders 加列 + 建新表)
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java`(加字段 + 显式 setter)
+- 编译验证:`mvn clean compile`
+
+> 迁移 389:`CREATE TABLE IF NOT EXISTS special_price_deals`
+> 迁移 390:`ALTER TABLE product_orders ADD COLUMN special_deal_id BIGINT NULL ...`
+
+- [ ] **步骤 1:编写 `schema.sql` 中两个 DDL**
+
+在 `schema.sql` 的 `product_orders` 建表语句(第 1357 行 `ENGINE=InnoDB...`)之后追加 `special_price_deals` 建表。在 `product_orders` 的 `INDEX idx_supply_system_id (supply_system_id)` 之后追加列定义。DDL 严格对照规格 §5.1 的字段:
+
+```sql
+-- 限时特价活动表
+CREATE TABLE IF NOT EXISTS special_price_deals (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    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,
+    INDEX idx_product (product_id),
+    INDEX idx_status (status, start_time, end_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时特价活动';
+```
+
+在 `product_orders` 的 `INDEX idx_supply_system_id (supply_system_id)` 之后追加:
+
+```sql
+    special_deal_id BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)',
+```
+
+(保持 `product_orders` 原有字段顺序,仅在已有列最后插入,避免破坏已有的索引与注释。)
+
+- [ ] **步骤 2:在 `DatabaseInitializer.java` 追加两个迁移**
+
+先定位文件末尾搜索 `// 迁移` 确认最大编号(当前为 `388`)。新增:
+
+```java
+// 迁移389: 创建 special_price_deals 表(限时特价活动,见 specs/2026-10-20)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS special_price_deals (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+            "product_id BIGINT NOT NULL," +
+            "name VARCHAR(100) NOT NULL," +
+            "special_price INT NOT NULL," +
+            "start_time DATETIME," +
+            "end_time DATETIME," +
+            "total_quantity INT NOT NULL DEFAULT 0," +
+            "sold_quantity INT NOT NULL DEFAULT 0," +
+            "per_user_limit INT NOT NULL DEFAULT 1," +
+            "allow_coupon TINYINT NOT NULL DEFAULT 0," +
+            "status VARCHAR(20) NOT NULL DEFAULT 'draft'," +
+            "created_at DATETIME," +
+            "updated_at DATETIME," +
+            "INDEX idx_product (product_id)," +
+            "INDEX idx_status (status, start_time, end_time)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时特价活动'");
+    log.info("迁移389: 已创建special_price_deals表");
+} catch (Exception e) {
+    log.warn("迁移389: 创建special_price_deals失败(可能已存在): {}", e.getMessage());
+}
+
+// 迁移390: product_orders 表添加 special_deal_id 列(限时特价订单关联,见 specs/2026-10-20)
+ensureColumn("product_orders", "special_deal_id", "BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)'");
+```
+
+> `ensureColumn` 是既有辅助方法(位于 `DatabaseInitializer.java` 约第 4099 行),自动忽略"列已存在"异常。
+
+- [ ] **步骤 3:创建实体 `SpecialPriceDeal.java`**
+
+按 `Product.java` 的 Lombok + 显式 setter 风格:
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+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("special_price_deals")
+public class SpecialPriceDeal implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long productId;
+    private String name;
+    private Integer specialPrice;       // 分
+    private Date startTime;
+    private Date endTime;
+    private Integer totalQuantity;
+    private Integer soldQuantity;
+    private Integer perUserLimit;
+    private Integer allowCoupon;        // 0/1
+    private String status;              // draft/active/disabled
+    private Date createdAt;
+    private Date updatedAt;
+    // 显式 setter(避免 Lombok 字段冲突)
+    public void setStatus(String status) { this.status = status; }
+    public void setSoldQuantity(Integer soldQuantity) { this.soldQuantity = soldQuantity; }
+    public void setTotalQuantity(Integer totalQuantity) { this.totalQuantity = totalQuantity; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}
+```
+
+- [ ] **步骤 4:创建 Mapper `SpecialPriceDealMapper.java`**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SpecialPriceDeal;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SpecialPriceDealMapper extends BaseMapper<SpecialPriceDeal> {
+    /** 原子扣量:WHERE id=? AND status='active' AND sold_quantity + ? <= total_quantity */
+    int consumeQuota(@org.apache.ibatis.annotations.Param("id") Long dealId,
+                     @org.apache.ibatis.annotations.Param("qty") int qty);
+    /** 回滚释放:sold_quantity = GREATEST(sold_quantity - ?, 0) */
+    int releaseQuota(@org.apache.ibatis.annotations.Param("id") Long dealId,
+                     @org.apache.ibatis.annotations.Param("qty") int qty);
+    /** 统计用户已购(pending/paid/refunding 均计,cancelled/refunded 排除) */
+    int userPurchased(@org.apache.ibatis.annotations.Param("dealId") Long dealId,
+                      @org.apache.ibatis.annotations.Param("buyerId") Long buyerId);
+}
+```
+
+对应的 XML 映射放在 `cfc-backend/src/main/resources/mapper/SpecialPriceDealMapper.xml`(新建,与 `ProductOrderMapper.xml` 同目录风格):
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.etotem.cfc.mapper.SpecialPriceDealMapper">
+
+    <update id="consumeQuota">
+        UPDATE special_price_deals
+        SET sold_quantity = sold_quantity + #{qty},
+            updated_at    = NOW()
+        WHERE id = #{id}
+          AND status = 'active'
+          AND start_time &lt;= NOW()
+          AND NOW() &lt;= end_time
+          AND sold_quantity + #{qty} &lt;= total_quantity
+    </update>
+
+    <update id="releaseQuota">
+        UPDATE special_price_deals
+        SET sold_quantity = GREATEST(sold_quantity - #{qty}, 0),
+            updated_at    = NOW()
+        WHERE id = #{id}
+    </update>
+
+    <select id="userPurchased" resultType="int">
+        SELECT COALESCE(SUM(quantity), 0)
+        FROM product_orders
+        WHERE special_deal_id = #{dealId}
+          AND buyer_id = #{buyerId}
+          AND status NOT IN ('cancelled', 'refunded')
+    </select>
+
+</mapper>
+```
+
+- [ ] **步骤 5:扩展 `ProductOrder.java` 加 `specialDealId`**
+
+字段:`private Long specialDealId;` 后接显式 setter(与 `ProductOrder.java:85-111` 的风格保持一致):
+
+```java
+private Long specialDealId;            // 限时特价活动ID(非空=特价单)
+public void setSpecialDealId(Long specialDealId) { this.specialDealId = specialDealId; }
+```
+
+- [ ] **步骤 6:运行 `mvn clean compile` 验证**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn clean compile
+```
+
+预期:BUILD SUCCESS,无报错。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/resources/schema.sql
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/SpecialPriceDeal.java
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/SpecialPriceDealMapper.java
+git add cfc-backend/src/main/resources/mapper/SpecialPriceDealMapper.xml
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java
+git commit -m "feat(db): 添加special_price_deals表 + product_orders.special_deal_id列 + 实体/Mapper"
+```
+
+---
+
+### 任务 2:SpecialPriceService 核心服务(含测试)
+
+**文件:**
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/service/SpecialPriceService.java`
+- 新建:`cfc-backend/src/test/java/com/etotem/cfc/service/SpecialPriceServiceTest.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/mapper/SpecialPriceDealMapper.java`(追加列表查询方法)
+- 修改:`cfc-backend/src/main/resources/mapper/SpecialPriceDealMapper.xml`(追加列表 SQL)
+
+- [ ] **步骤 0:在 `SpecialPriceDealMapper` 追加列表方法及 XML**(供管理端分页)
+
+Mapper 接口追加:
+
+```java
+/** 分页列表(JOIN products 取商品名/原价),返回字段含 productName/productPrice。
+ *  runStatus: ACTIVE/NOT_STARTED/SOLD_OUT/ENDED(SQL 推导)或 draft/disabled(按存储状态),空=全部 */
+java.util.List<java.util.Map<String, Object>> listWithProductName(
+    @org.apache.ibatis.annotations.Param("keyword") String keyword,
+    @org.apache.ibatis.annotations.Param("runStatus") String runStatus,
+    @org.apache.ibatis.annotations.Param("limit") int limit,
+    @org.apache.ibatis.annotations.Param("offset") int offset);
+/** 分页总数(与 listWithProductName 同 where 条件) */
+long countByKeyword(@org.apache.ibatis.annotations.Param("keyword") String keyword,
+                    @org.apache.ibatis.annotations.Param("runStatus") String runStatus);
+```
+
+XML 追加(`</mapper>` 之前)。运行状态在 SQL 内用 `NOW()` 推导(管理端分页必须由 DB 过滤,不能取回内存过滤否则翻页总数失准):
+
+```xml
+<sql id="runStatusCondition">
+    <if test="runStatus != null and runStatus != ''">
+        <choose>
+            <when test="runStatus == 'ACTIVE'">
+                AND d.status = 'active' AND d.start_time &lt;= NOW() AND NOW() &lt;= d.end_time
+                AND d.sold_quantity &lt; d.total_quantity
+            </when>
+            <when test="runStatus == 'NOT_STARTED'">
+                AND d.status = 'active' AND d.start_time &gt; NOW()
+            </when>
+            <when test="runStatus == 'SOLD_OUT'">
+                AND d.status = 'active' AND d.start_time &lt;= NOW() AND NOW() &lt;= d.end_time
+                AND d.sold_quantity &gt;= d.total_quantity
+            </when>
+            <when test="runStatus == 'ENDED'">
+                AND d.status = 'active' AND (d.end_time IS NULL OR d.end_time &lt; NOW())
+            </when>
+            <otherwise>
+                AND d.status = #{runStatus}
+            </otherwise>
+        </choose>
+    </if>
+</sql>
+
+<select id="listWithProductName" resultType="java.util.Map">
+    SELECT d.id, d.product_id AS productId, p.name AS productName, p.price AS productPrice,
+           d.name, d.special_price AS specialPrice, d.start_time AS startTime, d.end_time AS endTime,
+           d.total_quantity AS totalQuantity, d.sold_quantity AS soldQuantity,
+           d.per_user_limit AS perUserLimit, d.allow_coupon AS allowCoupon, d.status
+    FROM special_price_deals d
+    LEFT JOIN products p ON p.id = d.product_id
+    <where>
+        <if test="keyword != null and keyword != ''">
+            AND d.name LIKE CONCAT('%', #{keyword}, '%')
+        </if>
+        <include refid="runStatusCondition"/>
+    </where>
+    ORDER BY d.created_at DESC, d.id DESC
+    LIMIT #{limit} OFFSET #{offset}
+</select>
+
+<select id="countByKeyword" resultType="long">
+    SELECT COUNT(*) FROM special_price_deals d
+    <where>
+        <if test="keyword != null and keyword != ''">
+            AND d.name LIKE CONCAT('%', #{keyword}, '%')
+        </if>
+        <include refid="runStatusCondition"/>
+    </where>
+</select>
+```
+
+- [ ] **步骤 1:编写测试**
+
+按照 `ProductOrderServiceUpdateExpressFieldTest.java` 的 Mockito + `ReflectionTestUtils` 模式。新建 `SpecialPriceServiceTest.java`,测试 `computeRunStatus` 状态机和 `consumeQuota/releaseQuota` 两事务方法的调用契约:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.mapper.SpecialPriceDealMapper;
+import com.etotem.cfc.entity.SpecialPriceDeal;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Calendar;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+public class SpecialPriceServiceTest {
+
+    private SpecialPriceService service;
+    private SpecialPriceDealMapper mapper;
+
+    @BeforeEach
+    public void setUp() {
+        service = new SpecialPriceService();
+        mapper = mock(SpecialPriceDealMapper.class);
+        ReflectionTestUtils.setField(service, "specialPriceDealMapper", mapper);
+    }
+
+    @Test
+    public void computeRunStatus_draft_returnsDRAFT() {
+        assertEquals("DRAFT", SpecialPriceService.computeRunStatus("draft", null, null, 0, 0));
+    }
+
+    @Test
+    public void computeRunStatus_disabled_returnsDISABLED() {
+        assertEquals("DISABLED", SpecialPriceService.computeRunStatus("disabled", null, null, 0, 0));
+    }
+
+    @Test
+    public void computeRunStatus_active_not_started_returnsNOT_STARTED() {
+        Date future = new Date(System.currentTimeMillis() + 86400_000L);
+        assertEquals("NOT_STARTED", SpecialPriceService.computeRunStatus("active", future, null, 0, 100));
+    }
+
+    @Test
+    public void computeRunStatus_active_within_window_sold_out_returnsSOLD_OUT() {
+        Date now = new Date();
+        Date tomorrow = new Date(now.getTime() + 86400_000L);
+        assertEquals("SOLD_OUT", SpecialPriceService.computeRunStatus("active", now, tomorrow, 100, 100));
+    }
+
+    @Test
+    public void computeRunStatus_active_within_window_remaining_returnsACTIVE() {
+        Date now = new Date();
+        Date tomorrow = new Date(now.getTime() + 86400_000L);
+        assertEquals("ACTIVE", SpecialPriceService.computeRunStatus("active", now, tomorrow, 5, 100));
+    }
+
+    @Test
+    public void computeRunStatus_active_past_end_returnsENDED() {
+        Date past = new Date(System.currentTimeMillis() - 86400_000L);
+        assertEquals("ENDED", SpecialPriceService.computeRunStatus("active", past, past, 5, 100));
+    }
+
+    @Test
+    public void consumeQuota_callsMapperWithCorrectIdQty() {
+        when(mapper.consumeQuota(1L, 2)).thenReturn(1);
+        assertTrue(service.consumeQuota(1L, 2));
+        verify(mapper).consumeQuota(1L, 2);
+    }
+
+    @Test
+    public void consumeQuota_whenZeroRows_throwsInsufficientException() {
+        when(mapper.consumeQuota(1L, 2)).thenReturn(0);
+        assertThrows(IllegalStateException.class, () -> service.consumeQuota(1L, 2));
+    }
+
+    @Test
+    public void releaseQuota_callsMapperGreatest() {
+        service.releaseQuota(1L, 3);
+        verify(mapper).releaseQuota(1L, 3);
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试确认失败**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn test -pl . -Dtest=SpecialPriceServiceTest
+```
+
+预期:`COMPILATION FAILURE`(`SpecialPriceService` 不存在 / `computeRunStatus` 是静态方法未定义)。
+
+- [ ] **步骤 3:创建 `SpecialPriceService.java`(完整实现)**
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.SpecialPriceDeal;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.SpecialPriceDealMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class SpecialPriceService {
+
+    @Resource
+    private SpecialPriceDealMapper specialPriceDealMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    /**
+     * 状态机:从存储状态 + 时间窗 + 已售量推导出运行状态(纯逻辑,不访问 DB)。
+     * 用于详情与列表接口,避免每次跑 SQL。
+     */
+    public static String computeRunStatus(String status,
+                                          java.util.Date startTime,
+                                          java.util.Date endTime,
+                                          int soldQuantity,
+                                          int totalQuantity) {
+        if (status == null || status.isEmpty()) return "DRAFT";
+        switch (status) {
+            case "draft":           return "DRAFT";
+            case "disabled":        return "DISABLED";
+            case "active": {
+                java.util.Date now = new java.util.Date();
+                if (startTime != null && now.before(startTime)) return "NOT_STARTED";
+                if (endTime != null && now.after(endTime))     return "ENDED";
+                if (soldQuantity >= totalQuantity) return "SOLD_OUT";
+                return "ACTIVE";
+            }
+            default: return "DISABLED";
+        }
+    }
+
+    public Map<String, Object> list(int page, int size, String keyword, String runStatus) {
+        int offset = (page - 1) * size;
+        java.util.List<java.util.Map<String, Object>> records =
+            specialPriceDealMapper.listWithProductName(keyword, runStatus, size, offset);
+        long total = specialPriceDealMapper.countByKeyword(keyword, runStatus);
+        Map<String, Object> data = new HashMap<>();
+        data.put("records", records);
+        data.put("total", total);
+        return data;
+    }
+
+    public SpecialPriceDeal getById(Long id) {
+        return specialPriceDealMapper.selectById(id);
+    }
+
+    public SpecialPriceDeal create(SpecialPriceDeal deal) {
+        // 校验:特价必须低于商品原价
+        Product product = productMapper.selectById(deal.getProductId());
+        if (product == null) throw new IllegalArgumentException("商品不存在");
+        int productPrice = product.getPrice() != null ? product.getPrice() : 0;
+        if (deal.getSpecialPrice() == null || deal.getSpecialPrice() >= productPrice) {
+            throw new IllegalArgumentException("特价必须低于商品原价");
+        }
+        // 校验时间
+        if (deal.getStartTime() != null && deal.getEndTime() != null
+            && deal.getStartTime().after(deal.getEndTime())) {
+            throw new IllegalArgumentException("开始时间必须早于结束时间");
+        }
+        // 校验同商品互斥 active
+        int activeCount = specialPriceDealMapper.countActiveByProduct(deal.getProductId());
+        if (activeCount > 0) {
+            throw new IllegalStateException("该商品已有进行中的特价活动,无法新建/启用");
+        }
+        deal.setStatus("draft");
+        deal.setCreatedAt(new java.util.Date());
+        deal.setUpdatedAt(new java.util.Date());
+        specialPriceDealMapper.insert(deal);
+        return deal;
+    }
+
+    public void update(SpecialPriceDeal deal) {
+        // 仅 draft/disabled 可修改
+        SpecialPriceDeal old = getById(deal.getId());
+        if (old == null) throw new IllegalArgumentException("特价活动不存在");
+        if (!"draft".equals(old.getStatus()) && !"disabled".equals(old.getStatus())) {
+            throw new IllegalStateException("仅草稿/已停用可修改");
+        }
+        // 修改前校验特价<原价
+        Product product = productMapper.selectById(deal.getProductId());
+        if (product != null) {
+            int productPrice = product.getPrice() != null ? product.getPrice() : 0;
+            if (deal.getSpecialPrice() == null || deal.getSpecialPrice() >= productPrice) {
+                throw new IllegalArgumentException("特价必须低于商品原价");
+            }
+        }
+        deal.setUpdatedAt(new java.util.Date());
+        specialPriceDealMapper.updateById(deal);
+    }
+
+    public void updateStatus(Long id, String newStatus) {
+        SpecialPriceDeal d = getById(id);
+        if (d == null) throw new IllegalArgumentException("特价活动不存在");
+        if (!"draft".equals(d.getStatus()) && !"disabled".equals(d.getStatus())) {
+            throw new IllegalStateException("仅草稿/已停用状态可启用或停用");
+        }
+        if ("active".equals(newStatus)) {
+            // 再次检查互斥(并发安全:DB 唯一索引更好,此处双重检查)
+            int activeCount = specialPriceDealMapper.countActiveByProduct(d.getProductId());
+            if (activeCount > 0) {
+                throw new IllegalStateException("该商品已有进行中的特价活动,无法启用");
+            }
+        }
+        d.setStatus(newStatus);
+        d.setUpdatedAt(new java.util.Date());
+        specialPriceDealMapper.updateById(d);
+    }
+
+    public void delete(Long id) {
+        SpecialPriceDeal d = getById(id);
+        if (d == null) throw new IllegalArgumentException("特价活动不存在");
+        // 检查是否已有特价订单
+        int orderCount = specialPriceDealMapper.countOrdersByDeal(id);
+        if (orderCount > 0) {
+            throw new IllegalStateException("该活动已有 " + orderCount + " 笔特价订单,不可删除(请改为停用)");
+        }
+        specialPriceDealMapper.deleteById(id);
+    }
+
+    /**
+     * 原子扣量:影响行数=1 成功;=0 失败(已售罄 / 非活动期 / 非 active)。
+     */
+    public boolean consumeQuota(Long dealId, int qty) {
+        int affected = specialPriceDealMapper.consumeQuota(dealId, qty);
+        if (affected == 0) throw new IllegalStateException("已售罄或活动不可用");
+        return true;
+    }
+
+    /**
+     * 回滚释放:用于取消 / 退款 / 超时取消。sold_quantity 不跌到 < 0。
+     */
+    public void releaseQuota(Long dealId, int qty) {
+        if (dealId == null) return;
+        specialPriceDealMapper.releaseQuota(dealId, qty);
+    }
+
+    public int getUserPurchased(Long dealId, Long buyerId) {
+        return specialPriceDealMapper.userPurchased(dealId, buyerId);
+    }
+}
+```
+
+> 注意:新增了两个 Mapper 方法 `countActiveByProduct(productId)` 和 `countOrdersByDeal(dealId)`,需同步到 Mapper 接口与 XML。
+
+**Mapper 接口追加:**
+
+```java
+/** 同商品进行中(status=active 且在时间窗内)的活动数,用于互斥校验 */
+int countActiveByProduct(@org.apache.ibatis.annotations.Param("productId") Long productId);
+/** 该特价活动已产生的订单数 */
+int countOrdersByDeal(@org.apache.ibatis.annotations.Param("dealId") Long dealId);
+```
+
+**XML 追加:**
+
+```xml
+<select id="countActiveByProduct" resultType="int">
+    SELECT COUNT(*) FROM special_price_deals
+    WHERE product_id = #{productId}
+      AND status = 'active'
+      AND start_time <= NOW()
+      AND NOW() <= end_time
+</select>
+
+<select id="countOrdersByDeal" resultType="int">
+    SELECT COUNT(*) FROM product_orders
+    WHERE special_deal_id = #{dealId}
+</select>
+```
+
+- [ ] **步骤 4:运行测试确认通过**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn test -pl . -Dtest=SpecialPriceServiceTest
+```
+
+预期:`Tests run: 9, Failures: 0, Errors: 0, Skipped: 0`。
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn clean compile
+```
+
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/SpecialPriceService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/SpecialPriceDealMapper.java
+git add cfc-backend/src/main/resources/mapper/SpecialPriceDealMapper.xml
+git add cfc-backend/src/test/java/com/etotem/cfc/service/SpecialPriceServiceTest.java
+git commit -m "feat(service): SpecialPriceService 核心服务与状态机测试"
+```
+
+---
+
+### 任务 3:控制器层(管理端 + 小程序端)
+
+**文件:**
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSpecialPriceController.java`
+- 新建:`cfc-backend/src/main/java/com/etotem/cfc/controller/special/SpecialPriceController.java`
+
+- [ ] **步骤 1:`SpecialPriceController` 接口定义**
+
+路径 `/api/special-price`,方法均为 `@PostMapping`:
+
+| 方法 | 请求体 | 返回 | 说明 |
+|---|---|---|---|
+| `info` | `{dealId}` | `Result<Map>` | 公开(不加登录校验),返回特价信息 + `runStatus` + `allowCoupon` 等 |
+| 其余方法均走 JWT,后续如果需其他接口在此增加 |
+
+**`info` 返回字段**:`id, productId, name, specialPrice, startTime, endTime, runStatus, totalQuantity, soldQuantity, remainingQuantity, perUserLimit, allowCoupon, userPurchased, userRemaining`。`runStatus` 取值严格为 `DRAFT/ACTIVE/NOT_STARTED/SOLD_OUT/ENDED/DISABLED/INVALID`。
+
+> ⚠️ **白名单路径不注入用户属性**:`/api/special-price/info` 一旦进白名单,`JwtInterceptor.preHandle` 在写入任何 attribute 之前就 `return true`(连匿名 `userId=-1` 都不写)。因此控制器**禁止用 `@RequestAttribute("userId")`**(会抛 `ServletRequestBindingException`)。实现方式:`HttpServletRequest request.getAttribute("userId")` 恒为 null → `userPurchased=userRemaining` 一律返回 0。每人限购不做前端显示,完全由下单接口 `createSpecialDealOrder` 兜底校验(返回"每人限购 N 件"错误)。前端展示静态「每人限购 N 件」即可。
+
+- [ ] **步骤 2:`AdminSpecialPriceController` 接口定义**
+
+路径 `/api/admin/special-price`,方法均为 `@PostMapping`:
+
+| 方法 | 说明 |
+|---|---|
+| `list` | 分页 `{page, size, keyword, runStatus}`;`runStatus` 取 `ACTIVE / NOT_STARTED / SOLD_OUT / ENDED`(SQL 里用 `NOW()` 推导)或 `draft / disabled`(按存储状态),空=全部;返回 `Result<{records, total}>`(records 含 productName/productPrice) |
+| `create` | 校验必填与 `specialPrice < product.price`(需查 product)、`startTime < endTime`、`totalQuantity > 0`、`perUserLimit >= 1`;同商品互斥 active(`SELECT COUNT(*) FROM special_price_deals WHERE product_id=? AND status='active' AND start_time <= now() <= end_time`) |
+| `update` | 仅 `draft/disabled` 状态允许修改;active 中不允许 |
+| `status` | `{id, action: enable/disable}`;enable 时再次检查互斥 |
+| `delete` | 仅无特价订单(`SELECT COUNT(*) FROM product_orders WHERE special_deal_id=?`) 可删除;否则拒绝并返回已有订单数 |
+| `qrcode` | `{id}` → 查 `deal.productId` + `deal.id` → 调 `wechatService.generateWxacode("id=" + productId + "&sp=" + dealId, "pages/discover-detail/product-detail/product-detail")` → 返回 `{qrCodeBase64: "data:image/png;base64,..."}`。测试模式(返回空串)时降级返回 `{qrCodeBase64: "", note:"测试模式无法生成真实二维码,请在生产环境操作"}` |
+
+- [ ] **步骤 3:实现并编写测试(Controller 单测不接 DB 但用 MockMvc + `@MockBean`)**
+
+参考 `CfCommissionServiceTest` 或 `ActivityControllerTest` 的 MockitoMvcTest 模式。新建:
+- `cfc-backend/src/test/java/com/etotem/cfc/integration/controller/SpecialPriceControllerTest.java`
+- `cfc-backend/src/test/java/com/etotem/cfc/integration/controller/AdminSpecialPriceControllerTest.java`
+
+两个测试类各验证关键路径(成功路径 + 典型失败):
+
+`SpecialPriceControllerTest` 用例:
+1. `info` 正常返回 ACTIVE 状态 + userPurchased/userRemaining
+2. `info` dealId 不存在返回 `INVALID`(runStatus=INVALID, 错误码 200 但 data 提示活动不存在)
+3. `info` 匿名/登录均可访问
+
+`AdminSpecialPriceControllerTest` 用例:
+1. `create` 成功 + `specialPrice < product.price` 校验
+2. `create` 失败:特价 ≥ 原价 → `Result.error`
+3. `create` 失败:同商品已有 active 活动 → `Result.error`
+4. `status` enable 成功
+5. `delete` 有订单时被拒绝(返回错误 + 已有订单数)
+6. `qrcode` 成功
+
+测试写法参考 `cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ActivityControllerTest.java`(已有 MockMvc 示例)+ `CfCommissionServiceSettleTest`。若发现 MockMvc 配置缺失,按项目中 `ProductControllerTest` 的模式复制。
+
+- [ ] **步骤 4:在三处白名单加入 `/api/special-price/info`**
+
+WebConfig 实际注册了 4 个拦截器,其中两个带 `excludePathPatterns`(`operationLogInterceptor`/`rateLimitInterceptor` 拦 `/api/**` 但正常放行匿名请求,无需改)。三处修改:
+
+1. `JwtInterceptor.java` 的 `PUBLIC_PATHS` 常量数组(L32-75,字段名是 `PUBLIC_PATHS`,不是 `ALLOWED_PATHS`)末尾追加 `"/api/special-price/info"`
+2. `WebConfig.addInterceptors()` 中 `jwtInterceptor` 的 `excludePathPatterns`(L97-136)追加 `"/api/special-price/info"`(与 `/api/product/detail` 相邻)
+3. `WebConfig.addInterceptors()` 中 `familyAccessInterceptor` 的 `excludePathPatterns`(L146-177)追加 `"/api/special-price/info"`(与 `/api/product/list` 相邻)
+
+> 为什么三处:`jwtInterceptor` 白名单路径在最前面直接 `return true`(下文不写任何 attribute);`familyAccessInterceptor` 是家庭访问控制,匿名路径必须同时排除,否则会被家庭归属校验拦截。
+
+> 注意:`ProductOrderController.java:41` 已 `@Resource private QrCodeService qrCodeService`(与 WechatService 同层),AdminSpecialPriceController 的 `qrcode` 方法复用 `wechatService.generateWxacode`,不要重复引入 `QrCodeService`。
+
+- [ ] **步骤 5:运行测试 + 编译**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn test -pl . -Dtest="SpecialPriceControllerTest,AdminSpecialPriceControllerTest" && mvn clean compile
+```
+
+预期:Tests run 通过;BUILD SUCCESS。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/special/SpecialPriceController.java
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSpecialPriceController.java
+git add cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
+git add cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
+git add cfc-backend/src/test/java/com/etotem/cfc/integration/controller/SpecialPriceControllerTest.java
+git add cfc-backend/src/test/java/com/etotem/cfc/integration/controller/AdminSpecialPriceControllerTest.java
+git commit -m "feat(controller): 特价活动 Controller + 白名单"
+```
+
+---
+
+### 任务 4:ProductOrderService 特价分支 + 三处回滚
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/task/OrderTimeoutCancelTask.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java`
+
+- [ ] **步骤 1:`CreateProductOrderDTO` 加 `specialDealId`**
+
+字段:`private Long specialDealId;` + 显式 getter/setter(Lombok 已被绕过,沿用显式风格)。
+
+- [ ] **步骤 2:`ProductOrderService.create` 分流**
+
+在 `ProductOrderService.create(dto, buyerId)` 的最开头(L208)加:
+
+```java
+if (dto.getSpecialDealId() != null) {
+    return createSpecialDealOrder(dto, buyerId);
+}
+```
+
+然后实现 `createSpecialDealOrder`(独立 `@Transactional(rollbackFor = Exception.class)` 方法,签名 `private Result<ProductOrderDTO> createSpecialDealOrder(CreateProductOrderDTO dto, Long buyerId)`)。
+
+**内部流程严格对照规格 §5.2**:
+1. `items != null && !items.isEmpty()` → 返回错误 `"特价商品请单独购买"`
+2. 查 `specialPriceService.getById(dto.getSpecialDealId())`(Service→Service 调用:ProductOrderService 不直接持有 SpecialPriceDealMapper,分层约束 Controller→Service→Mapper):不存在 → `"特价活动不存在"`
+3. 活动 status 不是 `active` → `"特价活动已结束或不存在"`(不区分 draft/disabled/ENDED,简洁返回)
+4. `now` 不在 `[start_time, end_time]` → `"特价活动未开始/已结束"`
+5. 商品 ID 不匹配 → `"该特价活动不适用于此商品"`
+6. 原子扣量:`specialPriceService.consumeQuota(dealId, qty)`;失败 → `"活动已售罄"`
+7. 每人限购:`specialPriceService.getUserPurchased(dealId, buyerId) + qty > deal.getPerUserLimit()` → `"每人限购{perUserLimit}件,您已购买{已购}件"`
+8. 券开关:`deal.getAllowCoupon() == 0 && dto.getCouponId() != null` → `"该特价商品不可使用优惠券"`
+9. 复用原 `create()` 的单商品分支构建逻辑(`productMapper.selectById(productId)` + 在架校验 + 库存校验 + validateFulfill + address + 赠品 + 购买信息采集),唯一差异是定价:
+   - `unitPrice = deal.getSpecialPrice()`(覆盖会员价)
+   - `totalAmount = unitPrice * quantity`
+   - `moneyAmount = totalAmount - pointsCost - discountAmount`(与普通订单计算方式一致)
+   - `order.setSpecialDealId(dealId)`
+10. 插入订单 + `couponService.reserve`(券开关允许时)
+11. 返回 `ProductOrderDTO`
+
+> 注意:`inventoryService.checkBundleStock` / `productService.decreaseStock` 仍然按支付成功时调用,本方法只扣 `special_price_deals.sold_quantity`,不碰商品库存(规格 §5.2 明确"特价供应量独立于商品库存")。
+
+- [ ] **步骤 3:`reviewUpdate` 防护**
+
+在 `ProductOrderService.reviewUpdate`(约 L1617)中,收到 `order.specialDealId != null` 时最开头返回 `"特价订单不允许修改价格"`(即便只改非价格字段也拒绝,保持简单)。
+
+- [ ] **步骤 4:三处回滚**
+
+在以下三处分别插入释放特价扣量(与恢复商品库存并列,失败忽略):
+
+1. `ProductOrderService.cancel()` 方法末尾(L1020 附近,置 `cancelled` 后):
+   ```java
+   try { specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity()); } catch (Exception ex) {
+       log.warn("特价扣量回滚失败: dealId={}, orderNo={}, err={}", order.getSpecialDealId(), order.getOrderNo(), ex.getMessage());
+   }
+   ```
+2. `ProductOrderService.approveRefund()` 方法末尾(L1295 附近,置 `refunded` 后):
+   ```java
+   try { specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity()); } catch (Exception ex) {
+       log.warn("特价扣量回滚失败(refund): dealId={}, orderNo={}, err={}", order.getSpecialDealId(), order.getOrderNo(), ex.getMessage());
+   }
+   ```
+3. `OrderTimeoutCancelTask`:抽取 package-private 方法(主循环开头有 `DatabaseInitializer.isMigrationsComplete()` 静态开关,单测无法覆盖整个 `cancelTimeoutOrders()`,抽方法让释放逻辑可独立测试):
+
+   ```java
+   /**
+    * 超时取消单个订单时释放特价扣量(若有)。
+    * 抽取为可单测的 package-private 方法:cancelTimeoutOrders() 主循环受
+    * DatabaseInitializer.isMigrationsComplete() 静态开关保护,单测无法进入循环体。
+    */
+   void releaseSpecialDealQuotaIfNeeded(ProductOrder order) {
+       if (order == null || order.getSpecialDealId() == null) return;
+       try {
+           specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
+           log.info("订单 {} 超时取消:释放特价扣量 dealId={}", order.getOrderNo(), order.getSpecialDealId());
+       } catch (Exception ex) {
+           log.error("订单 {} 超时取消:释放特价扣量失败: dealId={}, err={}", order.getOrderNo(), order.getSpecialDealId(), ex.getMessage());
+       }
+   }
+   ```
+
+   在 `cancelTimeoutOrders()` 循环内「恢复库存」try-catch 块之后(当前 L96 之后、`log.info("订单 {} 超时未支付,已自动取消")` L98 之前)追加一行调用:
+   ```java
+   releaseSpecialDealQuotaIfNeeded(order);
+   ```
+   同时 `OrderTimeoutCancelTask` 顶部声明依赖:`@Resource private SpecialPriceService specialPriceService;`(import `com.etotem.cfc.service.SpecialPriceService`)。
+
+`SpecialPriceService` 需在 `ProductOrderService` 内通过 `@Resource` 注入(字段名 `specialPriceService`,Bean 名首字母小写)。
+
+- [ ] **步骤 5:编写两个测试类(重点:测试原子扣量 + 三处回滚)**
+
+**(a) `cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderServiceSpecialDealTest.java`**
+
+参考 `ProductOrderServiceUpdateExpressFieldTest` 的 Mockito 模式,mock `SpecialPriceDealMapper`、`ProductMapper`、`InventoryService`、`CouponService` 等依赖切面(用 `ReflectionTestUtils.setField` 注入 `specialPriceService`、`productMapper`、`inventoryService`、`couponService`;其中 `specialPriceService` 也用 mock `SpecialPriceService`,并单独 mock `SpecialPriceDealMapper` 供它的 `consumeQuota/userPurchased/selectById` 桩),重点验证:
+1. `createSpecialDealOrder` 调用 `specialPriceService.consumeQuota` 一次、`orderMapper.insert` 一次
+2. 扣量失败时订单未插入而且方法返回错误(`consumeQuota` 内部 `affected==0` 抛 `IllegalStateException`;`createSpecialDealOrder` 捕获后返回 `Result.error("活动已售罄")`,不向上抛)——验证 `when(mapper.consumeQuota(id, qty)).thenReturn(0)` 时返回错误且 `orderMapper.insert` 未被调用
+3. `cancel()` 置 `cancelled` 后调用 `specialPriceService.releaseQuota(dealId, qty)`
+4. `approveRefund()` 置 `refunded` 后调用 `specialPriceService.releaseQuota(dealId, qty)`
+5. `reviewUpdate` 收到 `specialDealId != null` 的订单在最开头返回错误(不落库)
+6. 每人限购:`userPurchased(id, buyerId)+qty > perUserLimit` 时返回 `Result.error("每人限购...")`
+
+**(b) `cfc-backend/src/test/java/com/etotem/cfc/task/OrderTimeoutCancelTaskSpecialDealTest.java`**
+
+验证抽取的 `releaseSpecialDealQuotaIfNeeded`(无需碰 `cancelTimeoutOrders()` 主循环与静态开关):
+1. `specialDealId != null` → 调用 `specialPriceService.releaseQuota(dealId, quantity)` 一次
+2. `specialDealId == null` → 零交互(`verifyZeroInteractions`)
+3. `order == null` → 不抛异常
+写法:`new OrderTimeoutCancelTask()` + `ReflectionTestUtils.setField(task, "specialPriceService", mockSpecialPriceService)`;`ProductOrder` 用 `setSpecialDealId/setQuantity/setOrderNo` 构造。
+
+- [ ] **步骤 6:运行测试 + 编译**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn test -pl . -Dtest="SpecialPriceServiceTest,ProductOrderServiceSpecialDealTest,OrderTimeoutCancelTaskSpecialDealTest,SpecialPriceControllerTest,AdminSpecialPriceControllerTest" && mvn clean compile
+```
+
+预期:全部 Tests run 通过;BUILD SUCCESS。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/SpecialPriceService.java
+git add cfc-backend/src/main/java/com/etotem/cfc/task/OrderTimeoutCancelTask.java
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
+git add cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderServiceSpecialDealTest.java
+git add cfc-backend/src/test/java/com/etotem/cfc/task/OrderTimeoutCancelTaskSpecialDealTest.java
+git commit -m "feat(order): 特价下单分支 + 三处回滚 + reviewUpdate 防护"
+```
+
+---
+
+### 任务 5:小程序前端
+
+**文件:**
+- 修改:`cfc-frontend/pages/discover-detail/product-detail/product-detail.vue`
+- 修改:`cfc-frontend/pages/shop/checkout/checkout.vue`
+- 修改:`cfc-frontend/utils/api.js`
+
+- [ ] **步骤 1:`utils/api.js` 新增唯一入口**
+
+在 `// ========== 商品 API ==========` 区块末尾新增:
+
+```js
+export const getSpecialPriceInfo = (dealId) => {
+  return request('/api/special-price/info', 'POST', { dealId })
+}
+```
+
+- [ ] **步骤 2:`product-detail.vue` 扩展**
+
+在 `onLoad(options)`(约 L356)的 scene 解析块,**紧跟现有 `id=(\d+)` 正则之后**追加:
+
+```js
+var spMatch = sceneDecoded.match(/sp=(\d+)/)
+if (spMatch && spMatch[1]) {
+  this.specialDealId = parseInt(spMatch[1], 10)
+}
+```
+
+并在 `onLoad` 末尾(`if (pid) { this.loadDetail(pid) }` 块之后)追加:
+
+```js
+if (this.specialDealId) {
+  this.loadSpecialDealInfo()
+}
+```
+
+新增方法 `loadSpecialDealInfo`:
+
+```js
+loadSpecialDealInfo: function() {
+  var that = this
+  if (!this.specialDealId) return
+  getSpecialPriceInfo(this.specialDealId).then(function(res) {
+    if (res && res.code === 200 && res.data) {
+      that.specialDeal = res.data
+      that.specialDealStatus = that.computeSpecialDealStatus(res.data.runStatus)
+    } else {
+      that.specialDeal = null
+      that.specialDealStatus = 'invalid'
+    }
+  }).catch(function() {
+    that.specialDeal = null
+    that.specialDealStatus = 'invalid'
+  })
+},
+computeSpecialDealStatus: function(runStatus) {
+  if (!runStatus) return 'invalid'
+  if (runStatus === 'ACTIVE') return 'active'
+  if (runStatus === 'NOT_STARTED') return 'not_started'
+  if (runStatus === 'SOLD_OUT') return 'sold_out'
+  if (runStatus === 'ENDED' || runStatus === 'DISABLED' || runStatus === 'DRAFT' || runStatus === 'INVALID') return 'invalid'
+  return 'invalid'
+}
+```
+
+**模板修改**(在商品价格区下方、购买按钮上方)插入特价展示区块,按 `specialDealStatus` 条件渲染:
+- `active`:显示划线原价 + 特价 + 「限每人 N 件 · 剩余 M 件」进度条(`remainingQuantity / totalQuantity`)+ 立即购买按钮走特价路径
+- `not_started`:显示特价 + 「距开抢还有 X 小时」+ 禁用购买按钮(倒计时到 `start_time` 时刷新状态)
+- `sold_out / invalid`:显示「活动已结束 / 已抢完」,按原价展示与购买
+- 其他:不显示特价区块
+
+**购买逻辑**(`buyNow` / `goCheckout` 方法):
+
+当 `specialDealId` 有值且 `specialDealStatus === 'active'` 时,走原有 checkout 跳转,但额外 `uni.setStorageSync('checkoutSpecialDealId', this.specialDealId)`;否则走原有逻辑。`uni.setStorageSync` 前清除旧的:
+
+```js
+uni.removeStorageSync('checkoutSpecialDealId')
+```
+
+**倒计时**:仅在 `not_started` 状态时启动 `setInterval` 每秒调用 `loadSpecialDealInfo()` 刷新(避免多余 API);进入 `active/sold_out/invalid` 时 clearInterval。
+
+- [ ] **步骤 3:`checkout.vue` 修改**
+
+在 `onLoad(options)` 末尾(约 L670)加载现有 `checkoutItems` 之后追加读取:
+
+```js
+this.checkoutSpecialDealId = uni.getStorageSync('checkoutSpecialDealId') || null
+```
+
+下单前(约 L1061 的 `couponId` 透传处)追加:
+
+```js
+// 特价单透传(无则传 null)
+payload.specialDealId = that.checkoutSpecialDealId || null
+```
+
+订单提交成功后(约 L1076-1078 的 `removeStorageSync` 附近)追加清理:
+
+```js
+uni.removeStorageSync('checkoutSpecialDealId')
+```
+
+`allow_coupon=0` 时隐藏优惠券选择区:在 `v-if="showCoupon"` 上增加条件(若 `checkoutSpecialDealId` 有值且 `specialDeal.allowCoupon === 0`,`showCoupon=false`)。由于优惠券面板已经在 checkout 存在,按现有展示逻辑加判断即可。
+
+> 遵守 `AGENTS.md` 的 API 去重规则:`getSpecialPriceInfo` 是唯一 `request` 封装入口,其它地方如需特价信息只能读 storage 或通过接口获取。
+
+- [ ] **步骤 4:语法检查**
+
+```bash
+node --check C:\code\cfc\cfc-frontend\pages\discover-detail\product-detail\product-detail.vue
+node --check C:\code\cfc\cfc-frontend\pages\shop\checkout\checkout.vue
+node --check C:\code\cfc\cfc-frontend\utils\api.js
+```
+
+预期:零报错。
+
+- [ ] **步骤 5:人工验证(HBuilderX 重新运行到模拟器)**
+
+提示:Agent 不做 HBuilderX 打包。交付时告知开发者在 HBuilderX 中「运行 → 运行到小程序模拟器 → 微信开发者工具」后验证:
+1. 打开一个已知有效的特价二维码 URL(scene 含 `sp=xxx`)→ 商品页展示特价
+2. 普通商品 URL 无特价区块
+3. 进入结算页:有 `specialDealId` 时不显示优惠券选择
+4. 提交订单 → 订单详情页能看到特价单标识(`unitPrice` 显示特价)
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git add cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
+git add cfc-frontend/pages/shop/checkout/checkout.vue
+git commit -m "feat(frontend): 小程序商品页特价展示 + checkout 特价透传"
+```
+
+---
+
+### 任务 6:Web 管理端(新建页面 + 路由 + API 封装)
+
+**文件:**
+- 新建:`cfc-web/src/views/admin/SpecialPriceManage.vue`
+- 修改:`cfc-web/src/router/index.js`(加路由)
+- 修改:`cfc-web/src/api/admin.js`(加 6 个封装函数)
+
+- [ ] **步骤 1:`src/api/admin.js` 新增**
+
+在商品相关区块末尾(`getProductStock` 之后)追加:
+
+```js
+export function getSpecialPriceList(params) {
+  return request({ url: '/api/admin/special-price/list', method: 'post', data: params || {} })
+}
+export function createSpecialPrice(data) {
+  return request({ url: '/api/admin/special-price/create', method: 'post', data })
+}
+export function updateSpecialPrice(data) {
+  return request({ url: '/api/admin/special-price/update', method: 'post', data })
+}
+export function setSpecialPriceStatus(data) {
+  return request({ url: '/api/admin/special-price/status', method: 'post', data })
+}
+export function deleteSpecialPrice(id) {
+  return request({ url: '/api/admin/special-price/delete', method: 'post', data: { id } })
+}
+export function getSpecialPriceQrcode(id) {
+  return request({ url: '/api/admin/special-price/qrcode', method: 'post', data: { id } })
+}
+```
+
+- [ ] **步骤 2:路由**
+
+在 `router/index.js` 的商品相关路由区块(第 273-302 行)中,`product-manage` 路由之后插入:
+
+```js
+{
+  path: 'special-price',
+  name: 'SpecialPriceManage',
+  component: () => import('@/views/admin/SpecialPriceManage.vue'),
+  meta: { title: '限时特价', perm: 'commerce:products' }
+}
+```
+
+- [ ] **步骤 3:`SpecialPriceManage.vue`**
+
+严格按照 `ProductManage.vue` 的 CSS 变量与类名命名风格(`pm-*`),新建页面。结构:
+
+**顶部**:`<el-card>` 内含 `pm-header` 区域:左侧标题「限时特价活动」+ 右侧「新建活动」按钮(type=primary)
+
+**筛选区**:`pm-filter`:关键词输入(活动名模糊)、状态下拉(全部/未开始/进行中/已售罄/已结束/已停用)
+
+**列表**:`pm-table-wrap` 包裹的 `<el-table>`,列:
+- 复选框(多选)
+- ID
+- 活动名
+- 商品(商品名 + ID,可点击跳转到商品编辑)
+- 原价 / 特价(价差高亮红色)
+- 时间窗口(`el-tag` 时间范围)
+- 已售 / 剩余(红色进度条:`remaining/total`)
+- 每人限购
+- 券开关(`<el-tag :type="allowCoupon ? 'success' : 'danger'"`)
+- 运行状态(复用 `pm-status--{status}` 样式)
+- 操作:编辑 / 启用或停用(按当前 status)/ 二维码 / 删除
+
+**新建/编辑弹窗**:`el-dialog`,表单项:
+- 活动名称(必填)
+- 商品(`el-select filterable remote`,用 `getProductList({keyword, page:1, size:20})` 远程搜索)
+- 特价(`el-input-number`,单位元,`*100` 后传分)
+- 起止时间(`el-date-picker type="datetimerange"`,value-format=`yyyy-MM-dd HH:mm:ss`)
+- 总供应量(`el-input-number`,>=1)
+- 每人限购(`el-input-number`,>=1)
+- 允许优惠券(`el-switch`)
+
+保存时校验:特价必须 `< 原价`,时间窗口合法(`startTime < endTime`),同商品互斥 active。
+
+**二维码弹窗**:点「二维码」打开 `<el-dialog>`,调 `getSpecialPriceQrcode(id)`;返回 `data.qrCodeBase64` 以 `<img :src="qrCodeBase64" />` 展示;`note` 非空(测试模式)时提示文字;提供 `<a download="qrcode.png">` 下载。
+
+**状态转换**:
+- 新建默认 `draft`,弹窗「启用」按钮调 `setSpecialPriceStatus({id, action:'enable'})`
+- 进行中可「停用」调 `setSpecialPriceStatus({id, action:'disable'})`
+- 已停用/草稿可「启用」
+- 活动不存在 active 中的同商品时,启用校验失败返回错误
+
+**编辑限制**:`status` 为 `active`(`runStatus='ACTIVE'`)时只允许「停用」,其他操作置灰。
+
+- [ ] **步骤 4:编译 / lint 检查**
+
+```bash
+cd C:\code\cfc\cfc-web && npm run lint
+```
+
+预期:零 ERROR。若有 WARNING 自行修复(不阻塞)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-web/src/views/admin/SpecialPriceManage.vue
+git add cfc-web/src/router/index.js
+git add cfc-web/src/api/admin.js
+git commit -m "feat(web): 限时特价管理端页面 + 路由 + API 封装"
+```
+
+---
+
+### 任务 7:编译 + 全量测试 + 文档更新 + 验收
+
+**文件:**
+- 修改:`docs/superpowers/api/API_REFERENCE.md`(补新接口条目)
+
+- [ ] **步骤 1:后端全量编译**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn clean compile
+```
+
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 2:后端全量测试**
+
+```bash
+cd C:\code\cfc\cfc-backend && mvn test
+```
+
+预期:全部 Tests run 通过。如有新增失败(非特价相关),在实现任务 4 时可能触发了回归,需要隔离修复(只改特价相关部分)。
+
+- [ ] **步骤 3:更新 `API_REFERENCE.md`**
+
+在 `docs/superpowers/api/API_REFERENCE.md` 增加 **4.53 / 4.54** 两条(按现有编号顺延)。格式与现有条目对齐(path / method / 角色 / 请求 / 响应 / 说明):
+
+```markdown
+#### 4.53 特价活动信息(公开,无需登录)
+- **方法**: POST
+- **路径**: `/api/special-price/info`
+- **请求体**: `{ "dealId": 1 }`
+- **响应**: `Result<{id, productId, name, specialPrice, startTime, endTime, runStatus, totalQuantity, soldQuantity, remainingQuantity, perUserLimit, allowCoupon, userPurchased, userRemaining}>`
+- **说明**: `runStatus` 枚举值 `DRAFT / ACTIVE / NOT_STARTED / SOLD_OUT / ENDED / DISABLED / INVALID`;`remainingQuantity = totalQuantity - soldQuantity`;白名单路径不写 request attribute,`userPurchased`/`userRemaining` 一律返回 0(每人限购由下单接口兜底)
+
+#### 4.54 管理端:特价活动列表
+- **方法**: POST
+- **路径**: `/api/admin/special-price/list`
+- **请求体**: `{ "page":1, "size":20, "keyword":"", "runStatus":"" }`
+- **响应**: `Result<{records: [{id, productId, productName, name, specialPrice, startTime, endTime, runStatus, totalQuantity, soldQuantity, perUserLimit, allowCoupon}], total}>`
+- **说明**: `runStatus` 同上;`runStatus` 过滤支持 `active/not_started/sold_out/ended/disabled/draft`(对应 stored status 与 SQL 推导的四种)
+
+(其余 4 个管理端接口按同样风格续写:create/update/status/delete/qrcode)
+```
+
+- [ ] **步骤 4:前端语法检查 + API 去重审计**
+
+```bash
+cd C:\code\cfc\cfc-web && npm run lint
+cd C:\code\cfc\cfc-frontend && node scripts/audit-duplicate-api-calls.js
+```
+
+预期:lint 零 ERROR;去重审计 exit 0。
+
+- [ ] **步骤 5:最终 Commit**
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md
+git commit -m "docs(api): 补充限时特价接口到 API_REFERENCE"
+```
+
+---
+
+## 验收标准(对应规格 §7)
+
+- [ ] `mvn clean compile` 通过。
+- [ ] `mvn test` 全部通过,新加 5 个测试类(`SpecialPriceServiceTest`、`ProductOrderServiceSpecialDealTest`、`OrderTimeoutCancelTaskSpecialDealTest`、`SpecialPriceControllerTest`、`AdminSpecialPriceControllerTest`)不回归。
+- [ ] 管理端可创建/编辑/启停/删除活动;同商品仅一个 active;特价必须 < 原价。
+- [ ] 管理端 `qrcode` 生成 `scene=id={productId}&sp={dealId}`(≤32 字符)的小程序码,路径正确。
+- [ ] 小程序扫码(scene 含 `sp=xxx`)进入产品详情页:未开始显示倒计时且按钮禁用;进行中显示特价、进度、可下单;已售罄/已结束回落原价;其他 URL 无特价区块。
+- [ ] 特价下单校验全通过:时间窗口、原子扣量(并发不超卖)、每人限购累计、券开关。
+- [ ] 取消 / 超时自动取消 / 退款完成后 `sold_quantity` 回滚(`GREATEST(x-? ,0)`),额度释放可再购。
+- [ ] 特价订单 `unitPrice = specialPrice`(覆盖会员价),保留积分抵扣与赠品;`allow_coupon=0` 时禁用优惠券。
+- [ ] 特价订单供应商改价被拒绝。
+- [ ] `docs/superpowers/api/API_REFERENCE.md` 补充新接口条目。
+- [ ] `docs/superpowers/PROJECT-OVERVIEW.md` 已登记(任务 1 完成后已登记)。
+
+---
+
+## 自检备注(计划写给执行者的额外提示)
+
+- **并发超卖防泄漏**:`consumeQuota` SQL 的 `AND sold_quantity + #{qty} <= total_quantity` 是关键。测试里必须 mock 返回 0 的情形并验证抛出。
+- **回滚幂等**:`releaseQuota` 用 `GREATEST` 不会负向,重复调用安全;但应只在 `cancelled`/`refunded` 终态调用,pending 中间态不调用。
+- **特价 ≠ 会员价**:特价单 `unitPrice = specialPrice` 固定,不走 `getEligibleMemberPrice`;但 `discountAmount`(优惠券)与 `pointsCost`(积分)照常计算。
+- **小程序码 scene 长度**:`id={productId}&sp={dealId}` 最长 32 字符(微信规定)。productId 与 dealId 都按 long 序列化,可能超出;极端情况下截断。实现时用 debug 日志打印 scene 串,确认 ≤32 字符;超限时后端返回明确错误而非静默截断(由前端展示错误)。
+- **任务 4 事务**:`createSpecialDealOrder` 是 `@Transactional`,`consumeQuota` 与 `orderMapper.insert` 在同一事务,任一失败回滚,不出现「订单插入成功但扣量失败」或「扣量成功但订单没建」的中断状态。
+- **schema.sql / DatabaseInitializer 同步**:任务 1 完成后,schema.sql 与迁移代码必须完全一致(迁移 389/390 两条都要在 `runMigrations()` 末尾出现)。