multi-store-tiered-promotion-design.md 69 KB

多店铺阶梯满减活动系统设计方案

版本: 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
活动 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_storefs_store_order 等不存在或不准确的表名 统一为 fs_store_scrmfs_store_order_scrmfs_store_product_scrmfs_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 与现有代码一致,使用项目内 redisCacheRedisCache)+ 可选 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 FsStoreOrderComputedParamFsStoreOrderComputeDTOFsStoreOrderCreateParam 增加满减字段
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 且券可用)

目录

  1. 评审结论与实施计划
  2. 管理后台页面设计(重点) 1.4. 用户端结算页设计(APP / 小程序)
  3. 活动时效性生效与失效方案(重点)
  4. 数据库设计
  5. 核心业务逻辑
  6. 接口设计
  7. 非功能性设计

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 前端状态判断逻辑(核心)

// 前端计算展示状态的函数(需前后端双重保障)
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档,否则给出提示"至少保留一档"
  • 删除不需要二次确认

前端即时校验规则

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 = 1now < start_time
2 进行中 已启用且在有效期内 manual_status = 1start_time <= now <= end_time
3 已结束 已过结束时间 manual_status = 1now > end_time,或 status = 3(定时/查即验证写入)
4 已关闭 被手动关闭 manual_status = 0status 保持原值,展示态优先于时间)

说明:列表/详情展示的 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 携带 promotionActivityIdpromotionTierId

不在本次改造:商品详情页活动标签(可选二期)、购物车页(仍走 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)

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_timeend_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 = 1now <= end_time 才有效;若 now > end_time,即使 manual_status = 1 也视为已结束。
  3. 启用/停用操作应校验时间冲突
    • 启用时如果已超过结束时间 → 拒绝操作
    • 停用时不需校验时间,直接关闭生效

后端校验代码示例逻辑

// 启用活动接口伪代码
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(核心)

-- 查询当前时刻生效的活动(供用户端使用)
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. 定时任务(每分钟):

    UPDATE fs_store_promotion_activity 
    SET status = 3, update_time = NOW() 
    WHERE status = 1 AND manual_status = 1 AND end_time < NOW();
    
    1. 请求触发更新(查即验证): 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

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_timeend_time:时间核心字段
  • status:辅助状态存储,加速列表查询
  • manual_status:手动开关,决定性字段
  • version:并发控制

3.2 满减档位表 fs_store_promotion_tier

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

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

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(总优惠)、couponPriceuserCouponId,本次新增:

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 层核心方法签名

/**
 * 查询结算页适用活动及优惠计算
 * @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)

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. 【返回订单创建成功】

乐观锁伪代码

@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 管理端保存/修改后端校验

/**
 * 保存或修改活动时的后端校验流程
 */
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 每页条数

响应示例

{
  "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}

5.1.2 新增活动

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": "操作成功"}

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(),否则返回错误。

响应

{"code": 200, "msg": "操作成功", "data": {"newDisplayStatus": 2}}

5.1.7 停用活动

PUT /store/storePromotion/{activityId}/disable

前置校验:仅未开始/进行中状态可停用。

响应

{"code": 200, "msg": "操作成功", "data": {"newDisplayStatus": 4}}

5.1.8 店铺列表(下拉选项)

复用现有店铺接口,无需新增

GET /store/his/store/listOption

前端引用:@/api/hisStore/store.jslistOption()


5.2 用户端接口(结算活动查询 + 订单链路扩展)

Controllerfs-user-appStoreOrderScrmController,基础路径 /store/app/storeOrder
Servicefs-service → 新增 IFsStorePromotionComputeService(或并入 FsStoreOrderScrmServiceImpl),供 list/computed/create 共用
购物车数据仍通过 Redis orderKey / orderCarts:{orderKey} 读取(与 confirmOrdercomputedOrder 一致)

5.2.1 结算页查询可用满减活动(★新增)

单店铺

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
      }
    ]
  }
}

后端逻辑

  1. 从 Redis 读 orderCarts:{orderKey},校验未过期
  2. storeId 查生效活动(查即验证 + 缓存)
  3. 逐活动计算 eligibleAmount、匹配档位、estimatedDiscount
  4. recommendedActivityId = enabled=trueestimatedDiscount 最大者
  5. 校验用户 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));
}

5.2.2 结算页查询活动详情(可选)

用于「查看规则 >」弹窗,避免 list 重复传完整 tiers:

GET /store/app/storeOrder/promotion/{activityId}

QueryorderKey(校验该活动对当前购物车是否适用)

响应:活动规则 + 全部档位 + 当前匹配结果。

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

请求体扩展示例

{
  "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 内处理要点

  1. promotion/list 共用 computeEligibleAmountmatchTier 方法
  2. promotionActivityId 非空时:validateAndExpire → 重算满减 → 扣减 payPrice
  3. is_stackable=0 且已选优惠券 → 抛错「不可与优惠券叠加」
  4. 满减在积分之后、优惠券之前(见 0.3 节)

5.2.5 创建订单(现有接口扩展)

POST /store/app/storeOrder/create
POST /store/app/storeOrder/createMultiStore

请求体扩展promotionActivityIdpromotionTierId(金额以服务端重算为准)。

处理流程createOrder 内再次执行满减逻辑 → 写 fs_store_order_scrm.promotion_* → 写 usage(待支付)。

错误响应

{"code": 500, "msg": "活动已结束,请重新下单"}
{"code": 500, "msg": "您已超过该活动参与次数限制"}
{"code": 500, "msg": "该活动不可与优惠券叠加使用"}
{"code": 500, "msg": "优惠信息已变更,请刷新后重试"}

5.2.6 用户端 API 封装示例(uni-app)

// 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分钟
@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 参与次数并发控制实现

@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`)

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 订单表扩展

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. 档位枚举与常量定义

// 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/listMultiStorecomputedMultiStorecreateMultiStore
  10. 查即验证:在 promotion/listcomputedOrdercreateOrder、管理端详情接口调用统一 validateAndExpire
  11. 结算页改造:APP 与小程序共用 uni-app 结算页,进入页先调 promotion/list,切换活动再调 computed(见 1.4 节)
  12. 接口顺序confirmpromotion/listcomputed(可多次)→ create,不可跳过 list 直接写死 activityId

文档结束 — v1.2.1 管理端后台已实现;用户端与下单链路见「实现进度追踪」待办项。