浏览代码

feat(汇安融): 门店省市目录只读端点 + 建表脚本(三)—— 给运营一个录地区的入口

上一刀(32384b2b)解决了「按顾客 IP 怎么挑店」,但没解决「地区从哪来」:门店自
2026-10-08 起必填 province_code/city_code(规范 261008 5.5),而运营在页面上**没有
录地区的入口** ⇒ 没有一家门店带地区,每一笔收款链接下单都会以「该地区没开通」拒收。
这一刀补上入口:

- GET /payment/har/region/provinces | /cities —— 吃的是打进包里的 HarRegionCatalog,
  与挑店比对用的是**同一份**目录,所以页面选出来的编码不会与路由用的编码分叉;
- db/har-shop-region.sql —— pay_har_shop 加 province_code/city_code 两列,
  外加挑店查询要用的 merchant_code 索引。

三条不显然但必须如此的:

- 省码不认识就 400,**不回空表**:空表在下拉框里与「这个省真的没有市」长得一模一样,
  而这里只有一种可能 —— 调用方拿了个目录里没有的码(前端存了旧表、或手改了 URL);
- 权限串是 shop:list 与 shop:create **两条** hasAny:只认 :create 的话,「能看门店页
  但不建店」的账号一打开页面就 403;两个端点同一条串 —— 选择器两跳要么都通要么都不通,
  半通的下拉框是最难查的那一类工单;
- 单独一个控制器,不塞进 HarShopController:那边的 /{id} 会把 regions 当 Long 解析,
  得到一个类型转换 400。

⚠ 部署顺序:**先执行 db/har-shop-region.sql,再上代码**。反了不是静默降级而是直接报错 ——
实体带上这两列后,门店列表与挑店的每一句 SELECT 都会取这两列(门店列表 500,
且每一笔收款链接下单都失败)。
⚠ 存量门店补不上:上游没有门店更新接口,2026-10-08 之前建的门店这两列永远是 NULL,
从本脚本执行起它们不再能用于收款链接收款,要收款只能换新的 third_shop_id 重开。

验证:HarRegionControllerTest 5/5 绿(含权限串逐字断言与「400 不是空表」)。变异验证两向:
3 个变异体(省码不校验 / 用空表代替 400 / 权限串只留 :create)预测红集 3 条,实测恰为那 3 条,
2 条对照仍绿;还原后 sha256 与变异前一致。整包 mvn test 1212/1212 绿(BUILD SUCCESS,
HarCheckoutControllerTest 24 + HarRegionControllerTest 5 都在内)。
alphaH 1 周之前
父节点
当前提交
c47b0c63aa

+ 90 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarRegionController.java

