diff --git a/openspec/changes/add-payment-merchant-pool-management/design.md b/openspec/changes/add-payment-merchant-pool-management/design.md new file mode 100644 index 0000000..1d9f3c1 --- /dev/null +++ b/openspec/changes/add-payment-merchant-pool-management/design.md @@ -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,或继续保持单个只写对象,但不得把结构暴露为可回显配置。 +- 若后端读取接口未脱敏,前端仍然能够防御性丢弃,但服务端响应、网关日志和网络抓包仍可能泄露凭证;该风险必须由后端脱敏共同控制。 +- 客户支付端若不在当前仓库,文案和错误码任务需要跨仓库联调;本提案负责固化契约,不能仅通过后台页面上线完成验收。 +- 金额阈值若直接按分展示会降低可读性,因此采用元输入、分传输;需要测试防止小数点精度和空值转换错误。 diff --git a/openspec/changes/add-payment-merchant-pool-management/proposal.md b/openspec/changes/add-payment-merchant-pool-management/proposal.md new file mode 100644 index 0000000..2c0c3f9 --- /dev/null +++ b/openspec/changes/add-payment-merchant-pool-management/proposal.md @@ -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、商户池、轮询策略、阈值或路由世代。 diff --git a/openspec/changes/add-payment-merchant-pool-management/specs/payment-checkout-feedback/spec.md b/openspec/changes/add-payment-merchant-pool-management/specs/payment-checkout-feedback/spec.md new file mode 100644 index 0000000..61ab4d1 --- /dev/null +++ b/openspec/changes/add-payment-merchant-pool-management/specs/payment-checkout-feedback/spec.md @@ -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 复用失败支付请求的商户选择或前端临时支付状态 diff --git a/openspec/changes/add-payment-merchant-pool-management/specs/payment-merchant-pool-management/spec.md b/openspec/changes/add-payment-merchant-pool-management/specs/payment-merchant-pool-management/spec.md new file mode 100644 index 0000000..e2d672f --- /dev/null +++ b/openspec/changes/add-payment-merchant-pool-management/specs/payment-merchant-pool-management/spec.md @@ -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 在提交后缓存敏感字段值 diff --git a/openspec/changes/add-payment-merchant-pool-management/tasks.md b/openspec/changes/add-payment-merchant-pool-management/tasks.md new file mode 100644 index 0000000..4a6457d --- /dev/null +++ b/openspec/changes/add-payment-merchant-pool-management/tasks.md @@ -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`。 diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index 90b4cb8..0a8ca56 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -1,4 +1,4 @@ -/** +/** * API 服务模块统一导出 */ @@ -23,6 +23,7 @@ export { OrderService } from './order' export { AssetService } from './asset' export { AgentRechargeService } from './agentRecharge' export { PaymentSettingsService } from './paymentSettings' +export { PaymentMerchantPoolsService } from './paymentMerchantPools' export { SystemConfigService } from './systemConfig' export { ExchangeService } from './exchange' export { RefundService } from './refund' @@ -42,3 +43,4 @@ export { WecomService } from './wecom' // TODO: 按需添加其他业务模块 // export { SettingService } from './setting' + diff --git a/src/api/modules/paymentMerchantPools.ts b/src/api/modules/paymentMerchantPools.ts new file mode 100644 index 0000000..a856f70 --- /dev/null +++ b/src/api/modules/paymentMerchantPools.ts @@ -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 +): 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 { + return this.get(PAYMENT_MERCHANTS_BASE_URL, params).then( + (response) => ({ + ...response, + data: sanitizeMerchantPage(response.data) + }) + ) + } + + static getPaymentMerchantById(id: number): Promise { + return this.get(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`).then( + (response) => ({ + ...response, + data: sanitizeMerchant(response.data as RawPaymentMerchant) + }) + ) + } + + static createPaymentMerchant( + data: CreatePaymentMerchantRequest + ): Promise { + return this.post(PAYMENT_MERCHANTS_BASE_URL, data).then( + (response) => ({ + ...response, + data: sanitizeMerchant(response.data as RawPaymentMerchant) + }) + ) + } + + static updatePaymentMerchant( + id: number, + data: UpdatePaymentMerchantRequest + ): Promise { + return this.put(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`, data).then( + (response) => ({ + ...response, + data: sanitizeMerchant(response.data as RawPaymentMerchant) + }) + ) + } + + static deletePaymentMerchant(id: number): Promise> { + return this.delete>(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`, undefined, { + data: { confirm: true } + }) + } + + static getPaymentMerchantPools( + params?: PaymentMerchantPoolQueryParams + ): Promise { + return this.get(PAYMENT_MERCHANT_POOLS_BASE_URL, params) + } + + static getPaymentMerchantPoolById(id: number): Promise> { + return this.get>(`${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}`) + } + + static createPaymentMerchantPool( + data: PaymentMerchantPoolPayload + ): Promise> { + return this.post>(PAYMENT_MERCHANT_POOLS_BASE_URL, data) + } + + static updatePaymentMerchantPool( + id: number, + data: PaymentMerchantPoolPayload + ): Promise> { + return this.put>( + `${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}`, + data + ) + } + + static enablePaymentMerchantPool(id: number): Promise> { + return this.post>( + `${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}/enable` + ) + } + + static disablePaymentMerchantPool(id: number): Promise> { + return this.post>( + `${PAYMENT_MERCHANT_POOLS_BASE_URL}/${id}/disable` + ) + } + + static getWechatAuthorization(): Promise { + return this.get(WECHAT_AUTHORIZATIONS_BASE_URL).then( + (response) => ({ + ...response, + data: sanitizeWechatAuthorization( + response.data as WechatAuthorizationConfig & Record + ) + }) + ) + } + + static updateWechatAuthorization( + data: UpdateWechatAuthorizationRequest + ): Promise { + return this.put( + `${WECHAT_AUTHORIZATIONS_BASE_URL}/current`, + data + ).then((response) => ({ + ...response, + data: sanitizeWechatAuthorization( + response.data as WechatAuthorizationConfig & Record + ) + })) + } +} diff --git a/src/composables/usePermission.ts b/src/composables/usePermission.ts index bc800fb..9d43d74 100644 --- a/src/composables/usePermission.ts +++ b/src/composables/usePermission.ts @@ -1,4 +1,4 @@ -/** +/** * 权限检查 Composable * 用于在模板或脚本中检查用户权限 */ @@ -18,6 +18,9 @@ export function usePermission() { // 是否是超级管理员 const isSuperAdmin = computed(() => userStore.isSuperAdmin) + // 是否是超级管理员或平台用户 + const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type))) + /** * 检查是否有指定权限 * @param permission 权限码 @@ -76,6 +79,7 @@ export function usePermission() { permissions, buttons, isSuperAdmin, + isPlatformAccount, hasPermission, hasAnyPermission, hasAllPermissions, @@ -84,3 +88,4 @@ export function usePermission() { hasButton } } + diff --git a/src/locales/langs/en.json b/src/locales/langs/en.json index 7351ec7..b7021f1 100644 --- a/src/locales/langs/en.json +++ b/src/locales/langs/en.json @@ -26,11 +26,20 @@ "setting": { "menuType": { "title": "Menu Layout", - "list": ["Vertical", "Horizontal", "Mixed", "Dual"] + "list": [ + "Vertical", + "Horizontal", + "Mixed", + "Dual" + ] }, "theme": { "title": "Theme Style", - "list": ["Light", "Dark", "System"] + "list": [ + "Light", + "Dark", + "System" + ] }, "menu": { "title": "Menu Style" @@ -40,11 +49,17 @@ }, "box": { "title": "Box Style", - "list": ["Border", "Shadow"] + "list": [ + "Border", + "Shadow" + ] }, "container": { "title": "Container Width", - "list": ["Full", "Boxed"] + "list": [ + "Full", + "Boxed" + ] }, "basics": { "title": "Basic Config", @@ -82,8 +97,14 @@ "notice": { "title": "Notice", "btnRead": "Mark as read", - "bar": ["Notice", "Message", "Todo"], - "text": ["No"], + "bar": [ + "Notice", + "Message", + "Todo" + ], + "text": [ + "No" + ], "viewAll": "View all" }, "worktab": { @@ -275,10 +296,10 @@ "evening": "Good evening!" }, "exceptionPage": { - "gohome": "Go Home", "403": "Sorry, you do not have permission to access this page", "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": { "login": { @@ -478,7 +499,11 @@ "detailsOfPaymentConfiguration": "Payment Configuration Details", "paymentMerchant": "Payment Merchant", "developerApi": "Developer API", - "commissionTemplate": "Commission Template" + "commissionTemplate": "Commission Template", + "paymentMerchantPools": "Merchant Pool Management", + "paymentMerchantPoolsTabMerchants": "Payment Merchants", + "paymentMerchantPoolsTabPools": "Merchant Pools", + "paymentMerchantPoolsTabWechatAuth": "WeChat Authorization" }, "batch": { "title": "Batch Operations", diff --git a/src/locales/langs/zh.json b/src/locales/langs/zh.json index 70740b1..3a931d6 100644 --- a/src/locales/langs/zh.json +++ b/src/locales/langs/zh.json @@ -27,11 +27,20 @@ "setting": { "menuType": { "title": "菜单布局", - "list": ["垂直", "水平", "混合", "双列"] + "list": [ + "垂直", + "水平", + "混合", + "双列" + ] }, "theme": { "title": "主题风格", - "list": ["浅色", "深色", "系统"] + "list": [ + "浅色", + "深色", + "系统" + ] }, "menu": { "title": "菜单风格" @@ -41,11 +50,17 @@ }, "box": { "title": "盒子样式", - "list": ["边框", "阴影"] + "list": [ + "边框", + "阴影" + ] }, "container": { "title": "容器宽度", - "list": ["铺满", "定宽"] + "list": [ + "铺满", + "定宽" + ] }, "basics": { "title": "基础配置", @@ -83,8 +98,14 @@ "notice": { "title": "通知", "btnRead": "标为已读", - "bar": ["通知", "消息", "代办"], - "text": ["暂无"], + "bar": [ + "通知", + "消息", + "代办" + ], + "text": [ + "暂无" + ], "viewAll": "查看全部" }, "worktab": { @@ -110,7 +131,11 @@ "admin": "管理员", "user": "普通用户" }, - "placeholder": ["请输入手机号", "请输入密码", "请拖动滑块完成验证"], + "placeholder": [ + "请输入手机号", + "请输入密码", + "请拖动滑块完成验证" + ], "sliderText": "按住滑块拖动", "sliderSuccessText": "验证成功", "rememberPwd": "记住密码", @@ -153,7 +178,11 @@ "register": { "title": "创建账号", "subTitle": "欢迎加入我们,请填写以下信息完成注册", - "placeholder": ["请输入账号", "请输入密码", "请再次输入密码"], + "placeholder": [ + "请输入账号", + "请输入密码", + "请再次输入密码" + ], "rule": [ "请再次输入密码", "两次输入密码不一致!", @@ -288,10 +317,10 @@ "evening": "晚上好!" }, "exceptionPage": { - "gohome": "返回首页", "403": "抱歉,您无权访问该页面", "404": "抱歉,您访问的页面不存在", - "500": "抱歉,服务器出错了" + "500": "抱歉,服务器出错了", + "gohome": "返回首页" }, "menus": { "login": { @@ -421,7 +450,11 @@ "paymentSettings": "支付设置", "detailsOfPaymentConfiguration": "支付配置详情", "withdrawalSettings": "提现配置", - "passwordSettings": "密码设置" + "passwordSettings": "密码设置", + "paymentMerchantPools": "商户池管理", + "paymentMerchantPoolsTabMerchants": "支付商户", + "paymentMerchantPoolsTabPools": "商户池", + "paymentMerchantPoolsTabWechatAuth": "微信授权配置" } }, "table": { diff --git a/src/router/guards/permission.ts b/src/router/guards/permission.ts index de0f23e..f366188 100644 --- a/src/router/guards/permission.ts +++ b/src/router/guards/permission.ts @@ -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 } @@ -195,3 +204,4 @@ export const buildLoginRedirect = (currentPath: string): string => { } return `/auth/login?redirect=${encodeURIComponent(currentPath)}` } + diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index b945d97..a6e65d0 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -1,4 +1,4 @@ -import { RoutesAlias } from '../routesAlias' +import { RoutesAlias } from '../routesAlias' import { AppRouteRecord } from '@/types/router' import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' @@ -836,6 +836,17 @@ export const asyncRoutes: AppRouteRecord[] = [ 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', @@ -1096,3 +1107,5 @@ export const asyncRoutes: AppRouteRecord[] = [ // ] // }, ] + + diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index 13f918b..6c0be44 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -1,4 +1,4 @@ -/** +/** * 路由别名,方便快速找到页面,同时可以用作路由跳转 */ export enum RoutesAlias { @@ -102,6 +102,7 @@ export enum RoutesAlias { WithdrawalSettings = '/settings/withdrawal-settings', // 提现配置 PaymentSettings = '/settings/payment-settings', // 支付设置 PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情 + PaymentMerchantPools = '/settings/payment-merchant-pools', // 支付商户与商户池管理 OperationPasswordSettings = '/settings/operation-password', // 操作密码设置 SystemConfigs = '/settings/system-configs', // 系统配置 WecomSettings = '/settings/wecom', // 企业微信配置兼容入口 @@ -133,3 +134,4 @@ export enum RoutesAlias { // 主页路由 - 修改为资产信息页面 export const HOME_PAGE = RoutesAlias.AssetInformation + diff --git a/src/types/api/index.ts b/src/types/api/index.ts index fee2ff9..425b699 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -1,4 +1,4 @@ -/** +/** * API 类型统一导出 */ @@ -78,6 +78,9 @@ export * from './agentRecharge' // 支付设置相关 export * from './paymentSettings' +// 支付商户与商户池相关 +export * from './paymentMerchantPools' + // 系统配置相关 export * from './systemConfig' @@ -131,3 +134,5 @@ export * from './audit' // 企业微信审批配置相关 export * from './wecom' + + diff --git a/src/types/api/paymentMerchantPools.ts b/src/types/api/paymentMerchantPools.ts new file mode 100644 index 0000000..af92e24 --- /dev/null +++ b/src/types/api/paymentMerchantPools.ts @@ -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 +> + +/** + * 仅用于前端表单编辑场景:携带本地 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 + +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 + +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 +export type PaymentMerchantPageResponse = BaseResponse +export type PaymentMerchantPoolResponse = BaseResponse +export type PaymentMerchantPoolPageResponse = BaseResponse +export type WechatAuthorizationResponse = BaseResponse + +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 +} diff --git a/src/types/router/index.ts b/src/types/router/index.ts index 28d4c0d..0fd8843 100644 --- a/src/types/router/index.ts +++ b/src/types/router/index.ts @@ -40,6 +40,8 @@ export interface RouteMeta extends Record { exportTaskScene?: ExportTaskScene /** 是否固定标签页 */ fixedTab?: boolean + /** 仅允许指定 user_type 访问(1=超级管理员,2=平台用户),为空时不做用户类型限制 */ + allowedUserTypes?: number[] } // 扩展路由记录 diff --git a/src/utils/business/paymentMerchantPool.ts b/src/utils/business/paymentMerchantPool.ts new file mode 100644 index 0000000..99291eb --- /dev/null +++ b/src/utils/business/paymentMerchantPool.ts @@ -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((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) diff --git a/src/views/settings/payment-merchant-pools/components/MerchantManagement.vue b/src/views/settings/payment-merchant-pools/components/MerchantManagement.vue new file mode 100644 index 0000000..0b574c6 --- /dev/null +++ b/src/views/settings/payment-merchant-pools/components/MerchantManagement.vue @@ -0,0 +1,717 @@ + + + + + diff --git a/src/views/settings/payment-merchant-pools/components/PoolManagement.vue b/src/views/settings/payment-merchant-pools/components/PoolManagement.vue new file mode 100644 index 0000000..fac2bd3 --- /dev/null +++ b/src/views/settings/payment-merchant-pools/components/PoolManagement.vue @@ -0,0 +1,871 @@ + + + + + diff --git a/src/views/settings/payment-merchant-pools/components/WechatAuthorizationManagement.vue b/src/views/settings/payment-merchant-pools/components/WechatAuthorizationManagement.vue new file mode 100644 index 0000000..e1256bd --- /dev/null +++ b/src/views/settings/payment-merchant-pools/components/WechatAuthorizationManagement.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/src/views/settings/payment-merchant-pools/index.vue b/src/views/settings/payment-merchant-pools/index.vue new file mode 100644 index 0000000..72fd55e --- /dev/null +++ b/src/views/settings/payment-merchant-pools/index.vue @@ -0,0 +1,56 @@ + + + + +