浏览代码

feat(统一支付前端): API 模块与商户配置页

alphaH 1 周之前
父节点
当前提交
30b5957773

+ 309 - 0
frontend/src/api/module_payment/unified.ts

@@ -0,0 +1,309 @@
+import request from "@/utils/request";
+
+/**
+ * 统一支付后台 API(spec §9.6)。
+ *
+ * ★ **金额单位是分,不是元**:请求体的 `amount` / `refund_amount` 与响应里的
+ *   `amount` / `refunded_amount` / `refundable_amount` 全部是整数分。
+ *   页面一律用 `@/utils/unifiedPay` 的 `yuanToFen` / `fenToYuan` 换算,
+ *   不要在各页面里另写一份 `* 100` / `/ 100`。
+ * ★ 字段命名按全局 `SNAKE_CASE` 策略:请求与响应都是 `mch_no` / `out_trade_no` /
+ *   `channel_extra` / `refundable_amount` 这种形状,不发 camelCase。
+ * ★ 所有 `id` 都是 19 位雪花号的**字符串**(超 JS 安全整数,按 number 处理会丢精度);
+ *   页面拼路径时也不要先转数字。
+ */
+
+const API_PATH = "/payment/unified";
+
+/**
+ * 分页结果 —— 后端 `PageResult` 全局 SNAKE_CASE 序列化后 JSON key 为 `list`
+ * (全局 `PageResult<T>` 声明的是 `items`,与后端契约不符)。
+ *
+ * ★ 与 `HarPageResult` 同形状、同一个坑:调用方必须**两个键都读**
+ *   (`res.data.data?.list || res.data.data?.items || []`)。
+ *   只读 `items` 会静默拿到空数组,表现为「列表永远是空的、没有任何报错」。
+ */
+export interface UnifiedPageResult<T> {
+  items?: T;
+  list: T;
+  total: number;
+  page_no: number;
+  page_size: number;
+}
+
+/** 商户配置(后端 UnifiedMerchantVO)。★ 响应里**没有** `app_secret`,只有 `app_secret_set`。 */
+export interface UnifiedMerchantVO {
+  id?: string;
+  /** 归属租户。租户号是自增小整数,不需要转字符串。 */
+  tenant_id?: number;
+  /** 上游商户号,全局唯一;上游通知只带它,无认证上下文靠它定位租户 */
+  mch_no: string;
+  /** 上游应用 ID */
+  app_id: string;
+  merchant_name?: string;
+  /** 渠道用户 ID 跳转用的 ifCode:fuioupay / wxpay / AUTO */
+  channel_user_if_code?: string;
+  /** "0"=启用 / "1"=停用 */
+  status: string;
+  /** 是否已配置密钥(后端现算,绝不回传密钥本身) */
+  app_secret_set: boolean;
+  description?: string;
+  created_time?: string;
+  updated_time?: string;
+}
+
+/**
+ * 商户配置保存入参(后端 UnifiedMerchantSaveDTO)—— `id` 为空即新增。
+ *
+ * ★ **只有超管**能调这个端点(后端 `@perm.hasRole('SUPERUSER')`),所以调用方必然
+ *   在超管上下文里:`tenant_id` 新增时必填(替哪个租户建号),修改时后端忽略它。
+ * ★ `app_secret` 编辑时**留空表示不修改**(后端按 blank 判断保留原值);
+ *   响应永远不会回传密钥,页面也绝不回显。
+ */
+export interface UnifiedMerchantSaveParams {
+  id?: string;
+  /** 新增必填;修改时忽略(归属不可改) */
+  tenant_id?: number;
+  mch_no: string;
+  app_id: string;
+  /** 新增必填;编辑留空 = 保留原值 */
+  app_secret?: string;
+  merchant_name?: string;
+  channel_user_if_code?: string;
+  /** "0"=启用 / "1"=停用;留空由数据库默认值兜底 */
+  status?: string;
+  description?: string;
+}
+
+/** 订单列表查询条件 —— 起止日期为 yyyy-MM-dd(含当天) */
+export interface UnifiedOrderQueryParams {
+  order_no?: string;
+  out_trade_no?: string;
+  mch_no?: string;
+  way_code?: string;
+  trade_status?: string;
+  start_date?: string;
+  end_date?: string;
+  page_no?: number;
+  page_size?: number;
+}
+
+/**
+ * 订单(后端 UnifiedOrderVO),后台详情与开放 API 共用。
+ *
+ * ★ `id` / `tenant_id` 只在后台视图里有(开放 API 不返回内部主键)。
+ * ★ `refundable_amount` **列表不算(null)、详情才传值** —— 列表上不要显示它。
+ */
+export interface UnifiedOrderVO {
+  id?: string;
+  tenant_id?: number;
+  order_no: string;
+  out_trade_no: string;
+  mch_no: string;
+  /** spec §4.6 的 16 个值之一,大小写敏感 */
+  way_code: string;
+  /** 金额,分 */
+  amount: number;
+  /** 已退金额,分 */
+  refunded_amount: number;
+  /** 可退金额,分;列表为 null */
+  refundable_amount?: number | null;
+  /** 0 订单生成 / 1 支付中 / 2 支付成功 / 3 支付失败 / 4 已撤销 / 5 已退款 / 6 订单关闭 */
+  trade_status: string;
+  upstream_pay_order_id?: string;
+  upstream_channel_order_no?: string;
+  pay_data_type?: string;
+  /** 上游原样返回(二维码 URL / 表单 HTML / 跳转链接);渲染前必须校验协议 */
+  pay_data?: string;
+  err_code?: string;
+  err_msg?: string;
+  pay_time?: string;
+  created_time?: string;
+  updated_time?: string;
+  /** PENDING=未回调 / SUCCESS / FAILED */
+  notify_status?: string;
+  next_query_time?: string;
+}
+
+/** 退款单(后端 UnifiedRefundVO)。 */
+export interface UnifiedRefundVO {
+  id?: string;
+  refund_no: string;
+  out_refund_no: string;
+  order_no: string;
+  mch_no: string;
+  /** 退款金额,分 */
+  amount: number;
+  /** 0 退款生成 / 1 退款中 / 2 退款成功 / 3 退款失败 / 4 退款关闭 */
+  state: string;
+  upstream_refund_order_id?: string;
+  upstream_channel_order_no?: string;
+  err_code?: string;
+  err_msg?: string;
+  success_time?: string;
+  created_time?: string;
+  updated_time?: string;
+  notify_status?: string;
+  next_query_time?: string;
+}
+
+/**
+ * 后台退款入参(后端 UnifiedAdminRefundDTO)。
+ *
+ * ★ `out_refund_no` 是**幂等键**,后端 `@NotBlank`:弹窗打开时生成一次,
+ *   提交失败重试必须复用同一个号,不能每次点击都换。
+ * ★ `order_no` / `out_trade_no` 不在报文里 —— 订单由路径上的 `{id}` 定位,
+ *   否则页面可以把 A 单的退款挂到 B 单上。
+ */
+export interface UnifiedRefundParams {
+  out_refund_no: string;
+  /** 退款金额,分 */
+  refund_amount: number;
+  /** 原因;留空由后端补「商户申请退款」 */
+  refund_reason?: string;
+}
+
+/**
+ * 收银台下单入参(后端 UnifiedOrderCreateCommand)。
+ *
+ * ★ **没有 `client_ip`**:控制器从传输层取来源地址,页面自报的 IP 不可信、
+ *   也不该由页面负责(不要为了「参数完整」把它加回来)。
+ */
+export interface UnifiedOrderCreateParams {
+  /** 上游商户号;租户只有一条配置时可空(服务层解析),多条时必填 */
+  mch_no?: string;
+  /** 金额,分 */
+  amount: number;
+  way_code: string;
+  /** 商品标题,必填 */
+  subject: string;
+  body?: string;
+  /** 调用方单号,幂等键,必填(`buildOutTradeNo` 生成) */
+  out_trade_no: string;
+  /** 下游回调地址快照(只存本地,绝不透传给上游) */
+  notify_url?: string;
+  /** 上游同步跳转地址,透传 */
+  return_url?: string;
+  /** 过期间隔(秒);空则后端用 300(收银台) */
+  expired_time?: number;
+  /** 按 way_code 收敛的额外参数(对象形态,后端序列化成紧凑 JSON 字符串) */
+  channel_extra?: Record<string, unknown>;
+  /** 上游原样返回的透传参数 */
+  ext_param?: string;
+}
+
+/**
+ * 统一支付后台 API。
+ *
+ * 每个方法都返回 `request<ApiResponse<...>>(…)` 的原始 promise —— **不解包**,
+ * 与 `HarAPI` 一致,调用方自己读 `res.data.data`(列表还要双键读 `list` / `items`)。
+ */
+const UnifiedAPI = {
+  // ---------- 商户配置 ----------
+
+  /** 商户配置列表(分页);租户身份只能看到自己租户的行(后端过滤) */
+  merchantPage(params: {
+    /** 租户筛选 —— 只有超管看得见所有租户的行(后端对超管跳过租户过滤) */
+    tenant_id?: number;
+    mch_no?: string;
+    merchant_name?: string;
+    /** "0"=启用 / "1"=停用 */
+    status?: string;
+    page_no?: number;
+    page_size?: number;
+  }) {
+    return request<ApiResponse<UnifiedPageResult<UnifiedMerchantVO[]>>>({
+      url: `${API_PATH}/merchant`,
+      method: "get",
+      params,
+    });
+  },
+
+  /** 新增或修改商户配置(`id` 有值为修改);**仅超管身份** */
+  merchantSave(data: UnifiedMerchantSaveParams) {
+    return request<ApiResponse<UnifiedMerchantVO>>({
+      url: `${API_PATH}/merchant`,
+      method: "post",
+      data,
+    });
+  },
+
+  /**
+   * 删除商户配置;**仅超管身份**。
+   * 已有订单/退款时后端返回 400「已有交易,不能删除,请停用」—— 提示运营停用而不是删除。
+   */
+  merchantDelete(id: string) {
+    return request<ApiResponse<void>>({
+      url: `${API_PATH}/merchant/${id}`,
+      method: "delete",
+    });
+  },
+
+  // ---------- 订单 ----------
+
+  /** 订单列表(分页) */
+  orderPage(params: UnifiedOrderQueryParams) {
+    return request<ApiResponse<UnifiedPageResult<UnifiedOrderVO[]>>>({
+      url: `${API_PATH}/order`,
+      method: "get",
+      params,
+    });
+  },
+
+  /** 订单详情(含 `refundable_amount`);`id` 是雪花号字符串 */
+  orderDetail(id: string) {
+    return request<ApiResponse<UnifiedOrderVO>>({
+      url: `${API_PATH}/order/${id}`,
+      method: "get",
+    });
+  },
+
+  /** 主动同步订单与未终态退款;上游异常统一折叠成 500「上游同步失败」 */
+  orderSync(id: string) {
+    return request<ApiResponse<void>>({
+      url: `${API_PATH}/order/${id}/sync`,
+      method: "post",
+    });
+  },
+
+  /**
+   * 发起退款(`refund_amount` 单位分)。
+   * 返回的是**受理结果**,不是已退款到账 —— 到账由通知/轮询把 state 收敛成 2。
+   */
+  orderRefund(id: string, data: UnifiedRefundParams) {
+    return request<ApiResponse<UnifiedRefundVO>>({
+      url: `${API_PATH}/order/${id}/refund`,
+      method: "post",
+      data,
+    });
+  },
+
+  /** 该订单的退款列表(后台详情抽屉用,共用 `:detail` 权限) */
+  orderRefunds(id: string) {
+    return request<ApiResponse<UnifiedRefundVO[]>>({
+      url: `${API_PATH}/order/${id}/refunds`,
+      method: "get",
+    });
+  },
+
+  // ---------- 收银台 ----------
+
+  /** 收银台下单(主扫/被扫/H5 共用);归属只来自登录身份,报文中没有租户字段 */
+  cashierCreate(data: UnifiedOrderCreateParams) {
+    return request<ApiResponse<UnifiedOrderVO>>({
+      url: `${API_PATH}/cashier/order`,
+      method: "post",
+      data,
+    });
+  },
+
+  /** 收银台查本单;后端 3 秒内直接返回本地快照(节流,不每跳都打上游) */
+  cashierOrder(orderNo: string) {
+    return request<ApiResponse<UnifiedOrderVO>>({
+      url: `${API_PATH}/cashier/order/${orderNo}`,
+      method: "get",
+    });
+  },
+};
+
+export default UnifiedAPI;