@@ -0,0 +1,90 @@
+package com.payment.platform.module.payment.har.controller;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarRegionVO;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 省市目录(只读)—— 门店表单的级联选择器吃这两个端点。
+ *
+ * <h2>它为什么存在</h2>
+ * 门店自 2026-10-08 起必填省份/市(规范 5.5),而收款链接下单要按顾客 IP 挑同地区门店
+ * ⇒ **不给运营一个录地区的入口,整个功能就是死的**:没有门店带地区,每一笔收款链接下单
+ * 都会以「该地区没开通」拒收。
+ *
+ * <p>数据源是 {@link HarRegionCatalog}(打进包里的快照,同时也是顾客 IP 匹配用的那一份)
+ * ⇒ 页面选出来的编码与挑店比对的编码**同源**,不会各走各的。前端不要自己存一份省市表。
+ *
+ * <h2>三条实现上的规矩</h2>
+ * <ol>
+ *   <li><b>独立控制器</b>:不塞进 {@link HarShopController} —— 那边的 {@code /{id}}
+ *       会把 {@code regions} 当 {@code Long} 解析,得到的是一句类型转换 400;</li>
+ *   <li><b>不碰库、不涉租户</b>:目录是内存快照,一次库操作都没有,所以不需要租户上下文,
+ *       也不在 {@code TenantInnerInterceptor} 的管辖范围内;</li>
+ *   <li><b>省码不认识就 400,不回空表</b>:空表在下拉框里与「这个省真的没有市」
+ *       长得一模一样,而这里只有一种可能 —— 调用方拿了个我们目录里没有的码。
+ *       措辞与 {@code HarShopService#requireKnownCity} 同源。</li>
+ * </ol>
+ *
+ * <h2>权限串为什么是两条</h2>
+ * {@code shop:create}(在建店表单里用下拉的人)与 {@code shop:list}(能看门店页的人)都放行:
+ * 只认 {@code :create} 的话,「能看门店页但不建店」的账号打开页面会拿到 403。两个串在
+ * {@code db/har-menu.sql} 里本来就是一起下发的,这里多写一条只是把那个例外兜住。
+ * ★ 两个端点用**同一条**串:选择器的两跳要么都通、要么都不通。
+ */
+@Tag(name = "汇安融-省市目录")
+@RestController
+@RequestMapping("/payment/har/region")
+@RequiredArgsConstructor
+public class HarRegionController {
+
+    /**
+     * 两个端点共用的权限串。写成常量而不是两处字面量:编进注解是允许的
+     * (编译期常量),而「两跳权限不一致」是那种只在一半用户身上复现的故障。
+     */
+    private static final String PERM =
+            "@perm.hasAny('module_payment:har:shop:list','module_payment:har:shop:create')";
+
+    private final HarRegionCatalog regionCatalog;
+
+    /** 省级 31 个(港澳台不在内,见 {@link HarRegionCatalog} 的 javadoc) */
+    @GetMapping("/provinces")
+    @Operation(summary = "省级列表(门店表单级联选择器)")
+    @PreAuthorize(PERM)
+    public Result<List<HarRegionVO>> provinces() {
+        return Result.ok(regionCatalog.provinces().stream().map(HarRegionVO::of).toList());
+    }
+
+    /**
+     * 某省的地级列表。
+     *
+     * @param provinceCode 两位省码,必须是上面那个列表里给出的值
+     * @throws BusinessException 400 —— 空白或不在目录里(**不回空表**,理由见类 javadoc)
+     */
+    @GetMapping("/cities")
+    @Operation(summary = "某省的地级列表(门店表单级联选择器)")
+    @PreAuthorize(PERM)
+    public Result<List<HarRegionVO>> cities(@RequestParam(name = "province_code") String provinceCode) {
+        if (StrUtil.isBlank(provinceCode)) {
+            throw new BusinessException(400, "省份编码不能为空");
+        }
+        String code = provinceCode.trim();
+        boolean known = regionCatalog.provinces().stream().anyMatch(p -> code.equals(p.code()));
+        if (!known) {
+            throw new BusinessException(400, "省份编码不存在: " + provinceCode);
+        }
+        return Result.ok(regionCatalog.cities(code).stream().map(HarRegionVO::of).toList());
+    }
+}

+ 39 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarRegionVO.java

@@ -0,0 +1,39 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
+import lombok.Data;
+
+/**
+ * 一条地区记录 —— 省或地级,两种共用一个形状(都只有「码 + 名字」两格)。
+ *
+ * <p>前端门店表单的级联选择器吃它:{@code code} 是提交回 {@link HarShopCreateDTO} 的值,
+ * {@code name} 是给人看的。**没有第三格**(父级码之类):市列表本身就是按省查出来的,
+ * 再带一个省码回去是冗余,而冗余的那一格迟早会与请求参数不一致。
+ *
+ * <p>★ 只有这两格是因为:门店创建在上游是「一次成功、之后没有更新接口」的,
+ * 编码写错的门店永远挑不中 —— 所以这层能做的就是把目录的原文照搬给页面,
+ * 不加工、不缩写。
+ */
+@Data
+public class HarRegionVO {
+
+    /** GB/T 2260 编码:省两位({@code 32}),地级四位({@code 3201}) */
+    private String code;
+
+    /** 展示名(目录里那份,直辖市的地级是占位名换成的省名,见 HarRegionCatalog 的 javadoc) */
+    private String name;
+
+    public static HarRegionVO of(HarRegionCatalog.Province p) {
+        HarRegionVO vo = new HarRegionVO();
+        vo.setCode(p.code());
+        vo.setName(p.name());
+        return vo;
+    }
+
+    public static HarRegionVO of(HarRegionCatalog.City c) {
+        HarRegionVO vo = new HarRegionVO();
+        vo.setCode(c.code());
+        vo.setName(c.name());
+        return vo;
+    }
+}

+ 56 - 0
java/src/main/resources/db/har-shop-region.sql

@@ -0,0 +1,56 @@
+-- pay_har_shop 新增 province_code / city_code(2026-10-08,规范 261008 5.5 + 异地收款风控)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- 《支付宝支付接口接入规范261008》门店创建(kf.har.shop.create)参数表新增了两行必填:
+--     province_code | 省份编码 | varchar(16) | √
+--     city_code     | 市编码   | varchar(16) | √
+-- 而支付宝对**异地收款**有风控 ⇒ 收款链接下单时要按顾客 IP 挑一家同地区的门店去
+-- precreate。挑店读的就是下面这两列(与 HarRegionCatalog 的 GB/T 2260 编码对上),
+-- 所以它们既是「发上游的必填」,也是「我们自己路由的键」。
+--
+-- 列宽 16 取自规范。两列**都不加 NOT NULL**:见下面那条「存量补不上」。
+--
+-- ── ⚠ 存量门店补不上,只能重开 ────────────────────────────────────────
+-- 上游**没有**门店查询/更新接口(见 V1.8 建表处注释与 HarShopEntity 的 javadoc)
+-- ⇒ 2026-10-08 之前建的门店,这两列永远是 NULL,无法回填。
+-- 它们在挑店时会被当成「地区不匹配」而**落选** —— 这是刻意的:宁可拒收,不可把
+-- 顾客导到异地门店(那正是这套东西要规避的风控)。
+-- 换句话说:**存量门店从本脚本执行起就不再能用于收款链接收款**,要收款得重新建店
+-- (换一个新的 third_shop_id)。
+--
+-- ── ⚠ 顺序:先执行本脚本,再部署代码 ──────────────────────────────────
+-- 反了的话不是「静默降级」而是**直接报错**:实体带上这两列后,门店列表与挑店
+-- 的每一句 SELECT 都会取这两列,列不存在 ⇒ 门店列表 500、且**每一笔收款链接下单
+-- 都失败**。顺序不能反。
+--
+-- ── 执行 ─────────────────────────────────────────────────────────────
+ALTER TABLE pay_har_shop ADD COLUMN IF NOT EXISTS province_code varchar(16);
+ALTER TABLE pay_har_shop ADD COLUMN IF NOT EXISTS city_code varchar(16);
+
+COMMENT ON COLUMN pay_har_shop.province_code IS
+    'GB/T 2260 两位省码(32=江苏省)。发上游 province_code(必填),也是按顾客 IP 挑店的匹配键。2026-10-08 之前建的门店为空,且无法回填';
+COMMENT ON COLUMN pay_har_shop.city_code IS
+    'GB/T 2260 四位市码(3201=南京市)。发上游 city_code(必填),见 province_code';
+
+-- 挑店那句查询按 merchant_code 过滤(租户谓词由拦截器另加)—— 它在收款链接的
+-- **每一笔支付**上执行,给个索引免得随门店铺开变成顺扫。
+CREATE INDEX IF NOT EXISTS idx_har_shop_merchant_code ON pay_har_shop (merchant_code);
+
+-- ── 只读核对(执行后跑)──────────────────────────────────────────────
+-- ① 列在不在、宽对不对
+-- SELECT column_name, data_type, character_maximum_length, is_nullable
+--   FROM information_schema.columns
+--  WHERE table_name = 'pay_har_shop' AND column_name IN ('province_code','city_code');
+-- 期望:两行,16,YES
+--
+-- ② 索引在不在
+-- SELECT indexname FROM pg_indexes
+--  WHERE tablename = 'pay_har_shop' AND indexname = 'idx_har_shop_merchant_code';
+-- 期望:一行
+--
+-- ③ ★ 有多少存量门店会因缺地区而落选(记下这个数字,它就是要重开的店数)
+-- SELECT count(*) AS 缺地区的店 FROM pay_har_shop
+--  WHERE province_code IS NULL OR city_code IS NULL;

+ 177 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/HarRegionControllerTest.java

@@ -0,0 +1,177 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarRegionVO;
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 管理端「省市目录」两个只读端点 —— 门店表单的级联选择器吃它们。
+ *
+ * <h2>它为什么必须有(而不是「前端自己存一份省市表」)</h2>
+ * 门店自 2026-10-08 起**必填**省份/市(规范 5.5),而收款链接下单时要按顾客 IP 挑一家
+ * 同地区的门店 —— 也就是说,**不给运营一个能录地区的入口,这个功能就是死的**:
+ * 没有一家门店带地区 ⇒ 每一笔收款链接下单都会以「该地区没开通」拒收。
+ *
+ * <p>两份数据必须同源:前端自己存一份省市表,就会与后端那份(同时也是与顾客 IP 匹配的
+ * 那一份)**各走各的**,某天数据集更新后,运营在页面上选出来的编码会成为「永远挑不中」
+ * 的那种门店 —— 而上游没有门店更新接口,补不上。所以前端吃目录,目录只有一份。
+ *
+ * <h2>为什么单独一个控制器,不塞进 HarShopController</h2>
+ * 那边的 {@code @GetMapping("/{id}")} 会把 {@code /regions} 当成一个 {@code Long} 路径变量
+ * 去解析 ⇒ 结果是一句类型转换的 400,而不是这个目录。路径要独立。
+ *
+ * <h2>权限串为什么是两条</h2>
+ * 见 {@link #contract_pathAndPermissionAreTheOnesTheFrontendCalls()}。
+ *
+ * <h2>不碰库、不涉租户</h2>
+ * 目录是打进包里的内存快照({@link HarRegionCatalog}),两个端点**一次库操作都没有** ——
+ * 所以既不需要租户上下文,也不在 {@code TenantInnerInterceptor} 的管辖范围内。
+ */
+class HarRegionControllerTest {
+
+    /** 真目录:它就是这个端点的数据源,mock 掉等于把被测行为换成测试自己写的剧本 */
+    private final HarRegionCatalog catalog = new HarRegionCatalog();
+    private final HarRegionController controller = new HarRegionController(catalog);
+
+    /** 按码取名字;取不到就把**实际拿到的码**全打出来 —— 否则失败信息只有一句「没有 32」 */
+    private static String nameOf(List<HarRegionVO> rows, String code) {
+        return rows.stream()
+                .filter(v -> code.equals(v.getCode()))
+                .map(HarRegionVO::getName)
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("目录里没有 " + code
+                        + ";实际拿到的码=" + rows.stream().map(HarRegionVO::getCode).toList()));
+    }
+
+    // ─────────────────────────── ① 省 ───────────────────────────
+
+    @Test
+    void provinces_returnsTheWholeMainlandCatalog() {
+        Result<List<HarRegionVO>> r = controller.provinces();
+        List<HarRegionVO> rows = r.getData();
+
+        assertEquals(0, r.getCode(), "0 = 成功(Result.ok 的口径)");
+        assertEquals(31, rows.size(),
+                "省级 31 个。港澳台不在内是有意的:ipwho.is 对它们的 country_code 不是 CN,"
+                        + "解析那一步就先判掉了(见 HarIpRegionResolver 的 javadoc)");
+        // 抽三个点:首都、本功能最常用的那个省、名字最长的一个(自治区的归一化专挑它)
+        assertEquals("北京市", nameOf(rows, "11"));
+        assertEquals("江苏省", nameOf(rows, "32"));
+        assertEquals("新疆维吾尔自治区", nameOf(rows, "65"));
+        assertTrue(rows.stream().allMatch(v -> v.getCode() != null && v.getCode().length() == 2),
+                "两位省码是 GB/T 2260 的形状;选择器回填的就是这一格,缺一个字符都拼不上");
+        assertTrue(rows.stream().allMatch(v -> v.getName() != null && !v.getName().isBlank()),
+                "名字要直接上屏,空名字在下拉里是一个看不见的空行");
+    }
+
+    // ─────────────────────────── ② 市 ───────────────────────────
+
+    @Test
+    void cities_returnsOnlyThatProvincesCities() {
+        Result<List<HarRegionVO>> r = controller.cities("32");
+        List<HarRegionVO> rows = r.getData();
+
+        assertEquals(0, r.getCode());
+        assertEquals(13, rows.size(), "江苏 13 个地级");
+        assertEquals("南京市", nameOf(rows, "3201"));
+        assertTrue(rows.stream().allMatch(v -> v.getCode().length() == 4),
+                "四位市码");
+        assertTrue(rows.stream().allMatch(v -> v.getCode().startsWith("32")),
+                "返回的必须是**该省**的市:这是「按省取市」这件事本身");
+
+        // ★ 正对照:3401(合肥)在目录里确实存在 —— 少了这一句,上面那条否定断言
+        //   在「目录里根本没有 3401」时也会绿(那就是一条空断言)。
+        assertTrue(catalog.cities("34").stream().anyMatch(c -> "3401".equals(c.code())),
+                "正对照:3401 是安徽的合肥市,目录里有");
+        assertFalse(rows.stream().anyMatch(v -> "3401".equals(v.getCode())),
+                "安徽的市不许出现在江苏的列表里");
+
+        // 空白是查询串里最常见的手滑(手填 URL / 复制粘贴带空格)—— 认得出就别报 400
+        assertEquals(13, controller.cities(" 32 ").getData().size(),
+                "带空格的省码要先 trim 再查表,不能直接拿去比对");
+    }
+
+    // ─────────────────────── ③ 参数不对就 400 ───────────────────────
+
+    /**
+     * 省码不认识 ⇒ 400。**不返回空表**:空表在下拉里与「这个省真的没有市」长得一样,
+     * 而这里只有一种可能 —— 调用方传了个我们目录里没有的码(写错了、或前端存了一份旧表)。
+     */
+    @Test
+    void cities_unknownProvinceCode_400_notAnEmptyList() {
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.cities("99"));
+
+        assertEquals(400, ex.getCode());
+        assertEquals("省份编码不存在: 99", ex.getMessage(),
+                "措辞与 HarShopService#requireKnownCity 同源 —— 同一类错误在两处该是同一句话");
+    }
+
+    @Test
+    void cities_blankProvinceCode_400() {
+        assertEquals(400, assertThrows(BusinessException.class, () -> controller.cities(null)).getCode());
+        assertEquals(400, assertThrows(BusinessException.class, () -> controller.cities("   ")).getCode(),
+                "空白串不能当成一个「码」去查表(空格不是省码,查不到也不该静默回空表)");
+        assertEquals("省份编码不能为空",
+                assertThrows(BusinessException.class, () -> controller.cities(null)).getMessage());
+    }
+
+    // ─────────────────────── ④ 接口形状(契约) ───────────────────────
+
+    /**
+     * 前端要调的就是这两个形状,所以路径与方法级授权得有个钉住的地方。
+     *
+     * <p>★ 权限串**两条都写进 hasAny**:{@code shop:list}(能看门店页的人)与
+     * {@code shop:create}(在建店表单里要用下拉的人)。只写 {@code :create} 的话,
+     * 「能看门店页但不建店」的账号打开页面同样是 403 —— 而这两个串在菜单 SQL 里
+     * 本来就是一起下发的({@code db/har-menu.sql}),多写一条只是把那个例外兜住。
+     */
+    @Test
+    void contract_pathAndPermissionAreTheOnesTheFrontendCalls() throws Exception {
+        RequestMapping rm = HarRegionController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(rm, "类级 @RequestMapping 不得为空 —— 真实路径拼不出来");
+        assertEquals("/payment/har/region", rm.value()[0]);
+
+        Method provinces = HarRegionController.class.getMethod("provinces");
+        GetMapping gp = provinces.getAnnotation(GetMapping.class);
+        assertNotNull(gp, "省列表必须是 GET");
+        assertEquals(List.of("/provinces"), List.of(gp.value()));
+
+        Method cities = HarRegionController.class.getMethod("cities", String.class);
+        GetMapping gc = cities.getAnnotation(GetMapping.class);
+        assertNotNull(gc, "市列表必须是 GET");
+        assertEquals(List.of("/cities"), List.of(gc.value()));
+
+        // ★ 拼出来的是前端真正要调的那两条(分开断言会在「有人把两段一起改掉」时照样绿)
+        assertEquals("/payment/har/region/provinces", rm.value()[0] + gp.value()[0]);
+        assertEquals("/payment/har/region/cities", rm.value()[0] + gc.value()[0]);
+
+        PreAuthorize pa = provinces.getAnnotation(PreAuthorize.class);
+        PreAuthorize pc = cities.getAnnotation(PreAuthorize.class);
+        assertNotNull(pa, "端点必须挂权限串:不挂就是「登录即可」,任何账号都能读到内部编码表");
+        assertNotNull(pc);
+        // 逐字钉死(不是 contains):串写错是**静默的** —— 超管直通,拿超管跑的用例一条都测不出,
+        // 而普通账号会全线 403,那正好是「门店建不出来」的形态。
+        assertEquals("@perm.hasAny('module_payment:har:shop:list','module_payment:har:shop:create')",
+                pa.value());
+        assertEquals(pa.value(), pc.value(),
+                "两个端点同一条串 —— 选择器的两跳要么都通、要么都不通,半通的下拉框最难查");
+
+        // 其中一条必须与「创建门店」逐字相同(复用既有权限点,不新增菜单 SQL)
+        PreAuthorize shopCreate = HarShopController.class
+                .getMethod("create", HarShopCreateDTO.class)
+                .getAnnotation(PreAuthorize.class);
+        assertNotNull(shopCreate);
+        assertEquals("@perm.hasAny('module_payment:har:shop:create')", shopCreate.value());
+    }
+}