{
  "source_path": "work_store/VWCG-1119-consume-limit-account-scope/technical-development-design.html",
  "status": "historical-conflict-snapshot-not-an-active-contract",
  "original_sha256": "cb33819d7ef26ba786fc044401157b19420a3d205ab3b5141722d82c008113ee",
  "original_text": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<<<<<<< HEAD\n  <title>VWCG-1119 消费限制按受限账户生效｜技术开发文档</title>\n  <style>\n    :root {\n      --ink: #172033;\n      --muted: #61708a;\n      --line: #dfe5ef;\n      --paper: #ffffff;\n      --canvas: #f4f7fb;\n      --brand: #2457d6;\n      --brand-soft: #edf3ff;\n      --good: #13795b;\n      --good-soft: #eaf8f3;\n      --warn: #9a5b00;\n      --warn-soft: #fff5df;\n      --risk: #b42318;\n      --risk-soft: #fff0ee;\n      --shadow: 0 16px 42px rgba(31, 45, 72, .09);\n    }\n    * { box-sizing: border-box; }\n    html { scroll-behavior: smooth; }\n    body {\n      margin: 0;\n      color: var(--ink);\n      background: var(--canvas);\n      font: 15px/1.72 -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n        \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n    }\n    a { color: var(--brand); text-decoration: none; }\n    code {\n      padding: .12em .38em;\n      border-radius: 5px;\n      background: #f0f3f8;\n      color: #31415e;\n      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n      font-size: .92em;\n    }\n    .shell {\n      display: grid;\n      grid-template-columns: 230px minmax(0, 900px);\n      gap: 32px;\n      width: min(1200px, calc(100% - 40px));\n      margin: 34px auto 80px;\n    }\n    aside {\n      position: sticky;\n      top: 24px;\n      align-self: start;\n      max-height: calc(100vh - 48px);\n      overflow: auto;\n      padding: 18px;\n      border: 1px solid var(--line);\n      border-radius: 16px;\n      background: rgba(255,255,255,.88);\n      box-shadow: 0 8px 24px rgba(31,45,72,.05);\n      backdrop-filter: blur(10px);\n    }\n    .toc-title {\n      margin: 0 0 10px;\n      color: var(--muted);\n      font-size: 12px;\n      font-weight: 700;\n      letter-spacing: .08em;\n      text-transform: uppercase;\n    }\n    aside a {\n      display: block;\n      padding: 7px 9px;\n      border-radius: 8px;\n      color: #42506a;\n      font-size: 13px;\n    }\n    aside a:hover { background: var(--brand-soft); color: var(--brand); }\n    main { min-width: 0; }\n    .hero {\n      position: relative;\n      overflow: hidden;\n      padding: 42px;\n      border-radius: 24px;\n      color: white;\n      background:\n        radial-gradient(circle at 92% 12%, rgba(255,255,255,.20), transparent 28%),\n        linear-gradient(135deg, #173b9d 0%, #2965e8 66%, #3e82ed 100%);\n      box-shadow: var(--shadow);\n    }\n    .eyebrow {\n      margin: 0 0 12px;\n      color: #cfe0ff;\n      font-size: 12px;\n      font-weight: 800;\n      letter-spacing: .12em;\n    }\n    h1 {\n      max-width: 760px;\n      margin: 0;\n      font-size: clamp(28px, 5vw, 44px);\n      line-height: 1.22;\n      letter-spacing: -.03em;\n    }\n    .hero p { max-width: 720px; margin: 18px 0 0; color: #eaf1ff; }\n    .meta {\n      display: flex;\n      flex-wrap: wrap;\n      gap: 9px;\n      margin-top: 25px;\n    }\n    .pill {\n      padding: 6px 11px;\n      border: 1px solid rgba(255,255,255,.24);\n      border-radius: 999px;\n      background: rgba(255,255,255,.10);\n      font-size: 12px;\n    }\n    section {\n      margin-top: 22px;\n      padding: 30px;\n      border: 1px solid var(--line);\n      border-radius: 19px;\n      background: var(--paper);\n      box-shadow: 0 7px 22px rgba(31,45,72,.045);\n    }\n    h2 { margin: 0 0 18px; font-size: 23px; letter-spacing: -.02em; }\n    h3 { margin: 24px 0 10px; font-size: 17px; }\n    p { margin: 10px 0; }\n    ul, ol { margin: 10px 0; padding-left: 22px; }\n    li + li { margin-top: 6px; }\n    .lead {\n      margin: 0;\n      color: #35445e;\n      font-size: 17px;\n    }\n    .callout {\n      margin: 18px 0;\n      padding: 17px 19px;\n      border-left: 4px solid var(--brand);\n      border-radius: 10px;\n      background: var(--brand-soft);\n    }\n    .callout strong { display: block; margin-bottom: 4px; }\n    .callout.warn { border-left-color: var(--warn); background: var(--warn-soft); }\n    .callout.risk { border-left-color: var(--risk); background: var(--risk-soft); }\n    .grid {\n      display: grid;\n      grid-template-columns: repeat(2, minmax(0, 1fr));\n      gap: 14px;\n      margin-top: 16px;\n    }\n    .card {\n      padding: 18px;\n      border: 1px solid var(--line);\n      border-radius: 13px;\n      background: #fbfcfe;\n    }\n    .card h3 { margin: 0 0 7px; font-size: 15px; }\n    .card p { margin: 0; color: var(--muted); font-size: 14px; }\n    .flow {\n      display: grid;\n      grid-template-columns: repeat(5, 1fr);\n      gap: 8px;\n      align-items: center;\n      margin: 20px 0 6px;\n    }\n    .step {\n      position: relative;\n      min-height: 94px;\n      padding: 15px 12px;\n      border: 1px solid #cdd9ef;\n      border-radius: 12px;\n      background: #f7faff;\n      text-align: center;\n      font-size: 13px;\n    }\n    .step b { display: block; margin-bottom: 5px; color: var(--brand); }\n    .step:not(:last-child)::after {\n      content: \"→\";\n      position: absolute;\n      z-index: 2;\n      right: -14px;\n      top: 35px;\n      color: var(--brand);\n      font-size: 20px;\n      font-weight: 800;\n    }\n    .table-wrap { overflow-x: auto; margin-top: 14px; }\n    table { width: 100%; border-collapse: collapse; font-size: 14px; }\n    th, td {\n      padding: 11px 12px;\n      border-bottom: 1px solid var(--line);\n      text-align: left;\n      vertical-align: top;\n    }\n    th { background: #f6f8fc; color: #40506b; font-weight: 700; }\n    tr:last-child td { border-bottom: 0; }\n    .tag {\n      display: inline-block;\n      margin: 1px 4px 1px 0;\n      padding: 2px 7px;\n      border-radius: 999px;\n      background: var(--brand-soft);\n      color: var(--brand);\n      font-size: 12px;\n      font-weight: 700;\n    }\n    .tag.good { background: var(--good-soft); color: var(--good); }\n    .tag.warn { background: var(--warn-soft); color: var(--warn); }\n    .checklist { list-style: none; padding: 0; }\n    .checklist li { position: relative; padding-left: 27px; }\n    .checklist li::before {\n      content: \"✓\";\n      position: absolute;\n      left: 0;\n      top: 0;\n      color: var(--good);\n      font-weight: 900;\n    }\n    .decision {\n      display: grid;\n      grid-template-columns: 44px 1fr;\n      gap: 13px;\n      padding: 15px 0;\n      border-bottom: 1px solid var(--line);\n    }\n    .decision:last-child { border-bottom: 0; }\n    .priority {\n      width: 42px;\n      height: 29px;\n      display: grid;\n      place-items: center;\n      border-radius: 8px;\n      color: var(--risk);\n      background: var(--risk-soft);\n      font-size: 12px;\n      font-weight: 900;\n    }\n    .decision p { margin: 3px 0 0; color: var(--muted); }\n    footer {\n      padding: 24px 8px;\n      color: var(--muted);\n      text-align: center;\n      font-size: 12px;\n    }\n    @media (max-width: 900px) {\n      .shell { display: block; width: min(100% - 24px, 900px); margin-top: 12px; }\n      aside { display: none; }\n      .hero { padding: 30px 24px; }\n      section { padding: 23px 20px; }\n      .grid { grid-template-columns: 1fr; }\n      .flow { grid-template-columns: 1fr; }\n      .step { min-height: 0; text-align: left; }\n      .step:not(:last-child)::after { content: \"↓\"; right: 50%; top: auto; bottom: -20px; }\n    }\n    @media print {\n      body { background: white; }\n      .shell { display: block; width: 100%; margin: 0; }\n      aside { display: none; }\n      .hero, section { box-shadow: none; break-inside: avoid; }\n      .hero { border-radius: 0; }\n=======\n  <title>VWCG-1119 消费限制按受限账户余额生效｜技术开发文档 V1.2</title>\n  <style>\n    :root {\n      --ink:#172033; --muted:#637089; --line:#dfe5ef; --paper:#fff;\n      --canvas:#f4f7fb; --brand:#2457d6; --brand-soft:#edf3ff;\n      --good:#13795b; --good-soft:#eaf8f3; --warn:#945800;\n      --warn-soft:#fff5df; --risk:#b42318; --risk-soft:#fff0ee;\n    }\n    * { box-sizing:border-box; }\n    html { scroll-behavior:smooth; }\n    body {\n      margin:0; color:var(--ink); background:var(--canvas);\n      font:15px/1.72 -apple-system,BlinkMacSystemFont,\"Segoe UI\",\"PingFang SC\",\"Microsoft YaHei\",sans-serif;\n    }\n    code {\n      padding:.12em .38em; border-radius:5px; background:#f0f3f8;\n      color:#31415e; font: .92em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;\n    }\n    .shell {\n      display:grid; grid-template-columns:230px minmax(0,920px); gap:32px;\n      width:min(1220px,calc(100% - 40px)); margin:34px auto 80px;\n    }\n    aside {\n      position:sticky; top:24px; align-self:start; max-height:calc(100vh - 48px);\n      overflow:auto; padding:18px; border:1px solid var(--line); border-radius:16px;\n      background:rgba(255,255,255,.92);\n    }\n    aside p { margin:0 0 10px; color:var(--muted); font-size:12px; font-weight:800; letter-spacing:.08em; }\n    aside a { display:block; padding:7px 9px; border-radius:8px; color:#42506a; font-size:13px; text-decoration:none; }\n    aside a:hover { color:var(--brand); background:var(--brand-soft); }\n    main { min-width:0; }\n    .hero {\n      padding:42px; border-radius:24px; color:#fff;\n      background:radial-gradient(circle at 92% 12%,rgba(255,255,255,.2),transparent 28%),\n                 linear-gradient(135deg,#173b9d 0%,#2965e8 66%,#3e82ed 100%);\n      box-shadow:0 16px 42px rgba(31,45,72,.09);\n    }\n    .eyebrow { margin:0 0 12px; color:#cfe0ff; font-size:12px; font-weight:800; letter-spacing:.12em; }\n    h1 { margin:0; max-width:780px; font-size:clamp(28px,5vw,44px); line-height:1.22; letter-spacing:-.03em; }\n    .hero>p:not(.eyebrow) { max-width:760px; margin:18px 0 0; color:#eaf1ff; }\n    .meta { display:flex; flex-wrap:wrap; gap:9px; margin-top:25px; }\n    .pill { padding:6px 11px; border:1px solid rgba(255,255,255,.24); border-radius:999px; background:rgba(255,255,255,.1); font-size:12px; }\n    section {\n      margin-top:22px; padding:30px; border:1px solid var(--line); border-radius:19px;\n      background:var(--paper); box-shadow:0 7px 22px rgba(31,45,72,.045);\n    }\n    h2 { margin:0 0 18px; font-size:23px; letter-spacing:-.02em; }\n    h3 { margin:24px 0 10px; font-size:17px; }\n    p { margin:10px 0; }\n    ul,ol { margin:10px 0; padding-left:22px; }\n    li+li { margin-top:6px; }\n    .lead { margin:0; color:#35445e; font-size:17px; }\n    .callout { margin:18px 0; padding:17px 19px; border-left:4px solid var(--brand); border-radius:10px; background:var(--brand-soft); }\n    .callout.warn { border-left-color:var(--warn); background:var(--warn-soft); }\n    .callout.risk { border-left-color:var(--risk); background:var(--risk-soft); }\n    .callout.good { border-left-color:var(--good); background:var(--good-soft); }\n    .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; margin-top:16px; }\n    .card { padding:18px; border:1px solid var(--line); border-radius:13px; background:#fbfcfe; }\n    .card h3 { margin:0 0 7px; font-size:15px; }\n    .card p { margin:0; color:var(--muted); font-size:14px; }\n    .flow { display:grid; grid-template-columns:repeat(5,1fr); gap:8px; margin:20px 0 6px; }\n    .step { position:relative; min-height:96px; padding:15px 11px; border:1px solid #cdd9ef; border-radius:12px; background:#f7faff; text-align:center; font-size:13px; }\n    .step b { display:block; margin-bottom:5px; color:var(--brand); }\n    .step:not(:last-child)::after { content:\"→\"; position:absolute; z-index:2; right:-14px; top:35px; color:var(--brand); font-size:20px; font-weight:800; }\n    .table-wrap { overflow-x:auto; margin-top:14px; }\n    table { width:100%; border-collapse:collapse; font-size:14px; }\n    th,td { padding:11px 12px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; }\n    th { background:#f6f8fc; color:#40506b; }\n    tr:last-child td { border-bottom:0; }\n    .tag { display:inline-block; margin:1px 4px 1px 0; padding:2px 7px; border-radius:999px; background:var(--brand-soft); color:var(--brand); font-size:12px; font-weight:700; }\n    .tag.good { background:var(--good-soft); color:var(--good); }\n    .tag.warn { background:var(--warn-soft); color:var(--warn); }\n    .decision { display:grid; grid-template-columns:44px 1fr; gap:13px; padding:15px 0; border-bottom:1px solid var(--line); }\n    .decision:last-child { border-bottom:0; }\n    .priority { width:42px; height:29px; display:grid; place-items:center; border-radius:8px; color:var(--risk); background:var(--risk-soft); font-size:12px; font-weight:900; }\n    .decision p { margin:3px 0 0; color:var(--muted); }\n    footer { padding:24px 8px; color:var(--muted); text-align:center; font-size:12px; }\n    @media(max-width:900px) {\n      .shell { display:block; width:min(100% - 24px,920px); margin-top:12px; }\n      aside { display:none; } .hero { padding:30px 24px; } section { padding:23px 20px; }\n      .grid,.flow { grid-template-columns:1fr; }\n      .step { min-height:0; text-align:left; }\n      .step:not(:last-child)::after { content:\"↓\"; right:50%; top:auto; bottom:-20px; }\n    }\n    @media print {\n      body { background:#fff; } .shell { display:block; width:100%; margin:0; } aside { display:none; }\n      .hero,section { box-shadow:none; break-inside:avoid; } .hero { border-radius:0; }\n>>>>>>> 5d05833ef476571c794ef3016336bc136526872c\n    }\n  </style>\n</head>\n<body>\n  <div class=\"shell\">\n    <aside aria-label=\"文档目录\">\n<<<<<<< HEAD\n      <p class=\"toc-title\">目录</p>\n      <a href=\"#summary\">1. 评审结论</a>\n      <a href=\"#scope\">2. 范围边界</a>\n      <a href=\"#gap\">3. 核心技术缺口</a>\n      <a href=\"#architecture\">4. 目标链路</a>\n      <a href=\"#data\">5. 数据与接口</a>\n      <a href=\"#implementation\">6. 实施清单</a>\n      <a href=\"#semantics\">7. 规则语义</a>\n      <a href=\"#test\">8. 验证策略</a>\n      <a href=\"#release\">9. 发布与回滚</a>\n      <a href=\"#decisions\">10. 待决策项</a>\n=======\n      <p>目录</p>\n      <a href=\"#summary\">1. 修订结论</a>\n      <a href=\"#rule\">2. 业务规则</a>\n      <a href=\"#channels\">3. 三类入口</a>\n      <a href=\"#architecture\">4. 目标架构</a>\n      <a href=\"#data\">5. 数据与接口</a>\n      <a href=\"#store\">6. store 改造</a>\n      <a href=\"#mini\">7. 小程序改造</a>\n      <a href=\"#bugs\">8. 隐形 Bug</a>\n      <a href=\"#test\">9. 测试与发布</a>\n      <a href=\"#decisions\">10. 待确认项</a>\n>>>>>>> 5d05833ef476571c794ef3016336bc136526872c\n    </aside>\n\n    <main>\n      <header class=\"hero\">\n        <p class=\"eyebrow\">TECHNICAL DEVELOPMENT DESIGN · VWCG-1119</p>\n<<<<<<< HEAD\n        <h1>消费限制支持按“受限账户”生效</h1>\n        <p>基于云效 PRD、交互原型、正式代码基线与 VWCG-1121 依赖关系形成的技术开发评审稿。</p>\n        <div class=\"meta\">\n          <span class=\"pill\">V1.1 开发稿</span>\n          <span class=\"pill\">2026-07-29</span>\n          <span class=\"pill\">目标仓库：store</span>\n          <span class=\"pill\">风险：中高</span>\n          <span class=\"pill\">状态：已按确认口径开发</span>\n=======\n        <h1>消费限制按“受限账户是否有钱”生效</h1>\n        <p>覆盖 store 消费机 HTTP API、MQTT，以及 ai_api 小程序订餐的双项目技术方案。</p>\n        <div class=\"meta\">\n          <span class=\"pill\">V1.2 兼容修订稿</span>\n          <span class=\"pill\">2026-07-29</span>\n          <span class=\"pill\">store + ai_api</span>\n          <span class=\"pill\">风险：高</span>\n>>>>>>> 5d05833ef476571c794ef3016336bc136526872c\n        </div>\n      </header>\n\n      <section id=\"summary\">\n<<<<<<< HEAD\n        <h2>1. 评审结论</h2>\n        <p class=\"lead\">这不是一个只加表单字段的需求。要真正做到“未勾选账户不受限制”，限制校验、支付预览和最终扣款必须使用同一受限账户集合。</p>\n        <div class=\"callout\">\n          <strong>推荐实现</strong>\n          新增规则—账户关系；规则命中后只排除所选账户，并使用未受限账户重算可用余额和扣款计划；剩余账户不足以覆盖整单时才拦截。\n        </div>\n        <div class=\"grid\">\n          <div class=\"card\"><h3>账户枚举</h3><p><span class=\"tag\">cash</span><span class=\"tag\">subsidy</span><span class=\"tag\">gift</span> 全链路统一口径。</p></div>\n          <div class=\"card\"><h3>交付节奏</h3><p>本期页面同时启用个人、餐补和赠送账户；三者复用同一范围/次数规则。</p></div>\n          <div class=\"card\"><h3>次数口径</h3><p>按订单支付字段金额大于 0 分账户计次；混合支付对每个实际账户分别计一次。</p></div>\n          <div class=\"card\"><h3>兼容原则</h3><p>历史规则回填个人与餐补，不自动扩大旧规则到赠送账户。</p></div>\n        </div>\n      </section>\n\n      <section id=\"scope\">\n        <h2>2. 范围边界</h2>\n        <div class=\"grid\">\n          <div class=\"card\">\n            <h3>本期必须实现</h3>\n            <ul class=\"checklist\">\n              <li>应用场景后增加必填“受限账户”多选</li>\n              <li>个人账户、餐补账户可选并保存回显</li>\n              <li>赠送账户作为受限账户的第三个选项，不增加独立金额配置</li>\n              <li>命中范围或次数后仅排除选中账户，未选账户继续支付</li>\n              <li>接口、服务、数据库均做枚举和非空校验</li>\n            </ul>\n          </div>\n          <div class=\"card\">\n            <h3>明确不做</h3>\n            <ul>\n              <li>指定人员、身份或适用对象扩展</li>\n              <li>金额限制与账户独立阈值</li>\n              <li>按账户分别配置档口、餐次</li>\n              <li>新增菜单、路由、列表列或筛选项</li>\n              <li>在本任务实现赠送账户余额、流水、退款和导出</li>\n            </ul>\n          </div>\n        </div>\n        <div class=\"callout warn\">\n          <strong>历史设计不是本期范围</strong>\n          <code>account-type-consume-limit-20260713</code> 是更宽的扩展方案。当前实现必须以云效 PRD 的收敛版本为准，不能顺带实现三维规则、金额限制或复杂组合。\n        </div>\n      </section>\n\n      <section id=\"gap\">\n        <h2>3. 核心技术缺口</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>层级</th><th>当前行为</th><th>本需求需要</th></tr></thead>\n            <tbody>\n              <tr><td>数据</td><td>规则没有账户关系</td><td>支持一条规则关联多个账户类型</td></tr>\n              <tr><td>支付时序</td><td>限制校验早于价格计算与扣款拆分</td><td>最终价格后排除受限账户并重算扣款计划</td></tr>\n              <tr><td>范围校验</td><td>人员命中后整单拦截</td><td>只排除规则选中的受限账户</td></tr>\n              <tr><td>次数统计</td><td>统计人员全部成功订单</td><td>按 <code>cash/subsidy/gift &gt; 0</code> 分账户统计</td></tr>\n              <tr><td>日志</td><td>不记录触发账户</td><td>落库 <code>account_type</code> 便于追溯</td></tr>\n              <tr><td>赠送账户</td><td>当前分支已具备余额、扣款、退款和订单字段</td><td>页面启用且后端允许保存 gift</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout risk\">\n          <strong>不能接受的简化实现</strong>\n          仅新增数据库字段和前端复选框，会出现“配置已保存，但实际仍然整单受限”的伪完成状态。\n        </div>\n      </section>\n\n      <section id=\"architecture\">\n        <h2>4. 目标处理链路</h2>\n        <div class=\"flow\" aria-label=\"目标支付处理链路\">\n          <div class=\"step\"><b>1. 定价</b>得到最终<br><code>pay_price</code></div>\n          <div class=\"step\"><b>2. 评估</b>按账户判断范围与次数</div>\n          <div class=\"step\"><b>3. 排除</b>移除命中的受限账户</div>\n          <div class=\"step\"><b>4. 结算</b>按剩余账户重算并扣款</div>\n          <div class=\"step\"><b>5. 落单</b>保存分账户金额与拦截证据</div>\n        </div>\n        <h3>必须保持的不变量</h3>\n        <ul>\n          <li>支付预览与最终结算必须使用相同的受限账户集合。</li>\n          <li>未选账户不受规则影响，可覆盖整单时不得拦截。</li>\n          <li>次数只统计对应账户实付字段大于 0 的成功订单。</li>\n          <li>离线补单当前绕过限制校验，需在发布说明中保留该既有边界。</li>\n        </ul>\n      </section>\n\n      <section id=\"data\">\n        <h2>5. 数据与接口设计</h2>\n        <h3>5.1 新增关系表</h3>\n        <p>新增 <code>ydy_consume_limit_account</code>，保持与部门、场景、范围关系表一致的建模方式，不使用逗号字符串或主表 JSON。</p>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>字段</th><th>类型/约束</th><th>说明</th></tr></thead>\n            <tbody>\n              <tr><td><code>id</code></td><td>int unsigned, PK</td><td>主键</td></tr>\n              <tr><td><code>limit_id</code></td><td>int unsigned, NOT NULL</td><td>消费限制 ID</td></tr>\n              <tr><td><code>account_type</code></td><td>varchar(16), NOT NULL</td><td>cash / subsidy / gift</td></tr>\n              <tr><td><code>store_id</code></td><td>int unsigned, NOT NULL</td><td>租户隔离字段</td></tr>\n              <tr><td><code>create_time</code></td><td>datetime</td><td>创建时间</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <p>索引：<code>UNIQUE(limit_id, account_type)</code>、<code>INDEX(store_id, account_type)</code>。拦截日志表补充 <code>account_type</code>。</p>\n\n        <h3>5.2 API 契约</h3>\n        <div class=\"grid\">\n          <div class=\"card\">\n            <h3>创建/更新入参</h3>\n            <p><code>accountTypes: [\"cash\", \"subsidy\"]</code></p>\n            <p>数组、去重后至少一项、仅允许 <code>cash/subsidy/gift</code> 三个受支持枚举。</p>\n          </div>\n          <div class=\"card\">\n            <h3>详情回显</h3>\n            <p><code>account_types: [\"cash\", \"subsidy\"]</code></p>\n            <p>接口路由保持不变；当前 PRD 不要求列表展示与筛选。</p>\n          </div>\n        </div>\n\n        <h3>5.3 历史数据</h3>\n        <p>所有有效历史规则回填 <code>cash</code> 与 <code>subsidy</code>；不回填 <code>gift</code>。迁移后检查规则覆盖率、未知枚举、重复关系和 <code>store_id</code> 一致性。</p>\n      </section>\n\n      <section id=\"implementation\">\n        <h2>6. 实施清单</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>模块</th><th>主要文件/对象</th><th>改动要点</th></tr></thead>\n            <tbody>\n              <tr><td>前端</td><td><code>addConsumeLimit.vue</code></td><td>新增必填复选组、Tooltip、编辑回显；展示个人、餐补和赠送</td></tr>\n              <tr><td>接口校验</td><td><code>api/validate/ConsumeLimit.php</code></td><td>校验数组、非空及 cash/subsidy/gift 枚举</td></tr>\n              <tr><td>CRUD</td><td><code>api/controller/ConsumeLimit.php</code><br><code>api/service/ConsumeLimit.php</code></td><td>事务内同步关系表；详情返回账户集合；删除保持一致性</td></tr>\n              <tr><td>账户枚举</td><td><code>common/enum/account/Type.php</code></td><td>让 VWCG-1119 与 VWCG-1121 共用 cash/subsidy/gift 口径</td></tr>\n              <tr><td>支付规划</td><td>账户结算服务 / 扣款规划器</td><td>提供无副作用的 <code>previewDeduction</code>；实际扣款锁内复核预期计划</td></tr>\n              <tr><td>规则评估</td><td><code>api/service/Consume.php</code> 拆分出的评估器</td><td>两个在线支付入口统一调用；按账户判断范围与次数</td></tr>\n              <tr><td>DDL</td><td>目标版本 SQL</td><td>关系表、日志字段、历史规则回填和核验 SQL</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout warn\">\n          <strong>双支付入口必须同时覆盖</strong>\n          当前同步支付与异步快照路径都会调用消费限制。只改其中一条会产生渠道行为不一致。\n        </div>\n      </section>\n\n      <section id=\"semantics\">\n        <h2>7. 规则语义</h2>\n        <h3>范围限制</h3>\n        <p>沿用现有“配置范围为禁止范围”的语义。档口与餐次命中后仅排除规则所选账户；未选账户继续参与支付，剩余账户不足时才拦截整单。</p>\n        <h3>次数限制</h3>\n        <p>在相同人员、应用场景与时间窗口下，按账户读取成功订单：</p>\n        <ul>\n          <li>个人账户：<code>cash &gt; 0</code></li>\n          <li>餐补账户：<code>subsidy &gt; 0</code></li>\n          <li>赠送账户：<code>gift &gt; 0</code></li>\n        </ul>\n        <p>一笔混合支付订单对每个金额大于 0 的账户分别记 1 次，而不是按金额或拆分行数计次。退款、撤销订单是否回退次数属于待确认口径。</p>\n      </section>\n\n      <section id=\"test\">\n        <h2>8. 验证策略</h2>\n        <div class=\"grid\">\n          <div class=\"card\">\n            <h3>单元与服务测试</h3>\n            <p>枚举、参数校验、关系表事务、历史回填、纯扣款规划、规则与实际账户交集、分账户计次。</p>\n          </div>\n          <div class=\"card\">\n            <h3>支付集成测试</h3>\n            <p>个人/餐补单账户、混合支付、未选账户、余额临界变化、同步与异步入口、失败不落单。</p>\n          </div>\n          <div class=\"card\">\n            <h3>前端与手工验收</h3>\n            <p>字段位置、必填、Tooltip、个人/餐补/赠送三选项、新增编辑回显、原范围/次数配置不回归。</p>\n          </div>\n          <div class=\"card\">\n            <h3>多租户与权限</h3>\n            <p>不同 <code>store_id</code> 不串数据；现有部门、餐厅与档口数据权限保持有效。</p>\n          </div>\n        </div>\n        <p>后端至少运行相关 PHPUnit 与支付回归；前端运行 lint、构建及组件级验证。由于表单行为变化，需要人工 UI 验收证据。</p>\n      </section>\n\n      <section id=\"release\">\n        <h2>9. 发布、依赖与回滚</h2>\n        <ol>\n          <li>先部署 DDL：创建账户关系表、日志字段并回填历史规则。</li>\n          <li>部署后端兼容代码；服务端允许并校验 <code>cash/subsidy/gift</code>。</li>\n          <li>部署前端：个人、餐补和赠送账户均可选。</li>\n          <li>观察规则保存失败率、消费拦截量、按账户拦截分布及扣款计划漂移告警。</li>\n          <li>赠送账户与个人、餐补同时开放，三者复用相同规则与验证矩阵。</li>\n        </ol>\n        <p>回滚时先回退功能代码并保留关系表和日志字段，避免丢失新规则配置；旧代码恢复“全部账户共同受限”的历史行为。</p>\n        <div class=\"callout\">\n          <strong>异步导出联动结论</strong>\n          VWCG-1119 不修改九类业务列表的查询、字段、统计、权限、分页或导出数据源，因此本任务无需修改异步导出。赠送账户引入后的订单与导出字段影响由 VWCG-1121 单独负责。\n        </div>\n        <p><strong>工作量估算：</strong>个人+餐补+赠送完整闭环约 44–62 小时。估算不含大规模历史数据治理。</p>\n      </section>\n\n      <section id=\"decisions\">\n        <h2>10. 已确认口径与保留项</h2>\n        <div class=\"decision\">\n          <span class=\"priority\">P0</span>\n          <div><strong>次数按账户独立计数</strong><p>个人、餐补、赠送分别按订单对应实付字段大于 0 计一次；混合订单分别计入。</p></div>\n        </div>\n        <div class=\"decision\">\n          <span class=\"priority\">P0</span>\n          <div><strong>受限账户命中后改用未受限账户</strong><p>只排除命中的账户；未受限账户可以覆盖整单时继续支付，不能覆盖时才拦截。</p></div>\n        </div>\n        <div class=\"decision\">\n          <span class=\"priority\">P0</span>\n          <div><strong>赠送账户与其他账户同时启用</strong><p>赠送账户不是独立赠送金额配置，只是本条消费限制的第三个受限账户选项。</p></div>\n        </div>\n        <div class=\"decision\">\n          <span class=\"priority\" style=\"color:var(--warn);background:var(--warn-soft)\">P1</span>\n          <div><strong>历史规则是否统一回填个人+餐补？退款/撤销、离线补单如何计次？</strong><p>本文给出兼容默认值，但需要产品、研发和测试在评审中确认并固化验收样例。</p></div>\n        </div>\n        <div class=\"callout\">\n          <strong>开发结论</strong>\n          已按以上口径进入实现；发布前继续保留历史规则回填、退款计次和离线补单边界的验证证据。\n        </div>\n      </section>\n\n      <footer>\n        VWCG-1119 · 技术开发文档 V1.1 · 详细证据、代码定位与完整测试矩阵见同目录 Markdown 真源\n      </footer>\n=======\n        <h2>1. 修订结论</h2>\n        <p class=\"lead\">本版不再根据“本单最终扣了哪个账户”决定规则，而是在下单/支付前读取所选受限账户的可用余额。</p>\n        <div class=\"callout good\">\n          <strong>统一判定</strong><br>\n          所选账户中任意一个可用余额大于 0，继续执行原有范围/次数限制；全部为 0 时跳过本条规则。\n        </div>\n        <div class=\"grid\">\n          <article class=\"card\"><h3>次数口径</h3><p>继续按当前成功订单数统计，不按 cash/subsidy/gift 最终扣款拆分。</p></article>\n          <article class=\"card\"><h3>资金来源</h3><p>规则只决定是否拦截，不修改营销价格、扣款顺序，也不自动换账户。</p></article>\n          <article class=\"card\"><h3>项目边界</h3><p>消费机在 store；小程序订餐在 ai_api。只改一个仓库无法闭环。</p></article>\n          <article class=\"card\"><h3>赠送账户</h3><p>依赖 VWCG-1121；迁移与全链路验证前保持置灰。</p></article>\n        </div>\n        <div class=\"callout\"><strong>旧数据默认值</strong><br>历史规则没有受限账户字段，统一默认个人账户 + 餐补账户；赠送账户不默认选中。</div>\n      </section>\n\n      <section id=\"rule\">\n        <h2>2. 业务规则</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>账户</th><th>“有钱”定义</th><th>排除项</th></tr></thead>\n            <tbody>\n              <tr><td><span class=\"tag\">cash</span>个人</td><td>活动本金余额 + 可提现余额；旧数据未拆分时兼容回退 balance</td><td>透支额度默认不算</td></tr>\n              <tr><td><span class=\"tag\">subsidy</span>餐补</td><td>判断时点有效、未删除且大于 0 的补贴余额之和</td><td>未生效、过期、负数</td></tr>\n              <tr><td><span class=\"tag warn\">gift</span>赠送</td><td>gift_balance 大于 0</td><td>能力未开放时不可配置</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <ul>\n          <li>金额使用 BCMath 两位小数比较，禁止浮点直接判断。</li>\n          <li>未选账户余额不参与规则是否生效。</li>\n          <li>余额查询失败不能按 0 放行，应返回可重试错误。</li>\n          <li>范围、月/日/餐次次数阈值保持现状。</li>\n        </ul>\n      </section>\n\n      <section id=\"channels\">\n        <h2>3. 三类入口</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>入口</th><th>真实调用链</th><th>接入要求</th></tr></thead>\n            <tbody>\n              <tr><td>消费机 HTTP API</td><td><code>Consume controller → orderPayAsync</code></td><td>覆盖 Redis 快照与 DB 回退</td></tr>\n              <tr><td>消费机 MQTT</td><td><code>MqttServer → orderPayAsync</code></td><td>与 HTTP 共享 Service，不复制规则</td></tr>\n              <tr><td>小程序点餐</td><td><code>MealCheckout / DirectPay → MealPayment / PaySuccess</code></td><td>提交早检 + 支付最终复检</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout warn\"><strong>代码事实</strong><br>store 只识别 <code>source=4</code> 的订餐订单，没有小程序提交入口；小程序必须在 ai_api 改造。</div>\n      </section>\n\n      <section id=\"architecture\">\n        <h2>4. 目标架构</h2>\n        <div class=\"flow\">\n          <div class=\"step\"><b>入口</b>HTTP / MQTT / 小程序</div>\n          <div class=\"step\"><b>身份映射</b>staff、zhct user、AI user</div>\n          <div class=\"step\"><b>余额快照</b>cash / subsidy / gift</div>\n          <div class=\"step\"><b>规则评估</b>余额门禁 + 范围/次数</div>\n          <div class=\"step\"><b>原支付流程</b>创建订单与扣款</div>\n        </div>\n        <p>两个项目使用相同的评估输入、输出和契约样例。输入至少包含租户、人员、智慧餐厅用户、场景、档口、餐次、消费时间、余额快照和幂等号。</p>\n      </section>\n\n      <section id=\"data\">\n        <h2>5. 数据与接口</h2>\n        <h3>规则账户关系</h3>\n        <p>新增 <code>ydy_consume_limit_account(limit_id, account_type, store_id, create_time)</code>，唯一索引为 <code>(limit_id, account_type)</code>。所有未删除历史规则（包括启用和停用）幂等回填 <code>cash + subsidy</code>，不自动回填 gift。</p>\n        <h3>CRUD</h3>\n        <p>新增/编辑传 <code>accountTypes: [\"cash\",\"subsidy\"]</code>；详情返回 <code>account_types</code>。必须为数组、去重后至少一项，并执行枚举白名单与赠送能力校验。</p>\n        <h3>拦截日志</h3>\n        <p>增加命中账户、脱敏余额快照和渠道，渠道取值 <code>consume_api</code>、<code>consume_mqtt</code>、<code>mini_program</code>。</p>\n        <div class=\"callout\">\n          <strong>四层兼容</strong><br>\n          ① 数据库回填；② 详情缺关系时返回 cash+subsidy；③ 新增和旧数据编辑默认勾选个人+餐补；④ 运行期缺关系按 cash+subsidy 判断并告警。旧规则首次编辑后把默认值真实持久化。\n        </div>\n      </section>\n\n      <section id=\"store\">\n        <h2>6. store 改造</h2>\n        <ol>\n          <li>新增账户余额解析器，完成 <code>staff_uuid → store user → ai_user_id</code> 映射。</li>\n          <li>从 <code>Consume.php</code> 抽出消费限制评估器，按“账户余额交集 → 范围 → 次数”执行。</li>\n          <li>HTTP 与 MQTT 只传渠道标识，统一在 <code>orderPayAsync()</code> 的支付锁内判断。</li>\n          <li>同时覆盖 <code>handleAsyncSnapshotPayment()</code> 和 <code>runAsyncFallback() → orderPay()</code>。</li>\n          <li>在线设备时间需要漂移校验；补贴有效期、次数边界和日志使用同一消费时间。</li>\n        </ol>\n        <div class=\"callout risk\"><strong>Redis 快照风险</strong><br>当前 TTL 为 300 秒。应在支付锁内刷新账户余额，或严格保证限制判断和后续扣款使用同一份最新快照。</div>\n      </section>\n\n      <section id=\"mini\">\n        <h2>7. 小程序改造</h2>\n        <ul>\n          <li><code>MealCheckout::submit()</code>：创建订单前早检，避免产生不可支付订单。</li>\n          <li><code>meal/DirectPay::pay()</code>：创建订单和扣款前，在同一事务内最终检查。</li>\n          <li><code>cashier/MealPayment</code> / <code>meal/PaySuccess</code>：支付前防御复检，覆盖历史待支付订单和旁路调用。</li>\n          <li>0 元订单必须在自动支付成功前检查。</li>\n          <li>AI 用户映射不到智慧餐厅用户/人员时失败，不得按“无规则”放行。</li>\n        </ul>\n        <p>双检查的目的：提交时尽早反馈，支付时处理余额、规则和时间变化，避免提交后充值或规则调整造成绕过。</p>\n      </section>\n\n      <section id=\"bugs\">\n        <h2>8. 隐形 Bug 风险</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>风险</th><th>隐形后果</th><th>防护</th></tr></thead>\n            <tbody>\n              <tr><td>只改 HTTP 或 MQTT</td><td>上报方式不同，限制结果不同</td><td>共享 Service + 双入口契约测试</td></tr>\n              <tr><td>300 秒旧快照</td><td>充值/扣款/补贴变化后误判</td><td>锁内刷新并复用同快照</td></tr>\n              <tr><td>只改 store</td><td>小程序完全不判断</td><td>ai_api 三个接入点同步改</td></tr>\n              <tr><td>透支算余额</td><td>实际 0 余额仍触发规则</td><td>默认排除透支</td></tr>\n              <tr><td>账户异常当 0</td><td>跨库故障时规则绕过</td><td>失败关闭、提示重试</td></tr>\n              <tr><td>只在提交检查</td><td>支付时余额变化后绕过</td><td>支付前最终复检</td></tr>\n              <tr><td>次数先查后写</td><td>并发两单同时通过最后额度</td><td>跨项目同一用户/场景锁</td></tr>\n              <tr><td>两个项目算法漂移</td><td>消费机与小程序行为不一致</td><td>共享版本化契约样例</td></tr>\n              <tr><td>0 元自动支付</td><td>范围/次数被旁路</td><td>自动支付前检查</td></tr>\n              <tr><td>只做前端默认</td><td>API/任务链路仍把旧规则当空配置</td><td>SQL、详情、前端、运行期四层兼容</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout risk\"><strong>并发门禁</strong><br>锁名统一为 <code>consume_limit:{storeId}:{zhctUserId}:{scenario}</code>，覆盖最终复检到成功订单落库。两项目 Redis 不共享时必须采用 mysql_zhct 数据库锁/守卫行。</div>\n      </section>\n\n      <section id=\"test\">\n        <h2>9. 测试与发布</h2>\n        <div class=\"grid\">\n          <article class=\"card\"><h3>余额边界</h3><p>个人拆分/旧值、有效与过期餐补、赠送开关、多选任一有钱、透支、查询失败。</p></article>\n          <article class=\"card\"><h3>渠道一致性</h3><p>同一载荷走 HTTP 和 MQTT，除 channel/trace_id 外结果一致。</p></article>\n          <article class=\"card\"><h3>小程序</h3><p>submit、0 元自动支付、余额支付、directPay、历史待支付复检。</p></article>\n          <article class=\"card\"><h3>并发</h3><p>MQTT 与小程序并发争抢最后一次额度，只允许一单成功。</p></article>\n        </div>\n        <h3>上线顺序</h3>\n        <ol>\n          <li>完成 VWCG-1121 账户迁移，gift 未就绪则保持禁用。</li>\n          <li>创建关系表、日志字段，为全部未删除旧规则幂等回填 cash+subsidy。</li>\n          <li>验证重复回填无重复数据，旧规则详情默认勾选个人+餐补，gift 不默认选中。</li>\n          <li>部署 store 后端，再部署 ai_api，最后开放 PC 表单。</li>\n          <li>内部门店同时验证 HTTP、MQTT、小程序，观察后灰度。</li>\n        </ol>\n        <p>本需求不改变订单列表字段或导出口径，九类异步导出无需同步修改。</p>\n      </section>\n\n      <section id=\"decisions\">\n        <h2>10. 待确认项</h2>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>多选账户触发逻辑</strong><p>本文按“任一所选账户有可用余额即生效”设计。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>透支口径</strong><p>本文默认透支额度不属于“账户有钱”。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>跨项目锁</strong><p>确认 store 与 ai_api 是否共享 Redis；否则采用 mysql_zhct 数据库锁。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>0 元订单</strong><p>本文默认也执行范围与次数限制。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P1</span><div><strong>设备时间与离线补传</strong><p>确认在线时间漂移窗口；离线补传默认仍不实时拦截。</p></div></div>\n        <div class=\"callout warn\"><strong>开发门禁</strong><br>方案可实现，但属于双项目、高一致性改造。P0 口径、跨项目锁方案和赠送账户依赖未关闭前，不建议直接上线。</div>\n      </section>\n\n      <footer>VWCG-1119 · 技术开发文档 V1.2 · Markdown 为内容真源</footer>\n>>>>>>> 5d05833ef476571c794ef3016336bc136526872c\n    </main>\n  </div>\n</body>\n</html>\n",
  "blocks": [
    {
      "line": 6,
      "variant_a": "  <title>VWCG-1119 消费限制按受限账户生效｜技术开发文档</title>\n  <style>\n    :root {\n      --ink: #172033;\n      --muted: #61708a;\n      --line: #dfe5ef;\n      --paper: #ffffff;\n      --canvas: #f4f7fb;\n      --brand: #2457d6;\n      --brand-soft: #edf3ff;\n      --good: #13795b;\n      --good-soft: #eaf8f3;\n      --warn: #9a5b00;\n      --warn-soft: #fff5df;\n      --risk: #b42318;\n      --risk-soft: #fff0ee;\n      --shadow: 0 16px 42px rgba(31, 45, 72, .09);\n    }\n    * { box-sizing: border-box; }\n    html { scroll-behavior: smooth; }\n    body {\n      margin: 0;\n      color: var(--ink);\n      background: var(--canvas);\n      font: 15px/1.72 -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n        \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n    }\n    a { color: var(--brand); text-decoration: none; }\n    code {\n      padding: .12em .38em;\n      border-radius: 5px;\n      background: #f0f3f8;\n      color: #31415e;\n      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n      font-size: .92em;\n    }\n    .shell {\n      display: grid;\n      grid-template-columns: 230px minmax(0, 900px);\n      gap: 32px;\n      width: min(1200px, calc(100% - 40px));\n      margin: 34px auto 80px;\n    }\n    aside {\n      position: sticky;\n      top: 24px;\n      align-self: start;\n      max-height: calc(100vh - 48px);\n      overflow: auto;\n      padding: 18px;\n      border: 1px solid var(--line);\n      border-radius: 16px;\n      background: rgba(255,255,255,.88);\n      box-shadow: 0 8px 24px rgba(31,45,72,.05);\n      backdrop-filter: blur(10px);\n    }\n    .toc-title {\n      margin: 0 0 10px;\n      color: var(--muted);\n      font-size: 12px;\n      font-weight: 700;\n      letter-spacing: .08em;\n      text-transform: uppercase;\n    }\n    aside a {\n      display: block;\n      padding: 7px 9px;\n      border-radius: 8px;\n      color: #42506a;\n      font-size: 13px;\n    }\n    aside a:hover { background: var(--brand-soft); color: var(--brand); }\n    main { min-width: 0; }\n    .hero {\n      position: relative;\n      overflow: hidden;\n      padding: 42px;\n      border-radius: 24px;\n      color: white;\n      background:\n        radial-gradient(circle at 92% 12%, rgba(255,255,255,.20), transparent 28%),\n        linear-gradient(135deg, #173b9d 0%, #2965e8 66%, #3e82ed 100%);\n      box-shadow: var(--shadow);\n    }\n    .eyebrow {\n      margin: 0 0 12px;\n      color: #cfe0ff;\n      font-size: 12px;\n      font-weight: 800;\n      letter-spacing: .12em;\n    }\n    h1 {\n      max-width: 760px;\n      margin: 0;\n      font-size: clamp(28px, 5vw, 44px);\n      line-height: 1.22;\n      letter-spacing: -.03em;\n    }\n    .hero p { max-width: 720px; margin: 18px 0 0; color: #eaf1ff; }\n    .meta {\n      display: flex;\n      flex-wrap: wrap;\n      gap: 9px;\n      margin-top: 25px;\n    }\n    .pill {\n      padding: 6px 11px;\n      border: 1px solid rgba(255,255,255,.24);\n      border-radius: 999px;\n      background: rgba(255,255,255,.10);\n      font-size: 12px;\n    }\n    section {\n      margin-top: 22px;\n      padding: 30px;\n      border: 1px solid var(--line);\n      border-radius: 19px;\n      background: var(--paper);\n      box-shadow: 0 7px 22px rgba(31,45,72,.045);\n    }\n    h2 { margin: 0 0 18px; font-size: 23px; letter-spacing: -.02em; }\n    h3 { margin: 24px 0 10px; font-size: 17px; }\n    p { margin: 10px 0; }\n    ul, ol { margin: 10px 0; padding-left: 22px; }\n    li + li { margin-top: 6px; }\n    .lead {\n      margin: 0;\n      color: #35445e;\n      font-size: 17px;\n    }\n    .callout {\n      margin: 18px 0;\n      padding: 17px 19px;\n      border-left: 4px solid var(--brand);\n      border-radius: 10px;\n      background: var(--brand-soft);\n    }\n    .callout strong { display: block; margin-bottom: 4px; }\n    .callout.warn { border-left-color: var(--warn); background: var(--warn-soft); }\n    .callout.risk { border-left-color: var(--risk); background: var(--risk-soft); }\n    .grid {\n      display: grid;\n      grid-template-columns: repeat(2, minmax(0, 1fr));\n      gap: 14px;\n      margin-top: 16px;\n    }\n    .card {\n      padding: 18px;\n      border: 1px solid var(--line);\n      border-radius: 13px;\n      background: #fbfcfe;\n    }\n    .card h3 { margin: 0 0 7px; font-size: 15px; }\n    .card p { margin: 0; color: var(--muted); font-size: 14px; }\n    .flow {\n      display: grid;\n      grid-template-columns: repeat(5, 1fr);\n      gap: 8px;\n      align-items: center;\n      margin: 20px 0 6px;\n    }\n    .step {\n      position: relative;\n      min-height: 94px;\n      padding: 15px 12px;\n      border: 1px solid #cdd9ef;\n      border-radius: 12px;\n      background: #f7faff;\n      text-align: center;\n      font-size: 13px;\n    }\n    .step b { display: block; margin-bottom: 5px; color: var(--brand); }\n    .step:not(:last-child)::after {\n      content: \"→\";\n      position: absolute;\n      z-index: 2;\n      right: -14px;\n      top: 35px;\n      color: var(--brand);\n      font-size: 20px;\n      font-weight: 800;\n    }\n    .table-wrap { overflow-x: auto; margin-top: 14px; }\n    table { width: 100%; border-collapse: collapse; font-size: 14px; }\n    th, td {\n      padding: 11px 12px;\n      border-bottom: 1px solid var(--line);\n      text-align: left;\n      vertical-align: top;\n    }\n    th { background: #f6f8fc; color: #40506b; font-weight: 700; }\n    tr:last-child td { border-bottom: 0; }\n    .tag {\n      display: inline-block;\n      margin: 1px 4px 1px 0;\n      padding: 2px 7px;\n      border-radius: 999px;\n      background: var(--brand-soft);\n      color: var(--brand);\n      font-size: 12px;\n      font-weight: 700;\n    }\n    .tag.good { background: var(--good-soft); color: var(--good); }\n    .tag.warn { background: var(--warn-soft); color: var(--warn); }\n    .checklist { list-style: none; padding: 0; }\n    .checklist li { position: relative; padding-left: 27px; }\n    .checklist li::before {\n      content: \"✓\";\n      position: absolute;\n      left: 0;\n      top: 0;\n      color: var(--good);\n      font-weight: 900;\n    }\n    .decision {\n      display: grid;\n      grid-template-columns: 44px 1fr;\n      gap: 13px;\n      padding: 15px 0;\n      border-bottom: 1px solid var(--line);\n    }\n    .decision:last-child { border-bottom: 0; }\n    .priority {\n      width: 42px;\n      height: 29px;\n      display: grid;\n      place-items: center;\n      border-radius: 8px;\n      color: var(--risk);\n      background: var(--risk-soft);\n      font-size: 12px;\n      font-weight: 900;\n    }\n    .decision p { margin: 3px 0 0; color: var(--muted); }\n    footer {\n      padding: 24px 8px;\n      color: var(--muted);\n      text-align: center;\n      font-size: 12px;\n    }\n    @media (max-width: 900px) {\n      .shell { display: block; width: min(100% - 24px, 900px); margin-top: 12px; }\n      aside { display: none; }\n      .hero { padding: 30px 24px; }\n      section { padding: 23px 20px; }\n      .grid { grid-template-columns: 1fr; }\n      .flow { grid-template-columns: 1fr; }\n      .step { min-height: 0; text-align: left; }\n      .step:not(:last-child)::after { content: \"↓\"; right: 50%; top: auto; bottom: -20px; }\n    }\n    @media print {\n      body { background: white; }\n      .shell { display: block; width: 100%; margin: 0; }\n      aside { display: none; }\n      .hero, section { box-shadow: none; break-inside: avoid; }\n      .hero { border-radius: 0; }\n",
      "variant_b": "  <title>VWCG-1119 消费限制按受限账户余额生效｜技术开发文档 V1.2</title>\n  <style>\n    :root {\n      --ink:#172033; --muted:#637089; --line:#dfe5ef; --paper:#fff;\n      --canvas:#f4f7fb; --brand:#2457d6; --brand-soft:#edf3ff;\n      --good:#13795b; --good-soft:#eaf8f3; --warn:#945800;\n      --warn-soft:#fff5df; --risk:#b42318; --risk-soft:#fff0ee;\n    }\n    * { box-sizing:border-box; }\n    html { scroll-behavior:smooth; }\n    body {\n      margin:0; color:var(--ink); background:var(--canvas);\n      font:15px/1.72 -apple-system,BlinkMacSystemFont,\"Segoe UI\",\"PingFang SC\",\"Microsoft YaHei\",sans-serif;\n    }\n    code {\n      padding:.12em .38em; border-radius:5px; background:#f0f3f8;\n      color:#31415e; font: .92em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;\n    }\n    .shell {\n      display:grid; grid-template-columns:230px minmax(0,920px); gap:32px;\n      width:min(1220px,calc(100% - 40px)); margin:34px auto 80px;\n    }\n    aside {\n      position:sticky; top:24px; align-self:start; max-height:calc(100vh - 48px);\n      overflow:auto; padding:18px; border:1px solid var(--line); border-radius:16px;\n      background:rgba(255,255,255,.92);\n    }\n    aside p { margin:0 0 10px; color:var(--muted); font-size:12px; font-weight:800; letter-spacing:.08em; }\n    aside a { display:block; padding:7px 9px; border-radius:8px; color:#42506a; font-size:13px; text-decoration:none; }\n    aside a:hover { color:var(--brand); background:var(--brand-soft); }\n    main { min-width:0; }\n    .hero {\n      padding:42px; border-radius:24px; color:#fff;\n      background:radial-gradient(circle at 92% 12%,rgba(255,255,255,.2),transparent 28%),\n                 linear-gradient(135deg,#173b9d 0%,#2965e8 66%,#3e82ed 100%);\n      box-shadow:0 16px 42px rgba(31,45,72,.09);\n    }\n    .eyebrow { margin:0 0 12px; color:#cfe0ff; font-size:12px; font-weight:800; letter-spacing:.12em; }\n    h1 { margin:0; max-width:780px; font-size:clamp(28px,5vw,44px); line-height:1.22; letter-spacing:-.03em; }\n    .hero>p:not(.eyebrow) { max-width:760px; margin:18px 0 0; color:#eaf1ff; }\n    .meta { display:flex; flex-wrap:wrap; gap:9px; margin-top:25px; }\n    .pill { padding:6px 11px; border:1px solid rgba(255,255,255,.24); border-radius:999px; background:rgba(255,255,255,.1); font-size:12px; }\n    section {\n      margin-top:22px; padding:30px; border:1px solid var(--line); border-radius:19px;\n      background:var(--paper); box-shadow:0 7px 22px rgba(31,45,72,.045);\n    }\n    h2 { margin:0 0 18px; font-size:23px; letter-spacing:-.02em; }\n    h3 { margin:24px 0 10px; font-size:17px; }\n    p { margin:10px 0; }\n    ul,ol { margin:10px 0; padding-left:22px; }\n    li+li { margin-top:6px; }\n    .lead { margin:0; color:#35445e; font-size:17px; }\n    .callout { margin:18px 0; padding:17px 19px; border-left:4px solid var(--brand); border-radius:10px; background:var(--brand-soft); }\n    .callout.warn { border-left-color:var(--warn); background:var(--warn-soft); }\n    .callout.risk { border-left-color:var(--risk); background:var(--risk-soft); }\n    .callout.good { border-left-color:var(--good); background:var(--good-soft); }\n    .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; margin-top:16px; }\n    .card { padding:18px; border:1px solid var(--line); border-radius:13px; background:#fbfcfe; }\n    .card h3 { margin:0 0 7px; font-size:15px; }\n    .card p { margin:0; color:var(--muted); font-size:14px; }\n    .flow { display:grid; grid-template-columns:repeat(5,1fr); gap:8px; margin:20px 0 6px; }\n    .step { position:relative; min-height:96px; padding:15px 11px; border:1px solid #cdd9ef; border-radius:12px; background:#f7faff; text-align:center; font-size:13px; }\n    .step b { display:block; margin-bottom:5px; color:var(--brand); }\n    .step:not(:last-child)::after { content:\"→\"; position:absolute; z-index:2; right:-14px; top:35px; color:var(--brand); font-size:20px; font-weight:800; }\n    .table-wrap { overflow-x:auto; margin-top:14px; }\n    table { width:100%; border-collapse:collapse; font-size:14px; }\n    th,td { padding:11px 12px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; }\n    th { background:#f6f8fc; color:#40506b; }\n    tr:last-child td { border-bottom:0; }\n    .tag { display:inline-block; margin:1px 4px 1px 0; padding:2px 7px; border-radius:999px; background:var(--brand-soft); color:var(--brand); font-size:12px; font-weight:700; }\n    .tag.good { background:var(--good-soft); color:var(--good); }\n    .tag.warn { background:var(--warn-soft); color:var(--warn); }\n    .decision { display:grid; grid-template-columns:44px 1fr; gap:13px; padding:15px 0; border-bottom:1px solid var(--line); }\n    .decision:last-child { border-bottom:0; }\n    .priority { width:42px; height:29px; display:grid; place-items:center; border-radius:8px; color:var(--risk); background:var(--risk-soft); font-size:12px; font-weight:900; }\n    .decision p { margin:3px 0 0; color:var(--muted); }\n    footer { padding:24px 8px; color:var(--muted); text-align:center; font-size:12px; }\n    @media(max-width:900px) {\n      .shell { display:block; width:min(100% - 24px,920px); margin-top:12px; }\n      aside { display:none; } .hero { padding:30px 24px; } section { padding:23px 20px; }\n      .grid,.flow { grid-template-columns:1fr; }\n      .step { min-height:0; text-align:left; }\n      .step:not(:last-child)::after { content:\"↓\"; right:50%; top:auto; bottom:-20px; }\n    }\n    @media print {\n      body { background:#fff; } .shell { display:block; width:100%; margin:0; } aside { display:none; }\n      .hero,section { box-shadow:none; break-inside:avoid; } .hero { border-radius:0; }\n"
    },
    {
      "line": 358,
      "variant_a": "      <p class=\"toc-title\">目录</p>\n      <a href=\"#summary\">1. 评审结论</a>\n      <a href=\"#scope\">2. 范围边界</a>\n      <a href=\"#gap\">3. 核心技术缺口</a>\n      <a href=\"#architecture\">4. 目标链路</a>\n      <a href=\"#data\">5. 数据与接口</a>\n      <a href=\"#implementation\">6. 实施清单</a>\n      <a href=\"#semantics\">7. 规则语义</a>\n      <a href=\"#test\">8. 验证策略</a>\n      <a href=\"#release\">9. 发布与回滚</a>\n      <a href=\"#decisions\">10. 待决策项</a>\n",
      "variant_b": "      <p>目录</p>\n      <a href=\"#summary\">1. 修订结论</a>\n      <a href=\"#rule\">2. 业务规则</a>\n      <a href=\"#channels\">3. 三类入口</a>\n      <a href=\"#architecture\">4. 目标架构</a>\n      <a href=\"#data\">5. 数据与接口</a>\n      <a href=\"#store\">6. store 改造</a>\n      <a href=\"#mini\">7. 小程序改造</a>\n      <a href=\"#bugs\">8. 隐形 Bug</a>\n      <a href=\"#test\">9. 测试与发布</a>\n      <a href=\"#decisions\">10. 待确认项</a>\n"
    },
    {
      "line": 388,
      "variant_a": "        <h1>消费限制支持按“受限账户”生效</h1>\n        <p>基于云效 PRD、交互原型、正式代码基线与 VWCG-1121 依赖关系形成的技术开发评审稿。</p>\n        <div class=\"meta\">\n          <span class=\"pill\">V1.1 开发稿</span>\n          <span class=\"pill\">2026-07-29</span>\n          <span class=\"pill\">目标仓库：store</span>\n          <span class=\"pill\">风险：中高</span>\n          <span class=\"pill\">状态：已按确认口径开发</span>\n",
      "variant_b": "        <h1>消费限制按“受限账户是否有钱”生效</h1>\n        <p>覆盖 store 消费机 HTTP API、MQTT，以及 ai_api 小程序订餐的双项目技术方案。</p>\n        <div class=\"meta\">\n          <span class=\"pill\">V1.2 兼容修订稿</span>\n          <span class=\"pill\">2026-07-29</span>\n          <span class=\"pill\">store + ai_api</span>\n          <span class=\"pill\">风险：高</span>\n"
    },
    {
      "line": 410,
      "variant_a": "        <h2>1. 评审结论</h2>\n        <p class=\"lead\">这不是一个只加表单字段的需求。要真正做到“未勾选账户不受限制”，限制校验、支付预览和最终扣款必须使用同一受限账户集合。</p>\n        <div class=\"callout\">\n          <strong>推荐实现</strong>\n          新增规则—账户关系；规则命中后只排除所选账户，并使用未受限账户重算可用余额和扣款计划；剩余账户不足以覆盖整单时才拦截。\n        </div>\n        <div class=\"grid\">\n          <div class=\"card\"><h3>账户枚举</h3><p><span class=\"tag\">cash</span><span class=\"tag\">subsidy</span><span class=\"tag\">gift</span> 全链路统一口径。</p></div>\n          <div class=\"card\"><h3>交付节奏</h3><p>本期页面同时启用个人、餐补和赠送账户；三者复用同一范围/次数规则。</p></div>\n          <div class=\"card\"><h3>次数口径</h3><p>按订单支付字段金额大于 0 分账户计次；混合支付对每个实际账户分别计一次。</p></div>\n          <div class=\"card\"><h3>兼容原则</h3><p>历史规则回填个人与餐补，不自动扩大旧规则到赠送账户。</p></div>\n        </div>\n      </section>\n\n      <section id=\"scope\">\n        <h2>2. 范围边界</h2>\n        <div class=\"grid\">\n          <div class=\"card\">\n            <h3>本期必须实现</h3>\n            <ul class=\"checklist\">\n              <li>应用场景后增加必填“受限账户”多选</li>\n              <li>个人账户、餐补账户可选并保存回显</li>\n              <li>赠送账户作为受限账户的第三个选项，不增加独立金额配置</li>\n              <li>命中范围或次数后仅排除选中账户，未选账户继续支付</li>\n              <li>接口、服务、数据库均做枚举和非空校验</li>\n            </ul>\n          </div>\n          <div class=\"card\">\n            <h3>明确不做</h3>\n            <ul>\n              <li>指定人员、身份或适用对象扩展</li>\n              <li>金额限制与账户独立阈值</li>\n              <li>按账户分别配置档口、餐次</li>\n              <li>新增菜单、路由、列表列或筛选项</li>\n              <li>在本任务实现赠送账户余额、流水、退款和导出</li>\n            </ul>\n          </div>\n        </div>\n        <div class=\"callout warn\">\n          <strong>历史设计不是本期范围</strong>\n          <code>account-type-consume-limit-20260713</code> 是更宽的扩展方案。当前实现必须以云效 PRD 的收敛版本为准，不能顺带实现三维规则、金额限制或复杂组合。\n        </div>\n      </section>\n\n      <section id=\"gap\">\n        <h2>3. 核心技术缺口</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>层级</th><th>当前行为</th><th>本需求需要</th></tr></thead>\n            <tbody>\n              <tr><td>数据</td><td>规则没有账户关系</td><td>支持一条规则关联多个账户类型</td></tr>\n              <tr><td>支付时序</td><td>限制校验早于价格计算与扣款拆分</td><td>最终价格后排除受限账户并重算扣款计划</td></tr>\n              <tr><td>范围校验</td><td>人员命中后整单拦截</td><td>只排除规则选中的受限账户</td></tr>\n              <tr><td>次数统计</td><td>统计人员全部成功订单</td><td>按 <code>cash/subsidy/gift &gt; 0</code> 分账户统计</td></tr>\n              <tr><td>日志</td><td>不记录触发账户</td><td>落库 <code>account_type</code> 便于追溯</td></tr>\n              <tr><td>赠送账户</td><td>当前分支已具备余额、扣款、退款和订单字段</td><td>页面启用且后端允许保存 gift</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout risk\">\n          <strong>不能接受的简化实现</strong>\n          仅新增数据库字段和前端复选框，会出现“配置已保存，但实际仍然整单受限”的伪完成状态。\n        </div>\n      </section>\n\n      <section id=\"architecture\">\n        <h2>4. 目标处理链路</h2>\n        <div class=\"flow\" aria-label=\"目标支付处理链路\">\n          <div class=\"step\"><b>1. 定价</b>得到最终<br><code>pay_price</code></div>\n          <div class=\"step\"><b>2. 评估</b>按账户判断范围与次数</div>\n          <div class=\"step\"><b>3. 排除</b>移除命中的受限账户</div>\n          <div class=\"step\"><b>4. 结算</b>按剩余账户重算并扣款</div>\n          <div class=\"step\"><b>5. 落单</b>保存分账户金额与拦截证据</div>\n        </div>\n        <h3>必须保持的不变量</h3>\n        <ul>\n          <li>支付预览与最终结算必须使用相同的受限账户集合。</li>\n          <li>未选账户不受规则影响，可覆盖整单时不得拦截。</li>\n          <li>次数只统计对应账户实付字段大于 0 的成功订单。</li>\n          <li>离线补单当前绕过限制校验，需在发布说明中保留该既有边界。</li>\n        </ul>\n      </section>\n\n      <section id=\"data\">\n        <h2>5. 数据与接口设计</h2>\n        <h3>5.1 新增关系表</h3>\n        <p>新增 <code>ydy_consume_limit_account</code>，保持与部门、场景、范围关系表一致的建模方式，不使用逗号字符串或主表 JSON。</p>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>字段</th><th>类型/约束</th><th>说明</th></tr></thead>\n            <tbody>\n              <tr><td><code>id</code></td><td>int unsigned, PK</td><td>主键</td></tr>\n              <tr><td><code>limit_id</code></td><td>int unsigned, NOT NULL</td><td>消费限制 ID</td></tr>\n              <tr><td><code>account_type</code></td><td>varchar(16), NOT NULL</td><td>cash / subsidy / gift</td></tr>\n              <tr><td><code>store_id</code></td><td>int unsigned, NOT NULL</td><td>租户隔离字段</td></tr>\n              <tr><td><code>create_time</code></td><td>datetime</td><td>创建时间</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <p>索引：<code>UNIQUE(limit_id, account_type)</code>、<code>INDEX(store_id, account_type)</code>。拦截日志表补充 <code>account_type</code>。</p>\n\n        <h3>5.2 API 契约</h3>\n        <div class=\"grid\">\n          <div class=\"card\">\n            <h3>创建/更新入参</h3>\n            <p><code>accountTypes: [\"cash\", \"subsidy\"]</code></p>\n            <p>数组、去重后至少一项、仅允许 <code>cash/subsidy/gift</code> 三个受支持枚举。</p>\n          </div>\n          <div class=\"card\">\n            <h3>详情回显</h3>\n            <p><code>account_types: [\"cash\", \"subsidy\"]</code></p>\n            <p>接口路由保持不变；当前 PRD 不要求列表展示与筛选。</p>\n          </div>\n        </div>\n\n        <h3>5.3 历史数据</h3>\n        <p>所有有效历史规则回填 <code>cash</code> 与 <code>subsidy</code>；不回填 <code>gift</code>。迁移后检查规则覆盖率、未知枚举、重复关系和 <code>store_id</code> 一致性。</p>\n      </section>\n\n      <section id=\"implementation\">\n        <h2>6. 实施清单</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>模块</th><th>主要文件/对象</th><th>改动要点</th></tr></thead>\n            <tbody>\n              <tr><td>前端</td><td><code>addConsumeLimit.vue</code></td><td>新增必填复选组、Tooltip、编辑回显；展示个人、餐补和赠送</td></tr>\n              <tr><td>接口校验</td><td><code>api/validate/ConsumeLimit.php</code></td><td>校验数组、非空及 cash/subsidy/gift 枚举</td></tr>\n              <tr><td>CRUD</td><td><code>api/controller/ConsumeLimit.php</code><br><code>api/service/ConsumeLimit.php</code></td><td>事务内同步关系表；详情返回账户集合；删除保持一致性</td></tr>\n              <tr><td>账户枚举</td><td><code>common/enum/account/Type.php</code></td><td>让 VWCG-1119 与 VWCG-1121 共用 cash/subsidy/gift 口径</td></tr>\n              <tr><td>支付规划</td><td>账户结算服务 / 扣款规划器</td><td>提供无副作用的 <code>previewDeduction</code>；实际扣款锁内复核预期计划</td></tr>\n              <tr><td>规则评估</td><td><code>api/service/Consume.php</code> 拆分出的评估器</td><td>两个在线支付入口统一调用；按账户判断范围与次数</td></tr>\n              <tr><td>DDL</td><td>目标版本 SQL</td><td>关系表、日志字段、历史规则回填和核验 SQL</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout warn\">\n          <strong>双支付入口必须同时覆盖</strong>\n          当前同步支付与异步快照路径都会调用消费限制。只改其中一条会产生渠道行为不一致。\n        </div>\n      </section>\n\n      <section id=\"semantics\">\n        <h2>7. 规则语义</h2>\n        <h3>范围限制</h3>\n        <p>沿用现有“配置范围为禁止范围”的语义。档口与餐次命中后仅排除规则所选账户；未选账户继续参与支付，剩余账户不足时才拦截整单。</p>\n        <h3>次数限制</h3>\n        <p>在相同人员、应用场景与时间窗口下，按账户读取成功订单：</p>\n        <ul>\n          <li>个人账户：<code>cash &gt; 0</code></li>\n          <li>餐补账户：<code>subsidy &gt; 0</code></li>\n          <li>赠送账户：<code>gift &gt; 0</code></li>\n        </ul>\n        <p>一笔混合支付订单对每个金额大于 0 的账户分别记 1 次，而不是按金额或拆分行数计次。退款、撤销订单是否回退次数属于待确认口径。</p>\n      </section>\n\n      <section id=\"test\">\n        <h2>8. 验证策略</h2>\n        <div class=\"grid\">\n          <div class=\"card\">\n            <h3>单元与服务测试</h3>\n            <p>枚举、参数校验、关系表事务、历史回填、纯扣款规划、规则与实际账户交集、分账户计次。</p>\n          </div>\n          <div class=\"card\">\n            <h3>支付集成测试</h3>\n            <p>个人/餐补单账户、混合支付、未选账户、余额临界变化、同步与异步入口、失败不落单。</p>\n          </div>\n          <div class=\"card\">\n            <h3>前端与手工验收</h3>\n            <p>字段位置、必填、Tooltip、个人/餐补/赠送三选项、新增编辑回显、原范围/次数配置不回归。</p>\n          </div>\n          <div class=\"card\">\n            <h3>多租户与权限</h3>\n            <p>不同 <code>store_id</code> 不串数据；现有部门、餐厅与档口数据权限保持有效。</p>\n          </div>\n        </div>\n        <p>后端至少运行相关 PHPUnit 与支付回归；前端运行 lint、构建及组件级验证。由于表单行为变化，需要人工 UI 验收证据。</p>\n      </section>\n\n      <section id=\"release\">\n        <h2>9. 发布、依赖与回滚</h2>\n        <ol>\n          <li>先部署 DDL：创建账户关系表、日志字段并回填历史规则。</li>\n          <li>部署后端兼容代码；服务端允许并校验 <code>cash/subsidy/gift</code>。</li>\n          <li>部署前端：个人、餐补和赠送账户均可选。</li>\n          <li>观察规则保存失败率、消费拦截量、按账户拦截分布及扣款计划漂移告警。</li>\n          <li>赠送账户与个人、餐补同时开放，三者复用相同规则与验证矩阵。</li>\n        </ol>\n        <p>回滚时先回退功能代码并保留关系表和日志字段，避免丢失新规则配置；旧代码恢复“全部账户共同受限”的历史行为。</p>\n        <div class=\"callout\">\n          <strong>异步导出联动结论</strong>\n          VWCG-1119 不修改九类业务列表的查询、字段、统计、权限、分页或导出数据源，因此本任务无需修改异步导出。赠送账户引入后的订单与导出字段影响由 VWCG-1121 单独负责。\n        </div>\n        <p><strong>工作量估算：</strong>个人+餐补+赠送完整闭环约 44–62 小时。估算不含大规模历史数据治理。</p>\n      </section>\n\n      <section id=\"decisions\">\n        <h2>10. 已确认口径与保留项</h2>\n        <div class=\"decision\">\n          <span class=\"priority\">P0</span>\n          <div><strong>次数按账户独立计数</strong><p>个人、餐补、赠送分别按订单对应实付字段大于 0 计一次；混合订单分别计入。</p></div>\n        </div>\n        <div class=\"decision\">\n          <span class=\"priority\">P0</span>\n          <div><strong>受限账户命中后改用未受限账户</strong><p>只排除命中的账户；未受限账户可以覆盖整单时继续支付，不能覆盖时才拦截。</p></div>\n        </div>\n        <div class=\"decision\">\n          <span class=\"priority\">P0</span>\n          <div><strong>赠送账户与其他账户同时启用</strong><p>赠送账户不是独立赠送金额配置，只是本条消费限制的第三个受限账户选项。</p></div>\n        </div>\n        <div class=\"decision\">\n          <span class=\"priority\" style=\"color:var(--warn);background:var(--warn-soft)\">P1</span>\n          <div><strong>历史规则是否统一回填个人+餐补？退款/撤销、离线补单如何计次？</strong><p>本文给出兼容默认值，但需要产品、研发和测试在评审中确认并固化验收样例。</p></div>\n        </div>\n        <div class=\"callout\">\n          <strong>开发结论</strong>\n          已按以上口径进入实现；发布前继续保留历史规则回填、退款计次和离线补单边界的验证证据。\n        </div>\n      </section>\n\n      <footer>\n        VWCG-1119 · 技术开发文档 V1.1 · 详细证据、代码定位与完整测试矩阵见同目录 Markdown 真源\n      </footer>\n",
      "variant_b": "        <h2>1. 修订结论</h2>\n        <p class=\"lead\">本版不再根据“本单最终扣了哪个账户”决定规则，而是在下单/支付前读取所选受限账户的可用余额。</p>\n        <div class=\"callout good\">\n          <strong>统一判定</strong><br>\n          所选账户中任意一个可用余额大于 0，继续执行原有范围/次数限制；全部为 0 时跳过本条规则。\n        </div>\n        <div class=\"grid\">\n          <article class=\"card\"><h3>次数口径</h3><p>继续按当前成功订单数统计，不按 cash/subsidy/gift 最终扣款拆分。</p></article>\n          <article class=\"card\"><h3>资金来源</h3><p>规则只决定是否拦截，不修改营销价格、扣款顺序，也不自动换账户。</p></article>\n          <article class=\"card\"><h3>项目边界</h3><p>消费机在 store；小程序订餐在 ai_api。只改一个仓库无法闭环。</p></article>\n          <article class=\"card\"><h3>赠送账户</h3><p>依赖 VWCG-1121；迁移与全链路验证前保持置灰。</p></article>\n        </div>\n        <div class=\"callout\"><strong>旧数据默认值</strong><br>历史规则没有受限账户字段，统一默认个人账户 + 餐补账户；赠送账户不默认选中。</div>\n      </section>\n\n      <section id=\"rule\">\n        <h2>2. 业务规则</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>账户</th><th>“有钱”定义</th><th>排除项</th></tr></thead>\n            <tbody>\n              <tr><td><span class=\"tag\">cash</span>个人</td><td>活动本金余额 + 可提现余额；旧数据未拆分时兼容回退 balance</td><td>透支额度默认不算</td></tr>\n              <tr><td><span class=\"tag\">subsidy</span>餐补</td><td>判断时点有效、未删除且大于 0 的补贴余额之和</td><td>未生效、过期、负数</td></tr>\n              <tr><td><span class=\"tag warn\">gift</span>赠送</td><td>gift_balance 大于 0</td><td>能力未开放时不可配置</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <ul>\n          <li>金额使用 BCMath 两位小数比较，禁止浮点直接判断。</li>\n          <li>未选账户余额不参与规则是否生效。</li>\n          <li>余额查询失败不能按 0 放行，应返回可重试错误。</li>\n          <li>范围、月/日/餐次次数阈值保持现状。</li>\n        </ul>\n      </section>\n\n      <section id=\"channels\">\n        <h2>3. 三类入口</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>入口</th><th>真实调用链</th><th>接入要求</th></tr></thead>\n            <tbody>\n              <tr><td>消费机 HTTP API</td><td><code>Consume controller → orderPayAsync</code></td><td>覆盖 Redis 快照与 DB 回退</td></tr>\n              <tr><td>消费机 MQTT</td><td><code>MqttServer → orderPayAsync</code></td><td>与 HTTP 共享 Service，不复制规则</td></tr>\n              <tr><td>小程序点餐</td><td><code>MealCheckout / DirectPay → MealPayment / PaySuccess</code></td><td>提交早检 + 支付最终复检</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout warn\"><strong>代码事实</strong><br>store 只识别 <code>source=4</code> 的订餐订单，没有小程序提交入口；小程序必须在 ai_api 改造。</div>\n      </section>\n\n      <section id=\"architecture\">\n        <h2>4. 目标架构</h2>\n        <div class=\"flow\">\n          <div class=\"step\"><b>入口</b>HTTP / MQTT / 小程序</div>\n          <div class=\"step\"><b>身份映射</b>staff、zhct user、AI user</div>\n          <div class=\"step\"><b>余额快照</b>cash / subsidy / gift</div>\n          <div class=\"step\"><b>规则评估</b>余额门禁 + 范围/次数</div>\n          <div class=\"step\"><b>原支付流程</b>创建订单与扣款</div>\n        </div>\n        <p>两个项目使用相同的评估输入、输出和契约样例。输入至少包含租户、人员、智慧餐厅用户、场景、档口、餐次、消费时间、余额快照和幂等号。</p>\n      </section>\n\n      <section id=\"data\">\n        <h2>5. 数据与接口</h2>\n        <h3>规则账户关系</h3>\n        <p>新增 <code>ydy_consume_limit_account(limit_id, account_type, store_id, create_time)</code>，唯一索引为 <code>(limit_id, account_type)</code>。所有未删除历史规则（包括启用和停用）幂等回填 <code>cash + subsidy</code>，不自动回填 gift。</p>\n        <h3>CRUD</h3>\n        <p>新增/编辑传 <code>accountTypes: [\"cash\",\"subsidy\"]</code>；详情返回 <code>account_types</code>。必须为数组、去重后至少一项，并执行枚举白名单与赠送能力校验。</p>\n        <h3>拦截日志</h3>\n        <p>增加命中账户、脱敏余额快照和渠道，渠道取值 <code>consume_api</code>、<code>consume_mqtt</code>、<code>mini_program</code>。</p>\n        <div class=\"callout\">\n          <strong>四层兼容</strong><br>\n          ① 数据库回填；② 详情缺关系时返回 cash+subsidy；③ 新增和旧数据编辑默认勾选个人+餐补；④ 运行期缺关系按 cash+subsidy 判断并告警。旧规则首次编辑后把默认值真实持久化。\n        </div>\n      </section>\n\n      <section id=\"store\">\n        <h2>6. store 改造</h2>\n        <ol>\n          <li>新增账户余额解析器，完成 <code>staff_uuid → store user → ai_user_id</code> 映射。</li>\n          <li>从 <code>Consume.php</code> 抽出消费限制评估器，按“账户余额交集 → 范围 → 次数”执行。</li>\n          <li>HTTP 与 MQTT 只传渠道标识，统一在 <code>orderPayAsync()</code> 的支付锁内判断。</li>\n          <li>同时覆盖 <code>handleAsyncSnapshotPayment()</code> 和 <code>runAsyncFallback() → orderPay()</code>。</li>\n          <li>在线设备时间需要漂移校验；补贴有效期、次数边界和日志使用同一消费时间。</li>\n        </ol>\n        <div class=\"callout risk\"><strong>Redis 快照风险</strong><br>当前 TTL 为 300 秒。应在支付锁内刷新账户余额，或严格保证限制判断和后续扣款使用同一份最新快照。</div>\n      </section>\n\n      <section id=\"mini\">\n        <h2>7. 小程序改造</h2>\n        <ul>\n          <li><code>MealCheckout::submit()</code>：创建订单前早检，避免产生不可支付订单。</li>\n          <li><code>meal/DirectPay::pay()</code>：创建订单和扣款前，在同一事务内最终检查。</li>\n          <li><code>cashier/MealPayment</code> / <code>meal/PaySuccess</code>：支付前防御复检，覆盖历史待支付订单和旁路调用。</li>\n          <li>0 元订单必须在自动支付成功前检查。</li>\n          <li>AI 用户映射不到智慧餐厅用户/人员时失败，不得按“无规则”放行。</li>\n        </ul>\n        <p>双检查的目的：提交时尽早反馈，支付时处理余额、规则和时间变化，避免提交后充值或规则调整造成绕过。</p>\n      </section>\n\n      <section id=\"bugs\">\n        <h2>8. 隐形 Bug 风险</h2>\n        <div class=\"table-wrap\">\n          <table>\n            <thead><tr><th>风险</th><th>隐形后果</th><th>防护</th></tr></thead>\n            <tbody>\n              <tr><td>只改 HTTP 或 MQTT</td><td>上报方式不同，限制结果不同</td><td>共享 Service + 双入口契约测试</td></tr>\n              <tr><td>300 秒旧快照</td><td>充值/扣款/补贴变化后误判</td><td>锁内刷新并复用同快照</td></tr>\n              <tr><td>只改 store</td><td>小程序完全不判断</td><td>ai_api 三个接入点同步改</td></tr>\n              <tr><td>透支算余额</td><td>实际 0 余额仍触发规则</td><td>默认排除透支</td></tr>\n              <tr><td>账户异常当 0</td><td>跨库故障时规则绕过</td><td>失败关闭、提示重试</td></tr>\n              <tr><td>只在提交检查</td><td>支付时余额变化后绕过</td><td>支付前最终复检</td></tr>\n              <tr><td>次数先查后写</td><td>并发两单同时通过最后额度</td><td>跨项目同一用户/场景锁</td></tr>\n              <tr><td>两个项目算法漂移</td><td>消费机与小程序行为不一致</td><td>共享版本化契约样例</td></tr>\n              <tr><td>0 元自动支付</td><td>范围/次数被旁路</td><td>自动支付前检查</td></tr>\n              <tr><td>只做前端默认</td><td>API/任务链路仍把旧规则当空配置</td><td>SQL、详情、前端、运行期四层兼容</td></tr>\n            </tbody>\n          </table>\n        </div>\n        <div class=\"callout risk\"><strong>并发门禁</strong><br>锁名统一为 <code>consume_limit:{storeId}:{zhctUserId}:{scenario}</code>，覆盖最终复检到成功订单落库。两项目 Redis 不共享时必须采用 mysql_zhct 数据库锁/守卫行。</div>\n      </section>\n\n      <section id=\"test\">\n        <h2>9. 测试与发布</h2>\n        <div class=\"grid\">\n          <article class=\"card\"><h3>余额边界</h3><p>个人拆分/旧值、有效与过期餐补、赠送开关、多选任一有钱、透支、查询失败。</p></article>\n          <article class=\"card\"><h3>渠道一致性</h3><p>同一载荷走 HTTP 和 MQTT，除 channel/trace_id 外结果一致。</p></article>\n          <article class=\"card\"><h3>小程序</h3><p>submit、0 元自动支付、余额支付、directPay、历史待支付复检。</p></article>\n          <article class=\"card\"><h3>并发</h3><p>MQTT 与小程序并发争抢最后一次额度，只允许一单成功。</p></article>\n        </div>\n        <h3>上线顺序</h3>\n        <ol>\n          <li>完成 VWCG-1121 账户迁移，gift 未就绪则保持禁用。</li>\n          <li>创建关系表、日志字段，为全部未删除旧规则幂等回填 cash+subsidy。</li>\n          <li>验证重复回填无重复数据，旧规则详情默认勾选个人+餐补，gift 不默认选中。</li>\n          <li>部署 store 后端，再部署 ai_api，最后开放 PC 表单。</li>\n          <li>内部门店同时验证 HTTP、MQTT、小程序，观察后灰度。</li>\n        </ol>\n        <p>本需求不改变订单列表字段或导出口径，九类异步导出无需同步修改。</p>\n      </section>\n\n      <section id=\"decisions\">\n        <h2>10. 待确认项</h2>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>多选账户触发逻辑</strong><p>本文按“任一所选账户有可用余额即生效”设计。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>透支口径</strong><p>本文默认透支额度不属于“账户有钱”。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>跨项目锁</strong><p>确认 store 与 ai_api 是否共享 Redis；否则采用 mysql_zhct 数据库锁。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P0</span><div><strong>0 元订单</strong><p>本文默认也执行范围与次数限制。</p></div></div>\n        <div class=\"decision\"><span class=\"priority\">P1</span><div><strong>设备时间与离线补传</strong><p>确认在线时间漂移窗口；离线补传默认仍不实时拦截。</p></div></div>\n        <div class=\"callout warn\"><strong>开发门禁</strong><br>方案可实现，但属于双项目、高一致性改造。P0 口径、跨项目锁方案和赠送账户依赖未关闭前，不建议直接上线。</div>\n      </section>\n\n      <footer>VWCG-1119 · 技术开发文档 V1.2 · Markdown 为内容真源</footer>\n"
    }
  ]
}
