From e82b0ab5da73eb996c2aa94f8cfc5b1f8e65122c Mon Sep 17 00:00:00 2001 From: luo Date: Wed, 23 Sep 2026 09:32:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=BE=AE=E4=BF=A1=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/modules/order.js | 6 +- api/modules/wallet.js | 3 +- components/RenewalPaymentPopup.vue | 25 +++++- docs/产品迭代8月份/支付.md | 15 ++++ .../update-payment-routing-policy/design.md | 33 +++++++ .../update-payment-routing-policy/proposal.md | 29 ++++++ .../specs/payment-routing-policy/spec.md | 90 +++++++++++++++++++ .../update-payment-routing-policy/tasks.md | 19 ++++ pages/my-wallet/my-wallet.vue | 12 ++- pages/package-order/package-order.vue | 15 +++- utils/payment-attempt.js | 9 ++ utils/request.js | 11 ++- 12 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 docs/产品迭代8月份/支付.md create mode 100644 openspec/changes/update-payment-routing-policy/design.md create mode 100644 openspec/changes/update-payment-routing-policy/proposal.md create mode 100644 openspec/changes/update-payment-routing-policy/specs/payment-routing-policy/spec.md create mode 100644 openspec/changes/update-payment-routing-policy/tasks.md create mode 100644 utils/payment-attempt.js diff --git a/api/modules/order.js b/api/modules/order.js index bc5a82b..bb92c3f 100644 --- a/api/modules/order.js +++ b/api/modules/order.js @@ -30,7 +30,8 @@ export const orderApi = { return request({ url: '/api/c/v1/orders/create', method: 'POST', - data + data, + showError: false }); }, @@ -45,7 +46,8 @@ export const orderApi = { return request({ url: `/api/c/v1/orders/${order_id}/pay`, method: 'POST', - data + data, + showError: false }); } }; diff --git a/api/modules/wallet.js b/api/modules/wallet.js index 7e04478..0f61deb 100644 --- a/api/modules/wallet.js +++ b/api/modules/wallet.js @@ -19,7 +19,8 @@ export const walletApi = { return request({ url: '/api/c/v1/wallet/recharge', method: 'POST', - data + data, + showError: false }); }, diff --git a/components/RenewalPaymentPopup.vue b/components/RenewalPaymentPopup.vue index 2415129..719478e 100644 --- a/components/RenewalPaymentPopup.vue +++ b/components/RenewalPaymentPopup.vue @@ -47,12 +47,14 @@ handlePaymentError, isValidAlipayPaymentLink, isValidWechatPayConfig, + markPendingPaymentRefresh, openAlipayPayment, PAYMENT_REFRESH_TARGETS, showPaymentToast, wechatH5Pay } from '@/utils/payment.js'; import { formatMoney } from '@/utils/display.js'; + import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js'; import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js'; const props = defineProps({ @@ -277,7 +279,13 @@ } uni.showLoading({ title: '准备支付...', mask: true }); - const payResult = await orderApi.pay(orderId, paymentMethod.value); + // Prefer payment data returned by order creation. The fallback keeps + // compatibility with older backends without a duplicate preparation + // request when the new contract is available. + const payResult = hasPreparedPaymentData(orderResult) || + (paymentMethod.value === 'wallet' && orderResult.order.payment_status === 2) + ? orderResult + : await orderApi.pay(orderId, paymentMethod.value); uni.hideLoading(); show.value = false; @@ -290,7 +298,10 @@ uni.hideLoading(); show.value = false; console.error('创建续费订单或支付失败', error); - uni.showToast({ title: error.msg || error.message || '操作失败,请稍后重试', icon: 'none' }); + if (isUnknownPaymentResult(error)) { + markPendingPaymentRefresh(props.paymentRefreshTarget); + } + uni.showToast({ title: getPaymentErrorMessage(error, '操作失败,请稍后重试'), icon: 'none' }); } finally { submitting.value = false; } @@ -333,7 +344,10 @@ } catch (error) { uni.hideLoading(); console.error('订单支付失败', error); - uni.showToast({ title: error.msg || error.message || '支付失败,请稍后重试', icon: 'none' }); + if (isUnknownPaymentResult(error)) { + markPendingPaymentRefresh(props.paymentRefreshTarget); + } + uni.showToast({ title: getPaymentErrorMessage(error), icon: 'none' }); } finally { submitting.value = false; } @@ -353,7 +367,10 @@ } catch (error) { uni.hideLoading(); console.error('直接支付订单失败', error); - uni.showToast({ title: error.msg || error.message || '支付失败,请稍后重试', icon: 'none' }); + if (isUnknownPaymentResult(error)) { + markPendingPaymentRefresh(props.paymentRefreshTarget); + } + uni.showToast({ title: getPaymentErrorMessage(error), icon: 'none' }); } finally { submitting.value = false; } diff --git a/docs/产品迭代8月份/支付.md b/docs/产品迭代8月份/支付.md new file mode 100644 index 0000000..6330217 --- /dev/null +++ b/docs/产品迭代8月份/支付.md @@ -0,0 +1,15 @@ +* 二、C 端支付接口这些接口路径不变,但支付路由行为已变化:┌──────┬───────────────────────────┬───────────────────────────┐│ 方法 │ 接口 │ 用途 │├──────┼───────────────────────────┼───────────────────────────┤│ POST │ /api/c/v1/orders/create │ C 端套餐订单/强充订单创建 │├──────┼───────────────────────────┼───────────────────────────┤│ POST │ /api/c/v1/wallet/recharge │ C 端资产钱包充值 │└──────┴───────────────────────────┴───────────────────────────┘前端需要注意: + +* 支付商户由商户池自动选择,前端不需要传商户 ID。 + +* 明确预下单失败时,当前支付失败,不会自动切换商户重试。 + +* 超时、连接中断等未知结果不会自动切换或重复创建支付。 + +* 金额/笔数轮询按支付创建尝试计入,不再只按支付成功计入。 + +* 原有错误码和错误提示仍以接口文档为准。 + +# 创建订单## OpenAPI Specification```yamlopenapi: 3.0.1info: title: '' description: '' version: 1.0.0paths: /api/c/v1/orders/create: post: summary: 创建订单 deprecated: false description: '' tags: - 个人客户 - 订单 - 个人客户 - 订单 parameters: [] requestBody: content: application/json: schema: $ref: '#/components/schemas/DtoClientCreateOrderRequest' responses: '200': description: 成功 content: application/json: schema: properties: code: description: 响应码 type: integer examples: - 0 data: $ref: '#/components/schemas/DtoClientCreateOrderResponse' msg: description: 响应消息 type: string examples: - success timestamp: description: 时间戳 format: date-time type: string required: - code - data - msg - timestamp type: object x-apifox-orders: - code - data - msg - timestamp x-apifox-ignore-properties: [] headers: {} x-apifox-name: '' '400': description: 请求参数错误 content: application/json: schema: &ref_0 $ref: '#/components/schemas/ErrorResponse' headers: {} x-apifox-name: '' '401': description: 未认证或认证已过期 content: application/json: schema: *ref_0 headers: {} x-apifox-name: '' '403': description: 无权访问 content: application/json: schema: *ref_0 headers: {} x-apifox-name: '' '500': description: 服务器内部错误 content: application/json: schema: *ref_0 headers: {} x-apifox-name: '' security: - BearerAuth: [] x-apifox: schemeGroups: - id: OdihZcUosHAGcYabPlrwh schemeIds: - BearerAuth required: true use: id: OdihZcUosHAGcYabPlrwh scopes: OdihZcUosHAGcYabPlrwh: BearerAuth: [] x-apifox-folder: 个人客户 - 订单 x-apifox-status: released x-run-in-apifox: https://app.apifox.com/web/project/7591618/apis/api-430306493-runcomponents: schemas: DtoClientCreateOrderRequest: properties: app_type: description: 应用类型(微信强充必传)(official_account:公众号, miniapp:小程序) type: string identifier: description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) maxLength: 50 minLength: 1 type: string package_ids: description: 套餐ID列表 items: minimum: 0 type: integer type: array nullable: true payment_method: description: 订单支付方式 (wallet:钱包, wechat:微信, alipay:支付宝) type: string required: - identifier - package_ids - payment_method type: object x-apifox-orders: - app_type - identifier - package_ids - payment_method x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientCreateOrderResponse: properties: idempotent: description: 幂等标志(true 表示返回的是已存在的待支付充值订单) type: boolean linked_package_info: $ref: '#/components/schemas/DtoLinkedPackageInfo' order: $ref: '#/components/schemas/DtoClientOrderInfo' order_type: description: 订单类型 (package:套餐订单, recharge:充值订单) type: string pay_config: $ref: '#/components/schemas/DtoClientPayConfig' payment_link: $ref: '#/components/schemas/DtoClientPaymentLink' recharge: $ref: '#/components/schemas/DtoClientRechargeInfo' type: object x-apifox-orders: - idempotent - linked_package_info - order - order_type - pay_config - payment_link - recharge x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientRechargeInfo: properties: amount: description: 充值金额(分) type: integer auto_purchase_status: description: 自动购包状态 type: string recharge_id: description: 充值ID minimum: 0 type: integer recharge_no: description: 充值单号 type: string status: description: 状态 (0:待支付, 1:已支付, 2:已关闭) type: integer status_name: description: 状态名称(中文) type: string type: object x-apifox-orders: - amount - auto_purchase_status - recharge_id - recharge_no - status - status_name x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientPaymentLink: properties: copy_link: description: 复制到普通浏览器打开的支付链接(与 qr_link 相同) type: string pay_expire_at: description: 支付链接过期时间(RFC3339 格式) type: string payment_no: description: 支付单号 type: string qr_link: description: 二维码内容链接,前端用该链接生成二维码 type: string type: object x-apifox-orders: - copy_link - pay_expire_at - payment_no - qr_link x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientPayConfig: properties: app_id: description: 应用ID type: string nonce_str: description: 随机字符串 type: string package: description: 预支付参数 type: string pay_sign: description: 支付签名 type: string sign_type: description: 签名类型 type: string timestamp: description: 时间戳 type: string type: object x-apifox-orders: - app_id - nonce_str - package - pay_sign - sign_type - timestamp x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientOrderInfo: properties: created_at: description: 创建时间 type: string order_id: description: 订单ID minimum: 0 type: integer order_no: description: 订单号 type: string payment_method: description: 订单创建时选定的支付方式 (wallet:钱包, wechat:微信, alipay:支付宝) type: string payment_status: description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) type: integer payment_status_name: description: 支付状态名称(中文) type: string total_amount: description: 订单总金额(分) type: integer type: object x-apifox-orders: - created_at - order_id - order_no - payment_method - payment_status - payment_status_name - total_amount x-apifox-ignore-properties: [] x-apifox-folder: '' DtoLinkedPackageInfo: properties: force_recharge_amount: description: 强制充值金额(分) type: integer package_names: description: 套餐名称列表 items: type: string type: array nullable: true total_package_amount: description: 套餐总金额(分) type: integer wallet_credit: description: 钱包抵扣金额(分) type: integer type: object x-apifox-orders: - force_recharge_amount - package_names - total_package_amount - wallet_credit x-apifox-ignore-properties: [] x-apifox-folder: '' ErrorResponse: properties: code: description: 错误码 type: integer examples: - 1001 data: description: 错误详情(可选) type: object x-apifox-orders: [] properties: {} x-apifox-ignore-properties: [] msg: description: 错误消息 type: string examples: - 参数验证失败 timestamp: description: 时间戳 format: date-time type: string required: - code - msg - timestamp type: object x-apifox-orders: - code - data - msg - timestamp x-apifox-ignore-properties: [] x-apifox-folder: '' securitySchemes: BearerAuth: bearerFormat: JWT scheme: bearer type: jwt AgentOpenAPIAccount: description: 代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001。 in: header name: X-Agent-Account type: apikey AgentOpenAPINonce: description: 调用方每次请求自行生成的随机串,不需要平台提前分配;同一账号在 5 分钟内不可重复。 in: header name: X-Agent-Nonce type: apikey AgentOpenAPIPassword: description: 代理账号当前登录密码,同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名。 in: header name: X-Agent-Password type: apikey AgentOpenAPISign: description: 调用方按签名规则实时计算的 HMAC-SHA256 小写十六进制签名,不是固定值。 in: header name: X-Agent-Sign type: apikey AgentOpenAPITimestamp: description: 调用方生成的请求发起时间,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,必须与签名原文 timestamp 行一致。 in: header name: X-Agent-Timestamp type: apikey Combination: group: - id: 1262265 - id: 1262266 - id: 1262267 - id: 1262268 - id: 1262269 type: combinationservers: - url: https://cmp-api.boss160.cn description: 测试环境security: []``` + +# 创建充值订单## OpenAPI Specification```yamlopenapi: 3.0.1info: title: '' description: '' version: 1.0.0paths: /api/c/v1/wallet/recharge: post: summary: 创建充值订单 deprecated: false description: '' tags: - 个人客户 - 钱包 - 个人客户 - 钱包 parameters: [] requestBody: content: application/json: schema: $ref: '#/components/schemas/DtoClientCreateRechargeRequest' responses: '200': description: 成功 content: application/json: schema: properties: code: description: 响应码 type: integer examples: - 0 data: $ref: '#/components/schemas/DtoClientRechargeResponse' msg: description: 响应消息 type: string examples: - success timestamp: description: 时间戳 format: date-time type: string required: - code - data - msg - timestamp type: object x-apifox-orders: - code - data - msg - timestamp x-apifox-ignore-properties: [] headers: {} x-apifox-name: '' '400': description: 请求参数错误 content: application/json: schema: &ref_0 $ref: '#/components/schemas/ErrorResponse' headers: {} x-apifox-name: '' '401': description: 未认证或认证已过期 content: application/json: schema: *ref_0 headers: {} x-apifox-name: '' '403': description: 无权访问 content: application/json: schema: *ref_0 headers: {} x-apifox-name: '' '500': description: 服务器内部错误 content: application/json: schema: *ref_0 headers: {} x-apifox-name: '' security: - BearerAuth: [] x-apifox: schemeGroups: - id: BaTDfS1rv5Zg6feGxUPR8 schemeIds: - BearerAuth required: true use: id: BaTDfS1rv5Zg6feGxUPR8 scopes: BaTDfS1rv5Zg6feGxUPR8: BearerAuth: [] x-apifox-folder: 个人客户 - 钱包 x-apifox-status: released x-run-in-apifox: https://app.apifox.com/web/project/7591618/apis/api-430306496-runcomponents: schemas: DtoClientCreateRechargeRequest: properties: amount: description: 充值金额(分) maximum: 10000000 minimum: 1 type: integer app_type: description: 应用类型(微信支付必传)(official_account:公众号, miniapp:小程序) type: string identifier: description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) maxLength: 50 minLength: 1 type: string payment_method: description: 支付方式 (wechat:微信支付, alipay:支付宝) type: string required: - amount - identifier - payment_method type: object x-apifox-orders: - amount - app_type - identifier - payment_method x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientRechargeResponse: properties: pay_config: $ref: '#/components/schemas/DtoClientRechargePayConfig' payment_link: $ref: '#/components/schemas/DtoClientPaymentLink' recharge: $ref: '#/components/schemas/DtoClientRechargeResult' type: object x-apifox-orders: - pay_config - payment_link - recharge x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientRechargeResult: properties: amount: description: 充值金额(分) type: integer recharge_id: description: 充值ID minimum: 0 type: integer recharge_no: description: 充值单号 type: string status: description: 状态 (0:待支付, 1:已支付, 2:已关闭) type: integer type: object x-apifox-orders: - amount - recharge_id - recharge_no - status x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientPaymentLink: properties: copy_link: description: 复制到普通浏览器打开的支付链接(与 qr_link 相同) type: string pay_expire_at: description: 支付链接过期时间(RFC3339 格式) type: string payment_no: description: 支付单号 type: string qr_link: description: 二维码内容链接,前端用该链接生成二维码 type: string type: object x-apifox-orders: - copy_link - pay_expire_at - payment_no - qr_link x-apifox-ignore-properties: [] x-apifox-folder: '' DtoClientRechargePayConfig: properties: app_id: description: 应用ID type: string nonce_str: description: 随机字符串 type: string package: description: 预支付参数 type: string pay_sign: description: 支付签名 type: string sign_type: description: 签名类型 type: string timestamp: description: 时间戳 type: string type: object x-apifox-orders: - app_id - nonce_str - package - pay_sign - sign_type - timestamp x-apifox-ignore-properties: [] x-apifox-folder: '' ErrorResponse: properties: code: description: 错误码 type: integer examples: - 1001 data: description: 错误详情(可选) type: object x-apifox-orders: [] properties: {} x-apifox-ignore-properties: [] msg: description: 错误消息 type: string examples: - 参数验证失败 timestamp: description: 时间戳 format: date-time type: string required: - code - msg - timestamp type: object x-apifox-orders: - code - data - msg - timestamp x-apifox-ignore-properties: [] x-apifox-folder: '' securitySchemes: BearerAuth: bearerFormat: JWT scheme: bearer type: jwt AgentOpenAPIAccount: description: 代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001。 in: header name: X-Agent-Account type: apikey AgentOpenAPINonce: description: 调用方每次请求自行生成的随机串,不需要平台提前分配;同一账号在 5 分钟内不可重复。 in: header name: X-Agent-Nonce type: apikey AgentOpenAPIPassword: description: 代理账号当前登录密码,同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名。 in: header name: X-Agent-Password type: apikey AgentOpenAPISign: description: 调用方按签名规则实时计算的 HMAC-SHA256 小写十六进制签名,不是固定值。 in: header name: X-Agent-Sign type: apikey AgentOpenAPITimestamp: description: 调用方生成的请求发起时间,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,必须与签名原文 timestamp 行一致。 in: header name: X-Agent-Timestamp type: apikey Combination: group: - id: 1262265 - id: 1262266 - id: 1262267 - id: 1262268 - id: 1262269 type: combinationservers: - url: https://cmp-api.boss160.cn description: 测试环境security: []``` diff --git a/openspec/changes/update-payment-routing-policy/design.md b/openspec/changes/update-payment-routing-policy/design.md new file mode 100644 index 0000000..87e1561 --- /dev/null +++ b/openspec/changes/update-payment-routing-policy/design.md @@ -0,0 +1,33 @@ +## Context + +The August backend change is intentionally compatible at the HTTP surface: the two C端 payment creation paths remain unchanged, while merchant selection and retry semantics move to a stricter merchant-pool policy. The H5 client already has separate WeChat, Alipay, and wallet result handling, submit guards, and post-payment status refreshes; this change must tighten creation-attempt boundaries without changing those payment contracts. + +## Goals / Non-Goals + +- Goals: make one accepted user submission correspond to one payment-creation attempt; prevent client-side merchant failover and duplicate creation; preserve backend error semantics; keep status reconciliation read-only. +- Goals: make the attempt-count implication explicit so UI retries and polling do not silently exceed payment limits. +- Non-Goals: changing endpoint paths, adding merchant credentials/IDs to the client, redesigning payment pages, or changing WeChat/Alipay payment invocation contracts. + +## Decisions + +- Decision: merchant selection is opaque to the client. The client sends the documented request payload and never chooses or rotates a merchant. +- Decision: classify an API-declared pre-order failure as terminal for that attempt. The UI reports the backend error and exits the submit flow. +- Decision: classify timeout/network interruption as unknown rather than failed-success. The client does not create a replacement payment automatically; it refreshes status where supported and leaves any retry to an explicit user action. +- Decision: status polling and page refresh use read-only order/recharge queries. They must not call either payment creation endpoint. +- Decision: treat every invocation of a payment creation endpoint as an attempt for quota/count semantics, regardless of the eventual payment result. + +## Risks / Trade-offs + +- A user may need to retry manually after a transient network problem. This is intentional to avoid duplicate payment creation; provide clear “check status/retry” feedback. +- A delayed backend result can leave the UI temporarily indeterminate. Reconciliation through existing status APIs reduces this risk without creating another payment. + +## Migration Plan + +1. Audit API adapters and all payment callers for hidden retries or merchant parameters. +2. Add/adjust terminal and unknown-result branches while retaining existing result handlers. +3. Verify read-only status refresh and submit guards across package and wallet flows. +4. Roll back the client branch if backend rollout is delayed; endpoint contracts remain backward compatible. + +## Open Questions + +- Which order/recharge status endpoint and polling window should be used for an unknown creation result in each deployment environment? diff --git a/openspec/changes/update-payment-routing-policy/proposal.md b/openspec/changes/update-payment-routing-policy/proposal.md new file mode 100644 index 0000000..85092a2 --- /dev/null +++ b/openspec/changes/update-payment-routing-policy/proposal.md @@ -0,0 +1,29 @@ +# Change: Align C端 payment flows with merchant-pool routing policy + +## Why + +The August payment iteration keeps the existing C端 payment creation endpoints, but changes how the backend routes payment creation through its merchant pool. The client must stop assuming that a failed or unknown payment-creation result can be retried against another merchant, otherwise one user action can create duplicate payment attempts and consume payment limits unexpectedly. + +## What Changes + +- Keep `POST /api/c/v1/orders/create` and `POST /api/c/v1/wallet/recharge` as the payment creation endpoints; do not add a merchant ID to client requests. +- Treat merchant selection as backend-owned and preserve the selected payment method and existing request fields (`identifier`, package IDs or amount, and WeChat-only `app_type`). +- Treat an explicit payment pre-order failure as the terminal result for the current attempt; surface the API error without automatically switching merchants or creating another payment. +- Treat timeout, connection interruption, and other unknown payment-creation results as indeterminate; do not automatically switch merchants or repeat payment creation. Any retry must be a deliberate, user-visible action after the applicable order/payment status is reconciled. +- Ensure payment-limit and attempt-count behavior is modeled as “every payment creation attempt counts”, rather than “only successful payments count”; client polling/status refresh must never create another payment. +- Continue to use the API-defined error codes and messages as the source of truth. + +## Capabilities + +### New Capabilities + +- `payment-routing-policy`: Apply merchant-pool routing, terminal-failure, unknown-result, and payment-attempt accounting rules to C端 payment creation. + +### Modified Capabilities + +## Impact + +- Affected API adapters: `api/modules/order.js`, `api/modules/wallet.js`. +- Affected payment callers: package order, wallet recharge, order-list payment, and shared payment-result/status refresh flows. +- No endpoint path or client merchant-selection configuration changes. +- Existing WeChat `pay_config`, Alipay `payment_link`, wallet payment, submit guards, and backend status reconciliation remain compatible. diff --git a/openspec/changes/update-payment-routing-policy/specs/payment-routing-policy/spec.md b/openspec/changes/update-payment-routing-policy/specs/payment-routing-policy/spec.md new file mode 100644 index 0000000..dadbcc7 --- /dev/null +++ b/openspec/changes/update-payment-routing-policy/specs/payment-routing-policy/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Payment creation SHALL use the stable C端 API contract + +The client SHALL create package/strong-recharge orders through `POST /api/c/v1/orders/create` and wallet recharge orders through `POST /api/c/v1/wallet/recharge`. It SHALL submit the documented identifier, package or amount fields, and selected `payment_method`; it SHALL submit `app_type` only when required for WeChat payment and SHALL NOT submit a merchant ID. + +#### Scenario: Package order creation uses the stable route + +- **WHEN** a customer confirms a package or strong-recharge order +- **THEN** the client SHALL call `POST /api/c/v1/orders/create` +- **AND** the request SHALL not contain a client-selected merchant ID + +#### Scenario: Wallet recharge creation uses the stable route + +- **WHEN** a customer confirms a wallet recharge +- **THEN** the client SHALL call `POST /api/c/v1/wallet/recharge` +- **AND** the request SHALL contain the selected payment method and amount in the documented unit + +### Requirement: Merchant selection SHALL remain backend-owned + +The system SHALL rely on the backend merchant pool to select a payment merchant. The client SHALL not rotate merchants, expose merchant selection, or retry the same creation request against another merchant automatically. + +#### Scenario: Payment creation is accepted + +- **WHEN** the backend receives a valid payment creation request +- **THEN** the client SHALL use the returned payment preparation data (`pay_config`, `payment_link`, or wallet result) without attempting merchant selection locally + +### Requirement: Explicit pre-order failure SHALL be terminal for the current attempt + +When a payment creation endpoint returns an explicit failure, the client SHALL treat that attempt as failed, display the API-defined error code/message, and SHALL NOT automatically switch merchants or create another payment. + +#### Scenario: Package order pre-order fails + +- **WHEN** `/api/c/v1/orders/create` returns an error response before payment preparation succeeds +- **THEN** the current package/strong-recharge attempt SHALL end as failed +- **AND** the client SHALL surface the backend error without an automatic second creation request + +#### Scenario: Wallet recharge pre-order fails + +- **WHEN** `/api/c/v1/wallet/recharge` returns an error response before payment preparation succeeds +- **THEN** the current recharge attempt SHALL end as failed +- **AND** the client SHALL not switch merchants or silently create a replacement recharge order + +### Requirement: Unknown payment-creation results SHALL not create duplicates + +Timeouts, connection interruptions, and other results where the creation outcome is unknown SHALL be treated as indeterminate. The client SHALL not automatically switch merchants or repeat payment creation, and any status refresh SHALL be read-only. + +#### Scenario: Creation request times out + +- **WHEN** a payment creation request times out or the connection is interrupted before a definitive response is received +- **THEN** the client SHALL make no automatic replacement creation request +- **AND** the client SHALL allow the existing status-reconciliation flow to determine whether an order or recharge was created + +#### Scenario: Status polling follows an unknown result + +- **WHEN** the client polls or refreshes order/recharge status after an unknown result +- **THEN** it SHALL call only read-only status APIs +- **AND** it SHALL not count the refresh as a new payment creation attempt or invoke either creation endpoint + +### Requirement: Payment attempt accounting SHALL include every creation attempt + +The client SHALL behave consistently with the backend rule that amount/attempt limits count every invocation of a payment creation endpoint, regardless of whether the resulting payment later succeeds. It SHALL not loop until success or assume a failed/unknown attempt is free. + +#### Scenario: Explicit failure consumes an attempt + +- **WHEN** a payment creation request is sent and the backend returns an explicit failure +- **THEN** the client SHALL finish the current flow without automatic repetition +- **AND** subsequent creation SHALL require a new deliberate user action + +#### Scenario: Successful payment follows one creation attempt + +- **WHEN** a payment creation request returns valid payment preparation data +- **THEN** the client SHALL proceed with the existing WeChat, Alipay, or wallet flow +- **AND** status polling or payment-link return handling SHALL not create an additional payment + +### Requirement: Existing payment result contracts SHALL remain compatible + +The routing-policy change SHALL preserve existing WeChat `pay_config`, Alipay `payment_link`, wallet payment, submit-guard, and backend status-reconciliation behavior. API-defined error codes and messages SHALL remain authoritative. + +#### Scenario: WeChat or Alipay preparation succeeds + +- **WHEN** the backend returns valid `pay_config` or `payment_link` +- **THEN** the client SHALL invoke the existing corresponding payment flow +- **AND** it SHALL determine final success from the existing backend status refresh rather than from a routing decision + +#### Scenario: Backend returns a documented error + +- **WHEN** the API returns a documented error code and message +- **THEN** the client SHALL preserve and present that error according to the existing error-handling behavior +- **AND** it SHALL not replace it with a merchant-specific or generic retry result diff --git a/openspec/changes/update-payment-routing-policy/tasks.md b/openspec/changes/update-payment-routing-policy/tasks.md new file mode 100644 index 0000000..86dced1 --- /dev/null +++ b/openspec/changes/update-payment-routing-policy/tasks.md @@ -0,0 +1,19 @@ +## 1. Request contract and routing + +- [x] 1.1 Audit order and wallet payment creation adapters to keep the documented endpoint paths and required fields unchanged. +- [x] 1.2 Ensure no payment creation request accepts or forwards a merchant ID; keep `app_type` conditional on WeChat payment only. +- [x] 1.3 Document the backend-owned merchant-pool selection and the fact that the client must issue one creation request per accepted user action. + +## 2. Failure and unknown-result handling + +- [x] 2.1 Handle explicit pre-order failures as terminal for the current attempt and display the backend-provided error code/message. +- [x] 2.2 Ensure timeout, connection interruption, and other unknown creation results do not trigger merchant switching or a second automatic creation request. +- [x] 2.3 Keep status polling/refresh flows read-only; they must reconcile order or recharge state without creating another payment. +- [x] 2.4 Provide a deliberate, user-visible retry path only after the current order/payment state can be safely reconciled, preserving existing submit guards. + +## 3. Attempt accounting and regression verification + +- [x] 3.1 Verify client behavior does not treat a failed or unknown attempt as free and does not loop until payment succeeds. +- [x] 3.2 Verify package order creation and wallet recharge each make at most one payment-creation call per accepted submission. +- [x] 3.3 Verify WeChat, Alipay, and wallet result handling remains unchanged after routing-policy updates. +- [x] 3.4 Verify API error codes/messages are passed through according to the interface documentation. diff --git a/pages/my-wallet/my-wallet.vue b/pages/my-wallet/my-wallet.vue index e343070..20f8d57 100644 --- a/pages/my-wallet/my-wallet.vue +++ b/pages/my-wallet/my-wallet.vue @@ -238,11 +238,13 @@ handlePaymentError, isValidAlipayPaymentLink, isValidWechatPayConfig, + markPendingPaymentRefresh, openAlipayPayment, PAYMENT_REFRESH_TARGETS, showPaymentToast, wechatH5Pay } from '@/utils/payment.js'; + import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js'; import { formatMoney } from '@/utils/display.js'; import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js'; @@ -683,9 +685,12 @@ uni.hideLoading(); console.error('充值失败', error); showRechargeModal.value = false; + if (isUnknownPaymentResult(error)) { + markPendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.MY_WALLET); + } setTimeout(() => { uni.showToast({ - title: error.msg || error.message || '充值失败,请稍后重试', + title: getPaymentErrorMessage(error, '充值失败,请稍后重试'), icon: 'none' }); }, 50); @@ -744,8 +749,11 @@ } catch (error) { uni.hideLoading(); console.error('获取支付配置失败', error); + if (isUnknownPaymentResult(error)) { + markPendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.MY_WALLET); + } uni.showToast({ - title: error.msg || error.message || '支付失败,请稍后重试', + title: getPaymentErrorMessage(error), icon: 'none' }); } finally { diff --git a/pages/package-order/package-order.vue b/pages/package-order/package-order.vue index ace4a0f..4285dbc 100644 --- a/pages/package-order/package-order.vue +++ b/pages/package-order/package-order.vue @@ -112,11 +112,13 @@ handlePaymentError, isValidAlipayPaymentLink, isValidWechatPayConfig, + markPendingPaymentRefresh, openAlipayPayment, PAYMENT_REFRESH_TARGETS, showPaymentToast, wechatH5Pay } from '@/utils/payment.js'; + import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js'; import { formatMoney } from '@/utils/display.js'; import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js'; @@ -443,7 +445,13 @@ mask: true }); - const payResult = await orderApi.pay(orderResult.order.order_id, paymentMethod.value); + // Newer create responses already contain payment preparation data. + // Reuse it to keep one user submission to one payment-creation + // attempt; retain the pay endpoint fallback for older responses. + const payResult = hasPreparedPaymentData(orderResult) || + (paymentMethod.value === 'wallet' && orderResult.order.payment_status === 2) + ? orderResult + : await orderApi.pay(orderResult.order.order_id, paymentMethod.value); uni.hideLoading(); showModal.value = false; @@ -467,6 +475,9 @@ uni.hideLoading(); showModal.value = false; console.error('创建订单或支付失败', error); + if (isUnknownPaymentResult(error)) { + markPendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.PACKAGE_ORDER); + } if (error.code === 1008) { uni.showModal({ @@ -483,7 +494,7 @@ } uni.showToast({ - title: error.msg || error.message || '操作失败,请稍后重试', + title: getPaymentErrorMessage(error, '操作失败,请稍后重试'), icon: 'none' }); } finally { diff --git a/utils/payment-attempt.js b/utils/payment-attempt.js new file mode 100644 index 0000000..1d1315c --- /dev/null +++ b/utils/payment-attempt.js @@ -0,0 +1,9 @@ +export const isUnknownPaymentResult = (error) => Boolean(error?.paymentResultUnknown); + +export const getPaymentErrorMessage = (error, fallback = '支付失败,请稍后重试') => { + if (isUnknownPaymentResult(error)) { + return '支付结果未知,请稍后查看订单状态'; + } + + return error?.msg || error?.message || fallback; +}; diff --git a/utils/request.js b/utils/request.js index 5a8e7e5..d2cc7bd 100644 --- a/utils/request.js +++ b/utils/request.js @@ -47,8 +47,15 @@ const request = (options) => { reject(responseData); }, fail: (err) => { - showErrorToast('网络请求失败'); - reject(err); + if (options.showError !== false) { + showErrorToast('网络请求失败'); + } + // A transport failure does not tell us whether the backend created a + // payment. Callers must reconcile status and must not retry creation. + reject({ + ...err, + paymentResultUnknown: true + }); } }); });