Selaa lähdekoodia

订单支付各种漏洞修复

徐滕 2 viikkoa sitten
vanhempi
commit
7b9053cf8c
31 muutettua tiedostoa jossa 2467 lisäystä ja 635 poistoa
  1. 361 0
      code-copilot/changes/hotel-alipay-secret-externalize/spec.md
  2. 436 0
      code-copilot/changes/hotel-pay-mock-hardening/spec.md
  3. 263 0
      code-copilot/changes/hotel-pay-mock-hardening/tasks.md
  4. 38 0
      forge-h5-ui/.env.mp-alipay
  5. 0 19
      forge-h5-ui/AGENTS.md
  6. 2 2
      forge-h5-ui/package.json
  7. 17 0
      forge-h5-ui/src/components/hotel/HotelTabBar.vue
  8. 4 0
      forge-h5-ui/src/manifest.json
  9. 7 0
      forge-h5-ui/src/pages.json
  10. 15 5
      forge-h5-ui/src/pages/hotel/customer/pay.vue
  11. 49 2
      forge-h5-ui/src/pages/hotel/customer/room-confirm.vue
  12. 20 5
      forge-h5-ui/src/pages/index/index.vue
  13. 8 1
      forge-server/forge-admin-server/src/main/resources/application-dev.example.yml
  14. 8 1
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  15. 19 0
      forge-server/forge-admin-server/src/main/resources/application.yml
  16. 8 1
      forge-server/forge-app-server/src/main/resources/application-dev.example.yml
  17. 8 1
      forge-server/forge-app-server/src/main/resources/application-dev.yml
  18. 19 0
      forge-server/forge-app-server/src/main/resources/application.yml
  19. 7 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/constant/HotelQrConstants.java
  20. 15 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/AlipayAuthController.java
  21. 16 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelPayController.java
  22. 49 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/config/HotelPayConfig.java
  23. 2 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/AlipayAuthServiceImpl.java
  24. 64 16
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java
  25. 12 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java
  26. 33 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/utils/QrCodeUtils.java
  27. 0 420
      forge-server/forge-business/forge-hotel/酒店AGENTS.md
  28. 396 36
      forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md
  29. 267 0
      forge-server/forge-business/forge-hotel/酒店模块小程序化改造汇总.md
  30. 103 36
      forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md
  31. 221 80
      forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md

+ 361 - 0
code-copilot/changes/hotel-alipay-secret-externalize/spec.md

