VWCG-1120 消费策略适用账户与命中规则技术开发文档

版本:V1.1 修订评审稿
日期:2026-07-28
目标仓库:store
当前分支:codex/VWCG-1121-pc-account-settings-20260727
风险等级:高
实施结论:核心方案已按需求收敛,可进入研发评审;编码前需确认 VWCG-1121 环境依赖和管理费资格规则,UI 验收前需完成原型视觉核对

1. 结论摘要

本需求不仅是在 PC 表单增加多选字段,还会改变支付前的策略资格判断。首期采用“账户关系表 + 允许余额不足的原始金额预分摊 + 商户隔离的账户交集查询 + 异步策略计算标识 + 单字段资格快照”的最小闭环方案:

  1. 新建 ydy_marketing_strategy_account,保存策略与个人、餐补、赠送账户的多选关系。
  2. 对订单原始应付金额做只读账户使用预览。预览只分配现有可用金额,允许返回缺口,不因原始金额余额不足而提前失败。
  3. 只把预分摊金额大于 0.00 的一级账户视为“本单使用账户”。
  4. 使用带 store_id 条件的 EXISTS 账户交集查询;高优先级账户不匹配时自然继续下一条策略。
  5. 计算优惠或管理费后,才按最终应付金额执行严格余额校验和正式扣款。
  6. 异步事件显式保存“策略已计算”标识。策略 ID 为 0 也代表合法冻结的“未命中”,队列不得重算。
  7. 历史未删除策略回填全部三种账户;新建策略默认不选择账户,由用户主动选择。
  8. 订单只增加一个最小资格快照;最终账户拆分继续使用 VWCG-1121 已有字段。

不在首期强制范围内:新增全局功能开关、完整余额审计 JSON、额外三列资格快照、首单优惠候选算法重构。

2. 需求追踪矩阵

证据标识:

编号 云效需求 技术落点 状态
R1 新增、编辑增加“适用账户” PC 弹窗增加 accountTypes REQ
R2 个人、餐补、赠送账户支持多选 账户枚举 1/2/3 + 关系表 REQ
R3 标签旁显示小 i 提示 el-tooltip + el-icon-info,文案采用云效原文 REQ
R4 多账户为 OR,任一交集即命中 usedAccountTypes ∩ strategyAccountTypes ≠ ∅ REQ
R5 未选择不允许保存 前后端均校验非空,错误文案一致 REQ
R6 列表增加展示列、筛选和回显 列表/详情接口、筛选区、表格列 REQ
R7 高优先级账户不匹配继续下一条 账户条件进入同一策略查询 REQ
R8 历史策略默认全部账户 迁移回填 1/2/3 REQ
A1 有余额但预分摊为 0 不算使用 只提取预分摊金额大于 0 的账户 REQ
A2 同步、异步、重试结果一致 同一预览合同 + 异步计算标识 REQ

3. 需求解释与边界

3.1 账户映射

产品名称 算法键 存储值 当前组成 命中条件
个人账户 cash 1 活动本金 + 可提现现金 + 合法透支 预览 cash > 0
餐补账户 subsidy 2 消费时点有效的餐补批次 预览 subsidy > 0
赠送账户 gift 3 独立赠送余额 预览 gift > 0

个人账户的内部现金拆分属于 VWCG-1121 结算事实,对消费策略只暴露一个“个人账户”选项。该映射属于当前分支代码事实,不新增第四种策略账户。

3.2 “账户有余额”不等于“账户被使用”

策略资格只看原始金额预分摊:

used_account_types = {
  cash      if preview.cash > 0,
  subsidy   if preview.subsidy > 0,
  gift      if preview.gift > 0
}

例如扣款顺序为餐补 → 个人 → 赠送,订单原始应付 20 元,餐补 30 元、个人 100 元,则预分摊为餐补 20 元、个人 0 元。个人账户虽然有余额,但个人账户策略不命中。

3.3 原始金额允许存在预分摊缺口

账户资格预览不能复用当前严格扣款方法的“余额不足即抛异常”行为。

例如:

原始应付:100 元
可用余额:餐补 50 元 + 个人 30 元
策略:固定优惠 30 元
最终应付:70 元

原始金额预览应返回:

used_account_types = [餐补, 个人]
allocated_amount = 80.00
shortfall_amount = 20.00

随后允许这两个账户参与策略匹配。只有优惠计算完成后,才校验最终应付 70 元能否正式扣款。否则会把“优惠后可支付”的合法订单错误拦截为余额不足。

