From db65295ddcf343ac23f57580b3cda0d42d91bdd5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 22:42:27 +0800 Subject: [PATCH] feat: add payment provider webhooks --- .../tenant-provider-config.ts} | 87 ++-- apps/api/src/features/auth/providers.ts | 2 +- apps/api/src/features/auth/routes.ts | 2 +- apps/api/src/features/commerce/index.ts | 6 + apps/api/src/features/commerce/providers.ts | 420 ++++++++++++++++++ apps/api/src/features/commerce/routes.ts | 388 +++++++++++++++- docs/refactor/auth-payment-provider-plan.md | 70 ++- docs/refactor/backend-capability-status.md | 4 +- docs/refactor/next-development-todo.md | 4 +- docs/refactor/taro-frontend-integration.md | 70 ++- scripts/api-integration-test.js | 272 +++++++++++- 11 files changed, 1286 insertions(+), 39 deletions(-) rename apps/api/src/{features/auth/provider-config.ts => core/tenant-provider-config.ts} (64%) create mode 100644 apps/api/src/features/commerce/providers.ts diff --git a/apps/api/src/features/auth/provider-config.ts b/apps/api/src/core/tenant-provider-config.ts similarity index 64% rename from apps/api/src/features/auth/provider-config.ts rename to apps/api/src/core/tenant-provider-config.ts index a691f79d..35af3e1f 100644 --- a/apps/api/src/features/auth/provider-config.ts +++ b/apps/api/src/core/tenant-provider-config.ts @@ -1,6 +1,6 @@ -import { query, queryOne } from '../../core/db.js'; -import { HttpError } from '../../core/http.js'; -import { config as appConfig } from '../../core/config.js'; +import { query, queryOne } from './db.js'; +import { HttpError } from './http.js'; +import { config as appConfig } from './config.js'; export type TenantSecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system'; @@ -11,17 +11,20 @@ export interface TenantSecretConfig { secretJson: Record; } -export interface TenantAuthProviderConfig { +export interface TenantProviderConfig { tenantId: string; provider: string; - status: 'active' | 'testing' | 'disabled'; + status: string; configPublic: Record; secret: TenantSecretConfig | null; } -interface TenantAuthProviderRow { +export type TenantAuthProviderConfig = TenantProviderConfig; +export type TenantPaymentProviderConfig = TenantProviderConfig; + +interface ProviderRow { provider: string; - status: 'active' | 'testing' | 'disabled'; + status: string; configPublic: Record | null; } @@ -49,6 +52,7 @@ function parseSecretRef(value: unknown): { scope: TenantSecretScope; key: string function defaultSecretScope(provider: string): TenantSecretScope { const normalized = provider.toLowerCase().replace(/[_\s]/g, '-'); if (normalized.includes('sms') || normalized === 'aliyun' || normalized === 'tencent') return 'sms'; + if (normalized.includes('pay') || normalized.includes('alipay')) return 'payment'; return 'oauth'; } @@ -83,31 +87,33 @@ async function loadTenantSecret( }; } -export async function loadTenantAuthProviderConfig( - tenantId: string, - providers: string[], -): Promise { - const uniqueProviders = [...new Set(providers.map(item => item.trim()).filter(Boolean))]; +async function loadTenantProviderConfig(input: { + table: 'tenant_auth_providers' | 'tenant_payment_accounts'; + tenantId: string; + providers: string[]; + allowedStatuses: string[]; +}): Promise { + const uniqueProviders = [...new Set(input.providers.map(item => item.trim()).filter(Boolean))]; if (uniqueProviders.length === 0) return null; - const rows = await query( + const rows = await query( ` select provider, status, config_public as "configPublic" - from public.tenant_auth_providers + from public.${input.table} where tenant_id = $1 and provider = any($2::text[]) - and status in ('active', 'testing') + and status = any($3::text[]) `, - [tenantId, uniqueProviders], + [input.tenantId, uniqueProviders, input.allowedStatuses], ); const row = uniqueProviders.map(provider => rows.find(item => item.provider === provider)).find(Boolean); if (!row) return null; const configPublic = objectValue(row.configPublic); - const secret = await loadTenantSecret(tenantId, configPublic, row.provider); + const secret = await loadTenantSecret(input.tenantId, configPublic, row.provider); return { - tenantId, + tenantId: input.tenantId, provider: row.provider, status: row.status, configPublic, @@ -115,7 +121,25 @@ export async function loadTenantAuthProviderConfig( }; } -export function requirePublicString(config: TenantAuthProviderConfig, keys: string[], code: string) { +export function loadTenantAuthProviderConfig(tenantId: string, providers: string[]) { + return loadTenantProviderConfig({ + table: 'tenant_auth_providers', + tenantId, + providers, + allowedStatuses: ['active', 'testing'], + }); +} + +export function loadTenantPaymentProviderConfig(tenantId: string, providers: string[]) { + return loadTenantProviderConfig({ + table: 'tenant_payment_accounts', + tenantId, + providers, + allowedStatuses: ['active'], + }); +} + +export function requirePublicString(config: TenantProviderConfig, keys: string[], code: string) { for (const key of keys) { const value = config.configPublic[key]; if (typeof value === 'string' && value.trim()) return value.trim(); @@ -123,7 +147,7 @@ export function requirePublicString(config: TenantAuthProviderConfig, keys: stri throw new HttpError(503, `${config.provider} public config is missing ${keys[0]}`, code); } -export function optionalPublicString(config: TenantAuthProviderConfig, keys: string[]) { +export function optionalPublicString(config: TenantProviderConfig, keys: string[]) { for (const key of keys) { const value = config.configPublic[key]; if (typeof value === 'string' && value.trim()) return value.trim(); @@ -140,41 +164,46 @@ function isAllowedHost(hostname: string, allowedHosts: string[]) { } export function providerEndpoint( - authProvider: TenantAuthProviderConfig, + providerConfig: TenantProviderConfig, fallback: string, allowedHosts: string[], code = 'PROVIDER_ENDPOINT_NOT_ALLOWED', ) { - const raw = optionalPublicString(authProvider, ['endpoint']) || fallback; + const raw = optionalPublicString(providerConfig, ['endpoint']) || fallback; let endpoint: URL; try { endpoint = new URL(raw); } catch { - throw new HttpError(503, `${authProvider.provider} endpoint is invalid`, code); + throw new HttpError(503, `${providerConfig.provider} endpoint is invalid`, code); } const localDev = !appConfig.isProduction && isLocalDevHost(endpoint.hostname); if (!localDev && endpoint.protocol !== 'https:') { - throw new HttpError(503, `${authProvider.provider} endpoint must use HTTPS`, code); + throw new HttpError(503, `${providerConfig.provider} endpoint must use HTTPS`, code); } if (!localDev && !isAllowedHost(endpoint.hostname, allowedHosts)) { - throw new HttpError(503, `${authProvider.provider} endpoint host is not allowed`, code); + throw new HttpError(503, `${providerConfig.provider} endpoint host is not allowed`, code); } endpoint.username = ''; endpoint.password = ''; return endpoint.toString(); } -export function optionalPublicObject(config: TenantAuthProviderConfig, key: string) { +export function optionalPublicObject(config: TenantProviderConfig, key: string) { return objectValue(config.configPublic[key]); } -export function optionalPublicArray(config: TenantAuthProviderConfig, key: string) { +export function optionalPublicArray(config: TenantProviderConfig, key: string) { const value = config.configPublic[key]; return Array.isArray(value) ? value : []; } -export function secretString(config: TenantAuthProviderConfig, keys: string[]) { +export function optionalPublicBoolean(config: TenantProviderConfig, key: string, fallback = false) { + const value = config.configPublic[key]; + return typeof value === 'boolean' ? value : fallback; +} + +export function secretString(config: TenantProviderConfig, keys: string[]) { const secret = config.secret; if (!secret) return ''; if (typeof secret.secretValue === 'string' && secret.secretValue.trim()) return secret.secretValue.trim(); @@ -185,7 +214,7 @@ export function secretString(config: TenantAuthProviderConfig, keys: string[]) { return ''; } -export function requireSecretString(config: TenantAuthProviderConfig, keys: string[], code: string) { +export function requireSecretString(config: TenantProviderConfig, keys: string[], code: string) { const value = secretString(config, keys); if (value) return value; throw new HttpError(503, `${config.provider} secret is missing ${keys[0]}`, code); diff --git a/apps/api/src/features/auth/providers.ts b/apps/api/src/features/auth/providers.ts index 498245a0..23cc4d32 100644 --- a/apps/api/src/features/auth/providers.ts +++ b/apps/api/src/features/auth/providers.ts @@ -8,7 +8,7 @@ import { requirePublicString, requireSecretString, type TenantAuthProviderConfig, -} from './provider-config.js'; +} from '../../core/tenant-provider-config.js'; export type SmsProviderName = 'mock' | 'aliyun' | 'tencent'; export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'wechat-miniapp' | 'qq'; diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 8abc5be5..40757e14 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -11,7 +11,7 @@ import { providerEndpoint, requirePublicString, requireSecretString, -} from './provider-config.js'; +} from '../../core/tenant-provider-config.js'; import { assertChinaPhone, clientIpFrom, diff --git a/apps/api/src/features/commerce/index.ts b/apps/api/src/features/commerce/index.ts index 3c030ef1..85484a23 100644 --- a/apps/api/src/features/commerce/index.ts +++ b/apps/api/src/features/commerce/index.ts @@ -1,10 +1,12 @@ import type { RouteDefinition } from '../../core/router.js'; import { confirmManualPaymentRoute, + createPaymentRoute, createOrderRoute, entitlementCheckRoute, entitlementsRoute, ordersRoute, + paymentNotifyRoute, redeemActivationCodeRoute, } from './routes.js'; @@ -13,6 +15,10 @@ export const commerceRoutes: RouteDefinition[] = [ ['GET', '/api/commerce/orders', ordersRoute], ['GET', '/api/commerce/entitlements', entitlementsRoute], ['GET', '/api/commerce/entitlements/check', entitlementCheckRoute], + ['POST', '/api/commerce/payments/create', createPaymentRoute], ['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute], + ['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute], + ['POST', '/api/commerce/payments/notify/wechat-pay', paymentNotifyRoute], + ['POST', '/api/commerce/payments/notify/alipay', paymentNotifyRoute], ['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute], ]; diff --git a/apps/api/src/features/commerce/providers.ts b/apps/api/src/features/commerce/providers.ts new file mode 100644 index 00000000..cd81701f --- /dev/null +++ b/apps/api/src/features/commerce/providers.ts @@ -0,0 +1,420 @@ +import crypto from 'node:crypto'; +import { HttpError } from '../../core/http.js'; +import { config as appConfig } from '../../core/config.js'; +import { + optionalPublicBoolean, + optionalPublicString, + providerEndpoint, + requirePublicString, + requireSecretString, + type TenantPaymentProviderConfig, +} from '../../core/tenant-provider-config.js'; + +export type PaymentProviderName = 'wechat_pay' | 'alipay' | 'manual'; + +export interface PaymentOrderInput { + tenantId: string; + orderId: string; + orderNo: string; + description: string; + amountCents: number; + userId: string; + openId?: string; + clientIp?: string; + returnUrl?: string; + quitUrl?: string; +} + +export interface PaymentCreateResult { + provider: PaymentProviderName; + method: string; + providerTradeNo?: string; + paymentParams: Record; + raw: Record; +} + +export interface PaymentNotificationResult { + provider: PaymentProviderName; + eventId: string; + eventType: string; + orderNo: string; + providerTradeNo: string; + amountCents: number; + paidAt?: string; + raw: Record; +} + +export interface PaymentProvider { + name: PaymentProviderName; + createPayment(input: PaymentOrderInput): Promise; + parseNotification(input: { + headers: Record; + body: Record; + rawBody: string; + }): Promise; +} + +function base64urlDecode(value: string) { + return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function requiredBodyString(body: Record, key: string, code: string) { + const value = body[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + throw new HttpError(400, `${key} is required`, code); +} + +function optionalBodyString(body: Record, key: string) { + const value = body[key]; + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function rsaSignSha256(privateKey: string, message: string) { + return crypto.createSign('RSA-SHA256').update(message, 'utf8').sign(privateKey, 'base64'); +} + +function rsaVerifySha256(publicKey: string, message: string, signature: string) { + return crypto.createVerify('RSA-SHA256').update(message, 'utf8').verify(publicKey, signature, 'base64'); +} + +function normalizePem(value: string, label: 'PRIVATE KEY' | 'PUBLIC KEY') { + if (value.includes('-----BEGIN')) return value; + const wrapped = value.match(/.{1,64}/g)?.join('\n') || value; + return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----`; +} + +function safeJson(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function canonicalForm(params: Record) { + return Object.keys(params) + .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') + .sort() + .map(key => `${key}=${params[key]}`) + .join('&'); +} + +function encodedForm(params: Record) { + return Object.keys(params) + .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') + .sort() + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join('&'); +} + +function wechatPrivateKey(config: TenantPaymentProviderConfig) { + return normalizePem(requireSecretString(config, ['privateKey', 'merchantPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY'); +} + +function randomNonce(size = 16) { + return crypto.randomBytes(size).toString('base64url'); +} + +class WechatPayProvider implements PaymentProvider { + readonly name = 'wechat_pay' as const; + + constructor(private readonly config: TenantPaymentProviderConfig) {} + + async createPayment(input: PaymentOrderInput): Promise { + const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const mchId = requirePublicString(this.config, ['merchantId', 'mchId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const merchantSerialNo = requirePublicString(this.config, ['merchantSerialNo'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const endpoint = providerEndpoint( + this.config, + 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi', + ['api.mch.weixin.qq.com'], + 'PAYMENT_ENDPOINT_NOT_ALLOWED', + ); + const openId = input.openId || optionalPublicString(this.config, ['testOpenId']); + if (!openId) { + throw new HttpError(400, 'openId is required for WeChat JSAPI payment', 'WECHAT_OPENID_REQUIRED'); + } + + const payload = JSON.stringify({ + appid: appId, + mchid: mchId, + description: input.description.slice(0, 120), + out_trade_no: input.orderNo, + notify_url: notifyUrl, + amount: { + total: input.amountCents, + currency: 'CNY', + }, + payer: { + openid: openId, + }, + }); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = randomNonce(); + const url = new URL(endpoint); + const message = ['POST', `${url.pathname}${url.search}`, timestamp, nonce, payload].join('\n') + '\n'; + const signature = rsaSignSha256(wechatPrivateKey(this.config), message); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`, + 'content-type': 'application/json', + }, + body: payload, + }); + const raw = safeJson(await response.json().catch(() => ({}))); + if (!response.ok) { + throw new HttpError(502, 'WeChat Pay create transaction failed', 'PAYMENT_PROVIDER_CREATE_FAILED'); + } + + const prepayId = typeof raw.prepay_id === 'string' ? raw.prepay_id : ''; + if (!prepayId) throw new HttpError(502, 'WeChat Pay response is missing prepay_id', 'PAYMENT_PROVIDER_RESPONSE_INVALID'); + const payTimestamp = Math.floor(Date.now() / 1000).toString(); + const payNonce = randomNonce(); + const packageValue = `prepay_id=${prepayId}`; + const paySignMessage = [appId, payTimestamp, payNonce, packageValue].join('\n') + '\n'; + const paySign = rsaSignSha256(wechatPrivateKey(this.config), paySignMessage); + + return { + provider: this.name, + method: 'jsapi', + providerTradeNo: prepayId, + paymentParams: { + appId, + timeStamp: payTimestamp, + nonceStr: payNonce, + package: packageValue, + signType: 'RSA', + paySign, + }, + raw: { + prepayId, + }, + }; + } + + async parseNotification(input: { + headers: Record; + body: Record; + rawBody: string; + }): Promise { + const publicKey = optionalPublicString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey']) + || requireSecretString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'); + const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(this.config, 'allowLocalMock', false); + if (publicKey && input.headers['wechatpay-signature']) { + const timestamp = input.headers['wechatpay-timestamp'] || ''; + const nonceHeader = input.headers['wechatpay-nonce'] || ''; + const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`; + if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) { + throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID'); + } + } else if (!allowMockSignature) { + throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED'); + } + + const apiV3Key = requireSecretString(this.config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED'); + const resource = objectValue(input.body.resource); + const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID'); + const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID'); + const associatedData = optionalBodyString(resource, 'associated_data'); + + let decrypted: Record; + try { + const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8')); + if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8')); + const encrypted = base64urlDecode(ciphertext); + const authTag = encrypted.subarray(encrypted.length - 16); + const cipherText = encrypted.subarray(0, encrypted.length - 16); + decipher.setAuthTag(authTag); + const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]); + decrypted = safeJson(JSON.parse(buffer.toString('utf8'))); + } catch { + throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID'); + } + + const tradeState = requiredBodyString(decrypted, 'trade_state', 'PAYMENT_NOTIFICATION_INVALID'); + if (tradeState !== 'SUCCESS') { + throw new HttpError(409, `WeChat Pay trade state is ${tradeState}`, 'PAYMENT_NOT_PAID'); + } + const orderNo = requiredBodyString(decrypted, 'out_trade_no', 'PAYMENT_NOTIFICATION_INVALID'); + const providerTradeNo = requiredBodyString(decrypted, 'transaction_id', 'PAYMENT_NOTIFICATION_INVALID'); + const amount = objectValue(decrypted.amount); + const amountCents = Number(amount.total); + if (!Number.isInteger(amountCents) || amountCents < 0) { + throw new HttpError(400, 'WeChat Pay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID'); + } + + return { + provider: this.name, + eventId: requiredBodyString(input.body, 'id', 'PAYMENT_NOTIFICATION_INVALID'), + eventType: optionalBodyString(input.body, 'event_type') || 'TRANSACTION.SUCCESS', + orderNo, + providerTradeNo, + amountCents, + paidAt: optionalBodyString(decrypted, 'success_time') || undefined, + raw: { + notification: input.body, + transaction: decrypted, + headers: { + serial: input.headers['wechatpay-serial'] || '', + timestamp: input.headers['wechatpay-timestamp'] || '', + }, + }, + }; + } +} + +class AlipayProvider implements PaymentProvider { + readonly name = 'alipay' as const; + + constructor(private readonly config: TenantPaymentProviderConfig) {} + + async createPayment(input: PaymentOrderInput): Promise { + const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const returnUrl = input.returnUrl || optionalPublicString(this.config, ['returnUrl']); + const quitUrl = input.quitUrl || optionalPublicString(this.config, ['quitUrl']); + const gateway = providerEndpoint( + this.config, + 'https://openapi.alipay.com/gateway.do', + ['openapi.alipay.com'], + 'PAYMENT_ENDPOINT_NOT_ALLOWED', + ); + const privateKey = normalizePem(requireSecretString(this.config, ['privateKey', 'appPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY'); + const bizContent: Record = { + out_trade_no: input.orderNo, + total_amount: (input.amountCents / 100).toFixed(2), + subject: input.description.slice(0, 120), + product_code: 'QUICK_WAP_WAY', + }; + if (quitUrl) bizContent.quit_url = quitUrl; + const params: Record = { + app_id: appId, + method: 'alipay.trade.wap.pay', + charset: 'utf-8', + sign_type: 'RSA2', + timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19), + version: '1.0', + notify_url: notifyUrl, + biz_content: JSON.stringify(bizContent), + }; + if (returnUrl) params.return_url = returnUrl; + params.sign = rsaSignSha256(privateKey, canonicalForm(params)); + + return { + provider: this.name, + method: 'wap', + paymentParams: { + gateway, + method: 'GET', + query: params, + url: `${gateway}?${encodedForm(params)}`, + }, + raw: { + outTradeNo: input.orderNo, + }, + }; + } + + async parseNotification(input: { + headers: Record; + body: Record; + rawBody: string; + }): Promise { + const alipayPublicKey = normalizePem( + requireSecretString(this.config, ['alipayPublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'), + 'PUBLIC KEY', + ); + const params: Record = {}; + for (const [key, value] of Object.entries(input.body)) { + if (typeof value === 'string') params[key] = value; + } + const sign = params.sign || ''; + const signType = params.sign_type || 'RSA2'; + delete params.sign; + delete params.sign_type; + if (signType !== 'RSA2') throw new HttpError(400, 'Alipay notification must use RSA2', 'PAYMENT_SIGNATURE_INVALID'); + if (!rsaVerifySha256(alipayPublicKey, canonicalForm(params), sign)) { + throw new HttpError(400, 'Alipay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID'); + } + if (!['TRADE_SUCCESS', 'TRADE_FINISHED'].includes(params.trade_status || '')) { + throw new HttpError(409, `Alipay trade status is ${params.trade_status || 'unknown'}`, 'PAYMENT_NOT_PAID'); + } + + const totalAmount = Number(params.total_amount || params.receipt_amount); + const amountCents = Math.round(totalAmount * 100); + if (!Number.isInteger(amountCents) || amountCents < 0) { + throw new HttpError(400, 'Alipay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID'); + } + + return { + provider: this.name, + eventId: params.notify_id || `${params.trade_no}:${params.trade_status}`, + eventType: params.trade_status || 'TRADE_SUCCESS', + orderNo: params.out_trade_no, + providerTradeNo: params.trade_no, + amountCents, + paidAt: params.gmt_payment || params.notify_time || undefined, + raw: { + notification: input.body, + }, + }; + } +} + +class ManualPaymentProvider implements PaymentProvider { + readonly name = 'manual' as const; + + async createPayment(input: PaymentOrderInput): Promise { + return { + provider: this.name, + method: 'manual', + paymentParams: { + orderNo: input.orderNo, + amountCents: input.amountCents, + }, + raw: {}, + }; + } + + async parseNotification(): Promise { + throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED'); + } +} + +export function normalizePaymentProvider(value: string): PaymentProviderName { + const normalized = value.toLowerCase().replace(/[-\s]/g, '_'); + if (['wechat', 'wechatpay', 'wxpay', 'wx_pay', 'wechat_pay'].includes(normalized)) return 'wechat_pay'; + if (['alipay', 'ali_pay'].includes(normalized)) return 'alipay'; + return 'manual'; +} + +export function paymentProviderAliases(value: string) { + const provider = normalizePaymentProvider(value); + if (provider === 'wechat_pay') return ['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay']; + if (provider === 'alipay') return ['alipay', 'ali_pay']; + return ['manual']; +} + +export function createPaymentProvider( + name: string, + config?: TenantPaymentProviderConfig | null, +): PaymentProvider { + const provider = normalizePaymentProvider(name); + if (provider === 'wechat_pay') { + if (!config) throw new HttpError(503, 'WeChat Pay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED'); + return new WechatPayProvider(config); + } + if (provider === 'alipay') { + if (!config) throw new HttpError(503, 'Alipay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED'); + return new AlipayProvider(config); + } + return new ManualPaymentProvider(); +} + +export function allowsLocalMockPayment(config: TenantPaymentProviderConfig | null) { + return config ? optionalPublicBoolean(config, 'allowLocalMock', false) : false; +} diff --git a/apps/api/src/features/commerce/routes.ts b/apps/api/src/features/commerce/routes.ts index 79d08a01..2e5b6bbe 100644 --- a/apps/api/src/features/commerce/routes.ts +++ b/apps/api/src/features/commerce/routes.ts @@ -1,4 +1,4 @@ -import { HttpError, type RequestContext } from '../../core/http.js'; +import { getHeader, HttpError, type RequestContext } from '../../core/http.js'; import { intParam, optionalInteger, @@ -10,6 +10,13 @@ import { } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; import { createOrderNo, grantSvipEntitlement } from './service.js'; +import { + createPaymentProvider, + normalizePaymentProvider, + paymentProviderAliases, + type PaymentProviderName, +} from './providers.js'; +import { loadTenantPaymentProviderConfig } from '../../core/tenant-provider-config.js'; interface PlanRow { id: string; @@ -29,6 +36,25 @@ interface OrderRow { region_id: string | null; } +interface PaymentRow { + id: string; + status: string; + amount_cents: number; +} + +interface PaymentOrderRow { + id: string; + order_no: string; + status: string; + amount_cents: number; + product_name: string | null; + pay_provider: string | null; + pay_method: string | null; + days: number | null; + user_id: string | null; + region_id: string | null; +} + interface EntitlementRow { id: string; entitlementType: string; @@ -322,6 +348,366 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) { return { item }; } +function requestHeaders(ctx: RequestContext) { + const headers: Record = {}; + for (const [key, value] of Object.entries(ctx.req.headers)) { + headers[key.toLowerCase()] = Array.isArray(value) ? value[0] || '' : value || ''; + } + return headers; +} + +async function readWebhookBody(ctx: RequestContext) { + const contentLength = Number(getHeader(ctx.req, 'content-length') || 0); + const maxBytes = 1024 * 1024; + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new HttpError(413, 'Webhook body is too large', 'WEBHOOK_BODY_TOO_LARGE'); + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of ctx.req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > maxBytes) { + ctx.req.destroy(); + throw new HttpError(413, 'Webhook body is too large', 'WEBHOOK_BODY_TOO_LARGE'); + } + chunks.push(buffer); + } + + const rawBody = Buffer.concat(chunks).toString('utf8'); + const contentType = getHeader(ctx.req, 'content-type').toLowerCase(); + if (contentType.includes('application/x-www-form-urlencoded')) { + return { + rawBody, + body: Object.fromEntries(new URLSearchParams(rawBody).entries()), + }; + } + try { + const parsed = rawBody.trim() ? JSON.parse(rawBody) : {}; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new HttpError(400, 'Webhook JSON body must be an object', 'INVALID_WEBHOOK_BODY'); + } + return { rawBody, body: parsed as Record }; + } catch (error) { + if (error instanceof HttpError) throw error; + throw new HttpError(400, 'Invalid webhook body', 'INVALID_WEBHOOK_BODY'); + } +} + +function paidAtValue(value?: string) { + return value || new Date().toISOString(); +} + +async function loadPaymentProvider(tenantId: string, providerName: string) { + const provider = normalizePaymentProvider(providerName); + const providerConfig = + provider === 'manual' ? null : await loadTenantPaymentProviderConfig(tenantId, paymentProviderAliases(provider)); + return createPaymentProvider(provider, providerConfig); +} + +export async function createPaymentRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const body = await readJsonBody(ctx); + const userId = await userIdFrom(ctx, body); + const orderNo = requiredString(body, 'orderNo'); + + const order = await queryOne( + ` + select id, order_no, status, amount_cents, product_name, pay_provider, pay_method, + days, user_id, region_id + from public.orders + where tenant_id = $1 and order_no = $2 and user_id = $3 + limit 1 + `, + [tenantId, orderNo, userId], + ); + if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND'); + if (order.status === 'paid') throw new HttpError(409, 'Order is already paid', 'ORDER_ALREADY_PAID'); + if (order.status !== 'pending') throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE'); + + const providerName = optionalString(body, 'provider') || order.pay_provider || 'manual'; + const provider = await loadPaymentProvider(tenantId, providerName); + const payment = await provider.createPayment({ + tenantId, + orderId: order.id, + orderNo: order.order_no, + description: order.product_name || `题库会员 ${order.order_no}`, + amountCents: order.amount_cents, + userId, + openId: optionalString(body, 'openId') || undefined, + clientIp: getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || ctx.req.socket.remoteAddress || undefined, + returnUrl: optionalString(body, 'returnUrl') || undefined, + quitUrl: optionalString(body, 'quitUrl') || undefined, + }); + + const item = await transaction(async client => { + const updatedPayment = await client.query( + ` + update public.payments + set provider_trade_no = coalesce($4, provider_trade_no), + method = $5, + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $6::jsonb, + updated_at = now() + where tenant_id = $1 + and order_id = $2 + and provider = $3 + and status = 'pending' + returning id + `, + [ + tenantId, + order.id, + payment.provider, + payment.providerTradeNo || null, + payment.method, + JSON.stringify({ createPayment: payment.raw }), + ], + ); + + if (!updatedPayment.rows[0]) { + await client.query( + ` + insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, provider_trade_no, raw_payload) + values ($1, $2, $3, $4, 'pending', $5, $6, $7::jsonb) + `, + [ + tenantId, + order.id, + payment.provider, + payment.method, + order.amount_cents, + payment.providerTradeNo || null, + JSON.stringify({ createPayment: payment.raw }), + ], + ); + } + + await client.query( + ` + update public.orders + set pay_provider = $3, pay_method = $4, updated_at = now() + where tenant_id = $1 and id = $2 + `, + [tenantId, order.id, payment.provider, payment.method], + ); + + return { + orderNo: order.order_no, + provider: payment.provider, + method: payment.method, + paymentParams: payment.paymentParams, + }; + }); + + return { item }; +} + +export async function paymentNotifyRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const providerName = ctx.url.pathname.split('/').at(-1) || optionalString(Object.fromEntries(ctx.url.searchParams), 'provider'); + const provider = await loadPaymentProvider(tenantId, providerName); + const { rawBody, body } = await readWebhookBody(ctx); + const notification = await provider.parseNotification({ + headers: requestHeaders(ctx), + body, + rawBody, + }); + const eventKey = `${tenantId}:${notification.eventId}`; + + const item = await transaction(async client => { + const eventResult = await client.query<{ + id: string; + processedAt: string | null; + error: string | null; + }>( + ` + insert into public.payment_events ( + tenant_id, provider, event_type, event_id, signature_valid, payload + ) + values ($1, $2, $3, $4, true, $5::jsonb) + on conflict (provider, event_id) + do update set payload = case + when public.payment_events.processed_at is null or public.payment_events.error is not null + then excluded.payload + else public.payment_events.payload + end, + signature_valid = true + returning id, processed_at as "processedAt", error + `, + [ + tenantId, + notification.provider, + notification.eventType, + eventKey, + JSON.stringify(notification.raw), + ], + ); + + const event = eventResult.rows[0]; + if (event.processedAt && !event.error) { + return { + status: 'processed', + idempotent: true, + eventId: notification.eventId, + orderNo: notification.orderNo, + }; + } + + try { + const orderResult = await client.query( + ` + select id, order_no, status, amount_cents, days, user_id, region_id + from public.orders + where tenant_id = $1 and order_no = $2 + limit 1 + for update + `, + [tenantId, notification.orderNo], + ); + const order = orderResult.rows[0]; + if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND'); + if (!order.user_id) throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING'); + if (order.amount_cents !== notification.amountCents) { + throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH'); + } + + let payment = ( + await client.query( + ` + select id, status, amount_cents + from public.payments + where tenant_id = $1 and order_id = $2 and provider = $3 + order by created_at desc + limit 1 + for update + `, + [tenantId, order.id, notification.provider], + ) + ).rows[0]; + + if (!payment) { + const inserted = await client.query( + ` + insert into public.payments ( + tenant_id, order_id, provider, method, status, amount_cents, provider_trade_no, paid_at, raw_payload + ) + values ($1, $2, $3, 'webhook', 'pending', $4, $5, $6::timestamptz, $7::jsonb) + returning id, status, amount_cents + `, + [ + tenantId, + order.id, + notification.provider, + notification.amountCents, + notification.providerTradeNo, + paidAtValue(notification.paidAt), + JSON.stringify({ notification: notification.raw }), + ], + ); + payment = inserted.rows[0]; + } + + if (payment.amount_cents !== notification.amountCents) { + throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH'); + } + + if (order.status === 'paid') { + await client.query( + ` + update public.payment_events + set payment_id = $2, processed_at = coalesce(processed_at, now()), error = null + where id = $1 + `, + [event.id, payment.id], + ); + return { + status: 'paid', + idempotent: true, + eventId: notification.eventId, + orderNo: order.order_no, + }; + } + + await client.query( + ` + update public.orders + set status = 'paid', trade_no = $3, paid_at = $4::timestamptz, + pay_provider = $5, updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + tenantId, + order.id, + notification.providerTradeNo, + paidAtValue(notification.paidAt), + notification.provider, + ], + ); + + await client.query( + ` + update public.payments + set status = 'paid', provider_trade_no = $3, paid_at = $4::timestamptz, + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + tenantId, + payment.id, + notification.providerTradeNo, + paidAtValue(notification.paidAt), + JSON.stringify({ notification: notification.raw }), + ], + ); + + const entitlement = await grantSvipEntitlement(client, { + tenantId, + userId: order.user_id, + days: order.days || 0, + regionId: order.region_id, + sourceType: 'order', + sourceId: order.id, + metadata: { + orderNo: order.order_no, + paymentProvider: notification.provider, + providerTradeNo: notification.providerTradeNo, + }, + }); + + await client.query( + ` + update public.payment_events + set payment_id = $2, processed_at = now(), error = null + where id = $1 + `, + [event.id, payment.id], + ); + + return { + status: 'paid', + idempotent: false, + eventId: notification.eventId, + orderNo: order.order_no, + entitlement, + }; + } catch (error) { + await client.query( + ` + update public.payment_events + set error = $2 + where id = $1 + `, + [event.id, error instanceof Error ? error.message : 'Unknown payment notification error'], + ); + throw error; + } + }); + + return { item }; +} + export async function redeemActivationCodeRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); const tenantId = await tenantIdFrom(ctx); diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md index e01bc054..0a71580f 100644 --- a/docs/refactor/auth-payment-provider-plan.md +++ b/docs/refactor/auth-payment-provider-plan.md @@ -27,6 +27,9 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先 - `app_private.auth_sessions`:迁移期 session token hash。 - 阿里云短信 `SendSms` provider:使用租户级 AccessKey、签名、模板发送。 - 腾讯云短信 `SendSms` provider:使用租户级 SecretId/SecretKey、SdkAppId、签名、模板发送。 +- `POST /api/commerce/payments/create`:按订单创建微信支付 JSAPI 或支付宝 WAP 支付参数。 +- `POST /api/commerce/payments/notify/wechat_pay`:微信支付 API v3 通知验签、AES-GCM 解密、幂等落库和权益开通。 +- `POST /api/commerce/payments/notify/alipay`:支付宝 RSA2 通知验签、幂等落库和权益开通。 ## 短信 Provider @@ -129,10 +132,73 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先 支付不走 Supabase 内置能力。推荐继续扩展 `commerce`: - `POST /api/commerce/orders` 只负责创建订单,金额以后端套餐为准。 -- `POST /api/commerce/payments/:provider/create` 后续按 provider 创建支付参数或收银台地址。 -- `POST /api/commerce/payments/:provider/notify` 统一落 `payment_events`,先验签、再幂等、再更新订单和权益。 +- `POST /api/commerce/payments/create` 按 provider 创建支付参数或收银台地址。 +- `POST /api/commerce/payments/notify/` 统一落 `payment_events`,先验签、再幂等、再更新订单和权益。 - 支付成功继续复用 `grantSvipEntitlement`,避免微信/支付宝/XPay 各写一套开通逻辑。 +当前支持: + +- `wechat_pay`:微信支付 API v3 JSAPI 下单;通知验签后用 API v3 key 解密 `resource`。 +- `alipay`:支付宝 WAP/H5 支付参数生成;通知按 RSA2 验签。 +- `manual`:仅本地/运营手工确认,不作为生产自动支付。 + +### 微信支付配置示例 + +```json +{ + "provider": "wechat_pay", + "mode": "tenant_collect", + "status": "active", + "configPublic": { + "appId": "wx...", + "merchantId": "1900000001", + "merchantSerialNo": "商户证书序列号", + "notifyUrl": "https://api.example.com/api/commerce/payments/notify/wechat_pay?tenantId=", + "wechatpayPublicKey": "微信支付平台证书公钥或平台公钥" + }, + "secret": { + "secretScope": "payment", + "secretKey": "wechat_pay", + "secretJson": { + "privateKey": "商户 API 证书私钥 PEM", + "apiV3Key": "32位 API v3 key" + } + } +} +``` + +### 支付宝配置示例 + +```json +{ + "provider": "alipay", + "mode": "tenant_collect", + "status": "active", + "configPublic": { + "appId": "2021000000000000", + "notifyUrl": "https://api.example.com/api/commerce/payments/notify/alipay?tenantId=", + "returnUrl": "https://h5.example.com/pay/success" + }, + "secret": { + "secretScope": "payment", + "secretKey": "alipay", + "secretJson": { + "privateKey": "应用私钥 PEM", + "alipayPublicKey": "支付宝公钥 PEM" + } + } +} +``` + +支付回调处理规则: + +- `payment_events(provider,event_id)` 幂等。 +- 通知验签/解密失败直接拒绝,不更新订单。 +- 通知金额必须等于后端订单金额。 +- 订单已支付时重复通知只返回幂等成功,不重复开通权益。 +- 支付成功事务内更新 `orders`、`payments`、`payment_events`、`entitlements`。 +- 支付密钥只允许放在 `app_private.tenant_secrets` 或生产 KMS/Vault。 + B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务: - `platform_saas_plans`:平台售卖给合作商的 SaaS 套餐。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index a142abf1..bec178d6 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -92,11 +92,13 @@ | SVIP 套餐 | 可联调 | `/api/catalog/svip-plans` | | 创建订单/订单列表 | 可联调 | `/api/commerce/orders` | | 手工支付确认 | 迁移期 | 可用于测试,不是生产支付 | +| 微信支付 JSAPI | 可联调 | `/api/commerce/payments/create`、`notify/wechat_pay`,已覆盖 API v3 签名、通知解密、幂等和权益开通 | +| 支付宝 WAP/H5 | 可联调 | `/api/commerce/payments/create`、`notify/alipay`,已覆盖 RSA2 通知验签、幂等和权益开通 | | 权益查询/校验 | 可联调 | `/api/commerce/entitlements` | | 激活码兑换 | 可联调 | 事务开通权益 | | 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` | | 优惠券前台兑换/下单抵扣 | 待补齐 | 后端还需接入下单计算 | -| 微信/支付宝/小程序支付 | 待补齐 | 需 provider、验签、幂等、退款、补偿 | +| 退款/补偿/对账 | 待补齐 | 需退款接口、支付补偿任务、对账、异常订单处理 | ## 租户后台与平台后台 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 81c90bfe..9f58dd37 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -49,8 +49,8 @@ ### P1 商用功能完善 1. 支付 - - 微信支付、支付宝、XPay 或实际使用的支付网关 adapter。 - - webhook 验签、幂等、退款、支付补偿任务。 + - 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。 + - 继续补退款、支付补偿任务、对账、异常订单处理。 - 租户自有商户收款和平台代收/服务商模式。 2. 国内登录和短信 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 77df008e..86142345 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -146,7 +146,7 @@ tenant::theme | 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` | | 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years` | | 资料下载 | `/api/catalog/assets`、`/api/catalog/assets/download` | -| 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/orders` | +| 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` | | 订单/权益 | `/api/commerce/orders`、`/api/commerce/entitlements` | | 激活码兑换 | `POST /api/commerce/activation-codes/redeem` | | 个人中心 | `GET/PATCH /api/profile/me` | @@ -245,6 +245,74 @@ identity.unionId - 如果登录前已经解析到推广码,登录成功后再调用 `/api/referral/bind` 完成首绑保护。 - 手机号授权后续应走独立的“绑定手机号”接口,不要把微信手机号解密逻辑写在页面里。 +## 支付对接 + +支付流程必须以后端订单金额和后端回调为准,前端只负责拉起支付。 + +### 创建订单 + +```text +POST /api/commerce/orders +body: { + "planId": "", + "quantity": 1, + "payProvider": "wechat_pay | alipay", + "payMethod": "jsapi | wap", + "regionId": "" +} +``` + +返回 `orderNo` 后,再创建支付参数: + +```text +POST /api/commerce/payments/create +body: { + "orderNo": "", + "provider": "wechat_pay", + "openId": "<微信小程序登录后的 openId>" +} +``` + +微信小程序返回的 `paymentParams` 可直接映射到 `Taro.requestPayment`: + +```text +appId +timeStamp +nonceStr +package +signType +paySign +``` + +支付宝 H5/WAP 返回: + +```text +paymentParams.url +``` + +H5 可以跳转到该 URL。小程序端如果后续要接支付宝小程序,需要新增独立 provider/method,不要复用 H5 WAP URL。 + +支付完成后前端不要自行开通会员。前端应轮询或重新请求: + +```text +GET /api/commerce/orders +GET /api/commerce/entitlements +``` + +后端支付回调地址由租户支付账户配置: + +```text +/api/commerce/payments/notify/wechat_pay?tenantId= +/api/commerce/payments/notify/alipay?tenantId= +``` + +前端禁止: + +- 传入自定义金额。 +- 伪造支付成功状态。 +- 保存商户号私钥、API v3 key、支付宝应用私钥。 +- 在页面里实现 webhook 验签或权益开通。 + ## 第一阶段页面建议 1. `pages/bootstrap/index` diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 5e6b6b4e..73ade05b 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import http from 'node:http'; import net from 'node:net'; @@ -32,12 +33,31 @@ const ids = { scorelineSchool: '00000000-0000-0000-0000-000000000831', }; +const paymentFixture = (() => { + const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const wechatPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const alipayApp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const alipayPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + return { + wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + wechatMerchantPublicKey: wechatMerchant.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + wechatPlatformPrivateKey: wechatPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + wechatPlatformPublicKey: wechatPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + alipayAppPrivateKey: alipayApp.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + alipayAppPublicKey: alipayApp.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + alipayPlatformPrivateKey: alipayPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + alipayPlatformPublicKey: alipayPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + wechatApiV3Key: '12345678901234567890123456789012', + }; +})(); + let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787'; let serverProcess = null; let serverLogs = ''; let legacyDisabledServer = null; let legacyDisabledServerLogs = ''; let fakeWechatServer = null; +let fakeWechatPayServer = null; function buildUrl(path, query = {}) { return buildUrlAt(apiBase, path, query); @@ -252,6 +272,76 @@ async function startFakeWechatServer() { }; } +async function startFakeWechatPayServer() { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const requests = []; + fakeWechatPayServer = http.createServer((req, res) => { + const url = new URL(req.url || '/', baseUrl); + let raw = ''; + req.on('data', chunk => { + raw += chunk.toString(); + }); + req.on('end', () => { + const body = raw ? JSON.parse(raw) : {}; + requests.push({ + method: req.method, + pathname: url.pathname, + headers: req.headers, + body, + }); + + if (url.pathname !== '/v3/pay/transactions/jsapi') { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 'NOT_FOUND' })); + return; + } + + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ prepay_id: `prepay-${body.out_trade_no || 'unknown'}` })); + }); + }); + + await new Promise((resolve, reject) => { + fakeWechatPayServer.once('error', reject); + fakeWechatPayServer.listen(port, '127.0.0.1', resolve); + }); + + return { + endpoint: `${baseUrl}/v3/pay/transactions/jsapi`, + requests, + }; +} + +function encryptWechatResource(plain) { + const nonce = crypto.randomBytes(12).toString('base64url'); + const aad = 'transaction'; + const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(paymentFixture.wechatApiV3Key, 'utf8'), Buffer.from(nonce, 'utf8')); + cipher.setAAD(Buffer.from(aad, 'utf8')); + const encrypted = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return { + algorithm: 'AEAD_AES_256_GCM', + nonce, + associated_data: aad, + ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'), + }; +} + +function signWechatNotification(rawBody, timestamp, nonce) { + const message = `${timestamp}\n${nonce}\n${rawBody}\n`; + return crypto.createSign('RSA-SHA256').update(message).sign(paymentFixture.wechatPlatformPrivateKey, 'base64'); +} + +function signAlipayParams(params) { + const canonical = Object.keys(params) + .filter(key => !['sign', 'sign_type'].includes(key) && params[key] !== undefined && params[key] !== null && params[key] !== '') + .sort() + .map(key => `${key}=${params[key]}`) + .join('&'); + return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64'); +} + async function waitForProcessExit(child, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -422,10 +512,14 @@ function stopServer() { fakeWechatServer.close(); fakeWechatServer = null; } + if (fakeWechatPayServer) { + fakeWechatPayServer.close(); + fakeWechatPayServer = null; + } } async function testCatalogAndLearning() { - const questions = await request('/api/catalog/questions', { query: { limit: 20 } }); + const questions = await request('/api/catalog/questions', { query: { limit: 500 } }); const question = questions.items?.find(item => item.id === ids.question); assert.ok(question, 'main tenant should return smoke question'); assert.equal(question.hasVideoExplanation, true, 'smoke question should expose video marker'); @@ -597,6 +691,182 @@ async function testCommerce() { assert.ok(Array.isArray(entitlements.items), 'entitlements should return a list'); assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary'); assert.equal(entitlements.summary.isSvip, true, 'redeemed activation code should make smoke user SVIP'); + + const fakeWechatPay = await startFakeWechatPayServer(); + const wechatAccount = await request('/api/tenant-admin/payment-accounts', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + provider: 'wechat_pay', + mode: 'tenant_collect', + displayName: '集成测试微信支付', + status: 'active', + configPublic: { + appId: 'wx-pay-smoke-appid', + merchantId: '1900000001', + merchantSerialNo: 'serial-smoke', + notifyUrl: 'https://pay.example.test/wechat/notify', + endpoint: fakeWechatPay.endpoint, + wechatpayPublicKey: paymentFixture.wechatPlatformPublicKey, + }, + secret: { + secretJson: { + privateKey: paymentFixture.wechatMerchantPrivateKey, + apiV3Key: paymentFixture.wechatApiV3Key, + }, + }, + }, + }); + assert.equal(wechatAccount.item?.provider, 'wechat_pay', 'tenant admin should configure active WeChat Pay account'); + assert.ok(!JSON.stringify(wechatAccount).includes(paymentFixture.wechatApiV3Key), 'payment account response must not leak apiV3Key'); + + const wechatOrder = await request('/api/commerce/orders', { + method: 'POST', + body: { + planId: '00000000-0000-0000-0000-000000000201', + payProvider: 'wechat_pay', + payMethod: 'jsapi', + regionId: ids.region, + }, + }); + assert.ok(wechatOrder.item?.orderNo, 'student should create a WeChat Pay order'); + + const wechatPayment = await request('/api/commerce/payments/create', { + method: 'POST', + body: { + orderNo: wechatOrder.item.orderNo, + provider: 'wechat_pay', + openId: 'openid-pay-smoke', + }, + }); + assert.equal(wechatPayment.item?.provider, 'wechat_pay', 'payment create should use WeChat Pay provider'); + assert.ok(wechatPayment.item?.paymentParams?.paySign, 'WeChat Pay create should return signed JSAPI params'); + assert.equal(fakeWechatPay.requests.at(-1)?.body?.out_trade_no, wechatOrder.item.orderNo, 'WeChat Pay provider should call transaction endpoint with order number'); + + const wechatNotificationBody = { + id: `evt-${wechatOrder.item.orderNo}`, + create_time: '2026-06-28T00:00:00+08:00', + event_type: 'TRANSACTION.SUCCESS', + resource_type: 'encrypt-resource', + resource: encryptWechatResource({ + appid: 'wx-pay-smoke-appid', + mchid: '1900000001', + out_trade_no: wechatOrder.item.orderNo, + transaction_id: `wx-trade-${wechatOrder.item.orderNo}`, + trade_state: 'SUCCESS', + success_time: '2026-06-28T00:00:00+08:00', + amount: { total: wechatOrder.item.amountCents, currency: 'CNY' }, + }), + }; + const wechatRaw = JSON.stringify(wechatNotificationBody); + const wechatTimestamp = String(Math.floor(Date.now() / 1000)); + const wechatNonce = 'nonce-pay-smoke'; + const wechatNotify = await request('/api/commerce/payments/notify/wechat_pay', { + userId: false, + method: 'POST', + query: { tenantId: MAIN_TENANT_ID }, + headers: { + 'wechatpay-timestamp': wechatTimestamp, + 'wechatpay-nonce': wechatNonce, + 'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce), + 'wechatpay-serial': 'platform-serial-smoke', + }, + body: wechatNotificationBody, + }); + assert.equal(wechatNotify.item?.status, 'paid', 'WeChat Pay notify should mark order paid'); + assert.ok(wechatNotify.item?.entitlement?.id, 'WeChat Pay notify should grant entitlement'); + + const wechatNotifyAgain = await request('/api/commerce/payments/notify/wechat_pay', { + userId: false, + method: 'POST', + query: { tenantId: MAIN_TENANT_ID }, + headers: { + 'wechatpay-timestamp': wechatTimestamp, + 'wechatpay-nonce': wechatNonce, + 'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce), + 'wechatpay-serial': 'platform-serial-smoke', + }, + body: wechatNotificationBody, + }); + assert.equal(wechatNotifyAgain.item?.idempotent, true, 'duplicate WeChat Pay notify should be idempotent'); + + const alipayAccount = await request('/api/tenant-admin/payment-accounts', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + provider: 'alipay', + mode: 'tenant_collect', + displayName: '集成测试支付宝', + status: 'active', + configPublic: { + appId: 'alipay-smoke-appid', + notifyUrl: 'https://pay.example.test/alipay/notify', + returnUrl: 'https://app.example.test/pay/success', + }, + secret: { + secretJson: { + privateKey: paymentFixture.alipayAppPrivateKey, + alipayPublicKey: paymentFixture.alipayPlatformPublicKey, + }, + }, + }, + }); + assert.equal(alipayAccount.item?.provider, 'alipay', 'tenant admin should configure active Alipay account'); + assert.ok(!JSON.stringify(alipayAccount).includes('PRIVATE KEY'), 'Alipay account response must not leak private key'); + + const alipayOrder = await request('/api/commerce/orders', { + method: 'POST', + body: { + planId: '00000000-0000-0000-0000-000000000201', + payProvider: 'alipay', + payMethod: 'wap', + regionId: ids.region, + }, + }); + const alipayPayment = await request('/api/commerce/payments/create', { + method: 'POST', + body: { + orderNo: alipayOrder.item.orderNo, + provider: 'alipay', + }, + }); + assert.equal(alipayPayment.item?.provider, 'alipay', 'payment create should use Alipay provider'); + assert.ok(alipayPayment.item?.paymentParams?.url?.includes('alipay.trade.wap.pay'), 'Alipay create should return WAP URL'); + + const alipayNotifyBody = { + notify_id: `alipay-notify-${alipayOrder.item.orderNo}`, + notify_time: '2026-06-28 00:00:00', + app_id: 'alipay-smoke-appid', + trade_no: `ali-trade-${alipayOrder.item.orderNo}`, + out_trade_no: alipayOrder.item.orderNo, + trade_status: 'TRADE_SUCCESS', + total_amount: (alipayOrder.item.amountCents / 100).toFixed(2), + receipt_amount: (alipayOrder.item.amountCents / 100).toFixed(2), + charset: 'utf-8', + version: '1.0', + }; + alipayNotifyBody.sign_type = 'RSA2'; + alipayNotifyBody.sign = signAlipayParams(alipayNotifyBody); + const alipayNotify = await request('/api/commerce/payments/notify/alipay', { + userId: false, + method: 'POST', + query: { tenantId: MAIN_TENANT_ID }, + body: alipayNotifyBody, + }); + assert.equal(alipayNotify.item?.status, 'paid', 'Alipay notify should mark order paid'); + assert.ok(alipayNotify.item?.entitlement?.id, 'Alipay notify should grant entitlement'); + + const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', { + userId: false, + method: 'POST', + query: { tenantId: MAIN_TENANT_ID }, + body: { + ...alipayNotifyBody, + total_amount: '0.01', + }, + expectStatus: 400, + }); + assert.equal(tamperedAlipayNotify.code, 'PAYMENT_SIGNATURE_INVALID', 'tampered Alipay notify must fail signature verification'); } async function testTenantIsolation() {