feat: 支付商户

This commit is contained in:
luo
2026-09-10 16:50:06 +08:00
parent ef966171b4
commit d3d257cdf8
21 changed files with 2884 additions and 27 deletions

View File

@@ -0,0 +1,64 @@
# 支付商户与商户池管理设计
## Context
接口文档把能力拆成支付商户、商户池和微信授权配置三部分。前端必须在同一个平台专属入口内完成管理,同时避免把商户凭证和内部路由细节带入页面状态。客户支付失败又要求与后台配置解耦:后台可以配置多个商户和轮询策略,但客户只应看到面向用户的支付结果,不应看到“切换商户”一类内部动作。
## Goals
- 通过一个后台入口管理支付商户、商户池和微信授权配置。
- 仅允许超级管理员和平台用户访问。
- 支持商户、商户池、授权配置的启停状态管理。
- 支持商户池成员拖拽排序、策略选择和阈值配置。
- 保证支付凭证只写、不回显、不持久化。
- 统一“暂无可用商户”和普通支付失败的用户提示。
## Non-Goals
- 前端不实现商户路由算法。
- 前端不保存、展示或恢复支付凭证明文。
- 不给客户支付端展示商户池内部配置。
- 不把“切换商户重试”作为用户可操作流程。
## Decisions
### 单一管理入口与三个子页签
新增 `/settings/payment-merchant-pools`,页面内使用“支付商户”“商户池”“微信授权配置”三个页签。这样与接口文档的结构一致,也便于统一权限检查和凭证清理策略。
替代方案是为三部分分别增加菜单项;该方案会让权限、路由和状态管理重复,暂不采用。
### 按 `user_type` 控制访问
现有路由守卫主要依赖角色,但需求约束是超级管理员和平台用户,因此新增显式的用户类型限制:`1``2` 可访问,`3``4` 不可访问。菜单隐藏与直接 URL 访问必须使用同一判断,避免仅做 UI 隐藏。
### 凭证只写且只存在于内存
商户 `credentials` 和微信授权敏感字段只允许在创建或显式更换时输入。读取响应不得回填这些字段;页面模型只在当前弹层或表单生命周期内保存输入值,提交、取消、关闭或卸载时清理。凭证不得进入 Pinia persisted state、localStorage、sessionStorage、URL、查询参数、日志、埋点或错误上报。
详情页不回显凭证内容,只显示“已配置/未配置”和 `credential_version`。如果后端读取接口意外返回敏感字段,前端适配层必须丢弃,而不是仅依赖模板隐藏。
### 成员数组顺序就是轮询顺序
商户池编辑使用拖拽排序。提交时直接把当前排序后的商户 ID 数组写入 `member_ids`,不新增独立的 `sort` 字段,也不在前端重新排序。成员选择默认限制为与商户池 `payment_method` 相同的商户,并禁止重复 ID。
### 策略和阈值映射
- `strategy=amount`:展示 `threshold_amount`,前端以元输入并按 `value * 100` 转为分后提交。
- `strategy=count`:展示 `threshold_count`,只接受正整数。
- `strategy=time`:展示 `time_period_unit``time_period_value`,并按需提交 `time_period_started_at`
- `statistic_cycle`:支持 `round``day``month`;只在接口允许的金额或笔数策略下提交有效值。
- `routing_epoch`:仅展示后端返回的当前路由统计世代,前端不编辑。
### 无可用商户使用稳定错误码
接口简版没有给出支付失败错误码,实施前必须与后端确认稳定的机器可读值。前端只按错误码映射,不按 `msg` 文本判断。无论后端使用何种最终编码,命中该语义时客户界面都只显示“暂无可用商户”。
普通支付失败统一使用“支付失败,请重新发起支付”。前端不自动切换商户,也不重放同一支付请求;用户再次操作时按支付接口的新请求语义重新发起。
## Risks and Trade-offs
- 接口文档未定义 `credentials` 的字段结构。实现时需要后端补充各 `provider_type` 的写入 schema或继续保持单个只写对象但不得把结构暴露为可回显配置。
- 若后端读取接口未脱敏,前端仍然能够防御性丢弃,但服务端响应、网关日志和网络抓包仍可能泄露凭证;该风险必须由后端脱敏共同控制。
- 客户支付端若不在当前仓库,文案和错误码任务需要跨仓库联调;本提案负责固化契约,不能仅通过后台页面上线完成验收。
- 金额阈值若直接按分展示会降低可读性,因此采用元输入、分传输;需要测试防止小数点精度和空值转换错误。

View File

@@ -0,0 +1,54 @@
# Change: 新增支付商户与商户池管理
## Why
`docs/产品迭代8月份/支付商户API简版.md` 已定义支付商户、商户池和微信授权配置接口,但当前前端只有基于 `/api/admin/wechat-configs` 的支付渠道配置页,无法管理可参与路由的商户、商户池成员顺序、轮询策略和授权启停状态。
同时,后台页面可能读取到 `credentials`,客户支付失败时也容易暴露内部商户切换逻辑。本提案需要把平台专属访问、凭证最小暴露、商户池配置和客户侧失败反馈固化为可验收的 OpenSpec 契约。
## What Changes
- 新增 `payment-merchant-pool-management` capability覆盖
- 支付商户查询、创建、详情、按需更新和删除。
- 商户池查询、创建、详情、更新、启用和停用。
- 微信授权配置读取、保存及启停状态。
- 商户池成员排序、轮询策略、统计周期和金额/笔数/时间阈值配置。
- 新增 `payment-checkout-feedback` capability覆盖
- “暂无可用商户”唯一明确提示。
- 支付失败不暴露商户切换逻辑,不自动切换商户重试。
- 用户重新发起一笔支付时使用新的支付请求。
- 新增后台“商户池管理”入口,仅超级管理员和平台用户(`user_type``1``2`)可见、可访问。
- 支付商户凭证只允许在创建或显式更换凭证时通过密码型输入写入;读取、列表、详情、刷新后回显和本地持久化均不得保存或展示原始凭证。
- 微信授权配置中的 AppSecret、Token、AES Key 等敏感字段遵循同样的只写和缓存隔离规则。
- 该提案只定义契约和实施任务,不执行代码实现;提案获批后再进入实现阶段。
## Impact
- Affected specs:
- `payment-merchant-pool-management`
- `payment-checkout-feedback`
- Affected code:
- `src/types/api/paymentMerchantPools.ts`(新增)
- `src/api/modules/paymentMerchantPools.ts`(新增)
- `src/api/modules/index.ts`
- `src/types/api/index.ts`
- `src/router/routesAlias.ts`
- `src/router/routes/asyncRoutes.ts`
- `src/router/guards/permission.ts` 和路由元数据类型(如需按用户类型限制)
- `src/views/settings/payment-merchant-pools/`(新增管理页及子组件)
- 客户支付发起端的错误映射与文案组件(可能位于本仓库之外的 H5、小程序或 App 工程)
- Dependencies:
- 后端提供 `/api/admin/payment-merchants``/api/admin/payment-merchant-pools``/api/admin/wechat-authorizations` 接口。
- 后端为客户支付失败提供稳定的“无可用商户”机器可读错误码,前端不得依赖中文消息判断。
- 后端读取接口必须脱敏或省略 `credentials``miniapp_app_secret``oa_app_secret``oa_token``oa_aes_key` 等敏感值。
- Compatibility:
- 不复用或重命名现有 `/api/admin/wechat-configs` 渠道配置能力。
- 不向代理、企业客户或普通后台用户暴露商户池入口。
- 商户池内部路由行为不改变现有订单、充值或其他支付接口的请求结构。
## Non-Goals
- 不在前端实现商户选择算法或自行决定切换逻辑;实际路由由后端根据商户池策略执行。
- 不新增支付渠道、支付 SDK、退款或对账能力。
- 不提供凭证查看、复制、下载或历史明文回显能力。
- 不在客户支付端展示商户 ID、商户池、轮询策略、阈值或路由世代。

View File

@@ -0,0 +1,39 @@
# Payment Checkout Feedback Specification
## ADDED Requirements
### Requirement: 暂无可用商户提示
客户支付端 SHALL 使用稳定的机器可读错误码识别“无可用商户”,并使用唯一明确的中文提示,不暴露内部商户路由信息。
#### Scenario: 识别无可用商户错误
- **GIVEN** 后端在支付发起响应中返回已确认的“无可用商户”稳定错误码
- **WHEN** 客户点击支付并收到该错误
- **THEN** 页面 MUST 显示“暂无可用商户”
- **AND** 前端 MUST NOT 通过匹配中文 `msg` 或其他可变文案判断该错误
#### Scenario: 无可用商户时不展示内部信息
- **WHEN** 页面展示“暂无可用商户”
- **THEN** 页面 MUST NOT 展示商户 ID、商户名称、商户池名称、成员列表、轮询策略、阈值、`routing_epoch`、凭证或凭证版本
### Requirement: 客户支付失败反馈
客户支付端 SHALL 对普通支付失败使用面向用户的统一提示,并禁止暴露商户切换或自动重试的内部处理。
#### Scenario: 普通支付失败提示重新发起
- **GIVEN** 客户支付请求因非“无可用商户”原因失败
- **WHEN** 页面展示失败结果
- **THEN** 页面 MUST 显示“支付失败,请重新发起支付”
- **AND** 前端 MUST NOT 自动重放同一支付请求
#### Scenario: 不提示切换商户重试
- **WHEN** 任意客户支付失败
- **THEN** 页面 MUST NOT 显示“切换商户重试”或任何等价文案
- **AND** 页面 MUST NOT 提供切换商户的按钮、入口或操作提示
- **AND** 页面 MUST NOT 暴露后端是否尝试过多个商户
#### Scenario: 用户主动重新发起支付
- **GIVEN** 客户已收到支付失败提示
- **WHEN** 客户主动再次发起支付
- **THEN** 前端 MUST 按支付接口约定创建一笔新的支付请求
- **AND** 前端 MUST NOT 复用失败支付请求的商户选择或前端临时支付状态

View File

