Kaynağa Gözat

feat(收付款): 前端管理端三页 + 接入文档上页面

- views/module_payment/collection/{merchant,order,refund}/ 三个页面
- api/module_payment/collection.ts 管理端接口封装
- apikey/docs/collection-openapi.md 租户接入文档(对外文案已去上游化,泄漏词表实测 0 命中)
- apikey/collectionDoc.ts 按标题把 md 切成 10 条侧边栏条目

⚠ 库里 4 行菜单仍是旧 component_path(指向已改名的 module_payment/leshouying/)
⇒ 不执行 db/lsy-menu-rename.sql 这三个页面会**静默落 404.vue**(前端拼不到组件不报错)。
alphaH 3 hafta önce
ebeveyn
işleme
f790a09ba4

+ 83 - 0
frontend/src/api/module_payment/collection.ts

@@ -0,0 +1,83 @@
+import request from "@/utils/request";
+
+/**
+ * 乐收赢支付通道 —— 管理端接口(Task 17 控制器)。
+ *
+ * ★ 两个容易踩的契约(都会「编译通过、构建通过、运行期静默出错」):
+ *
+ * 1. **查询参数是 camelCase**(`pageNo` / `pageSize`),而**响应字段是 snake_case**。
+ *    不矛盾:Jackson 的 `PropertyNamingStrategies.SNAKE_CASE`(`JacksonConfig`,@Primary ObjectMapper)
+ *    只作用于 JSON body,**不作用于 query string**。三个控制器的 `@RequestParam` 都**没写** `name`,
+ *    故线上参数名就是 Java 形参名 `pageNo` / `pageSize`(`pom.xml` 开了 `-parameters`)。
+ *    ⇒ 参数发 `page_no` 不会报错,会被 `defaultValue` 静默吃掉(永远第 1 页 / 20 条)。
+ *
+ * 2. **响应字段一律 snake_case**(`out_trade_no` / `created_time` / `notify_status` …),
+ *    极少数单词名(`id` / `status` / `total` / `list` / `description`)看不出区别。
+ *    证据:`PageResult` 的 `list` 与 `total` 恰好是单词,所以它们两种写法都一样 ——
+ *    但 `pageNo` 实际是 `page_no`;`BatchOrderEntity.outBatchNo` ↔ `BatchPayList.vue` 读 `out_batch_no`
+ *    是本仓已上线的实证。
+ *
+ * 3. **POST body 也必须 snake_case**(`saveLsyMerchant`):后端 `@RequestBody LsyMerchantEntity`,
+ *    同一个 SNAKE_CASE 策略 ⇒ 键名要 `enterprise_id` / `mch_no` / `shop_no` / `merchant_name`。
+ */
+
+// ==================== 订单 ====================
+export function getLsyOrderPage(params: {
+  pageNo: number;
+  pageSize: number;
+  status?: string;
+  payScene?: string;
+  keyword?: string;
+}) {
+  return request({ url: "/payment/collection/order/page", method: "get", params });
+}
+
+export function getLsyOrderDetail(outTradeNo: string) {
+  return request({ url: `/payment/collection/order/${outTradeNo}`, method: "get" });
+}
+
+export function getLsyNotifyLogs(outTradeNo: string) {
+  return request({
+    url: `/payment/collection/order/${outTradeNo}/notify-logs`,
+    method: "get",
+  });
+}
+
+// ==================== 退款 ====================
+export function getLsyRefundPage(params: {
+  pageNo: number;
+  pageSize: number;
+  status?: string;
+  keyword?: string;
+}) {
+  return request({ url: "/payment/collection/refund/page", method: "get", params });
+}
+
+export function getLsyRefundDetail(outRefundNo: string) {
+  return request({ url: `/payment/collection/refund/${outRefundNo}`, method: "get" });
+}
+
+// ==================== 商户映射 ====================
+export function getLsyMerchantPage(params: {
+  pageNo: number;
+  pageSize: number;
+  keyword?: string;
+}) {
+  return request({ url: "/payment/collection/merchant/page", method: "get", params });
+}
+
+/** 新增/修改:`id` 为空走 insert,非空走 updateById。键名须 snake_case(见文件头 ★ 3) */
+export function saveLsyMerchant(data: Record<string, unknown>) {
+  return request({ url: "/payment/collection/merchant", method: "post", data });
+}
+
+/**
+ * ★ `id` 是**字符串**,不是 number:后端 `LsyMerchantEntity.getId()` 挂了
+ * `@JsonSerialize(ToStringSerializer)`(19 位雪花超 JS 安全整数,number 会在 `JSON.parse`
+ * 那一刻丢精度且不可逆 ⇒ 编辑/删除会打不中目标行)。
+ * 形参类型刻意声明为 `string`:谁想在这里塞 number,先得过类型检查这一关。
+ * 千万不要 `Number(id)`;编辑时把它**原样**回传(后端靠 Jackson 标量强转回 Long)。
+ */
+export function deleteLsyMerchant(id: string) {
+  return request({ url: `/payment/collection/merchant/${id}`, method: "delete" });
+}

+ 111 - 0
frontend/src/views/module_payment/apikey/collectionDoc.ts