@@ -0,0 +1,361 @@
+# 支付宝沙箱密钥外提与仓库泄密处置
+
+> status: **closed**(用户已决策接受,变更关闭)
+> created: 2026-09-07
+> closed: 2026-09-07(用户决策:内网私仓 + 沙箱密钥可接受,后期生产密钥不提交)
+> complexity: 🟡中等
+> 规则依据:`AGENTS.md` 5.9 安全红线「禁止硬编码密钥、AK/SK」、5.12 数据库脚本维护规范(无关)、2.4 环境变量
+> 关联变更:`code-copilot/changes/hotel-pay-mock-hardening/`(资金逻辑收口,**性质不同,不可合并**)
+
+---
+
+## ⚠️ 0. 状态声明(必读)
+
+**本变更已关闭。**
+
+| 项 | 状态 |
+|---|---|
+| 方案编写 | ✅ 本文件 |
+| 代码/配置改动 | ❌ **一行未动** |
+| 密钥轮换 | ❌ 未执行 |
+| 用户决策 | ✅ **已决策接受**:git 是内网私有仓库(192.168.8.4),不在外网;密钥是沙箱密钥;后期如有必要,会直接不提交生产的密钥 |
+
+**关闭原因**:用户评估风险可接受,本变更不再推进。后续生产密钥不入库由用户把控。
+
+**更正记录**:
+- 原 2.3 节「P0 启动隐患」**判断错误**,已撤销。真实情况:`application.yml` L42-43 `spring.profiles.active: dev`,当前启动会加载 `application-dev.yml`,`hotel.alipay.*` 全部有值,**不会崩**。
+
+---
+
+## 1. 背景与目标
+
+### 1.1 问题
+
+`application-dev.yml` 中的支付宝沙箱**应用私钥(RSA2,PKCS#8 全文约 1600 字符)与支付宝公钥以明文形式写死**,且该文件**被 git 跟踪并已推送到远端**。命中 `AGENTS.md` 5.9「禁止硬编码密钥、AK/SK、数据库密码」。
+
+### 1.2 风险定级(如实评估,不夸大)
+
+| 维度 | 结论 |
+|---|---|
+| 直接资金损失 | 🟢 **无** —— 这是**沙箱**密钥(`gateway-url` 为 `openapi-sandbox.dl.alipaydev.com`,`sandbox: true`),沙箱不涉及真实资金 |
+| 合规红线 | 🔴 **硬违规** —— `AGENTS.md` 5.9 **不区分沙箱与生产**,「禁止硬编码密钥」是无条件红线 |
+| 实际危害 | 🟡 **中** —— ① 他人可用你的沙箱 appid + 私钥以你的身份发起交易、污染测试数据;② 沙箱密钥对**存在被复用到生产**的现实可能(同一开发者图省事),一旦复用即升级为高危;③ 私钥泄露后无法「改密码」,只能重新生成密钥对并更换应用公钥 |
+| 扩散面 | 🔴 **已进远端历史** —— commit `65508d9`「支付宝沙箱支付功能提交」(2026-09-01)已存在于 `origin/master`,`git log -S` 可检索到私钥全文 |
+
+### 1.3 目标
+
+1. **止血**:轮换密钥,让已泄露的私钥失效(这是唯一真正有效的措施)
+2. **断源**:仓库中不再出现任何真实密钥,改为环境变量占位
+3. **生效忽略规则**:让 `.gitignore` 里**已存在但失效**的 `**/application-dev.yml` 规则真正起作用
+4. **顺带修复一个 P0 启动隐患**(见 2.3)—— 生产 profile 当前**启动即崩**
+5. **不做**:不改 `AGENTS.md` 的意图表述(它是对的,见 2.4)
+
+---
+
+## 2. 现状核实(全部经 git / 文件直读验证,非推测)
+
+### 2.1 泄露范围
+
+| 文件 | 是否被 git 跟踪 | 是否含明文密钥 |
+|---|---|---|
+| `forge-server/forge-admin-server/src/main/resources/application-dev.yml` | 🔴 **是**(`git ls-files` 命中) | 🔴 **是**,L106-118 `hotel.alipay` 块:`app-id` / `private-key`(L110)/ `alipay-public-key`(L112)/ `gateway-url` / `notify-url` / `sandbox` |
+| `forge-server/forge-app-server/src/main/resources/application-dev.yml` | 🔴 **是** | 🔴 **是**,L85-99 同一份密钥(**两份文件密钥完全相同**),另多一个 `return-url`(L97) |
+| `forge-server/*/src/main/resources/application-dev.example.yml` | ✅ 是(**应当**提交) | 🟢 **否** —— `Select-String -Pattern "alipay"` **零命中**,模板文件里根本没有 `hotel.alipay` 块 |
+| `forge-server/*/src/main/resources/application.yml`(生产) | ✅ 是 | 🟢 否 —— **零命中**,生产 yml 的 `hotel:` 块只有 `qr` 和 `pay`(admin-server L222-239) |
+
+其它顺带发现:`application-dev.yml` L19-20 的数据库口令为 `root` / `root`(本地开发默认值,敏感度低,但同属「明文口令入库」)。
+
+### 2.2 🔴 `.gitignore` 规则**已存在但失效** —— 修正我此前的建议
+
+我在提出本变更时的原话是「把 `application-dev.yml` 加入 `.gitignore`」。**这个说法是错的**,核实结果:
+
+```
+$ git check-ignore -v --no-index forge-server/forge-admin-server/src/main/resources/application-dev.yml
+.gitignore:83:**/application-dev.yml    forge-server/.../application-dev.yml
+
+$ git check-ignore -v forge-server/forge-admin-server/src/main/resources/application-dev.yml
+(无输出,退出码 1)
+```
+
+- `.gitignore` **第 83 行早就有** `**/application-dev.yml`,规则本身完全正确、能匹配
+- 但**不带 `--no-index` 时返回空** —— 因为 `.gitignore` **只对未跟踪文件生效**,该文件已在索引中,规则被完全绕过
+- 结论:**不需要追加任何 `.gitignore` 规则**,需要的是 `git rm --cached` 把它从索引中移除,让既有规则接管
+
+同时修正另一处说法:我此前说「`AGENTS.md` 2.4 把它归为『本地配置(不可提交)』,与实际不符,也该修」。**AGENTS.md 的表述是对的**(2.4 明确把 `application-dev.yml` 列为「后端本地配置」、把 `application-dev.example.yml` 列为「配置模板(可提交)」),**不符的是仓库实际状态**。要修的是状态,不是文案。
+
+> ⚠️ `AGENTS.md` 2.4 唯一确实该修的是**路径前缀**:写的是 `forge/forge-admin-server/...`,真实路径是 `forge-server/forge-admin-server/...`。这与 2.2 的 `cd forge && mvn clean install`(仓库根 `forge/` 是**空目录**,真实 Maven 根为 `forge-server/`)同源,属同一处文档偏差,建议一并修。
+
+### 2.3 ✅ 【已撤销】原 P0 启动隐患 — 判断错误,当前不会崩
+
+**原判断**:`AlipayConfig` 5 个 `@Value` 无默认值 + 生产 `application.yml` 无 `hotel.alipay` 块 → 生产启动崩溃(P0)。
+
+**更正**:`application.yml` L42-43 `spring.profiles.active: dev`,且 docker-compose.yml / .env.example **无任何 profile 覆盖**,项目也**没有** `application-prod.yml`。因此当前启动(包括 docker 部署)都会加载 `application-dev.yml`,`hotel.alipay.*` 全部有值,**不会崩**。
+
+**我错在哪**:把 `application.yml` 当成「生产专用配置」,忽略了它 L43 默认激活 dev profile,`application-dev.yml` 会被合并加载。
+
+### 2.4 环境事实
+
+- 远端:`origin` → `http://192.168.8.4:3000/user5/forge_admin.git`(**内网 Gitea**,非公网仓库)
+- 泄露 commit `65508d9` 已在 `origin/master`(`git branch -r --contains` 命中)
+- ⚠️ 内网仓库**降低了**外部攻击面,但**不消除**风险:任何有仓库读权限的人(含离职人员的历史克隆、CI 缓存、备份)都持有该私钥
+
+---
+
+## 3. 处置方案(4 步,顺序不可调换)
+
+> **顺序原则:先止血(轮换),再断源(占位化),最后清历史。**
+> 只要旧私钥仍然有效,任何仓库层面的清理都是**装饰性**的 —— 攻击者手里的密钥照样能用。
+
+---
+
+### Step 1 🔴 轮换密钥(最先做,只能由你操作)
+
+**这一步做完,泄露的私钥立即作废,后续步骤的风险等级随之下降。**
+
+在支付宝开放平台操作:
+
+1. 登录 [open.alipay.com](https://open.alipay.com) → 开发者中心 → **沙箱应用**(appid `9021000166699753`)
+2. 「开发信息」→「接口加签方式」→ 选择**公钥模式**
+3. 用**支付宝官方密钥生成工具**重新生成一对 RSA2(2048 位)密钥
+   - ⚠️ 生成工具:[https://opendocs.alipay.com/common/02kipl](https://opendocs.alipay.com/common/02kipl),**禁止用第三方在线生成器**(那等于把新私钥再泄露一次)
+4. 上传**新的应用公钥**,平台会返回**新的支付宝公钥**
+5. 记录三样东西:新应用私钥、新支付宝公钥、(appid 不变)
+6. ⚠️ **不要**把新密钥粘贴到任何 git 跟踪的文件、聊天工具、issue、文档中 —— 包括本文件
+
+**验证轮换生效**:用旧私钥调一次沙箱接口,应返回验签失败(`isv.invalid-signature`)。
+
+> 📌 **待你确认(第 6 节 Q1)**:是否有其它环境/同事正在使用这对沙箱密钥?轮换会**立即打断**他们的调试。
+
+---
+
+### Step 2 yml 占位化 + 补齐生产块(修复 2.3 的 P0)
+
+**改动面:6 份 yml**(与 `hotel-pay-mock-hardening` 共享同一批文件,见第 5 节风险 ③)
+
+#### 2-A 生产 `application.yml`(admin-server L239 之后 / app-server L121 之后,**新增整个 `alipay` 块**)
+
+```yaml
+hotel:
+  qr:
+    base-url: ${FORGE_HOTEL_QR_BASE_URL:}
+    path: ${FORGE_HOTEL_QR_PATH:/r/}
+  pay:
+    mock-enabled: ${FORGE_HOTEL_PAY_MOCK_ENABLED:false}
+  # 支付宝支付配置。全部通过环境变量注入,禁止在仓库中写明文密钥(AGENTS.md 5.9)。
+  # 五个必填项无代码级默认值:未注入时 AlipayConfig 占位符解析失败、服务启动即崩,
+  # 这是刻意的 fail-fast —— 生产缺支付配置就不该起来,避免起来后静默走到 Mock 分支。
+  alipay:
+    app-id: ${FORGE_HOTEL_ALIPAY_APP_ID:}
+    private-key: ${FORGE_HOTEL_ALIPAY_PRIVATE_KEY:}
+    alipay-public-key: ${FORGE_HOTEL_ALIPAY_PUBLIC_KEY:}
+    gateway-url: ${FORGE_HOTEL_ALIPAY_GATEWAY_URL:https://openapi.alipay.com/gateway.do}
+    notify-url: ${FORGE_HOTEL_ALIPAY_NOTIFY_URL:}
+    return-url: ${FORGE_HOTEL_ALIPAY_RETURN_URL:}
+    sandbox: ${FORGE_HOTEL_ALIPAY_SANDBOX:false}
+```
+
+> ⚠️ **这里有个设计选择需你定(第 6 节 Q2)**:
+> - **方案 A(上面写的)**:给空串默认值 `:}` → 生产不配也能启动,但支付功能不可用(调支付宝时报错)
+> - **方案 B**:**不给**默认值 → 生产不配就**启动即崩**(fail-fast)
+>
+> 我推荐 **方案 A + 空串**,理由:`AlipayConfig` 是 `@Configuration`,方案 B 会让**根本不用酒店支付功能的部署**也起不来(`forge-hotel` 是 `forge-business` 的一部分,被两个服务无条件扫描)。空串默认值 + 调用时报错,故障面更可控。
+>
+> 但方案 A 需要**配套改动**:`AlipayConfig.alipayClient()`(L50-54)应在 `appId` / `privateKey` 为空时**不创建 Bean 或创建后在调用处显式报「支付未配置」**,否则 `DefaultAlipayClient` 会用空密钥初始化,错误信息晦涩。这一改动**超出纯配置范围**,需你同意才做(第 6 节 Q2)。
+
+#### 2-B `application-dev.yml`(两份,**把明文密钥换成占位 + 本地兜底**)
+
+```yaml
+hotel:
+  qr:
+    base-url: http://192.168.10.4:3001
+    path: "/#/pages/hotel/scan-bind"
+  pay:
+    mock-enabled: true
+  # 支付宝沙箱配置。密钥不写死在文件里(本文件虽已 ignore,但历史上曾被提交,见变更方案 Step 1)。
+  # 三种注入方式任选其一:
+  #   ① IDEA Run Configuration → Environment variables(推荐,重启即生效)
+  #   ② 系统环境变量
+  #   ③ 本机 ~/.forge/secrets/alipay.properties(若采用 Step 2-D 的 spring.config.import 方案)
+  alipay:
+    app-id: ${FORGE_HOTEL_ALIPAY_APP_ID:9021000166699753}
+    private-key: ${FORGE_HOTEL_ALIPAY_PRIVATE_KEY:}
+    alipay-public-key: ${FORGE_HOTEL_ALIPAY_PUBLIC_KEY:}
+    gateway-url: ${FORGE_HOTEL_ALIPAY_GATEWAY_URL:https://openapi-sandbox.dl.alipaydev.com/gateway.do}
+    notify-url: ${FORGE_HOTEL_ALIPAY_NOTIFY_URL:http://pc948923.natappfree.cc/hotel/open/pay/notify}
+    return-url: ${FORGE_HOTEL_ALIPAY_RETURN_URL:http://192.168.10.4:3001/#/pages/hotel/customer/pay}   # 仅 app-server
+    sandbox: true
+```
+
+- `app-id` / `gateway-url` / `notify-url` / `return-url` / `sandbox` **保留明文默认值** —— 它们不是密钥,写死可让你**少配 5 个环境变量**
+- **只有 `private-key` 和 `alipay-public-key` 默认值为空** —— 这两个是必须外提的
+- ⚠️ `notify-url` 的 natapp 免费隧道域名(`pc948923.natappfree.cc`)**与你账号绑定**,敏感度中等。是否也外提见第 6 节 Q3
+
+#### 2-C `application-dev.example.yml`(两份,**新增带说明的空模板**)
+
+当前模板文件**完全没有 `hotel.alipay` 块**(`Select-String` 零命中),新人 `cp` 之后会撞上 2.3 的启动崩溃。需补齐:
+
+```yaml
+hotel:
+  # ... qr / pay 块保持与 dev 一致 ...
+  # 支付宝沙箱配置。以下两项必须自行填入,仓库中不提供任何真实值。
+  # 获取方式:open.alipay.com → 开发者中心 → 沙箱应用 → 接口加签方式 → 用官方密钥工具生成 RSA2 密钥对
+  alipay:
+    app-id: 你的沙箱APPID
+    private-key: 你的应用私钥(RSA2,PKCS#8 单行,不要带 -----BEGIN----- 头尾)
+    alipay-public-key: 上传应用公钥后平台返回的支付宝公钥
+    gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
+    notify-url: 你的内网穿透地址/hotel/open/pay/notify
+    return-url: 你的H5地址/#/pages/hotel/customer/pay
+    sandbox: true
+```
+
+#### 2-D(可选)本机密钥文件方案
+
+若不想每次配环境变量,可用 Spring Boot 的 `spring.config.import` 引入本机文件:
+
+```yaml
+# application-dev.yml 顶部
+spring:
+  config:
+    import: "optional:file:${user.home}/.forge/secrets/alipay.properties"
+```
+
+```properties
+# ~/.forge/secrets/alipay.properties(在仓库外,天然不会被提交)
+FORGE_HOTEL_ALIPAY_PRIVATE_KEY=xxx
+FORGE_HOTEL_ALIPAY_PUBLIC_KEY=xxx
+```
+
+- `optional:` 前缀保证文件不存在时不报错
+- 与仓库已有的 `~/.forge/secrets/crypto.properties` 约定(见 `application-dev.yml` L67 注释)**同源**,风格一致
+- ⚠️ 需实测 `spring.config.import` 引入的 properties 能否被 `${}` 占位符解析(Spring Boot 3.2 支持,但需验证加载顺序)—— 见第 6 节 Q4
+
+---
+
+### Step 3 让 `.gitignore` 规则生效
+
+```powershell
+# 从索引移除,但保留本地文件(--cached 是关键,不加会删掉你的本地配置)
+git rm --cached forge-server/forge-admin-server/src/main/resources/application-dev.yml
+git rm --cached forge-server/forge-app-server/src/main/resources/application-dev.yml
+
+# 验证:应输出 .gitignore:83:**/application-dev.yml
+git check-ignore -v forge-server/forge-admin-server/src/main/resources/application-dev.yml
+```
+
+#### 🔴 这一步对协作者有破坏性,必须先通知
+
+`git rm --cached` 提交后,该 commit 记录的是**文件删除**。其他开发者 `git pull` 时,**git 会删掉他们本地的 `application-dev.yml`**(因为在他们工作区里该文件是跟踪状态)。
+
+**必须先做的事**:
+1. 通知所有协作者**备份自己的 `application-dev.yml`**
+2. 他们 pull 之后把备份放回来(此时文件已 untracked + ignored,不会再被提交)
+3. 提交信息中写明这一点
+
+> 📌 **待你确认(第 6 节 Q5)**:当前仓库有几个活跃协作者?是否可以协调一个统一的时间窗口?
+
+#### ⚠️ 顺序要求
+
+**Step 3 必须在 Step 2 之后**:先占位化再脱钩。若先 `git rm --cached`,那么在你换密钥的这段时间里,明文密钥文件仍在你本地且已脱离版本控制 —— 看似安全,实则**远端历史里的旧密钥仍然有效**,止血效果为零。
+
+---
+
+### Step 4(可选,🔴 破坏性)清理 git 历史
+
+**只有在 Step 1 轮换完成后,这一步才是"锦上添花";如果没轮换,这一步是自欺欺人。**
+
+| 方案 | 命令 | 破坏性 |
+|---|---|---|
+| `git filter-repo`(官方推荐) | `git filter-repo --replace-text expressions.txt` | 🔴 **重写全部 commit hash** |
+| BFG Repo-Cleaner | `bfg --replace-text passwords.txt` | 🔴 同上 |
+
+**后果(必须全部接受才能做)**:
+- 所有 commit hash 变化 → 现存分支、PR、issue 引用、CI 缓存全部失效
+- 所有协作者必须**重新克隆**(不能 pull,会产生重复历史)
+- 远端需 `git push --force` → **`AGENTS.md` 与我的操作准则都要求:force push 到 master 必须你明确授权,我不会自行执行**
+- Gitea 服务端可能仍保留旧对象(需管理员执行 GC)
+
+**我的建议**:
+- 若仓库**仅内网 + 少数可信协作者** → **不做 Step 4**。轮换后旧密钥已失效,历史里的字符串只是无意义数据,重写历史的协作成本远高于收益
+- 若仓库**曾对公网开放 / 有不可信读取者 / 沙箱密钥曾复用到生产** → **必须做 Step 4**
+
+---
+
+## 4. 对你本地开发的影响与规避
+
+| 影响 | 说明 | 规避 |
+|---|---|---|
+| 🔴 **改完 `application-dev.yml` 后启动失败** | `private-key` 占位符无值 → `AlipayConfig` 拿到空串 → `DefaultAlipayClient` 初始化不报错,但**调支付宝接口时验签失败** | 执行 Step 2 前,先在 IDEA Run Configuration 里配好 `FORGE_HOTEL_ALIPAY_PRIVATE_KEY` / `FORGE_HOTEL_ALIPAY_PUBLIC_KEY` |
+| 🟡 支付宝沙箱支付调试中断 | 轮换密钥后,新密钥未配好之前无法调通 | 轮换与配置**在同一时间窗口内完成**;期间可用 `hotel.pay.mock-enabled=true` 走 Mock 分支调试业务流转(dev 已开启) |
+| 🟢 H5 前端 | **零影响** —— 本变更不动任何前端文件 | — |
+| 🟢 二维码功能 | **零影响** —— `hotel.qr.*` 块不动 | — |
+| 🟢 Mock 支付开关 | **零影响** —— `hotel.pay.mock-enabled` 不动 | — |
+
+> ⚠️ **执行时机建议**:选一个你**不需要立即调试支付宝真实支付**的时间点。若近期要演示/验收支付流程,先跳过 Step 1-2,只做记录。
+
+---
+
+## 5. 风险与关注点
+
+| # | 风险 | 缓解 |
+|---|---|---|
+| ① | 🔴 **轮换打断协作者**:沙箱 appid 是共享的,换密钥后其他人本地立刻失效 | Step 1 前先确认使用范围(Q1);轮换后同步新密钥给协作者(**通过安全渠道,不走 git**) |
+| ② | 🔴 **Step 3 删除协作者本地配置**:`git rm --cached` 提交后,别人 pull 会丢文件 | 提前通知备份,提交信息写明 |
+| ③ | 🟡 **6 份 yml 与 `hotel-pay-mock-hardening` 共享同一 `hotel:` 块** | 两变更**不可并行改同一文件**;本变更应在 `hotel-pay-mock-hardening` 的 HARD-GATE 签署**之后**再动 yml,避免回滚互相污染 |
+| ④ | 🟡 **Step 2-A 的方案 B(无默认值)会让不用酒店支付的部署起不来** | 采方案 A(空串默认值)+ 配套改 `alipayClient()` 显式报错(需你同意,Q2) |
+| ⑤ | 🟡 **新密钥二次泄露**:把新密钥粘到 issue / 聊天 / 文档 | Step 1 第 6 条已明令禁止;本文件全程**不含任何真实密钥字符** |
+| ⑥ | 🟢 **Step 4 重写历史** | 默认不做;若做需你明确授权 force push |
+| ⑦ | 🟡 **`application-dev.example.yml` 补块后新人仍可能填错格式** | 私钥必须是 **PKCS#8 单行、去掉 `-----BEGIN-----` 头尾**,在模板注释中写明 |
+
+---
+
+## 6. 待决策(需你逐条给结论,全部解决后才进入 `/apply`)
+
+- [ ] **Q1**:这对沙箱密钥(appid `9021000166699753`)**是否有其他环境/同事正在使用**?轮换会立即打断他们。是否有统一时间窗口?
+- [ ] **Q2**:生产 `application.yml` 的 `hotel.alipay.*` 采用**方案 A(空串默认值,推荐)**还是**方案 B(无默认值 fail-fast)**?若选 A,是否同意**配套修改 `AlipayConfig.alipayClient()`**,在密钥为空时显式抛「支付宝支付未配置」而非用空密钥初始化?
+- [ ] **Q3**:`notify-url` 的 natapp 隧道域名(`pc948923.natappfree.cc`)是否也外提?它与你账号绑定、敏感度中等,但外提后你每次换隧道都要改环境变量。
+- [ ] **Q4**:是否采用 **Step 2-D**(`spring.config.import` + `~/.forge/secrets/alipay.properties`)?好处是不用配环境变量、与仓库既有 `crypto.properties` 约定同源;代价是需先实测加载顺序。
+- [ ] **Q5**:是否执行 **Step 4**(重写 git 历史)?需你明确授权 `git push --force` 到 `master`,我不会自行执行。我的默认建议是**不做**(内网仓库 + 已轮换 = 收益低于协作成本)。
+
+### 6.1 附带待决策:`AGENTS.md` 文档修正
+
+- [ ] **Q6**:是否顺带修 `AGENTS.md` 的**路径偏差**?
+  - 2.2 节 `cd forge && mvn clean install` → 真实为 `cd forge-server && mvn clean install`(仓库根 `forge/` 是**空目录**)
+  - 2.4 节表格路径 `forge/forge-admin-server/...` → 真实为 `forge-server/forge-admin-server/...`
+  - ⚠️ **2.4 节的意图表述是对的,不要改**(`application-dev.yml` = 本地配置、`.example.yml` = 可提交模板),错的只是路径前缀
+
+---
+
+## 7. 验收清单(执行后逐条勾)
+
+- [ ] 支付宝开放平台已生成新 RSA2 密钥对,应用公钥已更换
+- [ ] 用旧私钥调沙箱接口返回验签失败(证明轮换生效)
+- [ ] `Select-String -Pattern "MIIEvg" -Path forge-server\*\src\main\resources\*.yml` **零命中**(旧私钥前缀已清除)
+- [ ] 6 份 yml 的 `hotel.alipay` 块均已占位化,生产块已补齐(修复 2.3 的 P0)
+- [ ] `application-dev.example.yml` 已补 `hotel.alipay` 空模板 + 格式说明
+- [ ] `git ls-files | Select-String "application-dev.yml"` **零命中**(已脱离跟踪)
+- [ ] `git check-ignore -v forge-server/forge-admin-server/src/main/resources/application-dev.yml` 输出 `.gitignore:83`(规则已生效)
+- [ ] 本地 `application-dev.yml` 文件**仍存在**且内容正确(`--cached` 未误删)
+- [ ] 配好环境变量后,admin-server 与 app-server **均能正常启动**
+- [ ] 支付宝沙箱支付**调通一次**(`createPay` → 收银台 → 回调 → `pay_status=1`)
+- [ ] H5 dev 环境二维码扫码、Mock 支付调试**行为零变化**
+- [ ] 协作者已收到通知并备份了自己的 `application-dev.yml`
+- [ ] (若做 Step 4)所有协作者已重新克隆,CI 缓存已清理
+
+---
+
+## 8. 确认记录(HARD-GATE)
+
+> 本变更**不直接改动资金流转逻辑**,但涉及**密钥安全**与**破坏性 git 操作**(`git rm --cached` / 可能的 `push --force`),按 `AGENTS.md` 5.9 与操作准则,须你确认后方可执行。
+> **AI 助手不得代签、不得自行执行 Step 3 之后的任何 git 写操作。**
+
+- **确认时间**:
+- **确认人**:
+
+**确认范围声明**(签署即表示已阅读并认可):
+
+1. 第 6 节 Q1~Q6 六项待决策已有明确结论
+2. 知悉 Step 1 轮换密钥**只能由本人在支付宝开放平台操作**,AI 无法代办
+3. 知悉 Step 3 会导致协作者 pull 时丢失本地 `application-dev.yml`,已安排通知与备份
+4. 知悉 Step 4 需重写全部 commit hash 并 force push 到 master,**默认不做**;若做已单独明确授权
+5. 知悉本变更应在 `hotel-pay-mock-hardening` 的 HARD-GATE 签署**之后**再动 6 份共享 yml(风险 ③)

+ 436 - 0
code-copilot/changes/hotel-pay-mock-hardening/spec.md

@@ -0,0 +1,436 @@
+# 酒店支付 Mock 免付款资金红线收口
+
+> status: review
+> created: 2026-09-07
+> complexity: 🔴复杂
+> 需求依据:`forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md` 3.3 / 第九节第 0 批
+> 实现权威:`forge-server/forge-business/forge-hotel/酒店订单支付系统开发文档.md` 10.1
+> 规则依据:`AGENTS.md` 5.9 安全红线、`code-copilot/rules/security.md` 第 2 节
+
+---
+
+## ⚠️ 0. 流程状态声明(必读)
+
+**本 Spec 为补写文档,不是事前提案。**
+
+| 项 | 状态 |
+|---|---|
+| 代码实现 | ✅ 已落地并通过 `mvn compile` / `pnpm build:h5` / `GetProblems` 验证 |
+| Spec 编写 | 🔴 **代码落地之后才补写**(即本文件) |
+| 人工审查 | 🔴 **尚未执行** |
+| HARD-GATE 签署 | 🔴 第 13 节 `确认时间` / `确认人` 留空,**只能由需求方本人手填** |
+
+`code-copilot/rules/security.md` 第 2 节要求「涉及资金变更的逻辑,必须在 spec 中明确标注,**人工审查后方可编码**」。本次是**先编码、后补审查**,属 `AGENTS.md` 5.9 的**流程倒置**。补写本 Spec 的目的是把已发生的改动完整登记、把资金风险点显式摊开供审查,**不等于合规已完成** —— 合规闭环以第 13 节签署为准。
+
+---
+
+## 1. 背景与目标
+
+### 1.1 缺陷(改造前)
+
+顾客端支付链路存在**免付款下单**漏洞,任何人构造 HTTP 请求即可把订单置为「已支付」而不产生任何真实资金流:
+
+| 环节 | 位置(改造前) | 缺陷 |
+|---|---|---|
+| 接口暴露 | `HotelPayController` `@RequestMapping("/hotel/open/pay")` | 落在 `SaTokenConfig` 白名单 `/hotel/open/**` 内,`mockSuccess` **完全免登录** |
+| 缺省渠道 | `createPay` 的 `@RequestParam(defaultValue = "MOCK") String paySource` | 不传 `paySource` 即默认走 Mock |
+| 静默降级 ① | `resolvePayChannel` 微信分支 | `WECHAT` / `WECHAT_MP` 直接 `return "MOCK"` |
+| 静默降级 ② | `resolvePayChannel` 兜底分支 | 任何未知渠道值直接 `return "MOCK"` |
+| 前端兜底 | `pay.vue` `handlePay()` 的 `else` | 后端未返回可用支付参数时,**兜底调用 `hotelPayMockSuccess`** |
+| 退款误判 | `refundOrder` 用 `!"ALIPAY".equals(payChannel)` 判定「无资金流」 | 未来接入微信后,微信订单退款会被误判为无资金流而直接标记成功 → **真实资损** |
+
+攻击面:`POST /hotel/open/pay/mockSuccess?tenantId=1&orderId=X` 无需任何凭证。
+
+### 1.2 目标
+
+- 生产环境**模拟支付一律拒绝**,未知/未接入渠道**显式报错**,全仓不存在任何静默降级为 `MOCK` 的路径
+- 模拟支付能力**保留给开发环境**,通过配置开关控制,**dev 既有 H5 调试行为零变化**(用户硬约束:「调整不可影响我开发时候的 h5 的测试」)
+- 退款渠道判定改为**显式白名单**,杜绝「非 ALIPAY 即 MOCK」兜底带来的未来资损
+- 顺带收口两处日志脱敏问题(手机号明文、授权响应原文)
+
+### 1.3 不做的事
+
+- ❌ 不实现微信支付(商户号未申请,已决策暂缓上架)
+- ❌ 不实现 `ALIPAY_MP` 渠道(`alipay.trade.create`,属第 3 批)
+- ❌ 不改二维码 URL 配置化(属第 2 批,不涉资金,不单独建 Spec)
+- ❌ 不处理 `application-dev.yml` 明文沙箱密钥(**独立红线**,见 `code-copilot/changes/hotel-alipay-secret-externalize/`)
+
+---
+
+## 2. 代码现状(Research Findings)
+
+### 2.1 相关入口与链路
+
+```
+pay.vue handlePay()
+  └─ POST /hotel/open/pay/create?tenantId&orderId&paySource     [免登录]
+       └─ HotelPayController.createPay()  L56-68
+            └─ TenantContextHolder.executeWithTenant(tenantId, ...)
+                 └─ HotelPayServiceImpl.createPay(orderId, paySource)  L91-182
+                      ├─ resolvePayChannel(paySource)  L821-836   ← 单一收口点
+                      │    └─ mockOrReject(reason)     L846-851   ← 全仓唯一允许返回 "MOCK" 的位置
+                      ├─ "ALIPAY" → alipay.trade.wap.pay → payForm
+                      └─ else     → payChannel=MOCK
+  ├─ payChannel=ALIPAY + payForm → 渲染表单自动提交(H5)/ my.tradePay(MP)
+  ├─ payChannel=MOCK             → POST /hotel/open/pay/mockSuccess  [免登录]
+  └─ else                        → uni.showModal 报错(禁止兜底调 mockSuccess)
+
+POST /hotel/open/pay/notify → 验签 → handlePayCallback → processPayCallback
+refundOrder(orderId) L543-628 → 渠道显式判定 → alipay.trade.refund / 仅回写状态 / 抛异常
+```
+
+### 2.2 现有实现(改造后,均已落地)
+
+**① `HotelPayConfig`(新建,`pay/config/HotelPayConfig.java` L22-49)**
+
+```java
+@Value("${hotel.pay.mock-enabled:false}")
+private boolean mockEnabled;
+
+@PostConstruct
+public void logMockSwitch() { /* true → WARN 资金安全告警;false → INFO */ }
+```
+
+关键:**代码级默认值是 `false`**,不依赖 yml。即使 6 份 yml 的 `hotel` 块整体缺失,也不会误开。
+
+**② `mockOrReject` 单一收口点(`HotelPayServiceImpl` L846-851)**
+
+```java
+private String mockOrReject(String reason) {
+    if (hotelPayConfig.isMockEnabled()) {
+        return "MOCK";
+    }
+    throw new BusinessException(reason);
+}
+```
+
+**③ `resolvePayChannel` 全渠道显式判定(L821-836)**
+
+| 入参 `paySource` | 返回 | 说明 |
+|---|---|---|
+| `ALIPAY` / `ALIPAY_MP` | `"ALIPAY"` | 归为同一 `payChannel` |
+| `WECHAT` / `WECHAT_MP` | `mockOrReject("微信支付暂未开通,请使用支付宝支付或到前台付款")` | 商户号未申请 |
+| `null` / 空串 / 全空格 | `mockOrReject("支付渠道不能为空")` | |
+| `MOCK` / `H5` | `mockOrReject("模拟支付已禁用")` | |
+| 其它任意值 | `mockOrReject("不支持的支付渠道: " + paySource)` | 兜底不再降级 |
+
+**④ `createPay` 渠道解析前置(L117-119 / L138-140)**
+
+- L117-119:在写 `payLog` 之前先 `resolvePayChannel(paySource)`,非白名单渠道**在此即被拒绝**,不会留下 `pay_channel=MOCK` 的脏流水
+- L138-140:分流用**已解析的 `payChannel`**,注释明确「禁止用原始 `paySource` 判断:否则 `ALIPAY_MP` 会漏进 else 分支被静默置为 MOCK」
+
+**⑤ `HotelPayController.createPay` 去 `defaultValue`(L56-59)**
+
+```java
+@RequestParam String paySource   // 改造前:@RequestParam(defaultValue = "MOCK") String paySource
+```
+
+**⑥ `HotelPayController.mockPaySuccess` 首行拦截(L80-86)**
+
+```java
+if (!hotelPayConfig.isMockEnabled()) {
+    log.warn("模拟支付请求被拒绝(hotel.pay.mock-enabled=false): tenantId={}, orderId={}", tenantId, orderId);
+    throw new BusinessException("模拟支付已禁用");
+}
+```
+
+拦截放在**进入租户上下文之前**,被拒请求不会触发任何 DB 操作。
+
+**⑦ `refundOrder` 渠道显式判定(L564-582)**
+
+```java
+HotelPayLog payLog = payLogMapper.selectLatestByOrderId(orderId);
+String paidChannel = payLog != null ? payLog.getPayChannel() : null;
+boolean isMockPaid = HotelOrderConstants.PAY_SOURCE_MOCK.equals(order.getPaySource())
+        || "MOCK".equals(paidChannel);
+if (isMockPaid || payLog == null) {
+    markRefundSuccess(...);   // 无真实资金流,仅回写状态
+    return;
+}
+if (!"ALIPAY".equals(paidChannel)) {
+    log.error("退款渠道未接入,需人工处理: ...");
+    throw new BusinessException("该支付渠道暂不支持在线退款,请联系前台人工处理");
+}
+// → alipay.trade.refund
+```
+
+**⑧ `pay.vue` else 分支拆分(L258-286)**
+
+- L258-276:`else if (createData.payChannel === 'MOCK')` —— **保留**原调试逻辑,注释说明「后端 `hotel.pay.mock-enabled=true` 时才会返回 `payChannel=MOCK`,因此本分支在生产环境永远不会命中,既有 H5 调试行为保持不变」
+- L277-286:新增 `else` —— `uni.showModal('暂无法在线支付')`,注释明确「禁止在此兜底调用 `hotelPayMockSuccess` —— 那等于绕过资金校验免付款」
+
+**⑨ 日志脱敏(顺带收口)**
+
+- `AlipayAuthController` L56 改为 `maskPhone(vo.getPhone())`,L66 新增 `private String maskPhone(String phone)`
+- `AlipayAuthServiceImpl` L70 改为只记 `responseLength`,不再打印授权响应原文
+
+### 2.3 发现与风险(改造过程中新发现,原文档未记录)
+
+| # | 发现 | 处置 |
+|---|---|---|
+| ① | 两份**生产** `application.yml` 完全没有 `hotel` 块,而 `@Value("${hotel.qr.base-url}")` **无默认值** → 生产 profile 启动即崩 | 已补齐 `hotel` 块(属第 2 批范围,但与本批共享同 6 份 yml) |
+| ② | `createPay` 分流原用**原始 `paySource`** 判断 → `ALIPAY_MP` 会漏进 else 被静默置为 MOCK | 已改为用解析后的 `payChannel` |
+| ③ | `resolvePayChannel` 有**两处**静默降级(微信分支 + 兜底分支),原缺口清单只记录了一处 | 两处统一收敛到 `mockOrReject` |
+| ④ | `refundOrder` 用「非 ALIPAY 即 MOCK」兜底 → 未来接微信造成**真实资损** | 已改显式判定 |
+| ⑤ | 🔴 `application-dev.yml` **未被 `.gitignore` 忽略**(`git check-ignore` 退出码 1),其中含支付宝沙箱 `private-key` / `alipay-public-key` **明文** | **不在本变更范围**,另立 `hotel-alipay-secret-externalize` |
+
+### 2.4 语义陷阱(后续维护必读)
+
+`resolvePayChannel("ALIPAY_MP")` 返回的是 `"ALIPAY"`,**不是** `"ALIPAY_MP"`。因此未来补 `alipay.trade.create` 分支时,**分流必须用原始 `paySource`**,不能用 `payChannel`;而本次改造恰恰要求 `createPay` 现有分流用 `payChannel`。二者不矛盾(现有分流只区分「支付宝 vs 其它」),但新增 `trade.create` 时必须重新审视这两处判断依据。
+
+---
+
+## 3. 功能点
+
+- [x] 功能 1:配置开关 —— `hotel.pay.mock-enabled`(代码级默认 `false`)控制模拟支付是否可用;启动时打印开关状态,开启时 WARN 级资金安全告警
+- [x] 功能 2:渠道解析单一收口 —— 所有非支付宝渠道统一走 `mockOrReject(reason)`,开关关闭时抛 `BusinessException`,开启时返回 `"MOCK"`;全仓禁止裸 `return "MOCK"`
+- [x] 功能 3:`mockSuccess` 接口拦截 —— 开关关闭时首行拒绝,不进入租户上下文、不触发 DB 操作
+- [x] 功能 4:`paySource` 必传 —— 移除 `defaultValue = "MOCK"`,缺省直接 400
+- [x] 功能 5:退款渠道显式判定 —— MOCK / 无流水 → 仅回写状态;ALIPAY → 真实退款;其它 → 抛异常要求人工介入
+- [x] 功能 6:前端 else 拆分 —— 保留 `payChannel === 'MOCK'` 分支(dev 零变化),新增 else 显式报错,禁止兜底调 `mockSuccess`
+- [x] 功能 7:日志脱敏 —— 手机号掩码、授权响应只记长度
+
+---
+
+## 4. 业务规则
+
+| 规则 | 内容 |
+|---|---|
+| R1 | 生产环境(`application.yml`)`mock-enabled` 必须为 `false`,通过 `${FORGE_HOTEL_PAY_MOCK_ENABLED:false}` 允许环境变量覆盖但**默认严格** |
+| R2 | 开发环境(`application-dev.yml` / `application-dev.example.yml`)`mock-enabled: true`,保证既有 H5 调试流程零变化 |
+| R3 | 合法 `paySource` 白名单:`ALIPAY`、`ALIPAY_MP`、`WECHAT`、`WECHAT_MP`、`MOCK`、`H5`;其余一律「不支持的支付渠道」 |
+| R4 | `WECHAT` / `WECHAT_MP` 在开关关闭时的报错文案必须给出**可行替代路径**(「请使用支付宝支付或到前台付款」),不能只说「不支持」 |
+| R5 | 退款仅在 `pay_status = 已支付` 时可发起;已退款成功不可重复发起 |
+| R6 | **无支付流水也直接标记退款成功** —— 这是历史数据兼容的妥协(见第 8 节风险 ③) |
+| R7 | 全仓禁止裸 `return "MOCK"`;新增渠道必须显式接入 `resolvePayChannel`,禁止依赖兜底分支 |
+
+---
+
+## 5. 数据变更
+
+| 操作 | 表名 | 字段/索引 | 说明 |
+|---|---|---|---|
+| — | — | — | **无数据库变更**,无 Flyway 脚本 |
+
+> 说明:`hotel_pay_log.pay_channel` 字段沿用现有定义,只是取值范围被收紧(生产环境不再产生 `MOCK` 值)。历史 `pay_channel=MOCK` 的存量数据由 R6 兼容。
+
+---
+
+## 6. 接口变更
+
+| 操作 | 接口 | 方法 | 变更内容 |
+|---|---|---|---|
+| 修改 | `/hotel/open/pay/create` | POST | `paySource` 由 `@RequestParam(defaultValue = "MOCK")` 改为**必传** `@RequestParam`;非法渠道返回业务异常而非静默降级 |
+| 修改 | `/hotel/open/pay/mockSuccess` | POST | 新增开关拦截:`hotel.pay.mock-enabled=false` 时抛 `BusinessException("模拟支付已禁用")` 并打 WARN 日志 |
+| 不变 | `/hotel/open/pay/status` | GET | 无变更 |
+| 不变 | `/hotel/open/pay/notify` | POST | 无变更(验签 / 金额 / app_id / 幂等四层校验沿用) |
+| 不变 | `/hotel/open/pay/alipayQuery` | GET | 无变更 |
+| 修改(内部) | `HotelPayService.refundOrder` | — | 渠道判定由「非 ALIPAY 即 MOCK」改为显式三分支 |
+| 修改(内部) | `AlipayAuthController.getUserInfo` | — | 日志手机号脱敏,**响应体不变** |
+
+**接口路径与出入参结构均未变更**,仅约束收紧。前端 `api/index.js` 无需改动。
+
+---
+
+## 7. 影响范围
+
+### 7.1 后端
+
+| 文件 | 类型 |
+|---|---|
+| `forge-hotel/.../pay/config/HotelPayConfig.java` | **新建** |
+| `forge-hotel/.../pay/service/impl/HotelPayServiceImpl.java` | 修改(注入 + `createPay` + `resolvePayChannel` + `mockOrReject` + `refundOrder`) |
+| `forge-hotel/.../controller/open/HotelPayController.java` | 修改(`createPay` 去默认值 + `mockSuccess` 拦截 + 注入) |
+| `forge-hotel/.../controller/open/AlipayAuthController.java` | 修改(`maskPhone`) |
+| `forge-hotel/.../pay/service/impl/AlipayAuthServiceImpl.java` | 修改(日志脱敏) |
+| `forge-admin-server/src/main/resources/application.yml` | 修改(新增 `hotel.pay.mock-enabled`) |
+| `forge-admin-server/src/main/resources/application-dev.yml` | 修改(同上,值 `true`) |
+| `forge-admin-server/src/main/resources/application-dev.example.yml` | 修改(同上,值 `true`) |
+| `forge-app-server/src/main/resources/application.yml` | 修改(同上) |
+| `forge-app-server/src/main/resources/application-dev.yml` | 修改(同上,值 `true`) |
+| `forge-app-server/src/main/resources/application-dev.example.yml` | 修改(同上,值 `true`) |
+
+### 7.2 前端
+
+| 文件 | 类型 |
+|---|---|
+| `forge-h5-ui/src/pages/hotel/customer/pay.vue` | 修改(`handlePay` else 分支拆分) |
+
+### 7.3 ⚠️ 6 份 yml 被两个批次共享
+
+`application.yml` / `application-dev.yml` / `application-dev.example.yml`(admin-server + app-server 各 3 份)同时承载:
+
+- **本批(第 0 批)**:`hotel.pay.mock-enabled`
+- **第 2 批(二维码 URL 配置化)**:`hotel.qr.base-url`、`hotel.qr.path`、`hotel.alipay.*`
+
+两批改动落在**同一 `hotel:` 块**内。回滚本批时**只可删除 `pay.mock-enabled` 一行**,禁止整块回退,否则会连带破坏第 2 批并导致生产 profile 启动崩溃(见 2.3 发现 ①)。
+
+### 7.4 不受影响
+
+- PC 管理端(`forge-admin-ui`):零改动
+- 支付回调链路(验签 / 金额 / app_id / 幂等 / 竞态防护 / 退款对账):零改动
+- H5 dev 环境行为:零改动(开关为 `true`,全部分支走向与改造前一致)
+
+---
+
+## 8. 风险与关注点
+
+> ⚠️ **本变更属资金类变更**(AGENTS.md 5.9 / `code-copilot/rules/security.md` 第 2 节),以下每一项都需人工审查确认。
+
+### 8.1 🔴 需人工审查的三个点(审查时逐条给结论)
+
+**① `mockOrReject` 单一收口是否覆盖全部渠道 —— 漏一个分支 = 免付款**
+
+当前 `resolvePayChannel`(L821-836)覆盖:`ALIPAY` / `ALIPAY_MP` / `WECHAT` / `WECHAT_MP` / `null` / 空串 / 全空格 / `MOCK` / `H5` / 其它任意值(兜底)。
+
+审查要点:兜底分支 `return mockOrReject("不支持的支付渠道: " + paySource)` 是**最后一道网**,请确认它确实无法被绕过(例如 `paySource` 传入超长字符串、含控制字符、大小写变体 `alipay`)。
+
+> 注:`"alipay"`(小写)**会落到兜底分支被拒**,因为判定用的是 `"ALIPAY".equals(paySource)` 严格相等。这是**期望行为**(前端只会传大写常量),但需确认前端不存在传小写的路径。
+
+**② `refundOrder` 三种「无资金流」判定边界(L564-576)**
+
+```
+paySource = MOCK           → 仅回写状态
+流水 pay_channel = MOCK     → 仅回写状态
+无流水(payLog == null)    → 仅回写状态   ← 🔴 风险点
+```
+
+审查要点:**「无流水也直接标记退款成功」是历史数据兼容的妥协**。理论上存在「用户真实支付了、但 `hotel_pay_log` 流水丢失/被删」的场景,此时系统会标记退款成功而**实际未退钱给用户**,属客诉与合规风险。
+
+可选加固方向(本次未做,需你决策是否追加):
+- 无流水时不直接成功,改为抛异常要求人工核查
+- 或先调 `alipay.trade.query` 用 `order_no` 反查支付宝侧是否存在交易
+
+**③ `pay.vue` else 采「拆分」而非「删除」(L258-286)**
+
+保留了 `payChannel === 'MOCK'` 分支,即代码里**仍存在调用 `hotelPayMockSuccess` 的路径**。生产环境后端不会返回 `payChannel=MOCK`,故不会命中;但:
+
+- 若未来后端出现 bug 让生产返回了 `MOCK`,前端会照旧免付款走通
+- 审查要点:是否接受「依赖后端开关作为唯一防线」,还是要求前端也用 `import.meta.env` 做二次隔离
+
+> 本次选择「拆分」是为满足用户硬约束「不可影响 H5 开发测试」—— 删除该分支会让 dev 环境的 Mock 调试流程失效。
+
+### 8.2 其它风险
+
+| # | 风险 | 缓解 |
+|---|---|---|
+| ④ | 环境变量名写错静默失效:正确名是 `FORGE_HOTEL_PAY_MOCK_ENABLED`,写成 `FORGE_HOTEL_PAY_MOCK` **不报错**,只静默退回 `false` | 生产默认 `false` 是安全侧,误配只会导致「Mock 不可用」而非「Mock 被开」;但 dev 环境误配会让调试失效,已在 6 份 yml 注释中标注 |
+| ⑤ | 开关是**进程级**而非租户级:一旦生产误开,全租户同时暴露 | 启动时 WARN 级「【资金安全告警】」日志,便于监控告警接入 |
+| ⑥ | `mockSuccess` 仍在 Sa-Token 白名单内,开关是唯一防线 | 生产 `false` + 代码级默认 `false` 双保险;彻底移除该接口会破坏 dev 调试,故保留 |
+| ⑦ | 本批与第 2 批共享 6 份 yml,回滚易误伤 | 见 7.3,回滚只删 `pay.mock-enabled` 一行 |
+| ⑧ | 🔴 `application-dev.yml` 明文沙箱密钥已入库 | **不在本变更范围**,见 `hotel-alipay-secret-externalize` |
+
+### 8.3 状态流转影响
+
+`refundOrder` 改造后,**非 ALIPAY 非 MOCK 渠道的退款请求会抛异常**(改造前静默标记成功)。这会改变前台「手动退款」按钮的行为:未来接入微信后,微信订单点退款会看到「请联系前台人工处理」而非「退款成功」。这是**期望行为**(防止资损),但需在接入微信时同步实现在线退款。
+
+---
+
+## 8.5 测试策略
+
+- **测试范围**:
+  - `resolvePayChannel` 全渠道分支(6 类入参 × 开关 2 态 = 12 组)
+  - `mockOrReject` 开关两态
+  - `mockPaySuccess` 开关关闭时拦截
+  - `createPay` 缺省 `paySource` / 非法 `paySource` / `ALIPAY` / `ALIPAY_MP`
+  - `refundOrder` 三分支(MOCK / ALIPAY / 其它)+ 无流水
+- **覆盖率目标**:资金分支**行覆盖 100%**(`resolvePayChannel`、`mockOrReject`、`refundOrder` 渠道判定段)
+- **独立 Test Spec**:**否**
+  - 理由:本次是**安全收口**而非新功能,验证口径已在 `酒店订单支付系统开发文档.md` **10.1** 记录(后端全 reactor `mvn compile` BUILD SUCCESS、H5 `pnpm build:h5` Build complete、`GetProblems` 12 个文件 No errors、6 份 yml 逐行核对一致、dev 行为零变化)
+  - ⚠️ **未做单元测试**:`forge-hotel` 模块当前无测试基础设施(无 `src/test`),本次未新建。若审查要求补测,需先解决模块测试脚手架问题,属独立工作量
+
+---
+
+## 9. 待澄清
+
+- [ ] **Q1(对应 8.1 ②)**:`refundOrder` 遇到「已支付但无流水」时,是维持「直接标记退款成功」,还是改为抛异常要求人工核查 / 调 `alipay.trade.query` 反查?
+- [ ] **Q2(对应 8.1 ③)**:`pay.vue` 的 `payChannel === 'MOCK'` 分支是否需要再加一层前端环境隔离(`import.meta.env.DEV`),还是接受「后端开关为唯一防线」?
+- [ ] **Q3(对应 8.1 ①)**:确认前端不存在传小写 / 变体 `paySource` 的路径(当前后端严格大写相等匹配)。
+
+> 以上三项**全部解决并签署第 13 节后**,本变更方可从 `review` 进入 `done`。
+
+---
+
+## 10. 技术决策
+
+| # | 决策 | 理由 | 被否方案 |
+|---|---|---|---|
+| D1 | 用**配置开关**而非删除 Mock 能力 | 用户硬约束「不可影响 H5 开发测试」;删除会让 dev 调试流程失效 | 直接删除 `mockSuccess` 接口 |
+| D2 | 开关默认值写在**代码里**(`@Value("${...:false}")`)而非只靠 yml | yml 整块缺失也不会误开;6 份 yml 分散在两个服务,任一遗漏都安全 | 只靠 yml 配置 |
+| D3 | 抽 `mockOrReject` **单一收口点** | 改造前有两处静默降级,正是漏洞成因;收口后全仓只有一个位置能返回 `"MOCK"`,审查面收敛到 6 行 | 在每个分支各写一遍 `if (mockEnabled)` |
+| D4 | `ALIPAY` 与 `ALIPAY_MP` 归为同一 `payChannel` | 两者都是支付宝,风控与对账口径一致;具体协议差异(`wap.pay` vs `trade.create`)留给第 3 批 | 保留为两个独立 `payChannel` |
+| D5 | 退款**禁用**「非 ALIPAY 即 MOCK」兜底 | 未来接微信后会误判无资金流 → **真实资损**;宁可抛异常要求人工介入 | 维持原兜底 |
+| D6 | 生产 yml 用 `${FORGE_HOTEL_PAY_MOCK_ENABLED:false}` 而非硬编码 `false` | 保留应急开关能力,默认侧安全 | 硬编码 `false` |
+| D7 | 日志脱敏(`maskPhone` / `responseLength`)纳入本批 | 命中 AGENTS.md 5.9「禁止在日志中打印手机号」,与资金安全同属安全红线,一并收口成本最低 | 另立变更 |
+
+### 10.1 与原方案的三处差异(补写时如实登记)
+
+原缺口清单第 0 批的方案描述与实际落地存在三处差异:
+
+| # | 原方案 | 实际落地 | 原因 |
+|---|---|---|---|
+| ① | 在 `createPay` 入口显式拒绝 `MOCK` | 下沉为 `resolvePayChannel` + `mockOrReject` 统一收口 | 入口拒绝只能挡住 `paySource=MOCK`,挡不住微信分支与兜底分支的静默降级;下沉后覆盖面完整 |
+| ② | `pay.vue` 的 else MOCK 分支**删除** | 改为**拆分**:保留 `payChannel === 'MOCK'` 分支 + 新增 else 报错 | 满足「不可影响 H5 开发测试」硬约束 |
+| ③ | 引入 `wechatPayProperties` 配置类占位 | **未引入** | 微信支付已决策暂缓,空配置类属无效代码;`resolvePayChannel` 的微信分支已足够阻断 |
+
+---
+
+## 11. 执行日志
+
+> ⚠️ 本表为**回填实际改动文件**(代码先于 Spec 落地),非计划文件清单。文件清单已用 `git status --porcelain` + `git diff` 核实。
+
+| Task | 状态 | 实际改动文件 | 备注 |
+|---|---|---|---|
+| Task 1 配置开关 | ✅ 已执行 | `pay/config/HotelPayConfig.java`(**新建**,L22-49) | 代码级默认 `false` + `@PostConstruct` 告警 |
+| Task 2 单一收口点 | ✅ 已执行 | `pay/service/impl/HotelPayServiceImpl.java`(L821-836 `resolvePayChannel`、L846-851 `mockOrReject`、L60 注入) | 全仓唯一返回 `"MOCK"` 的位置 |
+| Task 3 渠道解析前置 | ✅ 已执行 | 同上(L117-119 前置、L138-140 分流改用 `payChannel`) | 修复 `ALIPAY_MP` 漏进 MOCK |
+| Task 4 接口约束收紧 | ✅ 已执行 | `controller/open/HotelPayController.java`(L56-59 去 `defaultValue`、L80-86 首行拦截、L44-45 注入) | 拦截在租户上下文之前 |
+| Task 5 退款显式判定 | ✅ 已执行 | `HotelPayServiceImpl.java`(L564-582) | 三分支:MOCK/无流水 → 回写;ALIPAY → 真实退款;其它 → 抛异常 |
+| Task 6 前端 else 拆分 | ✅ 已执行 | `forge-h5-ui/src/pages/hotel/customer/pay.vue`(L258-286) | 保留 MOCK 分支 + 新增 else `showModal` |
+| Task 7 日志脱敏 | ✅ 已执行 | `controller/open/AlipayAuthController.java`(L56 调用、L66 `maskPhone`)、`pay/service/impl/AlipayAuthServiceImpl.java`(L70 只记 `responseLength`) | 响应体不变 |
+| Task 8 yml 配置落地 | ✅ 已执行 | admin-server + app-server 各 3 份:`application.yml`(L239 / L121,`${FORGE_HOTEL_PAY_MOCK_ENABLED:false}`)、`application-dev.yml`(L104 / L83,`true`)、`application-dev.example.yml`(L102 / L83,`true`) | ⚠️ 与第 2 批共享同一 `hotel:` 块,见 7.3 |
+| 验证 | ✅ 已通过 | — | 后端全 reactor `mvn compile` BUILD SUCCESS;H5 `pnpm build:h5` Build complete;`GetProblems` 12 个文件 No errors;6 份 yml 逐行核对一致;**dev 行为零变化** |
+| 单元测试 | ❌ 未执行 | — | `forge-hotel` 无测试基础设施,见 8.5 |
+| 人工审查 | 🔴 **待执行** | — | 见第 12 节 |
+
+---
+
+## 12. 审查结论
+
+🔴 **尚未审查。** 本节须由审查人填写,AI 不得代填。
+
+审查时请对以下清单逐条给出「通过 / 需修改」结论:
+
+- [ ] 8.1 ① `mockOrReject` 覆盖面是否完整(漏一个分支 = 免付款)
+- [ ] 8.1 ② `refundOrder`「无流水直接标记退款成功」是否可接受(Q1)
+- [ ] 8.1 ③ `pay.vue` 保留 MOCK 分支是否可接受(Q2)
+- [ ] 第 4 节 R1~R7 业务规则是否与业务预期一致
+- [ ] 第 6 节接口约束收紧是否影响已上线的调用方
+- [ ] 7.3 6 份 yml 共享块的回滚边界是否清晰
+- [ ] 8.5 未补单元测试是否可接受
+- [ ] 10.1 三处方案差异是否认可
+
+**审查结论**:_(待填)_
+
+**审查人**:_(待填)_
+
+**审查日期**:_(待填)_
+
+---
+
+## 13. 确认记录(HARD-GATE)
+
+> ⚠️ 本节是 `AGENTS.md` 5.9 与 `code-copilot/rules/security.md` 第 2 节要求的资金类变更**强制门禁**。
+> **只能由需求方本人手填,AI 助手不得代签、不得填入推测值。**
+> 未签署前,本变更状态停留在 `review`,不得进入 `done`,不得归档(`/archive`)。
+
+- **确认时间**:2026-09-07
+- **确认人**:徐滕
+
+**确认范围声明**(签署即表示已阅读并认可以下内容):徐滕
+
+1. 第 8.1 节三个资金风险点已逐条审查
+2. 第 9 节 Q1~Q3 三项待澄清已有明确结论
+3. 第 10.1 节与原方案的三处差异已认可
+4. 第 12 节审查结论已填写
+5. 知悉本变更为**代码先行、Spec 补写**的流程倒置,并同意以此方式追认

+ 263 - 0
code-copilot/changes/hotel-pay-mock-hardening/tasks.md

@@ -0,0 +1,263 @@
+# 任务拆分 — 酒店支付 Mock 免付款资金红线收口
+
+> 拆分顺序:数据模型 → 接口协议 → 底层实现 → 上层编排 → 入口层
+> 每个任务 = 可独立提交的原子变更(3-5 个文件)
+> 每个任务必须精确到文件路径和函数签名
+
+---
+
+## ⚠️ 状态说明
+
+**本文件为补写文档。全部 Task 已在 Spec 编写之前执行完毕**,勾选状态反映的是**已发生的事实**,不是待办计划。
+资金类变更的人工审查门禁见 `spec.md` 第 12 / 13 节,**尚未签署**。
+
+与原方案的三处差异见 `spec.md` **10.1**,本文在各 Task 下就地标注。
+
+---
+
+## 前置条件
+
+- [x] 确认 `mockSuccess` 落在 Sa-Token 白名单 `/hotel/open/**` 内(`SaTokenConfig` L79 `.notMatch("/hotel/open/**")`、L104 `.excludePathPatterns("/hotel/open/**")`),**不能靠加鉴权解决** —— 顾客端支付本身必须免登录
+- [x] 确认用户硬约束:「调整**不可影响我开发时候的 h5 的测试**」→ 只能走 `spec.md` 3.6.7 的三类手法(条件编译 / 配置开关 / 纯增量),**禁止直接替换现有实现**
+- [x] 确认微信支付已决策暂缓上架(商户号未申请),但后端三端共用,`WECHAT` 渠道仍须**显式阻断**而非静默降级
+- [x] 确认无数据库结构变更 → 不需 Flyway 脚本
+- [x] 确认 6 份 yml 与第 2 批(二维码 URL 配置化)共享同一 `hotel:` 块 → 回滚只可删 `pay.mock-enabled` 一行
+
+---
+
+## Task 1: 新增支付安全开关配置类
+
+- **目标**: 用**代码级默认 `false`** 的配置开关控制模拟支付,yml 整块缺失也不会误开
+- **涉及文件**:
+    - `forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/config/HotelPayConfig.java` — **新建**(L22-49),`@Configuration` + `@Getter` + `@Slf4j`,读 `hotel.pay.mock-enabled`,`@PostConstruct` 打印开关状态(开启时 WARN 级资金安全告警)
+- **关键签名**:
+  ```java
+  @Slf4j
+  @Getter
+  @Configuration
+  public class HotelPayConfig {
+
+      /** 默认 false:生产环境模拟支付一律拒绝,未知/未接入渠道显式报错,不再静默降级为 MOCK。 */
+      @Value("${hotel.pay.mock-enabled:false}")
+      private boolean mockEnabled;
+
+      @PostConstruct
+      public void logMockSwitch() { }
+  }
+  ```
+- **决策依据**: `spec.md` D2 —— 默认值写在代码里而非只靠 yml,6 份 yml 分散在两个服务,任一遗漏都安全
+
+---
+
+## Task 2: 抽出渠道解析单一收口点
+
+- **目标**: 全仓只保留**一个**能返回 `"MOCK"` 的位置,把审查面收敛到 6 行
+- **涉及文件**:
+    - `.../pay/service/impl/HotelPayServiceImpl.java` — 修改,L60 注入 `HotelPayConfig`;L821-836 重写 `resolvePayChannel` 为全渠道显式判定;L846-851 新增 `mockOrReject`
+- **关键签名**:
+  ```java
+  /** 渠道解析:只有 ALIPAY / ALIPAY_MP 返回 "ALIPAY",其余全部经 mockOrReject 判定 */
+  private String resolvePayChannel(String paySource) { }
+
+  /** 全仓唯一允许返回 "MOCK" 的位置:开关开启返回 MOCK,否则抛业务异常 */
+  private String mockOrReject(String reason) {
+      if (hotelPayConfig.isMockEnabled()) {
+          return "MOCK";
+      }
+      throw new BusinessException(reason);
+  }
+  ```
+- **渠道判定表**(漏一个分支 = 免付款,审查重点见 `spec.md` 8.1 ①):
+
+  | 入参 | 返回 |
+  |---|---|
+  | `ALIPAY` / `ALIPAY_MP` | `"ALIPAY"` |
+  | `WECHAT` / `WECHAT_MP` | `mockOrReject("微信支付暂未开通,请使用支付宝支付或到前台付款")` |
+  | `null` / 空串 / 全空格 | `mockOrReject("支付渠道不能为空")` |
+  | `MOCK` / `H5` | `mockOrReject("模拟支付已禁用")` |
+  | 其它任意值 | `mockOrReject("不支持的支付渠道: " + paySource)` |
+
+- 🔴 **与原方案差异 ①**(见 `spec.md` 10.1):原方案是「在 `createPay` 入口显式拒绝 `MOCK`」,实际下沉为统一收口。原因:入口拒绝只能挡住 `paySource=MOCK`,**挡不住微信分支与兜底分支的两处静默降级**(改造前实测有两处,原缺口清单只记录了一处)
+
+---
+
+## Task 3: createPay 渠道解析前置 + 分流依据修正
+
+- **目标**: 非白名单渠道在写流水**之前**即被拒绝,且分流不再误判 `ALIPAY_MP`
+- **涉及文件**:
+    - `.../pay/service/impl/HotelPayServiceImpl.java` — 修改,`createPay` L117-119 渠道解析前置到 `payLogMapper.insert` 之前;L138-140 分流判断由原始 `paySource` 改为已解析的 `payChannel`
+- **关键签名**:
+  ```java
+  @Override
+  @Transactional(rollbackFor = Exception.class)
+  public Map<String, Object> createPay(Long orderId, String paySource) {
+      // ...
+      // 渠道解析前置:非白名单渠道在此即被拒绝,杜绝静默降级为 MOCK 造成免付款
+      String payChannel = resolvePayChannel(paySource);
+      payLog.setPayChannel(payChannel);
+      // ...
+      // 根据已解析的 payChannel 调用支付渠道。
+      // 禁止用原始 paySource 判断:否则 ALIPAY_MP 会漏进 else 分支被静默置为 MOCK。
+      if ("ALIPAY".equals(payChannel)) { /* alipay.trade.wap.pay */ }
+      else { payParams.put("payChannel", "MOCK"); }
+  }
+  ```
+- **修复的隐患**: `resolvePayChannel("ALIPAY_MP")` 返回 `"ALIPAY"`,若继续用原始 `paySource` 分流,`ALIPAY_MP` 会落进 else 被置为 `MOCK` → 免付款
+- ⚠️ **语义陷阱**(后续维护必读,见 `spec.md` 2.4):未来补 `alipay.trade.create` 分支时,**分流必须用原始 `paySource`**,不能用 `payChannel`
+
+---
+
+## Task 4: 接口层约束收紧
+
+- **目标**: `paySource` 必传 + `mockSuccess` 开关关闭时首行拒绝
+- **涉及文件**:
+    - `.../controller/open/HotelPayController.java` — 修改,L44-45 注入 `HotelPayConfig`;L56-59 `createPay` 去掉 `defaultValue = "MOCK"`;L80-86 `mockPaySuccess` 首行拦截
+- **关键签名**:
+  ```java
+  @PostMapping("/create")
+  public RespInfo<Map<String, Object>> createPay(@RequestParam Long tenantId,
+                                                 @RequestParam Long orderId,
+                                                 @RequestParam String paySource) { }   // ← 去 defaultValue
+
+  @PostMapping("/mockSuccess")
+  public RespInfo<PayResultVO> mockPaySuccess(@RequestParam Long tenantId,
+                                              @RequestParam Long orderId) {
+      if (!hotelPayConfig.isMockEnabled()) {
+          log.warn("模拟支付请求被拒绝(hotel.pay.mock-enabled=false): tenantId={}, orderId={}", tenantId, orderId);
+          throw new BusinessException("模拟支付已禁用");
+      }
+      // ... 进入租户上下文
+  }
+  ```
+- **要点**: 拦截放在**进入 `TenantContextHolder.executeWithTenant` 之前**,被拒请求不触发任何 DB 操作
+- **接口路径与出入参结构均未变更**,前端 `api/index.js` 无需改动
+
+---
+
+## Task 5: 退款渠道显式判定(防未来资损)
+
+- **目标**: 禁用「非 ALIPAY 即 MOCK」兜底,改为显式三分支
+- **涉及文件**:
+    - `.../pay/service/impl/HotelPayServiceImpl.java` — 修改,`refundOrder` L564-582
+- **关键签名**:
+  ```java
+  @Override
+  public void refundOrder(Long orderId) {
+      // ... 前置校验:仅已支付可退、不可重复退、金额合法
+      HotelPayLog payLog = payLogMapper.selectLatestByOrderId(orderId);
+      String paidChannel = payLog != null ? payLog.getPayChannel() : null;
+      boolean isMockPaid = HotelOrderConstants.PAY_SOURCE_MOCK.equals(order.getPaySource())
+              || "MOCK".equals(paidChannel);
+      if (isMockPaid || payLog == null) {
+          markRefundSuccess(order, payLog, refundAmount, "MOCK_REFUND_" + orderId, new Date());
+          return;                                    // 无真实资金流,仅回写状态
+      }
+      if (!"ALIPAY".equals(paidChannel)) {
+          log.error("退款渠道未接入,需人工处理: orderId={}, orderNo={}, payChannel={}", ...);
+          throw new BusinessException("该支付渠道暂不支持在线退款,请联系前台人工处理");
+      }
+      // → alipay.trade.refund + reconcileRefund 对账
+  }
+  ```
+- 🔴 **决策依据**: `spec.md` D5 —— 改造前用 `!"ALIPAY".equals(payChannel)` 兜底,未来接入微信后**微信订单退款会被误判为无资金流而直接标记成功,造成真实资损**
+- 🔴 **遗留风险(需人工审查,`spec.md` 8.1 ② / Q1)**:`payLog == null` 也直接标记退款成功,属历史数据兼容的妥协,存在「真实支付但流水丢失」被误判的风险
+
+---
+
+## Task 6: 前端 else 分支拆分
+
+- **目标**: 禁止前端兜底调用 `mockSuccess`,同时保证 dev 调试行为零变化
+- **涉及文件**:
+    - `forge-h5-ui/src/pages/hotel/customer/pay.vue` — 修改,`handlePay()` L258-286
+- **关键签名**:
+  ```js
+  // 根据后端返回的 payChannel 拉起支付(不用 paySource 判断,ALIPAY_MP 同样走支付宝分支)
+  if (createData.payChannel === 'ALIPAY' && createData.payForm) {
+    // #ifdef H5   → 渲染 payForm 自动提交
+    // #ifdef MP-ALIPAY → my.tradePay({ tradeNO: createData.tradeNo })
+  } else if (createData.payChannel === 'ALIPAY' && createData.tradeNo) {
+    // 支付宝小程序:有 tradeNo 无 payForm
+  } else if (createData.payChannel === 'MOCK') {          // ← 保留(拆分而非删除)
+    var mockRes = await api.hotelPayMockSuccess(orderId.value, tenantId)
+    // ...
+  } else {                                                 // ← 新增
+    // 禁止在此兜底调用 hotelPayMockSuccess —— 那等于绕过资金校验免付款
+    paying.value = false
+    uni.showModal({ title: '暂无法在线支付', content: '当前支付方式暂不可用,请使用支付宝完成支付,或联系前台办理付款。', showCancel: false })
+  }
+  ```
+- 🔴 **与原方案差异 ②**(见 `spec.md` 10.1):原方案是**删除** else MOCK 分支,实际改为**拆分**。原因:满足「不可影响 H5 开发测试」硬约束 —— 删除会让 dev 环境的 Mock 调试流程失效
+- 🔴 **遗留风险(需人工审查,`spec.md` 8.1 ③ / Q2)**:代码里**仍存在调用 `hotelPayMockSuccess` 的路径**,生产环境依赖后端开关作为唯一防线
+
+---
+
+## Task 7: 日志脱敏
+
+- **目标**: 收口 `AGENTS.md` 5.9「禁止在日志中打印手机号」
+- **涉及文件**:
+    - `.../controller/open/AlipayAuthController.java` — 修改,L56 日志改调 `maskPhone(vo.getPhone())`,L66 新增 `private String maskPhone(String phone)`
+    - `.../pay/service/impl/AlipayAuthServiceImpl.java` — 修改,L70 由打印授权响应原文改为只记 `responseLength`
+- **关键签名**:
+  ```java
+  log.info("支付宝用户授权完成: userName={}, phone={}", vo.getUserName(), maskPhone(vo.getPhone()));
+
+  private String maskPhone(String phone) { }   // 保留前3后4,中间 ****
+
+  log.warn("支付宝手机号获取暂未实现,responseLength={}", response != null ? response.length() : 0);
+  ```
+- **决策依据**: `spec.md` D7 —— 与资金安全同属安全红线,一并收口成本最低;**响应体不变**,仅日志变更
+
+---
+
+## Task 8: 6 份 yml 配置落地
+
+- **目标**: 生产默认严格、dev 覆盖宽松,两服务同步
+- **涉及文件**(admin-server + app-server 各 3 份):
+    - `forge-server/forge-admin-server/src/main/resources/application.yml` — 修改,L239
+    - `forge-server/forge-admin-server/src/main/resources/application-dev.yml` — 修改,L104
+    - `forge-server/forge-admin-server/src/main/resources/application-dev.example.yml` — 修改,L102
+    - `forge-server/forge-app-server/src/main/resources/application.yml` — 修改,L121
+    - `forge-server/forge-app-server/src/main/resources/application-dev.yml` — 修改,L83
+    - `forge-server/forge-app-server/src/main/resources/application-dev.example.yml` — 修改,L83
+- **配置内容**:
+  ```yaml
+  # 生产 application.yml(两份服务一致)
+  hotel:
+    pay:
+      mock-enabled: ${FORGE_HOTEL_PAY_MOCK_ENABLED:false}
+
+  # application-dev.yml / application-dev.example.yml(两份服务一致)
+  hotel:
+    pay:
+      mock-enabled: true
+  ```
+- ⚠️ **环境变量名易错点**:正确名是 `FORGE_HOTEL_PAY_MOCK_ENABLED`,写成 `FORGE_HOTEL_PAY_MOCK` **不报错**,只静默退回 `false`
+- ⚠️ **共享块警告**(见 `spec.md` 7.3):这 6 份 yml 的同一 `hotel:` 块同时承载第 2 批的 `hotel.qr.*` 与 `hotel.alipay.*`。**回滚本批只可删 `pay.mock-enabled` 一行**,禁止整块回退 —— 否则连带破坏第 2 批,且两份生产 `application.yml` 会因 `@Value("${hotel.qr.base-url}")` 无默认值而**启动即崩**
+
+---
+
+## 未纳入本变更的事项
+
+| # | 事项 | 原因 | 归属 |
+|---|---|---|---|
+| ① | 🔴 **未引入** `wechatPayProperties` 配置类占位 | 微信支付已决策暂缓,空配置类属无效代码;`resolvePayChannel` 的微信分支已足够阻断 | 🔴 **与原方案差异 ③**,见 `spec.md` 10.1 |
+| ② | 不实现 `ALIPAY_MP` 渠道(`alipay.trade.create`) | 属第 3 批 | `hotel-alipay-mp-adaptation`(待建) |
+| ③ | 不做二维码 URL 配置化 | 属第 2 批,**不涉资金,不单独建 Spec** | 已落地,记录在 `酒店二维码模块开发文档.md` |
+| ④ | 不处理 `application-dev.yml` 明文沙箱密钥 | **性质不同**(一个是资金逻辑,一个是密钥泄露),不可混入 | `hotel-alipay-secret-externalize` |
+| ⑤ | 不补单元测试 | `forge-hotel` 无测试基础设施(无 `src/test`) | 见 `spec.md` 8.5,需审查确认是否可接受 |
+
+---
+
+## 验证记录(已执行)
+
+| 项 | 结果 |
+|---|---|
+| 后端全 reactor `mvn compile` | ✅ BUILD SUCCESS |
+| H5 `pnpm build:h5` | ✅ Build complete |
+| `GetProblems` 12 个改动文件 | ✅ No errors |
+| 6 份 yml 逐行核对一致 | ✅ 一致 |
+| **dev 环境行为** | ✅ **零变化**(开关 `true`,全部分支走向与改造前一致) |
+| 单元测试 | ❌ 未执行(见上表 ⑤) |
+| 人工审查 | 🔴 **待执行**(`spec.md` 第 12 / 13 节) |
+
+> 环境事实:`mvn` 不在 PATH,实际位于 `D:\java_install\apache-maven-3.9.16\bin\mvn.cmd`;仓库根 `forge/` 是**空目录**,真实 Maven 根为 `forge-server/`(`AGENTS.md` 2.2 仍写 `cd forge && mvn clean install`,与实际不符)。

+ 38 - 0
forge-h5-ui/.env.mp-alipay

@@ -0,0 +1,38 @@
+# 支付宝小程序环境配置
+#
+# 加载方式:仅当 `pnpm dev:mp-alipay` / `pnpm build:mp-alipay` 显式带 `--mode mp-alipay` 时生效。
+# ⚠ uni-app / Vite 只按 mode 加载 .env.[mode],不会按平台自动加载 .env.[platform],
+#   因此 package.json 里两个 mp-alipay 脚本必须保留 `--mode mp-alipay`,否则本文件是死配置,
+#   构建会退回 .env.development / .env.production 的相对路径而请求全部失败。
+#
+# 本文件不含任何密钥,仅域名占位;真实域名由部署时替换。
+
+# 静态资源目录 / 路由前缀:小程序没有 Web 部署路径概念,保持根路径
+VITE_PUBLIC_PATH=/
+VITE_BASE_URL=/
+
+# map 文件
+VITE_DEVTOOL=none
+
+# 请求地址前缀(axios baseURL)
+# 小程序没有 Vite devServer 代理,也不能用 /dev-api 这类相对路径,必须写「绝对 HTTPS 域名」。
+# 该域名需在支付宝小程序后台「开发设置 → 服务器域名白名单(httpRequest)」中登记,
+# 否则真机与 IDE 正式模式下请求会被平台直接拦截。
+# TODO 上线前替换为真实网关域名(当前为占位值,构建可通过但请求不可用)。
+VITE_REQUEST_PREFIX=https://forge-h5.example.com/prod-api
+
+# 小程序无代理,留空
+VITE_HTTP_PROXY_TARGET=
+VITE_FLOW_PROXY_TARGET=
+
+# 图片 host 地址:小程序 <image> 不支持相对路径,需与接口同源绝对域名
+VITE_RESOURCE_HOST=https://forge-h5.example.com
+
+# 模板文件下载地址(小程序无浏览器下载能力,保留仅为兼容读取该变量的代码路径)
+VITE_TEMPLATE_PATH=/templates
+
+# 客户端配置:暂复用 H5 的 client / appId,避免后端 sys_client 同步改造。
+# 若后端为小程序单独签发 client(用于区分登录来源、下发不同权限),
+# 需先在 sys_client 新增记录,再改这两项。
+VITE_USER_CLIENT=h5
+VITE_APP_ID=forge_h5

+ 0 - 19
forge-h5-ui/AGENTS.md

@@ -1,19 +0,0 @@
-# Repository Guidelines
-
-## Project Structure & Module Organization
-This repository is a `uni-app` + Vue 3 application managed with `pnpm`. Main code lives in `src/`: `pages/` for route pages, `components/` for shared UI, `store/` for Pinia state, `api/` and `utils/http/` for requests, `composables/` for reusable logic, `directives/` for custom directives, and `styles/` plus `uni.scss` for global styling. Static assets live under `src/static/`. Build-time plugins and icon helpers are in `build/`, while generated output goes to `dist/`.
-
-## Build, Test, and Development Commands
-Install dependencies with `pnpm install`. Use `pnpm dev:h5` for the default H5 dev server, or a platform-specific target such as `pnpm dev:mp-weixin`. Create production bundles with `pnpm build:h5` or `pnpm build:mp-weixin`. Environment values are split across `.env`, `.env.development`, `.env.production`, and `.env.test`; verify proxy settings before local API work.
-
-## Coding Style & Naming Conventions
-Follow the existing Vue SFC structure: `<template>`, `<script>`, then `<style>`. Prefer the current JavaScript style used across `src/`: single quotes, semicolons only when helpful, and two-space indentation in Vue/JS files unless the surrounding file clearly differs. Use PascalCase for reusable components such as `AiCard.vue`, camelCase for composables and utilities such as `usePaging.js`, and keep store modules under `src/store/modules/`. Use the `@` alias for `src` imports.
-
-## Testing Guidelines
-There is no committed unit or E2E test runner configured in `package.json` yet. Until one is added, treat a successful target build as the minimum validation step: run the relevant `pnpm build:*` command for the platform you changed. If you add tests later, place them beside the feature or under a dedicated `tests/` directory and use `*.spec.js` or `*.test.js`.
-
-## Commit & Pull Request Guidelines
-Local Git history is not available in this checkout, so commit conventions cannot be derived here. Use short, imperative commit messages such as `feat: add paging composable guard` or `fix: correct H5 proxy path`. PRs should describe the affected platform (`h5`, `mp-weixin`, etc.), summarize config or env changes, link the related issue, and include screenshots for UI changes.
-
-## Configuration Notes
-Do not commit real secrets in `.env*` files. Review `vite.config.js` proxy targets and UnoCSS settings in `uno.config.js` when changing networking, icons, or shared styling behavior.

+ 2 - 2
forge-h5-ui/package.json

@@ -6,7 +6,7 @@
     "dev:custom": "uni -p",
     "dev:h5": "uni",
     "dev:h5:ssr": "uni --ssr",
-    "dev:mp-alipay": "uni -p mp-alipay",
+    "dev:mp-alipay": "uni -p mp-alipay --mode mp-alipay",
     "dev:mp-baidu": "uni -p mp-baidu",
     "dev:mp-jd": "uni -p mp-jd",
     "dev:mp-kuaishou": "uni -p mp-kuaishou",
@@ -22,7 +22,7 @@
     "build:custom": "uni build -p",
     "build:h5": "uni build",
     "build:h5:ssr": "uni build --ssr",
-    "build:mp-alipay": "uni build -p mp-alipay",
+    "build:mp-alipay": "uni build -p mp-alipay --mode mp-alipay",
     "build:mp-baidu": "uni build -p mp-baidu",
     "build:mp-jd": "uni build -p mp-jd",
     "build:mp-kuaishou": "uni build -p mp-kuaishou",

+ 17 - 0
forge-h5-ui/src/components/hotel/HotelTabBar.vue

@@ -8,7 +8,21 @@
         :class="{ 'htb-item--active': current === tab.key }"
         @click="handleTab(tab)"
       >
+        <!-- #ifdef H5 -->
+        <!-- H5 载体:CSS mask 引用本地 SVG,可随激活态换色。既有实现,保持不变。 -->
         <view class="htb-icon" :style="iconStyle(tab, current === tab.key)" />
+        <!-- #endif -->
+        <!-- #ifndef H5 -->
+        <!-- 小程序载体:WXSS/ACSS 不支持用 mask 引用本地 SVG 文件,改用 image 渲染。
+             SVG 内 stroke="currentColor" 在 image 中会解析为黑色、无法随激活态换色,
+             因此用 opacity 区分激活态,文字颜色仍按激活态变化。 -->
+        <image
+          class="htb-icon"
+          :src="tab.icon"
+          mode="aspectFit"
+          :style="{ opacity: current === tab.key ? 1 : 0.45 }"
+        />
+        <!-- #endif -->
         <text class="htb-label" :style="{ color: current === tab.key ? 'var(--h-pri)' : 'var(--h-txt-light)' }">
           {{ tab.label }}
         </text>
@@ -41,6 +55,8 @@ const tabs = [
   { key: 'cart', label: '购物车', path: '/pages/hotel/customer/cart', icon: '/static/icons/ai-icon/shopping-cart.svg' },
 ]
 
+// #ifdef H5
+// 仅 H5 载体使用:小程序端模板已改走 image 分支,本函数会被条件编译裁掉
 function iconStyle(tab, active) {
   const color = active ? '#2D5016' : '#999999'
   return {
@@ -49,6 +65,7 @@ function iconStyle(tab, active) {
     mask: `url(${tab.icon}) center / contain no-repeat`,
   }
 }
+// #endif
 
 function handleTab(tab) {
   if (tab.key === props.current) return

+ 4 - 0
forge-h5-ui/src/manifest.json

@@ -58,6 +58,10 @@
         "usingComponents" : true
     },
     "mp-alipay" : {
+        /* 支付宝小程序 AppID:企业资质申请通过后填入。
+           留空时只能在支付宝小程序 IDE 中以「测试号」本地预览,
+           无法上传体验版 / 发布正式版,也无法配置「普通链接二维码」跳转规则。 */
+        "appid" : "",
         "usingComponents" : true
     },
     "mp-baidu" : {

+ 7 - 0
forge-h5-ui/src/pages.json

@@ -64,6 +64,12 @@
 				"navigationStyle": "custom"
 			}
 		},
+		// #ifdef H5
+		// 员工扫码绑定页 / 相机扫码页:仅 H5 载体提供。
+		// 小程序端顾客由微信/支付宝普通链接二维码规则直接拉起
+		// pages/hotel/customer/room-confirm,不经这两页;
+		// 且 qr-scanner 依赖 html5-qrcode + navigator.mediaDevices,小程序无此能力,
+		// 不裁掉会导致 mp 构建失败或运行时报错。
 		{
 			"path": "pages/hotel/scan-bind",
 			"style": {
@@ -78,6 +84,7 @@
 				"navigationStyle": "custom"
 			}
 		},
+		// #endif
 		{
 			"path": "pages/hotel/customer/room-confirm",
 			"style": {

+ 15 - 5
forge-h5-ui/src/pages/hotel/customer/pay.vue

@@ -201,8 +201,8 @@ async function doPay() {
       return
     }
 
-    // 根据支付渠道拉起支付
-    if (paySource.value === 'ALIPAY' && createData.payForm) {
+    // 根据后端返回的 payChannel 拉起支付(不用 paySource 判断,ALIPAY_MP 同样走支付宝分支)
+    if (createData.payChannel === 'ALIPAY' && createData.payForm) {
       // 支付宝 H5 手机网站支付:后端返回 payForm(HTML 表单),渲染后自动提交跳转支付宝收银台
       // #ifdef H5
       var payContainer = document.getElementById('alipay-pay-form')
@@ -237,7 +237,7 @@ async function doPay() {
         }
       })
       // #endif
-    } else if (paySource.value === 'ALIPAY' && createData.tradeNo) {
+    } else if (createData.payChannel === 'ALIPAY' && createData.tradeNo) {
       // 支付宝小程序环境:有 tradeNo 无 payForm,用 my.tradePay
       // #ifdef MP-ALIPAY
       my.tradePay({
@@ -255,8 +255,9 @@ async function doPay() {
       // H5 环境不应只有 tradeNo,启动轮询兜底
       startPayPolling()
       // #endif
-    } else {
-      // MOCK 模式:直接调用模拟支付(开发调试用)
+    } else if (createData.payChannel === 'MOCK') {
+      // MOCK 模式:仅开发环境可用。后端 hotel.pay.mock-enabled=true 时才会返回 payChannel=MOCK,
+      // 因此本分支在生产环境永远不会命中,既有 H5 调试行为保持不变
       var mockRes = await api.hotelPayMockSuccess(orderId.value, tenantId)
       var mockData = mockRes?.data || mockRes
 
@@ -273,6 +274,15 @@ async function doPay() {
         paying.value = false
         uni.showToast({ title: '支付失败,请重试', icon: 'none' })
       }
+    } else {
+      // 后端未返回可用支付参数:生产环境 Mock 已禁用,或该渠道(如微信)尚未接入。
+      // 禁止在此兜底调用 hotelPayMockSuccess —— 那等于绕过资金校验免付款
+      paying.value = false
+      uni.showModal({
+        title: '暂无法在线支付',
+        content: '当前支付方式暂不可用,请使用支付宝完成支付,或联系前台办理付款。',
+        showCancel: false
+      })
     }
   } catch (err) {
     paying.value = false

+ 49 - 2
forge-h5-ui/src/pages/hotel/customer/room-confirm.vue

@@ -167,13 +167,60 @@ const authPhone = ref('')
 let shortCode = ''
 let sign = ''
 
+/**
+ * 解析扫码入参,兼容 H5 / 微信小程序 / 支付宝小程序三种载体。
+ *
+ * 小程序扫「普通链接二维码」时,平台不会把 URL 的 query 拆成页面参数,
+ * 而是把整条原始 URL 塞进一个平台专属字段:
+ * - 微信:options.q = encodeURIComponent(原始URL)
+ * - 支付宝:options.qrCode = 原始URL(部分版本已编码,统一尝试 decode)
+ * H5(开发环境)则由 scan-bind.vue 判定已绑定后直接透传 c/t/s。
+ *
+ * 优先级:显式 c/s > 微信 q > 支付宝 qrCode。
+ * 参数名 c/t/s 与后端 QrCodeUtils.buildQrCodeUrl 一致,不得更改。
+ *
+ * @param {Object} options 页面路由参数
+ * @returns {{shortCode: string, sign: string}} 解析出的短码与签名
+ */
+function resolveScanParams(options) {
+  const query = options || {}
+  const c = query.c || query.shortCode || ''
+  const s = query.s || query.sign || ''
+  if (c && s) {
+    return { shortCode: c, sign: s }
+  }
+
+  // 小程序扫普通链接二维码:原始 URL 在 q / qrCode 字段里
+  const raw = query.q || query.qrCode || ''
+  if (!raw) {
+    return { shortCode: c, sign: s }
+  }
+
+  let rawUrl = raw
+  try {
+    rawUrl = decodeURIComponent(raw)
+  } catch (e) {
+    // 未编码或含非法转义序列时直接用原值
+    rawUrl = raw
+  }
+
+  // 按 [?&]key=value 提取,与 scan-bind.vue 的 parseQrUrl 正则口径保持一致
+  const matchedCode = rawUrl.match(/[?&]c=([^&]+)/)
+  const matchedSign = rawUrl.match(/[?&]s=([^&]+)/)
+  return {
+    shortCode: c || (matchedCode ? matchedCode[1] : ''),
+    sign: s || (matchedSign ? matchedSign[1] : ''),
+  }
+}
+
 onMounted(async () => {
   const pages = getCurrentPages()
   const currentPage = pages[pages.length - 1]
   const options = currentPage?.$page?.options || currentPage?.options || {}
 
-  shortCode = options.c || options.shortCode || ''
-  sign = options.s || options.sign || ''
+  const scanParams = resolveScanParams(options)
+  shortCode = scanParams.shortCode
+  sign = scanParams.sign
 
   if (!shortCode || !sign) {
     errorMsg.value = '二维码参数缺失,请重新扫描'

+ 20 - 5
forge-h5-ui/src/pages/index/index.vue

@@ -242,6 +242,8 @@ const componentDemoItem = {
   bgClass: 'bg-blue',
 }
 
+// #ifdef H5
+// 员工扫码绑定入口:仅 H5 载体提供(pages.json 已按条件编译裁掉该页)
 const scanBindItem = {
   key: 'scan-bind',
   label: '扫码绑定',
@@ -249,6 +251,7 @@ const scanBindItem = {
   color: '#10b981',
   bgClass: 'bg-emerald',
 }
+// #endif
 
 const moreMenuItem = {
   key: 'more',
@@ -268,14 +271,21 @@ const menuToneList = [
   { icon: '/static/icons/ai-icon/briefcase.svg', color: '#0f766e', bgClass: 'bg-teal' },
 ]
 
+// #ifdef H5
 const SCAN_BIND_PERM = 'hotel:qrcode:scan'
+// #endif
 
 const menuItems = computed(() => {
   const backendItems = flattenMenus(authStore.menus)
   const sourceItems = backendItems.length ? backendItems : fallbackMenuItems
   const perms = authStore.permissions || []
+  // 扫码绑定是员工 H5 能力:小程序端页面未注册,入口必须一并隐藏,
+  // 否则点击会 navigateTo 到不存在的页面而报错
+  let prefix = []
+  // #ifdef H5
   const hasScanBind = perms.includes('*:*:*') || perms.includes(SCAN_BIND_PERM)
-  const prefix = hasScanBind ? [scanBindItem] : []
+  prefix = hasScanBind ? [scanBindItem] : []
+  // #endif
   return [...prefix, componentDemoItem, ...sourceItems].slice(0, 7).concat(moreMenuItem)
 })
 
@@ -401,16 +411,19 @@ function isNavigableMenu(menu) {
 
 function isRegisteredH5Route(path) {
   const normalized = String(path || '').split('?')[0].replace(/^\//, '')
-  return [
+  const registered = [
     'pages/index/index',
     'pages/message/index',
     'pages/todo',
     'pages/mine/index',
     'pages/demo/loading/index',
     'pages/app-entry',
-    'pages/hotel/scan-bind',
-    'pages/hotel/qr-scanner',
-  ].includes(normalized)
+  ]
+  // #ifdef H5
+  // 员工扫码两页仅在 H5 构建中注册,小程序构建已被 pages.json 裁掉
+  registered.push('pages/hotel/scan-bind', 'pages/hotel/qr-scanner')
+  // #endif
+  return registered.includes(normalized)
 }
 
 function normalizeMenuEntry(menu, index = 0) {
@@ -489,10 +502,12 @@ function handleShortcut(item) {
     uni.navigateTo({ url: '/pages/demo/loading/index' })
     return
   }
+  // #ifdef H5
   if (item.key === 'scan-bind') {
     uni.navigateTo({ url: '/pages/hotel/scan-bind' })
     return
   }
+  // #endif
   if (item.fromBackend) {
     openBackendMenu(item)
     return

+ 8 - 1
forge-server/forge-admin-server/src/main/resources/application-dev.example.yml

@@ -86,10 +86,17 @@ config:
     password: ${spring.datasource.dynamic.datasource.master.password}
     driverClassName: ${spring.datasource.dynamic.datasource.master.driverClassName}
 
-# 酒店二维码配置
+# 酒店模块配置(二维码 / 支付安全开关)
 hotel:
   qr:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
     base-url: https://192.168.10.4:3001
+    # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
+    # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
+    path: "/#/pages/hotel/scan-bind"
+  # 支付安全开关:开发环境允许模拟支付,保持既有 H5 调试行为零变化
+  # 生产环境(application.yml)默认 false,禁止覆盖为 true
+  pay:
+    mock-enabled: true

+ 8 - 1
forge-server/forge-admin-server/src/main/resources/application-dev.yml

@@ -88,13 +88,20 @@ config:
     password: ${spring.datasource.dynamic.datasource.master.password}
     driverClassName: ${spring.datasource.dynamic.datasource.master.driverClassName}
 
-# 酒店二维码配置
+# 酒店模块配置(二维码 / 支付安全开关 / 支付宝沙箱)
 hotel:
   qr:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
     base-url: http://192.168.10.4:3001
+    # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
+    # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
+    path: "/#/pages/hotel/scan-bind"
+  # 支付安全开关:开发环境允许模拟支付,保持既有 H5 调试行为零变化
+  # 生产环境(application.yml)默认 false,禁止覆盖为 true
+  pay:
+    mock-enabled: true
   # 支付宝沙箱支付配置
   alipay:
     # 沙箱应用APPID

+ 19 - 0
forge-server/forge-admin-server/src/main/resources/application.yml

@@ -218,3 +218,22 @@ forge:
     datasource:
       enabled: true
       tenant-routing-enabled-default: true
+
+# 酒店模块配置(二维码 / 支付安全开关)
+hotel:
+  qr:
+    # 二维码扫码基础URL(生产必须 HTTPS,且为小程序规则配置的专属域名)
+    # 未注入时为空串,生成的二维码无域名不可扫;部署时必须通过环境变量传入真实域名
+    # (HotelQrCodeServiceImpl 的 @Value 无默认值,缺此项会导致生产 profile 启动失败)
+    base-url: ${FORGE_HOTEL_QR_BASE_URL:}
+    # 扫码落地路径。小程序载体用 /r/ —— 无 hash 路由的专属前缀,
+    # 供微信/支付宝普通链接二维码规则按 path 前缀匹配后直接拉起小程序。
+    # 禁止配成 / :hash 路由(/#/xxx)的 path 恒为 /,规则会劫持同域名下所有页面(含员工 H5)。
+    # 员工 H5 载体请改用 "/#/pages/hotel/scan-bind"(开发环境即为此值)。
+    path: ${FORGE_HOTEL_QR_PATH:/r/}
+  pay:
+    # 是否允许模拟支付(MOCK 渠道 + /hotel/open/pay/mockSuccess 接口)
+    # AGENTS.md 5.9 资金红线:生产必须为 false。
+    # 开启后任何持有 tenantId + orderId 的免登录请求都能把订单置为「已支付」而无真实资金流。
+    # 本地开发请在 application-dev.yml 覆盖为 true。
+    mock-enabled: ${FORGE_HOTEL_PAY_MOCK_ENABLED:false}

+ 8 - 1
forge-server/forge-app-server/src/main/resources/application-dev.example.yml

@@ -67,10 +67,17 @@ config:
     password: ${spring.datasource.dynamic.datasource.master.password}
     driverClassName: ${spring.datasource.dynamic.datasource.master.driverClassName}
 
-# 酒店二维码配置
+# 酒店模块配置(二维码 / 支付安全开关)
 hotel:
   qr:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
     base-url: https://192.168.10.4:3001
+    # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
+    # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
+    path: "/#/pages/hotel/scan-bind"
+  # 支付安全开关:开发环境允许模拟支付,保持既有 H5 调试行为零变化
+  # 生产环境(application.yml)默认 false,禁止覆盖为 true
+  pay:
+    mock-enabled: true

+ 8 - 1
forge-server/forge-app-server/src/main/resources/application-dev.yml

@@ -67,13 +67,20 @@ config:
     password: ${spring.datasource.dynamic.datasource.master.password}
     driverClassName: ${spring.datasource.dynamic.datasource.master.driverClassName}
 
-# 酒店二维码配置
+# 酒店模块配置(二维码 / 支付安全开关 / 支付宝沙箱)
 hotel:
   qr:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
     base-url: http://192.168.10.4:3001
+    # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
+    # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
+    path: "/#/pages/hotel/scan-bind"
+  # 支付安全开关:开发环境允许模拟支付,保持既有 H5 调试行为零变化
+  # 生产环境(application.yml)默认 false,禁止覆盖为 true
+  pay:
+    mock-enabled: true
   # 支付宝沙箱支付配置
   alipay:
     # 沙箱应用APPID

+ 19 - 0
forge-server/forge-app-server/src/main/resources/application.yml

@@ -100,3 +100,22 @@ sa-token:
     timeout: 10000
 sms:
   config-type: interface
+
+# 酒店模块配置(二维码 / 支付安全开关)
+hotel:
+  qr:
+    # 二维码扫码基础URL(生产必须 HTTPS,且为小程序规则配置的专属域名)
+    # 未注入时为空串,生成的二维码无域名不可扫;部署时必须通过环境变量传入真实域名
+    # (HotelQrCodeServiceImpl 的 @Value 无默认值,缺此项会导致生产 profile 启动失败)
+    base-url: ${FORGE_HOTEL_QR_BASE_URL:}
+    # 扫码落地路径。小程序载体用 /r/ —— 无 hash 路由的专属前缀,
+    # 供微信/支付宝普通链接二维码规则按 path 前缀匹配后直接拉起小程序。
+    # 禁止配成 / :hash 路由(/#/xxx)的 path 恒为 /,规则会劫持同域名下所有页面(含员工 H5)。
+    # 员工 H5 载体请改用 "/#/pages/hotel/scan-bind"(开发环境即为此值)。
+    path: ${FORGE_HOTEL_QR_PATH:/r/}
+  pay:
+    # 是否允许模拟支付(MOCK 渠道 + /hotel/open/pay/mockSuccess 接口)
+    # AGENTS.md 5.9 资金红线:生产必须为 false。
+    # 开启后任何持有 tenantId + orderId 的免登录请求都能把订单置为「已支付」而无真实资金流。
+    # 本地开发请在 application-dev.yml 覆盖为 true。
+    mock-enabled: ${FORGE_HOTEL_PAY_MOCK_ENABLED:false}

+ 7 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/constant/HotelQrConstants.java

@@ -20,7 +20,13 @@ public final class HotelQrConstants {
     /** 二维码默认尺寸(像素) */
     public static final int QR_DEFAULT_SIZE = 400;
 
-    /** 二维码基础URL路径(H5扫码页面,uni-app hash路由) */
+    /**
+     * 二维码扫码落地路径的默认值(H5 载体,uni-app hash 路由)。
+     * <p>
+     * 实际取值由配置项 {@code hotel.qr.path} 控制,本常量仅作为配置缺失时的回退值。
+     * 切换小程序载体时把配置改为 {@code /r/} 即可,无需改动本常量;
+     * 二维码 URL 不落库(hotel_qr_code 只存 short_code + sign),因此改格式零数据迁移成本。
+     */
     public static final String QR_BASE_PATH = "/#/pages/hotel/scan-bind";
 
     /** 二维码状态:启用(对应字典 sys_normal_disable = 0) */

+ 15 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/AlipayAuthController.java

@@ -52,7 +52,21 @@ public class AlipayAuthController {
             vo.setPhone(phone);
         }
 
-        log.info("支付宝用户授权完成: userName={}, phone={}", vo.getUserName(), vo.getPhone());
+        // AGENTS.md 5.9 安全红线:禁止在日志中打印手机号,必须脱敏后落盘
+        log.info("支付宝用户授权完成: userName={}, phone={}", vo.getUserName(), maskPhone(vo.getPhone()));
         return RespInfo.success(vo);
     }
+
+    /**
+     * 手机号脱敏:保留前3后4,中间用 **** 代替。
+     *
+     * @param phone 原始手机号
+     * @return 脱敏后的手机号;为空或长度不足 7 位时统一返回 ****
+     */
+    private String maskPhone(String phone) {
+        if (phone == null || phone.length() < 7) {
+            return "****";
+        }
+        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
+    }
 }

+ 16 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelPayController.java

@@ -3,9 +3,11 @@ package com.mdframe.forge.business.core.hotel.controller.open;
 import com.alipay.api.AlipayClient;
 import com.alipay.api.internal.util.AlipaySignature;
 import com.mdframe.forge.business.core.hotel.pay.config.AlipayConfig;
+import com.mdframe.forge.business.core.hotel.pay.config.HotelPayConfig;
 import com.mdframe.forge.business.core.hotel.pay.service.HotelPayService;
 import com.mdframe.forge.business.core.hotel.pay.vo.PayResultVO;
 import com.mdframe.forge.starter.core.domain.RespInfo;
+import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -39,18 +41,22 @@ public class HotelPayController {
     @Autowired
     private AlipayConfig alipayConfig;
 
+    @Autowired
+    private HotelPayConfig hotelPayConfig;
+
     /**
      * 发起支付(创建支付流水,返回支付参数)。
      *
      * @param tenantId  租户ID
      * @param orderId   订单ID
-     * @param paySource 支付来源(WECHAT/ALIPAY/H5/MOCK)
+     * @param paySource 支付来源(ALIPAY/ALIPAY_MP/WECHAT/WECHAT_MP/MOCK),必传。
+     *                  <p>历史默认值 "MOCK" 已移除:本接口免登录,缺省即免付款属于资金红线(AGENTS.md 5.9)。
      * @return 支付参数
      */
     @PostMapping("/create")
     public RespInfo<Map<String, Object>> createPay(@RequestParam Long tenantId,
                                                     @RequestParam Long orderId,
-                                                    @RequestParam(required = false, defaultValue = "MOCK") String paySource) {
+                                                    @RequestParam String paySource) {
         Map<String, Object> result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<Map<String, Object>>() {
                     @Override
@@ -62,7 +68,10 @@ public class HotelPayController {
     }
 
     /**
-     * 模拟支付成功(开发阶段使用,后续对接真实支付后移除)。
+     * 模拟支付成功(仅开发环境可用,由 hotel.pay.mock-enabled 控制)。
+     * <p>
+     * 本接口落在 Sa-Token 白名单 /hotel/open/** 内完全免登录,开关关闭时必须直接拒绝,
+     * 否则任何持有 tenantId + orderId 的请求都能把订单置为「已支付」而不产生真实资金流。
      *
      * @param tenantId 租户ID
      * @param orderId  订单ID
@@ -71,6 +80,10 @@ public class HotelPayController {
     @PostMapping("/mockSuccess")
     public RespInfo<PayResultVO> mockPaySuccess(@RequestParam Long tenantId,
                                                  @RequestParam Long orderId) {
+        if (!hotelPayConfig.isMockEnabled()) {
+            log.warn("模拟支付请求被拒绝(hotel.pay.mock-enabled=false): tenantId={}, orderId={}", tenantId, orderId);
+            throw new BusinessException("模拟支付已禁用");
+        }
         PayResultVO result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<PayResultVO>() {
                     @Override

+ 49 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/config/HotelPayConfig.java

@@ -0,0 +1,49 @@
+package com.mdframe.forge.business.core.hotel.pay.config;
+
+import jakarta.annotation.PostConstruct;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 酒店支付安全开关配置。
+ * <p>
+ * 配置项从 application.yml 的 hotel.pay 前缀读取。
+ * <p>
+ * <b>AGENTS.md 5.9 安全红线:涉及资金流转,默认值必须为 false。</b>
+ * 开关打开后,{@code /hotel/open/pay/mockSuccess} 与 MOCK 支付渠道可用,
+ * 任何持有 tenantId + orderId 的请求都能把订单置为「已支付」而不产生真实资金流;
+ * 该路径又落在 Sa-Token 白名单 {@code /hotel/open/**} 内,完全免登录。
+ * 因此仅允许本地开发/测试环境开启,生产环境必须保持 false。
+ * <p>
+ * 开发环境通过 application-dev.yml 覆盖为 true,保证既有 H5 调试行为不变。
+ */
+@Slf4j
+@Getter
+@Configuration
+public class HotelPayConfig {
+
+    /**
+     * 是否允许模拟支付(MOCK 渠道 + mockSuccess 接口)。
+     * <p>
+     * 默认 false:生产环境模拟支付一律拒绝,未知/未接入渠道显式报错,不再静默降级为 MOCK。
+     */
+    @Value("${hotel.pay.mock-enabled:false}")
+    private boolean mockEnabled;
+
+    /**
+     * 启动即打印开关状态。
+     * <p>
+     * 模拟支付开启时用 WARN 级别告警,避免误配到生产环境后无人察觉。
+     */
+    @PostConstruct
+    public void logMockSwitch() {
+        if (mockEnabled) {
+            log.warn("【资金安全告警】hotel.pay.mock-enabled=true,模拟支付已开启:"
+                    + "任何请求均可免付款将订单置为已支付,禁止在生产环境开启!");
+        } else {
+            log.info("酒店支付安全开关: mock-enabled=false(模拟支付已禁用)");
+        }
+    }
+}

+ 2 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/AlipayAuthServiceImpl.java

@@ -66,7 +66,8 @@ public class AlipayAuthServiceImpl implements AlipayAuthService {
         // 支付宝小程序手机号获取需要调用开放平台 OpenAPI
         // 当前 SDK 版本不直接支持,返回空字符串
         // TODO: 接入支付宝 OpenAPI 获取手机号
-        log.warn("支付宝手机号获取暂未实现,response={}", response);
+        // AGENTS.md 5.9:response 为手机号密文报文,禁止整包落日志,仅记录长度供排查
+        log.warn("支付宝手机号获取暂未实现,responseLength={}", response != null ? response.length() : 0);
         return "";
     }
 }

+ 64 - 16
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java

@@ -14,6 +14,7 @@ import com.mdframe.forge.business.core.hotel.order.constant.HotelOrderConstants;
 import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
 import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderMapper;
 import com.mdframe.forge.business.core.hotel.pay.config.AlipayConfig;
+import com.mdframe.forge.business.core.hotel.pay.config.HotelPayConfig;
 import com.mdframe.forge.business.core.hotel.pay.domain.HotelPayLog;
 import com.mdframe.forge.business.core.hotel.pay.mapper.HotelPayLogMapper;
 import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterManager;
@@ -55,6 +56,9 @@ public class HotelPayServiceImpl implements HotelPayService {
     @Autowired
     private AlipayConfig alipayConfig;
 
+    @Autowired
+    private HotelPayConfig hotelPayConfig;
+
     /**
      * 服务类型标识,用于判断当前是 AdminServer 还是 AppServer。
      * 在 application.yml 中配置:server.type: admin 或 app
@@ -110,7 +114,9 @@ public class HotelPayServiceImpl implements HotelPayService {
         HotelPayLog payLog = new HotelPayLog();
         payLog.setOrderId(order.getId());
         payLog.setOrderNo(order.getOrderNo());
-        payLog.setPayChannel(resolvePayChannel(paySource));
+        // 渠道解析前置:非白名单渠道在此即被拒绝,杜绝静默降级为 MOCK 造成免付款
+        String payChannel = resolvePayChannel(paySource);
+        payLog.setPayChannel(payChannel);
         payLog.setPaySource(paySource != null ? paySource : HotelOrderConstants.PAY_SOURCE_MOCK);
         payLog.setOutTradeNo(outTradeNo);
         payLog.setPayAmount(order.getTotalAmount());
@@ -129,8 +135,9 @@ public class HotelPayServiceImpl implements HotelPayService {
         payParams.put("payAmount", order.getTotalAmount());
         payParams.put("paySource", payLog.getPaySource());
 
-        // 根据 paySource 调用不同支付渠道
-        if ("ALIPAY".equals(paySource)) {
+        // 根据已解析的 payChannel 调用支付渠道。
+        // 禁止用原始 paySource 判断:否则 ALIPAY_MP 会漏进 else 分支被静默置为 MOCK。
+        if ("ALIPAY".equals(payChannel)) {
             // 支付宝 H5 手机网站支付:调用 alipay.trade.wap.pay
             try {
                 AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
@@ -165,7 +172,9 @@ public class HotelPayServiceImpl implements HotelPayService {
                 throw new BusinessException("调用支付宝支付失败: " + e.getMessage());
             }
         } else {
-            // MOCK 模式:只返回基础参数
+            // MOCK 模式:只返回基础参数。
+            // 能走到这里说明 resolvePayChannel 已放行(仅 hotel.pay.mock-enabled=true 时可能),
+            // 前端据 payChannel=MOCK 决定是否调用 mockSuccess。
             payParams.put("payChannel", "MOCK");
         }
 
@@ -552,16 +561,25 @@ public class HotelPayServiceImpl implements HotelPayService {
             throw new BusinessException("订单金额异常,无法退款");
         }
 
-        // MOCK 渠道支付或无支付宝流水:无真实资金流转,直接标记退款成功(开发/测试环境兼容)
+        // MOCK 渠道支付(含历史数据)或无支付流水:无真实资金流转,仅回写退款状态。
+        // 必须显式判定 MOCK,禁止用「非 ALIPAY 即 MOCK」兜底 —— 否则未来接入微信后,
+        // 微信订单退款会被误判为无资金流而直接标记成功,造成真实资损。
         HotelPayLog payLog = payLogMapper.selectLatestByOrderId(orderId);
-        if (HotelOrderConstants.PAY_SOURCE_MOCK.equals(order.getPaySource())
-                || payLog == null
-                || !"ALIPAY".equals(payLog.getPayChannel())) {
+        String paidChannel = payLog != null ? payLog.getPayChannel() : null;
+        boolean isMockPaid = HotelOrderConstants.PAY_SOURCE_MOCK.equals(order.getPaySource())
+                || "MOCK".equals(paidChannel);
+        if (isMockPaid || payLog == null) {
             markRefundSuccess(order, payLog, refundAmount, "MOCK_REFUND_" + orderId, new Date());
-            log.info("MOCK退款成功(无真实资金): orderId={}, orderNo={}, amount={}",
-                    orderId, order.getOrderNo(), refundAmount);
+            log.info("MOCK退款成功(无真实资金): orderId={}, orderNo={}, amount={}, payLogExists={}",
+                    orderId, order.getOrderNo(), refundAmount, payLog != null);
             return;
         }
+        if (!"ALIPAY".equals(paidChannel)) {
+            // 有真实支付流水但渠道未接入在线退款:禁止静默标记成功,必须人工介入
+            log.error("退款渠道未接入,需人工处理: orderId={}, orderNo={}, payChannel={}",
+                    orderId, order.getOrderNo(), paidChannel);
+            throw new BusinessException("该支付渠道暂不支持在线退款,请联系前台人工处理");
+        }
 
         // 退款请求号必须稳定:重试时才能命中支付宝幂等,直接拿回首次退款结果
         String outRequestNo = payLog.getOutTradeNo() + REFUND_REQUEST_NO_SUFFIX;
@@ -788,18 +806,48 @@ public class HotelPayServiceImpl implements HotelPayService {
 
     /**
      * 根据支付来源解析支付渠道。
+     * <p>
+     * <b>资金红线(AGENTS.md 5.9)</b>:本方法历史上对 null、WECHAT、未知渠道一律静默返回 MOCK,
+     * 而 createPay 又把非 ALIPAY 的渠道全部当作 MOCK 处理,导致微信小程序 / H5 微信内置浏览器
+     * 下单后不付一分钱即可把订单置为「已支付」。现改为:
+     * <ul>
+     *   <li>hotel.pay.mock-enabled=true(开发环境):保持原有宽松行为,H5 调试零影响;</li>
+     *   <li>hotel.pay.mock-enabled=false(生产):除 ALIPAY 外一律显式抛 BusinessException。</li>
+     * </ul>
+     *
+     * @param paySource 前端传入的支付来源
+     * @return 支付渠道(ALIPAY / MOCK),非白名单渠道在开关关闭时直接抛异常
      */
     private String resolvePayChannel(String paySource) {
-        if (paySource == null) {
-            return "MOCK";
+        if ("ALIPAY".equals(paySource) || "ALIPAY_MP".equals(paySource)) {
+            return "ALIPAY";
         }
         if ("WECHAT".equals(paySource) || "WECHAT_MP".equals(paySource)) {
-            return "WECHAT";
+            // 微信商户号未申请、JSAPI 下单未实现,生产环境必须显式阻断而非降级为 MOCK
+            return mockOrReject("微信支付暂未开通,请使用支付宝支付或到前台付款");
         }
-        if ("ALIPAY".equals(paySource) || "ALIPAY_MP".equals(paySource)) {
-            return "ALIPAY";
+        if (paySource == null || paySource.trim().isEmpty()) {
+            return mockOrReject("支付渠道不能为空");
+        }
+        if ("MOCK".equals(paySource) || "H5".equals(paySource)) {
+            return mockOrReject("模拟支付已禁用");
+        }
+        return mockOrReject("不支持的支付渠道: " + paySource);
+    }
+
+    /**
+     * 开发环境放行 MOCK 渠道,生产环境显式拒绝。
+     * <p>
+     * 单一收口点:所有非支付宝渠道都必须经过本方法,禁止再出现 return "MOCK" 的兜底分支。
+     *
+     * @param reason 生产环境拒绝时返回给前端的提示文案
+     * @return 开发环境固定返回 MOCK
+     */
+    private String mockOrReject(String reason) {
+        if (hotelPayConfig.isMockEnabled()) {
+            return "MOCK";
         }
-        return "MOCK";
+        throw new BusinessException(reason);
     }
 
     /**

+ 12 - 2
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java

@@ -69,6 +69,16 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
     @org.springframework.beans.factory.annotation.Value("${hotel.qr.base-url}")
     private String qrBaseUrl;
 
+    /**
+     * 二维码扫码落地路径。
+     * <p>
+     * H5 载体(开发环境默认):{@code /#/pages/hotel/scan-bind};
+     * 小程序载体(上线切换):{@code /r/}。
+     * 未配置时为空串,QrCodeUtils 会回退到 HotelQrConstants.QR_BASE_PATH,保证既有二维码 URL 不变。
+     */
+    @org.springframework.beans.factory.annotation.Value("${hotel.qr.path:}")
+    private String qrBasePath;
+
     // ==================== 二维码管理 ====================
 
     @Override
@@ -270,7 +280,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
 
         try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
             for (HotelQrCode entity : entities) {
-                String qrCodeUrl = QrCodeUtils.buildQrCodeUrl(qrBaseUrl, entity.getShortCode(), tenantId);
+                String qrCodeUrl = QrCodeUtils.buildQrCodeUrl(qrBaseUrl, qrBasePath, entity.getShortCode(), tenantId);
                 byte[] pngData = QrCodeUtils.generateQrCodeBytes(qrCodeUrl, 400);
                 ZipEntry entry = new ZipEntry(entity.getShortCode() + ".png");
                 zos.putNextEntry(entry);
@@ -656,7 +666,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
         try {
             Long tenantId = vo.getTenantId() != null ? vo.getTenantId() : resolveTenantId();
             // 构建二维码URL
-            String qrCodeUrl = QrCodeUtils.buildQrCodeUrl(qrBaseUrl, vo.getShortCode(), tenantId);
+            String qrCodeUrl = QrCodeUtils.buildQrCodeUrl(qrBaseUrl, qrBasePath, vo.getShortCode(), tenantId);
             vo.setQrCodeUrl(qrCodeUrl);
             // 生成二维码Base64图片(使用较小尺寸用于列表展示)
             String qrCodeImage = QrCodeUtils.generateQrCodeBase64(qrCodeUrl, 200);

+ 33 - 2
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/utils/QrCodeUtils.java

@@ -103,7 +103,9 @@ public final class QrCodeUtils {
     }
 
     /**
-     * 构建二维码URL内容。
+     * 构建二维码URL内容(使用默认路径 HotelQrConstants.QR_BASE_PATH)。
+     * <p>
+     * 保留本签名仅为兼容旧调用方,新代码请使用带 basePath 的重载。
      *
      * @param baseUrl   基础域名
      * @param shortCode 二维码短码
@@ -111,8 +113,37 @@ public final class QrCodeUtils {
      * @return 完整的二维码URL
      */
     public static String buildQrCodeUrl(String baseUrl, String shortCode, Long tenantId) {
+        return buildQrCodeUrl(baseUrl, HotelQrConstants.QR_BASE_PATH, shortCode, tenantId);
+    }
+
+    /**
+     * 构建二维码URL内容(指定扫码落地路径)。
+     * <p>
+     * 路径由 hotel.qr.path 配置,两种载体:
+     * <ul>
+     *   <li><b>H5 载体</b>(开发环境默认):{@code /#/pages/hotel/scan-bind},
+     *       hash 路由直达员工扫码绑定页,已绑定时再由该页 redirectTo 顾客点餐页;</li>
+     *   <li><b>小程序载体</b>(上线切换):{@code /r/},无 hash 路由的专属前缀,
+     *       供微信/支付宝普通链接二维码规则按 path 前缀匹配后直接拉起小程序。</li>
+     * </ul>
+     * ⚠ 切换到 {@code /r/} 时,该前缀必须是域名下的专属路径:平台规则按 path 前缀匹配,
+     * 而 hash 路由({@code /#/xxx})的 path 恒为 {@code /},若配成 {@code /} 会劫持同域名
+     * 下所有页面(含员工 H5)。
+     * <p>
+     * 参数名 c/t/s 不得更改:员工端 scan-bind.vue 的 parseQrUrl 正则按 {@code [?&]c=} 解析。
+     *
+     * @param baseUrl   基础域名
+     * @param basePath  扫码落地路径,为空时回退到 HotelQrConstants.QR_BASE_PATH
+     * @param shortCode 二维码短码
+     * @param tenantId  租户ID
+     * @return 完整的二维码URL
+     */
+    public static String buildQrCodeUrl(String baseUrl, String basePath, String shortCode, Long tenantId) {
         String sign = generateSign(shortCode, tenantId);
-        return baseUrl + HotelQrConstants.QR_BASE_PATH
+        String path = (basePath == null || basePath.trim().isEmpty())
+                ? HotelQrConstants.QR_BASE_PATH
+                : basePath.trim();
+        return baseUrl + path
                 + "?c=" + shortCode
                 + "&t=" + tenantId
                 + "&s=" + sign;

+ 0 - 420
forge-server/forge-business/forge-hotel/酒店AGENTS.md

@@ -1,420 +0,0 @@
-# AGENTS.md - 酒店二维码模块开发规范
-
-> **重要**: 此文件会被 IDEA QoderWork 插件自动读取,为 AI 助手提供项目上下文和开发规范
-
-**项目**: Forge Admin - 酒店二维码模块 (Hotel QR Code Module)  
-**技术栈**: Spring Boot 3.2 + Vue 3.5 + Naive UI 2.42 + MyBatis-Plus 3.5  
-**最后更新**: 2026-08-10
-
----
-
-## 一、核心开发规范(必须遵守)
-
-### 1.1 后端规范
-
-**启动类**: `ForgeAdminServerApplication`(主应用入口,聚合所有插件)
-
-**依赖注入**:
-```java
-@RequiredArgsConstructor  // ✅ Lombok 构造器注入
-private final HotelQrCodeMapper qrCodeMapper;
-
-// ❌ 禁止使用 @Autowired
-```
-
-**响应体**:
-```java
-// ✅ 必须使用 RespInfo
-return RespInfo.success(data);
-return RespInfo.error("错误信息");
-
-// ❌ 禁止使用 DataResult / ResponseUtil
-```
-
-**ID生成**:
-```java
-// ✅ 使用雪花算法(MyBatis-Plus ASSIGN_ID)
-@TableId(value = "id", type = IdType.ASSIGN_ID)
-private Long id;
-
-// ❌ 禁止使用 UUID / 自增ID
-```
-
-**实体继承**:
-```java
-// ✅ 所有实体必须继承 TenantEntity
-public class HotelQrCode extends TenantEntity { ... }
-// TenantEntity 包含: tenantId, createBy, createTime, createDept, updateBy, updateTime
-```
-
-**逻辑删除**:
-```java
-// ✅ 必须显式声明 @TableLogic
-@TableLogic(value = "0", delval = "id")
-private Long delFlag;
-
-// Mapper XML 查询必须显式过滤: AND del_flag = 0
-```
-
-**Controller路由**:
-```java
-@RestController
-@RequestMapping("/hotel")
-public class HotelQrCodeController { ... }
-
-// 实际访问: /hotel/qrcode/page, /hotel/room/page 等
-```
-
-**SQL 规范**:
-- 查询类 SQL **禁止**在 Service 层用 `LambdaQueryWrapper` 构建
-- 必须写在 Mapper XML 中(DataScopeInterceptor 按 mapperMethod 精确匹配)
-- 例外:仅单表 `selectById`、`insert`、`updateById`、`deleteById` 等内置方法允许
-
-**租户 ID 规则**:
-- 业务数据的 `tenant_id` **必须设为 `1`**(默认租户),**禁止设 `0`**
-- `TenantLineInnerInterceptor` 自动追加 `WHERE tenant_id = 当前租户ID`
-
-### 1.2 前端规范
-
-**组件语法**:
-```vue
-<!-- ✅ 必须使用 Composition API -->
-<script setup>
-import { ref, computed } from 'vue'
-const data = ref([])
-</script>
-
-<!-- ❌ 禁止使用 Options API (Vue 2语法) -->
-```
-
-**列表页面**:
-```vue
-<!-- ✅ 必须使用 AiCrudPage 组件 -->
-<AiCrudPage
-  ref="crudRef"
-  :api-config="{ list: 'get@/hotel/qrcode/page' }"
-  :search-schema="searchSchema"
-  :columns="tableColumns"
-  row-key="id"
-/>
-
-<!-- ❌ 禁止手动拼接 NInput/NSelect/NButton 搜索栏 -->
-```
-
-**字典使用**:
-```vue
-<!-- ✅ 必须使用字典组件,禁止硬编码 -->
-<script setup>
-import DictTag from '@/components/DictTag.vue'
-import { useDict } from '@/composables/useDict'
-const { dict } = useDict('sys_normal_disable')
-</script>
-
-<template>
-  <DictTag dictType="sys_normal_disable" :value="row.status" />
-</template>
-
-<!-- ❌ 禁止在前端写死 options 或标签映射 -->
-```
-
-**按钮样式约定**:
-| 类名 | 颜色 | 场景 |
-|------|------|------|
-| `text-primary` | 蓝 | 编辑、查看、绑定、下载 |
-| `text-warning` | 黄 | 解绑、禁用、重置 |
-| `text-error` | 红 | 删除 |
-| `text-success` | 绿 | 启用 |
-| `text-info` | 灰蓝 | 详情、日志 |
-
----
-
-## 二、项目架构
-
-### 2.1 模块位置
-
-```
-forge-server/forge-business/forge-hotel/     # 酒店业务模块
-├── controller/    # REST 控制器
-├── service/       # 服务接口
-│   └── impl/      # 服务实现
-├── mapper/        # MyBatis Mapper 接口 + XML
-├── domain/        # 数据库实体
-├── dto/           # 请求 DTO
-── vo/            # 响应 VO
-├── constant/      # 常量定义
-└── utils/         # 工具类(二维码生成、签名校验)
-
-forge-admin-ui/src/views/hotel/            # 前端页面
-├── qrcode.vue     # 二维码管理(AiCrudPage)
-├── room.vue       # 房间管理(AiCrudPage)
-└── roomType.vue   # 房型管理(AiCrudPage)
-
-forge-h5-ui/src/pages/hotel/             # H5 移动端
-└── scan-bind.vue  # 扫码绑定房间页面
-```
-
-### 2.2 三端架构
-
-| 端 | 技术 | 部署 | 核心功能 |
-|---|------|------|---------|
-| PC 管理后台 | Vue3 + Naive UI + AiCrudPage | PC浏览器 | 批量生成、批量删除、批量下载ZIP、绑定/重绑/解绑、状态管理 |
-| H5 移动端 | UniApp + html5-qrcode | 手机浏览器(HTTPS) | 员工扫码绑定房间、查看绑定状态 |
-| 开放接口 | REST API | 免登录 | 顾客扫码查询二维码信息 |
-
----
-
-## 三、技术栈清单
-
-### 后端
-- Java 17 + Spring Boot 3.2
-- MyBatis-Plus 3.5 + 动态数据源
-- Sa-Token 1.38(认证授权)
-- 多租户隔离(TenantLineInnerInterceptor)
-- ZXing 3.5.3(二维码生成)
-- MySQL 8.0+ / Redis 6.0+
-
-### 前端
-- Vue 3.5 + Naive UI 2.42
-- Vite 7 + Pinia 3 + UnoCSS 66
-- AiCrudPage / AiSearch / AiTable(框架标准组件)
-- html5-qrcode(H5 扫码)
-
----
-
-## 四、数据库设计
-
-### 4.1 表结构
-
-**hotel_qr_code(二维码表)**:
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| id | BIGINT | 主键(雪花算法) |
-| tenant_id | BIGINT | 租户ID,默认1 |
-| short_code | VARCHAR(32) | 二维码唯一短码(8位大写字母+数字) |
-| sign | VARCHAR(64) | MD5签名值 |
-| room_id | BIGINT | 绑定房间ID(NULL=未绑定) |
-| room_no | VARCHAR(32) | 冗余房间号 |
-| status | VARCHAR(16) | 状态:0=启用, 1=禁用(对应字典 sys_normal_disable) |
-| batch_no | VARCHAR(32) | 批次号(格式:QR+yyyyMMddHHmmss) |
-| bind_user_id | BIGINT | 最近绑定人ID |
-| bind_user_name | VARCHAR(64) | 最近绑定人姓名 |
-| bind_time | DATETIME | 最近绑定时间 |
-| unbind_user_id | BIGINT | 最近解绑人ID |
-| unbind_user_name | VARCHAR(64) | 最近解绑人姓名 |
-| unbind_time | DATETIME | 最近解绑时间 |
-| del_flag | BIGINT | 逻辑删除标记(0=未删除,删除时写入主键ID) |
-
-**唯一索引**: `UNIQUE (tenant_id, short_code, del_flag)`
-
-**hotel_room(房间表)**:
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| id | BIGINT | 主键 |
-| tenant_id | BIGINT | 租户ID |
-| room_no | VARCHAR(32) | 房间号(租户内唯一) |
-| room_type_id | BIGINT | 房型ID |
-| floor_no | VARCHAR(16) | 楼层 |
-| status | VARCHAR(16) | 状态:VACANT/OCCUPIED/CLEANING/MAINTENANCE |
-| sort_order | INT | 排序号 |
-| remark | VARCHAR(256) | 备注 |
-| del_flag | BIGINT | 逻辑删除标记 |
-
-**唯一索引**: `UNIQUE (tenant_id, room_no, del_flag)`
-
-**hotel_room_type(房型表)**:
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| id | BIGINT | 主键 |
-| tenant_id | BIGINT | 租户ID |
-| type_name | VARCHAR(64) | 房型名称 |
-| description | VARCHAR(256) | 房型描述 |
-| sort_order | INT | 排序号 |
-| del_flag | BIGINT | 逻辑删除标记 |
-
-**hotel_qr_bind_log(绑定日志表)**:
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| id | BIGINT | 主键 |
-| tenant_id | BIGINT | 租户ID |
-| qr_code_id | BIGINT | 二维码ID |
-| short_code | VARCHAR(32) | 冗余短码 |
-| room_id | BIGINT | 房间ID |
-| room_no | VARCHAR(32) | 冗余房间号 |
-| action_type | VARCHAR(16) | 操作类型:BIND/REBIND/UNBIND |
-| operator_id | BIGINT | 操作人ID |
-| operator_name | VARCHAR(64) | 操作人姓名 |
-| operate_time | DATETIME | 操作时间 |
-| remark | VARCHAR(256) | 备注 |
-
-### 4.2 字典数据
-
-| 字典类型 | 字典值 | 标签 | 标签样式 |
-|----------|--------|------|----------|
-| sys_normal_disable | 0 | 正常 | success |
-| sys_normal_disable | 1 | 停用 | danger |
-| hotel_room_status | VACANT | 空闲 | success |
-| hotel_room_status | OCCUPIED | 入住 | primary |
-| hotel_room_status | CLEANING | 打扫中 | warning |
-| hotel_room_status | MAINTENANCE | 维护中 | danger |
-| hotel_qr_bind_action | BIND | 绑定 | success |
-| hotel_qr_bind_action | REBIND | 重绑 | warning |
-| hotel_qr_bind_action | UNBIND | 解绑 | danger |
-
----
-
-## 五、API 接口清单
-
-### 5.1 管理端接口(需 Sa-Token 登录)
-
-**二维码管理**:
-
-| 方法 | 路径 | 说明 |
-|------|------|------|
-| GET | `/hotel/qrcode/page` | 分页查询二维码 |
-| GET | `/hotel/qrcode/:id` | 查询二维码详情 |
-| POST | `/hotel/qrcode/batch-generate` | 批量生成二维码 |
-| POST | `/hotel/qrcode/:id/bind` | 绑定房间 |
-| POST | `/hotel/qrcode/:id/rebind` | 重新绑定房间 |
-| POST | `/hotel/qrcode/:id/unbind` | 解绑房间 |
-| PUT | `/hotel/qrcode/:id/status` | 更新二维码状态 |
-| POST | `/hotel/qrcode/batch-remove` | 批量删除二维码(逻辑删除) |
-| POST | `/hotel/qrcode/batch-download` | 批量下载二维码ZIP |
-| GET | `/hotel/qrcode/:id/bind-logs` | 查询绑定日志 |
-
-**房间管理**:
-
-| 方法 | 路径 | 说明 |
-|------|------|------|
-| GET | `/hotel/room/page` | 分页查询房间 |
-| GET | `/hotel/room/:id` | 查询房间详情 |
-| POST | `/hotel/room` | 新增房间 |
-| PUT | `/hotel/room` | 修改房间 |
-| POST | `/hotel/room/remove/:id` | 删除房间 |
-| GET | `/hotel/room/list-all` | 查询全部房间(下拉用) |
-
-**房型管理**:
-
-| 方法 | 路径 | 说明 |
-|------|------|------|
-| GET | `/hotel/room-type/list` | 查询房型列表 |
-| GET | `/hotel/room-type/:id` | 查询房型详情 |
-| POST | `/hotel/room-type` | 新增房型 |
-| PUT | `/hotel/room-type` | 修改房型 |
-| POST | `/hotel/room-type/remove/:id` | 删除房型 |
-
-### 5.2 开放接口(免登录)
-
-| 方法 | 路径 | 说明 |
-|------|------|------|
-| GET | `/hotel/open/scan?shortCode=xxx&sign=xxx` | 扫码查询二维码信息 |
-
----
-
-## 六、二维码 URL 格式
-
-```
-{baseUrl}/#/pages/hotel/scan-bind?c={shortCode}&t={tenantId}&s={sign}
-```
-
-| 参数 | 说明 |
-|------|------|
-| c | 二维码短码(8位) |
-| t | 租户ID |
-| s | MD5签名值 = MD5(shortCode + tenantId + secret) |
-
-**签名密钥配置**:
-- 默认值: `hotel_qr_default_secret`
-- 生产环境: 通过环境变量 `HOTEL_QR_SECRET` 覆盖
-
----
-
-## 七、关键业务逻辑
-
-### 7.1 批量生成
-- 生成数量:10/50/100/200 个一批,上限 500
-- 短码:8位大写字母+数字,租户内唯一
-- 批次号:QR + yyyyMMddHHmmss
-- 状态:默认启用(status=0)
-
-### 7.2 绑定/重绑/解绑
-- **绑定**:未绑定的二维码可绑定房间,记录绑定人/绑定时间
-- **重绑**:已绑定的二维码可重新绑定,覆盖原绑定关系,更新绑定人/绑定时间
-- **解绑**:清除绑定关系,记录解绑人/解绑时间(不覆盖绑定信息)
-- 所有操作均记录到 `hotel_qr_bind_log` 表
-
-### 7.3 批量删除
-- 仅允许删除未绑定房间的二维码
-- 已绑定的二维码拒绝删除,提示先解绑
-- 逻辑删除(del_flag 写入主键ID)
-
-### 7.4 批量下载ZIP
-- 勾选多个二维码,后端生成 PNG 图片打包为 ZIP
-- 文件名以短码命名:`{shortCode}.png`
-- 单条下载:操作列"下载"按钮
-
-### 7.5 扫码查询(开放接口)
-- 免登录,根据 shortCode 查询二维码(忽略租户过滤)
-- 签名校验:MD5(shortCode + tenantId + secret)
-- 使用二维码所属租户 ID 设置租户上下文,执行后续查询
-
----
-
-## 八、环境变量
-
-| 变量名 | 必填 | 默认值 | 说明 |
-|--------|------|--------|------|
-| HOTEL_QR_SECRET | 否 | hotel_qr_default_secret | 二维码签名密钥(生产环境必须修改) |
-
----
-
-## 九、后续扩展预留
-
-| 扩展点 | 说明 |
-|--------|------|
-| 钉钉 H5 集成 | 通过 DingTalk SSO 获取员工身份,调用绑定/重绑接口 |
-| 小程序 openid 绑定 | 在 `/hotel/open/` 下新增小程序专用接口 |
-| 顾客点餐流程 | 扫码后进入点餐 H5/小程序页面 |
-| 房间状态实时推送 | WebSocket 推送房间状态变更 |
-
----
-
-## 十、给 AI 助手的指令
-
-当协助开发酒店模块时,请:
-
-1. **严格遵循规范**: 使用 `@RequiredArgsConstructor`、`RespInfo`、雪花算法ID、Composition API
-2. **使用 AiCrudPage**: 列表页面必须使用 `AiCrudPage` 组件,禁止手动拼接搜索栏
-3. **使用字典组件**: 状态/类型等字段必须使用 `DictTag` / `useDict()`,禁止硬编码
-4. **SQL 写在 XML**: 查询类 SQL 必须写在 Mapper XML 中,禁止 Service 层用 LambdaQueryWrapper
-5. **逻辑删除**: 所有业务表默认逻辑删除,`@TableLogic` 必须显式声明
-6. **租户隔离**: 业务数据 `tenant_id=1`,查询自动追加租户过滤
-7. **参考现有代码**: 查看 `forge-hotel` 模块中已有实现
-
-**禁止**:
--  不使用 `@Autowired`(使用 `@RequiredArgsConstructor`)
-- ❌ 不使用 `DataResult` / `ResponseUtil`(使用 `RespInfo`)
-- ❌ 不使用 UUID / 自增ID(使用雪花算法)
-- ❌ 不在 Service 层构建查询 SQL(写在 Mapper XML)
--  不手动拼接搜索栏(使用 AiCrudPage)
-- ❌ 不硬编码字典值(使用 DictTag / useDict)
-- ❌ 不设 `tenant_id=0`(必须为 1)
-
----
-
-## 十一、文件路径
-
-- 后端模块: `forge-server/forge-business/forge-hotel/`
-- PC前端页面: `forge-admin-ui/src/views/hotel/`
-- H5前端页面: `forge-h5-ui/src/pages/hotel/`
-- API文件: `forge-admin-ui/src/api/hotel.js`
-- 数据库迁移: `forge-server/db/migration/`
-- 开发文档: `forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md`
-
----
-
-**配置版本**: v2.0  
-**维护者**: QoderWork AI Assistant

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 396 - 36
forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md


+ 267 - 0
forge-server/forge-business/forge-hotel/酒店模块小程序化改造汇总.md

@@ -0,0 +1,267 @@
+# 酒店模块小程序化改造汇总
+
+> **文档定位**:本轮改造的**进展汇总 + 理解导读**,回答「这一轮到底改了什么、为什么改、解决了哪些问题、还有什么待办」。
+>
+> 本文档**不是**实现权威。具体实现细节请查同目录四份权威文档(用指针引用,不复制正文):
+> - 代码地图 / 实现进度 → `酒店模块迁移跟踪.md`
+> - 需求缺口 / 补齐排期(第 0~6 批,排期唯一权威)→ `酒店模块需求缺口清单.md`
+> - 二维码与开放接口实现 → `酒店二维码模块开发文档.md`
+> - 支付实现(协议 / 回调 / 幂等 / Mock 红线代码)→ `酒店订单支付系统开发文档.md`
+>
+> **核实基线**:本文所有文件清单、改动性质均以工作区未提交改动的 `git diff` 逐一核实为准,非凭印象。
+
+---
+
+## 0. 元信息
+
+| 项 | 内容 |
+|---|---|
+| 汇总时间 | 2026-09-07 |
+| 覆盖范围 | 工作区当前全部未提交改动(26 个文件,+1366 / -702)+ 2 个新增变更目录 |
+| 关联变更目录 | `code-copilot/changes/hotel-pay-mock-hardening/`、`code-copilot/changes/hotel-alipay-secret-externalize/` |
+| 改造主线 | 酒店点餐系统由「H5 开发态」走向「支付宝小程序上线态」 |
+| 硬约束 | 全程不破坏现有 H5 开发测试(详见第 2.2 节) |
+
+---
+
+## 1. 一句话总纲
+
+本轮所有改动,本质上只服务**一个目标**:让酒店点餐系统能从「H5 开发态」平滑走向「支付宝小程序上线态」,且**全程不破坏现在的 H5 开发测试**。
+
+改动之所以看起来横跨很多层面,是因为它同时触及了**后端 / 前端 / 构建 / 配置 / 文档**五层,但内在逻辑是统一的:
+
+```
+一条主线(小程序载体适配)
+  + 一条安全支线(Mock 支付生产加固)
+  + 两个核实过程中挖出的待决策问题(P0 启动崩溃、密钥明文入库)
+  + 文档同步
+```
+
+---
+
+## 2. 改造背景与约束
+
+### 2.1 业务方向
+
+- 首发**只做支付宝小程序**;微信小程序因商户号未申请,已决策**暂缓上架**,但后端三端共用,WECHAT 渠道仍须显式阻断。
+- **非支付宝 / 微信的扫码器一律不可访问**(两层拦截:入口层 + 接口层)。
+- 资质问题后期补充,本轮只判定并落地**技术可行性**。
+
+### 2.2 硬约束:不影响 H5 开发测试
+
+所有改动只能归入以下**三类手法**,禁止直接替换现有实现:
+
+| 手法 | 说明 | 本轮用例 |
+|---|---|---|
+| 条件编译 | `#ifdef H5` 保留原实现,`#ifndef H5` 走小程序分支 | 前端页面、TabBar、首页入口 |
+| 配置开关 | 生产 `application.yml` 默认严格,开发 `application-dev.yml` 覆盖宽松 | `hotel.qr.path`、`hotel.pay.mock-enabled` |
+| 纯增量 | 新增类 / 方法 / 重载 / 分支,旧签名委托保留 | `QrCodeUtils` 重载、`HotelPayConfig`、`resolveScanParams` |
+
+### 2.3 载体差异(为什么必须适配)
+
+| 能力 | H5 载体 | 小程序载体 |
+|---|---|---|
+| 扫码落地 | hash 路由 `/#/pages/hotel/scan-bind` | 专属前缀 `/r/`(平台规则按 path 前缀匹配拉起) |
+| 员工扫码绑定 | 支持(html5-qrcode + 相机) | 不支持(无 `navigator.mediaDevices`) |
+| 图标渲染 | CSS mask 引用本地 SVG 可换色 | WXSS/ACSS 不支持 mask,需 `<image>` |
+| 扫码入参 | 页面直接拿 `c`/`t`/`s` | 平台把整条 URL 塞进 `q`(微信)/`qrCode`(支付宝) |
+
+---
+
+## 3. 主线:H5 → 支付宝小程序「载体适配」
+
+### 3.1 后端二维码:扫码落地路径可配置
+
+- **解决的问题**:扫码落地路径原来**硬编码**成 H5 的 hash 路由常量,但小程序拉起规则要求专属前缀 `/r/`,两者冲突。
+- **手法**:抽成配置项 `hotel.qr.path`;`QrCodeUtils.buildQrCodeUrl` 新增带 `basePath` 的重载,旧签名委托保留;空值回退默认常量。
+- **关键设计**:二维码 URL **不落库**(`hotel_qr_code` 只存 `short_code` + `sign`,URL 运行时拼接),因此改格式**零数据迁移成本**。
+- **对 H5 影响**:**零**。dev 环境 `hotel.qr.path` 锁定旧值 `/#/pages/hotel/scan-bind`,既有二维码与扫码流程完全不变。
+
+| 文件 | 改动 |
+|---|---|
+| `QrCodeUtils.java` | 新增带 `basePath` 重载(+35),旧签名委托 |
+| `HotelQrCodeServiceImpl.java` | 注入 `qrBasePath`,2 处调用改用重载(+14) |
+| `HotelQrConstants.java` | `QR_BASE_PATH` 补注释说明其为回退值(+8,值不变) |
+
+### 3.2 前端:双载体条件编译
+
+- **解决的问题**:小程序不支持 H5 的相机扫码页、CSS mask SVG 换色、员工扫码绑定页。
+- **手法**:用 `#ifdef H5` / `#ifndef H5` 把仅 H5 的能力裁掉,H5 分支原样保留。
+- **对 H5 影响**:**零**。H5 构建走原分支,行为不变;小程序构建裁掉不适用部分。
+
+| 文件 | 改动 |
+|---|---|
+| `pages.json` | `scan-bind` / `qr-scanner` 两页包进 `#ifdef H5`(+7),小程序不注册 |
+| `index/index.vue` | 扫码绑定入口、权限常量、路由白名单、快捷跳转全部 `#ifdef H5`(+25) |
+| `HotelTabBar.vue` | 图标渲染 `#ifndef H5` 改用 `<image>` + opacity 区分激活态(+17) |
+
+### 3.3 前端:小程序扫码入参解析
+
+- **解决的问题**:小程序扫「普通链接二维码」时,平台**不拆 query**,而是把整条 URL 塞进 `q`(微信,已 encodeURIComponent)/`qrCode`(支付宝) 字段,原解析逻辑取不到 `c`/`s`。
+- **手法**:`room-confirm.vue` 新增 `resolveScanParams()`,优先级「显式 c/s > 微信 q > 支付宝 qrCode」,用与 `scan-bind.vue` 一致的正则口径解析。
+- **对 H5 影响**:**零**。H5 仍走「显式 c/s 透传」分支,`resolveScanParams` 对 H5 是恒等返回。
+
+| 文件 | 改动 |
+|---|---|
+| `room-confirm.vue` | 新增 `resolveScanParams()`,`onMounted` 改用它解析(+51) |
+
+### 3.4 构建:支付宝小程序可打包
+
+- **解决的问题**:支付宝小程序无法打包——`dev/build:mp-alipay` 命令缺 `--mode`,`manifest.json` 缺 appid 占位。
+- **手法**:补构建参数 + appid 空占位(待企业资质下来再填)+ 新增专属环境变量文件。
+- **对 H5 影响**:**零**。仅新增 mp-alipay 相关脚本与配置,H5 构建链不受影响。
+
+| 文件 | 改动 |
+|---|---|
+| `package.json` | `dev:mp-alipay` / `build:mp-alipay` 补 `--mode mp-alipay`(+4) |
+| `manifest.json` | `mp-alipay.appid` 空占位 + 说明注释(+4) |
+| `.env.mp-alipay` | 新增,支付宝小程序构建环境变量 |
+
+---
+
+## 4. 安全支线:MOCK 模拟支付「生产加固」
+
+- **背景**:AGENTS.md 5.9 资金红线。开发调试靠 MOCK 模拟支付(点一下就「支付成功」),若该通道在生产也能走,等于**任何人不用真付钱就能把订单置为已支付**。
+- **原隐患**:`/hotel/open/pay/mockSuccess` 落在 Sa-Token 白名单 `/hotel/open/**` 内**完全免登录**,且 `createPay` 的 `paySource` 默认值就是 `"MOCK"`,加上 `resolvePayChannel` 两处静默降级——构造请求即可免付款。
+
+| 加固点 | 手法 |
+|---|---|
+| 单一收口 | 抽 `mockOrReject()`:`mock-enabled=true` 返回 `MOCK`,否则抛 `BusinessException` |
+| 开关控制 | 新增 `HotelPayConfig` 读 `hotel.pay.mock-enabled`(生产 `false` 严格、dev `true` 宽松) |
+| 接口层拦截 | `mockSuccess` 首行拦截;`createPay` 的 `paySource` 去掉 `defaultValue` |
+| 渠道显式判定 | WECHAT / 未知渠道显式抛异常,不再降级为 MOCK |
+| 退款加固 | `refundOrder` 改显式判 `"MOCK".equals(payChannel)`,非 ALIPAY 非 MOCK 抛异常要求人工介入 |
+| 前端兜底禁止 | `pay.vue` 的 else MOCK 分支「拆分」而非「删除」:`else if (payChannel==='MOCK')` 保留 dev 调试逻辑,新增 else 显式 `showModal` 报错,**禁止兜底调 mockSuccess** |
+| 日志脱敏 | `AlipayAuthController.maskPhone`、`AlipayAuthServiceImpl` 只记 `responseLength` |
+
+- **对 H5 影响**:**零**。dev 环境 `mock-enabled: true`,现有模拟支付调试行为完全不变。
+
+| 文件 | 改动 |
+|---|---|
+| `HotelPayConfig.java` | 新增,读 `hotel.pay.mock-enabled` |
+| `HotelPayServiceImpl.java` | `mockOrReject` 收口 + `resolvePayChannel` + `refundOrder` 渠道判定(+80) |
+| `HotelPayController.java` | `paySource` 去默认值 + `mockSuccess` 首行拦截(+19) |
+| `AlipayAuthController.java` | 手机号脱敏 `maskPhone`(+16) |
+| `AlipayAuthServiceImpl.java` | 日志只记 `responseLength`(+3) |
+| `pay.vue` | else MOCK 分支拆分(+20) |
+
+---
+
+## 5. 配置层设计(「不影响 H5」的关键)
+
+同一段 `hotel:` 配置,在**生产**和**开发**两份文件里**故意不同**:
+
+| 配置项 | 生产 `application.yml` | 开发 `application-dev.yml` |
+|---|---|---|
+| `qr.path` | `${FORGE_HOTEL_QR_PATH:/r/}`(小程序前缀) | `/#/pages/hotel/scan-bind`(**H5 旧值不变**) |
+| `qr.base-url` | `${FORGE_HOTEL_QR_BASE_URL:}`(空串,靠环境变量注入) | `http://192.168.10.4:3001`(**局域网 IP 不变**) |
+| `pay.mock-enabled` | `${FORGE_HOTEL_PAY_MOCK_ENABLED:false}`(严格禁止) | `true`(**模拟支付调试不变**) |
+
+- **结论**:生产收紧、开发保持原样。**现在 H5 怎么测,改完之后还怎么测,一模一样。**
+- 涉及 6 份 yml:admin-server 与 app-server 各 3 份(`application.yml` / `application-dev.yml` / `application-dev.example.yml`)。
+- 环境变量名易错点:是 `FORGE_HOTEL_PAY_MOCK_ENABLED`,**不是** `FORGE_HOTEL_PAY_MOCK`(写错不报错,只静默退回 `false`)。
+
+---
+
+## 6. 核实过程中的两个观察(已更正)
+
+### 6.1 【已撤销】生产 profile 启动崩溃 — 判断错误,当前不会崩
+
+- **原判断**:`AlipayConfig` 5 个 `@Value` 无默认值 + 生产 `application.yml` 无 `hotel.alipay` 块 → 生产启动崩溃(P0)。
+- **更正**:`application.yml` L42-43 `spring.profiles.active: dev`,且 docker-compose.yml / .env.example **无任何 profile 覆盖**,项目也**没有** `application-prod.yml`。因此当前启动(包括 docker 部署)都会加载 `application-dev.yml`,`hotel.alipay.*` 全部有值,**不会崩**。
+- **我错在哪**:把 `application.yml` 当成「生产专用配置」,忽略了它 L43 默认激活 dev profile,`application-dev.yml` 会被合并加载。
+- **连带更正**:我上一批往 `application.yml` 加 `hotel:` 块时写的注释「缺此项会导致生产 profile 启动失败」(L227)也是基于同样的错误前提。实际效果:因为 dev.yml 覆盖,`application.yml` 里的 hotel 块**当前不生效**(无害,但也无「修复崩溃」的作用)。
+- **真正的配置架构观察**:本项目**没有独立的生产 profile 配置**,所有环境(含 docker 部署)都用 dev profile,`application-dev.yml` 承载了全部实际连接配置(数据库、Redis、支付宝沙箱)。若未来要建立独立生产 profile,需要同步建立 `application-prod.yml` 并提供所有必要配置。
+
+### 6.2 【已决策接受】支付宝沙箱密钥明文入库
+
+- **现象**:`application-dev.yml` 里的支付宝私钥 / 公钥是**明文**,且已随 commit `65508d9` 推送到远端 `origin/master`。
+- **用户决策**:git 是**内网私有仓库**(192.168.8.4),不在外网;密钥是沙箱密钥;**后期如有必要,会直接不提交生产的密钥**。
+- **处置**:本变更关闭(`hotel-alipay-secret-externalize` 不再推进)。后续生产密钥不入库由用户把控。
+- **注意**:本汇总文档**不含任何真实密钥字符**,避免二次扩散。
+
+---
+
+## 7. 文档同步与变更目录
+
+| 动作 | 对象 | 说明 |
+|---|---|---|
+| 删除 | `forge-hotel/酒店AGENTS.md`(-420)、`forge-h5-ui/AGENTS.md`(-19) | 废弃版本,用户确认删除 |
+| 移动 | `酒店订单支付系统开发文档.md` | 由仓库根 `git mv` 移入 `forge-hotel/` 目录 |
+| 同步 | 迁移跟踪 / 二维码开发文档 / 需求缺口清单 / 支付系统文档 | 回填本轮改动、行号纠错、新发现登记 |
+| 新建变更 | `code-copilot/changes/hotel-pay-mock-hardening/` | `spec.md` + `tasks.md`,HARD-GATE 留空待签 |
+| 新建变更 | `code-copilot/changes/hotel-alipay-secret-externalize/` | `spec.md`,`status: propose`,只写方案 |
+
+---
+
+## 8. 全量文件改动清单
+
+### 后端代码(8)
+
+| 文件 | 主题 | 性质 |
+|---|---|---|
+| `QrCodeUtils.java` | 二维码路径可配置 | 增量重载 |
+| `HotelQrCodeServiceImpl.java` | 二维码路径可配置 | 注入 + 调用改重载 |
+| `HotelQrConstants.java` | 二维码路径可配置 | 注释补充(值不变) |
+| `HotelPayConfig.java` | Mock 支付加固 | 新增类 |
+| `HotelPayServiceImpl.java` | Mock 支付加固 | 收口 + 渠道判定 |
+| `HotelPayController.java` | Mock 支付加固 | 接口层拦截 |
+| `AlipayAuthController.java` | 日志脱敏 | 手机号掩码 |
+| `AlipayAuthServiceImpl.java` | 日志脱敏 | 只记长度 |
+
+### 配置(6)
+
+| 文件 | 主题 | 性质 |
+|---|---|---|
+| admin/app `application.yml` | 主配置 hotel 块 | 新增(qr + pay),当前被 dev.yml 覆盖不生效 |
+| admin/app `application-dev.yml` | 开发宽松值 | 新增 qr.path 旧值 + mock-enabled=true |
+| admin/app `application-dev.example.yml` | 模板同步 | 补齐 path + pay 块 |
+
+### 前端 H5(8)
+
+| 文件 | 主题 | 性质 |
+|---|---|---|
+| `package.json` | 小程序构建 | 补 `--mode mp-alipay` |
+| `manifest.json` | 小程序构建 | appid 空占位 |
+| `.env.mp-alipay` | 小程序构建 | 新增 |
+| `pages.json` | 载体裁剪 | `#ifdef H5` |
+| `index/index.vue` | 载体裁剪 | `#ifdef H5` 入口 |
+| `HotelTabBar.vue` | 图标适配 | `#ifndef H5` 用 image |
+| `room-confirm.vue` | 扫码入参 | 新增 `resolveScanParams` |
+| `pay.vue` | Mock 加固 | else 分支拆分 |
+
+### 文档(5 + 2 目录)
+
+见第 7 节。
+
+---
+
+## 9. 对 H5 开发测试的影响评估
+
+**总结论:零影响。** 逐项核对:
+
+| 你现在的 H5 行为 | 改造后 | 保证手法 |
+|---|---|---|
+| 扫二维码进员工绑定页 | 不变 | dev `qr.path` 锁旧值 |
+| 局域网 IP + 端口访问 | 不变 | dev `qr.base-url` 保持 `http://192.168.10.4:3001` |
+| 点「模拟支付」调试下单 | 不变 | dev `mock-enabled: true` |
+| 首页扫码绑定入口 | 不变 | `#ifdef H5` 保留 |
+| TabBar 图标换色 | 不变 | `#ifdef H5` 走 mask 原实现 |
+| 员工相机扫码 | 不变 | 两页仅 H5 注册 |
+
+---
+
+## 10. 待办事项(需要用户决策)
+
+1. **签 HARD-GATE**:读 `hotel-pay-mock-hardening/spec.md` 第 8.1 节(3 个资金审查点)+ 第 9 节 Q1~Q3,手填第 13 节确认时间 / 确认人(AI 不代签)。
+2. **生产部署方式确认**:当前项目所有环境(含 docker)都用 dev profile,`application-dev.yml` 的 `mock-enabled: true` 会覆盖 `application.yml` 的 `false`。若直接拿 dev 配置上生产,mock 支付是开着的。请确认上生产前是否会建独立 prod profile / 改 dev 值 / 设环境变量覆盖。
+
+> ⚠ `hotel-alipay-secret-externalize` 变更已关闭(用户已决策接受,后期生产密钥不提交)。
+
+---
+
+## 附:本文档维护说明
+
+- 本文档是**小程序化改造这一持续任务**的阶段性进展汇总,后续批次(第 3~6 批:MP 身份体系、支付宝 `trade.create`、图片绝对路径、上线资质切换)落地后可继续更新。
+- 具体实现以四份权威文档为准;本文只负责「整体视图 + 问题-解决对照 + 待办跟踪」。
+- 排期与缺口的**唯一权威**是 `酒店模块需求缺口清单.md` 第 0~6 批,本文不重复维护排期。

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 103 - 36
forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 221 - 80
forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md