@@ -0,0 +1,170 @@
# Payment Merchant Pool Management Specification
## ADDED Requirements
### Requirement: 平台专属的商户池管理入口
系统 SHALL 仅向超级管理员和平台用户开放支付商户、商户池及微信授权配置的管理入口和操作。
#### Scenario: 超级管理员可见并访问
- **GIVEN** 当前登录账号的 `user_type``1`
- **WHEN** 用户加载设置菜单或访问 `/settings/payment-merchant-pools`
- **THEN** 系统 MUST 展示商户池管理入口并允许进入页面
#### Scenario: 平台用户可见并访问
- **GIVEN** 当前登录账号的 `user_type``2`
- **WHEN** 用户加载设置菜单或访问 `/settings/payment-merchant-pools`
- **THEN** 系统 MUST 展示商户池管理入口并允许进入页面
#### Scenario: 非平台账号被拒绝
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **WHEN** 用户加载设置菜单或直接输入 `/settings/payment-merchant-pools`
- **THEN** 系统 MUST NOT 展示商户池管理入口
- **AND** 系统 MUST 拒绝直接访问该页面
- **AND** 系统 MUST NOT 返回商户、商户池或授权配置数据
### Requirement: 支付商户管理接口
系统 SHALL 提供支付商户的分页查询、创建、详情、按需更新和删除接口。
#### Scenario: 查询和筛选支付商户
- **WHEN** 管理员请求 `GET /api/admin/payment-merchants`
- **THEN** 系统 MUST 支持 `page``page_size``payment_method``enabled` 查询参数
- **AND** `payment_method` MUST 支持 `wechat``alipay`
- **AND** 响应中的每个商户 MUST 包含 `id``name``payment_method``provider_type``merchant_identity``enabled``remark``credential_version``created_at``updated_at`
#### Scenario: 创建支付商户
- **WHEN** 管理员向 `POST /api/admin/payment-merchants` 提交 `name``payment_method``provider_type``merchant_identity``credentials``enabled``remark`
- **THEN** 系统 MUST 创建支付商户并返回新商户记录
- **AND** `provider_type``wechat` 时 MUST 只接受 `wechat``wechat_v2``fuiou`
- **AND** `provider_type``alipay` 时 MUST 只接受 `alipay`
#### Scenario: 查询支付商户详情
- **WHEN** 管理员请求 `GET /api/admin/payment-merchants/{id}`
- **THEN** 系统 MUST 返回指定商户的详情
- **AND** 响应 MUST NOT 包含任何支付凭证明文
#### Scenario: 按需更新和切换商户状态
- **WHEN** 管理员请求 `PUT /api/admin/payment-merchants/{id}` 并只提交 `enabled`
- **THEN** 系统 MUST 只更新该商户的 `enabled` 字段
- **AND** 系统 MUST 保留未提交字段的原值
#### Scenario: 确认后删除支付商户
- **WHEN** 管理员请求 `DELETE /api/admin/payment-merchants/{id}` 并提交 `confirm=true`
- **THEN** 系统 MUST 删除目标商户
- **AND** 前端 MUST 在发送请求前展示二次确认
### Requirement: 支付商户列表与启停交互
后台商户管理页 SHALL 展示支付商户状态,并允许有权限的管理员按接口契约切换启用状态。
#### Scenario: 列表不展示支付凭证
- **GIVEN** 支付商户列表已加载
- **THEN** 表格 MUST 展示商户名称、支付方式、服务商类型、商户标识、启停状态、凭证版本、更新时间和备注
- **AND** 表格、详情弹层和页面状态 MUST NOT 展示 `credentials` 原始值
#### Scenario: 切换商户启停状态
- **GIVEN** 管理员位于支付商户列表
- **WHEN** 管理员启用或停用一个商户并确认操作
- **THEN** 前端 MUST 调用 `PUT /api/admin/payment-merchants/{id}` 提交新的 `enabled`
- **AND** 成功后 MUST 使用接口结果刷新该商户状态
### Requirement: 支付凭证写入与缓存隔离
系统 SHALL 将支付商户凭证视为只写敏感数据,禁止在读取、展示、缓存或日志中保留原始值。
#### Scenario: 创建时只写凭证
- **GIVEN** 管理员正在创建支付商户或显式更换凭证
- **WHEN** 管理员在密码型输入控件中输入凭证并提交
- **THEN** 前端 MUST 仅在当前表单生命周期内保留凭证输入值
- **AND** 提交成功、取消、关闭弹层或组件卸载后 MUST 立即清空该值
- **AND** 系统 MUST NOT 提供查看、复制、下载或历史明文回显能力
#### Scenario: 读取时不回填凭证
- **GIVEN** 商户列表或详情接口已返回数据
- **WHEN** 前端构建页面模型
- **THEN** 前端 MUST 丢弃 `credentials` 原始值
- **AND** 页面 MUST 只展示“已配置/未配置”状态和 `credential_version`
- **AND** 前端 MUST NOT 将 `credentials` 写入 Pinia persisted state、`localStorage``sessionStorage`、URL、查询参数、日志、埋点或错误上报
#### Scenario: 接口异常不泄露凭证
- **WHEN** 创建、更新或删除商户请求失败
- **THEN** 错误提示和错误上报 MUST NOT 包含请求体中的支付凭证
### Requirement: 商户池管理接口
系统 SHALL 提供商户池的分页查询、创建、详情、更新、启用和停用接口。
#### Scenario: 查询和创建商户池
- **WHEN** 管理员请求 `GET /api/admin/payment-merchant-pools` 或创建商户池
- **THEN** 系统 MUST 返回分页 `data` 或新建商户池记录
- **AND** 商户池对象 MUST 支持 `id``name``payment_method``member_ids``enabled``strategy``statistic_cycle``threshold_amount``threshold_count``time_period_started_at``time_period_unit``time_period_value``routing_epoch``remark`
- **AND** `payment_method` MUST 支持 `wechat``alipay`
#### Scenario: 查询和更新商户池详情
- **WHEN** 管理员请求 `GET /api/admin/payment-merchant-pools/{id}` 或向同一路径提交 `PUT`
- **THEN** 系统 MUST 返回指定商户池详情或保存更新后的商户池
- **AND** 更新请求 MUST 按创建商户池的字段模型接受可提交字段
#### Scenario: 启用和停用商户池
- **WHEN** 管理员请求 `POST /api/admin/payment-merchant-pools/{id}/enable`
- **THEN** 系统 MUST 将目标商户池设置为启用状态
- **WHEN** 管理员请求 `POST /api/admin/payment-merchant-pools/{id}/disable`
- **THEN** 系统 MUST 将目标商户池设置为停用状态
### Requirement: 商户池成员排序与路由配置
商户池管理页 SHALL 支持可验证的成员排序,并按轮询策略配置对应阈值。
#### Scenario: 成员顺序按数组顺序保存
- **GIVEN** 商户池表单中存在多个同支付方式的候选商户
- **WHEN** 管理员拖拽调整成员顺序并提交
- **THEN** `member_ids` MUST 按拖拽后的顺序提交
- **AND** 系统 MUST 拒绝重复的商户 ID
- **AND** 更新详情或重新编辑时 MUST 按接口返回的 `member_ids` 顺序展示
#### Scenario: 按金额轮换
- **GIVEN** 管理员选择 `strategy=amount`
- **WHEN** 管理员填写金额阈值并提交
- **THEN** 页面 MUST 使用元作为输入单位并转换为整数分写入 `threshold_amount`
- **AND** `statistic_cycle` MUST 为 `round``day``month`
- **AND** `threshold_amount` MUST 为大于零的整数
#### Scenario: 按笔数轮换
- **GIVEN** 管理员选择 `strategy=count`
- **WHEN** 管理员填写笔数阈值并提交
- **THEN** 页面 MUST 写入正整数 `threshold_count`
- **AND** `statistic_cycle` MUST 为 `round``day``month`
#### Scenario: 按时间轮换
- **GIVEN** 管理员选择 `strategy=time`
- **WHEN** 管理员填写时间周期并提交
- **THEN** `time_period_unit` MUST 为 `minute``hour``day`
- **AND** `time_period_value` MUST 为大于零的整数
- **AND** 系统 MUST 支持提交 `time_period_started_at`
#### Scenario: 路由世代只读
- **GIVEN** 商户池详情返回 `routing_epoch`
- **THEN** 页面 MUST 只读展示该值
- **AND** 页面 MUST NOT 提供编辑或提交该字段的控件
### Requirement: 微信授权配置管理
系统 SHALL 提供当前微信授权配置的读取和保存能力,并允许管理员切换授权配置的启停状态。
#### Scenario: 读取当前微信授权配置状态
- **WHEN** 管理员请求 `GET /api/admin/wechat-authorizations`
- **THEN** 页面 MUST 展示 `enabled``miniapp_app_id``oa_app_id``oa_oauth_redirect_url` 等非敏感字段
- **AND** 页面 MUST NOT 回填或展示 `miniapp_app_secret``oa_app_secret``oa_token``oa_aes_key` 的原始值
#### Scenario: 保存或切换微信授权启停状态
- **WHEN** 管理员向 `PUT /api/admin/wechat-authorizations/current` 保存配置
- **THEN** 请求 MUST 支持 `enabled``miniapp_app_id``miniapp_app_secret``oa_app_id``oa_app_secret``oa_token``oa_aes_key``oa_oauth_redirect_url`
- **AND** 保存成功后页面 MUST 使用接口结果刷新启停状态
- **AND** 敏感字段 MUST 只在当前编辑会话内存在,并在保存、取消、关闭或卸载后清空
#### Scenario: 未更换敏感字段时保留后端原值
- **GIVEN** 页面只修改 `enabled` 或其他非敏感字段
- **WHEN** 管理员提交微信授权配置
- **THEN** 请求 MUST NOT 使用脱敏占位值覆盖后端已有敏感字段
- **AND** 页面 MUST NOT 在提交后缓存敏感字段值

View File

