|
|
@@ -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 <= NOW()
|
|
|
+ AND NOW() <= end_time
|
|
|
+ AND sold_quantity + #{qty} <= 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 <= NOW() AND NOW() <= d.end_time
|
|
|
+ AND d.sold_quantity < d.total_quantity
|
|
|
+ </when>
|
|
|
+ <when test="runStatus == 'NOT_STARTED'">
|
|
|
+ AND d.status = 'active' AND d.start_time > NOW()
|
|
|
+ </when>
|
|
|
+ <when test="runStatus == 'SOLD_OUT'">
|
|
|
+ AND d.status = 'active' AND d.start_time <= NOW() AND NOW() <= d.end_time
|
|
|
+ AND d.sold_quantity >= d.total_quantity
|
|
|
+ </when>
|
|
|
+ <when test="runStatus == 'ENDED'">
|
|
|
+ AND d.status = 'active' AND (d.end_time IS NULL OR d.end_time < 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()` 末尾出现)。
|