|
@@ -0,0 +1,1740 @@
|
|
|
|
|
+# 多店铺阶梯满减活动系统设计方案
|
|
|
|
|
+
|
|
|
|
|
+> **版本**: v1.2.1
|
|
|
|
|
+> **日期**: 2026-06-23
|
|
|
|
|
+> **作者**: 系统架构组
|
|
|
|
|
+> **适用范围**: ylrz_his_scrm 电商系统(基于若依框架 + Spring Boot + Vue2/Element UI + uni-app APP/小程序)
|
|
|
|
|
+> **修订说明**: v1.2.1 管理端后台 CRUD 已实现;v1.2 新增 APP/小程序方案;v1.1 对齐 hisStore 商城模块
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 实现进度追踪(v1.2.1)
|
|
|
|
|
+
|
|
|
|
|
+| 模块 | 状态 | 路径 / 说明 |
|
|
|
|
|
+|------|:----:|-------------|
|
|
|
|
|
+| SQL 建表 + 订单字段 + 菜单 | ✅ 脚本已提供 | `docs/sql/fs_store_promotion_init.sql`(需 DBA 执行;菜单 `parent_id` 需调整) |
|
|
|
|
|
+| 活动 Domain / DTO / VO | ✅ 已完成 | `fs-service/.../hisStore/domain|dto|vo/` |
|
|
|
|
|
+| 活动 Mapper + XML | ✅ 已完成 | `fs-service/.../mapper/hisStore/FsStorePromotion*.xml` |
|
|
|
|
|
+| 管理端 Service | ✅ 已完成 | `IFsStorePromotionService` / `FsStorePromotionServiceImpl` |
|
|
|
|
|
+| 管理端 Controller | ✅ 已完成 | `fs-admin/.../FsStorePromotionController.java` |
|
|
|
|
|
+| 管理端 API | ✅ 已完成 | `src/api/hisStore/storePromotion.js` |
|
|
|
|
|
+| 管理端列表 + 新增/编辑/详情 | ✅ 已完成 | `src/views/hisStore/storePromotion/index.vue`(Dialog 模式) |
|
|
|
|
|
+| 用户端 settlement 查询接口 | ⬜ 未做 | `POST /store/app/storeOrder/promotion/list` 等(见 5.2.1) |
|
|
|
|
|
+| 订单 computed/create 满减 | ⬜ 未做 | `FsStoreOrderScrmServiceImpl` |
|
|
|
|
|
+| APP / 小程序结算页 | ⬜ 未做 | 见 1.4 节 |
|
|
|
|
|
+| usage 支付联动 / 定时 Job / Redis 缓存 | ⬜ 未做 | 见第 6 章 |
|
|
|
|
|
+
|
|
|
|
|
+**图例**:✅ 已完成 ⬜ 未做
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 0. 评审结论与实施计划
|
|
|
|
|
+
|
|
|
|
|
+### 0.1 文档评审结论(v1.0 → v1.1 优化点)
|
|
|
|
|
+
|
|
|
|
|
+| 类别 | 原方案问题 | 优化方向 |
|
|
|
|
|
+|------|-----------|---------|
|
|
|
|
|
+| 表名/关联 | 使用了 `fs_his_store`、`fs_store_order` 等不存在或不准确的表名 | 统一为 `fs_store_scrm`、`fs_store_order_scrm`、`fs_store_product_scrm`、`fs_store_product_category_scrm` |
|
|
|
|
|
+| 用户端接口 | 新增 `/api/store/promotion/compute`、`/api/store/order/submit` | **扩展现有** `FsStoreOrderScrmServiceImpl.computedOrder/createOrder`,路径沿用 `/store/app/storeOrder/computed`、`/create` |
|
|
|
|
|
+| 多店铺 | 未覆盖多店铺结算 | 同步扩展 `computedMultiStore` / `createMultiStore`,按 `storeId` 维度匹配活动 |
|
|
|
|
|
+| 优惠计算基数 | 未说明按整单还是按适用商品 | 阶梯门槛按**活动适用商品小计**计算,非适用商品不计入门槛 |
|
|
|
|
|
+| 参与次数 | 下单即写入 usage,未区分支付状态 | usage 增加 `usage_status`,仅**支付成功**计次;未支付取消/超时回滚 |
|
|
|
|
|
+| 状态字段 | `status` + `manual_status` 混用,前端草稿判断有逻辑错误 | 明确「展示态」由后端统一计算;草稿以 `manual_status IS NULL` 为准 |
|
|
|
|
|
+| 时间边界 | 文档前后 `>=` / `<` 描述不一致 | 统一:**开始含、结束含**,SQL 使用 `start_time <= NOW() AND end_time >= NOW()` |
|
|
|
|
|
+| 活动冲突 | 仅按店铺+时间冲突 | 增加**适用范围重叠**判断(同店铺、时间重叠、scope 有交集才冲突) |
|
|
|
|
|
+| 缓存工具 | 示例使用 `RedisTemplate` | 与现有代码一致,使用项目内 `redisCache`(`RedisCache`)+ 可选 Redisson 锁 |
|
|
|
|
|
+| 店铺下拉 | 新增 `/store/storePromotion/storeList` | 复用现有 `/store/his/store/listOption`(前端 `@/api/hisStore/store.js`) |
|
|
|
|
|
+
|
|
|
|
|
+### 0.2 分步实施计划(仅方案,不含代码)
|
|
|
|
|
+
|
|
|
|
|
+| 步骤 | 内容 | 模块 | 产出 |
|
|
|
|
|
+|:--:|------|------|------|
|
|
|
|
|
+| 1 | 执行 DDL:4 张活动表 + `fs_store_order_scrm` 扩展字段 | DBA / SQL 脚本 | ✅ 脚本见 `docs/sql/fs_store_promotion_init.sql` |
|
|
|
|
|
+| 2 | 创建 Domain / Mapper / XML / Service | `fs-service` | ✅ 已完成 |
|
|
|
|
|
+| 3 | 管理端 Controller + 权限标识 | `fs-admin` | ✅ `FsStorePromotionController` |
|
|
|
|
|
+| 4 | 管理端前端(列表 + Dialog 新增编辑 + 详情) | `ylrz_his_scrm_adminUI` | ✅ `views/hisStore/storePromotion/index.vue` |
|
|
|
|
|
+| 5 | 扩展订单计算 DTO/Param | `fs-service` | `FsStoreOrderComputedParam`、`FsStoreOrderComputeDTO`、`FsStoreOrderCreateParam` 增加满减字段 |
|
|
|
|
|
+| 6 | 接入 `computedOrder` / `createOrder` | `fs-service` | 在积分之后、优惠券之前追加满减计算与校验 |
|
|
|
|
|
+| 7 | 接入多店铺计算/下单 | `fs-service` | `computedOrderMultiStore` / `createOrderMultiStore` 按店铺独立匹配活动 |
|
|
|
|
|
+| 8 | **新增结算活动查询接口** | `fs-user-app` | `StoreOrderScrmController` 增加 `/promotion/list`、`/promotion/listMultiStore` |
|
|
|
|
|
+| 9 | **APP / 小程序结算页改造** | 用户端工程(uni-app 等) | 活动列表展示、选中/切换、与 computed/create 联动 |
|
|
|
|
|
+| 10 | 参与记录与订单取消联动 | `fs-service` | 支付成功确认 usage;取消/超时订单释放次数 |
|
|
|
|
|
+| 11 | 定时兜底任务 | `fs-quartz` 或 `@Scheduled` | 每分钟批量将过期活动 `status` 置为 3 |
|
|
|
|
|
+| 12 | 菜单 / 路由 / 字典配置 | adminUI + 系统管理 | ✅ SQL 脚本含菜单;路由 component=`hisStore/storePromotion/index` |
|
|
|
|
|
+| 13 | 联调与边界测试 | QA | 时间临界点、叠加优惠券、多店铺、限次、手动关停、结算页选活动 |
|
|
|
|
|
+
|
|
|
|
|
+### 0.3 与现有商城链路集成要点
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+购物车确认 → confirmOrder / confirmOrderMultiStore
|
|
|
|
|
+ ↓ 返回 orderKey、carts、address
|
|
|
|
|
+结算页进入 → POST /store/app/storeOrder/promotion/list(或 listMultiStore)★新增
|
|
|
|
|
+ ↓ 返回当前店铺可参与的满减活动列表 + 推荐活动 + 档位说明
|
|
|
|
|
+ ↓ 用户选择活动(或采用推荐活动;可不选=不参与满减)
|
|
|
|
|
+结算页计算 → POST /store/app/storeOrder/computed(或 computedMultiStore)
|
|
|
|
|
+ ↓ 传 promotionActivityId + 现有 couponUserId / useIntegral
|
|
|
|
|
+ ↓ 查即验证 → 计算满减 → 返回 payPrice、promotionDiscountAmount 等
|
|
|
|
|
+提交订单 → POST /store/app/storeOrder/create(或 createMultiStore)
|
|
|
|
|
+ ↓ 服务端重算 → 写 fs_store_order_scrm.promotion_* → usage 待支付
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**优惠叠加顺序(建议,与现有积分/券逻辑一致)**:
|
|
|
|
|
+
|
|
|
|
|
+1. 商品原价合计 → 2. 积分抵扣 → 3. 阶梯满减 → 4. 优惠券(若 `is_stackable=1` 且券可用)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 目录
|
|
|
|
|
+
|
|
|
|
|
+0. [评审结论与实施计划](#0-评审结论与实施计划)
|
|
|
|
|
+1. [管理后台页面设计(重点)](#1-管理后台页面设计)
|
|
|
|
|
+1.4. [用户端结算页设计(APP / 小程序)](#14-用户端结算页设计app--小程序)
|
|
|
|
|
+2. [活动时效性生效与失效方案(重点)](#2-活动时效性生效与失效方案)
|
|
|
|
|
+3. [数据库设计](#3-数据库设计)
|
|
|
|
|
+4. [核心业务逻辑](#4-核心业务逻辑)
|
|
|
|
|
+5. [接口设计](#5-接口设计)
|
|
|
|
|
+6. [非功能性设计](#6-非功能性设计)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 1. 管理后台页面设计
|
|
|
|
|
+
|
|
|
|
|
+> 管理后台模块路径:`D:\hdProject\ylrz_his_scrm_java\fs-admin`
|
|
|
|
|
+> 前端代码路径:`D:\web\cs\ylrz_his_scrm_adminUI\src\views\hisStore\storePromotion\`
|
|
|
|
|
+
|
|
|
|
|
+### 1.1 活动列表页
|
|
|
|
|
+
|
|
|
|
|
+页面路由:`/hisStore/storePromotion/index`
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1.1 页面布局
|
|
|
|
|
+
|
|
|
|
|
+采用若依框架标准 CRUD 页面布局(与现有 `storeCoupon` 模块一致):
|
|
|
|
|
+- 顶部:查询条件区域(可折叠 `showSearch`)
|
|
|
|
|
+- 中部:功能按钮栏 + 表格
|
|
|
|
|
+- 底部:分页组件
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1.2 筛选条件
|
|
|
|
|
+
|
|
|
|
|
+| 筛选字段 | 控件类型 | 说明 |
|
|
|
|
|
+|---------|---------|------|
|
|
|
|
|
+| 活动名称 | `el-input` | 支持模糊搜索 |
|
|
|
|
|
+| 店铺 | `el-select` | 下拉选择,默认全部(当前登录用户有权限的店铺) |
|
|
|
|
|
+| 活动状态 | `el-select` | 选项:全部/未开始/进行中/已结束/手动关闭/草稿 |
|
|
|
|
|
+| 生效时间范围 | `el-date-picker` (daterange) | 按 `start_time ~ end_time` 范围筛选 |
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1.3 列表展示字段
|
|
|
|
|
+
|
|
|
|
|
+| 字段 | 宽度 | 说明 |
|
|
|
|
|
+|-----|------|------|
|
|
|
|
|
+| 活动ID | 80 | 主键 |
|
|
|
|
|
+| 活动名称 | 200 | 活动标题,点击可跳转详情 |
|
|
|
|
|
+| 店铺名称 | 120 | `store_name` |
|
|
|
|
|
+| 活动时间 | 240 | 格式:`2026-06-01 00:00 ~ 2026-07-01 23:59` |
|
|
|
|
|
+| 阶梯档数 | 80 | 如"3档" |
|
|
|
|
|
+| 上不封顶 | 80 | 显示"是/否" Tag |
|
|
|
|
|
+| 可叠加优惠券 | 100 | 显示"是/否" Tag |
|
|
|
|
|
+| 适用范围 | 120 | 全场通用 / 指定分类(N) / 指定商品(N) |
|
|
|
|
|
+| 每人限次 | 80 | 如"1次" / "不限" |
|
|
|
|
|
+| 活动状态 | 100 | 见状态 Tag 颜色说明 |
|
|
|
|
|
+| 操作 | 240 | 编辑 / 启用/停用 / 删除 / 查看详情 |
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1.4 状态 Tag 颜色规范
|
|
|
|
|
+
|
|
|
|
|
+| 状态值 | 显示文字 | Tag 颜色 | 说明 |
|
|
|
|
|
+|-------|---------|----------|------|
|
|
|
|
|
+| 0 | 草稿 | `info`(灰) | 刚创建,尚未手动启用 |
|
|
|
|
|
+| 1 | 未开始 | `warning`(橙) | 已启用但未到开始时间 |
|
|
|
|
|
+| 2 | 进行中 | `success`(绿) | 当前时间在 [start, end] 内且是启用态 |
|
|
|
|
|
+| 3 | 已结束 | `info`(灰) | 已过结束时间 |
|
|
|
|
|
+| 4 | 已关闭 | `danger`(红) | 被手动关闭 |
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1.5 操作按钮逻辑
|
|
|
|
|
+
|
|
|
|
|
+| 按钮 | 草稿(0) | 未开始(1) | 进行中(2) | 已结束(3) | 已关闭(4) |
|
|
|
|
|
+|------|---------|-----------|-----------|-----------|-----------|
|
|
|
|
|
+| 编辑 | 显示 | 显示 | 显示(仅修改部分字段) | 隐藏 | 隐藏 |
|
|
|
|
|
+| 启用 | 显示 | 隐藏 | 隐藏 | 隐藏 | 显示 |
|
|
|
|
|
+| 停用 | 隐藏 | 显示 | 显示 | 隐藏 | 隐藏 |
|
|
|
|
|
+| 删除 | 显示 | 隐藏 | 隐藏 | 显示 | 显示 |
|
|
|
|
|
+| 查看详情 | 显示 | 显示 | 显示 | 显示 | 显示 |
|
|
|
|
|
+
|
|
|
|
|
+- **编辑按钮限制**:当状态为"进行中"时,可编辑但限制只能修改"活动名称"、"结束时间"(仅可延后)、"上不封顶"、"可叠加优惠券"、"每人限次"等不会引起歧义的字段。阶梯档位、适用范围不可修改。
|
|
|
|
|
+- **删除按钮**:仅草稿/已结束/已关闭状态可删除,且使用逻辑删除(`is_del = 1`)。
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1.6 前端状态判断逻辑(核心)
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+// 前端计算展示状态的函数(需前后端双重保障)
|
|
|
|
|
+function computeDisplayStatus(record) {
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ const startTime = new Date(record.startTime).getTime();
|
|
|
|
|
+ const endTime = new Date(record.endTime).getTime();
|
|
|
|
|
+
|
|
|
|
|
+ if (record.manualStatus === 0) {
|
|
|
|
|
+ // 手动关闭优先
|
|
|
|
|
+ return { status: 4, label: '已关闭', tag: 'danger' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (record.manualStatus === null || record.manualStatus === undefined) {
|
|
|
|
|
+ // 从未被手动操作过(刚创建)
|
|
|
|
|
+ return { status: 0, label: '草稿', tag: 'info' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (now < startTime) {
|
|
|
|
|
+ return { status: 1, label: '未开始', tag: 'warning' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (now >= startTime && now <= endTime) {
|
|
|
|
|
+ return { status: 2, label: '进行中', tag: 'success' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (now > endTime) {
|
|
|
|
|
+ return { status: 3, label: '已结束', tag: 'info' };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 1.2 新增/编辑活动页
|
|
|
|
|
+
|
|
|
|
|
+页面路由:`/hisStore/storePromotion/add`、`/hisStore/storePromotion/edit/:id`
|
|
|
|
|
+
|
|
|
|
|
+#### 1.2.1 表单布局(总览)
|
|
|
|
|
+
|
|
|
|
|
+采用 Element UI 的 `el-form` 组件,分三个卡片区域(`el-card`):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+┌──────────────────────────────────────────┐
|
|
|
|
|
+│ 【基本信息】 │
|
|
|
|
|
+│ 活动名称: [____________] │
|
|
|
|
|
+│ 所属店铺: [下拉选择] 活动状态: 草稿 │
|
|
|
|
|
+│ 开始时间: [日期时间选择器] │
|
|
|
|
|
+│ 结束时间: [日期时间选择器] │
|
|
|
|
|
+├──────────────────────────────────────────┤
|
|
|
|
|
+│ 【满减阶梯档位】 [+ 添加一档] │
|
|
|
|
|
+│ ┌─────────────────────────────────────┐ │
|
|
|
|
|
+│ │ 第1档 │ 满___元 │ 减___元 │ [删除] │ │
|
|
|
|
|
+│ │ 第2档 │ 满___元 │ 减___元 │ [删除] │ │
|
|
|
|
|
+│ │ 第3档 │ 满___元 │ 减___元 │ [删除] │ │
|
|
|
|
|
+│ └─────────────────────────────────────┘ │
|
|
|
|
|
+│ 上不封顶: [Switch 开关] │
|
|
|
|
|
+├──────────────────────────────────────────┤
|
|
|
|
|
+│ 【适用范围 & 规则】 │
|
|
|
|
|
+│ 适用类型: [全场通用 / 指定商品分类 / 指定商品] │
|
|
|
|
|
+│ 指定分类: [级联选择器](仅在"指定商品分类"显示)│
|
|
|
|
|
+│ 指定商品: [商品选择弹窗](仅在"指定商品"显示) │
|
|
|
|
|
+│ 可叠加优惠券: [Switch 开关] │
|
|
|
|
|
+│ 每人限参与次数: [数字输入框] 次(0=不限) │
|
|
|
|
|
+└──────────────────────────────────────────┘
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 1.2.2 详细字段说明
|
|
|
|
|
+
|
|
|
|
|
+| 字段名 | 控件类型 | 必填 | 校验规则 | 说明 |
|
|
|
|
|
+|--------|---------|------|---------|------|
|
|
|
|
|
+| 活动名称 | `el-input` | 是 | 1~50字符 | 活动标题 |
|
|
|
|
|
+| 所属店铺 | `el-select` | 是 | 下拉必选 | 复用 `/store/his/store/listOption`(`@/api/hisStore/store.js`) |
|
|
|
|
|
+| 开始时间 | `el-date-picker` (datetime) | 是 | 不能早于当前时间 | 精确到秒 |
|
|
|
|
|
+| 结束时间 | `el-date-picker` (datetime) | 是 | 必须 > 开始时间 | 精确到秒 |
|
|
|
|
|
+| 阶梯档位 | 动态表格 | 是 | 至少1档,最多10档 | 见下方详细交互说明 |
|
|
|
|
|
+| 上不封顶 | `el-switch` | — | 默认为否 | 开启后最高档之上继续按最后一档比例减 |
|
|
|
|
|
+| 适用类型 | `el-radio-group` | 是 | — | 1-全场通用, 2-指定分类, 3-指定商品 |
|
|
|
|
|
+| 指定分类ID列表 | `el-cascader` | 条件必填 | 适用类型=2时必填 | 多选,拉取商品分类树 |
|
|
|
|
|
+| 指定商品ID列表 | 自定义商品选择器 | 条件必填 | 适用类型=3时必填 | 多选,弹窗选择商品 |
|
|
|
|
|
+| 可叠加优惠券 | `el-switch` | — | 默认为是 | 是否可与优惠券同时使用 |
|
|
|
|
|
+| 每人限参与次数 | `el-input-number` | — | `min=0`, 0=不限 | 同一用户最多参与次数 |
|
|
|
|
|
+
|
|
|
|
|
+#### 1.2.3 阶梯档位交互设计
|
|
|
|
|
+
|
|
|
|
|
+**添加一档**:
|
|
|
|
|
+- 点击"添加一档"按钮,在档位列表末尾插入一个新的空档位行
|
|
|
|
|
+- 新行的"门槛金额"默认值为上一档门槛金额 + 100(若为第一档则默认100)
|
|
|
|
|
+- 添加时前端先做即时校验:当前已有档位数量 < 10
|
|
|
|
|
+
|
|
|
|
|
+**删除一档**:
|
|
|
|
|
+- 每行右侧有"删除"按钮
|
|
|
|
|
+- 删除时校验:删除后至少保留1档,否则给出提示"至少保留一档"
|
|
|
|
|
+- 删除不需要二次确认
|
|
|
|
|
+
|
|
|
|
|
+**前端即时校验规则**:
|
|
|
|
|
+```javascript
|
|
|
|
|
+const tierRules = {
|
|
|
|
|
+ // 1. 门槛金额必须递增
|
|
|
|
|
+ thresholdAscending: (tiers) => {
|
|
|
|
|
+ for (let i = 1; i < tiers.length; i++) {
|
|
|
|
|
+ if (tiers[i].thresholdAmount <= tiers[i-1].thresholdAmount) {
|
|
|
|
|
+ return `第${i+1}档门槛金额必须大于第${i}档`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ },
|
|
|
|
|
+ // 2. 减扣金额必须递增(满减逻辑:门槛越高优惠越多)
|
|
|
|
|
+ discountAscending: (tiers) => {
|
|
|
|
|
+ for (let i = 1; i < tiers.length; i++) {
|
|
|
|
|
+ if (tiers[i].discountAmount <= tiers[i-1].discountAmount) {
|
|
|
|
|
+ return `第${i+1}档减扣金额必须大于第${i}档`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ },
|
|
|
|
|
+ // 3. 减扣金额不能大于门槛金额(防止负数支付)
|
|
|
|
|
+ discountLessThanThreshold: (tiers) => {
|
|
|
|
|
+ for (let i = 0; i < tiers.length; i++) {
|
|
|
|
|
+ if (tiers[i].discountAmount >= tiers[i].thresholdAmount) {
|
|
|
|
|
+ return `第${i+1}档减扣金额不能大于等于门槛金额`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 1.2.4 提交前完整校验清单
|
|
|
|
|
+
|
|
|
|
|
+| 序号 | 校验点 | 规则 |
|
|
|
|
|
+|------|-------|------|
|
|
|
|
|
+| 1 | 活动名称 | 非空,1~50字符 |
|
|
|
|
|
+| 2 | 店铺 | 必选 |
|
|
|
|
|
+| 3 | 开始时间 | 必填,编辑时若已生效则不可修改 |
|
|
|
|
|
+| 4 | 结束时间 | 必填,必须 > 开始时间 |
|
|
|
|
|
+| 5 | 档位数量 | 1~10档 |
|
|
|
|
|
+| 6 | 门槛递增 | 每档门槛 > 前一档 |
|
|
|
|
|
+| 7 | 优惠递增 | 每档减扣 >= 前一档 |
|
|
|
|
|
+| 8 | 优惠 < 门槛 | 每档减扣 < 对应门槛 |
|
|
|
|
|
+| 9 | 适用类型 | 必选,选2/3时必须选具体分类/商品 |
|
|
|
|
|
+| 10 | 限次 | >= 0 整数 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 1.3 状态展示与操作逻辑
|
|
|
|
|
+
|
|
|
|
|
+#### 1.3.1 状态定义
|
|
|
|
|
+
|
|
|
|
|
+| 状态值 | 状态名称 | 定义 | 数据库中 `status` + `manual_status` 组合 |
|
|
|
|
|
+|--------|---------|------|------------------------------------------|
|
|
|
|
|
+| 0 | 草稿 | 创建后从未手动启用 | `status = 0, manual_status = NULL` |
|
|
|
|
|
+| 1 | 未开始 | 已启用,未到开始时间 | `manual_status = 1` 且 `now < start_time` |
|
|
|
|
|
+| 2 | 进行中 | 已启用且在有效期内 | `manual_status = 1` 且 `start_time <= now <= end_time` |
|
|
|
|
|
+| 3 | 已结束 | 已过结束时间 | `manual_status = 1` 且 `now > end_time`,或 `status = 3`(定时/查即验证写入) |
|
|
|
|
|
+| 4 | 已关闭 | 被手动关闭 | `manual_status = 0`(`status` 保持原值,展示态优先于时间) |
|
|
|
|
|
+
|
|
|
|
|
+> **说明**:列表/详情展示的 5 种状态均为**计算态**(displayStatus),数据库仅持久化 `status`(0草稿/1启用/3已结束)与 `manual_status`(NULL/0/1),避免前后端状态不一致。
|
|
|
|
|
+
|
|
|
|
|
+#### 1.3.2 启用/停用操作逻辑
|
|
|
|
|
+
|
|
|
|
|
+**点击"启用"按钮**(状态为草稿或已关闭时显示):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+用户点击"启用"
|
|
|
|
|
+ ↓
|
|
|
|
|
+前端校验:是否在活动时间范围内?
|
|
|
|
|
+ ├─ 在范围内 → 调用后端启用接口 → manual_status=1, status=1 → 状态变为"进行中"
|
|
|
|
|
+ ├─ 未到开始时间 → 调用后端启用接口 → manual_status=1, status=1 → 状态变为"未开始"
|
|
|
|
|
+ └─ 已过结束时间 → 阻止操作,提示"活动已过期,无法启用"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**点击"停用"按钮**(状态为未开始或进行中时显示):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+用户点击"停用"
|
|
|
|
|
+ ↓
|
|
|
|
|
+弹窗二次确认:"确认停用该活动?停用后用户将无法参与该活动。"
|
|
|
|
|
+ ↓ 确认
|
|
|
|
|
+调用后端停用接口 → manual_status=0 → 状态变为"已关闭"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 1.3.3 后端状态接口
|
|
|
|
|
+
|
|
|
|
|
+> 在现有 Controller 基础上新增两个方法,详见第5节接口设计。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 1.4 用户端结算页设计(APP / 小程序)
|
|
|
|
|
+
|
|
|
|
|
+> **后端模块**:`D:\hdProject\ylrz_his_scrm_java\fs-user-app\src\main\java\com\fs\app\controller\store\StoreOrderScrmController`
|
|
|
|
|
+> **前端模块**:用户端 uni-app 工程(APP 与小程序共用结算页,页面路径以各项目为准,常见如 `pages/store/order/confirm`)
|
|
|
|
|
+
|
|
|
|
|
+#### 1.4.1 改造范围
|
|
|
|
|
+
|
|
|
|
|
+| 页面 | 改造内容 |
|
|
|
|
|
+|------|---------|
|
|
|
|
|
+| 单店铺结算页 | 新增「满减活动」区块:活动列表、档位说明、选中态、优惠金额预览 |
|
|
|
|
|
+| 多店铺结算页 | 按店铺 Tab/卡片分组展示各自可用活动,每店独立 `promotionActivityId` |
|
|
|
|
|
+| 金额明细区 | 增加一行「满减优惠 -¥XX.XX」,置于积分与优惠券之间 |
|
|
|
|
|
+| 提交订单 | `create` / `createMultiStore` 携带 `promotionActivityId`、`promotionTierId` |
|
|
|
|
|
+
|
|
|
|
|
+**不在本次改造**:商品详情页活动标签(可选二期)、购物车页(仍走 confirm → 结算页查活动)。
|
|
|
|
|
+
|
|
|
|
|
+#### 1.4.2 页面布局(单店铺)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+┌─────────────────────────────────────┐
|
|
|
|
|
+│ 收货地址 │
|
|
|
|
|
+├─────────────────────────────────────┤
|
|
|
|
|
+│ 商品列表(现有) │
|
|
|
|
|
+├─────────────────────────────────────┤
|
|
|
|
|
+│ 【满减活动】 查看规则 > │ ← 新增区块
|
|
|
|
|
+│ ○ 不参与满减 │
|
|
|
|
|
+│ ● 618年中大促 已满399减80 │ ← 默认选中推荐活动
|
|
|
|
|
+│ 再购 XX 元可减 YY 元(下一档提示) │
|
|
|
|
|
+│ ○ 品类专享满减 满199减30(不可用灰显)│
|
|
|
|
|
+│ 原因:未达门槛 / 已达参与上限 │
|
|
|
|
|
+├─────────────────────────────────────┤
|
|
|
|
|
+│ 优惠券(现有) │
|
|
|
|
|
+│ 积分抵扣(现有) │
|
|
|
|
|
+├─────────────────────────────────────┤
|
|
|
|
|
+│ 商品金额 ¥498.00 │
|
|
|
|
|
+│ 运费 ¥0.00 │
|
|
|
|
|
+│ 积分抵扣 -¥0.00 │
|
|
|
|
|
+│ 满减优惠 -¥80.00 ← 新增 │
|
|
|
|
|
+│ 优惠券 -¥0.00 │
|
|
|
|
|
+│ ───────────────── │
|
|
|
|
|
+│ 实付 ¥418.00 │
|
|
|
|
|
+└─────────────────────────────────────┘
|
|
|
|
|
+ [ 提交订单 ]
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 1.4.3 交互流程
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+onLoad(携带 confirm 返回的 orderKey)
|
|
|
|
|
+ ↓
|
|
|
|
|
+调用 POST /store/app/storeOrder/promotion/list
|
|
|
|
|
+ ↓ 展示活动列表,默认选中 recommendedActivityId(优惠最大且可用)
|
|
|
|
|
+用户切换活动 / 选「不参与满减」
|
|
|
|
|
+ ↓
|
|
|
|
|
+调用 POST /store/app/storeOrder/computed(带 promotionActivityId,null=不参与)
|
|
|
|
|
+ ↓ 刷新金额明细(payPrice、promotionDiscountAmount)
|
|
|
|
|
+用户改地址 / 优惠券 / 积分
|
|
|
|
|
+ ↓ 重新 computed(保持当前 promotionActivityId)
|
|
|
|
|
+ ↓ 若 is_stackable=0 且已选券 → 提示互斥,禁用活动或清券(与后端一致)
|
|
|
|
|
+点击提交订单
|
|
|
|
|
+ ↓
|
|
|
|
|
+POST /store/app/storeOrder/create(带 promotionActivityId + promotionTierId)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**多店铺**:进入页后调用 `promotion/listMultiStore`,每个 `storeId` 渲染一组活动单选;`computedMultiStore` / `createMultiStore` 按店传不同 `promotionActivityId`(扩展 group 参数,见 5.2.4)。
|
|
|
|
|
+
|
|
|
|
|
+#### 1.4.4 前端状态与校验
|
|
|
|
|
+
|
|
|
|
|
+| 场景 | 处理 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| 无可用活动 | 隐藏满减区块或展示「暂无满减活动」 |
|
|
|
|
|
+| 活动不可用(`enabled=false`) | 灰显 + 展示 `disabledReason` |
|
|
|
|
|
+| 切换活动 | 防抖 300ms 后调 `computed` |
|
|
|
|
|
+| 活动列表为空但 computed 有推荐 | 以 list 接口为准,不前端臆造活动 |
|
|
|
|
|
+| orderKey 过期 | 与现有逻辑一致,提示返回购物车 |
|
|
|
|
|
+
|
|
|
|
|
+#### 1.4.5 前端数据结构(页面 data)
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ orderKey: '',
|
|
|
|
|
+ storeId: null,
|
|
|
|
|
+ promotionList: [], // list 接口返回
|
|
|
|
|
+ selectedPromotionId: null, // null = 不参与
|
|
|
|
|
+ recommendedPromotionId: null,
|
|
|
|
|
+ promotionDiscountAmount: 0,
|
|
|
|
|
+ promotionTitle: '',
|
|
|
|
|
+ promotionRemainCount: null,
|
|
|
|
|
+ computeLoading: false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 1.4.6 APP 与小程序差异
|
|
|
|
|
+
|
|
|
|
|
+| 项 | APP | 小程序 |
|
|
|
|
|
+|----|-----|--------|
|
|
|
|
|
+| 网络请求 | 同一套 API 封装 | 同一套 API 封装 |
|
|
|
|
|
+| 活动规则弹窗 | 可用原生 Modal / 底部 Sheet | 建议 `uni-popup` 展示档位表 |
|
|
|
|
|
+| 登录 | `@Login` Token | `@Login` Token |
|
|
|
|
|
+| 多店铺 | 与 APP 一致 | 与 APP 一致 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 2. 活动时效性生效与失效方案
|
|
|
|
|
+
|
|
|
|
|
+### 2.1 活动生命周期状态机
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+ ┌──────────┐
|
|
|
|
|
+ 创建 │ 草稿 │
|
|
|
|
|
+ ───────────▶ │ (status=0, │
|
|
|
|
|
+ │ manual=NULL)│
|
|
|
|
|
+ └─────┬─────┘
|
|
|
|
|
+ │ 管理员点击"启用"
|
|
|
|
|
+ ▼
|
|
|
|
|
+ ┌───────────────────────┐
|
|
|
|
|
+ │ 是否在时间范围内? │
|
|
|
|
|
+ └───────┬───────┬───────┘
|
|
|
|
|
+ │ │
|
|
|
|
|
+ now < start│ │ now >= start && now <= end
|
|
|
|
|
+ ▼ ▼
|
|
|
|
|
+ ┌─────────┐ ┌───────────┐
|
|
|
|
|
+ │ 未开始 │ │ 进行中 │
|
|
|
|
|
+ │(status=1,│ │(status=1, │
|
|
|
|
|
+ │manual=1) │ │manual=1) │
|
|
|
|
|
+ └────┬────┘ └──┬──┬───┬─┘
|
|
|
|
|
+ │ │ │ │
|
|
|
|
|
+ 时间到 start_time 自动 │ │ │ 时间到 end_time 自动
|
|
|
|
|
+ │ │ │ │
|
|
|
|
|
+ ▼ │ │ ▼
|
|
|
|
|
+ ┌───────────┐ │ │ ┌──────────┐
|
|
|
|
|
+ │ 进行中 │◀───┘ │ │ 已结束 │
|
|
|
|
|
+ └───────────┘ │ │(status=3,│
|
|
|
|
|
+ │ │manual=1) │
|
|
|
|
|
+ │ └──────────┘
|
|
|
|
|
+ │
|
|
|
|
|
+ 管理员点击"停用"
|
|
|
|
|
+ │
|
|
|
|
|
+ ▼
|
|
|
|
|
+ ┌────────────┐
|
|
|
|
|
+ │ 已关闭 │
|
|
|
|
|
+ │(status=1, │
|
|
|
|
|
+ │manual=0) │
|
|
|
|
|
+ └──────┬─────┘
|
|
|
|
|
+ │
|
|
|
|
|
+ 管理员点击"启用"
|
|
|
|
|
+ 且时间仍在有效范围内
|
|
|
|
|
+ │
|
|
|
|
|
+ ▼
|
|
|
|
|
+ ┌───────────┐
|
|
|
|
|
+ │ 进行中 │
|
|
|
|
|
+ └───────────┘
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**状态流转说明**:
|
|
|
|
|
+
|
|
|
|
|
+| 当前状态 | 触发事件 | 目标状态 | 条件 |
|
|
|
|
|
+|---------|---------|---------|------|
|
|
|
|
|
+| 草稿 | 管理员启用 | 未开始/进行中 | 根据当前时间判断 |
|
|
|
|
|
+| 未开始 | 时间到达 start_time | 进行中 | `manual_status=1` |
|
|
|
|
|
+| 进行中 | 时间超过 end_time | 已结束 | `manual_status=1` |
|
|
|
|
|
+| 进行中 | 管理员停用 | 已关闭 | — |
|
|
|
|
|
+| 未开始 | 管理员停用 | 已关闭 | — |
|
|
|
|
|
+| 已关闭 | 管理员启用 | 未开始/进行中 | 必须在 [start_time, end_time] 内或之前 |
|
|
|
|
|
+| 已结束 | (终态) | — | 不可再操作 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 2.2 自动生效/失效触发机制(方案对比与推荐)
|
|
|
|
|
+
|
|
|
|
|
+#### 方案一:定时任务轮询扫描(如 Spring `@Scheduled` 或 XXL-JOB)
|
|
|
|
|
+
|
|
|
|
|
+**机制**:每分钟执行一次定时任务,扫描 `status=1 AND manual_status=1` 的活动,根据时间更新状态。
|
|
|
|
|
+
|
|
|
|
|
+**优点**:
|
|
|
|
|
+- 实现简单,与现有 `fs-quartz` 模块可直接集成
|
|
|
|
|
+- 不依赖额外中间件特性
|
|
|
|
|
+- 可批量处理,适合大批量活动
|
|
|
|
|
+
|
|
|
|
|
+**缺点**:
|
|
|
|
|
+- 有延迟(最小粒度为分钟级),无法做到秒级精确
|
|
|
|
|
+- 无活动时也执行,浪费资源
|
|
|
|
|
+- 高并发时可能存在竞争(需加锁)
|
|
|
|
|
+
|
|
|
|
|
+#### 方案二:基于 Redis 过期事件 + Keyspace Notifications
|
|
|
|
|
+
|
|
|
|
|
+**机制**:活动启用时,计算 `end_time - now` 秒数,设置 Redis key 的过期时间。当 key 过期时,通过 Redis 的 `__keyevent@*__:expired` 通知,触发状态更新。
|
|
|
|
|
+
|
|
|
|
|
+**优点**:
|
|
|
|
|
+- 秒级精度
|
|
|
|
|
+- 无需轮询,资源消耗小
|
|
|
|
|
+- 天然分布式友好
|
|
|
|
|
+
|
|
|
|
|
+**缺点**:
|
|
|
|
|
+- 依赖 Redis Keyspace Notifications(需开启 `notify-keyspace-events Ex`)
|
|
|
|
|
+- Redis 过期事件可能丢失(Redis 重启、主从切换时)
|
|
|
|
|
+- 部署复杂度较高
|
|
|
|
|
+
|
|
|
|
|
+#### 方案三(推荐):**查即验证 + 定时兜底** — 实时惰性校验
|
|
|
|
|
+
|
|
|
|
|
+**机制**:
|
|
|
|
|
+
|
|
|
|
|
+1. **前置拦截(核心)**:每次用户请求(浏览活动详情 / 下单计算优惠 / 提交订单)时,在 Service 层通过 AOP 或工具方法执行**时间窗口校验**,若当前时间已超过结束时间,**即时更新活动状态**为已结束,并返回无效。
|
|
|
|
|
+
|
|
|
|
|
+2. **缓存标记**:下发到用户端的活动数据中,始终携带 `start_time` 和 `end_time`。前端在发起下单请求前,本地先做时间校验,早于到达后端就拦截。
|
|
|
|
|
+
|
|
|
|
|
+3. **定时兜底(每分钟)**:通过 XXL-JOB / `@Scheduled` 定时任务全量扫描,处理那些长期无人访问的"僵尸活动",确保管理端列表准时刷新状态。
|
|
|
|
|
+
|
|
|
|
|
+**方案对比表**:
|
|
|
|
|
+
|
|
|
|
|
+| 维度 | 方案一 纯定时任务 | 方案二 Redis过期事件 | 方案三 查即验证+定时兜底 |
|
|
|
|
|
+|------|:--:|:--:|:--:|
|
|
|
|
|
+| 实时性 | 分钟级 | 秒级 | **秒级**(请求触发) |
|
|
|
|
|
+| 可靠性 | 高 | 中(事件可能丢失) | **高**(双重保障) |
|
|
|
|
|
+| 实现复杂度 | 低 | 高 | **中** |
|
|
|
|
|
+| 资源消耗 | 持续扫描 | 低 | **低** |
|
|
|
|
|
+| 部署依赖 | 无 | Redis Keyspace | **Redis(通用缓存)** |
|
|
|
|
|
+
|
|
|
|
|
+**推荐结论**:采用 **方案三(查即验证 + 定时兜底)**,核心原因:
|
|
|
|
|
+- 用户明确要求"购买或查看详情时过期就立即更新状态"——该方案天然满足
|
|
|
|
|
+- 不依赖 Redis 过期事件,降低基础设施要求
|
|
|
|
|
+- 双重机制保障可靠性和实时性
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 2.3 时间边界准确性保障
|
|
|
|
|
+
|
|
|
|
|
+#### 下单瞬间的防纠纷机制
|
|
|
|
|
+
|
|
|
|
|
+**问题**:用户在 `end_time = 2026-06-23 23:59:59` 的 23:59:58 秒下单,后端处理时已经过了 23:59:59,活动已结束,用户看到的价格和实际扣款不一致。
|
|
|
|
|
+
|
|
|
|
|
+**解决方案——数据库乐观锁 + 二次时间校验**:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+下单流程中的关键步骤:
|
|
|
|
|
+1. 用户点击"提交订单"
|
|
|
|
|
+2. 后端查询当前适用活动列表(调用 computeActivity 接口)
|
|
|
|
|
+ → 此时记录活动的 start_time / end_time
|
|
|
|
|
+3. 后端计算优惠金额,返回给前端确认
|
|
|
|
|
+4. 用户确认支付,后端创建订单
|
|
|
|
|
+ → 扣减库存前,再次校验活动时间:
|
|
|
|
|
+ SELECT * FROM fs_store_promotion_activity
|
|
|
|
|
+ WHERE id = #{activityId}
|
|
|
|
|
+ AND manual_status = 1
|
|
|
|
|
+ AND start_time <= NOW()
|
|
|
|
|
+ AND end_time >= NOW()
|
|
|
|
|
+ → 若查不到记录,说明活动已失效
|
|
|
|
|
+ → 若查到记录,使用版本号(version)做乐观锁更新参与次数
|
|
|
|
|
+
|
|
|
|
|
+5. 若活动失效 → 回滚订单创建,提示用户"活动已结束,请重新下单"
|
|
|
|
|
+6. 若活动有效 → 正常完成订单
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**时间比较精度**:统一使用 MySQL `NOW()` 或 Java `LocalDateTime.now()`,精度为**秒级**。**开始时间含边界**(`start_time <= NOW()`),**结束时间含边界**(`end_time >= NOW()`),与现有优惠券、订单超时逻辑保持一致。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 2.4 手动"启用/关闭"与时间范围的关系
|
|
|
|
|
+
|
|
|
|
|
+#### 规则矩阵
|
|
|
|
|
+
|
|
|
|
|
+| `manual_status` | 时间条件 | 最终生效状态 | 说明 |
|
|
|
|
|
+|:--:|------|:--:|------|
|
|
|
|
|
+| `NULL` | 任意 | **草稿** | 从未操作过 |
|
|
|
|
|
+| `1` | `now < start_time` | **未开始** | 等待生效 |
|
|
|
|
|
+| `1` | `start_time <= now <= end_time` | **进行中** | 正常生效 |
|
|
|
|
|
+| `1` | `now > end_time` | **已结束** | 自动失效 |
|
|
|
|
|
+| `0` | 任意 | **已关闭** | 手动关闭优先,时间条件无效 |
|
|
|
|
|
+
|
|
|
|
|
+#### 核心原则
|
|
|
|
|
+
|
|
|
|
|
+1. **手动关闭优先级最高**:`manual_status = 0` 时,无论时间是否在有效期内,活动均不生效。
|
|
|
|
|
+2. **手动开启必须在时间范围内**:`manual_status = 1` 且 `now <= end_time` 才有效;若 `now > end_time`,即使 `manual_status = 1` 也视为已结束。
|
|
|
|
|
+3. **启用/停用操作应校验时间冲突**:
|
|
|
|
|
+ - 启用时如果已超过结束时间 → 拒绝操作
|
|
|
|
|
+ - 停用时不需校验时间,直接关闭生效
|
|
|
|
|
+
|
|
|
|
|
+#### 后端校验代码示例逻辑
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+// 启用活动接口伪代码
|
|
|
|
|
+public void enableActivity(Long activityId) {
|
|
|
|
|
+ FsStorePromotionActivity activity = getById(activityId);
|
|
|
|
|
+
|
|
|
|
|
+ // 校验:已超过结束时间的活动不允许启用
|
|
|
|
|
+ if (activity.getEndTime().isBefore(LocalDateTime.now())) {
|
|
|
|
|
+ throw new ServiceException("活动已过期,无法启用");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 校验:同一店铺+同一时间不允许有多个启用状态且范围重叠的活动
|
|
|
|
|
+ checkTimeConflict(activity);
|
|
|
|
|
+
|
|
|
|
|
+ activity.setManualStatus(1);
|
|
|
|
|
+ // status 由查询时动态计算,此处可不设置
|
|
|
|
|
+ updateById(activity);
|
|
|
|
|
+
|
|
|
|
|
+ // 缓存失效(如有)
|
|
|
|
|
+ redisTemplate.delete("promotion:store:" + activity.getStoreId());
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 2.5 数据库层时效控制关键设计
|
|
|
|
|
+
|
|
|
|
|
+#### 关键字段
|
|
|
|
|
+
|
|
|
|
|
+| 字段 | 类型 | 作用 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| `start_time` | `datetime` | 活动生效开始时间 |
|
|
|
|
|
+| `end_time` | `datetime` | 活动生效结束时间 |
|
|
|
|
|
+| `status` | `tinyint` | 存储态状态(0草稿/1启用/3已结束),辅助查询 |
|
|
|
|
|
+| `manual_status` | `tinyint` | 手动开关(NULL未操作/1启用/0关闭) |
|
|
|
|
|
+| `version` | `int` | 乐观锁版本号,用于并发控制 |
|
|
|
|
|
+
|
|
|
|
|
+#### 状态查询 SQL(核心)
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 查询当前时刻生效的活动(供用户端使用)
|
|
|
|
|
+SELECT * FROM fs_store_promotion_activity
|
|
|
|
|
+WHERE is_del = 0
|
|
|
|
|
+ AND manual_status = 1 -- 手动启用
|
|
|
|
|
+ AND status IN (1, 3) -- 仅启用和已结束(已结束由时间二次过滤)
|
|
|
|
|
+ AND start_time <= NOW() -- 已到开始时间
|
|
|
|
|
+ AND end_time >= NOW() -- 未到结束时间
|
|
|
|
|
+ AND store_id = #{storeId};
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 状态更新策略
|
|
|
|
|
+
|
|
|
|
|
+1. **定时任务**(每分钟):
|
|
|
|
|
+```sql
|
|
|
|
|
+UPDATE fs_store_promotion_activity
|
|
|
|
|
+SET status = 3, update_time = NOW()
|
|
|
|
|
+WHERE status = 1 AND manual_status = 1 AND end_time < NOW();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+2. **请求触发更新**(查即验证):
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 查询单个活动时,附带时间校验
|
|
|
|
|
+SELECT * FROM fs_store_promotion_activity WHERE id = #{id};
|
|
|
|
|
+-- 若 end_time < NOW() 且 status != 3,则执行:
|
|
|
|
|
+UPDATE fs_store_promotion_activity SET status = 3, update_time = NOW() WHERE id = #{id} AND status = 1;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 3. 数据库设计
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 活动主表 `fs_store_promotion_activity`
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+CREATE TABLE `fs_store_promotion_activity` (
|
|
|
|
|
+ `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
|
|
|
|
+ `title` VARCHAR(100) NOT NULL COMMENT '活动名称',
|
|
|
|
|
+ `store_id` BIGINT(20) NOT NULL COMMENT '店铺ID',
|
|
|
|
|
+ `start_time` DATETIME NOT NULL COMMENT '活动开始时间',
|
|
|
|
|
+ `end_time` DATETIME NOT NULL COMMENT '活动结束时间',
|
|
|
|
|
+ `scope_type` TINYINT(2) NOT NULL DEFAULT 1 COMMENT '适用范围类型:1全场通用 2指定分类 3指定商品',
|
|
|
|
|
+ `is_stackable` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可叠加优惠券:0否 1是',
|
|
|
|
|
+ `is_capped` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否上不封顶:0否(仅命中最高档) 1是(超出最高档按最后一档循环减)',
|
|
|
|
|
+ `limit_per_user` INT(11) NOT NULL DEFAULT 0 COMMENT '每人限参与次数,0=不限',
|
|
|
|
|
+ `status` TINYINT(2) NOT NULL DEFAULT 0 COMMENT '状态:0草稿 1启用 3已结束(★时效关键字段)',
|
|
|
|
|
+ `manual_status` TINYINT(1) DEFAULT NULL COMMENT '手动开关:NULL从未操作 1启用 0关闭(★时效关键字段,优先级最高)',
|
|
|
|
|
+ `version` INT(11) NOT NULL DEFAULT 0 COMMENT '乐观锁版本号(★并发控制字段)',
|
|
|
|
|
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
|
|
|
|
|
+ `is_del` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
|
|
|
|
|
+ `create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
|
|
|
|
|
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
|
|
+ `update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
|
|
|
|
|
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
|
|
|
+ PRIMARY KEY (`id`),
|
|
|
|
|
+ KEY `idx_store_id` (`store_id`),
|
|
|
|
|
+ KEY `idx_status_time` (`status`, `manual_status`, `start_time`, `end_time`),
|
|
|
|
|
+ KEY `idx_store_status` (`store_id`, `status`, `manual_status`, `is_del`)
|
|
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店铺阶梯满减活动主表';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**时效/状态相关字段标注**:
|
|
|
|
|
+- `start_time`、`end_time`:时间核心字段
|
|
|
|
|
+- `status`:辅助状态存储,加速列表查询
|
|
|
|
|
+- `manual_status`:手动开关,决定性字段
|
|
|
|
|
+- `version`:并发控制
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 满减档位表 `fs_store_promotion_tier`
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+CREATE TABLE `fs_store_promotion_tier` (
|
|
|
|
|
+ `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
|
|
|
|
+ `activity_id` BIGINT(20) NOT NULL COMMENT '活动ID,关联fs_store_promotion_activity.id',
|
|
|
|
|
+ `sort_order` INT(11) NOT NULL DEFAULT 1 COMMENT '档位序号:1,2,3...',
|
|
|
|
|
+ `threshold_amount` DECIMAL(10,2) NOT NULL COMMENT '门槛金额(元)',
|
|
|
|
|
+ `discount_amount` DECIMAL(10,2) NOT NULL COMMENT '减扣金额(元)',
|
|
|
|
|
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
|
|
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
|
|
|
+ PRIMARY KEY (`id`),
|
|
|
|
|
+ KEY `idx_activity_id` (`activity_id`)
|
|
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='阶梯满减档位表';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3.3 适用范围表 `fs_store_promotion_scope`
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+CREATE TABLE `fs_store_promotion_scope` (
|
|
|
|
|
+ `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
|
|
|
|
+ `activity_id` BIGINT(20) NOT NULL COMMENT '活动ID',
|
|
|
|
|
+ `scope_type` TINYINT(2) NOT NULL COMMENT '范围类型:2商品分类 3指定商品(与主表scope_type对应)',
|
|
|
|
|
+ `target_id` BIGINT(20) NOT NULL COMMENT '目标ID(分类ID或商品ID)',
|
|
|
|
|
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
|
|
+ PRIMARY KEY (`id`),
|
|
|
|
|
+ KEY `idx_activity_id` (`activity_id`),
|
|
|
|
|
+ KEY `idx_target` (`scope_type`, `target_id`)
|
|
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动适用范围明细表';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**说明**:
|
|
|
|
|
+- 当 `scope_type = 1`(全场通用)时,此表无记录
|
|
|
|
|
+- 当 `scope_type = 2`(指定分类)时,`target_id` 存储分类ID
|
|
|
|
|
+- 当 `scope_type = 3`(指定商品)时,`target_id` 存储商品ID
|
|
|
|
|
+
|
|
|
|
|
+### 3.4 用户参与记录表 `fs_store_promotion_usage`
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+CREATE TABLE `fs_store_promotion_usage` (
|
|
|
|
|
+ `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
|
|
|
|
+ `activity_id` BIGINT(20) NOT NULL COMMENT '活动ID',
|
|
|
|
|
+ `user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
|
|
|
|
|
+ `order_id` BIGINT(20) NOT NULL COMMENT '关联订单ID',
|
|
|
|
|
+ `order_amount` DECIMAL(10,2) NOT NULL COMMENT '订单原金额',
|
|
|
|
|
+ `discount_amount` DECIMAL(10,2) NOT NULL COMMENT '本次优惠金额',
|
|
|
|
|
+ `tier_id` BIGINT(20) DEFAULT NULL COMMENT '命中的档位ID',
|
|
|
|
|
+ `usage_status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '记录状态:0待支付 1已生效 2已回滚(★与限次统计相关)',
|
|
|
|
|
+ `usage_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '参与时间(下单时间)',
|
|
|
|
|
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
|
|
+ PRIMARY KEY (`id`),
|
|
|
|
|
+ KEY `idx_activity_user` (`activity_id`, `user_id`),
|
|
|
|
|
+ KEY `idx_order_id` (`order_id`),
|
|
|
|
|
+ KEY `idx_usage_time` (`usage_time`),
|
|
|
|
|
+ UNIQUE KEY `uk_activity_order` (`activity_id`, `order_id`)
|
|
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户活动参与记录表';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**限次统计规则**:`limit_per_user` 仅统计 `usage_status = 1`(已支付生效)的记录;待支付(0)不占额度,订单取消/超时后将记录置为 2(已回滚)。
|
|
|
|
|
+
|
|
|
|
|
+### 3.5 索引优化说明
|
|
|
|
|
+
|
|
|
|
|
+| 索引 | 所在表 | 优化目的 |
|
|
|
|
|
+|------|-------|---------|
|
|
|
|
|
+| `idx_status_time` | 主表 | 支持"查询当前生效活动"的高频查询(范围扫描) |
|
|
|
|
|
+| `idx_store_status` | 主表 | 支持按店铺筛选活动列表(管理端列表页) |
|
|
|
|
|
+| `idx_activity_id` | 档位表 | JOIN 查询档位信息 |
|
|
|
|
|
+| `idx_target` | 范围表 | 反查"某商品/分类参与哪些活动" |
|
|
|
|
|
+| `idx_activity_user` | 参与表 | 校验"用户已参与次数"(聚合查询) |
|
|
|
|
|
+
|
|
|
|
|
+### 3.6 与现有系统表的关联
|
|
|
|
|
+
|
|
|
|
|
+| 本表字段 | 关联现有表 | 实际表名 | 说明 |
|
|
|
|
|
+|---------|-----------|---------|------|
|
|
|
|
|
+| `store_id` | 店铺 | `fs_store_scrm` | 多店铺主表,`FsStoreScrm.storeId` |
|
|
|
|
|
+| `target_id`(分类) | 商品分类 | `fs_store_product_category_scrm` | 级联选择分类树 |
|
|
|
|
|
+| `target_id`(商品) | 商品 | `fs_store_product_scrm` | 商品选择器数据源 |
|
|
|
|
|
+| `order_id` | 订单 | `fs_store_order_scrm` | 实体 `FsStoreOrderScrm` |
|
|
|
|
|
+| `user_id` | 会员 | `fs_user` | 下单用户 |
|
|
|
|
|
+
|
|
|
|
|
+### 3.7 订单表扩展(`fs_store_order_scrm`)
|
|
|
|
|
+
|
|
|
|
|
+现有订单实体已含 `discountMoney`(总优惠)、`couponPrice`、`userCouponId`,本次新增:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+ALTER TABLE `fs_store_order_scrm`
|
|
|
|
|
+ ADD COLUMN `promotion_activity_id` BIGINT(20) DEFAULT NULL COMMENT '阶梯满减活动ID' AFTER `user_coupon_id`,
|
|
|
|
|
+ ADD COLUMN `promotion_tier_id` BIGINT(20) DEFAULT NULL COMMENT '命中档位ID' AFTER `promotion_activity_id`,
|
|
|
|
|
+ ADD COLUMN `promotion_discount_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '满减优惠金额' AFTER `promotion_tier_id`;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> 字段命名与 Java 实体 `FsStoreOrderScrm` 驼峰属性一致,Mapper XML 使用下划线映射。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 4. 核心业务逻辑
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 用户端查询适用活动(高效过滤)
|
|
|
|
|
+
|
|
|
|
|
+#### 查询流程
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+用户进入结算页
|
|
|
|
|
+ ↓
|
|
|
|
|
+后端接收请求:用户ID、店铺ID、商品列表
|
|
|
|
|
+ ↓
|
|
|
|
|
+【第1步】缓存查询:从 Redis 获取该店铺当前生效的活动列表
|
|
|
|
|
+ ├─ 命中 → 直接返回缓存
|
|
|
|
|
+ └─ 未命中 → 查询 DB 并写入 Redis
|
|
|
|
|
+ ↓
|
|
|
|
|
+【第2步】范围匹配 + 金额基数
|
|
|
|
|
+ ├─ 从 Redis orderKey 读取购物车(与 computedOrder 一致)
|
|
|
|
|
+ ├─ 按 storeId 过滤当前店铺商品
|
|
|
|
|
+ ├─ scope_type=1 → 店铺内商品全额计入
|
|
|
|
|
+ ├─ scope_type=2 → 仅商品所属分类(含父级)在 scope 表中的 SKU 计入
|
|
|
|
|
+ └─ scope_type=3 → 仅 productId 在 scope 表中的 SKU 计入
|
|
|
|
|
+ → 得到 eligibleAmount(活动适用商品小计,不含运费/服务费)
|
|
|
|
|
+ ↓
|
|
|
|
|
+【第3步】用户参与校验:usage 表 count(user_id) WHERE usage_status=1
|
|
|
|
|
+ ↓
|
|
|
|
|
+【第4步】阶梯计算:按 eligibleAmount 匹配档位(非整单 totalPrice)
|
|
|
|
|
+ ├─ 选出满足门槛的最高档位
|
|
|
|
|
+ ├─ 若 is_capped=1(上不封顶),继续按最后一档比例叠加
|
|
|
|
|
+ └─ 返回优惠后金额
|
|
|
|
|
+ ↓
|
|
|
|
|
+返回适用活动列表 + 优惠计算结果
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### Service 层核心方法签名
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+/**
|
|
|
|
|
+ * 查询结算页适用活动及优惠计算
|
|
|
|
|
+ * @param storeId 店铺ID
|
|
|
|
|
+ * @param userId 用户ID
|
|
|
|
|
+ * @param orderItems 订单商品列表(含价格、分类ID)
|
|
|
|
|
+ * @return 活动列表及优惠计算结果
|
|
|
|
|
+ */
|
|
|
|
|
+List<PromotionComputeResult> computeApplicablePromotions(
|
|
|
|
|
+ Long storeId, Long userId, List<OrderItemParam> orderItems);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 档位匹配算法
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+// 输入:订单总金额 totalAmount,档位列表 tiers(已按 sort_order ASC 排序)
|
|
|
|
|
+// 输出:命中的最优档位
|
|
|
|
|
+PromotionTier matchTier(BigDecimal totalAmount, List<PromotionTier> tiers) {
|
|
|
|
|
+ PromotionTier matched = null;
|
|
|
|
|
+ for (PromotionTier tier : tiers) {
|
|
|
|
|
+ if (totalAmount.compareTo(tier.getThresholdAmount()) >= 0) {
|
|
|
|
|
+ matched = tier; // 持续覆盖,最后保留匹配的最高档
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return matched;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 上不封顶计算(is_capped=1):
|
|
|
|
|
+// 例:档位 满599减150;eligibleAmount=1198
|
|
|
|
|
+// 基础优惠 = 150(命中最高档)
|
|
|
|
|
+// 超出部分 = 1198 - 599 = 599
|
|
|
|
|
+// 额外优惠 = floor(599 / 599) * 150 = 150
|
|
|
|
|
+// 总优惠 = 300
|
|
|
|
|
+// 公式:extra = floor((eligibleAmount - lastThreshold) / lastThreshold) * lastDiscount
|
|
|
|
|
+// totalDiscount = lastDiscount + extra(eligibleAmount >= lastThreshold 时)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**多活动命中策略**:同一店铺同一时间若存在多个适用范围重叠的活动(管理端应禁止启用冲突活动),用户端默认取**优惠金额最大**的一条;实现时在 Service 层遍历候选活动后 `maxBy(discountAmount)`。
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 下单后端校验流程
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /api/store/promotion/order/submit
|
|
|
|
|
+ ↓
|
|
|
|
|
+1. 【参数校验】校验请求参数完整性
|
|
|
|
|
+ ↓
|
|
|
|
|
+2. 【活动时间校验】
|
|
|
|
|
+ SELECT * FROM fs_store_promotion_activity
|
|
|
|
|
+ WHERE id = #{activityId} AND manual_status = 1
|
|
|
|
|
+ AND start_time <= NOW() AND end_time >= NOW()
|
|
|
|
|
+ → 无结果则抛出 "活动已结束,请重新下单"
|
|
|
|
|
+ → 若有结果但 end_time < NOW(),更新 status=3,然后抛异常
|
|
|
|
|
+ ↓
|
|
|
|
|
+3. 【参与次数校验】
|
|
|
|
|
+ SELECT COUNT(1) FROM fs_store_promotion_usage
|
|
|
|
|
+ WHERE activity_id = #{activityId} AND user_id = #{userId} AND usage_status = 1
|
|
|
|
|
+ → count >= limit_per_user (且 limit_per_user != 0)
|
|
|
|
|
+ → 抛出 "您已超过该活动参与次数限制"
|
|
|
|
|
+ ↓
|
|
|
|
|
+4. 【叠加规则校验】
|
|
|
|
|
+ → 若 is_stackable = 0 且该订单使用了优惠券
|
|
|
|
|
+ → 抛出 "该活动不可与优惠券叠加使用"
|
|
|
|
|
+ ↓
|
|
|
|
|
+5. 【优惠金额重算校验】
|
|
|
|
|
+ → 服务端重新计算优惠金额(基于DB最新时间)
|
|
|
|
|
+ → 对比客户端传来的优惠金额
|
|
|
|
|
+ → 不一致则抛出 "优惠信息已变更,请刷新后重试"
|
|
|
|
|
+ ↓
|
|
|
|
|
+6. 【创建订单 + 写入参与记录】
|
|
|
|
|
+ → 事务开始
|
|
|
|
|
+ → 创建 fs_store_order_scrm(写入 promotion_* 字段)
|
|
|
|
|
+ → 插入 usage(usage_status=0 待支付)
|
|
|
|
|
+ → 事务提交
|
|
|
|
|
+ ↓
|
|
|
|
|
+7. 【支付成功回调】→ 将 usage.usage_status 更新为 1(计次生效)
|
|
|
|
|
+ ↓
|
|
|
|
|
+8. 【返回订单创建成功】
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 乐观锁伪代码
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@Transactional
|
|
|
|
|
+public void deductUsage(Long activityId, Long userId, Long orderId,
|
|
|
|
|
+ BigDecimal orderAmount, BigDecimal discountAmount, Long tierId) {
|
|
|
|
|
+ // 1. 先查询已使用次数(在事务内,SELECT FOR UPDATE 精确到活动+用户维度)
|
|
|
|
|
+ // 或者使用 version 乐观锁
|
|
|
|
|
+
|
|
|
|
|
+ FsStorePromotionActivity activity = activityMapper.selectById(activityId);
|
|
|
|
|
+ int usageCount = usageMapper.countByActivityIdAndUserId(activityId, userId);
|
|
|
|
|
+
|
|
|
|
|
+ if (activity.getLimitPerUser() > 0 && usageCount >= activity.getLimitPerUser()) {
|
|
|
|
|
+ throw new ServiceException("参与次数已达上限");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 乐观锁更新:通过 version 保证不会重复扣减
|
|
|
|
|
+ int updated = usageMapper.insertWithVersion(usage);
|
|
|
|
|
+ if (updated == 0) {
|
|
|
|
|
+ throw new ServiceException("活动繁忙,请重试");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.3 管理端保存/修改后端校验
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+/**
|
|
|
|
|
+ * 保存或修改活动时的后端校验流程
|
|
|
|
|
+ */
|
|
|
|
|
+public void saveOrUpdateActivity(FsStorePromotionActivityDTO dto) {
|
|
|
|
|
+ // 1. 基本校验
|
|
|
|
|
+ if (dto.getStartTime().isAfter(dto.getEndTime())) {
|
|
|
|
|
+ throw new ServiceException("开始时间不能晚于结束时间");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 编辑时状态校验
|
|
|
|
|
+ if (dto.getId() != null) {
|
|
|
|
|
+ FsStorePromotionActivity exist = getById(dto.getId());
|
|
|
|
|
+
|
|
|
|
|
+ // 进行中的活动不允许修改时间和档位
|
|
|
|
|
+ if (isActive(exist)) {
|
|
|
|
|
+ if (isFieldChanged(dto.getStartTime(), exist.getStartTime())
|
|
|
|
|
+ || isFieldChanged(dto.getEndTime(), exist.getEndTime())
|
|
|
|
|
+ || isFieldChanged(dto.getScopeType(), exist.getScopeType())) {
|
|
|
|
|
+ throw new ServiceException("进行中的活动不可修改时间和适用范围");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 允许修改结束时间(仅可延后)
|
|
|
|
|
+ if (dto.getEndTime().isBefore(exist.getEndTime())) {
|
|
|
|
|
+ throw new ServiceException("只能延后结束时间,不能提前");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 时间+范围冲突检测:同一店铺、时间重叠、且 scope 有交集的「已启用」活动不可并存
|
|
|
|
|
+ int conflictCount = activityMapper.countConflictActivity(
|
|
|
|
|
+ dto.getStoreId(), dto.getStartTime(), dto.getEndTime(),
|
|
|
|
|
+ dto.getScopeType(), dto.getScopeIds(), dto.getId()
|
|
|
|
|
+ );
|
|
|
|
|
+ if (conflictCount > 0) {
|
|
|
|
|
+ throw new ServiceException("该时间段内已存在适用范围重叠的满减活动,请调整时间或范围");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 档位校验
|
|
|
|
|
+ List<PromotionTierDTO> tiers = dto.getTiers();
|
|
|
|
|
+ if (tiers == null || tiers.size() < 1) {
|
|
|
|
|
+ throw new ServiceException("至少设置一档满减规则");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (int i = 1; i < tiers.size(); i++) {
|
|
|
|
|
+ if (tiers.get(i).getThresholdAmount().compareTo(tiers.get(i-1).getThresholdAmount()) <= 0) {
|
|
|
|
|
+ throw new ServiceException("第" + (i+1) + "档门槛金额必须大于第" + i + "档");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (tiers.get(i).getDiscountAmount().compareTo(tiers.get(i-1).getDiscountAmount()) < 0) {
|
|
|
|
|
+ throw new ServiceException("第" + (i+1) + "档优惠金额不能小于第" + i + "档");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 适用范围校验
|
|
|
|
|
+ if (dto.getScopeType() == 2 && (dto.getScopeIds() == null || dto.getScopeIds().isEmpty())) {
|
|
|
|
|
+ throw new ServiceException("选择指定分类时,请至少选择一个分类");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dto.getScopeType() == 3 && (dto.getScopeIds() == null || dto.getScopeIds().isEmpty())) {
|
|
|
|
|
+ throw new ServiceException("选择指定商品时,请至少选择一个商品");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 5. 接口设计
|
|
|
|
|
+
|
|
|
|
|
+### 5.1 管理端接口
|
|
|
|
|
+
|
|
|
|
|
+> 基于现有 Controller 模式:`@RestController + @RequestMapping + @PreAuthorize`
|
|
|
|
|
+> 模块路径:`D:\hdProject\ylrz_his_scrm_java\fs-admin\src\main\java\com\fs\hisStore\controller\`
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.1 活动列表查询
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+GET /store/storePromotion/list
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**请求参数**:
|
|
|
|
|
+
|
|
|
|
|
+| 参数 | 类型 | 必填 | 说明 |
|
|
|
|
|
+|------|------|------|------|
|
|
|
|
|
+| title | String | 否 | 活动名称(模糊搜索) |
|
|
|
|
|
+| storeId | Long | 否 | 店铺ID |
|
|
|
|
|
+| status | Integer | 否 | 活动状态:0草稿 1未开始 2进行中 3已结束 4已关闭 |
|
|
|
|
|
+| startTime | Date | 否 | 开始时间(范围起) |
|
|
|
|
|
+| endTime | Date | 否 | 结束时间(范围止) |
|
|
|
|
|
+| pageNum | Integer | 是 | 页码 |
|
|
|
|
|
+| pageSize | Integer | 是 | 每页条数 |
|
|
|
|
|
+
|
|
|
|
|
+**响应示例**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "code": 200,
|
|
|
|
|
+ "rows": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 1,
|
|
|
|
|
+ "title": "618年中大促",
|
|
|
|
|
+ "storeId": 100,
|
|
|
|
|
+ "storeName": "旗舰店",
|
|
|
|
|
+ "startTime": "2026-06-01 00:00:00",
|
|
|
|
|
+ "endTime": "2026-06-30 23:59:59",
|
|
|
|
|
+ "scopeType": 1,
|
|
|
|
|
+ "scopeTypeLabel": "全场通用",
|
|
|
|
|
+ "isStackable": 1,
|
|
|
|
|
+ "isCapped": 0,
|
|
|
|
|
+ "limitPerUser": 3,
|
|
|
|
|
+ "displayStatus": 2,
|
|
|
|
|
+ "displayStatusLabel": "进行中",
|
|
|
|
|
+ "tierCount": 3,
|
|
|
|
|
+ "createTime": "2026-05-20 10:00:00"
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**后端注意**:`displayStatus` 为 SQL 查询时动态计算字段,使用 `CASE WHEN` 根据 `manual_status` + 时间范围实时计算:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- display_status 动态计算(列表 SELECT 子句)
|
|
|
|
|
+SELECT
|
|
|
|
|
+ a.*,
|
|
|
|
|
+ CASE
|
|
|
|
|
+ WHEN a.manual_status IS NULL THEN 0
|
|
|
|
|
+ WHEN a.manual_status = 0 THEN 4
|
|
|
|
|
+ WHEN a.manual_status = 1 AND NOW() < a.start_time THEN 1
|
|
|
|
|
+ WHEN a.manual_status = 1 AND NOW() >= a.start_time AND NOW() <= a.end_time THEN 2
|
|
|
|
|
+ WHEN a.manual_status = 1 AND NOW() > a.end_time THEN 3
|
|
|
|
|
+ ELSE 0
|
|
|
|
|
+ END AS display_status
|
|
|
|
|
+FROM fs_store_promotion_activity a
|
|
|
|
|
+WHERE a.is_del = 0
|
|
|
|
|
+-- 按 display_status 筛选时使用相同 CASE 表达式或 HAVING display_status = #{status}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.2 新增活动
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /store/storePromotion
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**请求体**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "title": "618年中大促",
|
|
|
|
|
+ "storeId": 100,
|
|
|
|
|
+ "startTime": "2026-06-01 00:00:00",
|
|
|
|
|
+ "endTime": "2026-06-30 23:59:59",
|
|
|
|
|
+ "scopeType": 2,
|
|
|
|
|
+ "scopeIds": [10, 20, 30],
|
|
|
|
|
+ "isStackable": 1,
|
|
|
|
|
+ "isCapped": 0,
|
|
|
|
|
+ "limitPerUser": 3,
|
|
|
|
|
+ "tiers": [
|
|
|
|
|
+ {"sortOrder": 1, "thresholdAmount": 199.00, "discountAmount": 30.00},
|
|
|
|
|
+ {"sortOrder": 2, "thresholdAmount": 399.00, "discountAmount": 80.00},
|
|
|
|
|
+ {"sortOrder": 3, "thresholdAmount": 599.00, "discountAmount": 150.00}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "remark": ""
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{"code": 200, "msg": "操作成功"}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.3 编辑活动
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+PUT /store/storePromotion
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+请求体同上,增加 `"id": 1`。
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.4 查询活动详情
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+GET /store/storePromotion/{activityId}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应**:返回活动完整信息,含档位列表、适用范围明细。
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.5 删除活动
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+DELETE /store/storePromotion/{activityIds}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+逻辑删除(`is_del = 1`),仅草稿/已结束/已关闭状态可删。
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.6 启用活动
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+PUT /store/storePromotion/{activityId}/enable
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**前置校验**:`end_time >= NOW()`,否则返回错误。
|
|
|
|
|
+
|
|
|
|
|
+**响应**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{"code": 200, "msg": "操作成功", "data": {"newDisplayStatus": 2}}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.7 停用活动
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+PUT /store/storePromotion/{activityId}/disable
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**前置校验**:仅未开始/进行中状态可停用。
|
|
|
|
|
+
|
|
|
|
|
+**响应**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{"code": 200, "msg": "操作成功", "data": {"newDisplayStatus": 4}}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1.8 店铺列表(下拉选项)
|
|
|
|
|
+
|
|
|
|
|
+复用现有店铺接口,**无需新增**:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+GET /store/his/store/listOption
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+前端引用:`@/api/hisStore/store.js` → `listOption()`。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 5.2 用户端接口(结算活动查询 + 订单链路扩展)
|
|
|
|
|
+
|
|
|
|
|
+> **Controller**:`fs-user-app` → `StoreOrderScrmController`,基础路径 `/store/app/storeOrder`
|
|
|
|
|
+> **Service**:`fs-service` → 新增 `IFsStorePromotionComputeService`(或并入 `FsStoreOrderScrmServiceImpl`),供 list/computed/create 共用
|
|
|
|
|
+> 购物车数据仍通过 Redis `orderKey` / `orderCarts:{orderKey}` 读取(与 `confirmOrder`、`computedOrder` 一致)
|
|
|
|
|
+
|
|
|
|
|
+#### 5.2.1 结算页查询可用满减活动(★新增)
|
|
|
|
|
+
|
|
|
|
|
+**单店铺**
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /store/app/storeOrder/promotion/list
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+| 属性 | 说明 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| 鉴权 | `@Login`,与 `/computed` 相同 |
|
|
|
|
|
+| 用途 | 结算页进入时拉取当前订单可参与的活动列表,供用户选择 |
|
|
|
|
|
+
|
|
|
|
|
+**请求体** `FsStorePromotionListParam`:
|
|
|
|
|
+
|
|
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
|
|
+|------|------|:--:|------|
|
|
|
|
|
+| orderKey | String | 是 | confirm 返回的订单缓存 key |
|
|
|
|
|
+| storeId | Long | 否 | 单店购物车可不传,由 carts 推断 |
|
|
|
|
|
+| couponUserId | Long | 否 | 已选优惠券 ID,用于标记 `stackableConflict` |
|
|
|
|
|
+
|
|
|
|
|
+**响应** `FsStorePromotionListVO`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "code": 200,
|
|
|
|
|
+ "data": {
|
|
|
|
|
+ "storeId": 100,
|
|
|
|
|
+ "storeName": "旗舰店",
|
|
|
|
|
+ "eligibleAmount": 498.00,
|
|
|
|
|
+ "recommendedActivityId": 1,
|
|
|
|
|
+ "activities": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "activityId": 1,
|
|
|
|
|
+ "title": "618年中大促",
|
|
|
|
|
+ "scopeType": 1,
|
|
|
|
|
+ "scopeTypeLabel": "全场通用",
|
|
|
|
|
+ "isStackable": true,
|
|
|
|
|
+ "isCapped": false,
|
|
|
|
|
+ "limitPerUser": 3,
|
|
|
|
|
+ "userUsedCount": 1,
|
|
|
|
|
+ "userRemainCount": 2,
|
|
|
|
|
+ "enabled": true,
|
|
|
|
|
+ "disabledReason": null,
|
|
|
|
|
+ "matchedTier": {
|
|
|
|
|
+ "tierId": 2,
|
|
|
|
|
+ "thresholdAmount": 399.00,
|
|
|
|
|
+ "discountAmount": 80.00
|
|
|
|
|
+ },
|
|
|
|
|
+ "nextTierTip": "再购101元可减150元",
|
|
|
|
|
+ "estimatedDiscount": 80.00,
|
|
|
|
|
+ "tiers": [
|
|
|
|
|
+ {"sortOrder": 1, "thresholdAmount": 199.00, "discountAmount": 30.00},
|
|
|
|
|
+ {"sortOrder": 2, "thresholdAmount": 399.00, "discountAmount": 80.00},
|
|
|
|
|
+ {"sortOrder": 3, "thresholdAmount": 599.00, "discountAmount": 150.00}
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "activityId": 2,
|
|
|
|
|
+ "title": "指定品类满减",
|
|
|
|
|
+ "enabled": false,
|
|
|
|
|
+ "disabledReason": "适用商品金额未达最低门槛",
|
|
|
|
|
+ "estimatedDiscount": 0
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**后端逻辑**:
|
|
|
|
|
+
|
|
|
|
|
+1. 从 Redis 读 `orderCarts:{orderKey}`,校验未过期
|
|
|
|
|
+2. 按 `storeId` 查生效活动(查即验证 + 缓存)
|
|
|
|
|
+3. 逐活动计算 `eligibleAmount`、匹配档位、`estimatedDiscount`
|
|
|
|
|
+4. `recommendedActivityId` = `enabled=true` 中 `estimatedDiscount` 最大者
|
|
|
|
|
+5. 校验用户 `usage_status=1` 次数 → 填充 `userRemainCount`
|
|
|
|
|
+
|
|
|
|
|
+**多店铺**
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /store/app/storeOrder/promotion/listMultiStore
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**请求体** `FsStorePromotionListMultiStoreParam`:
|
|
|
|
|
+
|
|
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
|
|
+|------|------|:--:|------|
|
|
|
|
|
+| orderKeys | List\<String\> | 是 | 与 `computedMultiStore` 一致,每店一个 orderKey |
|
|
|
|
|
+| couponUserId | Long | 否 | 全局优惠券(若业务按店分券则扩展为 Map) |
|
|
|
|
|
+
|
|
|
|
|
+**响应**:`List<FsStorePromotionListVO>`,每个元素对应一个店铺的活动列表。
|
|
|
|
|
+
|
|
|
|
|
+**Controller 伪代码**:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@Login
|
|
|
|
|
+@ApiOperation("结算页-查询可用满减活动")
|
|
|
|
|
+@PostMapping("/promotion/list")
|
|
|
|
|
+public R promotionList(@Validated @RequestBody FsStorePromotionListParam param) {
|
|
|
|
|
+ Long userId = Long.parseLong(getUserId());
|
|
|
|
|
+ return R.ok().put("data", promotionComputeService.listApplicablePromotions(userId, param));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@Login
|
|
|
|
|
+@ApiOperation("结算页-多店铺查询可用满减活动")
|
|
|
|
|
+@PostMapping("/promotion/listMultiStore")
|
|
|
|
|
+public R promotionListMultiStore(@Validated @RequestBody FsStorePromotionListMultiStoreParam param) {
|
|
|
|
|
+ Long userId = Long.parseLong(getUserId());
|
|
|
|
|
+ return R.ok().put("data", promotionComputeService.listApplicablePromotionsMultiStore(userId, param));
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5.2.2 结算页查询活动详情(可选)
|
|
|
|
|
+
|
|
|
|
|
+用于「查看规则 >」弹窗,避免 list 重复传完整 tiers:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+GET /store/app/storeOrder/promotion/{activityId}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**Query**:`orderKey`(校验该活动对当前购物车是否适用)
|
|
|
|
|
+
|
|
|
|
|
+**响应**:活动规则 + 全部档位 + 当前匹配结果。
|
|
|
|
|
+
|
|
|
|
|
+#### 5.2.3 扩展 Param / DTO 字段
|
|
|
|
|
+
|
|
|
|
|
+**FsStoreOrderComputedParam** 新增:
|
|
|
|
|
+
|
|
|
|
|
+| 字段 | 类型 | 说明 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| promotionActivityId | Long | 用户选中的满减活动 ID;**null = 不参与满减** |
|
|
|
|
|
+| storeId | Long | 单店铺计算时指定店铺 |
|
|
|
|
|
+
|
|
|
|
|
+**FsStoreOrderComputedGroupStoreParam** 扩展(多店铺):
|
|
|
|
|
+
|
|
|
|
|
+| 字段 | 类型 | 说明 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| promotionActivityIds | List\<PromotionStoreActivityParam\> | 每店选中活动,`[{storeId, promotionActivityId}]` |
|
|
|
|
|
+
|
|
|
|
|
+**FsStoreOrderComputeDTO** 新增:
|
|
|
|
|
+
|
|
|
|
|
+| 字段 | 类型 | 说明 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| promotionActivityId | Long | 命中的活动 ID |
|
|
|
|
|
+| promotionTierId | Long | 命中档位 ID |
|
|
|
|
|
+| promotionDiscountAmount | BigDecimal | 满减金额 |
|
|
|
|
|
+| promotionTitle | String | 活动名称 |
|
|
|
|
|
+| promotionRemainCount | Integer | 剩余可参与次数(null=不限) |
|
|
|
|
|
+
|
|
|
|
|
+**FsStoreOrderCreateParam** / **FsStoreOrderCreateGroupStoreParam** 同步增加上述字段。
|
|
|
|
|
+
|
|
|
|
|
+**fs-service 新增类**(附录 A 补充):
|
|
|
|
|
+
|
|
|
|
|
+- `param/FsStorePromotionListParam.java`
|
|
|
|
|
+- `param/FsStorePromotionListMultiStoreParam.java`
|
|
|
|
|
+- `param/PromotionStoreActivityParam.java`
|
|
|
|
|
+- `vo/FsStorePromotionListVO.java`
|
|
|
|
|
+- `vo/FsStorePromotionActivityItemVO.java`
|
|
|
|
|
+
|
|
|
|
|
+#### 5.2.4 计算订单金额(现有接口扩展)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /store/app/storeOrder/computed
|
|
|
|
|
+POST /store/app/storeOrder/computedMultiStore
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**请求体扩展示例**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "orderKey": "abc123",
|
|
|
|
|
+ "addressId": 1,
|
|
|
|
|
+ "couponUserId": null,
|
|
|
|
|
+ "useIntegral": 0,
|
|
|
|
|
+ "promotionActivityId": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应扩展示例**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "code": 200,
|
|
|
|
|
+ "data": {
|
|
|
|
|
+ "totalPrice": 498.00,
|
|
|
|
|
+ "payPrice": 418.00,
|
|
|
|
|
+ "payPostage": 0,
|
|
|
|
|
+ "deductionPrice": 0,
|
|
|
|
|
+ "promotionActivityId": 1,
|
|
|
|
|
+ "promotionTierId": 2,
|
|
|
|
|
+ "promotionDiscountAmount": 80.00,
|
|
|
|
|
+ "promotionTitle": "618年中大促",
|
|
|
|
|
+ "promotionRemainCount": 2
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**Service 内处理要点**:
|
|
|
|
|
+
|
|
|
|
|
+1. 与 `promotion/list` 共用 `computeEligibleAmount`、`matchTier` 方法
|
|
|
|
|
+2. `promotionActivityId` 非空时:`validateAndExpire` → 重算满减 → 扣减 `payPrice`
|
|
|
|
|
+3. `is_stackable=0` 且已选优惠券 → 抛错「不可与优惠券叠加」
|
|
|
|
|
+4. 满减在积分之后、优惠券之前(见 0.3 节)
|
|
|
|
|
+
|
|
|
|
|
+#### 5.2.5 创建订单(现有接口扩展)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /store/app/storeOrder/create
|
|
|
|
|
+POST /store/app/storeOrder/createMultiStore
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**请求体扩展**:`promotionActivityId`、`promotionTierId`(金额以服务端重算为准)。
|
|
|
|
|
+
|
|
|
|
|
+**处理流程**:`createOrder` 内再次执行满减逻辑 → 写 `fs_store_order_scrm.promotion_*` → 写 usage(待支付)。
|
|
|
|
|
+
|
|
|
|
|
+**错误响应**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{"code": 500, "msg": "活动已结束,请重新下单"}
|
|
|
|
|
+{"code": 500, "msg": "您已超过该活动参与次数限制"}
|
|
|
|
|
+{"code": 500, "msg": "该活动不可与优惠券叠加使用"}
|
|
|
|
|
+{"code": 500, "msg": "优惠信息已变更,请刷新后重试"}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5.2.6 用户端 API 封装示例(uni-app)
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+// api/store/order.js(用户端工程,路径以实际项目为准)
|
|
|
|
|
+
|
|
|
|
|
+/** 结算页-查询可用满减活动(单店) */
|
|
|
|
|
+export function listSettlementPromotions(data) {
|
|
|
|
|
+ return request({ url: '/store/app/storeOrder/promotion/list', method: 'post', data })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 结算页-查询可用满减活动(多店) */
|
|
|
|
|
+export function listSettlementPromotionsMultiStore(data) {
|
|
|
|
|
+ return request({ url: '/store/app/storeOrder/promotion/listMultiStore', method: 'post', data })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 计算金额(扩展 promotionActivityId) */
|
|
|
|
|
+export function computedOrder(data) {
|
|
|
|
|
+ return request({ url: '/store/app/storeOrder/computed', method: 'post', data })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 6. 非功能性设计
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 缓存策略与更新机制
|
|
|
|
|
+
|
|
|
|
|
+#### 6.1.1 缓存架构
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+ 用户请求
|
|
|
|
|
+ │
|
|
|
|
|
+ ▼
|
|
|
|
|
+ Redis 缓存层
|
|
|
|
|
+ (店铺维度Key)
|
|
|
|
|
+ │
|
|
|
|
|
+ ┌─ 命中 ───▶ 返回
|
|
|
|
|
+ │
|
|
|
|
|
+ └─ 未命中
|
|
|
|
|
+ │
|
|
|
|
|
+ ▼
|
|
|
|
|
+ MySQL 查询
|
|
|
|
|
+ (当前生效活动)
|
|
|
|
|
+ │
|
|
|
|
|
+ ▼
|
|
|
|
|
+ 写入 Redis
|
|
|
|
|
+ (TTL = 5分钟)
|
|
|
|
|
+ │
|
|
|
|
|
+ ▼
|
|
|
|
|
+ 返回结果
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 6.1.2 Redis Key 设计
|
|
|
|
|
+
|
|
|
|
|
+| Key 模式 | 说明 | TTL |
|
|
|
|
|
+|----------|------|-----|
|
|
|
|
|
+| `promotion:store:{storeId}` | 店铺当前生效的活动列表(含档位) | 5 分钟 |
|
|
|
|
|
+| `promotion:detail:{activityId}` | 单个活动详情(含档位+范围) | 10 分钟 |
|
|
|
|
|
+| `promotion:usage:{activityId}:{userId}` | 用户当前已参与次数 | 与 DB 同步,30 秒 |
|
|
|
|
|
+
|
|
|
|
|
+#### 6.1.3 缓存更新策略
|
|
|
|
|
+
|
|
|
|
|
+| 触发场景 | 更新操作 |
|
|
|
|
|
+|---------|---------|
|
|
|
|
|
+| 管理端新增/编辑活动 | 删除 `promotion:store:{storeId}`,下次请求时懒加载 |
|
|
|
|
|
+| 管理端启用/停用活动 | 同上 |
|
|
|
|
|
+| 用户下单使用活动 | 更新 `promotion:usage:{activityId}:{userId}`(INCR) |
|
|
|
|
|
+| 定时任务检测到活动过期 | 删除 `promotion:store:{storeId}` |
|
|
|
|
|
+| 缓存自然过期 | 自动失效,下次请求重新加载 |
|
|
|
|
|
+
|
|
|
|
|
+#### 6.1.4 缓存穿透/雪崩防护
|
|
|
|
|
+
|
|
|
|
|
+- **缓存穿透**:启用活动时,同时将 NULL 活动标记存到 Redis(TTL 30秒),避免不存在店铺的请求穿透到 DB
|
|
|
|
|
+- **缓存雪崩**:各 Key 的 TTL 加随机偏移量(±60秒),避免同时过期
|
|
|
|
|
+- **热点缓存**:针对大促期间的爆款店铺活动,使用本地缓存(Caffeine)作为二级缓存,TTL 1分钟
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@Service
|
|
|
|
|
+public class PromotionCacheService {
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private RedisCache redisCache; // 与 FsStoreOrderScrmServiceImpl 一致
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取店铺当前生效活动(查即验证 + 缓存)
|
|
|
|
|
+ * Key: promotion:store:{storeId}
|
|
|
|
|
+ */
|
|
|
|
|
+ public List<PromotionActivityVO> getActivePromotions(Long storeId) {
|
|
|
|
|
+ String redisKey = "promotion:store:" + storeId;
|
|
|
|
|
+ List<PromotionActivityVO> cached = redisCache.getCacheObject(redisKey);
|
|
|
|
|
+ if (cached != null) {
|
|
|
|
|
+ return cached;
|
|
|
|
|
+ }
|
|
|
|
|
+ cached = loadFromDBAndExpireCheck(storeId);
|
|
|
|
|
+ redisCache.setCacheObject(redisKey, cached, 5, TimeUnit.MINUTES);
|
|
|
|
|
+ return cached;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 管理端变更后主动失效 */
|
|
|
|
|
+ public void evictStorePromotion(Long storeId) {
|
|
|
|
|
+ redisCache.deleteObject("promotion:store:" + storeId);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 6.2 并发控制——防超卖与重复使用
|
|
|
|
|
+
|
|
|
|
|
+#### 6.2.1 核心机制:MySQL 乐观锁 + Redis 预占
|
|
|
|
|
+
|
|
|
|
|
+| 并发场景 | 机制 | 说明 |
|
|
|
|
|
+|---------|------|------|
|
|
|
|
|
+| 同一用户并发下单同一活动 | 乐观锁 + 唯一约束 | 通过 `version` 字段避免参与次数超限 |
|
|
|
|
|
+| 多用户并发抢同一活动 | 无库存概念 | 阶梯满减无固定库存,不需限制总量 |
|
|
|
|
|
+| 时间临界点并发 | `SELECT FOR UPDATE` + 时间二次校验 | 下单事务中严格校验时间边界 |
|
|
|
|
|
+
|
|
|
|
|
+#### 6.2.2 参与次数并发控制实现
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+public void recordPromotionUsage(Long activityId, Long userId,
|
|
|
|
|
+ Long orderId, BigDecimal discountAmount, Long tierId) {
|
|
|
|
|
+ // 1. 悲观锁锁定活动行,防止并发修改
|
|
|
|
|
+ FsStorePromotionActivity activity = activityMapper.selectForUpdate(activityId);
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 时间二次校验
|
|
|
|
|
+ LocalDateTime now = LocalDateTime.now();
|
|
|
|
|
+ if (activity.getManualStatus() != 1
|
|
|
|
|
+ || now.isBefore(activity.getStartTime())
|
|
|
|
|
+ || now.isAfter(activity.getEndTime())) {
|
|
|
|
|
+ throw new ServiceException("活动已失效");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 查询当前使用次数
|
|
|
|
|
+ Integer usageCount = usageMapper.countByActivityAndUser(activityId, userId);
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 次数校验
|
|
|
|
|
+ if (activity.getLimitPerUser() > 0 && usageCount >= activity.getLimitPerUser()) {
|
|
|
|
|
+ throw new ServiceException("参与次数已达上限");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 插入记录(带唯一索引防重)
|
|
|
|
|
+ try {
|
|
|
|
|
+ usageMapper.insert(new FsStorePromotionUsage()
|
|
|
|
|
+ .setActivityId(activityId)
|
|
|
|
|
+ .setUserId(userId)
|
|
|
|
|
+ .setOrderId(orderId)
|
|
|
|
|
+ .setDiscountAmount(discountAmount)
|
|
|
|
|
+ .setTierId(tierId));
|
|
|
|
|
+ } catch (DuplicateKeyException e) {
|
|
|
|
|
+ throw new ServiceException("请勿重复提交");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**建议**:`fs_store_promotion_usage` 表唯一索引(已纳入 3.4 建表语句):
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+UNIQUE KEY `uk_activity_order` (`activity_id`, `order_id`)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 6.2.3 Redis 分布式锁(可选增强)
|
|
|
|
|
+
|
|
|
|
|
+对于高并发场景,在下单入口增加 Redis 分布式锁(Redisson),锁粒度为 `promotion:lock:{activityId}:{userId}`,超时时间 10 秒。保证同一用户同一活动串行处理。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 6.3 异常情况处理
|
|
|
|
|
+
|
|
|
|
|
+#### 6.3.1 手动关停时,已下单但未支付订单处理
|
|
|
|
|
+
|
|
|
|
|
+| 订单状态 | 处理策略 | 实现方式 |
|
|
|
|
|
+|---------|---------|---------|
|
|
|
|
|
+| 已支付 | 不受影响,活动优惠保留 | 订单快照字段 `promotion_discount_amount` 已写入 |
|
|
|
|
|
+| 未支付 - 超时未付 | 订单自动取消,usage 置为已回滚 | 现有订单超时任务中 `UPDATE usage SET usage_status=2` |
|
|
|
|
|
+| 未支付 - 用户主动取消 | 同上 | 取消订单接口同步回滚 usage |
|
|
|
|
|
+| 未支付 - 用户继续支付 | **允许支付**,以订单创建时快照为准 | 支付回调不重复校验活动时效 |
|
|
|
|
|
+
|
|
|
|
|
+**设计理由**:
|
|
|
|
|
+- 下单时 `fs_store_order` 表中已有 `discount_amount`(折扣金额)字段,订单是活动优惠的快照
|
|
|
|
|
+- 关停活动不应影响已创建的合法订单,否则引起大量客诉
|
|
|
|
|
+- 取消订单时回滚 usage 记录,归还参与次数
|
|
|
|
|
+
|
|
|
|
|
+#### 6.3.2 支付时活动已结束的处理
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+支付回调 → 仅校验支付金额与订单金额一致 → 完成支付
|
|
|
|
|
+(不再次校验活动状态,以订单创建时的计算结果为准)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`fs_store_order_scrm` 表已有字段:
|
|
|
|
|
+- `discountMoney`(BigDecimal):订单优惠总金额(可与满减+券合计或分项存储,需与财务口径对齐)
|
|
|
|
|
+- `couponPrice`(BigDecimal):优惠券金额
|
|
|
|
|
+- `userCouponId`(Long):使用的用户优惠券 ID
|
|
|
|
|
+
|
|
|
|
|
+本次新增字段见 [3.7 订单表扩展](#37-订单表扩展fs_store_order_scrm)。
|
|
|
|
|
+
|
|
|
|
|
+#### 6.3.3 其他异常场景
|
|
|
|
|
+
|
|
|
|
|
+| 异常场景 | 处理策略 |
|
|
|
|
|
+|---------|---------|
|
|
|
|
|
+| Redis 不可用 | 降级为直接查 DB,活动查询不受影响 |
|
|
|
|
|
+| 定时任务未执行 | 查即验证兜底,用户请求时触发状态更新 |
|
|
|
|
|
+| 数据库主从延迟 | 下单时使用主库读写;查询时若读从库允许短暂延迟 |
|
|
|
|
|
+| 活动修改并发冲突 | `version` 乐观锁,后提交者收到冲突提示 |
|
|
|
|
|
+| 订单部分退款 | 不退优惠金额(满减优惠为整单优惠),按商品实付金额比例退款 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 附录
|
|
|
|
|
+
|
|
|
|
|
+### A. 文件目录规划
|
|
|
|
|
+
|
|
|
|
|
+**后端 Java 文件**(模块:`fs-admin` + `fs-service`,Domain 均在 `fs-service`):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+fs-admin/src/main/java/com/fs/hisStore/controller/
|
|
|
|
|
+└── FsStorePromotionController.java ← 管理端 CRUD / 启用停用
|
|
|
|
|
+
|
|
|
|
|
+fs-service/src/main/java/com/fs/hisStore/
|
|
|
|
|
+├── domain/ ...(同前)
|
|
|
|
|
+├── param/
|
|
|
|
|
+│ ├── FsStorePromotionListParam.java ← 结算页 list 入参
|
|
|
|
|
+│ ├── FsStorePromotionListMultiStoreParam.java
|
|
|
|
|
+│ └── PromotionStoreActivityParam.java ← 多店选中活动
|
|
|
|
|
+├── vo/
|
|
|
|
|
+│ ├── FsStorePromotionListVO.java
|
|
|
|
|
+│ └── FsStorePromotionActivityItemVO.java
|
|
|
|
|
+├── service/
|
|
|
|
|
+│ ├── IFsStorePromotionComputeService.java ← list + 计算共用
|
|
|
|
|
+│ └── impl/FsStorePromotionComputeServiceImpl.java
|
|
|
|
|
+└── ...
|
|
|
|
|
+
|
|
|
|
|
+fs-service/.../FsStoreOrderScrmServiceImpl.java ← computedOrder / createOrder 调用 ComputeService
|
|
|
|
|
+
|
|
|
|
|
+fs-user-app/.../StoreOrderScrmController.java ← 新增 promotion/list、listMultiStore;扩展 computed/create
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**用户端 uni-app(APP / 小程序,工程路径以各环境仓库为准)**:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+pages/store/order/confirm.vue ← 单店结算页(或项目内等价路径)
|
|
|
|
|
+pages/store/order/confirmMulti.vue ← 多店结算页
|
|
|
|
|
+api/store/order.js ← listSettlementPromotions / computed / create
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**前端 Vue 文件**(`D:\web\cs\ylrz_his_scrm_adminUI\src`):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+src/
|
|
|
|
|
+├── api/hisStore/
|
|
|
|
|
+│ └── storePromotion.js ← API 接口封装
|
|
|
|
|
+├── views/hisStore/storePromotion/
|
|
|
|
|
+│ ├── index.vue ← 活动列表页
|
|
|
|
|
+│ ├── add.vue ← 新增活动页
|
|
|
|
|
+│ ├── edit.vue ← 编辑活动页
|
|
|
|
|
+│ └── detail.vue ← 活动详情页
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### B. 档位枚举与常量定义
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+// PromotionScopeTypeEnum.java
|
|
|
|
|
+public enum PromotionScopeTypeEnum {
|
|
|
|
|
+ ALL(1, "全场通用"),
|
|
|
|
|
+ CATEGORY(2, "指定分类"),
|
|
|
|
|
+ PRODUCT(3, "指定商品");
|
|
|
|
|
+
|
|
|
|
|
+ private final Integer code;
|
|
|
|
|
+ private final String desc;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// PromotionDisplayStatusEnum.java
|
|
|
|
|
+public enum PromotionDisplayStatusEnum {
|
|
|
|
|
+ DRAFT(0, "草稿"),
|
|
|
|
|
+ NOT_STARTED(1, "未开始"),
|
|
|
|
|
+ ACTIVE(2, "进行中"),
|
|
|
|
|
+ ENDED(3, "已结束"),
|
|
|
|
|
+ CLOSED(4, "已关闭");
|
|
|
|
|
+
|
|
|
|
|
+ private final Integer code;
|
|
|
|
|
+ private final String desc;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### C. 现有系统集成注意事项
|
|
|
|
|
+
|
|
|
|
|
+1. **权限控制**:管理端 `@PreAuthorize("@ss.hasPermi('store:storePromotion:list')")` 等,命名对齐 `store:storeCoupon:*`
|
|
|
|
|
+2. **操作日志**:`@Log(title = "阶梯满减", businessType = BusinessType.xxx)`
|
|
|
|
|
+3. **BaseEntity**:活动主表实体继承 `BaseEntity`,档位/范围/usage 表仅保留必要时间字段
|
|
|
|
|
+4. **事务**:`@Transactional(rollbackFor = Exception.class)`,与 `FsStoreOrderScrmServiceImpl.createOrder` 一致
|
|
|
|
|
+5. **订单扩展**:见 3.7 节,Java 实体 `FsStoreOrderScrm` 同步增加属性
|
|
|
|
|
+6. **优惠券模块参考**:UI/交互可对齐 `views/hisStore/storeCoupon/index.vue`;Controller 对齐 `FsStoreCouponScrmController`(`/store/storeCoupon`)
|
|
|
|
|
+7. **管理端路由前缀**:`/store/storePromotion`,与 `/store/storeCoupon` 并列
|
|
|
|
|
+8. **菜单 SQL**:在 `sys_menu` 新增「阶梯满减」菜单,父级挂在现有 hisStore 商城菜单下
|
|
|
|
|
+9. **多店铺下单**:必须测试 `promotion/listMultiStore`、`computedMultiStore`、`createMultiStore`
|
|
|
|
|
+10. **查即验证**:在 `promotion/list`、`computedOrder`、`createOrder`、管理端详情接口调用统一 `validateAndExpire`
|
|
|
|
|
+11. **结算页改造**:APP 与小程序共用 uni-app 结算页,进入页先调 `promotion/list`,切换活动再调 `computed`(见 1.4 节)
|
|
|
|
|
+12. **接口顺序**:`confirm` → `promotion/list` → `computed`(可多次)→ `create`,不可跳过 list 直接写死 activityId
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+> **文档结束** — v1.2.1 管理端后台已实现;用户端与下单链路见「实现进度追踪」待办项。
|