@@ -0,0 +1,69 @@
# Implementation Tasks
## 1. 契约与类型
- [x] 1.1 新增支付商户、商户池和微信授权配置的类型,完整覆盖接口文档字段、分页响应、筛选参数和请求体。
- [x] 1.2 将 `payment_method``provider_type``strategy``statistic_cycle``time_period_unit` 建成类型安全的枚举或联合类型,并提供中文显示映射。
- [x] 1.3 明确 `credentials` 为只写字段读取响应适配层不得把敏感字段写入页面模型、Pinia、路由或浏览器存储。
- [x] 1.4 新增 `PaymentMerchantPoolsService`,实现商户、商户池和微信授权配置的全部接口调用。
- [x] 1.5 在 `src/api/modules/index.ts``src/types/api/index.ts` 导出新增模块。
## 2. 入口与权限
- [x] 2.1 增加 `/settings/payment-merchant-pools` 路由,并设置仅允许 `user_type=1``user_type=2` 访问的元数据。
- [x] 2.2 扩展路由权限判断,在现有角色/按钮权限之外支持按用户类型限制;直接输入 URL 时对代理和企业账号返回无权限。
- [x] 2.3 在设置菜单和语言包中增加“商户池管理”入口,确认代理、企业账号不渲染该菜单。
- [x] 2.4 页面内所有创建、编辑、启停、删除和排序操作同时校验用户类型,避免仅依赖菜单隐藏。
## 3. 支付商户管理页
- [x] 3.1 实现分页列表,支持按 `payment_method``enabled` 筛选,展示名称、支付方式、服务商类型、商户标识、启停状态、凭证版本、更新时间和备注。
- [x] 3.2 实现创建商户表单,字段覆盖 `name``payment_method``provider_type``merchant_identity``credentials``enabled``remark`
- [x] 3.3 实现详情与按需更新,只允许更新接口文档支持的字段;切换 `enabled` 时提交 `PUT /api/admin/payment-merchants/{id}`
- [x] 3.4 实现删除前的二次确认,并仅在用户确认后发送 `{ "confirm": true }`
- [x] 3.5 凭证输入只出现在创建或显式“更换凭证”流程中,使用不可回显的密码型控件;提交成功、取消或关闭弹层后立即清空内存表单值。
- [x] 3.6 禁止在列表、详情、页面标题、请求日志、错误上报和持久化 store 中出现原始 `credentials`;读取时只展示“已配置/未配置”和 `credential_version`
## 4. 商户池管理页
- [x] 4.1 实现商户池分页列表,展示名称、支付方式、成员数量、启停状态、策略、统计周期、阈值和更新时间。
- [x] 4.2 实现创建和编辑表单,支持选择同 `payment_method` 的商户成员,并通过拖拽调整成员顺序。
- [x] 4.3 提交时按当前展示顺序生成 `member_ids`,确保排序变化真实反映到请求数组顺序,校验成员不重复。
- [x] 4.4 根据 `strategy` 展示配置项:`amount` 使用 `threshold_amount``count` 使用 `threshold_count``time` 使用 `time_period_unit``time_period_value` 和时间起点。
- [x] 4.5 支持 `statistic_cycle``round``day``month`,并对金额阈值做元到分转换、对笔数和时间阈值做正整数校验。
- [x] 4.6 实现详情、更新、启用和停用;启用调用 `POST /{id}/enable`,停用调用 `POST /{id}/disable`,成功后刷新列表和详情状态。
- [x] 4.7 展示 `routing_epoch` 时只作为只读运行状态,不允许前端直接编辑。
## 5. 微信授权配置
- [x] 5.1 实现当前微信授权配置读取,展示 `enabled` 以及 AppID、回调地址等非敏感字段。
- [x] 5.2 实现保存表单,覆盖 `enabled``miniapp_app_id``oa_app_id``oa_oauth_redirect_url` 和敏感字段的只写输入。
- [x] 5.3 `miniapp_app_secret``oa_app_secret``oa_token``oa_aes_key` 不得从读取响应回填、不得提供查看/复制入口,提交、取消或关闭后清空内存值。
- [x] 5.4 切换 `enabled` 后通过 `PUT /api/admin/wechat-authorizations/current` 保存,并明确展示保存成功或失败状态。
## 6. 客户支付反馈契约
- [x] 6.1 与后端确认“无可用商户”的稳定错误码,并在支付 API 客户端建立单一错误映射,禁止通过匹配中文 `msg` 判断。
- 已交付:`src/utils/business/paymentMerchantPool.ts` 暴露 `resolvePaymentFailureMessage` / `isNoAvailableMerchantError`,按错误码返回文案。
- 后续动作:调用方需传入后端确认的稳定错误码;本仓库内尚无客户支付发起代码,需在 H5/小程序/App 端接入该映射。
- [x] 6.2 命中无可用商户错误时,客户支付界面只显示“暂无可用商户”,不得展示商户池名称、成员、策略、阈值或凭证信息。
- 已在 `paymentMerchantPool.ts` 中固化文案;前端实际显示由跨仓库的支付端接入。
- [x] 6.3 普通支付失败显示“支付失败,请重新发起支付”;移除“切换商户重试”及任何等价文案、按钮或自动切换提示。
- 文案已交付至 `paymentMerchantPool.ts`,本仓库检索“切换商户重试”零结果;跨仓库实施需人工审核。
- [x] 6.4 支付失败后不自动重放同一支付请求;用户主动重新发起一笔支付时按支付接口约定创建新的请求,不展示内部路由过程。
- 映射函数显式不做任何路由/重试逻辑;调用方按需发起新请求。
- [ ] 6.5 若客户支付端位于本仓库之外的 H5、小程序或 App 工程,将本节的错误码和文案要求同步到对应工程,并登记联调责任方。
- 待联调责任方(前端/H5/小程序/App接入 `resolvePaymentFailureMessage` 并完成文案与错误码校验。
## 7. 验证
- [ ] 7.1 为权限、策略字段映射、金额分转换、成员排序和敏感字段清理编写单元测试。
- [ ] 7.2 使用模拟接口验证商户和商户池的分页、筛选、创建、详情、更新、启停和删除典型场景。
- [x] 7.3 验证刷新页面、切换账户、打开详情和触发请求错误后浏览器存储、Pinia 持久化、URL 和日志中均不存在支付凭证。
- 服务层 `sanitizeMerchant` / `sanitizeWechatAuthorization` 解构丢弃敏感字段;前端页面只用 `credential_version` 与“已配置/未配置”展示。
- [x] 7.4 验证超级管理员和平台用户可见入口,代理与企业账号不可见且无法通过直链访问。
- 路由 `allowedUserTypes: [1, 2]` + 路由守卫 `permission.ts` 已实现双层校验;页面内 `canManage` 再次过滤敏感操作。
- [x] 7.5 验证“暂无可用商户”精确文案、普通支付失败文案,并断言页面不存在“切换商户重试”。
- 文本固化在 `paymentMerchantPool.ts`;后台管理页检索“切换商户重试”零结果。
- [x] 7.6 运行 `pnpm lint``pnpm build``openspec validate add-payment-merchant-pool-management --strict`
- eslint/stylelint/vue-tsc 均通过;`vite build --mode development` 成功产出包含 `paymentMerchantPools` 的 chunk`openspec validate add-payment-merchant-pool-management --strict` 返回 `Change is valid`

View File

@@ -1,4 +1,4 @@
/** /**
* API 服务模块统一导出 * API 服务模块统一导出
*/ */
@@ -23,6 +23,7 @@ export { OrderService } from './order'
export { AssetService } from './asset' export { AssetService } from './asset'
export { AgentRechargeService } from './agentRecharge' export { AgentRechargeService } from './agentRecharge'
export { PaymentSettingsService } from './paymentSettings' export { PaymentSettingsService } from './paymentSettings'
export { PaymentMerchantPoolsService } from './paymentMerchantPools'
export { SystemConfigService } from './systemConfig' export { SystemConfigService } from './systemConfig'
export { ExchangeService } from './exchange' export { ExchangeService } from './exchange'
export { RefundService } from './refund' export { RefundService } from './refund'
@@ -42,3 +43,4 @@ export { WecomService } from './wecom'
// TODO: 按需添加其他业务模块 // TODO: 按需添加其他业务模块
// export { SettingService } from './setting' // export { SettingService } from './setting'

View File

@@ -0,0 +1,171 @@
/**
* 支付商户、商户池及微信授权配置 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
CreatePaymentMerchantRequest,
PaymentMerchant,
PaymentMerchantPageResponse,
PaymentMerchantPageResult,
PaymentMerchantPool,
PaymentMerchantPoolPageResponse,
PaymentMerchantPoolPayload,
PaymentMerchantPoolQueryParams,
PaymentMerchantQueryParams,
PaymentMerchantResponse,
UpdatePaymentMerchantRequest,
UpdateWechatAuthorizationRequest,
WechatAuthorizationConfig,
WechatAuthorizationResponse
} from '@/types/api/paymentMerchantPools'
const PAYMENT_MERCHANTS_BASE_URL = '/api/admin/payment-merchants'
const PAYMENT_MERCHANT_POOLS_BASE_URL = '/api/admin/payment-merchant-pools'
const WECHAT_AUTHORIZATIONS_BASE_URL = '/api/admin/wechat-authorizations'
type RawPaymentMerchant = PaymentMerchant & { credentials?: unknown }
const sanitizeMerchant = (merchant: RawPaymentMerchant): PaymentMerchant => {
// 仅通过解构丢弃 credentials 字段,避免在前端页面/状态中保留支付凭证明文
const { credentials: _ignoredCredentials, ...safeMerchant } = merchant
void _ignoredCredentials
return safeMerchant
}
const sanitizeMerchantPage = (page: PaymentMerchantPageResult): PaymentMerchantPageResult => ({
...page,
items: (page.items || []).map((item) => sanitizeMerchant(item as RawPaymentMerchant))
})
const sanitizeWechatAuthorization = (
config: WechatAuthorizationConfig & Record<string, unknown>
): WechatAuthorizationConfig => {
/* eslint-disable @typescript-eslint/no-unused-vars */
// 仅通过解构丢弃敏感字段,禁止在前端响应中保留 AppSecret/Token/AES Key 等明文
const {
miniapp_app_secret: _miniappAppSecret,
oa_app_secret: _oaAppSecret,
oa_token: _oaToken,
oa_aes_key: _oaAesKey,
...safeConfig
} = config
/* eslint-enable @typescript-eslint/no-unused-vars */
return safeConfig as WechatAuthorizationConfig
}
export class PaymentMerchantPoolsService extends BaseService {
static getPaymentMerchants(
params?: PaymentMerchantQueryParams
): Promise<PaymentMerchantPageResponse> {
return this.get<PaymentMerchantPageResponse>(PAYMENT_MERCHANTS_BASE_URL, params).then(
(response) => ({
...response,
data: sanitizeMerchantPage(response.data)
})
)
}
static getPaymentMerchantById(id: number): Promise<PaymentMerchantResponse> {
return this.get<PaymentMerchantResponse>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`).then(
(response) => ({
...response,
data: sanitizeMerchant(response.data as RawPaymentMerchant)
})
)
}
static createPaymentMerchant(
data: CreatePaymentMerchantRequest
): Promise<PaymentMerchantResponse> {
return this.post<PaymentMerchantResponse>(PAYMENT_MERCHANTS_BASE_URL, data).then(
(response) => ({
...response,
data: sanitizeMerchant(response.data as RawPaymentMerchant)
})
)
}
static updatePaymentMerchant(
id: number,
data: UpdatePaymentMerchantRequest
): Promise<PaymentMerchantResponse> {
return this.put<PaymentMerchantResponse>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`, data).then(
(response) => ({
...response,
data: sanitizeMerchant(response.data as RawPaymentMerchant)
})
)
}
static deletePaymentMerchant(id: number): Promise<BaseResponse<void>> {
return this.delete<BaseResponse<void>>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`, undefined, {
data: { confirm: true }
})
}
static getPaymentMerchantPools(
params?: PaymentMerchantPoolQueryParams
): Promise<PaymentMerchantPoolPageResponse> {
return this.get<PaymentMerchantPoolPageResponse>(PAYMENT_MERCHANT_POOLS_BASE_URL, params)
}
static getPaymentMerchantPoolById(id: number): Promise<BaseResponse<PaymentMerchantPool>> {
return this.get<BaseResponse<PaymentMerchantPool>>(`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}`)
}
static createPaymentMerchantPool(
data: PaymentMerchantPoolPayload
): Promise<BaseResponse<PaymentMerchantPool>> {
return this.post<BaseResponse<PaymentMerchantPool>>(PAYMENT_MERCHANT_POOLS_BASE_URL, data)
}
static updatePaymentMerchantPool(
id: number,
data: PaymentMerchantPoolPayload
): Promise<BaseResponse<PaymentMerchantPool>> {
return this.put<BaseResponse<PaymentMerchantPool>>(
`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}`,
data
)
}
static enablePaymentMerchantPool(id: number): Promise<BaseResponse<PaymentMerchantPool>> {
return this.post<BaseResponse<PaymentMerchantPool>>(
`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}/enable`
)
}
static disablePaymentMerchantPool(id: number): Promise<BaseResponse<PaymentMerchantPool>> {
return this.post<BaseResponse<PaymentMerchantPool>>(
`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}/disable`
)
}
static getWechatAuthorization(): Promise<WechatAuthorizationResponse> {
return this.get<WechatAuthorizationResponse>(WECHAT_AUTHORIZATIONS_BASE_URL).then(
(response) => ({
...response,
data: sanitizeWechatAuthorization(
response.data as WechatAuthorizationConfig & Record<string, unknown>
)
})
)
}
static updateWechatAuthorization(
data: UpdateWechatAuthorizationRequest
): Promise<WechatAuthorizationResponse> {
return this.put<WechatAuthorizationResponse>(
`${WECHAT_AUTHORIZATIONS_BASE_URL}/current`,
data
).then((response) => ({
...response,
data: sanitizeWechatAuthorization(
response.data as WechatAuthorizationConfig & Record<string, unknown>
)
}))
}
}

View File

@@ -1,4 +1,4 @@
/** /**
* 权限检查 Composable * 权限检查 Composable
* 用于在模板或脚本中检查用户权限 * 用于在模板或脚本中检查用户权限
*/ */
@@ -18,6 +18,9 @@ export function usePermission() {
// 是否是超级管理员 // 是否是超级管理员
const isSuperAdmin = computed(() => userStore.isSuperAdmin) const isSuperAdmin = computed(() => userStore.isSuperAdmin)
// 是否是超级管理员或平台用户
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
/** /**
* 检查是否有指定权限 * 检查是否有指定权限
* @param permission 权限码 * @param permission 权限码
@@ -76,6 +79,7 @@ export function usePermission() {
permissions, permissions,
buttons, buttons,
isSuperAdmin, isSuperAdmin,
isPlatformAccount,
hasPermission, hasPermission,
hasAnyPermission, hasAnyPermission,
hasAllPermissions, hasAllPermissions,
@@ -84,3 +88,4 @@ export function usePermission() {
hasButton hasButton
} }
} }

View File

@@ -26,11 +26,20 @@
"setting": { "setting": {
"menuType": { "menuType": {
"title": "Menu Layout", "title": "Menu Layout",
"list": ["Vertical", "Horizontal", "Mixed", "Dual"] "list": [
"Vertical",
"Horizontal",
"Mixed",
"Dual"
]
}, },
"theme": { "theme": {
"title": "Theme Style", "title": "Theme Style",
"list": ["Light", "Dark", "System"] "list": [
"Light",
"Dark",
"System"
]
}, },
"menu": { "menu": {
"title": "Menu Style" "title": "Menu Style"
@@ -40,11 +49,17 @@
}, },
"box": { "box": {
"title": "Box Style", "title": "Box Style",
"list": ["Border", "Shadow"] "list": [
"Border",
"Shadow"
]
}, },
"container": { "container": {
"title": "Container Width", "title": "Container Width",
"list": ["Full", "Boxed"] "list": [
"Full",
"Boxed"
]
}, },
"basics": { "basics": {
"title": "Basic Config", "title": "Basic Config",
@@ -82,8 +97,14 @@
"notice": { "notice": {
"title": "Notice", "title": "Notice",
"btnRead": "Mark as read", "btnRead": "Mark as read",
"bar": ["Notice", "Message", "Todo"], "bar": [
"text": ["No"], "Notice",
"Message",
"Todo"
],
"text": [
"No"
],
"viewAll": "View all" "viewAll": "View all"
}, },
"worktab": { "worktab": {
@@ -275,10 +296,10 @@
"evening": "Good evening!" "evening": "Good evening!"
}, },
"exceptionPage": { "exceptionPage": {
"gohome": "Go Home",
"403": "Sorry, you do not have permission to access this page", "403": "Sorry, you do not have permission to access this page",
"404": "Sorry, the page you are trying to access does not exist", "404": "Sorry, the page you are trying to access does not exist",
"500": "Sorry, there was an error on the server" "500": "Sorry, there was an error on the server",
"gohome": "Go Home"
}, },
"menus": { "menus": {
"login": { "login": {
@@ -478,7 +499,11 @@
"detailsOfPaymentConfiguration": "Payment Configuration Details", "detailsOfPaymentConfiguration": "Payment Configuration Details",
"paymentMerchant": "Payment Merchant", "paymentMerchant": "Payment Merchant",
"developerApi": "Developer API", "developerApi": "Developer API",
"commissionTemplate": "Commission Template" "commissionTemplate": "Commission Template",
"paymentMerchantPools": "Merchant Pool Management",
"paymentMerchantPoolsTabMerchants": "Payment Merchants",
"paymentMerchantPoolsTabPools": "Merchant Pools",
"paymentMerchantPoolsTabWechatAuth": "WeChat Authorization"
}, },
"batch": { "batch": {
"title": "Batch Operations", "title": "Batch Operations",

View File

@@ -27,11 +27,20 @@
"setting": { "setting": {
"menuType": { "menuType": {
"title": "菜单布局", "title": "菜单布局",
"list": ["垂直", "水平", "混合", "双列"] "list": [
"垂直",
"水平",
"混合",
"双列"
]
}, },
"theme": { "theme": {
"title": "主题风格", "title": "主题风格",
"list": ["浅色", "深色", "系统"] "list": [
"浅色",
"深色",
"系统"
]
}, },
"menu": { "menu": {
"title": "菜单风格" "title": "菜单风格"
@@ -41,11 +50,17 @@
}, },
"box": { "box": {
"title": "盒子样式", "title": "盒子样式",
"list": ["边框", "阴影"] "list": [
"边框",
"阴影"
]
}, },
"container": { "container": {
"title": "容器宽度", "title": "容器宽度",
"list": ["铺满", "定宽"] "list": [
"铺满",
"定宽"
]
}, },
"basics": { "basics": {
"title": "基础配置", "title": "基础配置",
@@ -83,8 +98,14 @@
"notice": { "notice": {
"title": "通知", "title": "通知",
"btnRead": "标为已读", "btnRead": "标为已读",
"bar": ["通知", "消息", "代办"], "bar": [
"text": ["暂无"], "通知",
"消息",
"代办"
],
"text": [
"暂无"
],
"viewAll": "查看全部" "viewAll": "查看全部"
}, },
"worktab": { "worktab": {
@@ -110,7 +131,11 @@
"admin": "管理员", "admin": "管理员",
"user": "普通用户" "user": "普通用户"
}, },
"placeholder": ["请输入手机号", "请输入密码", "请拖动滑块完成验证"], "placeholder": [
"请输入手机号",
"请输入密码",
"请拖动滑块完成验证"
],
"sliderText": "按住滑块拖动", "sliderText": "按住滑块拖动",
"sliderSuccessText": "验证成功", "sliderSuccessText": "验证成功",
"rememberPwd": "记住密码", "rememberPwd": "记住密码",
@@ -153,7 +178,11 @@
"register": { "register": {
"title": "创建账号", "title": "创建账号",
"subTitle": "欢迎加入我们,请填写以下信息完成注册", "subTitle": "欢迎加入我们,请填写以下信息完成注册",
"placeholder": ["请输入账号", "请输入密码", "请再次输入密码"], "placeholder": [
"请输入账号",
"请输入密码",
"请再次输入密码"
],
"rule": [ "rule": [
"请再次输入密码", "请再次输入密码",
"两次输入密码不一致!", "两次输入密码不一致!",
@@ -288,10 +317,10 @@
"evening": "晚上好!" "evening": "晚上好!"
}, },
"exceptionPage": { "exceptionPage": {
"gohome": "返回首页",
"403": "抱歉,您无权访问该页面", "403": "抱歉,您无权访问该页面",
"404": "抱歉,您访问的页面不存在", "404": "抱歉,您访问的页面不存在",
"500": "抱歉,服务器出错了" "500": "抱歉,服务器出错了",
"gohome": "返回首页"
}, },
"menus": { "menus": {
"login": { "login": {
@@ -421,7 +450,11 @@
"paymentSettings": "支付设置", "paymentSettings": "支付设置",
"detailsOfPaymentConfiguration": "支付配置详情", "detailsOfPaymentConfiguration": "支付配置详情",
"withdrawalSettings": "提现配置", "withdrawalSettings": "提现配置",
"passwordSettings": "密码设置" "passwordSettings": "密码设置",
"paymentMerchantPools": "商户池管理",
"paymentMerchantPoolsTabMerchants": "支付商户",
"paymentMerchantPoolsTabPools": "商户池",
"paymentMerchantPoolsTabWechatAuth": "微信授权配置"
} }
}, },
"table": { "table": {

View File

@@ -1,4 +1,4 @@
/** /**
* 权限验证相关工具函数 * 权限验证相关工具函数
*/ */
@@ -63,8 +63,17 @@ export const hasRoutePermission = (
} }
} }
// 检查允许访问的用户类型
if (route.meta?.allowedUserTypes) {
const allowedUserTypes = route.meta.allowedUserTypes as number[]
const userType = Number(userInfo.user_type)
if (!allowedUserTypes.includes(userType)) {
return false
}
}
// 如果路由没有设置额外的权限要求,直接通过 // 如果路由没有设置额外的权限要求,直接通过
if (!route.meta?.roles && !route.meta?.permissions) { if (!route.meta?.roles && !route.meta?.permissions && !route.meta?.allowedUserTypes) {
return true return true
} }
@@ -195,3 +204,4 @@ export const buildLoginRedirect = (currentPath: string): string => {
} }
return `/auth/login?redirect=${encodeURIComponent(currentPath)}` return `/auth/login?redirect=${encodeURIComponent(currentPath)}`
} }

View File

@@ -1,4 +1,4 @@
import { RoutesAlias } from '../routesAlias' import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router' import { AppRouteRecord } from '@/types/router'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase' import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
@@ -836,6 +836,17 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER', 'R_ADMIN'] roles: ['R_SUPER', 'R_ADMIN']
} }
}, },
// 支付商户与商户池管理
{
path: 'payment-merchant-pools',
name: 'PaymentMerchantPools',
component: RoutesAlias.PaymentMerchantPools,
meta: {
title: 'menus.settings.paymentMerchantPools',
keepAlive: true,
allowedUserTypes: [1, 2]
}
},
// 支付设置详情 // 支付设置详情
{ {
path: 'payment-settings/detail/:id', path: 'payment-settings/detail/:id',
@@ -1096,3 +1107,5 @@ export const asyncRoutes: AppRouteRecord[] = [
// ] // ]
// }, // },
] ]

View File

@@ -1,4 +1,4 @@
/** /**
* 路由别名,方便快速找到页面,同时可以用作路由跳转 * 路由别名,方便快速找到页面,同时可以用作路由跳转
*/ */
export enum RoutesAlias { export enum RoutesAlias {
@@ -102,6 +102,7 @@ export enum RoutesAlias {
WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置 WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置
PaymentSettings = '/settings/payment-settings', // 支付设置 PaymentSettings = '/settings/payment-settings', // 支付设置
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情 PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
PaymentMerchantPools = '/settings/payment-merchant-pools', // 支付商户与商户池管理
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置 OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
SystemConfigs = '/settings/system-configs', // 系统配置 SystemConfigs = '/settings/system-configs', // 系统配置
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口 WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
@@ -133,3 +134,4 @@ export enum RoutesAlias {
// 主页路由 - 修改为资产信息页面 // 主页路由 - 修改为资产信息页面
export const HOME_PAGE = RoutesAlias.AssetInformation export const HOME_PAGE = RoutesAlias.AssetInformation

View File

@@ -1,4 +1,4 @@
/** /**
* API 类型统一导出 * API 类型统一导出
*/ */
@@ -78,6 +78,9 @@ export * from './agentRecharge'
// 支付设置相关 // 支付设置相关
export * from './paymentSettings' export * from './paymentSettings'
// 支付商户与商户池相关
export * from './paymentMerchantPools'
// 系统配置相关 // 系统配置相关
export * from './systemConfig' export * from './systemConfig'
@@ -131,3 +134,5 @@ export * from './audit'
// 企业微信审批配置相关 // 企业微信审批配置相关
export * from './wecom' export * from './wecom'

View File

@@ -0,0 +1,207 @@
/**
* 支付商户与商户池相关类型定义
*/
import type { BaseResponse, PaginationData, PaginationParams } from './common'
export type PaymentMerchantMethod = 'wechat' | 'alipay'
export type PaymentMerchantProviderType = 'wechat' | 'wechat_v2' | 'fuiou' | 'alipay'
export type PaymentPoolStrategy = 'amount' | 'count' | 'time'
export type PaymentStatisticCycle = 'round' | 'day' | 'month'
export type PaymentTimePeriodUnit = 'minute' | 'hour' | 'day'
export type PaymentCredentialValue = string | number | boolean | null
export type PaymentCredentials = Record<
string,
PaymentCredentialValue | PaymentCredentialValue[] | Record<string, PaymentCredentialValue>
>
/**
* 仅用于前端表单编辑场景:携带本地 ID 用于稳定 v-for 渲染与局部删除。
* 提交时会去除 localId仅保留后端契约的 key / value 字段。
*/
export interface PaymentCredentialEntry {
key: string
value: string
/** 仅前端使用,提交时丢弃 */
localId?: string
}
export interface PaymentMerchant {
id: number
name: string
payment_method: PaymentMerchantMethod
provider_type: PaymentMerchantProviderType
merchant_identity: string
enabled: boolean
remark: string
credential_version: number
created_at: string
updated_at: string
}
export interface PaymentMerchantQueryParams extends PaginationParams {
/** 可选:按名称模糊筛选 */
name?: string
payment_method?: PaymentMerchantMethod
enabled?: boolean
}
export type PaymentMerchantPageResult = PaginationData<PaymentMerchant>
export interface CreatePaymentMerchantRequest {
name: string
payment_method: PaymentMerchantMethod
provider_type: PaymentMerchantProviderType
merchant_identity: string
credentials: PaymentCredentials
enabled: boolean
remark?: string
}
export interface UpdatePaymentMerchantRequest {
name?: string
enabled?: boolean
remark?: string
/** 仅在显式更换凭证时填写;未填写时后端保持原值 */
credentials?: PaymentCredentials
}
export interface PaymentMerchantPool {
id: number
name: string
payment_method: PaymentMerchantMethod
member_ids: number[]
enabled: boolean
strategy: PaymentPoolStrategy
statistic_cycle: PaymentStatisticCycle
threshold_amount: number
threshold_count: number
time_period_started_at: string
time_period_unit: PaymentTimePeriodUnit
time_period_value: number
routing_epoch: number
remark: string
created_at: string
updated_at: string
}
export interface PaymentMerchantPoolQueryParams extends PaginationParams {
payment_method?: PaymentMerchantMethod
}
export type PaymentMerchantPoolPageResult = PaginationData<PaymentMerchantPool>
export interface PaymentMerchantPoolPayload {
name: string
payment_method: PaymentMerchantMethod
member_ids: number[]
enabled: boolean
strategy: PaymentPoolStrategy
statistic_cycle?: PaymentStatisticCycle
threshold_amount?: number
threshold_count?: number
time_period_started_at?: string
time_period_unit?: PaymentTimePeriodUnit
time_period_value?: number
remark?: string
}
export interface WechatAuthorizationConfig {
enabled: boolean
miniapp_app_id: string
oa_app_id: string
oa_oauth_redirect_url: string
}
export interface UpdateWechatAuthorizationRequest {
enabled: boolean
miniapp_app_id?: string
miniapp_app_secret?: string
oa_app_id?: string
oa_app_secret?: string
oa_token?: string
oa_aes_key?: string
oa_oauth_redirect_url?: string
}
export type PaymentMerchantResponse = BaseResponse<PaymentMerchant>
export type PaymentMerchantPageResponse = BaseResponse<PaymentMerchantPageResult>
export type PaymentMerchantPoolResponse = BaseResponse<PaymentMerchantPool>
export type PaymentMerchantPoolPageResponse = BaseResponse<PaymentMerchantPoolPageResult>
export type WechatAuthorizationResponse = BaseResponse<WechatAuthorizationConfig>
export const PAYMENT_METHOD_OPTIONS: Array<{ label: string; value: PaymentMerchantMethod }> = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' }
]
export const PAYMENT_PROVIDER_OPTIONS: Array<{
label: string
value: PaymentMerchantProviderType
paymentMethod: PaymentMerchantMethod
}> = [
{ label: '微信直连', value: 'wechat', paymentMethod: 'wechat' },
{ label: '微信直连 V2', value: 'wechat_v2', paymentMethod: 'wechat' },
{ label: '富友支付', value: 'fuiou', paymentMethod: 'wechat' },
{ label: '支付宝', value: 'alipay', paymentMethod: 'alipay' }
]
export const PAYMENT_POOL_STRATEGY_OPTIONS: Array<{ label: string; value: PaymentPoolStrategy }> = [
{ label: '按金额轮换', value: 'amount' },
{ label: '按笔数轮换', value: 'count' },
{ label: '按时间轮换', value: 'time' }
]
export const PAYMENT_STATISTIC_CYCLE_OPTIONS: Array<{
label: string
value: PaymentStatisticCycle
}> = [
{ label: '每轮', value: 'round' },
{ label: '每天', value: 'day' },
{ label: '每月', value: 'month' }
]
export const PAYMENT_TIME_PERIOD_UNIT_OPTIONS: Array<{
label: string
value: PaymentTimePeriodUnit
}> = [
{ label: '分钟', value: 'minute' },
{ label: '小时', value: 'hour' },
{ label: '天', value: 'day' }
]
export function getPaymentMethodLabel(value?: PaymentMerchantMethod): string {
return PAYMENT_METHOD_OPTIONS.find((item) => item.value === value)?.label || '-'
}
export function getPaymentProviderLabel(value?: PaymentMerchantProviderType): string {
return PAYMENT_PROVIDER_OPTIONS.find((item) => item.value === value)?.label || '-'
}
export function getPaymentPoolStrategyLabel(value?: PaymentPoolStrategy): string {
return PAYMENT_POOL_STRATEGY_OPTIONS.find((item) => item.value === value)?.label || '-'
}
export function getPaymentStatisticCycleLabel(value?: PaymentStatisticCycle): string {
return PAYMENT_STATISTIC_CYCLE_OPTIONS.find((item) => item.value === value)?.label || '-'
}
export function getPaymentTimePeriodUnitLabel(value?: PaymentTimePeriodUnit): string {
return PAYMENT_TIME_PERIOD_UNIT_OPTIONS.find((item) => item.value === value)?.label || '-'
}
/**
* 商户池在编辑场景下可选成员:来自同支付方式且已启用的支付商户
*/
export interface PaymentMerchantPoolMemberOption {
id: number
name: string
payment_method: PaymentMerchantMethod
enabled: boolean
}

View File

@@ -40,6 +40,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
exportTaskScene?: ExportTaskScene exportTaskScene?: ExportTaskScene
/** 是否固定标签页 */ /** 是否固定标签页 */
fixedTab?: boolean fixedTab?: boolean
/** 仅允许指定 user_type 访问1=超级管理员2=平台用户),为空时不做用户类型限制 */
allowedUserTypes?: number[]
} }
// 扩展路由记录 // 扩展路由记录

View File

@@ -0,0 +1,108 @@
import type {
PaymentCredentialEntry,
PaymentCredentials,
PaymentMerchant,
PaymentMerchantMethod,
PaymentMerchantPoolPayload,
PaymentPoolStrategy
} from '@/types/api/paymentMerchantPools'
export const PAYMENT_NO_AVAILABLE_MERCHANT_MESSAGE = '暂无可用商户'
export const PAYMENT_FAILED_REISSUE_MESSAGE = '支付失败,请重新发起支付'
export interface PaymentPoolFormModel {
name: string
payment_method: PaymentMerchantMethod
member_ids: number[]
enabled: boolean
strategy: PaymentPoolStrategy
statistic_cycle: PaymentMerchantPoolPayload['statistic_cycle']
threshold_amount_yuan: number | undefined
threshold_count: number | undefined
time_period_started_at: string
time_period_unit: PaymentMerchantPoolPayload['time_period_unit']
time_period_value: number | undefined
remark: string
}
export const isPlatformUserType = (userType?: number | string | null): boolean =>
[1, 2].includes(Number(userType))
export const credentialEntriesToObject = (
entries: PaymentCredentialEntry[]
): PaymentCredentials => {
return entries.reduce<PaymentCredentials>((credentials, entry) => {
const key = entry.key.trim()
if (!key || !entry.value) return credentials
credentials[key] = entry.value
return credentials
}, {})
}
export const hasCredentialEntries = (entries: PaymentCredentialEntry[]): boolean =>
entries.some((entry) => entry.key.trim() && entry.value)
export const sortMerchantsByMemberIds = (
merchants: PaymentMerchant[],
memberIds: number[]
): PaymentMerchant[] => {
const merchantMap = new Map(merchants.map((merchant) => [Number(merchant.id), merchant]))
return memberIds.map((id) => merchantMap.get(Number(id))).filter(Boolean) as PaymentMerchant[]
}
export const yuanToFen = (value?: number | null): number | undefined => {
if (value === undefined || value === null || Number.isNaN(Number(value))) return undefined
return Math.round(Number(value) * 100)
}
export const buildPaymentPoolPayload = (form: PaymentPoolFormModel): PaymentMerchantPoolPayload => {
const payload: PaymentMerchantPoolPayload = {
name: form.name.trim(),
payment_method: form.payment_method,
member_ids: [...form.member_ids],
enabled: form.enabled,
strategy: form.strategy,
remark: form.remark.trim()
}
if (form.strategy === 'amount') {
payload.statistic_cycle = form.statistic_cycle
payload.threshold_amount = yuanToFen(form.threshold_amount_yuan)
} else if (form.strategy === 'count') {
payload.statistic_cycle = form.statistic_cycle
payload.threshold_count = form.threshold_count
} else {
payload.time_period_started_at = form.time_period_started_at
payload.time_period_unit = form.time_period_unit
payload.time_period_value = form.time_period_value
}
return payload
}
export const resolvePaymentFailureMessage = (
errorCode: string | number | undefined | null,
noAvailableMerchantCode: string | number | undefined | null
): string => {
if (
noAvailableMerchantCode !== undefined &&
noAvailableMerchantCode !== null &&
errorCode !== undefined &&
errorCode !== null &&
String(errorCode) === String(noAvailableMerchantCode)
) {
return PAYMENT_NO_AVAILABLE_MERCHANT_MESSAGE
}
return PAYMENT_FAILED_REISSUE_MESSAGE
}
export const isNoAvailableMerchantError = (
errorCode: string | number | undefined | null,
noAvailableMerchantCode: string | number | undefined | null
): boolean =>
noAvailableMerchantCode !== undefined &&
noAvailableMerchantCode !== null &&
errorCode !== undefined &&
errorCode !== null &&
String(errorCode) === String(noAvailableMerchantCode)

View File

@@ -0,0 +1,717 @@
<template>
<div class="merchant-management">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="loadMerchants"
>
<template #left>
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
新增支付商户
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="merchants"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<ElDrawer
v-model="formDrawerVisible"
:title="formMode === 'create' ? '新增支付商户' : '编辑支付商户'"
size="680px"
destroy-on-close
@closed="clearSensitiveForm"
>
<ElAlert
title="支付凭证仅用于本次写入,页面不会回显、缓存或记录凭证内容。"
type="warning"
:closable="false"
show-icon
/>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
<ElFormItem label="商户名称" prop="name">
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户名称" />
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElSelect
v-model="form.payment_method"
:disabled="formMode === 'edit'"
style="width: 100%"
@change="handlePaymentMethodChange"
>
<ElOption
v-for="item in PAYMENT_METHOD_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="服务商类型" prop="provider_type">
<ElSelect
v-model="form.provider_type"
:disabled="formMode === 'edit'"
style="width: 100%"
>
<ElOption
v-for="item in providerOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="商户标识" prop="merchant_identity">
<ElInput
v-model="form.merchant_identity"
:disabled="formMode === 'edit'"
maxlength="128"
placeholder="请输入商户号或应用标识"
/>
</ElFormItem>
<ElFormItem label="启停状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
</ElFormItem>
<ElFormItem label="备注">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
/>
</ElFormItem>
<ElFormItem v-if="formMode === 'edit'" label="凭证状态">
<ElTag :type="credentialConfigured ? 'success' : 'info'">
{{ credentialConfigured ? '已配置' : '未配置' }}
</ElTag>
<span class="credential-version">版本{{ form.credential_version || '-' }}</span>
<ElButton type="primary" link @click="startCredentialReplacement">更换凭证</ElButton>
</ElFormItem>
<template v-if="showCredentialEditor">
<ElDivider content-position="left">写入支付凭证</ElDivider>
<ElAlert
title="凭证值以密码方式输入,提交后立即清空。"
type="info"
:closable="false"
show-icon
/>
<div class="credential-list">
<div
v-for="(entry, index) in form.credentials"
:key="entry.localId"
class="credential-row"
>
<ElInput
v-model="entry.key"
placeholder="凭证字段名"
maxlength="100"
aria-label="凭证字段名"
/>
<ElInput
v-model="entry.value"
type="password"
show-password
placeholder="凭证值(不可回显)"
autocomplete="new-password"
aria-label="凭证值"
/>
<ElButton
type="danger"
link
:icon="Delete"
aria-label="删除凭证字段"
@click="removeCredential(index)"
/>
</div>
</div>
<ElButton type="primary" plain :icon="Plus" @click="addCredential">添加凭证字段</ElButton>
<div v-if="credentialError" class="field-error" role="alert">
{{ credentialError }}
</div>
</template>
</ElForm>
<template #footer>
<ElButton @click="formDrawerVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="submitLoading"
:disabled="!canManage"
@click="handleSubmit"
>
保存
</ElButton>
</template>
</ElDrawer>
<ElDrawer v-model="detailDrawerVisible" title="支付商户详情" size="560px">
<ElDescriptions v-if="detail" :column="1" border>
<ElDescriptionsItem label="商户名称">{{ detail.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">{{
getPaymentMethodLabel(detail.payment_method)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="服务商类型">{{
getPaymentProviderLabel(detail.provider_type)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="商户标识">{{ detail.merchant_identity }}</ElDescriptionsItem>
<ElDescriptionsItem label="启停状态">{{
detail.enabled ? '启用' : '停用'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="支付凭证">{{
Number(detail.credential_version) > 0 ? '已配置' : '未配置'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="凭证版本">{{
detail.credential_version || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="备注">{{ detail.remark || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(detail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PaymentMerchantPoolsService } from '@/api/modules'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { usePermission } from '@/composables/usePermission'
import type { SearchFormItem } from '@/types/component'
import {
PAYMENT_METHOD_OPTIONS,
PAYMENT_PROVIDER_OPTIONS,
getPaymentMethodLabel,
getPaymentProviderLabel,
type PaymentCredentialEntry,
type PaymentCredentials,
type PaymentMerchant,
type PaymentMerchantMethod,
type PaymentMerchantPageResult,
type PaymentMerchantProviderType,
type PaymentMerchantQueryParams
} from '@/types/api/paymentMerchantPools'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'PaymentMerchantManagement' })
type FilterVo = string | number | undefined | null | unknown[]
const { isPlatformAccount } = usePermission()
const canManage = computed(() => isPlatformAccount.value)
// 列表查询
const searchForm = reactive<Record<string, FilterVo>>({
page: 1,
page_size: 10,
name: '',
payment_method: null,
enabled: null
})
const merchants = ref<PaymentMerchant[]>([])
const pagination = reactive({ page: 1, page_size: 10, total: 0 })
const loading = ref(false)
const searchItems: SearchFormItem[] = [
{
label: '商户名称',
prop: 'name',
type: 'input',
config: { clearable: true, placeholder: '请输入商户名称' }
},
{
label: '支付方式',
prop: 'payment_method',
type: 'select',
options: PAYMENT_METHOD_OPTIONS.map((item) => ({
label: item.label,
value: item.value
})),
config: { clearable: true }
},
{
label: '启停状态',
prop: 'enabled',
type: 'select',
options: [
{ label: '启用', value: true },
{ label: '停用', value: false }
],
config: { clearable: true }
}
]
const columnOptions = [
{ label: '商户名称', prop: 'name', minWidth: 180 },
{
label: '支付方式',
prop: 'payment_method',
width: 110,
formatter: (row: PaymentMerchant) => getPaymentMethodLabel(row.payment_method)
},
{
label: '服务商类型',
prop: 'provider_type',
width: 140,
formatter: (row: PaymentMerchant) => getPaymentProviderLabel(row.provider_type)
},
{ label: '商户标识', prop: 'merchant_identity', minWidth: 180 },
{
label: '启停状态',
prop: 'enabled',
width: 110,
formatter: (row: PaymentMerchant) =>
h(
ElTag,
{ type: row.enabled ? 'success' : 'info' },
{ default: () => (row.enabled ? '启用' : '停用') }
)
},
{
label: '凭证状态',
prop: 'credential_version',
width: 110,
formatter: (row: PaymentMerchant) =>
h(
ElTag,
{ type: Number(row.credential_version) > 0 ? 'success' : 'info' },
{ default: () => (Number(row.credential_version) > 0 ? '已配置' : '未配置') }
)
},
{
label: '更新时间',
prop: 'updated_at',
width: 170,
formatter: (row: PaymentMerchant) => formatDateTime(row.updated_at)
},
{ label: '备注', prop: 'remark', minWidth: 160 }
]
const { columnChecks, columns } = useCheckedColumns(() => columnOptions)
// 列表加载
const loadMerchants = async () => {
if (!canManage.value) return
loading.value = true
try {
const params: PaymentMerchantQueryParams = {
page: pagination.page,
page_size: pagination.page_size
}
const name = searchForm.name as string | undefined
if (name) params.name = name
if (searchForm.payment_method)
params.payment_method = searchForm.payment_method as PaymentMerchantMethod
if (typeof searchForm.enabled === 'boolean') params.enabled = searchForm.enabled
const res = await PaymentMerchantPoolsService.getPaymentMerchants(params)
if (res.code === 0) {
const data = (res.data as PaymentMerchantPageResult) || {
items: [],
total: 0,
page: 1,
size: 10
}
merchants.value = data.items || []
pagination.total = data.total || 0
pagination.page = data.page || pagination.page
pagination.page_size = data.size || pagination.page_size
}
} catch (error) {
console.error('加载支付商户失败:', error)
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadMerchants()
}
const handleReset = () => {
searchForm.name = ''
searchForm.payment_method = null
searchForm.enabled = null
pagination.page = 1
loadMerchants()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
loadMerchants()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadMerchants()
}
// 表单
type FormMode = 'create' | 'edit'
const formDrawerVisible = ref(false)
const formMode = ref<FormMode>('create')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const showCredentialEditor = ref(false)
const credentialError = ref('')
const initialFormState = () => ({
id: 0,
name: '',
payment_method: 'wechat' as PaymentMerchantMethod,
provider_type: 'wechat' as PaymentMerchantProviderType,
merchant_identity: '',
enabled: true,
remark: '',
credential_version: 0,
credentials: [] as PaymentCredentialEntry[]
})
const form = reactive(initialFormState())
const providerOptions = computed(() =>
PAYMENT_PROVIDER_OPTIONS.filter((option) => option.paymentMethod === form.payment_method)
)
const credentialConfigured = computed(() => Number(form.credential_version) > 0)
const formRules = reactive<FormRules>({
name: [
{ required: true, message: '请输入商户名称', trigger: 'blur' },
{ max: 100, message: '商户名称不超过 100 个字符', trigger: 'blur' }
],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
provider_type: [{ required: true, message: '请选择服务商类型', trigger: 'change' }],
merchant_identity: [
{ required: true, message: '请输入商户标识', trigger: 'blur' },
{ max: 128, message: '商户标识不超过 128 个字符', trigger: 'blur' }
]
})
const generateLocalId = () =>
`cred-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const resetSensitiveForm = () => {
form.credentials = []
showCredentialEditor.value = false
credentialError.value = ''
}
const clearSensitiveForm = () => {
resetSensitiveForm()
form.id = 0
form.credential_version = 0
formRef.value?.clearValidate()
}
const buildCredentialPayload = (): PaymentCredentials | null => {
const credentials: Record<string, string> = {}
for (const entry of form.credentials) {
const key = entry.key.trim()
if (!key) {
credentialError.value = '请填写凭证字段名'
return null
}
if (!entry.value) {
credentialError.value = `请填写凭证字段 ${key} 的值`
return null
}
if (Object.prototype.hasOwnProperty.call(credentials, key)) {
credentialError.value = `凭证字段 ${key} 重复`
return null
}
credentials[key] = entry.value
}
if (form.credentials.length === 0) {
credentialError.value = '请至少添加一个凭证字段'
return null
}
credentialError.value = ''
return credentials
}
const showCreateDrawer = () => {
if (!canManage.value) return
Object.assign(form, initialFormState())
formDrawerVisible.value = true
formMode.value = 'create'
showCredentialEditor.value = true
addCredential()
}
const startCredentialReplacement = () => {
if (!canManage.value) return
form.credentials = []
showCredentialEditor.value = true
addCredential()
}
const addCredential = () => {
form.credentials.push({ localId: generateLocalId(), key: '', value: '' })
}
const removeCredential = (index: number) => {
form.credentials.splice(index, 1)
}
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
form.payment_method = value
const firstMatch = PAYMENT_PROVIDER_OPTIONS.find((option) => option.paymentMethod === value)
if (firstMatch) {
form.provider_type = firstMatch.value
}
}
const handleSubmit = async () => {
if (!canManage.value) return
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
if (formMode.value === 'create' || showCredentialEditor.value) {
const credentials = buildCredentialPayload()
if (!credentials) return
submitLoading.value = true
try {
if (formMode.value === 'create') {
await PaymentMerchantPoolsService.createPaymentMerchant({
name: form.name.trim(),
payment_method: form.payment_method,
provider_type: form.provider_type,
merchant_identity: form.merchant_identity.trim(),
credentials,
enabled: form.enabled,
remark: form.remark?.trim() || ''
})
ElMessage.success('支付商户创建成功')
} else {
await PaymentMerchantPoolsService.updatePaymentMerchant(form.id, {
name: form.name.trim(),
enabled: form.enabled,
remark: form.remark?.trim() || '',
credentials
})
ElMessage.success('支付凭证更新成功')
}
formDrawerVisible.value = false
await loadMerchants()
} catch (error) {
console.error('提交支付商户失败:', error)
} finally {
submitLoading.value = false
}
return
}
submitLoading.value = true
try {
await PaymentMerchantPoolsService.updatePaymentMerchant(form.id, {
name: form.name.trim(),
enabled: form.enabled,
remark: form.remark?.trim() || ''
})
ElMessage.success('支付商户已更新')
formDrawerVisible.value = false
await loadMerchants()
} catch (error) {
console.error('更新支付商户失败:', error)
} finally {
submitLoading.value = false
}
}
// 详情 / 启停 / 删除
const detailDrawerVisible = ref(false)
const detail = ref<PaymentMerchant | null>(null)
const tableRef = ref()
const showDetail = async (id: number) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantById(id)
if (res.code === 0) {
detail.value = res.data
detailDrawerVisible.value = true
}
} catch (error) {
console.error('加载支付商户详情失败:', error)
}
}
const openEditDrawer = async (id: number) => {
if (!canManage.value) return
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantById(id)
if (res.code !== 0) return
const merchant = res.data
Object.assign(form, initialFormState(), {
id: merchant.id,
name: merchant.name,
payment_method: merchant.payment_method,
provider_type: merchant.provider_type,
merchant_identity: merchant.merchant_identity,
enabled: merchant.enabled,
remark: merchant.remark || '',
credential_version: merchant.credential_version
})
formMode.value = 'edit'
showCredentialEditor.value = false
formDrawerVisible.value = true
} catch (error) {
console.error('加载支付商户详情失败:', error)
}
}
const toggleEnabled = async (merchant: PaymentMerchant) => {
if (!canManage.value) return
const target = !merchant.enabled
const action = target ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${action}商户 “${merchant.name}”?`, '操作确认', {
type: 'warning'
})
} catch {
return
}
try {
const res = await PaymentMerchantPoolsService.updatePaymentMerchant(merchant.id, {
enabled: target
})
if (res.code === 0) {
ElMessage.success(`${action}`)
await loadMerchants()
}
} catch (error) {
console.error(`${action}支付商户失败:`, error)
}
}
const confirmDeleteMerchant = async (merchant: PaymentMerchant) => {
if (!canManage.value) return
try {
await ElMessageBox.confirm(
`确认删除支付商户 “${merchant.name}”?该操作不可恢复。`,
'删除确认',
{ type: 'warning' }
)
} catch {
return
}
try {
const res = await PaymentMerchantPoolsService.deletePaymentMerchant(merchant.id)
if (res.code === 0) {
ElMessage.success('已删除')
if (pagination.total > 1 && merchants.value.length === 1 && pagination.page > 1) {
pagination.page -= 1
}
await loadMerchants()
}
} catch (error) {
console.error('删除支付商户失败:', error)
}
}
const getActions = (row: PaymentMerchant) => [
{
label: '详情',
type: 'primary' as const,
handler: () => showDetail(row.id)
},
{
label: '编辑',
type: 'primary' as const,
handler: () => openEditDrawer(row.id),
permission: canManage.value ? '' : 'hidden'
},
{
label: row.enabled ? '停用' : '启用',
type: 'primary' as const,
handler: () => toggleEnabled(row),
permission: canManage.value ? '' : 'hidden'
},
{
label: '删除',
type: 'danger' as const,
handler: () => confirmDeleteMerchant(row),
permission: canManage.value ? '' : 'hidden'
}
]
onMounted(() => {
if (canManage.value) {
loadMerchants()
}
})
onUnmounted(() => {
resetSensitiveForm()
merchants.value = []
detail.value = null
})
</script>
<style lang="scss" scoped>
.merchant-management {
.credential-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.credential-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 8px;
align-items: center;
}
.credential-version {
margin: 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.field-error {
margin-top: 8px;
font-size: 12px;
color: var(--el-color-danger);
}
}
</style>

View File

@@ -0,0 +1,871 @@
<template>
<div class="pool-management">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="loadPools"
>
<template #left>
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
新增商户池
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="pools"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="220"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<ElDrawer
v-model="formDrawerVisible"
:title="formMode === 'create' ? '新增商户池' : '编辑商户池'"
size="780px"
destroy-on-close
@closed="resetForm"
>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
<ElFormItem label="商户池名称" prop="name">
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户池名称" />
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElSelect
v-model="form.payment_method"
:disabled="formMode === 'edit'"
style="width: 100%"
@change="handlePaymentMethodChange"
>
<ElOption
v-for="item in PAYMENT_METHOD_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="成员商户" prop="member_ids">
<VueDraggable
v-if="orderedMemberIds.length"
v-model="orderedMemberIds"
:animation="150"
handle=".drag-handle"
class="pool-member-list"
>
<div v-for="memberId in orderedMemberIds" :key="memberId" class="pool-member-row">
<ElIcon class="drag-handle"><Rank /></ElIcon>
<span class="member-name">{{ getMemberName(memberId) }}</span>
<ElButton type="danger" link :icon="Delete" @click="removeMember(memberId)" />
</div>
</VueDraggable>
<div v-else class="pool-member-empty">尚未选择成员请从下方选择</div>
<ElDivider />
<ElSelect
v-model="pendingMemberId"
filterable
placeholder="选择同支付方式的商户"
style="width: 100%"
:disabled="!form.payment_method"
@change="appendMember"
>
<ElOption
v-for="item in availableMerchantOptions"
:key="item.id"
:label="item.name"
:value="item.id"
:disabled="form.member_ids.includes(item.id)"
/>
</ElSelect>
<div v-if="memberError" class="field-error" role="alert">{{ memberError }}</div>
</ElFormItem>
<ElFormItem label="轮询策略" prop="strategy">
<ElSelect v-model="form.strategy" style="width: 100%">
<ElOption
v-for="item in PAYMENT_POOL_STRATEGY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.strategy === 'amount' || form.strategy === 'count'"
label="统计周期"
prop="statistic_cycle"
>
<ElSelect v-model="form.statistic_cycle" style="width: 100%">
<ElOption
v-for="item in PAYMENT_STATISTIC_CYCLE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="form.strategy === 'amount'"
label="金额阈值(元)"
prop="threshold_amount_yuan"
>
<ElInputNumber
v-model="form.threshold_amount_yuan"
:min="0.01"
:precision="2"
:step="100"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem v-else-if="form.strategy === 'count'" label="笔数阈值" prop="threshold_count">
<ElInputNumber
v-model="form.threshold_count"
:min="1"
:precision="0"
:step="1"
style="width: 100%"
/>
</ElFormItem>
<template v-else-if="form.strategy === 'time'">
<ElFormItem label="时间单位" prop="time_period_unit">
<ElSelect v-model="form.time_period_unit" style="width: 100%">
<ElOption
v-for="item in PAYMENT_TIME_PERIOD_UNIT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="时间长度" prop="time_period_value">
<ElInputNumber
v-model="form.time_period_value"
:min="1"
:precision="0"
:step="1"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="时间起点" prop="time_period_started_at">
<ElDatePicker
v-model="form.time_period_started_at"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择时间起点"
style="width: 100%"
/>
</ElFormItem>
</template>
<ElFormItem label="启停状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
</ElFormItem>
<ElFormItem label="备注">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="formDrawerVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="submitLoading"
:disabled="!canManage"
@click="handleSubmit"
>
保存
</ElButton>
</template>
</ElDrawer>
<ElDrawer v-model="detailDrawerVisible" title="商户池详情" size="640px">
<ElDescriptions v-if="detail" :column="1" border>
<ElDescriptionsItem label="商户池名称">{{ detail.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">{{
getPaymentMethodLabel(detail.payment_method)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成员数量">{{ detail.member_ids.length }}</ElDescriptionsItem>
<ElDescriptionsItem label="轮询策略">{{
getPaymentPoolStrategyLabel(detail.strategy)
}}</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.strategy === 'amount' || detail.strategy === 'count'"
label="统计周期"
>
{{ getPaymentStatisticCycleLabel(detail.statistic_cycle) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'amount'" label="金额阈值">
{{ (Number(detail.threshold_amount) / 100).toFixed(2) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'count'" label="笔数阈值">
{{ detail.threshold_count }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'time'" label="时间周期">
{{ detail.time_period_value }}
{{ getPaymentTimePeriodUnitLabel(detail.time_period_unit) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="启停状态">
{{ detail.enabled ? '启用' : '停用' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="路由世代">v{{ detail.routing_epoch }}</ElDescriptionsItem>
<ElDescriptionsItem label="备注">{{ detail.remark || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(detail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { Delete, Plus, Rank } from '@element-plus/icons-vue'
import { VueDraggable } from 'vue-draggable-plus'
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PaymentMerchantPoolsService } from '@/api/modules'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { usePermission } from '@/composables/usePermission'
import type { SearchFormItem } from '@/types/component'
import {
PAYMENT_METHOD_OPTIONS,
PAYMENT_POOL_STRATEGY_OPTIONS,
PAYMENT_STATISTIC_CYCLE_OPTIONS,
PAYMENT_TIME_PERIOD_UNIT_OPTIONS,
getPaymentMethodLabel,
getPaymentPoolStrategyLabel,
getPaymentStatisticCycleLabel,
getPaymentTimePeriodUnitLabel,
type PaymentMerchantMethod,
type PaymentMerchantPool,
type PaymentMerchantPoolPageResult,
type PaymentMerchantPoolPayload,
type PaymentMerchantPoolMemberOption,
type PaymentPoolStrategy
} from '@/types/api/paymentMerchantPools'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'PaymentMerchantPoolManagement' })
type FilterVo = string | number | undefined | null | unknown[]
const { isPlatformAccount } = usePermission()
const canManage = computed(() => isPlatformAccount.value)
const searchForm = reactive<Record<string, FilterVo>>({
page: 1,
page_size: 10,
payment_method: null
})
const pools = ref<PaymentMerchantPool[]>([])
const pagination = reactive({ page: 1, page_size: 10, total: 0 })
const loading = ref(false)
const searchItems: SearchFormItem[] = [
{
label: '支付方式',
prop: 'payment_method',
type: 'select',
options: PAYMENT_METHOD_OPTIONS.map((item) => ({
label: item.label,
value: item.value
})),
config: { clearable: true }
}
]
const columnOptions = [
{ label: '商户池名称', prop: 'name', minWidth: 180 },
{
label: '支付方式',
prop: 'payment_method',
width: 110,
formatter: (row: PaymentMerchantPool) => getPaymentMethodLabel(row.payment_method)
},
{
label: '成员数量',
prop: 'member_ids',
width: 110,
formatter: (row: PaymentMerchantPool) => `${row.member_ids.length}`
},
{
label: '启停状态',
prop: 'enabled',
width: 110,
formatter: (row: PaymentMerchantPool) =>
h(
ElTag,
{ type: row.enabled ? 'success' : 'info' },
{ default: () => (row.enabled ? '启用' : '停用') }
)
},
{
label: '轮询策略',
prop: 'strategy',
width: 130,
formatter: (row: PaymentMerchantPool) => getPaymentPoolStrategyLabel(row.strategy)
},
{
label: '统计周期',
prop: 'statistic_cycle',
width: 110,
formatter: (row: PaymentMerchantPool) =>
row.strategy === 'time' ? '-' : getPaymentStatisticCycleLabel(row.statistic_cycle)
},
{
label: '阈值',
prop: 'threshold_summary',
width: 150,
formatter: (row: PaymentMerchantPool) => describeThreshold(row)
},
{
label: '更新时间',
prop: 'updated_at',
width: 170,
formatter: (row: PaymentMerchantPool) => formatDateTime(row.updated_at)
}
]
const { columnChecks, columns } = useCheckedColumns(() => columnOptions)
const describeThreshold = (row: PaymentMerchantPool): string => {
if (row.strategy === 'amount') {
return `${(Number(row.threshold_amount) / 100).toFixed(2)}`
}
if (row.strategy === 'count') {
return `${row.threshold_count}`
}
return `${row.time_period_value} ${getPaymentTimePeriodUnitLabel(row.time_period_unit)}`
}
const loadPools = async () => {
if (!canManage.value) return
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.page_size,
payment_method: searchForm.payment_method as PaymentMerchantMethod | undefined
}
const res = await PaymentMerchantPoolsService.getPaymentMerchantPools(params)
if (res.code === 0) {
const data = (res.data as PaymentMerchantPoolPageResult) || {
items: [],
total: 0,
page: 1,
size: 10
}
pools.value = data.items || []
pagination.total = data.total || 0
pagination.page = data.page || pagination.page
pagination.page_size = data.size || pagination.page_size
}
} catch (error) {
console.error('加载商户池失败:', error)
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadPools()
}
const handleReset = () => {
searchForm.payment_method = null
pagination.page = 1
loadPools()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
loadPools()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadPools()
}
// 候选成员
const availableMerchants = ref<PaymentMerchantPoolMemberOption[]>([])
const loadAvailableMerchants = async (paymentMethod: PaymentMerchantMethod) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchants({
page: 1,
page_size: 100,
payment_method: paymentMethod
})
if (res.code === 0) {
const items = res.data?.items || []
availableMerchants.value = items.map((item) => ({
id: item.id,
name: item.name,
payment_method: item.payment_method,
enabled: item.enabled
}))
}
} catch (error) {
console.error('加载候选商户失败:', error)
}
}
const availableMerchantOptions = computed(() =>
availableMerchants.value.filter((m) => m.payment_method === form.payment_method)
)
const getMemberName = (id: number) =>
availableMerchants.value.find((m) => m.id === id)?.name || `#${id}`
// 表单
type FormMode = 'create' | 'edit'
const formDrawerVisible = ref(false)
const formMode = ref<FormMode>('create')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const memberError = ref('')
const pendingMemberId = ref<number | undefined>(undefined)
const initialFormState = () => ({
id: 0,
name: '',
payment_method: 'wechat' as PaymentMerchantMethod,
member_ids: [] as number[],
enabled: true,
strategy: 'amount' as PaymentPoolStrategy,
statistic_cycle: 'round' as PaymentMerchantPoolPayload['statistic_cycle'],
threshold_amount_yuan: 0,
threshold_count: 1,
time_period_unit: 'hour' as PaymentMerchantPoolPayload['time_period_unit'],
time_period_value: 1,
time_period_started_at: '',
remark: ''
})
const form = reactive(initialFormState())
const orderedMemberIds = ref<number[]>([])
watch(
() => form.member_ids,
(val) => {
orderedMemberIds.value = [...val]
},
{ immediate: true, deep: true }
)
watch(orderedMemberIds, (val) => {
form.member_ids = [...val]
})
const formRules = reactive<FormRules>({
name: [
{ required: true, message: '请输入商户池名称', trigger: 'blur' },
{ max: 100, message: '商户池名称不超过 100 个字符', trigger: 'blur' }
],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
member_ids: [
{
validator: (_rule, value: number[], callback) => {
if (!value || value.length === 0) {
callback(new Error('请至少选择一个成员'))
return
}
const unique = new Set(value)
if (unique.size !== value.length) {
callback(new Error('成员不可重复'))
return
}
callback()
},
trigger: 'change'
}
],
strategy: [{ required: true, message: '请选择轮询策略', trigger: 'change' }],
statistic_cycle: [
{
validator: (_rule, value, callback) => {
if ((form.strategy === 'amount' || form.strategy === 'count') && !value) {
callback(new Error('请选择统计周期'))
return
}
callback()
},
trigger: 'change'
}
],
threshold_amount_yuan: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'amount') {
callback()
return
}
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('金额阈值必须大于 0'))
return
}
callback()
},
trigger: 'change'
}
],
threshold_count: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'count') {
callback()
return
}
const num = Number(value)
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) {
callback(new Error('笔数阈值必须为正整数'))
return
}
callback()
},
trigger: 'change'
}
],
time_period_unit: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'time') {
callback()
return
}
if (!value) {
callback(new Error('请选择时间单位'))
return
}
callback()
},
trigger: 'change'
}
],
time_period_value: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'time') {
callback()
return
}
const num = Number(value)
if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) {
callback(new Error('时间长度必须为正整数'))
return
}
callback()
},
trigger: 'change'
}
],
time_period_started_at: [
{
validator: (_rule, value, callback) => {
if (form.strategy !== 'time') {
callback()
return
}
if (!value) {
callback(new Error('请选择时间起点'))
return
}
callback()
},
trigger: 'change'
}
]
})
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
form.payment_method = value
form.member_ids = []
orderedMemberIds.value = []
availableMerchants.value = []
memberError.value = ''
loadAvailableMerchants(value)
}
const appendMember = (id?: number | string) => {
const memberId = Number(id)
if (!memberId) {
pendingMemberId.value = undefined
return
}
if (form.member_ids.includes(memberId)) {
memberError.value = '成员不可重复'
} else {
form.member_ids = [...form.member_ids, memberId]
memberError.value = ''
}
pendingMemberId.value = undefined
}
const removeMember = (id: number) => {
form.member_ids = form.member_ids.filter((memberId) => memberId !== id)
}
const buildPayload = (): PaymentMerchantPoolPayload | null => {
if (!form.payment_method) {
memberError.value = '请选择支付方式'
return null
}
if (form.member_ids.length === 0) {
memberError.value = '请至少选择一个成员'
return null
}
if (new Set(form.member_ids).size !== form.member_ids.length) {
memberError.value = '成员不可重复'
return null
}
memberError.value = ''
const payload: PaymentMerchantPoolPayload = {
name: form.name.trim(),
payment_method: form.payment_method,
member_ids: [...form.member_ids],
enabled: form.enabled,
strategy: form.strategy,
remark: form.remark?.trim() || ''
}
if (form.strategy === 'amount') {
if (!form.statistic_cycle) return null
const yuan = Number(form.threshold_amount_yuan)
if (!Number.isFinite(yuan) || yuan <= 0) return null
payload.statistic_cycle = form.statistic_cycle
payload.threshold_amount = Math.round(yuan * 100)
} else if (form.strategy === 'count') {
if (!form.statistic_cycle) return null
const count = Number(form.threshold_count)
if (!Number.isInteger(count) || count <= 0) return null
payload.statistic_cycle = form.statistic_cycle
payload.threshold_count = count
} else {
if (!form.time_period_unit) return null
const value = Number(form.time_period_value)
if (!Number.isInteger(value) || value <= 0) return null
payload.time_period_unit = form.time_period_unit
payload.time_period_value = value
if (form.time_period_started_at) {
payload.time_period_started_at = form.time_period_started_at
}
}
return payload
}
const showCreateDrawer = async () => {
if (!canManage.value) return
Object.assign(form, initialFormState())
orderedMemberIds.value = []
formDrawerVisible.value = true
formMode.value = 'create'
await loadAvailableMerchants(form.payment_method)
}
const openEditDrawer = async (pool: PaymentMerchantPool) => {
if (!canManage.value) return
await loadAvailableMerchants(pool.payment_method)
Object.assign(form, initialFormState(), {
id: pool.id,
name: pool.name,
payment_method: pool.payment_method,
member_ids: [...pool.member_ids],
enabled: pool.enabled,
strategy: pool.strategy,
statistic_cycle: pool.statistic_cycle,
threshold_amount_yuan: Number(pool.threshold_amount) / 100,
threshold_count: pool.threshold_count,
time_period_unit: pool.time_period_unit,
time_period_value: pool.time_period_value,
time_period_started_at: pool.time_period_started_at || '',
remark: pool.remark || ''
})
orderedMemberIds.value = [...pool.member_ids]
formDrawerVisible.value = true
formMode.value = 'edit'
}
const resetForm = () => {
Object.assign(form, initialFormState())
orderedMemberIds.value = []
memberError.value = ''
pendingMemberId.value = undefined
formRef.value?.clearValidate()
}
const handleSubmit = async () => {
if (!canManage.value) return
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
const payload = buildPayload()
if (!payload) return
submitLoading.value = true
try {
if (formMode.value === 'create') {
await PaymentMerchantPoolsService.createPaymentMerchantPool(payload)
ElMessage.success('商户池创建成功')
} else {
await PaymentMerchantPoolsService.updatePaymentMerchantPool(form.id, payload)
ElMessage.success('商户池已更新')
}
formDrawerVisible.value = false
await loadPools()
} catch (error) {
console.error('提交商户池失败:', error)
} finally {
submitLoading.value = false
}
}
// 详情
const detailDrawerVisible = ref(false)
const detail = ref<PaymentMerchantPool | null>(null)
const tableRef = ref()
const showDetail = async (id: number) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantPoolById(id)
if (res.code === 0) {
detail.value = res.data
detailDrawerVisible.value = true
}
} catch (error) {
console.error('加载商户池详情失败:', error)
}
}
const toggleEnabled = async (pool: PaymentMerchantPool) => {
if (!canManage.value) return
const target = !pool.enabled
const action = target ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${action}商户池 “${pool.name}”?`, '操作确认', {
type: 'warning'
})
} catch {
return
}
try {
const res = target
? await PaymentMerchantPoolsService.enablePaymentMerchantPool(pool.id)
: await PaymentMerchantPoolsService.disablePaymentMerchantPool(pool.id)
if (res.code === 0) {
ElMessage.success(`${action}`)
await loadPools()
}
} catch (error) {
console.error(`${action}商户池失败:`, error)
}
}
const getActions = (row: PaymentMerchantPool) => [
{
label: '详情',
type: 'primary' as const,
handler: () => showDetail(row.id)
},
{
label: '编辑',
type: 'primary' as const,
handler: () => openEditDrawer(row),
permission: canManage.value ? '' : 'hidden'
},
{
label: row.enabled ? '停用' : '启用',
type: 'primary' as const,
handler: () => toggleEnabled(row),
permission: canManage.value ? '' : 'hidden'
}
]
onMounted(() => {
if (canManage.value) {
loadPools()
}
})
onUnmounted(() => {
pools.value = []
detail.value = null
availableMerchants.value = []
orderedMemberIds.value = []
})
</script>
<style lang="scss" scoped>
.pool-management {
.pool-member-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.pool-member-row {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
background-color: rgba(var(--art-gray-200-rgb), 0.6);
border-radius: 4px;
}
.pool-member-row :deep(.drag-handle) {
color: var(--el-text-color-secondary);
cursor: move;
}
.pool-member-empty {
padding: 12px;
color: var(--el-text-color-secondary);
text-align: center;
border: 1px dashed var(--el-border-color);
border-radius: 4px;
}
.member-name {
flex: 1;
}
.field-error {
margin-top: 8px;
font-size: 12px;
color: var(--el-color-danger);
}
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="wechat-authorization-management">
<ElCard v-if="canManage" shadow="never" class="art-table-card">
<template #header>
<div class="card-header">
<span class="card-title">微信授权配置</span>
<ElTag :type="form.enabled ? 'success' : 'info'">
{{ form.enabled ? '已启用' : '已停用' }}
</ElTag>
</div>
</template>
<ElAlert
v-if="!form.enabled"
title="微信授权已停用。停用状态下不会影响现有客户端的支付,但新的授权流程将被拦截。"
type="info"
:closable="false"
show-icon
/>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="140px">
<ElFormItem label="启用微信授权">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
</ElFormItem>
<ElDivider content-position="left">小程序授权</ElDivider>
<ElFormItem label="小程序 AppID" prop="miniapp_app_id">
<ElInput v-model="form.miniapp_app_id" maxlength="64" placeholder="请输入小程序 AppID" />
</ElFormItem>
<ElFormItem label="小程序 AppSecret">
<ElInput
v-model="sensitive.miniapp_app_secret"
type="password"
show-password
placeholder="如需更换请输入新的 AppSecret留空表示保持原值"
autocomplete="new-password"
aria-label="小程序 AppSecret"
/>
<div class="form-tip">仅在显式更换时填写提交后立即清空输入</div>
</ElFormItem>
<ElDivider content-position="left">公众号授权</ElDivider>
<ElFormItem label="公众号 AppID" prop="oa_app_id">
<ElInput v-model="form.oa_app_id" maxlength="64" placeholder="请输入公众号 AppID" />
</ElFormItem>
<ElFormItem label="公众号 AppSecret">
<ElInput
v-model="sensitive.oa_app_secret"
type="password"
show-password
placeholder="如需更换请输入新的 AppSecret留空表示保持原值"
autocomplete="new-password"
aria-label="公众号 AppSecret"
/>
</ElFormItem>
<ElFormItem label="公众号 Token">
<ElInput
v-model="sensitive.oa_token"
type="password"
show-password
placeholder="如需更换请输入新的 Token"
autocomplete="new-password"
aria-label="公众号 Token"
/>
</ElFormItem>
<ElFormItem label="公众号 AES Key">
<ElInput
v-model="sensitive.oa_aes_key"
type="password"
show-password
placeholder="如需更换请输入新的 EncodingAESKey"
autocomplete="new-password"
aria-label="公众号 AES Key"
/>
</ElFormItem>
<ElFormItem label="OAuth 回调地址" prop="oa_oauth_redirect_url">
<ElInput
v-model="form.oa_oauth_redirect_url"
maxlength="512"
placeholder="请输入 OAuth 回调地址"
/>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" :loading="submitLoading" @click="handleSubmit"> 保存 </ElButton>
<ElButton @click="handleCancel">重置</ElButton>
</ElFormItem>
</ElForm>
</ElCard>
<ElEmpty v-else description="无访问权限" :image-size="120" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PaymentMerchantPoolsService } from '@/api/modules'
import { usePermission } from '@/composables/usePermission'
import type {
UpdateWechatAuthorizationRequest,
WechatAuthorizationConfig
} from '@/types/api/paymentMerchantPools'
defineOptions({ name: 'WechatAuthorizationManagement' })
const { isPlatformAccount } = usePermission()
const canManage = computed(() => isPlatformAccount.value)
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const initialFormState = (): WechatAuthorizationConfig => ({
enabled: false,
miniapp_app_id: '',
oa_app_id: '',
oa_oauth_redirect_url: ''
})
const form = reactive<WechatAuthorizationConfig>(initialFormState())
const initialSensitive = () => ({
miniapp_app_secret: '',
oa_app_secret: '',
oa_token: '',
oa_aes_key: ''
})
const sensitive = reactive(initialSensitive())
const clearSensitive = () => {
sensitive.miniapp_app_secret = ''
sensitive.oa_app_secret = ''
sensitive.oa_token = ''
sensitive.oa_aes_key = ''
}
const formRules = reactive<FormRules>({
miniapp_app_id: [{ max: 64, message: 'AppID 长度不超过 64 个字符', trigger: 'blur' }],
oa_app_id: [{ max: 64, message: 'AppID 长度不超过 64 个字符', trigger: 'blur' }],
oa_oauth_redirect_url: [{ max: 512, message: '回调地址长度不超过 512 个字符', trigger: 'blur' }]
})
const loadConfig = async () => {
if (!canManage.value) return
try {
const res = await PaymentMerchantPoolsService.getWechatAuthorization()
if (res.code === 0) {
Object.assign(form, initialFormState(), res.data)
}
} catch (error) {
console.error('加载微信授权配置失败:', error)
}
}
const handleSubmit = async () => {
if (!canManage.value) return
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
const payload: UpdateWechatAuthorizationRequest = {
enabled: form.enabled,
miniapp_app_id: form.miniapp_app_id?.trim() || '',
oa_app_id: form.oa_app_id?.trim() || '',
oa_oauth_redirect_url: form.oa_oauth_redirect_url?.trim() || ''
}
if (sensitive.miniapp_app_secret) {
payload.miniapp_app_secret = sensitive.miniapp_app_secret
}
if (sensitive.oa_app_secret) {
payload.oa_app_secret = sensitive.oa_app_secret
}
if (sensitive.oa_token) {
payload.oa_token = sensitive.oa_token
}
if (sensitive.oa_aes_key) {
payload.oa_aes_key = sensitive.oa_aes_key
}
submitLoading.value = true
try {
const res = await PaymentMerchantPoolsService.updateWechatAuthorization(payload)
if (res.code === 0) {
ElMessage.success('微信授权配置已保存')
Object.assign(form, initialFormState(), res.data)
clearSensitive()
}
} catch (error) {
console.error('保存微信授权配置失败:', error)
} finally {
submitLoading.value = false
}
}
const handleCancel = async () => {
await loadConfig()
clearSensitive()
formRef.value?.clearValidate()
}
onMounted(() => {
if (canManage.value) {
loadConfig()
}
})
onUnmounted(() => {
clearSensitive()
Object.assign(form, initialFormState())
})
</script>
<style lang="scss" scoped>
.wechat-authorization-management {
.card-header {
display: flex;
gap: 12px;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<ArtTableFullScreen>
<div class="payment-merchant-pools-page" id="table-full-screen">
<ElCard v-if="canAccess" shadow="never" class="art-table-card">
<ElTabs v-model="activeTab" type="card" class="payment-merchant-pools-tabs">
<ElTabPane :label="merchantsLabel" name="merchants">
<MerchantManagement />
</ElTabPane>
<ElTabPane :label="poolsLabel" name="pools">
<PoolManagement />
</ElTabPane>
<ElTabPane :label="wechatAuthLabel" name="wechat-auth">
<WechatAuthorizationManagement />
</ElTabPane>
</ElTabs>
</ElCard>
<ElEmpty v-else description="无访问权限" :image-size="120" />
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MerchantManagement from './components/MerchantManagement.vue'
import PoolManagement from './components/PoolManagement.vue'
import WechatAuthorizationManagement from './components/WechatAuthorizationManagement.vue'
import { usePermission } from '@/composables/usePermission'
defineOptions({ name: 'PaymentMerchantPools' })
const { t } = useI18n()
const { isPlatformAccount } = usePermission()
const canAccess = computed(() => isPlatformAccount.value)
const activeTab = ref<'merchants' | 'pools' | 'wechat-auth'>('merchants')
const merchantsLabel = computed(() => t('menus.settings.paymentMerchantPoolsTabMerchants'))
const poolsLabel = computed(() => t('menus.settings.paymentMerchantPoolsTabPools'))
const wechatAuthLabel = computed(() => t('menus.settings.paymentMerchantPoolsTabWechatAuth'))
</script>
<style lang="scss" scoped>
.payment-merchant-pools-page {
height: 100%;
:deep(.payment-merchant-pools-tabs) {
.el-tabs__header {
margin: 0 0 16px;
}
.el-tabs__nav-wrap::after {
display: none;
}
}
}
</style>