3.4 非目标

4. 当前实现与关键缺口

4.1 配置链路

当前消费策略通过独立关系表保存部门、身份、档口、场景、餐次和时间段。新增适用账户沿用相同模式,避免把数组保存为主表逗号字符串或 JSON。

当前接口:

POST /api/marketingStrategy/lists
POST /api/marketingStrategy/create
POST /api/marketingStrategy/update
POST /api/marketingStrategy/detail
POST /api/marketingStrategy/delete

4.2 匹配链路

当前 Consume::applyMarketingStrategy() 先选择策略,再校验最终余额;findApplicableMarketingStrategy() 按身份、部门、档口、场景和时间段过滤后直接取一条。

本需求需要在选策略前得到原始金额账户使用预览,并把账户条件放入同一候选查询。不能先取最高优先级策略,再在 PHP 中因账户不匹配返回空。

4.3 异步队列缺口

当前队列使用 marketing_strategy_id <= 0 判断是否需要重新计算。该判断无法区分:

  1. 还没有计算策略;
  2. 已经计算,但合法结果是没有命中策略。

本需求必须增加独立的 marketing_strategy_evaluated 标识;不能继续用策略 ID 是否大于 0 代表计算状态。

4.4 三账户能力依赖

当前分支已包含:

本需求需要新增“允许缺口的只读账户使用预览”,不能直接调用会写余额的 deduct(),也不能直接复用余额不足即抛异常的严格 planDeduction()

5. 核心架构决策

5.1 ADR-01:资格使用允许缺口的原始金额预览

状态:ACCEPTED

flowchart LR A["原始应付金额"] --> B["读取同一账户快照与扣款顺序"] B --> C["允许缺口的只读预分摊"] C --> D["得到使用账户与缺口"] D --> E["按人员/场景/时间/账户交集选策略"] E --> F["计算优惠或管理费"] F --> G{"最终金额余额是否充足"} G -->|否| H["返回余额不足"] G -->|是| I["正式扣款并保存订单"]

理由:

5.2 ADR-02:同步预览与扣款使用同一账户事实

状态:ACCEPTED

同步链路在账户库事务内锁定用户和有效餐补行,构建一次账户事实快照。资格预览和最终扣款都基于这份快照,不能预览时无锁查一次、扣款时重新查一次。

建议把账户能力拆成:

loadLockedAccountSnapshot()
previewAccountUsage()
deductWithLockedSnapshot()

三者复用同一余额结构和扣款顺序。现有跨业务库、账户库事务继续使用账户结算单和订单号作为幂等锚点,不在本需求中扩展分布式事务。

5.3 ADR-03:管理费不触发策略二次选择

状态:PENDING

若原始 20 元仅预览使用餐补,命中管理费策略后最终应付 25 元,新增 5 元可能使用个人账户。建议资格仍冻结为“仅餐补”,不根据最终拆分重新选择策略。

原因是二次选择会形成:

命中策略 → 金额变化 → 使用账户变化 → 重选策略

该规则需要产品和研发在编码前确认,但不改变其余模块的实施方案。

5.4 ADR-04:异步“未命中策略”也是冻结结果

状态:ACCEPTED

异步事件必须保存:

{
  "marketing_strategy_evaluated": 1,
  "strategy_snapshot_version": 1,
  "marketing_strategy_id": 0,
  "strategy_account_types": [],
  "strategy_hit_account_types": [2],
  "pay_price": "20.00"
}

marketing_strategy_evaluated = 1 时,队列必须沿用全部冻结字段。marketing_strategy_id = 0 表示已计算但未命中,不允许重算。

6. 数据库设计

6.1 账户关系表

