# 汇安融「门店地区 + 按顾客 IP 挑店」交付说明(2026-10-08) 需求原文(用户,指向《支付宝支付接口接入规范261008(1).docx》): 「门店新增了地区相关的字段,需要接上,因为支付宝异地收款会有风控,我们的 checkout 也要做一个调整, 在调起支付时要根据请求者 ip 自动选择对应地区的门店 id,这样才不会风控」 设计裁定(本批开工前已与你确认,未改):IP 归属地取 ipwho.is(进程内缓存,失败⇒空); 粒度 = 城市精确匹配、省作回退;匹配不上**拒收**(顾客语,不打上游、不落单);同城多店按 IP 稳定散列挑一家;城市未匹配则该省随机一家。 ★ 这一批**没有写计划文件**(前几批是 `.claude/plan/<日期>-<主题>.md`),需求以 docx + 上述裁定 为准,落地过程与读数都在 `logs/` 下的日志里 —— 这点与既有批次不同,先说明。 ## 一、提交清单(全部**未 push**) `origin/java` 停在 `ed24480f`。本地领先的提交 = **下表 6 个功能/文档刀 + 本说明自身的提交** (`0a07a456` 首次提交,以及本文件每次订正各一刀)—— 不写死总数:本说明自己也在数里, 写完再提交一次就 +1(要读数就现量 `git rev-list --count origin/java..java`)。 | 提交 | 内容 | |---|---| | `b1c8f6fb` | (一)目录快照 `har-region-cn.json`(31 省 / 342 地级 / 166 繁简对)+ `HarRegionCatalog` + `HarIpRegionResolver` + `HarShopService#pickForRegion` + 门店/创建请求带地区 | | `32384b2b` | (二)结账腿接线:`HarCheckoutController` 按顾客 IP 挑店填 `third_shop_code`;挑不出来 400 拒收(两种文案);`clientIp` 四个头 + unknown 兜底 | | `c47b0c63` | (三)省市目录只读端点 `GET /payment/har/region/provinces|cities` + `db/har-shop-region.sql` | | `8e352e7e` | (四)前端:门店表单省→市选择器、列表「地区」列、详情一行;开放 API 文档 §10.1 同步 | | `ccc34414` | 文档 markdown 空行修正(引用块贴着 `---` 会被当成懒惰续行) | | `300a27f9` | (五)编码填错统一 400:`HarShopService#requireKnownCity` 两处单参 `BusinessException`(默认码 500)改显式 400;两条地区拒收用例补码断言(先红后绿);开放文档 §10.1 补码值 | ★ **`b1c8f6fb` 里夹带了一份 `支付宝支付接口接入规范261008(1).docx`(87512 字节)** —— 那是开工时会话开始前就已经 `git add` 进索引的文件,提交时没有指定路径,于是跟着这一刀进去了。 **我没有改写历史**(改写会动到你已经能看到的提交)。**你的裁定是保持现状**(2026-10-08), 本批不再动它:留着无功能影响,摘掉需要 rebase/改写。后续提交都改成 「显式路径 `git add` + `git commit -F -- <显式路径>`」。 ## 二、验证读数(本机实测,不是推断) - 整包 `mvn test`:**1212 条,0 失败 0 错误 0 跳过,BUILD SUCCESS** (★ 两条读数:第一条 12:44:08 在 `300a27f9` 之前;400 订正后 **12:51:21 复跑同数** = 1212/0/0/0,`Total time: 21.430 s`,日志 `logs/_mvn-full-region-code-400.txt` 的 mtime; `HarCheckoutControllerTest` 24 + `HarRegionControllerTest` 5 都在内) - 两向变异(每条都先确认变异体生效,还原后 sha256 复核回基线): | 变异体 | 结果 | |---|---| | 结账腿:4 个(挑到不填 `third_shop_code` / 两句拒收文案合并 / 不取 XFF 第一跳 / 去掉 `unknown` 兜底) | 预测红集 **6**,实测恰为那 6 条,其余 18 条作正对照仍绿;还原后 sha256 与变异前一致(24/0) | | 目录端点:3 个(省码不校验 / 用空表代替 400 / 权限串只留 `:create`) | 预测红集 **3**,实测 `cities_returnsOnlyThatProvincesCities`、`cities_unknownProvinceCode_400_notAnEmptyList`、`contract_pathAndPermissionAreTheOnesTheFrontendCalls`;2 条对照仍绿;还原后 sha256 一致(5/0) | - 前端(**本仓无测试框架**,`ts:check` 基线本就非零 ⇒ 只能同机差分): - `pnpm ts:check`:基线 **289** 条 error TS(exit 2)→ 改动后 **289** 条,**逐条集合比对 新增 0、消失 0**;★ 400 订正(只动一行 md)之后再量一次仍是 **289**(exit 2, `logs/_tscheck-region-code-400.txt`)—— 文档经 `?raw` 进来,不进类型检查 - `pnpm build`:exit 0(12:42 / 12:46 / ★ 400 订正后那次 56.39s、日志 mtime 12:53:05,共三次); 当时那次已回读产物确认新代码真进了包: `dist/js/index.x_9MozyF.js` 含 `region/provinces`、`dist/js/index.SNmFzXAj.js` 含文档新句 「城市编码不属于该省」、`province_code` 出现在 3 个 chunk(★ 这三个 chunk 名只属于当时那一版, 12:53 那版全变了 —— 见上「产物指纹」)(**注意**:一开始我按 `dist/assets/*.js` 搜,3 个都查成 0 —— 是本仓产物在 `dist/js/`,是**检索式错**不是文件缺失,换路径后同一条搜索命中); - Element Plus 用法对着**装好的包**核过(2.14.0 `select.mjs`):`loading: Boolean` 「whether Select is loading data from server」、`disabled: Boolean` —— 不是凭记忆。 - 产物指纹(★ 下面这版是**要部署的**,12:51/12:53 在 `300a27f9` 之后重打): - `java/target/payment-platform-1.0.0.jar`,143,146,853 字节,**sha256 `b6b64c8ad8455d4e1e0639d7d8ad04ea9db347292ec75bb81cb384213379068d`**, 重打于 12:51:49;开包验的是产物本身:从 jar 里取 `HarShopService.class` 反汇编 (`javap -p -c`),两处拒收都是 `sipush 400` → `BusinessException.:(ILjava/lang/String;)V` - `frontend/dist.zip`,9,375,214 字节,**sha256 `80038a66ef9dd73678ec5d9158f66763eef6f4451feb9f3fd9b92d50a2468fa6`**, 12:53:49;281 个条目 = 277 文件 + 4 个目录条目(`dist/`、`dist/css/`、`dist/img/`、`dist/js/`, 与上一版同形:**全为正斜杠**、`testzip()` 无坏成员) - 这两版之前的旧件已被取代,别再照旧指纹核产物: ~~jar `977da280…`(143,146,829 字节,12:45:02)、`dist.zip` `dd4cf524…`(9,375,221 字节)~~ - 文档改了一行 ⇒ dist 里 **103 个 chunk 的文件名哈希跟着变了**(Rollup 的 `[hash]` 含依赖哈希, 一处内容改动脉冲式传播),条目数没变(前后各 277 个文件)。两向读数:旧包里只有旧句 `不属于该省的编码会被拒`(在 `js/index.SNmFzXAj.js`),新包里只有新句 `编码填错一律` (在 `js/index.ruVK6GJ7.js`),互不相见 —— 不是「重建后一律变」的空话 ## 三、部署要求(按顺序,第 1 步不做就没有后面) 1. **先执行 `java/src/main/resources/db/har-shop-region.sql`**(本工程未接 Flyway,只能你执行)。 ★ 反了不是静默降级而是**直接报错**:实体带上 `province_code`/`city_code` 后,门店列表与 挑店的每一句 SELECT 都会取这两列 ⇒ 门店列表 500,且**每一笔收款链接下单都失败**。 2. **重新部署**:上面的 jar + `frontend/dist.zip`。 3. **不需要新菜单 SQL、不需要重登**:目录端点复用门店既有的 `module_payment:har:shop:list` / `:create` 权限串(`@perm.hasAny` 两条),菜单不动。 4. **跑脚本末尾那三条只读核对**,尤其第 ③ 条 —— 它给出的数字就是**要重开的店数**: `province_code`/`city_code` 为空的门店(2026-10-08 之前建的)在挑店时必然落选。 上游没有门店更新接口,**补不上**,要收款只能换新的 `third_shop_id` 重开。 5. 验收路径:门店管理 → 创建门店 → 省/市两级下拉能出目录 → 提交 → 列表「地区」列显示 「江苏省 / 南京市」→ 从**另一个地区**的网络打开该商户的收款链接 → 应当拒收 (「无法确定您当前所在地区…」或「收款方暂未开通您所在地区的收款…」),而不是跑到异地门店。 ## 四、未验项(如实列,别当已验) 1. **前端页面没有执行级验证**:本仓前端没有测试框架、也没有能起真 SFC 的台子,页面还要登录态 才能打开。「选择器真的拉到 31 个省」「换省真的换了市」「列表把码显示成了名」这几步 **一次都没被真跑过** —— 我只能给到「类型零新增 + 构建通过 + 产物里确有这段代码」。 2. **`pay_url` 仍然没有被真机扫过**(上一批就挂着的问题,本批没变);挑店在**真实顾客 IP** 下的表现(真挑到哪个地区的哪家店)没有端到端跑过,只跑到「挑店逻辑 + 端口」这一层。 3. **上游是否真的按地区风控**未知:本批保证的是「我们把对的码发上去、按地区挑店」, 上游收了这两个码之后怎么用,没有可观测的读数。 4. **X-Forwarded-For 可伪造**:除非前置代理覆写它(写在 `HarCheckoutController` 的类 javadoc 「已知的边界」里)。后果是把顾客导到收款方自己的**另一家门店**,不涉资金与租户边界, 要堵得在网关上 —— 本批不动。 5. **`HarCheckoutController` 三个偏差未改**(都在代码注释里标了,属规范与现状不一致, 不是本次引入):`shopName` 我方 `@Size(128)` vs 规范 varchar(16);`thirdShopCode` 32 vs 30; 实体列宽按规范 16 建。 ## 五、两件拍板(你已裁定:① 我解决、② 保持现状) 1. **同类错误码两处不一致 —— 已按裁定修掉(`300a27f9`)**:编码不认识时, `HarRegionController` 返回 `code=400`(两参构造), 而 `HarShopService#requireKnownCity` 返回 **`code=500`**(单参 `BusinessException` 的默认码)。 后者是对外开放 API 也会走到的路径 ⇒ 外部调用方传错码会看到「服务器错误」。 现在两处都是显式 400,**文案一字未动**。读数:给两条地区拒收用例各加一条 `assertEquals(400, e.getCode())` 后实测红(`expected: <400> but was: <500>`,两条都红, 同类其余 16 条绿)→ 改完该件 18/18 绿 → 整包 **1212/0/0/0**(21.4s)。 开放文档 §10.1 那句从「只写 msg 形状、不写码值」改成写死 `code=400`(按上面的实测口径)。 2. **`b1c8f6fb` 里的 docx:保持现状**(按裁定未动,历史未改写)。 3. 顺带说明(**不在本次裁定范围,仍未动**):**`third_shop_id` 重复仍是 `code=500`** —— 它与上面同类,但只有一处入口、且开放文档第 82 行已按 500 写给外部调用方, 改它等于动一条既有对外契约。要统一请单独说。 另:`probe` 结论提醒 —— 顾客端拒收文案能上屏,是因为 `GlobalExceptionHandler` 对 `BusinessException` **不带 `@ResponseStatus`**(HTTP 200 + body `code`),顾客页自建实例 读的是 `res.data.msg`;如果哪天有人把它改成真 4xx/5xx,顾客页会 fallback 成 「网络异常,请检查网络后重试」,那句「你所在地区未开通」就再也到不了顾客眼前。 --- ## 六、2026-10-09 追加:开放 API 下单直传地区码(★ 本节产物指纹取代 §二 的,旧件别再用) **需求(用户原文)**:「还有个点做了没?har的下单开放api,我看文档中没有让调用方传用户ip」 → 定案:「要么让调用方传用户地区也可以,省的我们解析了」→ 硬性要求: 「那就传地区,但是要在开放文档里写清楚地区的格式,要按照我们接收的格式来, 不然找不到门店就麻烦了」。 **为什么不能按 IP**:openapi 是服务器对服务器调用,请求 IP 是**调用方机房**的,不是顾客的 (只有收款链接那条腿能按 IP —— 顾客浏览器直连)。所以本次让调用方直传 **GB/T 2260 省市码** (不传 IP、平台不解析)。 ### 行为口径(实现前已与你确认,未改) 1. 显式 `shop_code` / `third_shop_code` 优先,两个地区字段被忽略(现状不变); 2. 都没给而给了任一地区码 ⇒ 平台按该地区挑一家,填进 `third_shop_code` (**实体与上游请求两处**),响应回显挑中的那家; 3. 市码选填:不给按全省挑;给了但**市内无可用店回退全省**(与收款链接腿同一步梯); 4. **全省无可用店 ⇒ 400 拒收**,先于落库与上游(不留单、不打上游、不发订单信号)—— 相对「静默退回不带门店下单」,后者等于调用方明确要求的地区归属悄悄落空; 5. 只给 `city_code` 不给 `province_code` ⇒ 400(省码是地区定位的根); 6. 同地区多店的稳定散列种子 = **`out_biz_no`**(同一 `out_biz_no` 重复调用挑到同一家)。 ### 提交(现量:`origin/java` 停在 `f09e9cce`;本地领先 **3** 个 —— 含本批之前的测试夹具刀 `2717f793`) | 提交 | 内容 | |---|---| | `cb153add` | 后端:DTO 两个选填字段;`HarShopService#pickForOrder`(校验 + 挑店 + 三种 400,抽出 `requireKnownProvince` 与建店共用同一句文案);`HarOrderService#create` 接线(挑店块 + builder 带 `thirdShopCode`);新契约测试类 `HarOrderCreateJsonContractTest` | | `fcf026c7` | 开放文档 `har-openapi.md` 10.2:字段两行 + 挑店说明块(**格式按接收口径逐字**)+ 响应表 `third_shop_code` 语义订正 + 章首补句 | ### 验证读数(本机实测) - **RED**(先写测试,`logs/_tdd-region-red-1.txt`):三个测试文件红于编译错 —— `pickForOrder` 不存在(8 处)、`setProvinceCode`/`setCityCode` 不存在、 `HarOrderService` 期望 4 参实到 5 参。原因全部是「功能不存在」,无笔误。 - **GREEN 聚焦**(`logs/_tdd-region-green-1.txt`):**86/0/0/0** = 挑店 22(13→22)+ 下单 61(54→61)+ 新契约 3。 - **变异两向**(5 个真实变异 + 1 个无害改动,全部先确认变异体生效、还原后逐字节回基线): | 变异体 | 红集(预测=实测) | |---|---| | 城市码整个忽略 | 4 红(cityTier 挑成 SZ + 三条 400 文案/校验),82 对照绿 | | 显式门店码优先失效 | 2 红(两个 explicit 用例 NPE),84 对照绿 | | 挑不出来不拒收 | 3 红(三条 400 守卫,none thrown),83 对照绿 | | 散列种子换成 enterpriseId | 4 红(四条钉了精确种子的 create 用例),82 对照绿 | | 上游发 dto 的而不是挑中的 | 1 红(「挑中的店必须真的发到上游」NJ→null),85 对照绿 | | 无害重构(守卫提局部变量) | 全绿 86/86 ✓ | 还原复核:`HarShopService` `6341E642…`、`HarOrderService` `41745D49…`、 `HarOrderCreateDTO` `40165A56…`(与变异前一致)。 - **整包**:**1232 条,0 失败 0 错误 0 跳过,BUILD SUCCESS**,32.298s (`logs/_mvn-full-region-order.txt`;1232 = 上一批 1213 + 本批 19)。 - **产物**(★ 取代 §二 的指纹;旧 jar `b6b64c8a…` / 旧 dist `80038a66…` 作废): - `java/target/payment-platform-1.0.0.jar`,143,147,907 字节,12:13:28, sha256 `2F51689B49F8B51D5B890973BEDDF30557A553AD04CA732DA5D5BC2B772133D5`。 开包回读(`logs/_jar-readback-region-order.py`,只读 jar 里的 .class 常量池): `pickForOrder`、`requireKnownProvince`、四条 400 文案前缀、 DTO 的 `provinceCode/cityCode/getProvinceCode/setCityCode` 全为 True。 - `frontend/dist.zip`,9,376,009 字节,12:15:03, sha256 `3cbbdfffae3d6638fc662af1985173b685aafa928caa10c3ea2c4796d0c247b6`。 281 条目 = 277 文件 + 4 目录条目(与上一版**同形**)、**全正斜杠**、`testzip()` 无坏成员; 新文档句(「按地区自动挑店」「就必须一并传 province_code」「该地区没有可用门店」) **只**出现在 `dist/js/index.BRvi1q4X.js` —— 构建产物回读,zip 内也回读到同一文件 (`logs/_dist-zip-region-order.py`)。 - **ts:check 没重跑**:本批只动 md(文档经 `?raw` 导入、类型恒为 string),未新增前端代码 —— 与上批「md 改动不进类型检查」的同一条实测口径。 ### 开放文档写了什么(你点名要的「格式」) 10.2 请求表加 `province_code`/`city_code` 两行,其后一块说明写死:**与 10.1 建店 GB/T 2260 同一套码**(省级两位、地级四位);**传代码不是地名**;`city_code` 必须属于 `province_code`; **只在没传任何门店码时生效**;先同市、市无店回退全省;同地区多店按 `out_biz_no` 稳定; 挑中回显在响应 `third_shop_code`;三种 400 文案逐字(`省份编码不存在: 99` / `城市编码不属于该省: 3301(省 32)` / `省份编码不能为空: 传了 city_code 就必须一并传 province_code` / `该地区没有可用门店: 省 32`(带市码时 `该地区没有可用门店: 3201(省 32)`))—— **这四个字符串与测试里钉的完全同一批**,文档不是手抄的。 ### 部署(与 §三 同序,换新 jar/dist 即可) 1. 先跑 `db/har-shop-region.sql`(若尚未跑;跑过就不必重复); 2. 换 `2F51689B…` 的 jar + `3cbbdfff…` 的 dist.zip; 3. 无需新菜单 SQL、无需重登。 ### 悬置/未验(如实) 1. **收银台腿(`HarCashierService#createQr`)不带门店码,本批未动** —— 它要不要一并接 「地区」或「挑店」,待你拍板(现状=不带,不是回归)。 2. **「全省无店 ⇒ 400 拒收」按已批准设计执行**(未再单独确认过,若你要改成「允许不带门店 直接下单」请说)。 3. 挑店在**真实调用方**下的表现未端到端跑过(开放 API 需要一条真签名请求);本次只到 「单元 + 契约 + 产物回读」这一层。 4. `pay_url` 真机扫码仍未做过(老账,与 §四.2 相同)。