@@ -0,0 +1,111 @@
+/**
+ * 把《收付款 OpenAPI 接入文档》按章节切给「接入文档」tab 用。
+ *
+ * 文档本身(`docs/collection-openapi.md`)是唯一真相,本文件只做切片,不复制任何正文 ——
+ * 改 md,页面跟着变,不存在「文档改了页面没改」。
+ *
+ * 切片依据是标题原文,所以 md 里**标题被改字**会让对应条目从侧边栏消失(见下方 absent 逻辑):
+ * 宁可少一条,也不要一个点进去空白的页面。
+ */
+import raw from "./docs/collection-openapi.md?raw";
+
+interface Block {
+  /** 标题层级:`##` 为 2,`###` 为 3 */
+  level: number;
+  /** 标题整行原文,例:`## 5. 接口详情` */
+  title: string;
+  /** 标题之后、下一个标题之前的正文 */
+  body: string;
+}
+
+function parseBlocks(doc: string): Block[] {
+  const blocks: Block[] = [];
+  let current: Block | null = null;
+  for (const line of doc.split("\n")) {
+    const m = /^(#{1,6})\s+(.*)$/.exec(line);
+    if (m) {
+      if (current) blocks.push(current);
+      current = { level: m[1].length, title: line, body: "" };
+    } else if (current) {
+      current.body += line + "\n";
+    }
+  }
+  if (current) blocks.push(current);
+  return blocks;
+}
+
+const BLOCKS = parseBlocks(raw);
+
+/**
+ * 取标题为 title 的块,及其后所有**层级更深**的子块。
+ * 标题不存在时返回空数组。
+ */
+function take(title: string): Block[] {
+  const start = BLOCKS.findIndex(b => b.title === title);
+  if (start < 0) return [];
+  const out = [BLOCKS[start]];
+  for (let i = start + 1; i < BLOCKS.length && BLOCKS[i].level > BLOCKS[start].level; i++) {
+    out.push(BLOCKS[i]);
+  }
+  return out;
+}
+
+function join(blocks: Block[]): string {
+  return blocks.map(b => `${b.title}\n${b.body}`).join("\n");
+}
+
+/**
+ * GFM 任务列表 `- [ ]` → `☐`。
+ *
+ * markdown-it 默认不带 task-list 插件,`- [ ] 用样例自测签名` 会**原样**渲染出方括号,
+ * 看上去像坏掉的 markdown。这里只在展示层替换,md 文件仍是标准 GFM 语法 ——
+ * 在 GitHub、编辑器里打开依旧是真复选框。
+ */
+function toDisplayCheckboxes(md: string): string {
+  return md.replace(/^(\s*)- \[ \] /gm, "$1- ☐ ").replace(/^(\s*)- \[[xX]\] /gm, "$1- ☑ ");
+}
+
+export interface CollectionDocSection {
+  /** 侧边栏与卡片标题上显示的名字 */
+  title: string;
+  /** 该条目对应的 markdown 正文 */
+  content: string;
+}
+
+/**
+ * 侧边栏条目定义。键即 `activeSection` 的值。
+ * `parts` 里的标题必须与 md 里的标题**逐字一致**(含空格、全角括号)——
+ * 这是唯一会静默失效的地方,改标题时务必同步改这里。
+ */
+const SECTIONS: Array<{ key: string; title: string; parts: string[] }> = [
+  {
+    key: "collection_intro",
+    title: "接入总则",
+    parts: ["## 1. 概述", "## 2. 接入准备", "## 3. 请求约定", "## 4. 业务约定"],
+  },
+  { key: "collection_barcode", title: "付款码支付", parts: ["### 5.1 付款码支付(被扫)"] },
+  { key: "collection_qrcode", title: "二维码支付", parts: ["### 5.2 二维码支付(主扫)"] },
+  { key: "collection_h5", title: "H5 支付", parts: ["### 5.3 H5 支付"] },
+  { key: "collection_order_query", title: "订单查询", parts: ["### 5.4 订单查询"] },
+  { key: "collection_close", title: "撤销关单", parts: ["### 5.5 撤销 / 关单"] },
+  { key: "collection_refund", title: "发起退款", parts: ["### 5.6 退款"] },
+  { key: "collection_refund_query", title: "退款查询", parts: ["### 5.7 退款查询"] },
+  {
+    key: "collection_status",
+    title: "状态与通知",
+    parts: ["## 6. 状态说明", "## 7. 异步通知(回调)"],
+  },
+  {
+    key: "collection_checklist",
+    title: "联调与常见问题",
+    parts: ["## 8. 联调检查清单", "## 9. 常见问题"],
+  },
+];
+
+export const collectionSections: Record<string, CollectionDocSection> = Object.fromEntries(
+  SECTIONS.flatMap(({ key, title, parts }) => {
+    const blocks = parts.flatMap(take);
+    // 标题对不上就整条不显示,避免点进去一片空白让人以为接口不存在
+    return blocks.length === 0 ? [] : [[key, { title, content: toDisplayCheckboxes(join(blocks)) }]];
+  })
+);

+ 724 - 0
frontend/src/views/module_payment/apikey/docs/collection-openapi.md

@@ -0,0 +1,724 @@
+# 收付款 OpenAPI 接入文档
+
+> 适用对象:接入本平台「收付款」能力的商户 / 服务商技术对接人员
+> 版本:v1 | 最后更新:2026-09-22
+
+本文档描述的是**本平台对外提供的接口**。你只需要按本文档实现,不需要、也不会接触到平台内部的任何实现细节。
+
+---
+
+## 1. 概述
+
+平台提供四类能力,共 7 个接口:
+
+| 能力 | 场景 | 接口 |
+|---|---|---|
+| 收款 | 付款码支付(被扫)—— 用户出示付款码,收银员扫码 | `POST /payment/openapi/pay/barcode/pay` |
+| 收款 | 二维码支付(主扫)—— 平台出码,用户扫码 | `POST /payment/openapi/pay/qrcode/create` |
+| 收款 | H5 支付 —— 浏览器内拉起支付 | `POST /payment/openapi/pay/h5/create` |
+| 查询 | 订单查询 | `POST /payment/openapi/pay/order/query` |
+| 撤销 | 撤销 / 关单 | `POST /payment/openapi/pay/order/close` |
+| 退款 | 发起退款 | `POST /payment/openapi/pay/refund` |
+| 退款 | 退款查询 | `POST /payment/openapi/pay/refund/query` |
+
+所有接口均为 **POST + JSON**,请求体必须带签名。
+
+---
+
+## 2. 接入准备
+
+对接前需要平台方为你开通并提供三样东西:
+
+| 项 | 说明 |
+|---|---|
+| `api_key` | 你的身份标识,放在 `Authorization` 头 |
+| `api_secret` | 签名密钥,**只用于本地计算签名,任何情况下都不上网传输** |
+| 商户号 | 该 `api_key` 名下,每一个收款主体(企业)对应的商户号,下单时用 `enterprise_id` 指定 |
+
+还需向平台方提供一个**回调地址**(`notify_url`),或在下单时逐笔传入。
+
+> **`enterprise_id` 从哪来**:它是你在本平台的**企业标识**。下单前该企业必须已在平台开通报备,否则下单会失败并提示「该企业未配置商户号」。
+
+---
+
+## 3. 请求约定
+
+### 3.1 地址
+
+```
+{BASE}/payment/openapi/pay/<接口名>
+```
+
+生产环境 `{BASE}` 为 `https://qcsj88888.com/api/v1`;联调环境的地址由平台方另行提供。
+
+例如付款码支付的完整地址是:
+
+```
+https://qcsj88888.com/api/v1/payment/openapi/pay/barcode/pay
+```
+
+### 3.2 请求头
+
+| 头 | 值 |
+|---|---|
+| `Content-Type` | `application/json` |
+| `Authorization` | `ApiKey <你的 api_key>`(`ApiKey` 与 key 之间是**一个空格**) |
+| `Signature` | 见 3.3,**64 位小写十六进制** |
+
+`Signature` 必须小写。大写十六进制会被判为签名错误。
+
+### 3.3 签名算法
+
+对**请求体的顶层字段**做签名,与 HTTP 无关(URL、查询串、请求头都不参与)。
+
+1. **取字段**:取请求体 JSON 的顶层键值对。
+2. **排除**:丢弃值为 `null`、空字符串 `""`、空对象 `{}`、空数组 `[]` 的字段。名为 `sign` 的字段也一律排除(本平台不收该字段,列出仅为对齐规则)。
+3. **排序**:按**键名的 ASCII 字典序**升序。
+4. **编码值**:
+   - 标量(字符串、数字、布尔)→ 先转成字符串,再做 URL 编码;
+   - 对象 / 数组 → 先序列化为**紧凑 JSON**(键按字典序递归排序,分隔符 `,` 和 `:` 后**不带空格**),再对整个 JSON 串做 URL 编码。
+   - URL 编码按 RFC 3986:空格编码为 `%20`(**不是** `+`),`*` 编码为 `%2A`,`~` **不编码**,其余非 ASCII 字符按 UTF-8 逐字节大写 `%XX`。
+5. **拼接**:用 `&` 连成 `键1=值1&键2=值2&...`(值已是编码后的形式)。
+6. **摘要**:以 `api_secret` 为密钥,对上面这串做 `HMAC-SHA256`,输出**小写十六进制**。
+7. 该十六进制即 `Signature` 请求头的值。
+
+> ⚠️ **数字必须原样传输。** 签名是对**你实际发出的 JSON 里那个值**做字符串化后计算的。`total_amount` 传 `100` 和传 `100.0` 会得到两个不同的签名 —— 后者会因为签名不符被判 401,而报错信息是「签名验证失败」,不会提示你写法有问题。请一律用整数。
+
+#### 参考实现
+
+Python:
+
+```python
+import hmac, hashlib, json
+from urllib.parse import quote
+
+def build_signature(api_secret: str, body: dict) -> str:
+    parts = []
+    for key in sorted(body):
+        value = body[key]
+        if value is None or value == "" or value == {} or value == [] or key == "sign":
+            continue
+        if isinstance(value, (dict, list)):
+            text = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+        else:
+            text = str(value)
+        parts.append(f"{key}={quote(text, safe='')}")
+    sign_str = "&".join(parts)
+    return hmac.new(api_secret.encode("utf-8"), sign_str.encode("utf-8"),
+                    hashlib.sha256).hexdigest()
+```
+
+Java:
+
+```java
+// 注意:数值字段请用整型(如 Long),不要用 double,否则字符串化结果不同
+private static String buildSignature(String apiSecret, Map<String, Object> body) throws Exception {
+    TreeMap<String, String> filtered = new TreeMap<>();
+    for (Map.Entry<String, Object> e : body.entrySet()) {
+        Object v = e.getValue();
+        if (v == null || "sign".equals(e.getKey())) continue;
+        if (v instanceof String s && s.isEmpty()) continue;
+        if (v instanceof Map<?, ?> m && m.isEmpty()) continue;
+        if (v instanceof Collection<?> c && c.isEmpty()) continue;
+        String text = (v instanceof Map || v instanceof Collection)
+                ? new ObjectMapper()
+                    .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
+                    .writeValueAsString(v)
+                : String.valueOf(v);
+        filtered.put(e.getKey(), URLEncoder.encode(text, StandardCharsets.UTF_8)
+                .replace("+", "%20").replace("*", "%2A").replace("%7E", "~"));
+    }
+    StringBuilder sb = new StringBuilder();
+    for (Map.Entry<String, String> e : filtered.entrySet()) {
+        if (sb.length() > 0) sb.append('&');
+        sb.append(e.getKey()).append('=').append(e.getValue());
+    }
+    Mac mac = Mac.getInstance("HmacSHA256");
+    mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+    return HexFormat.of().formatHex(mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8)));
+}
+```
+
+Node.js:
+
+```js
+const crypto = require('crypto');
+
+function buildSignature(apiSecret, body) {
+  const parts = [];
+  for (const key of Object.keys(body).sort()) {
+    const v = body[key];
+    if (v === null || v === undefined || v === '') continue;
+    if (Array.isArray(v) && v.length === 0) continue;
+    if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
+    if (key === 'sign') continue;
+    const text = (typeof v === 'object') ? sortStringify(v) : String(v);
+    parts.push(`${key}=${encodeURIComponent(text)}`);
+  }
+  return crypto.createHmac('sha256', apiSecret)
+    .update(parts.join('&'), 'utf8').digest('hex');
+}
+// encodeURIComponent 与 RFC 3986 的差异仅在 ! ' ( ) * 五个字符,需要按需转义 *
+function sortStringify(obj) {
+  if (Array.isArray(obj)) return '[' + obj.map(sortStringify).join(',') + ']';
+  if (obj && typeof obj === 'object') {
+    return '{' + Object.keys(obj).sort()
+      .map(k => JSON.stringify(k) + ':' + sortStringify(obj[k])).join(',') + '}';
+  }
+  return JSON.stringify(obj);
+}
+```
+
+> 上面三段都请先跑通 3.4 的样例再接入。
+
+### 3.4 签名样例(可自测)
+
+**请求体**(付款码支付):
+
+```json
+{
+  "out_trade_no": "T20260922001",
+  "enterprise_id": "ENT10001",
+  "auth_code": "280812345678901234",
+  "total_amount": 100,
+  "pay_type": "000",
+  "order_body": "测试商品",
+  "attach": "table-1"
+}
+```
+
+**api_secret**(仅用于本样例演示,不是真实密钥):
+
+```
+doc_demo_secret_9f8e7d6c5b4a
+```
+
+**排序后逐项编码**(注意 `order_body` 的中文被编码):
+
+| 键 | 编码后的值 |
+|---|---|
+| `attach` | `table-1` |
+| `auth_code` | `280812345678901234` |
+| `enterprise_id` | `ENT10001` |
+| `order_body` | `%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81` |
+| `out_trade_no` | `T20260922001` |
+| `pay_type` | `000` |
+| `total_amount` | `100` |
+
+**待签串**:
+
+```
+attach=table-1&auth_code=280812345678901234&enterprise_id=ENT10001&order_body=%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81&out_trade_no=T20260922001&pay_type=000&total_amount=100
+```
+
+**Signature**:
+
+```
+2454f99173419a38a41a573bee26e555673d12beee61f9f4481af7d8d2eb3a70
+```
+
+如果算出来不是这个值,**先别调接口** —— 把待签串打印出来逐字符比对,多半是编码规则或数字格式不一致。这个样例已由平台侧的自动化测试钉住,不会无故变化。
+
+### 3.5 响应格式
+
+平台返回两种信封,按接口区分:
+
+**A 型 —— 三个下单接口**(`barcode/pay`、`qrcode/create`、`h5/create`)直接返回业务对象:
+
+```json
+{
+  "out_trade_no": "T20260922001",
+  "status": "SUCCESS",
+  "trade_no": "2026092222001",
+  "total_amount": 100,
+  "receipt_amount": 100,
+  "code_url": null,
+  "message": null
+}
+```
+
+**B 型 —— 其余四个接口**用统一信封:
+
+```json
+{
+  "code": 0,
+  "msg": "成功",
+  "data": { },
+  "status_code": 200,
+  "success": true
+}
+```
+
+> `code` 为 `0` 表示成功,非 `0` 表示失败(见 3.6)。
+> 信封字段名是 `status_code`(下划线),不是 `statusCode`。
+
+### 3.6 错误处理
+
+**判成败一律读响应体里的 `code`,不要依赖 HTTP 状态码。** 平台有两套错误出口,HTTP 状态码不一致:
+
+| 出错环节 | HTTP 状态码 | 响应体 |
+|---|---|---|
+| 参数校验失败(缺字段、金额非正、单号超长、**多了不认识的字段**等) | **400** | `{"code":400,"msg":"字段名: 具体原因", ...}` |
+| 认证 / 签名失败 | **401** | `{"code":10401,"msg":"...", ...}` |
+| 业务逻辑错误(订单不存在、状态不允许、退款超余额等) | **200** | `{"code":404 或 400 或 500,"msg":"...", ...}` |
+| 路径不存在(URL 拼错) | **404** | `{"code":404,"msg":"接口不存在", ...}` |
+| 服务端未预期异常 | **500** | `{"code":500,"msg":"服务器内部错误", ...}` |
+
+> ⚠️ **多传一个字段和少传一个字段一样,都是 400。** 平台**不忽略**不认识的字段 ——
+> 请求体里出现 DTO 没定义的键会被直接拒绝,`msg` 会指出是哪个键,例如
+> `{"code":400,"msg":"未知字段 \"totalAmount\",报文里的字段名一律用下划线式,请核对"}`。
+> 这是有意的:静默忽略会让你以为某个参数生效了,而它其实从头到尾没被读。
+> 所以**字段名拼错不会静默通过**,请以 `msg` 指出的键名为准改回下划线式。
+
+所以请以 `code == 0` 作为唯一成功判据。第三行是最容易被误判的一类:**HTTP 200 不等于成功**。
+
+> ⚠️ **校验错误的 `msg` 里,字段名是驼峰式,不是下划线式。** 例如少传 `enterprise_id`,返回的是
+> `{"code":400,"msg":"enterpriseId: 不能为空"}`;`total_amount` 传 0,返回
+> `{"code":400,"msg":"totalAmount: 必须是正数"}`。报文全用下划线,但这条消息里的字段名沿用服务端
+> 内部命名,**不要拿它直接去匹配你请求体里的键名**。要据 `msg` 定位参数时,请忽略大小写与下划线差异。
+
+常见错误码:
+
+| `code` | 含义 |
+|---|---|
+| `0` | 成功 |
+| `400` | 参数错误,`msg` 会指明是哪个字段 |
+| `401` | 认证上下文缺失(正常情况下由 10401 覆盖,此码防御性保留) |
+| `404` | 订单 / 退款单不存在,或不属于你的 `api_key` |
+| `500` | 业务处理未成功(如关单未生效、撤销被拒),**可重试** |
+| `10401` | 认证失败:缺少 `Authorization` / `Signature` 头、API Key 无效或已过期、签名验证不通过 |
+
+---
+
+## 4. 业务约定
+
+**金额单位是「分」。** 所有金额字段(`total_amount`、`receipt_amount`、`refund_fee`、`refunded_amount`)都是**整数,单位为分**。`100` 表示 1.00 元。
+
+**单号规则。** `out_trade_no`(商户订单号)与 `out_refund_no`(商户退款单号)由**你方生成**,要求:
+
+- 长度 1–64 字符;
+- 不含换行符与其它控制字符;
+- 在你自己的 `api_key` 范围内**唯一**。
+
+**下单幂等。** 同一个 `api_key` 下,用相同的 `out_trade_no` 重复调用任一**下单**接口,**不会**新建订单、**不会**重复扣款:平台直接返回已存在订单的当前结果,且 `message` 为「订单已存在,返回原结果」。所以网络超时后可以放心重试。
+
+**退款幂等**由 `out_refund_no` 承担,规则同上;此外:
+
+- 同一笔订单**同时最多只有一笔在途退款**,在途期间再发起会失败并提示「该订单有退款正在处理中」;
+- 累计退款金额不得超过订单金额,超出会失败并提示可退余额;
+- 一笔订单**可以先退一部分、成功后再退剩下的**(部分退款是允许的,见 5.6)。
+
+**超时与重试。** 请对下单接口设置合理的连接/读取超时(建议 15 秒),超时后用**同一个 `out_trade_no`** 重试或调用订单查询确认结果,不要换单号重发。
+
+---
+
+## 5. 接口详情
+
+### 5.1 付款码支付(被扫)
+
+用户出示付款码,你用扫码枪读取后调用本接口完成收款。
+
+```
+POST /payment/openapi/pay/barcode/pay
+```
+
+**请求**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `out_trade_no` | string | 是 | 你的商户订单号,见 4 |
+| `enterprise_id` | string | 是 | 收款企业标识 |
+| `auth_code` | string | 是 | 用户的付款码,由扫码枪读取 |
+| `total_amount` | long | 是 | 金额(分),必须 > 0 |
+| `pay_type` | string | 否 | `000` 自动识别(默认)/ `300` 支付宝 / `400` 微信 |
+| `order_body` | string | 否 | 商品描述,≤128 字符 |
+| `attach` | string | 否 | 你的透传字段,会原样出现在回调里,≤128 字符 |
+| `notify_url` | string | 否 | 本笔的回调地址,≤512 字符;不传则用默认配置 |
+
+**响应**(A 型)
+
+| 字段 | 说明 |
+|---|---|
+| `out_trade_no` | 原样返回 |
+| `status` | 订单状态,见 6。**当场扣款成功为 `SUCCESS`**;若返回 `PAYING` 表示用户仍在支付中,平台会自动轮询确认,最终结果通过回调通知你 |
+| `trade_no` | 平台交易号,后续查询/退款可用 |
+| `total_amount` | 订单金额(分) |
+| `receipt_amount` | 实收金额(分),支付成功时返回,否则为 `null` |
+| `code_url` | 被扫场景恒为 `null` |
+| `message` | 附加说明,可能为空 |
+
+**典型时序**:绝大多数情况下同步就能拿到 `SUCCESS`。若同步返回 `PAYING`,说明这笔支付还在处理中,平台会自动轮询确认(约 30 秒一次),确认后回调你 —— 此时**不要**把 `PAYING` 当作失败。
+
+> ⚠️ **未付款的订单会被自动撤销。** 若轮询约 15 次(合计约 7–8 分钟)后仍未确认收款,平台会**自动撤销**该笔订单并把状态回调给你(`CANCELED`)。
+> 如果你的业务需要更长的等待时间,请改用 **5.2 主扫**(二维码有效期默认 15 分钟,可延长),而不是依赖被扫长时间挂单。
+
+---
+
+### 5.2 二维码支付(主扫)
+
+平台生成一张二维码,你把它展示给用户,用户用支付宝/微信扫码付款。
+
+```
+POST /payment/openapi/pay/qrcode/create
+```
+
+**请求**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `out_trade_no` | string | 是 | 你的商户订单号 |
+| `enterprise_id` | string | 是 | 收款企业标识 |
+| `total_amount` | long | 是 | 金额(分),必须 > 0 |
+| `order_body` | string | 否 | 商品描述,≤128 字符 |
+| `attach` | string | 否 | 透传字段,≤128 字符 |
+| `notify_url` | string | 否 | 本笔回调地址,≤512 字符 |
+| `time_expire` | int | 否 | 二维码有效期(**分钟**),不传默认 15 分钟 |
+
+本接口**不接受** `pay_type`,由用户在扫码后自行选择支付方式。
+
+**响应**(A 型)
+
+收到 `code_url` 后,把它渲染成二维码即可(`code_url` 是可直接编码为二维码的字符串)。
+
+| 字段 | 说明 |
+|---|---|
+| `status` | **下单成功为 `PAYING`** —— 下单成功 ≠ 收款成功。用户付款前一直是 `PAYING`,付款后通过回调通知你 |
+| `code_url` | 二维码内容 |
+| `receipt_amount` | 用户付款前为 `null` |
+| 其余 | 同 5.1 |
+
+> ⚠️ **不要把 `PAYING` 当成失败。** 主扫的下单响应**永远**是 `PAYING`;收款是否成功只能看回调(或主动查询)。
+
+---
+
+### 5.3 H5 支付
+
+在手机浏览器里拉起支付。
+
+```
+POST /payment/openapi/pay/h5/create
+```
+
+**请求**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `out_trade_no` | string | 是 | 你的商户订单号 |
+| `enterprise_id` | string | 是 | 收款企业标识 |
+| `total_amount` | long | 是 | 金额(分),必须 > 0 |
+| `pay_type` | string | 是 | **`300` 支付宝 / `400` 微信**,必须明确指定 |
+| `order_body` | string | 否 | 商品描述,≤128 字符 |
+| `attach` | string | 否 | 透传字段,≤128 字符 |
+| `notify_url` | string | 否 | 本笔回调地址,≤512 字符 |
+
+**响应**(A 型)
+
+| 字段 | 说明 |
+|---|---|
+| `status` | 下单成功为 `PAYING` |
+| `code_url` | **跳转链接**,把它作为 URL 打开即可进入支付页 |
+| 其余 | 同 5.1 |
+
+> H5 场景用户会被跳转到外部页面,**不支持**主动关单(见 5.5)。
+
+---
+
+### 5.4 订单查询
+
+```
+POST /payment/openapi/pay/order/query
+```
+
+**请求**(两个字段**至少提供一个**;都提供时以 `out_trade_no` 为准)
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `out_trade_no` | string | 二选一 | 你的商户订单号 |
+| `trade_no` | string | 二选一 | 平台交易号 |
+
+**响应**(B 型,`data` 内)
+
+| 字段 | 说明 |
+|---|---|
+| `local_status` | 平台侧当前状态,见 6 |
+| `trade_no` | 平台交易号 |
+| `detail` | 实时回查的结果,用于对账核对(字段见下) |
+
+`detail` 的字段(固定这几个,取不到的为 `null`):
+
+| 字段 | 说明 |
+|---|---|
+| `pay_result` | 实时回查的支付结果:`SUCCESS` 成功 / `PROCESSING` 处理中 / `FAILED` 失败 / `UNKNOWN` 无法判定 |
+| `total_amount` | 订单金额(分) |
+| `receipt_amount` | 实收金额(分) |
+| `discount_amount` | 优惠金额(分) |
+| `pay_time` | 支付时间,格式 `yyyyMMddHHmmss` |
+| `code_url` | 二维码内容(主扫场景) |
+| `code_url_expire_time` | 二维码过期时间 |
+
+> `pay_result` 是**本次实时查询**的结果,`local_status` 是**平台已记录**的状态。两者通常一致;不一致时以 `local_status` 为准(例如已经收款、但回调尚未落库的瞬间)。
+
+> 当回调可能丢失、或你收到回调但状态未落定时,用本接口兜底确认。**本接口会实时发起一次支付结果查询**,不要在紧密循环里高频调用。
+
+---
+
+### 5.5 撤销 / 关单
+
+```
+POST /payment/openapi/pay/order/close
+```
+
+**请求**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `out_trade_no` | string | 是 | 你的商户订单号 |
+
+**行为**(按订单类型自动路由,无需你区分):
+
+| 订单类型 | 行为 |
+|---|---|
+| 付款码支付 | 先查后撤。**已支付**则不会撤销,而是提示你改走退款;未支付才真正撤销 |
+| 二维码支付 | 按二维码关单 |
+| H5 | **不支持主动关单**,平台仅把本地状态收敛为 `EXPIRED`。若用户之后仍完成支付,回调会把状态更正回来 |
+
+**响应**(B 型)
+
+成功时 `data.status` 为目标状态(`CANCELED` / `CLOSED` / `EXPIRED`)。
+
+两种特殊情形,都以**失败**返回(`code=500`):
+
+| `msg` | 含义与处理 |
+|---|---|
+| `该订单已支付,已转退款流程,请调用退款接口` | 订单已经收款成功,撤销无意义。**改调 5.6 退款** |
+| `关单结果未生效:订单状态已变更(可能已支付),请重新查询订单状态` | 关单动作与一笔刚到达的支付结果撞车,平台拒绝覆盖已收款的状态。**先查 5.4 确认,再决定是否退款** |
+| `撤销/关单未成功,请稍后重试` | 本次未成功,可稍后重试 |
+
+> 关单是**资金动作**:平台在无法确定订单是否已支付时(例如用户正在输密码)会**拒绝撤销**而不是冒险执行。收到 `code=500` 时请勿盲目重试,先查询订单状态。
+
+---
+
+### 5.6 退款
+
+```
+POST /payment/openapi/pay/refund
+```
+
+**请求**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `out_trade_no` | string | 是 | 要退款的订单号 |
+| `out_refund_no` | string | 是 | 你的商户退款单号,全局唯一 |
+| `refund_fee` | long | 是 | 退款金额(分),必须 > 0,且不超过可退余额 |
+
+**响应**(B 型,`data` 内)
+
+| 字段 | 说明 |
+|---|---|
+| `out_refund_no` | 原样返回 |
+| `refund_no` | 平台退款单号 |
+| `status` | 退款状态,见 6 |
+
+**退款是异步的。** 同步返回时 `status` 可能是:
+
+- `SUCCESS` —— 已退款成功;
+- `REFUNDING` —— 已受理,结果稍后通过**退款通知**(见 7.3)送达。
+
+无论哪种,退款到达终态时都会推送退款通知,请不要仅凭同步返回就记账。
+
+**部分退款**:一笔订单可以多次退款,只要累计不超过订单金额,且上一笔已出终态(不在 `REFUNDING`)。退款成功会改变订单状态(见 6),并通过订单回调携带更新后的已退金额。
+
+---
+
+### 5.7 退款查询
+
+```
+POST /payment/openapi/pay/refund/query
+```
+
+**请求**
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `out_refund_no` | string | **是** | 你的商户退款单号 |
+| `out_trade_no` | string | 否 | 请求体接受该字段,但**当前不参与查询** |
+
+> 只传 `out_trade_no` 查不到任何结果,会返回「退款单不存在」。请始终带上 `out_refund_no`。
+
+**响应**(B 型,`data` 内)
+
+| 字段 | 说明 |
+|---|---|
+| `local_status` | 平台侧退款状态 |
+| `detail` | 实时回查的结果(字段见下) |
+
+`detail` 的字段:
+
+| 字段 | 说明 |
+|---|---|
+| `refund_result` | 实时回查的退款结果,取值同 `pay_result` |
+| `refund_no` | 退款单号(平台侧) |
+| `refund_fee` | 退款金额(分) |
+
+---
+
+## 6. 状态说明
+
+### 订单状态
+
+| 状态 | 含义 | 是否终态 |
+|---|---|---|
+| `CREATED` | 已创建,尚未发起支付 | 否 |
+| `PAYING` | 已发起支付,等待用户付款 | 否 |
+| `SUCCESS` | **收款成功,钱已到账** | 是 |
+| `FAIL` | 支付失败 | 是 |
+| `CANCELED` | 已撤销 | 是 |
+| `CLOSED` | 已关单 | 是 |
+| `EXPIRED` | 已过期 | 是 |
+| `PARTIAL_REFUNDED` | 部分退款(**钱已到账过**,现在退了一部分) | 是 |
+| `REFUNDED` | 全额退款 | 是 |
+
+判断「是否收到过钱」用 `SUCCESS` / `PARTIAL_REFUNDED` / `REFUNDED` 三者;判断「是否还需要等结果」用 `CREATED` / `PAYING`。
+
+### 退款状态
+
+| 状态 | 含义 |
+|---|---|
+| `CREATED` | 已创建 |
+| `REFUNDING` | 退款处理中 |
+| `SUCCESS` | 退款成功 |
+| `FAIL` | 退款失败 |
+
+> 退款结果的正常到达途径是**退款通知**(见 7.3)。与订单侧不同,平台**没有**针对退款状态的兜底轮询任务 —— 平台不会主动去查询一笔在途退款的最新结果。
+> 因此若你的退款长时间停在 `REFUNDING` 且未收到通知,请用 **5.7 退款查询** 主动确认,并向平台方反馈。
+
+---
+
+## 7. 异步通知(回调)
+
+### 7.1 投递方式
+
+平台会推送两类通知:**订单通知**(订单状态发生变化:收款成功、关闭、过期,以及退款导致的订单状态变化)与**退款通知**(退款到达终态,见 7.3)。两者投递方式相同:
+
+```
+POST <你的 notify_url>
+Content-Type: application/x-www-form-urlencoded
+```
+
+**请求体**(表单编码,三个字段):
+
+| 字段 | 说明 |
+|---|---|
+| `notify_id` | 本次通知的唯一 ID,可用于去重 |
+| `timestamp` | 平台发出通知的时间,Unix 毫秒 |
+| `content` | **JSON 字符串**,业务内容见下(注意:是字符串,需要再解析一次) |
+
+**订单通知的 `content`**:
+
+```json
+{
+  "out_trade_no": "T20260922001",
+  "trade_no": "2026092222001",
+  "status": "SUCCESS",
+  "total_amount": 100,
+  "receipt_amount": 100,
+  "refunded_amount": 0,
+  "pay_scene": "BARCODE",
+  "attach": "table-1"
+}
+```
+
+`pay_scene` 取值:`BARCODE`(被扫)/ `QRCODE`(主扫)/ `H5`。
+
+**退款通知的 `content`**:
+
+```json
+{
+  "out_trade_no": "T20260922001",
+  "out_refund_no": "R20260922001",
+  "refund_no": "2026092299001",
+  "status": "SUCCESS",
+  "refund_fee": 100
+}
+```
+
+### 7.2 你方需要做的
+
+1. **应答**:处理成功后返回 **HTTP 2xx**(例如空 `200`)。返回非 2xx 或超时,平台视为投递失败。
+2. **去重**:同一笔业务**可能收到多次通知**(重试、或状态多次变化)。订单通知用 `out_trade_no` + `status`、退款通知用 `out_refund_no` + `status` 做幂等处理,不要重复记账。
+3. **快速应答**:先应答再异步处理,避免你的处理耗时导致超时被判失败。
+4. **重试**:投递失败时平台会重试,由兜底任务每 **5 分钟**捞一次,累计最多 **8 次**投递尝试(约 35 分钟内)。最终仍未成功则不再投递,需要你主动查询。
+
+> ⚠️ **回调目前不带签名。**
+> 因此**不能**仅凭回调报文认定收款成功。请务必在收到 `status` 为 `SUCCESS` / `PARTIAL_REFUNDED` / `REFUNDED` 的回调后,调用 **5.4 订单查询**核对一次;或以你方主动查询的结果为准,把回调当作「该查了」的触发信号。
+> 平台方已在计划中为回调补充签名机制,届时会另行通知。
+
+### 7.3 退款通知
+
+退款到达终态(成功或失败)时,平台还会**单独推送一条退款通知**,`content` 如下:
+
+```json
+{
+  "out_trade_no": "T20260922001",
+  "out_refund_no": "R20260922001",
+  "refund_no": "2026092299001",
+  "status": "SUCCESS",
+  "refund_fee": 100
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `out_trade_no` | 关联的商户订单号 |
+| `out_refund_no` | 你的商户退款单号 |
+| `refund_no` | 退款单号(平台侧) |
+| `status` | 退款状态:`SUCCESS` / `FAIL` |
+| `refund_fee` | 退款金额(分) |
+
+退款成功后,你还会收到一条**订单通知**(`status` 变为 `PARTIAL_REFUNDED` 或 `REFUNDED`,`refunded_amount` 已更新)。
+
+> **两条通知的先后顺序不保证。** 请分别做幂等处理,不要假设退款通知一定先到或后到。
+
+注意退款**失败**也会推送(`status` 为 `FAIL`)—— 请按 `status` 分支处理,不要只看「收到通知」就当作退款成功。
+
+> 退款通知与订单通知共用同一套投递与重试规则(7.1 / 7.2),同样**不带签名**。
+
+---
+
+## 8. 联调检查清单
+
+- [ ] 用 3.4 的样例自测签名,算出的十六进制与文档一致
+- [ ] `Authorization` 头格式为 `ApiKey <key>`(一个空格)
+- [ ] `Signature` 为**小写**十六进制
+- [ ] 金额参与签名时按**整数**发送(不要出现 `100.0`)
+- [ ] 以响应体 `code == 0` 判成败,而非 HTTP 状态码
+- [ ] 下单使用业务唯一的 `out_trade_no`,重复调用能接受「订单已存在」的返回
+- [ ] 回调接口能正确处理表单编码,并把 `content` 字段**再解析一次** JSON
+- [ ] 回调处理实现了幂等(同一订单可能收到多次)
+- [ ] 收到成功类回调后,调用订单查询核对一次
+
+---
+
+## 9. 常见问题
+
+**Q:一直返回「签名验证失败」(`code=10401`)怎么办?**
+把待签串打印出来与 3.4 的样例逐字符比对。绝大多数情况是这三类:数字被序列化成了 `100.0`;URL 编码把空格编成了 `+` 而不是 `%20`;或者 `Signature` 用了大写。
+
+**Q:`total_amount` 到底是元还是分?**
+分。`100` = 1.00 元。
+
+**Q:主扫下单返回 `PAYING`,是不是失败了?**
+不是。主扫下单成功**必然**返回 `PAYING`。等回调,或主动查询。
+
+**Q:用户一直不付款,二维码会怎样?**
+有效期默认 15 分钟(可用 `time_expire` 指定)。超时后订单状态变为 `EXPIRED`。可以调 5.5 显式关单。
+
+**Q:下单请求超时了,能直接重发吗?**
+可以,用**同一个** `out_trade_no` 重发。平台会返回已存在订单的结果,不会重复扣款。
+
+**Q:一笔订单能退几次?**
+不限次数,但同一时刻只允许一笔在途退款,且累计退款金额不得超过订单金额。
+
+**Q:回调丢了怎么办?**
+平台最多重试 8 次。之后请主动调用 5.4 订单查询。建议你在自己的系统里对长时间处于 `CREATED` / `PAYING` 的订单做轮询兜底。

+ 235 - 0
frontend/src/views/module_payment/collection/merchant/index.vue

@@ -0,0 +1,235 @@
+<template>
+  <div class="app-container">
+    <el-alert
+      type="info"
+      :closable="false"
+      style="margin-bottom: 12px"
+      title="企业在本平台开通的商户号。不配置则无法为该企业下单。"
+    />
+
+    <el-card shadow="never">
+      <el-form :inline="true" :model="query">
+        <el-form-item label="关键字">
+          <el-input
+            v-model="query.keyword"
+            placeholder="商户号 / 商户名 / 企业ID"
+            clearable
+            style="width: 260px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button type="success" @click="openForm(null)">新增</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card shadow="never" style="margin-top: 12px">
+      <el-table v-loading="loading" :data="list" border stripe>
+        <el-table-column
+          prop="enterprise_id"
+          label="企业 ID"
+          min-width="180"
+          show-overflow-tooltip
+        />
+        <el-table-column prop="mch_no" label="商户号" min-width="160" />
+        <el-table-column prop="shop_no" label="门店号" min-width="140" />
+        <el-table-column prop="merchant_name" label="商户名称" min-width="180" />
+        <el-table-column prop="review_status" label="审核状态" width="120" />
+        <el-table-column label="状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 'ENABLED' ? 'success' : 'info'">
+              {{ row.status === "ENABLED" ? "启用" : "停用" }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="created_time" label="创建时间" width="180" />
+        <el-table-column label="操作" width="140" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="openForm(row)">编辑</el-button>
+            <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="query.pageNo"
+        v-model:page-size="query.pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next, jumper"
+        style="margin-top: 12px; justify-content: flex-end"
+        @change="loadData"
+      />
+    </el-card>
+
+    <el-dialog v-model="formVisible" :title="form.id ? '编辑商户映射' : '新增商户映射'" width="560px">
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
+        <el-form-item label="企业 ID" prop="enterprise_id">
+          <el-input v-model="form.enterprise_id" placeholder="请输入企业 ID" />
+        </el-form-item>
+        <el-form-item label="商户号" prop="mch_no">
+          <el-input v-model="form.mch_no" placeholder="请输入商户号" />
+        </el-form-item>
+        <el-form-item label="门店号">
+          <el-input v-model="form.shop_no" placeholder="可空" />
+        </el-form-item>
+        <el-form-item label="商户名称">
+          <el-input v-model="form.merchant_name" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.description" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="formVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: "LeshouyingMerchant",
+  inheritAttrs: false,
+});
+
+import { onMounted, reactive, ref } from "vue";
+import { ElMessageBox } from "element-plus";
+import {
+  getLsyMerchantPage,
+  saveLsyMerchant,
+  deleteLsyMerchant,
+} from "@/api/module_payment/collection";
+
+/** 查询参数 camelCase / 响应与请求体字段 snake_case —— 见 `api/module_payment/collection.ts` 文件头 ★ */
+const query = reactive({ pageNo: 1, pageSize: 20, keyword: "" });
+const list = ref<any[]>([]);
+const total = ref(0);
+const loading = ref(false);
+const formVisible = ref(false);
+const formRef = ref();
+
+/**
+ * 表单**只认**这 6 个键(`id` + 5 个业务字段)。其余字段(含服务端将来新增的)一律不得进入表单模型 ——
+ * `buildForm` 是按这张白名单**逐键取值**的,不是把服务端整行摊进来。
+ */
+const FORM_FIELDS = [
+  "id",
+  "enterprise_id",
+  "mch_no",
+  "shop_no",
+  "merchant_name",
+  "description",
+] as const;
+
+/**
+ * ★ 键名必须 snake_case:后端 `@RequestBody LsyMerchantEntity` 走全局 SNAKE_CASE 反序列化。
+ *
+ * ★ 可空列(V1.8 DDL:`shop_no` / `merchant_name` / `description`)的初值是 `null` 而**不是** `""`:
+ *   这三列的语义是「空 ⇒ 库里 NULL」,而 `""` 与 `NULL` 在库里是两回事 ——
+ *   表格里都显示空白,但 `WHERE shop_no IS NULL` 查不到 `''`,于是数据悄悄查不出来。
+ *   初值给 `""` 会让「打开新增表单、什么都不填直接保存」写出空串。
+ *   `el-input` 把 `null` 当空串显示(不会报错也不会显示 "null"),界面无差别。
+ */
+function emptyForm(): Record<string, any> {
+  return {
+    id: null,
+    enterprise_id: "",
+    mch_no: "",
+    shop_no: null,
+    merchant_name: null,
+    description: null,
+  };
+}
+
+/**
+ * ★ 白名单投影 —— **不要**改回 `Object.assign(form, row)` / `{ ...row }`。
+ *
+ * 服务端回的是**整行**:除这 6 个键外还带 status / review_status / created_time / updated_time /
+ * tenant_id,以及**以后新增的任何字段**。整行摊进表单会静默写坏数据(每一跳都有依据):
+ *   · `status` / `review_status` —— 新增时,从上一行残留的非空值会**直接进 INSERT**
+ *     (后端只在空值时才补默认值);编辑时后端已改成定向更新、不写这两列,
+ *     但**不要依赖它**:白名单与后端定向更新是两处独立防线,各守一段(前端守「新增不得继承上一行」);
+ *   · `created_time` / `updated_time` —— `AutoFillMetaObjectHandler` 用 `strictInsertFill`,
+ *     **只在字段为 null 时**填充 ⇒ 新建的行会继承**别人那一行**的创建时间;
+ *   · 将来新加的字段同理,而且不会有任何报错。
+ * 按白名单逐键取值之后,「服务端多返回一个字段」在模型这一层就已经不成立了。
+ *
+ * ★ 只跳过 `undefined`(键不存在),**不跳过 `null`**:服务端回 `null`(可空列本来就是 NULL)
+ *   就原样留在模型里 —— 不要 `?? ""` / `|| ""` 折叠,那会把库里的 NULL 变成空串写回去(D-2)。
+ *   用户清空(`""`)与未填(`null`)都由后端 `LsyMerchantService.save` 收敛成 NULL;
+ *   前端只保证「不制造空串」,不做这层转换(收敛点只能有一个)。
+ */
+function buildForm(row?: any): Record<string, any> {
+  const next = emptyForm();
+  if (row) {
+    for (const key of FORM_FIELDS) {
+      if (row[key] !== undefined) {
+        next[key] = row[key];
+      }
+    }
+  }
+  return next;
+}
+
+const form = ref<Record<string, any>>(emptyForm());
+
+const rules = {
+  enterprise_id: [{ required: true, message: "企业 ID 必填", trigger: "blur" }],
+  mch_no: [{ required: true, message: "商户号必填", trigger: "blur" }],
+};
+
+async function loadData() {
+  loading.value = true;
+  try {
+    const res: any = await getLsyMerchantPage(query);
+    // 两层 `.data.data`(响应拦截器不解包);分页字段名是 `list`
+    list.value = res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  query.pageNo = 1;
+  loadData();
+}
+
+/**
+ * ★ 每次打开都**换一个新对象**(`form.value = …`),而不是往旧对象里 assign —— 两道机制各自独立:
+ *   · 白名单(`buildForm`)挡住「服务端多返回的字段」;
+ *   · 换对象挡住「上一次编辑残留的值」:旧值在新对象里**物理上无处可存**。
+ *
+ * ★ `id` 是**字符串**(后端 `LsyMerchantEntity.getId()` 挂了 `@JsonSerialize(ToStringSerializer)`):
+ *   19 位雪花超 JS 安全整数,number 会在 `JSON.parse` 那一刻丢精度且不可逆 ⇒ 编辑/删除打不中目标行。
+ *   原样回传即可,**不要** `Number(row.id)` / `parseInt(row.id)`。
+ */
+function openForm(row?: any) {
+  form.value = buildForm(row);
+  formVisible.value = true;
+}
+
+async function handleSubmit() {
+  await formRef.value?.validate();
+  await saveLsyMerchant(form.value);
+  // 成功提示由 `utils/request.ts` 的响应拦截器统一弹(非 GET 成功响应 → `ElMessage.success(data.msg)`),
+  // 这里**不能**再弹一次,否则同一个操作出两个 toast。
+  formVisible.value = false;
+  loadData();
+}
+
+async function handleDelete(row: any) {
+  await ElMessageBox.confirm(`确认删除商户映射「${row.mch_no}」?删除后该企业将无法下单。`, "提示", {
+    type: "warning",
+  });
+  await deleteLsyMerchant(row.id);
+  // 成功提示同上:由拦截器统一弹,页面不重复弹。
+  loadData();
+}
+
+onMounted(loadData);
+</script>

+ 256 - 0
frontend/src/views/module_payment/collection/order/index.vue

@@ -0,0 +1,256 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never">
+      <el-form :inline="true" :model="query">
+        <el-form-item label="订单号">
+          <el-input
+            v-model="query.keyword"
+            placeholder="商户订单号 / 交易号"
+            clearable
+            style="width: 240px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="场景">
+          <el-select v-model="query.payScene" placeholder="全部" clearable style="width: 140px">
+            <el-option label="被扫" value="BARCODE" />
+            <el-option label="主扫" value="QRCODE" />
+            <el-option label="H5" value="H5" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="query.status" placeholder="全部" clearable style="width: 160px">
+            <el-option
+              v-for="s in STATUS_OPTIONS"
+              :key="s.value"
+              :label="s.label"
+              :value="s.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card shadow="never" style="margin-top: 12px">
+      <el-table v-loading="loading" :data="list" border stripe>
+        <el-table-column
+          prop="out_trade_no"
+          label="商户订单号"
+          min-width="200"
+          show-overflow-tooltip
+        />
+        <el-table-column label="场景" width="90">
+          <template #default="{ row }">{{ sceneLabel(row.pay_scene) }}</template>
+        </el-table-column>
+        <el-table-column label="金额(元)" width="110" align="right">
+          <template #default="{ row }">{{ fen2yuan(row.total_amount) }}</template>
+        </el-table-column>
+        <el-table-column label="已退(元)" width="110" align="right">
+          <template #default="{ row }">{{ fen2yuan(row.refunded_amount) }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="130">
+          <template #default="{ row }">
+            <el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="trade_no"
+          label="交易号"
+          min-width="200"
+          show-overflow-tooltip
+        />
+        <el-table-column label="回调" width="100">
+          <template #default="{ row }">
+            <el-tag v-if="row.notify_status === 'SUCCESS'" type="success" size="small">已送达</el-tag>
+            <el-tag v-else-if="row.notify_status === 'FAIL'" type="danger" size="small">
+              失败({{ row.notify_count }})
+            </el-tag>
+            <el-tag v-else type="info" size="small">待推送</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="created_time" label="创建时间" width="180" />
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="openDetail(row)">详情</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="query.pageNo"
+        v-model:page-size="query.pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50, 100]"
+        layout="total, sizes, prev, pager, next, jumper"
+        style="margin-top: 12px; justify-content: flex-end"
+        @change="loadData"
+      />
+    </el-card>
+
+    <el-drawer v-model="detailVisible" title="订单详情" size="720px">
+      <el-descriptions v-if="detail" :column="1" border>
+        <el-descriptions-item label="商户订单号">{{ detail.out_trade_no }}</el-descriptions-item>
+        <el-descriptions-item label="商户号">{{ detail.mch_no }}</el-descriptions-item>
+        <el-descriptions-item label="支付流水号">{{ detail.pay_trace_no }}</el-descriptions-item>
+        <el-descriptions-item label="交易号">{{ detail.trade_no || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="支付单号">{{ detail.td_trade_no || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="金额">
+          {{ fen2yuan(detail.total_amount) }} 元 / 实收 {{ fen2yuan(detail.receipt_amount) }} 元
+        </el-descriptions-item>
+        <el-descriptions-item label="支付结果">
+          {{ detail.upstream_result_code }} {{ detail.upstream_result_msg }}
+        </el-descriptions-item>
+        <!--
+          上游返回值只按**文本**渲染:不拼 innerHTML、不做模板、也不做成 href ——
+          `code_url` 是上游回的任意字符串,`:href` 会把它当 URL 打开(`javascript:` 前缀即等价于执行脚本)。
+          要恢复成可点击链接,应先加 http/https 协议白名单。
+        -->
+        <el-descriptions-item v-if="detail.code_url" label="支付链接">
+          <span style="word-break: break-all">{{ detail.code_url }}</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="备注">{{ detail.description || "-" }}</el-descriptions-item>
+      </el-descriptions>
+
+      <h4 style="margin-top: 20px">回调轨迹</h4>
+      <el-table :data="notifyLogs" border size="small">
+        <el-table-column prop="created_time" label="时间" width="180" />
+        <el-table-column prop="trade_type" label="类型" width="70">
+          <template #default="{ row }">{{ tradeTypeLabel(row.trade_type) }}</template>
+        </el-table-column>
+        <el-table-column prop="verify_status" label="验签" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.verify_status === 'SUCCESS' ? 'success' : 'danger'" size="small">
+              {{ row.verify_status }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="process_status" label="处理" width="130" />
+        <el-table-column label="原文" min-width="200">
+          <template #default="{ row }">
+            <el-popover placement="left" :width="600" trigger="click">
+              <template #reference>
+                <el-button link type="primary">查看</el-button>
+              </template>
+              <pre style="max-height: 400px; overflow: auto">{{ row.raw_body }}</pre>
+            </el-popover>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup lang="ts">
+// ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
+//   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
+//   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+defineOptions({
+  name: "LeshouyingOrder",
+  inheritAttrs: false,
+});
+
+import { onMounted, reactive, ref } from "vue";
+import {
+  getLsyOrderPage,
+  getLsyOrderDetail,
+  getLsyNotifyLogs,
+} from "@/api/module_payment/collection";
+
+const STATUS_OPTIONS = [
+  { label: "已建单", value: "CREATED" },
+  { label: "支付中", value: "PAYING" },
+  { label: "支付成功", value: "SUCCESS" },
+  { label: "支付失败", value: "FAIL" },
+  { label: "已撤销", value: "CANCELED" },
+  { label: "已关单", value: "CLOSED" },
+  { label: "已过期", value: "EXPIRED" },
+  { label: "部分退款", value: "PARTIAL_REFUNDED" },
+  { label: "已全额退款", value: "REFUNDED" },
+];
+
+const SCENE_LABEL: Record<string, string> = {
+  BARCODE: "被扫",
+  QRCODE: "主扫",
+  H5: "H5",
+};
+
+/** 查询参数用 camelCase:后端 `@RequestParam` 未写 name,线上名即 `pageNo` / `pageSize` */
+const query = reactive({
+  pageNo: 1,
+  pageSize: 20,
+  status: "",
+  payScene: "",
+  keyword: "",
+});
+const list = ref<any[]>([]);
+const total = ref(0);
+const loading = ref(false);
+const detailVisible = ref(false);
+const detail = ref<any>(null);
+const notifyLogs = ref<any[]>([]);
+
+function sceneLabel(v: string) {
+  return SCENE_LABEL[v] || v;
+}
+function fen2yuan(fen?: number | null) {
+  return fen == null ? "-" : (fen / 100).toFixed(2);
+}
+function statusLabel(v: string) {
+  return STATUS_OPTIONS.find((s) => s.value === v)?.label || v;
+}
+function statusTagType(v: string) {
+  if (v === "SUCCESS") return "success";
+  if (v === "FAIL") return "danger";
+  if (v === "PAYING" || v === "CREATED") return "warning";
+  if (v === "REFUNDED" || v === "PARTIAL_REFUNDED") return "info";
+  return "info";
+}
+function tradeTypeLabel(v: string) {
+  return ({ "0": "退款", "1": "交易", "2": "云订单关单" } as Record<string, string>)[v] || v || "-";
+}
+
+async function loadData() {
+  loading.value = true;
+  try {
+    const res: any = await getLsyOrderPage(query);
+    // res 是 AxiosResponse(request.ts 的响应拦截器 `return response`,**不做解包**)
+    // → res.data = {code,msg,data}  → res.data.data = PageResult
+    // PageResult 的分页字段名是 `list`(不是 records/items);`total` 是单词,命名策略不影响它。
+    // ★ 实体字段是 snake_case(全局 Jackson SNAKE_CASE)—— 写成 camelCase 会「编译过、构建过、列表恒空」。
+    list.value = res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  query.pageNo = 1;
+  loadData();
+}
+function handleReset() {
+  query.keyword = "";
+  query.status = "";
+  query.payScene = "";
+  query.pageNo = 1;
+  loadData();
+}
+
+async function openDetail(row: any) {
+  const res: any = await getLsyOrderDetail(row.out_trade_no);
+  // ★ 两层 data:`request.ts` 的响应拦截器 `return response`,**不做解包**,
+  //   所以 res.data 是整个 `{code,msg,data}` 信封,业务数据在 res.data.data。
+  //   写成 res.data 会把信封塞给 el-descriptions,字段全部渲染成空 —— 页面不报错,
+  //   只是「什么都没显示」,是那种查半天查不出来的假成功。
+  detail.value = res.data.data;
+  const logs: any = await getLsyNotifyLogs(row.out_trade_no);
+  notifyLogs.value = logs.data.data || [];
+  detailVisible.value = true;
+}
+
+onMounted(loadData);
+</script>

+ 138 - 0
frontend/src/views/module_payment/collection/refund/index.vue

@@ -0,0 +1,138 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never">
+      <el-form :inline="true" :model="query">
+        <el-form-item label="退款单号">
+          <el-input
+            v-model="query.keyword"
+            placeholder="商户退款单号 / 退款单号"
+            clearable
+            style="width: 240px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="query.status" placeholder="全部" clearable style="width: 140px">
+            <el-option label="已建单" value="CREATED" />
+            <el-option label="退款中" value="REFUNDING" />
+            <el-option label="退款成功" value="SUCCESS" />
+            <el-option label="退款失败" value="FAIL" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card shadow="never" style="margin-top: 12px">
+      <el-table v-loading="loading" :data="list" border stripe>
+        <el-table-column
+          prop="out_refund_no"
+          label="商户退款单号"
+          min-width="200"
+          show-overflow-tooltip
+        />
+        <el-table-column
+          prop="out_trade_no"
+          label="原订单号"
+          min-width="200"
+          show-overflow-tooltip
+        />
+        <el-table-column label="退款金额(元)" width="130" align="right">
+          <template #default="{ row }">{{ fen2yuan(row.refund_fee) }}</template>
+        </el-table-column>
+        <el-table-column label="实退(元)" width="110" align="right">
+          <template #default="{ row }">{{ fen2yuan(row.refund_settle) }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="110">
+          <template #default="{ row }">
+            <el-tag
+              :type="
+                row.status === 'SUCCESS' ? 'success' : row.status === 'FAIL' ? 'danger' : 'warning'
+              "
+            >
+              {{ statusLabel(row.status) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="refund_no"
+          label="退款单号"
+          min-width="200"
+          show-overflow-tooltip
+        />
+        <el-table-column prop="created_time" label="创建时间" width="180" />
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="query.pageNo"
+        v-model:page-size="query.pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50, 100]"
+        layout="total, sizes, prev, pager, next, jumper"
+        style="margin-top: 12px; justify-content: flex-end"
+        @change="loadData"
+      />
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+// ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
+//   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
+//   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+defineOptions({
+  name: "LeshouyingRefund",
+  inheritAttrs: false,
+});
+
+import { onMounted, reactive, ref } from "vue";
+import { getLsyRefundPage } from "@/api/module_payment/collection";
+
+const STATUS_LABEL: Record<string, string> = {
+  CREATED: "已建单",
+  REFUNDING: "退款中",
+  SUCCESS: "退款成功",
+  FAIL: "退款失败",
+};
+
+/** 查询参数 camelCase / 响应字段 snake_case —— 见 `api/module_payment/collection.ts` 文件头 ★ */
+const query = reactive({ pageNo: 1, pageSize: 20, status: "", keyword: "" });
+const list = ref<any[]>([]);
+const total = ref(0);
+const loading = ref(false);
+
+function fen2yuan(fen?: number | null) {
+  return fen == null ? "-" : (fen / 100).toFixed(2);
+}
+function statusLabel(v: string) {
+  return STATUS_LABEL[v] || v;
+}
+
+async function loadData() {
+  loading.value = true;
+  try {
+    const res: any = await getLsyRefundPage(query);
+    // 两层 `.data.data`(响应拦截器不解包);分页字段名是 `list`
+    list.value = res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  query.pageNo = 1;
+  loadData();
+}
+function handleReset() {
+  query.keyword = "";
+  query.status = "";
+  query.pageNo = 1;
+  loadData();
+}
+
+onMounted(loadData);
+</script>