From 7f547ec76e379360d58dbde0a06949dc2596bf17 Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 12:42:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(alipay):=20=E6=94=AF=E4=BB=98=E5=AE=9D?= =?UTF-8?q?=E5=BD=93=E9=9D=A2=E4=BB=98=E7=9B=B4=E8=BF=9E=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E6=A0=87=20#12.2=20=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AE=9D=E4=BE=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计指出支付只有第三方聚合(易支付)一条路,缺「直连官方渠道」。这是支付宝半边: 当面付(`alipay.trade.precreate`)扫码下单 + 异步通知验签 + 关单,商户自有密钥, 不经中间商。 ## 能被验证的部分(在没有商户号的情况下) 密码学与协议全部跑真实实现,没有一处 mock 掉签名: - RSA2(SHA256withRSA)签名/验签、裸 base64 私钥自动补 PEM 头(控制台复制出来就是裸的) - 待签名串按 **ASCII 字节序**排序并排除空值 —— 用 `localeCompare` 会在大小写混排时 与支付宝不一致;保留空值则所有请求都验签失败 - 响应验签取**节点原文**(不是 `JSON.stringify(parsed)`):支付宝签的是响应体里那段 文本,重新序列化会在字段顺序/空格/转义上不一致,症状是「明明签名对却验不过」 - 回调:签名 → app_id 必须匹配 → 只有 `TRADE_SUCCESS`/`TRADE_FINISHED` 才结算 - 金额:分 ↔ 元字符串,回调用整数换算(浮点加法会把 0.1+0.2 变成 0.30000000000000004) - 时间戳必须是北京时间 `YYYY-MM-DD HH:mm:ss`,ISO 8601 会被直接拒 ## 设计取舍 - **未付款/已关闭的通知也回 `success`**:回 4xx 会让支付宝一直重推,而这些状态本来 就不该结算,重推多少次结果一样。 - **业务错误(code != 10000)标记为 definitive**:参数/商户配置问题重试一百次也一样。 - **网关地址只允许 https 域名**:允许自定义是为了沙箱,但「可配置的出站地址」天然是 SSRF 入口,所以拒 IP 字面量、localhost、.local/.internal。 - **私钥不回显**:后台只显示「已配置」,不把密钥材料吐回浏览器。 - **关单失败不假装成功**:返回 false,让内核保留记录等过期扫描。 ## 配套 后台配置页(`frontend/`):AppID / 网关 / 应用私钥 / 支付宝公钥,四语言目录。 装了插件才有支付方式 —— 没配密钥时 `getMethods()` 返回空,不制造「能点但必失败」的支付按钮。 ## 验证 `pnpm test` **33 例**:协议 23 例(签名往返、篡改、错密钥、裸私钥、金额换算、响应原文 验签、业务错误、嵌套节点提取、回调四类拒绝、网关 URL 校验)+ 插件接线 10 例 (未配置不给支付方式、CNY 之外不出网、precreate 请求含签名、回调结算与不结算、未签名通知 在结算前被拒、配置持久化与坏密钥拒绝保存)。 `pnpm typecheck` / `pnpm build` 通过;`install-into-kernel.mjs` 成功装入内核 data/plugins。 ## 未完成(写清楚,别当成已经能用) - **未用真实商户号跑过真单**:需要 AppID + 应用私钥 + 支付宝公钥 + 可公网回调的 https 域名。 - **微信支付 v3 未做**(需要 APIv3 密钥 + 平台证书,且回调要 AES-GCM 解密,单独一件)。 --- .../plugins/alipay/frontend/manifest.json | 140 +++++++ .../alipay/frontend/messages/en-US.json | 24 ++ .../alipay/frontend/messages/zh-CN.json | 24 ++ .../alipay/frontend/messages/zh-HK.json | 24 ++ .../alipay/frontend/messages/zh-TW.json | 24 ++ packages/plugins/alipay/frontend/package.json | 14 + .../plugins/alipay/frontend/src/admin.tsx | 213 ++++++++++ packages/plugins/alipay/frontend/src/i18n.ts | 20 + .../plugins/alipay/frontend/src/index.tsx | 20 + .../plugins/alipay/frontend/tsup.config.ts | 35 ++ packages/plugins/alipay/package.json | 35 ++ .../plugins/alipay/src/alipay-client.test.ts | 258 ++++++++++++ packages/plugins/alipay/src/alipay-client.ts | 380 ++++++++++++++++++ packages/plugins/alipay/src/index.test.ts | 225 +++++++++++ packages/plugins/alipay/src/index.ts | 284 +++++++++++++ packages/plugins/alipay/tsconfig.build.json | 11 + packages/plugins/alipay/tsconfig.json | 10 + pnpm-lock.yaml | 28 ++ 18 files changed, 1769 insertions(+) create mode 100644 packages/plugins/alipay/frontend/manifest.json create mode 100644 packages/plugins/alipay/frontend/messages/en-US.json create mode 100644 packages/plugins/alipay/frontend/messages/zh-CN.json create mode 100644 packages/plugins/alipay/frontend/messages/zh-HK.json create mode 100644 packages/plugins/alipay/frontend/messages/zh-TW.json create mode 100644 packages/plugins/alipay/frontend/package.json create mode 100644 packages/plugins/alipay/frontend/src/admin.tsx create mode 100644 packages/plugins/alipay/frontend/src/i18n.ts create mode 100644 packages/plugins/alipay/frontend/src/index.tsx create mode 100644 packages/plugins/alipay/frontend/tsup.config.ts create mode 100644 packages/plugins/alipay/package.json create mode 100644 packages/plugins/alipay/src/alipay-client.test.ts create mode 100644 packages/plugins/alipay/src/alipay-client.ts create mode 100644 packages/plugins/alipay/src/index.test.ts create mode 100644 packages/plugins/alipay/src/index.ts create mode 100644 packages/plugins/alipay/tsconfig.build.json create mode 100644 packages/plugins/alipay/tsconfig.json diff --git a/packages/plugins/alipay/frontend/manifest.json b/packages/plugins/alipay/frontend/manifest.json new file mode 100644 index 0000000..1eee13e --- /dev/null +++ b/packages/plugins/alipay/frontend/manifest.json @@ -0,0 +1,140 @@ +{ + "version": "0.1.0", + "entry": "frontend/dist/index.js", + "pages": [], + "layouts": [], + "finders": [], + "adminRoutes": [ + { + "path": "/settings", + "component": "admin/settings", + "permission": "alipay.admin", + "nav": { + "labelKey": "nav.settings", + "groupKey": "nav.group", + "order": 30 + }, + "data": [ + { + "fetch": { + "path": "/alipay/admin/config" + }, + "as": "status" + } + ] + } + ], + "adminActions": [], + "actions": [], + "accountRoutes": [], + "accountWidgets": [], + "files": [ + "frontend/dist/admin.js", + "frontend/dist/chunk-66KFN25F.js", + "frontend/dist/index.js" + ], + "locales": [ + "zh-CN", + "zh-TW", + "zh-HK", + "en-US" + ], + "messages": { + "zh-CN": { + "nav.group": "支付宝", + "nav.settings": "配置", + "page.title": "支付宝当面付", + "page.noSecrets": "未配置平台加密密钥,无法保存商户私钥。", + "page.callbackHint": "异步通知地址:{path}(需在支付宝开放平台登记为可公网访问的 https 地址)。", + "summary.appId": "应用 AppID", + "summary.gateway": "网关地址", + "summary.methods": "支付方式", + "field.appId": "应用 AppID", + "field.gatewayUrl": "网关地址", + "field.gatewayHint": "正式环境用默认值;沙箱填 {sandbox}。只接受 https 域名。", + "field.privateKey": "应用私钥(RSA2)", + "field.alipayPublicKey": "支付宝公钥", + "field.keyKeep": "已配置,留空则保持不变", + "action.save": "保存配置", + "action.saving": "保存中…", + "notice.saved": "支付宝配置已保存。", + "notice.saveFailed": "保存失败,请稍后重试。", + "error.unauthenticated": "登录状态已失效,请重新登录。", + "error.forbidden": "无权执行此操作。", + "error.withCode": "保存失败({code})。", + "error.withStatus": "保存失败(HTTP {status})。" + }, + "zh-TW": { + "nav.group": "支付寶", + "nav.settings": "配置", + "page.title": "支付寶當面付", + "page.noSecrets": "未配置平臺加密金鑰,無法儲存商戶私鑰。", + "page.callbackHint": "非同步通知地址:{path}(需在支付寶開放平臺登記為可公網訪問的 https 地址)。", + "summary.appId": "應用 AppID", + "summary.gateway": "閘道器地址", + "summary.methods": "支付方式", + "field.appId": "應用 AppID", + "field.gatewayUrl": "閘道器地址", + "field.gatewayHint": "正式環境用預設值;沙箱填 {sandbox}。只接受 https 域名。", + "field.privateKey": "應用私鑰(RSA2)", + "field.alipayPublicKey": "支付寶公鑰", + "field.keyKeep": "已配置,留空則保持不變", + "action.save": "儲存配置", + "action.saving": "儲存中…", + "notice.saved": "支付寶配置已儲存。", + "notice.saveFailed": "儲存失敗,請稍後重試。", + "error.unauthenticated": "登入狀態已失效,請重新登入。", + "error.forbidden": "無權執行此操作。", + "error.withCode": "儲存失敗({code})。", + "error.withStatus": "儲存失敗(HTTP {status})。" + }, + "zh-HK": { + "nav.group": "支付寶", + "nav.settings": "配置", + "page.title": "支付寶當面付", + "page.noSecrets": "未配置平臺加密金鑰,無法儲存商戶私鑰。", + "page.callbackHint": "非同步通知地址:{path}(需在支付寶開放平臺登記為可公網訪問的 https 地址)。", + "summary.appId": "應用 AppID", + "summary.gateway": "閘道器地址", + "summary.methods": "支付方式", + "field.appId": "應用 AppID", + "field.gatewayUrl": "閘道器地址", + "field.gatewayHint": "正式環境用默認值;沙箱填 {sandbox}。只接受 https 域名。", + "field.privateKey": "應用私鑰(RSA2)", + "field.alipayPublicKey": "支付寶公鑰", + "field.keyKeep": "已配置,留空則保持不變", + "action.save": "儲存配置", + "action.saving": "儲存中…", + "notice.saved": "支付寶配置已儲存。", + "notice.saveFailed": "儲存失敗,請稍後重試。", + "error.unauthenticated": "登錄狀態已失效,請重新登錄。", + "error.forbidden": "無權執行此操作。", + "error.withCode": "儲存失敗({code})。", + "error.withStatus": "儲存失敗(HTTP {status})。" + }, + "en-US": { + "nav.group": "Alipay", + "nav.settings": "Settings", + "page.title": "Alipay Face-to-Face", + "page.noSecrets": "Platform encryption keys are not configured, so the merchant private key cannot be stored.", + "page.callbackHint": "Async notification URL: {path} (register it on the Alipay open platform as a publicly reachable https address).", + "summary.appId": "App ID", + "summary.gateway": "Gateway", + "summary.methods": "Payment methods", + "field.appId": "App ID", + "field.gatewayUrl": "Gateway URL", + "field.gatewayHint": "Keep the default for production; use {sandbox} for the sandbox. Only https hosts are accepted.", + "field.privateKey": "App private key (RSA2)", + "field.alipayPublicKey": "Alipay public key", + "field.keyKeep": "Already configured — leave empty to keep it", + "action.save": "Save", + "action.saving": "Saving…", + "notice.saved": "Alipay configuration saved.", + "notice.saveFailed": "Could not save. Please retry.", + "error.unauthenticated": "Your session expired. Please sign in again.", + "error.forbidden": "You are not allowed to do this.", + "error.withCode": "Save failed ({code}).", + "error.withStatus": "Save failed (HTTP {status})." + } + } +} diff --git a/packages/plugins/alipay/frontend/messages/en-US.json b/packages/plugins/alipay/frontend/messages/en-US.json new file mode 100644 index 0000000..50449c3 --- /dev/null +++ b/packages/plugins/alipay/frontend/messages/en-US.json @@ -0,0 +1,24 @@ +{ + "nav.group": "Alipay", + "nav.settings": "Settings", + "page.title": "Alipay Face-to-Face", + "page.noSecrets": "Platform encryption keys are not configured, so the merchant private key cannot be stored.", + "page.callbackHint": "Async notification URL: {path} (register it on the Alipay open platform as a publicly reachable https address).", + "summary.appId": "App ID", + "summary.gateway": "Gateway", + "summary.methods": "Payment methods", + "field.appId": "App ID", + "field.gatewayUrl": "Gateway URL", + "field.gatewayHint": "Keep the default for production; use {sandbox} for the sandbox. Only https hosts are accepted.", + "field.privateKey": "App private key (RSA2)", + "field.alipayPublicKey": "Alipay public key", + "field.keyKeep": "Already configured — leave empty to keep it", + "action.save": "Save", + "action.saving": "Saving…", + "notice.saved": "Alipay configuration saved.", + "notice.saveFailed": "Could not save. Please retry.", + "error.unauthenticated": "Your session expired. Please sign in again.", + "error.forbidden": "You are not allowed to do this.", + "error.withCode": "Save failed ({code}).", + "error.withStatus": "Save failed (HTTP {status})." +} diff --git a/packages/plugins/alipay/frontend/messages/zh-CN.json b/packages/plugins/alipay/frontend/messages/zh-CN.json new file mode 100644 index 0000000..672ea00 --- /dev/null +++ b/packages/plugins/alipay/frontend/messages/zh-CN.json @@ -0,0 +1,24 @@ +{ + "nav.group": "支付宝", + "nav.settings": "配置", + "page.title": "支付宝当面付", + "page.noSecrets": "未配置平台加密密钥,无法保存商户私钥。", + "page.callbackHint": "异步通知地址:{path}(需在支付宝开放平台登记为可公网访问的 https 地址)。", + "summary.appId": "应用 AppID", + "summary.gateway": "网关地址", + "summary.methods": "支付方式", + "field.appId": "应用 AppID", + "field.gatewayUrl": "网关地址", + "field.gatewayHint": "正式环境用默认值;沙箱填 {sandbox}。只接受 https 域名。", + "field.privateKey": "应用私钥(RSA2)", + "field.alipayPublicKey": "支付宝公钥", + "field.keyKeep": "已配置,留空则保持不变", + "action.save": "保存配置", + "action.saving": "保存中…", + "notice.saved": "支付宝配置已保存。", + "notice.saveFailed": "保存失败,请稍后重试。", + "error.unauthenticated": "登录状态已失效,请重新登录。", + "error.forbidden": "无权执行此操作。", + "error.withCode": "保存失败({code})。", + "error.withStatus": "保存失败(HTTP {status})。" +} diff --git a/packages/plugins/alipay/frontend/messages/zh-HK.json b/packages/plugins/alipay/frontend/messages/zh-HK.json new file mode 100644 index 0000000..79ddc58 --- /dev/null +++ b/packages/plugins/alipay/frontend/messages/zh-HK.json @@ -0,0 +1,24 @@ +{ + "nav.group": "支付寶", + "nav.settings": "配置", + "page.title": "支付寶當面付", + "page.noSecrets": "未配置平臺加密金鑰,無法儲存商戶私鑰。", + "page.callbackHint": "非同步通知地址:{path}(需在支付寶開放平臺登記為可公網訪問的 https 地址)。", + "summary.appId": "應用 AppID", + "summary.gateway": "閘道器地址", + "summary.methods": "支付方式", + "field.appId": "應用 AppID", + "field.gatewayUrl": "閘道器地址", + "field.gatewayHint": "正式環境用默認值;沙箱填 {sandbox}。只接受 https 域名。", + "field.privateKey": "應用私鑰(RSA2)", + "field.alipayPublicKey": "支付寶公鑰", + "field.keyKeep": "已配置,留空則保持不變", + "action.save": "儲存配置", + "action.saving": "儲存中…", + "notice.saved": "支付寶配置已儲存。", + "notice.saveFailed": "儲存失敗,請稍後重試。", + "error.unauthenticated": "登錄狀態已失效,請重新登錄。", + "error.forbidden": "無權執行此操作。", + "error.withCode": "儲存失敗({code})。", + "error.withStatus": "儲存失敗(HTTP {status})。" +} diff --git a/packages/plugins/alipay/frontend/messages/zh-TW.json b/packages/plugins/alipay/frontend/messages/zh-TW.json new file mode 100644 index 0000000..e4fef4a --- /dev/null +++ b/packages/plugins/alipay/frontend/messages/zh-TW.json @@ -0,0 +1,24 @@ +{ + "nav.group": "支付寶", + "nav.settings": "配置", + "page.title": "支付寶當面付", + "page.noSecrets": "未配置平臺加密金鑰,無法儲存商戶私鑰。", + "page.callbackHint": "非同步通知地址:{path}(需在支付寶開放平臺登記為可公網訪問的 https 地址)。", + "summary.appId": "應用 AppID", + "summary.gateway": "閘道器地址", + "summary.methods": "支付方式", + "field.appId": "應用 AppID", + "field.gatewayUrl": "閘道器地址", + "field.gatewayHint": "正式環境用預設值;沙箱填 {sandbox}。只接受 https 域名。", + "field.privateKey": "應用私鑰(RSA2)", + "field.alipayPublicKey": "支付寶公鑰", + "field.keyKeep": "已配置,留空則保持不變", + "action.save": "儲存配置", + "action.saving": "儲存中…", + "notice.saved": "支付寶配置已儲存。", + "notice.saveFailed": "儲存失敗,請稍後重試。", + "error.unauthenticated": "登入狀態已失效,請重新登入。", + "error.forbidden": "無權執行此操作。", + "error.withCode": "儲存失敗({code})。", + "error.withStatus": "儲存失敗(HTTP {status})。" +} diff --git a/packages/plugins/alipay/frontend/package.json b/packages/plugins/alipay/frontend/package.json new file mode 100644 index 0000000..ae46c4c --- /dev/null +++ b/packages/plugins/alipay/frontend/package.json @@ -0,0 +1,14 @@ +{ + "name": "@stackpanel/plugin-alipay-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsup" + }, + "dependencies": { + "sonner": "^2.0.8", + "zod": "^4.4.3" + }, + "license": "MIT" +} diff --git a/packages/plugins/alipay/frontend/src/admin.tsx b/packages/plugins/alipay/frontend/src/admin.tsx new file mode 100644 index 0000000..c525606 --- /dev/null +++ b/packages/plugins/alipay/frontend/src/admin.tsx @@ -0,0 +1,213 @@ +'use client'; + +import type { AdminPageComponentProps } from '@stackpanel/sdk'; +import { PageHeader } from '@stackpanel/ui'; +import { useRouter } from 'next/navigation'; +import { useState, useTransition, type ReactElement } from 'react'; +import { toast } from 'sonner'; +import { t } from './i18n.js'; + +interface AlipayStatus { + configured: boolean; + secretsEnabled: boolean; + appId?: string; + gatewayUrl?: string; + methods?: Array<{ id: string; label: string }>; +} + +interface ProblemDetails { + code?: string; + title?: string; + detail?: string; +} + +const DEFAULT_GATEWAY = 'https://openapi.alipay.com/gateway.do'; +const SANDBOX_GATEWAY = 'https://openapi-sandbox.dl.alipaydev.com/gateway.do'; + +/** + * 支付宝当面付配置表单。 + * + * 直接提交到插件自己的内核路由(经通用 BFF 代理),不走声明式 action 桥: + * 桥在失败时 redirect 回退会重渲染整页,把已经粘贴进去的私钥清空 —— 对 + * 一段 2000 字符的密钥来说,这个体验代价太大。 + * + * 私钥不预填:服务端只回显「已配置」,不把密钥材料再吐回浏览器。 + */ +function AlipayAdminPage(props: AdminPageComponentProps): ReactElement { + const tr = t(props.locale); + const status = props.data.status as AlipayStatus | undefined; + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [appId, setAppId] = useState(status?.appId ?? ''); + const [gatewayUrl, setGatewayUrl] = useState(status?.gatewayUrl ?? DEFAULT_GATEWAY); + const [privateKey, setPrivateKey] = useState(''); + const [alipayPublicKey, setAlipayPublicKey] = useState(''); + + function handleSubmit(event: React.FormEvent): void { + event.preventDefault(); + const payload = { + appId: appId.trim(), + privateKey: privateKey.trim(), + alipayPublicKey: alipayPublicKey.trim(), + gatewayUrl: gatewayUrl.trim() || DEFAULT_GATEWAY, + }; + startTransition(async () => { + try { + const response = await fetch('/api/plugins/alipay/admin/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + toast.error(await readErrorMessage(response, tr)); + return; + } + toast.success(tr('notice.saved')); + setPrivateKey(''); + router.refresh(); + } catch { + toast.error(tr('notice.saveFailed')); + } + }); + } + + return ( +
+ + {status?.secretsEnabled === false ? ( +

+ {tr('page.noSecrets')} +

+ ) : null} +

+ {tr('page.callbackHint', { path: '/callbacks/alipay' })} +

+ + {status?.configured ? ( +
+
+
+
{tr('summary.appId')}
+
{status.appId}
+
+
+
{tr('summary.gateway')}
+
{status.gatewayUrl}
+
+
+
{tr('summary.methods')}
+
{status.methods?.map((method) => method.label).join('、')}
+
+
+
+ ) : null} + +
+ + + + + + +
+ ); +} + +function Field(props: { + label: string; + name: string; + value: string; + onChange: (value: string) => void; + hint?: string; + placeholder?: string; + textarea?: boolean; + required?: boolean; +}): ReactElement { + const id = `alipay-${props.name}`; + const className = + 'w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:border-primary'; + return ( +
+ + {props.textarea ? ( +