# 多店铺阶梯满减活动系统设计方案 > **版本**: 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 computeApplicablePromotions( Long storeId, Long userId, List orderItems); ``` #### 档位匹配算法 ```java // 输入:订单总金额 totalAmount,档位列表 tiers(已按 sort_order ASC 排序) // 输出:命中的最优档位 PromotionTier matchTier(BigDecimal totalAmount, List 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 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\ | 是 | 与 `computedMultiStore` 一致,每店一个 orderKey | | couponUserId | Long | 否 | 全局优惠券(若业务按店分券则扩展为 Map) | **响应**:`List`,每个元素对应一个店铺的活动列表。 **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\ | 每店选中活动,`[{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 getActivePromotions(Long storeId) { String redisKey = "promotion:store:" + storeId; List 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 管理端后台已实现;用户端与下单链路见「实现进度追踪」待办项。