CREATE TABLE `ydy_marketing_strategy_account` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `strategy_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '消费策略ID',
  `account_type` tinyint(3) unsigned NOT NULL DEFAULT '1'
    COMMENT '适用账户:1个人账户 2餐补账户 3赠送账户',
  `store_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '商城ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_store_strategy_account`
    (`store_id`,`strategy_id`,`account_type`),
  KEY `idx_strategy_account` (`strategy_id`,`account_type`),
  KEY `idx_store_account` (`store_id`,`account_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci COMMENT='消费策略-适用账户关联';

约束:

6.2 历史策略回填

只回填未删除历史策略,SQL 可重复执行:

INSERT INTO `ydy_marketing_strategy_account`
  (`strategy_id`, `account_type`, `store_id`)
SELECT ms.`id`, accounts.`account_type`, ms.`store_id`
FROM `ydy_marketing_strategy` ms
CROSS JOIN (
  SELECT 1 AS `account_type`
  UNION ALL SELECT 2
  UNION ALL SELECT 3
) accounts
LEFT JOIN `ydy_marketing_strategy_account` msa
  ON msa.`store_id` = ms.`store_id`
 AND msa.`strategy_id` = ms.`id`
 AND msa.`account_type` = accounts.`account_type`
WHERE ms.`is_delete` = 0
  AND msa.`id` IS NULL;

迁移校验:

SELECT ms.store_id, ms.id
FROM ydy_marketing_strategy ms
LEFT JOIN ydy_marketing_strategy_account msa
  ON msa.store_id = ms.store_id
 AND msa.strategy_id = ms.id
WHERE ms.is_delete = 0
GROUP BY ms.store_id, ms.id
HAVING COUNT(DISTINCT msa.account_type) <> 3;

结果必须为 0 行。

6.3 最小订单资格快照

ydy_meal_order 增加一个字段:

ALTER TABLE `ydy_meal_order`
ADD COLUMN `strategy_account_snapshot` varchar(512) NOT NULL DEFAULT ''
  COMMENT '消费策略账户资格快照JSON'
  AFTER `actual_strategy_value`;

示例:

{
  "v": 1,
  "configured": [1, 3],
  "used": [3],
  "original_amount": "20.00",
  "allocated_amount": "20.00",
  "shortfall_amount": "0.00",
  "order": [2, 1, 3]
}

该字段只保存解释策略资格所需的最小事实,不保存账户全量余额、身份、姓名或凭据。最终 cash/subsidy/gift 和现金内部拆分继续使用 VWCG-1121 已有字段。

7. API 合同

7.1 账户枚举

[
  {"value": 1, "label": "个人账户", "key": "cash"},
  {"value": 2, "label": "餐补账户", "key": "subsidy"},
  {"value": 3, "label": "赠送账户", "key": "gift"}
]

前端可保存本地常量;后端必须保留唯一枚举定义,供校验、名称转换、关系保存和策略匹配复用。

7.2 列表

POST /api/marketingStrategy/lists

新增请求参数:

{
  "accountTypes": [1, 3]
}

筛选为空表示不限账户;多选筛选按 OR 处理。

列表新增字段:

{
  "account_types": [1, 3],
  "account_names": "个人账户、赠送账户"
}

列表 account_names 使用字符串,与现有 department_namesrestaurant_names 风格一致,不再增加第二个同义展示字段。

7.3 新增和编辑

POST /api/marketingStrategy/create

POST /api/marketingStrategy/update

{
  "accountTypes": [1, 2, 3]
}

服务端规则:

7.4 详情

POST /api/marketingStrategy/detail

{
  "account_types": [1, 2, 3],
  "account_names": ["个人账户", "餐补账户", "赠送账户"]
}

迁移窗口内遇到无关系旧数据时,可短期兼容读为 [1,2,3] 并记录告警。迁移完成后不能长期把空关系静默解释为全部账户。

8. 后端改造

8.1 配置 CRUD

MarketingStrategy 控制器:

MarketingStrategy 校验器:

'accountTypes' => 'require|array|checkAccountTypes'

MarketingStrategy 服务:

8.2 允许缺口的账户使用预览

新增纯计算方法:

GiftAccountPolicy::previewAccountUsage(
    string $originalAmount,
    array $balances,
    array $deductionOrder,
    string $overdraftLimit = '0.00'
): array

返回:

[
    'subsidy' => '50.00',
    'cash' => '30.00',
    'gift' => '0.00',
    'used_account_types' => [2, 1],
    'allocated_amount' => '80.00',
    'shortfall_amount' => '20.00',
    'deduction_order' => ['subsidy', 'cash', 'gift'],
]

实现规则:

  1. 复用 planDeduction() 的账户顺序和现金内部顺序。
  2. 分配到可用金额耗尽为止。
  3. 不因 shortfall_amount > 0 抛异常。
  4. 不写数据库、Redis、流水或结算单。
  5. 金额计算继续使用整数分或项目现有高精度金额工具。

严格 planDeduction() 仍用于最终扣款,不修改其余额不足异常合同。

8.3 锁定账户快照

AccountSettlementService 内增加:

loadLockedAccountSnapshot($accountDb, array $params): array
deductWithLockedSnapshot($accountDb, array $params, array $snapshot): array

快照至少包含:

同步预览和最终扣款复用同一快照,避免并发期间资格与正式扣款看到不同余额。

8.4 策略候选查询

扩展签名:

findApplicableMarketingStrategy(
    array $profile,
    string $consumeTime,
    array $usedAccountTypes
): ?array

查询必须包含商户隔离和账户交集:

->where('ms.store_id', $this->storeId)
->whereExists(function ($query) use ($usedAccountTypes) {
    $query->name('marketing_strategy_account')
        ->alias('msa')
        ->whereRaw('msa.strategy_id = ms.id')
        ->whereRaw('msa.store_id = ms.store_id')
        ->whereIn('msa.account_type', $usedAccountTypes);
})

继续保留身份、部门、档口、场景、时间段、有效期和优先级排序。使用 EXISTS 而不是额外普通 JOIN,避免账户多选与时间段联表放大结果行。

usedAccountTypes 为空时直接返回无策略。原始金额为 0 的订单不命中金额策略。

9. 各消费链路设计

9.1 实时消费

  1. 解析人员、档口、场景、餐次、消费时间和原始金额。
  2. 读取扣款配置和透支额度。
  3. 开启账户库事务,锁定用户和有效餐补,构建账户快照。
  4. 对原始金额调用 previewAccountUsage()
  5. 使用 used_account_types 查询消费策略。
  6. 计算最终应付金额,并按最终金额进行严格余额校验。
  7. 开启业务库事务并创建订单。
  8. 基于同一锁定快照正式扣款,保存最终拆分和资格快照。
  9. 按现有顺序提交两库事务;失败使用订单号和账户结算单幂等回读/补偿。

9.2 异步 Redis 快照消费

  1. 在卡片 Redis 锁内读取三账户快照。
  2. 对原始金额执行允许缺口的账户使用预览。
  3. 查询策略并计算最终应付金额。
  4. 冻结以下字段:
{
  "marketing_strategy_evaluated": 1,
  "strategy_snapshot_version": 1,
  "marketing_strategy_id": 0,
  "marketing_strategy_time_range_id": 0,
  "strategy_account_types": [],
  "strategy_hit_account_types": [2],
  "strategy_account_snapshot": {},
  "management_fee": "0.00",
  "discount_amount": "0.00",
  "pay_price": "20.00"
}
  1. 用最终应付金额更新 Redis 账户快照。
  2. 将冻结字段完整放入队列消息。

策略 ID 为 0 仍是合法冻结结果。

9.3 队列扣款

if ((int)($params['marketing_strategy_evaluated'] ?? 0) === 1) {
    // 沿用策略 ID、金额、账户资格和版本;策略 ID 可为 0
} else {
    // 仅兼容旧消息,且订单/结算单尚未创建时才允许完整计算一次
}

其他约束:

9.4 绑盘后结算

如果“餐补有效性按绑盘时间还是结算时间”在现有结算链路中没有统一合同,必须先以最终扣款使用的规则为准,不能让资格预览单独选择另一个时间口径。

9.5 线上订餐和其他来源

来源 要求
绑盘机 1 结算前预览并冻结
消费机 2 实时和异步合同一致
虚拟订单 3 复用统一策略服务
线上订餐 4 最终余额结算时冻结
外部订单 5 复用统一策略服务
闸机 6 复用统一策略服务
点餐机 7 复用统一策略服务
聚合父单 10 不重复选择策略或扣款

所有来源都调用统一的“账户快照 → 使用预览 → 策略选择”能力,不在入口内复制账户算法。

10. PC 前端设计

10.1 筛选区

10.2 列表

10.3 新增和编辑

10.4 原型视觉门禁

当前环境仍无法访问原型站。字段位置、图标、列宽、弹窗高度和筛选控件样式必须在 UI 验收前由可访问环境核对。功能合同不依赖原型可访问性,视觉验收不能省略。

11. 并发、幂等与一致性

11.1 同步一致性

11.2 异步一致性

11.3 配置变化

12. 历史兼容与发布顺序

推荐顺序:

  1. 确认 VWCG-1121 已合并,相关 DDL 和三账户结算测试通过。
  2. 执行账户关系表建表和历史未删除策略三账户回填。
  3. 执行迁移校验,结果为 0 行。
  4. 发布后端兼容读取能力。
  5. 同一发布窗口发布强制非空校验和 PC 前端。
  6. 小流量验证实时消费。
  7. 再验证异步、队列、绑盘和线上订餐。

兼容规则:

13. 测试方案

13.1 账户使用预览单元测试

  1. 按扣款顺序分配账户。
  2. 有余额但分摊为 0 不进入使用账户。
  3. 个人账户合并活动本金、可提现现金和透支。
  4. 原始金额余额不足时返回缺口,不抛异常。
  5. 原始金额 100、余额 80、优惠后 70 可以进入最终扣款。
  6. 优惠后仍余额不足时由严格扣款拒绝。
  7. 0 元和负数输入边界。

13.2 策略配置合同测试

13.3 优先级测试

高优先级 低优先级 本单账户 预期
个人 餐补 餐补 命中低优先级
个人、赠送 餐补 赠送 命中高优先级
个人 餐补、赠送 餐补、个人 命中高优先级
个人 餐补 赠送 不命中任何策略

13.4 异步和重试测试

  1. 异步命中策略,队列沿用策略与金额。
  2. 异步未命中策略且 ID 为 0,队列不重算。
  3. 事件入队后修改策略,队列结果不变化。
  4. 事件入队后修改余额或扣款顺序,队列资格不变化。
  5. 重复消息只生成一个订单和一个账户结算单。
  6. 旧版无 marketing_strategy_evaluated 消息只在无订单、无结算单时兼容计算。

13.5 云效验收映射

验收 自动化建议
仅个人使用命中个人策略 策略计算集成测试
仅餐补不命中个人策略 策略计算集成测试
餐补+个人命中个人策略 多账户预览测试
餐补+赠送命中个人/赠送策略 OR 交集测试
有余额但分摊 0 不算使用 边界单测
高优先级不匹配继续低优先级 查询集成测试
CRUD/列表一致 API 合同 + 前端测试
异步/同步一致、重试不重复 Redis/队列/幂等集成测试
历史策略行为不变 迁移前后对比测试

13.6 必跑命令

vendor/bin/phpunit tests/GiftAccount
vendor/bin/phpunit tests/AccountSetting
vendor/bin/phpunit tests/apifox/pc/MarketingStrategyScenarioTimeRangeContractTest.php
vendor/bin/phpunit tests/MarketingStrategy
vendor/bin/phpunit

cd public/static
npm run lint
npm run unit
npm run build

应新增真实 HTTP 或服务集成用例,保存同步、异步、无策略队列、绑盘和线上订餐的订单及结算快照证据。

14. 异步导出联动

本需求修改的是消费策略列表,不属于仓库登记的 9 类异步导出列表,因此无需新增异步导出筛选或展示列。

策略资格会改变新订单的优惠、管理费和实付金额。消费订单及人员、部门、设备消费明细的异步导出必须继续读取已持久化订单金额,不得按当前策略重新计算。

结论:

无需修改异步导出逻辑,但必须回归页面金额、导出金额和订单持久化金额一致。

15. 发布、回滚与可选增强

15.1 发布验证

日志只能记录订单号、商户 ID、账户类型集合、策略 ID、快照版本和必要金额摘要,不记录完整余额或个人敏感信息。

15.2 回滚

  1. 回滚 PC 前端和后端账户匹配代码。
  2. 旧代码忽略账户关系表,历史策略恢复原有匹配行为。
  3. 保留关系表和订单资格快照,不删除交易证据。
  4. 不回滚已发生订单的优惠或账户扣款。
  5. 默认前向修复,不在发布回滚中删除数据表或字段。

15.3 可选增强

以下能力不阻塞首期:

16. 开发切片与门禁

16.1 建议切片

切片 范围 独立验收
S1 关系表、迁移、CRUD、列表、PC 页面 保存、筛选、回显、历史兼容
S2 允许缺口的账户使用预览、商户隔离策略查询 账户资格、优先级、余额缺口
S3 实时、异步、队列、绑盘、线上订餐接入 同步异步一致、ID=0 不重算
S4 自动化、迁移验证、发布和回滚证据 全量回归与证据包

工时应在 S1-S4 的实际代码改动点和测试范围确认后,分别填写到云效研发子任务;本评审稿不再用附加增强项提前固化总工时。

16.2 开发前门禁

  1. VWCG-1121 已合并,三账户 DDL 和结算能力在目标环境可用。
  2. 产品和研发确认“管理费导致最终新增账户时不反向重选策略”。
  3. UI 验收前由可访问环境完成原型视觉核对。

其中门禁 1 阻塞全链路联调,门禁 2 阻塞管理费策略编码,门禁 3 不阻塞后端开发但阻塞 UI 验收。