+ 161 - 0
frontend/src/utils/unifiedPay.ts

@@ -0,0 +1,161 @@
+/**
+ * 统一支付前端的展示常量与纯函数。
+ *
+ * ★ 金额单位:后端与 API 层一律是**分**(整数)。本文件是元↔分换算的**唯一**入口,
+ *   页面不要在模板或事件里各写一份 `* 100` / `/ 100` —— 两份换算迟早出现
+ *   「显示 1.00 元、实际扣 0.01 元」这类只在某个页面上成立的缺陷。
+ * ★ 状态值直接采用上游/后端枚举(字符串),这里只做展示映射,不做本地转义。
+ */
+
+/** 订单状态(上游 orderState / state,spec §4.5)—— 表外的值由页面原样显示。 */
+export const UNIFIED_ORDER_STATUS_TEXT: Record<string, string> = {
+  "0": "订单生成",
+  "1": "支付中",
+  "2": "支付成功",
+  "3": "支付失败",
+  "4": "已撤销",
+  "5": "已退款",
+  "6": "订单关闭",
+};
+
+/** 退款状态(上游 state,spec §4.5)—— 表外的值由页面原样显示。 */
+export const UNIFIED_REFUND_STATE_TEXT: Record<string, string> = {
+  "0": "退款生成",
+  "1": "退款中",
+  "2": "退款成功",
+  "3": "退款失败",
+  "4": "退款关闭",
+};
+
+/** 下游回调状态(后端 UnifiedNotifyStatus)。文案与汇安融通道同一套。 */
+export const UNIFIED_NOTIFY_STATUS_TEXT: Record<string, string> = {
+  PENDING: "未回调",
+  SUCCESS: "回调成功",
+  FAILED: "回调失败",
+};
+
+/**
+ * 支付方式(spec §4.6 的 16 个 wayCode,顺序即页面下拉顺序)。
+ *
+ * ★ value 逐字大小写敏感:后端按这个字符串拼上游报文,改一个字母上游就不认。
+ */
+export const UNIFIED_WAY_CODE_OPTIONS: Array<{ value: string; label: string }> = [
+  { value: "QR_CASHIER", label: "聚合扫码" },
+  { value: "AUTO_BAR", label: "聚合条码" },
+  { value: "ALI_BAR", label: "支付宝条码" },
+  { value: "ALI_JSAPI", label: "支付宝生活号" },
+  { value: "ALI_APP", label: "支付宝 APP" },
+  { value: "ALI_WAP", label: "支付宝 WAP" },
+  { value: "ALI_PC", label: "支付宝 PC 网站" },
+  { value: "ALI_QR", label: "支付宝二维码" },
+  { value: "WX_BAR", label: "微信条码" },
+  { value: "WX_JSAPI", label: "微信公众号" },
+  { value: "WX_LITE", label: "微信小程序" },
+  { value: "WX_APP", label: "微信 APP" },
+  { value: "WX_H5", label: "微信 H5" },
+  { value: "WX_NATIVE", label: "微信扫码" },
+  { value: "YSF_BAR", label: "云闪付条码" },
+  { value: "YSF_JSAPI", label: "云闪付 JSAPI" },
+];
+
+/**
+ * 元(字符串)→ 分(整数)。
+ *
+ * ★ 用字符串拆分而不是 `Number(text) * 100`:浮点乘法会把 `0.29` 算成
+ *   `28.999999999999996`,四舍五入后变成 28 分 —— 少收一分钱,且只在部分金额上出现。
+ * ★ 非法输入(负数、超过两位小数、空串、非数字)返回 `null`,由调用方拦在提交前。
+ *   容忍「打到一半」的输入:末尾只挂一个小数点(`"5."`),也吃省略整数位的 `".5"`。
+ */
+export function yuanToFen(text: string): number | null {
+  const s = text.trim().replace(/\.$/, "");
+  if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
+    return null;
+  }
+  const [intPart, decPart = ""] = s.split(".");
+  const fen = Number(intPart) * 100 + Number((decPart + "00").slice(0, 2));
+  // 超出安全整数的输入只可能是误触(或粘贴了别的单号),别拿它去建单
+  return Number.isSafeInteger(fen) ? fen : null;
+}
+
+/**
+ * 分 → 元(显示用,固定两位小数)。
+ *
+ * ★ 整数除法 + 两位补零,不经浮点:`(fen / 100).toFixed(2)` 在金额上会引入
+ *   二进制小数误差,而这里只需要把整数拆成「元」和「分」两段。
+ * ★ `null` / `undefined` / 非有限数返回空串:那是「没有这个值」(如列表未算
+ *   `refundable_amount`),不是 0 元;页面不要把空值显示成一个具体的金额。
+ */
+export function fenToYuan(fen: number | null | undefined): string {
+  if (fen === null || fen === undefined || !Number.isFinite(fen)) {
+    return "";
+  }
+  const sign = fen < 0 ? "-" : "";
+  const abs = Math.abs(Math.trunc(fen));
+  const yuan = Math.trunc(abs / 100);
+  const cent = abs % 100;
+  return `${sign}${yuan}.${String(cent).padStart(2, "0")}`;
+}
+
+/**
+ * 是否是可以安全交给浏览器导航/渲染的 URL。
+ *
+ * 只放行 `http:` / `https:`:上游 `pay_data` / `pay_data_type=codeImgUrl` 是原样返回的
+ * 外部数据,`javascript:`、`data:`、`file:` 一旦进了 `window.open`、`<img src>` 或
+ * `QRCode` 之外的渲染面,就是一条可执行的注入路径(spec §9.2 / §9.3)。
+ */
+export function isSafeHttpUrl(url: string | null | undefined): boolean {
+  if (!url) {
+    return false;
+  }
+  try {
+    const parsed = new URL(url);
+    return parsed.protocol === "http:" || parsed.protocol === "https:";
+  } catch {
+    return false;
+  }
+}
+
+/** `yyyyMMddHHmmss`(本地时区,14 位)—— 上游与我方订单号共用的时间片段。 */
+function compactTimestamp(now: Date): string {
+  const pad = (n: number) => String(n).padStart(2, "0");
+  return (
+    String(now.getFullYear()) +
+    pad(now.getMonth() + 1) +
+    pad(now.getDate()) +
+    pad(now.getHours()) +
+    pad(now.getMinutes()) +
+    pad(now.getSeconds())
+  );
+}
+
+/** 随机片段只留数字、最多 6 位(调用方给的可能是 UUID/字母串)。 */
+function randDigits(rand: string): string {
+  return (rand ?? "").replace(/\D/g, "").slice(0, 6);
+}
+
+/**
+ * 收银台订单的 `out_trade_no`:`C` + `yyyyMMddHHmmss` + ≤6 位随机数字。
+ *
+ * ★ 这是**幂等键**:一次提交双击必须复用同一个号(任务 21 在点击时生成一次并保存),
+ *   重新生成等于让同一笔收款落两单。
+ */
+export function buildOutTradeNo(now: Date, rand: string): string {
+  return `C${compactTimestamp(now)}${randDigits(rand)}`;
+}
+
+/** 后台退款的 `out_refund_no`:`R` + `yyyyMMddHHmmss` + ≤6 位随机数字,幂等键。 */
+export function buildOutRefundNo(now: Date, rand: string): string {
+  return `R${compactTimestamp(now)}${randDigits(rand)}`;
+}
+
+/**
+ * 收银台是否继续轮询订单状态。
+ *
+ * ★ 只轮询**非终态**(0 订单生成 / 1 支付中):2 支付成功之后还可能走 5 已退款,
+ *   但那不是收银台这一屏要等的事;3/4/6 都是终态。
+ * ★ 5 分钟上限(300000ms)是兜底:上游长时间不收敛时停止请求并提示到订单页手动同步,
+ *   不能让一个开着的页面永远每 3 秒打一次后端。
+ */
+export function shouldPollUnified(status: string, elapsedMs: number): boolean {
+  return ["0", "1"].includes(status) && elapsedMs < 300000;
+}

