版本: 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 商城模块
| 模块 | 状态 | 路径 / 说明 |
|---|---|---|
| SQL 建表 + 订单字段 + 菜单 | ✅ 脚本已提供 | docs/sql/fs_store_promotion_init.sql(需 DBA 执行;菜单 parent_id 需调整) |
| 活动 Domain / DTO / VO | ✅ 已完成 | `fs-service/.../hisStore/domain |
| 活动 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 章 |
图例:✅ 已完成 ⬜ 未做
| 类别 | 原方案问题 | 优化方向 |
|---|---|---|
| 表名/关联 | 使用了 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) |
| 步骤 | 内容 | 模块 | 产出 |
|---|---|---|---|
| 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 | 时间临界点、叠加优惠券、多店铺、限次、手动关停、结算页选活动 |
购物车确认 → 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 待支付
优惠叠加顺序(建议,与现有积分/券逻辑一致):
is_stackable=1 且券可用)管理后台模块路径:
D:\hdProject\ylrz_his_scrm_java\fs-admin
前端代码路径:D:\web\cs\ylrz_his_scrm_adminUI\src\views\hisStore\storePromotion\
页面路由:/hisStore/storePromotion/index
采用若依框架标准 CRUD 页面布局(与现有 storeCoupon 模块一致):
showSearch)| 筛选字段 | 控件类型 | 说明 |
|---|---|---|
| 活动名称 | el-input |
支持模糊搜索 |
| 店铺 | el-select |
下拉选择,默认全部(当前登录用户有权限的店铺) |
| 活动状态 | el-select |
选项:全部/未开始/进行中/已结束/手动关闭/草稿 |
| 生效时间范围 | el-date-picker (daterange) |
按 start_time ~ end_time 范围筛选 |
| 字段 | 宽度 | 说明 |
|---|---|---|
| 活动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 | 编辑 / 启用/停用 / 删除 / 查看详情 |
| 状态值 | 显示文字 | Tag 颜色 | 说明 |
|---|---|---|---|
| 0 | 草稿 | info(灰) |
刚创建,尚未手动启用 |
| 1 | 未开始 | warning(橙) |
已启用但未到开始时间 |
| 2 | 进行中 | success(绿) |
当前时间在 [start, end] 内且是启用态 |
| 3 | 已结束 | info(灰) |
已过结束时间 |
| 4 | 已关闭 | danger(红) |
被手动关闭 |
| 按钮 | 草稿(0) | 未开始(1) | 进行中(2) | 已结束(3) | 已关闭(4) | |------|---------|-----------|-----------|-----------|-----------| | 编辑 | 显示 | 显示 | 显示(仅修改部分字段) | 隐藏 | 隐藏 | | 启用 | 显示 | 隐藏 | 隐藏 | 隐藏 | 显示 | | 停用 | 隐藏 | 显示 | 显示 | 隐藏 | 隐藏 | | 删除 | 显示 | 隐藏 | 隐藏 | 显示 | 显示 | | 查看详情 | 显示 | 显示 | 显示 | 显示 | 显示 |
is_del = 1)。// 前端计算展示状态的函数(需前后端双重保障)
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' };
}
}
页面路由:/hisStore/storePromotion/add、/hisStore/storePromotion/edit/:id
采用 Element UI 的 el-form 组件,分三个卡片区域(el-card):
┌──────────────────────────────────────────┐
│ 【基本信息】 │
│ 活动名称: [____________] │
│ 所属店铺: [下拉选择] 活动状态: 草稿 │
│ 开始时间: [日期时间选择器] │
│ 结束时间: [日期时间选择器] │
├──────────────────────────────────────────┤
│ 【满减阶梯档位】 [+ 添加一档] │
│ ┌─────────────────────────────────────┐ │
│ │ 第1档 │ 满___元 │ 减___元 │ [删除] │ │
│ │ 第2档 │ 满___元 │ 减___元 │ [删除] │ │
│ │ 第3档 │ 满___元 │ 减___元 │ [删除] │ │
│ └─────────────────────────────────────┘ │
│ 上不封顶: [Switch 开关] │
├──────────────────────────────────────────┤
│ 【适用范围 & 规则】 │
│ 适用类型: [全场通用 / 指定商品分类 / 指定商品] │
│ 指定分类: [级联选择器](仅在"指定商品分类"显示)│
│ 指定商品: [商品选择弹窗](仅在"指定商品"显示) │
│ 可叠加优惠券: [Switch 开关] │
│ 每人限参与次数: [数字输入框] 次(0=不限) │
└──────────────────────────────────────────┘
| 字段名 | 控件类型 | 必填 | 校验规则 | 说明 |
|---|---|---|---|---|
| 活动名称 | 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=不限 |
同一用户最多参与次数 |
添加一档:
删除一档:
前端即时校验规则:
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 | 活动名称 | 非空,1~50字符 |
| 2 | 店铺 | 必选 |
| 3 | 开始时间 | 必填,编辑时若已生效则不可修改 |
| 4 | 结束时间 | 必填,必须 > 开始时间 |
| 5 | 档位数量 | 1~10档 |
| 6 | 门槛递增 | 每档门槛 > 前一档 |
| 7 | 优惠递增 | 每档减扣 >= 前一档 |
| 8 | 优惠 < 门槛 | 每档减扣 < 对应门槛 |
| 9 | 适用类型 | 必选,选2/3时必须选具体分类/商品 |
| 10 | 限次 | >= 0 整数 |
| 状态值 | 状态名称 | 定义 | 数据库中 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),避免前后端状态不一致。
点击"启用"按钮(状态为草稿或已关闭时显示):
用户点击"启用"
↓
前端校验:是否在活动时间范围内?
├─ 在范围内 → 调用后端启用接口 → manual_status=1, status=1 → 状态变为"进行中"
├─ 未到开始时间 → 调用后端启用接口 → manual_status=1, status=1 → 状态变为"未开始"
└─ 已过结束时间 → 阻止操作,提示"活动已过期,无法启用"
点击"停用"按钮(状态为未开始或进行中时显示):
用户点击"停用"
↓
弹窗二次确认:"确认停用该活动?停用后用户将无法参与该活动。"
↓ 确认
调用后端停用接口 → manual_status=0 → 状态变为"已关闭"
在现有 Controller 基础上新增两个方法,详见第5节接口设计。
后端模块:
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)
| 页面 | 改造内容 |
|---|---|
| 单店铺结算页 | 新增「满减活动」区块:活动列表、档位说明、选中态、优惠金额预览 |
| 多店铺结算页 | 按店铺 Tab/卡片分组展示各自可用活动,每店独立 promotionActivityId |
| 金额明细区 | 增加一行「满减优惠 -¥XX.XX」,置于积分与优惠券之间 |
| 提交订单 | create / createMultiStore 携带 promotionActivityId、promotionTierId |
不在本次改造:商品详情页活动标签(可选二期)、购物车页(仍走 confirm → 结算页查活动)。
┌─────────────────────────────────────┐
│ 收货地址 │
├─────────────────────────────────────┤
│ 商品列表(现有) │
├─────────────────────────────────────┤
│ 【满减活动】 查看规则 > │ ← 新增区块
│ ○ 不参与满减 │
│ ● 618年中大促 已满399减80 │ ← 默认选中推荐活动
│ 再购 XX 元可减 YY 元(下一档提示) │
│ ○ 品类专享满减 满199减30(不可用灰显)│
│ 原因:未达门槛 / 已达参与上限 │
├─────────────────────────────────────┤
│ 优惠券(现有) │
│ 积分抵扣(现有) │
├─────────────────────────────────────┤
│ 商品金额 ¥498.00 │
│ 运费 ¥0.00 │
│ 积分抵扣 -¥0.00 │
│ 满减优惠 -¥80.00 ← 新增 │
│ 优惠券 -¥0.00 │
│ ───────────────── │
│ 实付 ¥418.00 │
└─────────────────────────────────────┘
[ 提交订单 ]
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)。
| 场景 | 处理 |
|---|---|
| 无可用活动 | 隐藏满减区块或展示「暂无满减活动」 |
活动不可用(enabled=false) |
灰显 + 展示 disabledReason |
| 切换活动 | 防抖 300ms 后调 computed |
| 活动列表为空但 computed 有推荐 | 以 list 接口为准,不前端臆造活动 |
| orderKey 过期 | 与现有逻辑一致,提示返回购物车 |
data() {
return {
orderKey: '',
storeId: null,
promotionList: [], // list 接口返回
selectedPromotionId: null, // null = 不参与
recommendedPromotionId: null,
promotionDiscountAmount: 0,
promotionTitle: '',
promotionRemainCount: null,
computeLoading: false
}
}
| 项 | APP | 小程序 |
|---|---|---|
| 网络请求 | 同一套 API 封装 | 同一套 API 封装 |
| 活动规则弹窗 | 可用原生 Modal / 底部 Sheet | 建议 uni-popup 展示档位表 |
| 登录 | @Login Token |
@Login Token |
| 多店铺 | 与 APP 一致 | 与 APP 一致 |
┌──────────┐
创建 │ 草稿 │
───────────▶ │ (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] 内或之前 |
| 已结束 | (终态) | — | 不可再操作 |
@Scheduled 或 XXL-JOB)机制:每分钟执行一次定时任务,扫描 status=1 AND manual_status=1 的活动,根据时间更新状态。
优点:
fs-quartz 模块可直接集成缺点:
机制:活动启用时,计算 end_time - now 秒数,设置 Redis key 的过期时间。当 key 过期时,通过 Redis 的 __keyevent@*__:expired 通知,触发状态更新。
优点:
缺点:
notify-keyspace-events Ex)机制:
前置拦截(核心):每次用户请求(浏览活动详情 / 下单计算优惠 / 提交订单)时,在 Service 层通过 AOP 或工具方法执行时间窗口校验,若当前时间已超过结束时间,即时更新活动状态为已结束,并返回无效。
缓存标记:下发到用户端的活动数据中,始终携带 start_time 和 end_time。前端在发起下单请求前,本地先做时间校验,早于到达后端就拦截。
定时兜底(每分钟):通过 XXL-JOB / @Scheduled 定时任务全量扫描,处理那些长期无人访问的"僵尸活动",确保管理端列表准时刷新状态。
方案对比表:
| 维度 | 方案一 纯定时任务 | 方案二 Redis过期事件 | 方案三 查即验证+定时兜底 |
|---|---|---|---|
| 实时性 | 分钟级 | 秒级 | 秒级(请求触发) |
| 可靠性 | 高 | 中(事件可能丢失) | 高(双重保障) |
| 实现复杂度 | 低 | 高 | 中 |
| 资源消耗 | 持续扫描 | 低 | 低 |
| 部署依赖 | 无 | Redis Keyspace | Redis(通用缓存) |
推荐结论:采用 方案三(查即验证 + 定时兜底),核心原因:
问题:用户在 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()),与现有优惠券、订单超时逻辑保持一致。
manual_status |
时间条件 | 最终生效状态 | 说明 |
|---|---|---|---|
NULL |
任意 | 草稿 | 从未操作过 |
1 |
now < start_time |
未开始 | 等待生效 |
1 |
start_time <= now <= end_time |
进行中 | 正常生效 |
1 |
now > end_time |
已结束 | 自动失效 |
0 |
任意 | 已关闭 | 手动关闭优先,时间条件无效 |
manual_status = 0 时,无论时间是否在有效期内,活动均不生效。manual_status = 1 且 now <= end_time 才有效;若 now > end_time,即使 manual_status = 1 也视为已结束。// 启用活动接口伪代码
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());
}
| 字段 | 类型 | 作用 |
|---|---|---|
start_time |
datetime |
活动生效开始时间 |
end_time |
datetime |
活动生效结束时间 |
status |
tinyint |
存储态状态(0草稿/1启用/3已结束),辅助查询 |
manual_status |
tinyint |
手动开关(NULL未操作/1启用/0关闭) |
version |
int |
乐观锁版本号,用于并发控制 |
-- 查询当前时刻生效的活动(供用户端使用)
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};
定时任务(每分钟):
UPDATE fs_store_promotion_activity
SET status = 3, update_time = NOW()
WHERE status = 1 AND manual_status = 1 AND end_time < NOW();
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;
fs_store_promotion_activityCREATE 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:并发控制fs_store_promotion_tierCREATE 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='阶梯满减档位表';
fs_store_promotion_scopeCREATE 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 存储分类IDscope_type = 3(指定商品)时,target_id 存储商品IDfs_store_promotion_usageCREATE 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(已回滚)。
| 索引 | 所在表 | 优化目的 |
|---|---|---|
idx_status_time |
主表 | 支持"查询当前生效活动"的高频查询(范围扫描) |
idx_store_status |
主表 | 支持按店铺筛选活动列表(管理端列表页) |
idx_activity_id |
档位表 | JOIN 查询档位信息 |
idx_target |
范围表 | 反查"某商品/分类参与哪些活动" |
idx_activity_user |
参与表 | 校验"用户已参与次数"(聚合查询) |
| 本表字段 | 关联现有表 | 实际表名 | 说明 |
|---|---|---|---|
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 |
下单用户 |
fs_store_order_scrm)现有订单实体已含 discountMoney(总优惠)、couponPrice、userCouponId,本次新增:
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 使用下划线映射。
用户进入结算页
↓
后端接收请求:用户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(上不封顶),继续按最后一档比例叠加
└─ 返回优惠后金额
↓
返回适用活动列表 + 优惠计算结果
/**
* 查询结算页适用活动及优惠计算
* @param storeId 店铺ID
* @param userId 用户ID
* @param orderItems 订单商品列表(含价格、分类ID)
* @return 活动列表及优惠计算结果
*/
List<PromotionComputeResult> computeApplicablePromotions(
Long storeId, Long userId, List<OrderItemParam> orderItems);
// 输入:订单总金额 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)。
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. 【返回订单创建成功】
@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("活动繁忙,请重试");
}
}
/**
* 保存或修改活动时的后端校验流程
*/
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("选择指定商品时,请至少选择一个商品");
}
}
基于现有 Controller 模式:
@RestController + @RequestMapping + @PreAuthorize
模块路径:D:\hdProject\ylrz_his_scrm_java\fs-admin\src\main\java\com\fs\hisStore\controller\
GET /store/storePromotion/list
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| title | String | 否 | 活动名称(模糊搜索) |
| storeId | Long | 否 | 店铺ID |
| status | Integer | 否 | 活动状态:0草稿 1未开始 2进行中 3已结束 4已关闭 |
| startTime | Date | 否 | 开始时间(范围起) |
| endTime | Date | 否 | 结束时间(范围止) |
| pageNum | Integer | 是 | 页码 |
| pageSize | Integer | 是 | 每页条数 |
响应示例:
{
"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 + 时间范围实时计算:
-- 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}
POST /store/storePromotion
请求体:
{
"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": ""
}
响应:
{"code": 200, "msg": "操作成功"}
PUT /store/storePromotion
请求体同上,增加 "id": 1。
GET /store/storePromotion/{activityId}
响应:返回活动完整信息,含档位列表、适用范围明细。
DELETE /store/storePromotion/{activityIds}
逻辑删除(is_del = 1),仅草稿/已结束/已关闭状态可删。
PUT /store/storePromotion/{activityId}/enable
前置校验:end_time >= NOW(),否则返回错误。
响应:
{"code": 200, "msg": "操作成功", "data": {"newDisplayStatus": 2}}
PUT /store/storePromotion/{activityId}/disable
前置校验:仅未开始/进行中状态可停用。
响应:
{"code": 200, "msg": "操作成功", "data": {"newDisplayStatus": 4}}
复用现有店铺接口,无需新增:
GET /store/his/store/listOption
前端引用:@/api/hisStore/store.js → listOption()。
Controller:
fs-user-app→StoreOrderScrmController,基础路径/store/app/storeOrder
Service:fs-service→ 新增IFsStorePromotionComputeService(或并入FsStoreOrderScrmServiceImpl),供 list/computed/create 共用
购物车数据仍通过 RedisorderKey/orderCarts:{orderKey}读取(与confirmOrder、computedOrder一致)
单店铺
POST /store/app/storeOrder/promotion/list
| 属性 | 说明 |
|---|---|
| 鉴权 | @Login,与 /computed 相同 |
| 用途 | 结算页进入时拉取当前订单可参与的活动列表,供用户选择 |
请求体 FsStorePromotionListParam:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderKey | String | 是 | confirm 返回的订单缓存 key |
| storeId | Long | 否 | 单店购物车可不传,由 carts 推断 |
| couponUserId | Long | 否 | 已选优惠券 ID,用于标记 stackableConflict |
响应 FsStorePromotionListVO:
{
"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
}
]
}
}
后端逻辑:
orderCarts:{orderKey},校验未过期storeId 查生效活动(查即验证 + 缓存)eligibleAmount、匹配档位、estimatedDiscountrecommendedActivityId = enabled=true 中 estimatedDiscount 最大者usage_status=1 次数 → 填充 userRemainCount多店铺
POST /store/app/storeOrder/promotion/listMultiStore
请求体 FsStorePromotionListMultiStoreParam:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderKeys | List<String> | 是 | 与 computedMultiStore 一致,每店一个 orderKey |
| couponUserId | Long | 否 | 全局优惠券(若业务按店分券则扩展为 Map) |
响应:List<FsStorePromotionListVO>,每个元素对应一个店铺的活动列表。
Controller 伪代码:
@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));
}
用于「查看规则 >」弹窗,避免 list 重复传完整 tiers:
GET /store/app/storeOrder/promotion/{activityId}
Query:orderKey(校验该活动对当前购物车是否适用)
响应:活动规则 + 全部档位 + 当前匹配结果。
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.javaparam/FsStorePromotionListMultiStoreParam.javaparam/PromotionStoreActivityParam.javavo/FsStorePromotionListVO.javavo/FsStorePromotionActivityItemVO.javaPOST /store/app/storeOrder/computed
POST /store/app/storeOrder/computedMultiStore
请求体扩展示例:
{
"orderKey": "abc123",
"addressId": 1,
"couponUserId": null,
"useIntegral": 0,
"promotionActivityId": 1
}
响应扩展示例:
{
"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 内处理要点:
promotion/list 共用 computeEligibleAmount、matchTier 方法promotionActivityId 非空时:validateAndExpire → 重算满减 → 扣减 payPriceis_stackable=0 且已选优惠券 → 抛错「不可与优惠券叠加」POST /store/app/storeOrder/create
POST /store/app/storeOrder/createMultiStore
请求体扩展:promotionActivityId、promotionTierId(金额以服务端重算为准)。
处理流程:createOrder 内再次执行满减逻辑 → 写 fs_store_order_scrm.promotion_* → 写 usage(待支付)。
错误响应:
{"code": 500, "msg": "活动已结束,请重新下单"}
{"code": 500, "msg": "您已超过该活动参与次数限制"}
{"code": 500, "msg": "该活动不可与优惠券叠加使用"}
{"code": 500, "msg": "优惠信息已变更,请刷新后重试"}
// 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 })
}
用户请求
│
▼
Redis 缓存层
(店铺维度Key)
│
┌─ 命中 ───▶ 返回
│
└─ 未命中
│
▼
MySQL 查询
(当前生效活动)
│
▼
写入 Redis
(TTL = 5分钟)
│
▼
返回结果
| Key 模式 | 说明 | TTL |
|---|---|---|
promotion:store:{storeId} |
店铺当前生效的活动列表(含档位) | 5 分钟 |
promotion:detail:{activityId} |
单个活动详情(含档位+范围) | 10 分钟 |
promotion:usage:{activityId}:{userId} |
用户当前已参与次数 | 与 DB 同步,30 秒 |
| 触发场景 | 更新操作 |
|---|---|
| 管理端新增/编辑活动 | 删除 promotion:store:{storeId},下次请求时懒加载 |
| 管理端启用/停用活动 | 同上 |
| 用户下单使用活动 | 更新 promotion:usage:{activityId}:{userId}(INCR) |
| 定时任务检测到活动过期 | 删除 promotion:store:{storeId} |
| 缓存自然过期 | 自动失效,下次请求重新加载 |
@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);
}
}
| 并发场景 | 机制 | 说明 |
|---|---|---|
| 同一用户并发下单同一活动 | 乐观锁 + 唯一约束 | 通过 version 字段避免参与次数超限 |
| 多用户并发抢同一活动 | 无库存概念 | 阶梯满减无固定库存,不需限制总量 |
| 时间临界点并发 | SELECT FOR UPDATE + 时间二次校验 |
下单事务中严格校验时间边界 |
@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 建表语句):
UNIQUE KEY `uk_activity_order` (`activity_id`, `order_id`)
对于高并发场景,在下单入口增加 Redis 分布式锁(Redisson),锁粒度为 promotion:lock:{activityId}:{userId},超时时间 10 秒。保证同一用户同一活动串行处理。
| 订单状态 | 处理策略 | 实现方式 |
|---|---|---|
| 已支付 | 不受影响,活动优惠保留 | 订单快照字段 promotion_discount_amount 已写入 |
| 未支付 - 超时未付 | 订单自动取消,usage 置为已回滚 | 现有订单超时任务中 UPDATE usage SET usage_status=2 |
| 未支付 - 用户主动取消 | 同上 | 取消订单接口同步回滚 usage |
| 未支付 - 用户继续支付 | 允许支付,以订单创建时快照为准 | 支付回调不重复校验活动时效 |
设计理由:
fs_store_order 表中已有 discount_amount(折扣金额)字段,订单是活动优惠的快照支付回调 → 仅校验支付金额与订单金额一致 → 完成支付
(不再次校验活动状态,以订单创建时的计算结果为准)
fs_store_order_scrm 表已有字段:
discountMoney(BigDecimal):订单优惠总金额(可与满减+券合计或分项存储,需与财务口径对齐)couponPrice(BigDecimal):优惠券金额userCouponId(Long):使用的用户优惠券 ID本次新增字段见 3.7 订单表扩展。
| 异常场景 | 处理策略 |
|---|---|
| Redis 不可用 | 降级为直接查 DB,活动查询不受影响 |
| 定时任务未执行 | 查即验证兜底,用户请求时触发状态更新 |
| 数据库主从延迟 | 下单时使用主库读写;查询时若读从库允许短暂延迟 |
| 活动修改并发冲突 | version 乐观锁,后提交者收到冲突提示 |
| 订单部分退款 | 不退优惠金额(满减优惠为整单优惠),按商品实付金额比例退款 |
后端 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 ← 活动详情页
// 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;
}
@PreAuthorize("@ss.hasPermi('store:storePromotion:list')") 等,命名对齐 store:storeCoupon:*@Log(title = "阶梯满减", businessType = BusinessType.xxx)BaseEntity,档位/范围/usage 表仅保留必要时间字段@Transactional(rollbackFor = Exception.class),与 FsStoreOrderScrmServiceImpl.createOrder 一致FsStoreOrderScrm 同步增加属性views/hisStore/storeCoupon/index.vue;Controller 对齐 FsStoreCouponScrmController(/store/storeCoupon)/store/storePromotion,与 /store/storeCoupon 并列sys_menu 新增「阶梯满减」菜单,父级挂在现有 hisStore 商城菜单下promotion/listMultiStore、computedMultiStore、createMultiStorepromotion/list、computedOrder、createOrder、管理端详情接口调用统一 validateAndExpirepromotion/list,切换活动再调 computed(见 1.4 节)confirm → promotion/list → computed(可多次)→ create,不可跳过 list 直接写死 activityId文档结束 — v1.2.1 管理端后台已实现;用户端与下单链路见「实现进度追踪」待办项。