Selaa lähdekoodia

前端代码提交

yjwang 14 tuntia sitten
vanhempi
commit
d364cf723b

+ 1740 - 0
docs/multi-store-tiered-promotion-design.md

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

+ 182 - 0
docs/sql/fs_store_promotion_init.sql

@@ -0,0 +1,182 @@
+-- ============================================================
+-- 多店铺阶梯满减活动 - 初始化 / 增量升级 SQL
+-- 文档: docs/multi-store-tiered-promotion-design.md v1.2
+-- 执行前请备份数据库;菜单 parent_id 请按实际 hisStore 父菜单 ID 调整
+--
+-- 使用说明:
+--   1. 全新环境:从「一、建表」起顺序执行即可
+--   2. 已建活动表、仅缺订单字段:执行「二、订单表扩展字段(增量)」
+--   3. 增量语句可重复执行,字段/索引已存在时自动跳过
+-- ============================================================
+
+-- ============================================================
+-- 一、建表(CREATE TABLE IF NOT EXISTS,可重复执行)
+-- ============================================================
+
+-- 1. 活动主表
+CREATE TABLE IF NOT EXISTS `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='店铺阶梯满减活动主表';
+
+-- 2. 满减档位表
+CREATE TABLE IF NOT EXISTS `fs_store_promotion_tier` (
+  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `activity_id` BIGINT(20) NOT NULL COMMENT '活动ID',
+  `sort_order` INT(11) NOT NULL DEFAULT 1 COMMENT '档位序号',
+  `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. 适用范围表(指定分类/指定商品写入此表,全场通用不写记录)
+CREATE TABLE IF NOT EXISTS `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商品',
+  `target_id` BIGINT(20) NOT NULL COMMENT '分类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='活动适用范围明细表';
+
+-- 4. 用户参与记录表
+CREATE TABLE IF NOT EXISTS `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 DEFAULT 0.00 COMMENT '订单原金额',
+  `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 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`),
+  UNIQUE KEY `uk_activity_order` (`activity_id`, `order_id`),
+  KEY `idx_activity_user` (`activity_id`, `user_id`),
+  KEY `idx_order_id` (`order_id`),
+  KEY `idx_usage_time` (`usage_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户活动参与记录表';
+
+-- ============================================================
+-- 二、订单表扩展字段(增量,可重复执行)
+-- 表:fs_store_order_scrm
+-- ============================================================
+
+SET @db_name = DATABASE();
+
+-- promotion_activity_id
+SET @sql_add_promotion_activity_id = (
+  SELECT IF(
+    EXISTS(
+      SELECT 1 FROM information_schema.COLUMNS
+      WHERE TABLE_SCHEMA = @db_name
+        AND TABLE_NAME = 'fs_store_order_scrm'
+        AND COLUMN_NAME = 'promotion_activity_id'
+    ),
+    'SELECT ''skip: promotion_activity_id exists'' AS msg',
+    'ALTER TABLE `fs_store_order_scrm` ADD COLUMN `promotion_activity_id` BIGINT(20) DEFAULT NULL COMMENT ''阶梯满减活动ID'' AFTER `user_coupon_id`'
+  )
+);
+PREPARE stmt FROM @sql_add_promotion_activity_id;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- promotion_tier_id
+SET @sql_add_promotion_tier_id = (
+  SELECT IF(
+    EXISTS(
+      SELECT 1 FROM information_schema.COLUMNS
+      WHERE TABLE_SCHEMA = @db_name
+        AND TABLE_NAME = 'fs_store_order_scrm'
+        AND COLUMN_NAME = 'promotion_tier_id'
+    ),
+    'SELECT ''skip: promotion_tier_id exists'' AS msg',
+    'ALTER TABLE `fs_store_order_scrm` ADD COLUMN `promotion_tier_id` BIGINT(20) DEFAULT NULL COMMENT ''命中档位ID'' AFTER `promotion_activity_id`'
+  )
+);
+PREPARE stmt FROM @sql_add_promotion_tier_id;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- promotion_discount_amount
+SET @sql_add_promotion_discount_amount = (
+  SELECT IF(
+    EXISTS(
+      SELECT 1 FROM information_schema.COLUMNS
+      WHERE TABLE_SCHEMA = @db_name
+        AND TABLE_NAME = 'fs_store_order_scrm'
+        AND COLUMN_NAME = 'promotion_discount_amount'
+    ),
+    'SELECT ''skip: promotion_discount_amount exists'' AS msg',
+    'ALTER TABLE `fs_store_order_scrm` ADD COLUMN `promotion_discount_amount` DECIMAL(10,2) DEFAULT NULL COMMENT ''满减优惠金额'' AFTER `promotion_tier_id`'
+  )
+);
+PREPARE stmt FROM @sql_add_promotion_discount_amount;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ============================================================
+-- 三、参与记录表索引补全(旧库若已建表但缺 idx_usage_time 可执行)
+-- ============================================================
+
+SET @sql_add_idx_usage_time = (
+  SELECT IF(
+    EXISTS(
+      SELECT 1 FROM information_schema.STATISTICS
+      WHERE TABLE_SCHEMA = @db_name
+        AND TABLE_NAME = 'fs_store_promotion_usage'
+        AND INDEX_NAME = 'idx_usage_time'
+    ),
+    'SELECT ''skip: idx_usage_time exists'' AS msg',
+    'ALTER TABLE `fs_store_promotion_usage` ADD INDEX `idx_usage_time` (`usage_time`)'
+  )
+);
+PREPARE stmt FROM @sql_add_idx_usage_time;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ============================================================
+-- 四、菜单与按钮权限(parent_id 请替换为商城/hisStore 父菜单 ID)
+-- 注意:菜单 INSERT 仅首次执行,重复执行会插入重复菜单
+-- ============================================================
+-- SET @parent_menu_id = (SELECT menu_id FROM sys_menu WHERE menu_name = '商城管理' LIMIT 1);
+
+INSERT INTO `sys_menu` (`menu_name`, `parent_id`, `order_num`, `path`, `component`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
+VALUES ('阶梯满减', 0, 1, 'storePromotion', 'hisStore/storePromotion/index', 1, 0, 'C', '0', '0', 'store:storePromotion:list', 'money', 'admin', NOW(), '', NULL, '阶梯满减活动');
+
+SET @promotion_menu_id = LAST_INSERT_ID();
+
+INSERT INTO `sys_menu` (`menu_name`, `parent_id`, `order_num`, `path`, `component`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `remark`) VALUES
+('阶梯满减查询', @promotion_menu_id, 1, '#', '', 1, 0, 'F', '0', '0', 'store:storePromotion:query', '#', 'admin', NOW(), ''),
+('阶梯满减新增', @promotion_menu_id, 2, '#', '', 1, 0, 'F', '0', '0', 'store:storePromotion:add', '#', 'admin', NOW(), ''),
+('阶梯满减修改', @promotion_menu_id, 3, '#', '', 1, 0, 'F', '0', '0', 'store:storePromotion:edit', '#', 'admin', NOW(), ''),
+('阶梯满减删除', @promotion_menu_id, 4, '#', '', 1, 0, 'F', '0', '0', 'store:storePromotion:remove', '#', 'admin', NOW(), ''),
+('阶梯满减导出', @promotion_menu_id, 5, '#', '', 1, 0, 'F', '0', '0', 'store:storePromotion:export', '#', 'admin', NOW(), ''),
+('阶梯满减启用', @promotion_menu_id, 6, '#', '', 1, 0, 'F', '0', '0', 'store:storePromotion:enable', '#', 'admin', NOW(), ''),
+('阶梯满减停用', @promotion_menu_id, 7, '#', '', 1, 0, 'F', '0', '0', 'store:storePromotion:disable', '#', 'admin', NOW(), '');

+ 61 - 0
src/api/hisStore/storePromotion.js

@@ -0,0 +1,61 @@
+import request from '@/utils/request'
+
+export function listStorePromotion(query) {
+  return request({
+    url: '/store/storePromotion/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getStorePromotion(id) {
+  return request({
+    url: '/store/storePromotion/' + id,
+    method: 'get'
+  })
+}
+
+export function addStorePromotion(data) {
+  return request({
+    url: '/store/storePromotion',
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateStorePromotion(data) {
+  return request({
+    url: '/store/storePromotion',
+    method: 'put',
+    data: data
+  })
+}
+
+export function delStorePromotion(id) {
+  return request({
+    url: '/store/storePromotion/' + id,
+    method: 'delete'
+  })
+}
+
+export function enableStorePromotion(id) {
+  return request({
+    url: '/store/storePromotion/' + id + '/enable',
+    method: 'put'
+  })
+}
+
+export function disableStorePromotion(id) {
+  return request({
+    url: '/store/storePromotion/' + id + '/disable',
+    method: 'put'
+  })
+}
+
+export function exportStorePromotion(query) {
+  return request({
+    url: '/store/storePromotion/export',
+    method: 'get',
+    params: query
+  })
+}

+ 643 - 0
src/views/hisStore/storePromotion/index.vue

@@ -0,0 +1,643 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="88px">
+      <el-form-item label="活动名称" prop="title">
+        <el-input v-model="queryParams.title" placeholder="请输入活动名称" clearable size="small" style="width: 200px" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="店铺" prop="storeId">
+        <el-select v-model="queryParams.storeId" placeholder="请选择店铺" clearable filterable size="small" style="width: 200px">
+          <el-option v-for="item in storeOptions" :key="item.storeId" :label="item.storeName" :value="item.storeId"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="活动状态" prop="displayStatus">
+        <el-select v-model="queryParams.displayStatus" placeholder="全部" clearable size="small" style="width: 140px">
+          <el-option v-for="item in displayStatusOptions" :key="item.value" :label="item.label" :value="item.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="活动时间">
+        <el-date-picker v-model="dateRange" size="small" style="width: 240px" value-format="yyyy-MM-dd HH:mm:ss"
+                        type="datetimerange" range-separator="-" start-placeholder="开始" end-placeholder="结束"/>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="cyan" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['store:storePromotion:add']">新增</el-button>
+      </el-col>
+<!--      <el-col :span="1.5">-->
+<!--        <el-button type="warning" icon="el-icon-download" size="mini" @click="handleExport" v-hasPermi="['store:storePromotion:export']">导出</el-button>-->
+<!--      </el-col>-->
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"/>
+    </el-row>
+
+    <el-table height="500" border v-loading="loading" :data="promotionList">
+      <el-table-column label="ID" align="center" prop="id" width="70"/>
+      <el-table-column label="活动名称" align="center" prop="title" min-width="160" show-overflow-tooltip/>
+      <el-table-column label="店铺" align="center" prop="storeName" width="120"/>
+      <el-table-column label="活动时间" align="center" min-width="280">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.startTime) }} ~ {{ parseTime(scope.row.endTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="档数" align="center" prop="tierCount" width="60"/>
+      <el-table-column label="上不封顶" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isCapped === 1 ? 'success' : 'info'">{{ scope.row.isCapped === 1 ? '是' : '否' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="叠加券" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isStackable === 1 ? 'success' : 'warning'">{{ scope.row.isStackable === 1 ? '是' : '否' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="适用范围" align="center" prop="scopeTypeLabel" width="100"/>
+      <el-table-column label="每人限次" align="center" width="80">
+        <template slot-scope="scope">{{ scope.row.limitPerUser === 0 ? '不限' : scope.row.limitPerUser + '次' }}</template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="displayStatusTag(scope.row.displayStatus)">{{ scope.row.displayStatusLabel }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="240" fixed="right">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" @click="handleDetail(scope.row)" v-hasPermi="['store:storePromotion:query']">详情</el-button>
+          <el-button v-if="canEdit(scope.row)" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['store:storePromotion:edit']">编辑</el-button>
+          <el-button v-if="canEnable(scope.row)" size="mini" type="text" @click="handleEnable(scope.row)" v-hasPermi="['store:storePromotion:enable']">启用</el-button>
+          <el-button v-if="canDisable(scope.row)" size="mini" type="text" @click="handleDisable(scope.row)" v-hasPermi="['store:storePromotion:disable']">停用</el-button>
+          <el-button v-if="canDelete(scope.row)" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['store:storePromotion:remove']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList"/>
+
+    <!-- 新增/编辑 -->
+    <el-dialog :title="title" :visible.sync="open" width="860px" append-to-body :close-on-click-modal="false">
+      <el-form ref="form" :model="form" :rules="rules" label-width="120px">
+        <el-card shadow="never" class="mb8">
+          <div slot="header">基本信息</div>
+          <el-row :gutter="16">
+            <el-col :span="12">
+              <el-form-item label="活动名称" prop="title">
+                <el-input v-model="form.title" placeholder="1~50字符" maxlength="50"/>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="所属店铺" prop="storeId">
+                <el-select v-model="form.storeId" placeholder="请选择店铺" filterable style="width: 100%" :disabled="formLocked" @change="handleStoreChange">
+                  <el-option v-for="item in storeOptions" :key="item.storeId" :label="item.storeName" :value="item.storeId"/>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="开始时间" prop="startTime">
+                <el-date-picker v-model="form.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="开始时间" style="width: 100%" :disabled="formLocked && activeEditing"/>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="结束时间" prop="endTime">
+                <el-date-picker v-model="form.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="结束时间" style="width: 100%"/>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-card>
+
+        <el-card shadow="never" class="mb8">
+          <div slot="header" class="clearfix">
+            <span>满减阶梯</span>
+            <el-button v-if="!tierLocked" style="float: right; padding: 3px 0" type="text" @click="addTier">+ 添加一档</el-button>
+          </div>
+          <el-table :data="form.tiers" border size="mini">
+            <el-table-column label="档位" width="60" align="center">
+              <template slot-scope="scope">{{ scope.$index + 1 }}</template>
+            </el-table-column>
+            <el-table-column label="满(元)" align="center">
+              <template slot-scope="scope">
+                <el-input-number v-model="scope.row.thresholdAmount" :min="0.01" :precision="2" :disabled="tierLocked" controls-position="right" style="width: 100%"/>
+              </template>
+            </el-table-column>
+            <el-table-column label="减(元)" align="center">
+              <template slot-scope="scope">
+                <el-input-number v-model="scope.row.discountAmount" :min="0.01" :precision="2" :disabled="tierLocked" controls-position="right" style="width: 100%"/>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="70" align="center" v-if="!tierLocked">
+              <template slot-scope="scope">
+                <el-button type="text" size="mini" @click="removeTier(scope.$index)" :disabled="form.tiers.length <= 1">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <el-form-item label="上不封顶" style="margin-top: 12px">
+            <el-switch v-model="form.isCapped" :active-value="1" :inactive-value="0"/>
+          </el-form-item>
+        </el-card>
+
+        <el-card shadow="never">
+          <div slot="header">适用范围与规则</div>
+          <el-form-item label="适用类型" prop="scopeType">
+            <el-radio-group v-model="form.scopeType" :disabled="scopeLocked" @change="handleScopeTypeChange">
+              <el-radio :label="1">全场通用</el-radio>
+              <el-radio :label="2">指定分类</el-radio>
+              <el-radio :label="3">指定商品</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item v-if="form.scopeType === 2" label="指定分类" prop="scopeIds" key="scope-category">
+            <el-select v-model="form.scopeIds" multiple filterable placeholder="请选择分类" style="width: 100%" :disabled="scopeLocked">
+              <el-option v-for="item in categoryOptions" :key="item.cateId" :label="item.cateName" :value="item.cateId"/>
+            </el-select>
+          </el-form-item>
+          <el-form-item v-if="form.scopeType === 3" label="指定商品" prop="scopeIds" key="scope-product">
+            <el-select
+              v-model="form.scopeIds"
+              multiple
+              filterable
+              remote
+              reserve-keyword
+              :remote-method="searchProducts"
+              :loading="productLoading"
+              :disabled="scopeLocked || !form.storeId"
+              placeholder="请先选择店铺,可下拉选择或输入商品名搜索"
+              style="width: 100%"
+              @visible-change="handleProductSelectVisible"
+            >
+              <el-option v-for="item in productOptions" :key="item.productId" :label="formatProductLabel(item)" :value="item.productId"/>
+            </el-select>
+          </el-form-item>
+          <el-form-item label="可叠加优惠券">
+            <el-switch v-model="form.isStackable" :active-value="1" :inactive-value="0"/>
+          </el-form-item>
+          <el-form-item label="每人限次">
+            <el-input-number v-model="form.limitPerUser" :min="0" :precision="0"/> <span class="tip-text">0 表示不限</span>
+          </el-form-item>
+          <el-form-item label="备注">
+            <el-input v-model="form.remark" type="textarea" placeholder="备注"/>
+          </el-form-item>
+        </el-card>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 详情 -->
+    <el-dialog title="活动详情" :visible.sync="detailOpen" width="860px" append-to-body>
+      <el-descriptions v-if="detail" :column="2" border size="small">
+        <el-descriptions-item label="活动名称" :span="2">{{ detail.title }}</el-descriptions-item>
+        <el-descriptions-item label="店铺">{{ detail.storeName }}</el-descriptions-item>
+        <el-descriptions-item label="状态">{{ detail.displayStatusLabel }}</el-descriptions-item>
+        <el-descriptions-item label="开始时间">{{ parseTime(detail.startTime) }}</el-descriptions-item>
+        <el-descriptions-item label="结束时间">{{ parseTime(detail.endTime) }}</el-descriptions-item>
+        <el-descriptions-item label="适用范围">{{ detail.scopeTypeLabel }}</el-descriptions-item>
+        <el-descriptions-item label="每人限次">{{ detail.limitPerUser === 0 ? '不限' : detail.limitPerUser }}</el-descriptions-item>
+        <el-descriptions-item label="上不封顶">{{ detail.isCapped === 1 ? '是' : '否' }}</el-descriptions-item>
+        <el-descriptions-item label="叠加优惠券">{{ detail.isStackable === 1 ? '是' : '否' }}</el-descriptions-item>
+        <el-descriptions-item v-if="detail.remark" label="备注" :span="2">{{ detail.remark }}</el-descriptions-item>
+      </el-descriptions>
+
+      <div v-if="detail && detail.tiers && detail.tiers.length" class="detail-block">
+        <div class="detail-block-title">满减阶梯</div>
+        <el-table :data="detail.tiers" border size="mini">
+          <el-table-column label="档位" prop="sortOrder" width="60" align="center"/>
+          <el-table-column label="满(元)" prop="thresholdAmount" align="center"/>
+          <el-table-column label="减(元)" prop="discountAmount" align="center"/>
+        </el-table>
+      </div>
+
+      <div v-if="detail && detail.scopeType === 2" class="detail-block">
+        <div class="detail-block-title">指定分类</div>
+        <el-table v-if="detail.scopeCategories && detail.scopeCategories.length" :data="detail.scopeCategories" border size="mini">
+          <el-table-column label="分类ID" prop="cateId" width="90" align="center"/>
+          <el-table-column label="分类名称" prop="cateName" min-width="160" align="center" show-overflow-tooltip/>
+        </el-table>
+        <div v-else class="detail-empty">暂无分类数据</div>
+      </div>
+
+      <div v-if="detail && detail.scopeType === 3" class="detail-block">
+        <div class="detail-block-title">指定商品</div>
+        <el-table v-if="detail.scopeProducts && detail.scopeProducts.length" :data="detail.scopeProducts" border size="mini">
+          <el-table-column label="商品图" align="center" width="90">
+            <template slot-scope="scope">
+              <el-popover v-if="scope.row.image" placement="right" trigger="hover">
+                <img slot="reference" :src="scope.row.image" class="detail-product-thumb">
+                <img :src="scope.row.image" class="detail-product-preview">
+              </el-popover>
+              <span v-else class="detail-empty">无图</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="商品ID" prop="productId" width="90" align="center"/>
+          <el-table-column label="商品名称" prop="productName" min-width="180" align="center" show-overflow-tooltip/>
+          <el-table-column label="售价(元)" prop="price" width="100" align="center"/>
+        </el-table>
+        <div v-else class="detail-empty">暂无商品数据</div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listStorePromotion, getStorePromotion, addStorePromotion, updateStorePromotion,
+  delStorePromotion, enableStorePromotion, disableStorePromotion, exportStorePromotion
+} from '@/api/hisStore/storePromotion'
+import { listStoreOptions } from '@/api/hisStore/store'
+import { getAllStoreProductCategory } from '@/api/hisStore/storeProductCategory'
+import { listStoreProduct } from '@/api/hisStore/storeProduct'
+
+export default {
+  name: 'StorePromotion',
+  data() {
+    return {
+      loading: false,
+      showSearch: true,
+      total: 0,
+      promotionList: [],
+      storeOptions: [],
+      categoryOptions: [],
+      productOptions: [],
+      productLoading: false,
+      productSearchTimer: null,
+      productLoadedStoreId: null,
+      productRequestSeq: 0,
+      categoryLoaded: false,
+      categoryLoading: false,
+      dateRange: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        title: null,
+        storeId: null,
+        displayStatus: null
+      },
+      displayStatusOptions: [
+        { value: 0, label: '草稿' },
+        { value: 1, label: '未开始' },
+        { value: 2, label: '进行中' },
+        { value: 3, label: '已结束' },
+        { value: 4, label: '已关闭' }
+      ],
+      title: '',
+      open: false,
+      detailOpen: false,
+      detail: null,
+      activeEditing: false,
+      form: {},
+      rules: {
+        title: [{ required: true, message: '请输入活动名称', trigger: 'blur' }],
+        storeId: [{ required: true, message: '请选择店铺', trigger: 'change' }],
+        startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
+        endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
+        scopeType: [{ required: true, message: '请选择适用类型', trigger: 'change' }]
+      }
+    }
+  },
+  computed: {
+    formLocked() {
+      return this.activeEditing
+    },
+    tierLocked() {
+      return this.activeEditing
+    },
+    scopeLocked() {
+      return this.activeEditing
+    }
+  },
+  created() {
+    this.getList()
+    this.loadStoreOptions()
+  },
+  beforeDestroy() {
+    if (this.productSearchTimer) {
+      clearTimeout(this.productSearchTimer)
+    }
+  },
+  methods: {
+    parseTime(time) {
+      if (!time) return ''
+      return time
+    },
+    displayStatusTag(status) {
+      const map = { 0: 'info', 1: 'warning', 2: 'success', 3: 'info', 4: 'danger' }
+      return map[status] || 'info'
+    },
+    canEdit(row) {
+      return [0, 1, 2].includes(row.displayStatus)
+    },
+    canEnable(row) {
+      return [0, 4].includes(row.displayStatus)
+    },
+    canDisable(row) {
+      return [1, 2].includes(row.displayStatus)
+    },
+    canDelete(row) {
+      return [0, 3, 4].includes(row.displayStatus)
+    },
+    loadStoreOptions() {
+      listStoreOptions().then(res => {
+        this.storeOptions = res.data || []
+      })
+    },
+    loadCategories() {
+      if (this.categoryLoaded || this.categoryLoading) {
+        return Promise.resolve(this.categoryOptions)
+      }
+      this.categoryLoading = true
+      return getAllStoreProductCategory().then(res => {
+        this.categoryOptions = res.data || []
+        this.categoryLoaded = true
+        return this.categoryOptions
+      }).finally(() => {
+        this.categoryLoading = false
+      })
+    },
+    ensureCategoriesLoaded() {
+      if (this.form.scopeType === 2) {
+        return this.loadCategories()
+      }
+      return Promise.resolve(this.categoryOptions)
+    },
+    formatProductLabel(item) {
+      return item.productName + ' (ID:' + item.productId + ')'
+    },
+    mergeProductOptions(list) {
+      const map = new Map()
+      ;(this.productOptions || []).forEach(item => map.set(item.productId, item))
+      ;(list || []).forEach(item => map.set(item.productId, item))
+      this.productOptions = Array.from(map.values())
+    },
+    loadProductOptions(keyword) {
+      if (!this.form.storeId) {
+        this.productOptions = []
+        return Promise.resolve([])
+      }
+      const requestSeq = ++this.productRequestSeq
+      this.productLoading = true
+      const params = {
+        storeId: this.form.storeId,
+        pageNum: 1,
+        pageSize: 50,
+        isShow: '1'
+      }
+      if (keyword) {
+        params.productName = keyword
+      }
+      return listStoreProduct(params).then(res => {
+        if (requestSeq !== this.productRequestSeq) {
+          return this.productOptions
+        }
+        this.mergeProductOptions(res.rows || [])
+        if (!keyword) {
+          this.productLoadedStoreId = this.form.storeId
+        }
+        return this.productOptions
+      }).finally(() => {
+        if (requestSeq === this.productRequestSeq) {
+          this.productLoading = false
+        }
+      })
+    },
+    searchProducts(keyword) {
+      if (!this.form.storeId) {
+        this.msgWarning('请先选择所属店铺')
+        return
+      }
+      if (this.productSearchTimer) {
+        clearTimeout(this.productSearchTimer)
+      }
+      this.productSearchTimer = setTimeout(() => {
+        this.loadProductOptions(keyword)
+      }, 300)
+    },
+    handleProductSelectVisible(visible) {
+      if (!visible || !this.form.storeId) {
+        return
+      }
+      if (this.productLoadedStoreId === this.form.storeId && this.productOptions.length) {
+        return
+      }
+      this.loadProductOptions('')
+    },
+    handleStoreChange() {
+      this.productOptions = []
+      this.productLoadedStoreId = null
+      if (this.form.scopeType === 3) {
+        this.form.scopeIds = []
+        this.$nextTick(() => {
+          if (this.$refs.form) {
+            this.$refs.form.clearValidate('scopeIds')
+          }
+        })
+      }
+    },
+    /** 切换适用类型时清空已选范围,避免分类/商品 ID 混用 */
+    handleScopeTypeChange() {
+      this.form.scopeIds = []
+      this.productOptions = []
+      this.productLoadedStoreId = null
+      if (this.form.scopeType === 2) {
+        this.ensureCategoriesLoaded()
+      }
+      this.$nextTick(() => {
+        if (this.$refs.form) {
+          this.$refs.form.clearValidate('scopeIds')
+        }
+      })
+    },
+    getList() {
+      this.loading = true
+      const params = { ...this.queryParams }
+      if (this.dateRange && this.dateRange.length === 2) {
+        params.params = { beginTime: this.dateRange[0], endTime: this.dateRange[1] }
+      }
+      listStorePromotion(params).then(res => {
+        this.promotionList = res.rows
+        this.total = res.total
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    resetFormData() {
+      this.form = {
+        id: null,
+        title: null,
+        storeId: null,
+        startTime: null,
+        endTime: null,
+        scopeType: 1,
+        scopeIds: [],
+        isStackable: 1,
+        isCapped: 0,
+        limitPerUser: 0,
+        remark: null,
+        tiers: [{ sortOrder: 1, thresholdAmount: 199, discountAmount: 30 }]
+      }
+      this.activeEditing = false
+      this.productOptions = []
+      this.productLoadedStoreId = null
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.dateRange = []
+      this.resetForm('queryForm')
+      this.handleQuery()
+    },
+    handleAdd() {
+      this.resetFormData()
+      this.open = true
+      this.title = '新增阶梯满减活动'
+    },
+    handleUpdate(row) {
+      this.resetFormData()
+      getStorePromotion(row.id).then(res => {
+        const data = res.data
+        this.form = {
+          id: data.id,
+          title: data.title,
+          storeId: data.storeId,
+          startTime: data.startTime,
+          endTime: data.endTime,
+          scopeType: data.scopeType,
+          scopeIds: data.scopeIds || [],
+          isStackable: data.isStackable,
+          isCapped: data.isCapped,
+          limitPerUser: data.limitPerUser,
+          remark: data.remark,
+          tiers: (data.tiers && data.tiers.length) ? data.tiers.map(t => ({
+            sortOrder: t.sortOrder,
+            thresholdAmount: t.thresholdAmount,
+            discountAmount: t.discountAmount
+          })) : [{ sortOrder: 1, thresholdAmount: 199, discountAmount: 30 }]
+        }
+        this.activeEditing = data.displayStatus === 2
+        if (data.scopeType === 3 && data.scopeProducts && data.scopeProducts.length) {
+          this.productOptions = data.scopeProducts.map(item => ({
+            productId: item.productId,
+            productName: item.productName,
+            image: item.image
+          }))
+          this.productLoadedStoreId = data.storeId
+        }
+        if (data.scopeType === 2) {
+          this.ensureCategoriesLoaded()
+        }
+        this.open = true
+        this.title = '编辑阶梯满减活动'
+      })
+    },
+    handleDetail(row) {
+      this.detail = null
+      getStorePromotion(row.id).then(res => {
+        this.detail = res.data
+        this.detailOpen = true
+      })
+    },
+    addTier() {
+      if (this.form.tiers.length >= 10) {
+        this.msgWarning('最多10档')
+        return
+      }
+      const last = this.form.tiers[this.form.tiers.length - 1]
+      this.form.tiers.push({
+        sortOrder: this.form.tiers.length + 1,
+        thresholdAmount: last ? Number(last.thresholdAmount) + 100 : 100,
+        discountAmount: last ? Number(last.discountAmount) + 10 : 10
+      })
+    },
+    removeTier(index) {
+      if (this.form.tiers.length <= 1) {
+        this.msgWarning('至少保留一档')
+        return
+      }
+      this.form.tiers.splice(index, 1)
+    },
+    submitForm() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        if (this.form.scopeType !== 1 && (!this.form.scopeIds || !this.form.scopeIds.length)) {
+          this.msgWarning(this.form.scopeType === 2 ? '请选择指定分类' : '请选择指定商品')
+          return
+        }
+        const scopeIds = this.form.scopeType === 1
+          ? []
+          : (this.form.scopeIds || []).map(id => Number(id)).filter(id => !Number.isNaN(id))
+        const payload = {
+          ...this.form,
+          scopeType: this.form.scopeType,
+          scopeIds
+        }
+        if (this.activeEditing) {
+          delete payload.tiers
+        }
+        const req = payload.id ? updateStorePromotion(payload) : addStorePromotion(payload)
+        req.then(() => {
+          this.msgSuccess(payload.id ? '修改成功' : '新增成功')
+          this.open = false
+          this.getList()
+        })
+      })
+    },
+    cancel() {
+      this.open = false
+    },
+    handleEnable(row) {
+      this.$confirm('确认启用该活动?', '提示', { type: 'warning' }).then(() => {
+        return enableStorePromotion(row.id)
+      }).then(() => {
+        this.msgSuccess('启用成功')
+        this.getList()
+      }).catch(() => {})
+    },
+    handleDisable(row) {
+      this.$confirm('确认停用该活动?停用后用户将无法参与。', '提示', { type: 'warning' }).then(() => {
+        return disableStorePromotion(row.id)
+      }).then(() => {
+        this.msgSuccess('停用成功')
+        this.getList()
+      }).catch(() => {})
+    },
+    handleDelete(row) {
+      this.$confirm('是否确认删除活动"' + row.title + '"?', '警告', { type: 'warning' }).then(() => {
+        return delStorePromotion(row.id)
+      }).then(() => {
+        this.msgSuccess('删除成功')
+        this.getList()
+      }).catch(() => {})
+    },
+    handleExport() {
+      const params = { ...this.queryParams }
+      if (this.dateRange && this.dateRange.length === 2) {
+        params.params = { beginTime: this.dateRange[0], endTime: this.dateRange[1] }
+      }
+      this.$confirm('是否确认导出?', '警告', { type: 'warning' }).then(() => {
+        return exportStorePromotion(params)
+      }).then(res => {
+        this.download(res.msg)
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 8px; }
+.tip-text { color: #909399; font-size: 12px; margin-left: 8px; }
+.detail-block { margin-top: 12px; }
+.detail-block-title {
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+  margin-bottom: 8px;
+  padding-left: 8px;
+  border-left: 3px solid #409EFF;
+}
+.detail-empty { color: #909399; font-size: 13px; padding: 8px 0; }
+.detail-product-thumb { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; vertical-align: middle; }
+.detail-product-preview { max-width: 200px; max-height: 200px; object-fit: contain; }
+</style>