+ 482 - 0
frontend/src/views/module_payment/unified/merchant/index.vue

@@ -0,0 +1,482 @@
+<template>
+  <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素,
+       多根 fragment 会导致切换动画无法完成、后续路由挂载卡死 -->
+  <div class="unified-merchant">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>商户配置</span>
+          <!-- 只有超管能建号:判据用的是 sys_user.is_superuser,与后端
+               @PreAuthorize("@perm.hasRole('SUPERUSER')") 是同一个 —— 不要改用
+               v-hasPerm(那判的是角色授权,与端点实际校验的身份不是一回事)。
+               spec §9.5 明确不为 save/delete 造权限点。 -->
+          <el-button v-if="isSuperAdmin" type="primary" icon="Plus" @click="openCreate">
+            新增配置
+          </el-button>
+        </div>
+      </template>
+
+      <el-alert
+        class="mb-4"
+        type="info"
+        :closable="false"
+        show-icon
+        :title="
+          isSuperAdmin
+            ? '上游商户号与应用密钥由运营完成报备后录入;商户号全局唯一,新增时请选择归属租户。编辑时密钥留空表示不修改。'
+            : '这里显示的是本租户已配置的上游商户号(只读)。如需新增或修改,请联系平台运营。'
+        "
+      />
+
+      <div class="mb-4">
+        <el-form :inline="true" :model="searchForm">
+          <!-- 租户筛选只对超管有意义:非超管的查询被后端按租户钉死,只会返回自己的行 -->
+          <el-form-item v-if="isSuperAdmin" label="租户">
+            <el-select
+              v-model="searchForm.tenant_id"
+              placeholder="全部租户"
+              clearable
+              filterable
+              style="width: 180px"
+            >
+              <el-option
+                v-for="t in tenantList"
+                :key="t.id"
+                :label="t.name || String(t.id)"
+                :value="t.id"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="商户号">
+            <el-input
+              v-model="searchForm.mch_no"
+              placeholder="商户号"
+              clearable
+              style="width: 180px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="商户名称">
+            <el-input
+              v-model="searchForm.merchant_name"
+              placeholder="商户名称"
+              clearable
+              style="width: 180px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-select
+              v-model="searchForm.status"
+              placeholder="全部状态"
+              clearable
+              style="width: 120px"
+            >
+              <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>
+      </div>
+
+      <el-table v-loading="loading" :data="list" border stripe>
+        <template #empty>
+          <el-empty description="暂无数据" />
+        </template>
+        <!-- 租户列只对超管有意义:非超管的查询被后端按租户钉死,整列都是同一个值 -->
+        <el-table-column v-if="isSuperAdmin" label="租户" min-width="140">
+          <template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
+        </el-table-column>
+        <el-table-column prop="mch_no" label="商户号" min-width="160" />
+        <el-table-column label="商户名称" min-width="160">
+          <template #default="{ row }">{{ row.merchant_name || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="app_id" label="应用 ID" min-width="200" show-overflow-tooltip />
+        <el-table-column label="IfCode" width="130">
+          <template #default="{ row }">{{ row.channel_user_if_code || "-" }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="90">
+          <template #default="{ row }">
+            <el-tag :type="row.status === '0' ? 'success' : 'info'">
+              {{ row.status === "0" ? "启用" : "停用" }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="更新时间" width="170">
+          <template #default="{ row }">
+            {{ row.updated_time ? dayjs(row.updated_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="170" fixed="right">
+          <template #default="{ row }">
+            <el-button v-if="isSuperAdmin" size="small" type="primary" @click="openEdit(row)">
+              编辑
+            </el-button>
+            <el-button v-if="isSuperAdmin" size="small" type="danger" @click="handleDelete(row)">
+              删除
+            </el-button>
+            <span v-if="!isSuperAdmin" class="readonly-hint">只读</span>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="mt-4 flex justify-end">
+        <el-pagination
+          v-model:current-page="pageNo"
+          v-model:page-size="pageSize"
+          :total="total"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="load"
+          @current-change="load"
+        />
+      </div>
+    </el-card>
+
+    <el-dialog
+      v-model="dialogVisible"
+      :title="form.id ? '编辑商户配置' : '新增商户配置'"
+      width="600px"
+      @closed="handleDialogClosed"
+    >
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
+        <!-- 只新增时显示:归属不可改(后端修改分支忽略 tenant_id),
+             旧数据即使没有 tenant_id 也不该挡住改备注 -->
+        <el-form-item v-if="!form.id" label="租户" prop="tenant_id">
+          <el-select
+            v-model="form.tenant_id"
+            placeholder="选择该配置归属的租户"
+            filterable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="t in tenantList"
+              :key="t.id"
+              :label="t.name || String(t.id)"
+              :value="t.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="商户号" prop="mch_no">
+          <el-input
+            v-model="form.mch_no"
+            placeholder="上游分配的唯一商户号"
+            maxlength="64"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="应用 ID" prop="app_id">
+          <el-input v-model="form.app_id" placeholder="上游分配的应用 ID" maxlength="64" />
+        </el-form-item>
+        <el-form-item label="应用密钥" prop="app_secret">
+          <!-- ★ 永不回显:响应里没有 app_secret,编辑时这里**始终是空的**。
+               留空 = 保留原值(后端按 blank 判断),不是「清空密钥」。
+               autocomplete=new-password 防止浏览器把登录密码/旧密钥填进来。 -->
+          <el-input
+            v-model="form.app_secret"
+            type="password"
+            autocomplete="new-password"
+            :placeholder="secretPlaceholder"
+            maxlength="128"
+          />
+          <div v-if="form.id" class="form-hint">
+            {{ editingSecretSet ? "已配置,留空不修改" : "尚未配置密钥,请填写" }}
+          </div>
+        </el-form-item>
+        <el-form-item label="商户名称" prop="merchant_name">
+          <el-input v-model="form.merchant_name" maxlength="128" />
+        </el-form-item>
+        <el-form-item label="渠道 IfCode" prop="channel_user_if_code">
+          <el-select v-model="form.channel_user_if_code" style="width: 100%">
+            <el-option
+              v-for="o in CHANNEL_USER_IF_CODE_OPTIONS"
+              :key="o.value"
+              :label="o.label"
+              :value="o.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio v-for="s in STATUS_OPTIONS" :key="s.value" :value="s.value">
+              {{ s.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注" prop="description">
+          <el-input v-model="form.description" type="textarea" :rows="3" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+// ★ 与 sys_menu.route_name 逐字一致(`db/unified-menu.sql`):
+//   keep-alive 的 include 匹配的是组件名,不写这一行会按文件名推成 "index",
+//   `keep_alive=true` 静默失效(乐收赢先例 bf09016)。
+defineOptions({
+  name: "payment-unified-merchant",
+  inheritAttrs: false,
+});
+
+import { computed, onMounted, reactive, ref } from "vue";
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
+import dayjs from "dayjs";
+import UnifiedAPI, {
+  type UnifiedMerchantSaveParams,
+  type UnifiedMerchantVO,
+} from "@/api/module_payment/unified";
+import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
+import { useUserStore } from "@/store";
+
+const userStore = useUserStore();
+
+/**
+ * 超管判据 = `sys_user.is_superuser`,与后端 `@perm.hasRole('SUPERUSER')` 同一个来源。
+ *
+ * ★ 不要换成 `v-hasPerm`:那读的是角色/菜单权限串,与端点实际校验的**身份**不是一回事。
+ *   spec §9.5 也明确不建 `merchant:save` / `merchant:delete` 权限行。
+ */
+const isSuperAdmin = computed(() => userStore.basicInfo.is_superuser === true);
+
+/** 状态枚举:商户配置行是字符串 "0" 启用 / "1" 停用(DDL 默认值 "0") */
+const STATUS_OPTIONS = [
+  { value: "0", label: "启用" },
+  { value: "1", label: "停用" },
+];
+
+/** 渠道用户 ID 跳转的 ifCode(spec §4.7):服务商 / 自有公众号 / 自动 */
+const CHANNEL_USER_IF_CODE_OPTIONS = [
+  { value: "fuioupay", label: "服务商模式(fuioupay)" },
+  { value: "wxpay", label: "自有公众号(wxpay)" },
+  { value: "AUTO", label: "自动(AUTO)" },
+];
+
+/**
+ * 租户下拉数据。只有超管会用到,非超管不请求(那个接口对他们也未必有权限)。
+ *
+ * ★ 类型把 `id` 收窄成**必有**:`TenantTable.id` 是可选的 `number | undefined`,
+ *   而 `el-option` 的 `value` 不收 `undefined`(vue-tsc TS2322)。
+ *   下拉里出现一个选不中的租户在赋值那一刻就不成立(下面 onMounted 里过滤)。
+ */
+const tenantList = ref<(TenantTable & { id: number })[]>([]);
+
+const tenantName = (id?: number) => {
+  if (id === undefined || id === null) {
+    return "-";
+  }
+  const t = tenantList.value.find((x) => x.id === id);
+  return t ? t.name || String(id) : String(id);
+};
+
+const list = ref<UnifiedMerchantVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+
+const dialogVisible = ref(false);
+const saving = ref(false);
+const formRef = ref<FormInstance>();
+/** 编辑中的那一行是否已配置密钥 —— 只用于提示文案,密钥本身永远不回传/不回显 */
+const editingSecretSet = ref(false);
+
+const searchForm = reactive({
+  tenant_id: undefined as number | undefined,
+  mch_no: "",
+  merchant_name: "",
+  status: undefined as string | undefined,
+});
+
+const defaultForm = (): UnifiedMerchantSaveParams => ({
+  id: undefined,
+  tenant_id: undefined,
+  mch_no: "",
+  app_id: "",
+  app_secret: "",
+  merchant_name: "",
+  channel_user_if_code: "fuioupay",
+  status: "0",
+  description: "",
+});
+
+const form = reactive<UnifiedMerchantSaveParams>(defaultForm());
+
+/**
+ * 密钥提示:新增必填;编辑时「已配置,留空不修改」/「尚未配置,请填写」。
+ * ★ 这里只说状态,不提供查看原密钥的入口 —— 响应里根本没有这个字段。
+ */
+const secretPlaceholder = computed(() => {
+  if (!form.id) {
+    return "请输入上游应用密钥";
+  }
+  return editingSecretSet.value ? "已配置,留空不修改" : "未配置,请填写";
+});
+
+/**
+ * 租户只在**新增**时必填:后端对修改一律忽略 tenant_id(归属不可改),
+ * 编辑态那一格整个不渲染,校验它没有意义。
+ * 新增时 app_secret 必填(后端要求 tenant_id/mch_no/app_id/app_secret)。
+ */
+const rules = computed<FormRules>(() => ({
+  tenant_id: form.id ? [] : [{ required: true, message: "请选择租户", trigger: "change" }],
+  mch_no: [{ required: true, message: "请填写商户号", trigger: "blur" }],
+  app_id: [{ required: true, message: "请填写应用 ID", trigger: "blur" }],
+  app_secret: form.id ? [] : [{ required: true, message: "请填写应用密钥", trigger: "blur" }],
+}));
+
+async function load() {
+  loading.value = true;
+  try {
+    const res = await UnifiedAPI.merchantPage({
+      tenant_id: searchForm.tenant_id || undefined,
+      mch_no: searchForm.mch_no || undefined,
+      merchant_name: searchForm.merchant_name || undefined,
+      status: searchForm.status || undefined,
+      page_no: pageNo.value,
+      page_size: pageSize.value,
+    });
+    // 后端序列化的分页键是 `list`,全局 TS 声明写的是 `items` —— 两个都读,
+    // 只读 items 会静默拿到空数组(HAR 商户页同一个坑)。
+    list.value = res.data.data?.list || res.data.data?.items || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  pageNo.value = 1;
+  load();
+}
+
+function handleReset() {
+  searchForm.tenant_id = undefined;
+  searchForm.mch_no = "";
+  searchForm.merchant_name = "";
+  searchForm.status = undefined;
+  handleSearch();
+}
+
+function openCreate() {
+  editingSecretSet.value = false;
+  Object.assign(form, defaultForm());
+  dialogVisible.value = true;
+}
+
+function openEdit(row: UnifiedMerchantVO) {
+  editingSecretSet.value = row.app_secret_set === true;
+  // ★ app_secret 有意不取:响应里没有它,表单里它永远是空的(留空 = 不修改)
+  Object.assign(form, defaultForm(), {
+    id: row.id,
+    tenant_id: row.tenant_id,
+    mch_no: row.mch_no,
+    app_id: row.app_id,
+    merchant_name: row.merchant_name,
+    channel_user_if_code: row.channel_user_if_code || "fuioupay",
+    status: row.status || "0",
+    description: row.description,
+  });
+  dialogVisible.value = true;
+}
+
+function handleDialogClosed() {
+  formRef.value?.clearValidate();
+  editingSecretSet.value = false;
+  Object.assign(form, defaultForm());
+}
+
+async function handleSubmit() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) {
+    return;
+  }
+  const payload: UnifiedMerchantSaveParams = { ...form };
+  // 编辑时留空 = 保留原值:不发空串,语义更明确(后端两种都按 blank 处理)
+  if (payload.id && !payload.app_secret) {
+    delete payload.app_secret;
+  }
+  saving.value = true;
+  try {
+    await UnifiedAPI.merchantSave(payload);
+    ElMessage.success("保存成功");
+    dialogVisible.value = false;
+    load();
+  } finally {
+    saving.value = false;
+  }
+}
+
+async function handleDelete(row: UnifiedMerchantVO) {
+  const owner = isSuperAdmin.value ? `(租户 ${tenantName(row.tenant_id)})` : "";
+  try {
+    await ElMessageBox.confirm(
+      `删除后该商户号将无法下单。若已有交易,后端会拒绝删除,请改用「停用」。确定删除「${row.mch_no}」${owner}?`,
+      "提示",
+      { type: "warning" }
+    );
+  } catch {
+    return;
+  }
+  try {
+    await UnifiedAPI.merchantDelete(row.id as string);
+  } catch {
+    // 拦截器已经用后端原话弹过「已有交易,不能删除,请停用」(400),
+    // 这里不再叠一条 toast;列表保持不变,运营可以改为编辑 → 停用。
+    return;
+  }
+  ElMessage.success("删除成功");
+  load();
+}
+
+onMounted(async () => {
+  load();
+  if (isSuperAdmin.value) {
+    try {
+      const res = await TenantAPI.listTenant({ page_no: 1, page_size: 100 });
+      // 后端分页键是 `list`,全局声明写的是 `items` —— 两个都读(同 load)
+      const page = res.data.data as unknown as {
+        list?: TenantTable[];
+        items?: TenantTable[];
+      };
+      tenantList.value = (page?.list || page?.items || []).filter(
+        (t): t is TenantTable & { id: number } => typeof t.id === "number"
+      );
+    } catch {
+      // 租户列表加载失败不阻塞列表查询(租户列退化成显示 tenant_id 数字)
+    }
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.form-hint {
+  margin-top: 2px;
+  font-size: 12px;
+  line-height: 1.5;
+  color: var(--el-text-color-secondary);
+}
+
+.readonly-hint {
+  color: var(--el-text-color-secondary);
+}
+</style>