feat: add payment provider webhooks

This commit is contained in:
Codex
2026-06-28 22:42:27 +08:00
parent dee82e670f
commit db65295ddc
11 changed files with 1286 additions and 39 deletions

View File

@@ -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<string, unknown>;
}
export interface TenantAuthProviderConfig {
export interface TenantProviderConfig {
tenantId: string;
provider: string;
status: 'active' | 'testing' | 'disabled';
status: string;
configPublic: Record<string, unknown>;
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<string, unknown> | 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<TenantAuthProviderConfig | null> {
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<TenantProviderConfig | null> {
const uniqueProviders = [...new Set(input.providers.map(item => item.trim()).filter(Boolean))];
if (uniqueProviders.length === 0) return null;
const rows = await query<TenantAuthProviderRow>(
const rows = await query<ProviderRow>(
`
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);

View File

@@ -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';

View File

@@ -11,7 +11,7 @@ import {
providerEndpoint,
requirePublicString,
requireSecretString,
} from './provider-config.js';
} from '../../core/tenant-provider-config.js';
import {
assertChinaPhone,
clientIpFrom,

View File

@@ -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],
];

View File

@@ -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<string, unknown>;
raw: Record<string, unknown>;
}
export interface PaymentNotificationResult {
provider: PaymentProviderName;
eventId: string;
eventType: string;
orderNo: string;
providerTradeNo: string;
amountCents: number;
paidAt?: string;
raw: Record<string, unknown>;
}
export interface PaymentProvider {
name: PaymentProviderName;
createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult>;
parseNotification(input: {
headers: Record<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult>;
}
function base64urlDecode(value: string) {
return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function requiredBodyString(body: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>) : {};
}
function canonicalForm(params: Record<string, string>) {
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<string, string>) {
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<PaymentCreateResult> {
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<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult> {
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<string, unknown>;
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<PaymentCreateResult> {
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<string, unknown> = {
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<string, string> = {
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<string, string>;
body: Record<string, unknown>;
rawBody: string;
}): Promise<PaymentNotificationResult> {
const alipayPublicKey = normalizePem(
requireSecretString(this.config, ['alipayPublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'),
'PUBLIC KEY',
);
const params: Record<string, string> = {};
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<PaymentCreateResult> {
return {
provider: this.name,
method: 'manual',
paymentParams: {
orderNo: input.orderNo,
amountCents: input.amountCents,
},
raw: {},
};
}
async parseNotification(): Promise<PaymentNotificationResult> {
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;
}

View File

@@ -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<string, string> = {};
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<string, unknown> };
} 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<PaymentOrderRow>(
`
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<OrderRow>(
`
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<PaymentRow>(
`
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<PaymentRow>(
`
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);

View File

@@ -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/<provider>` 统一落 `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=<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=<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 套餐。

View File

@@ -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、验签、幂等、退款、补偿 |
| 退款/补偿/对账 | 待补齐 | 需退款接口、支付补偿任务、对账、异常订单处理 |
## 租户后台与平台后台

View File

@@ -49,8 +49,8 @@
### P1 商用功能完善
1. 支付
- 微信支付、支付宝、XPay 或实际使用的支付网关 adapter
- webhook 验签、幂等、退款、支付补偿任务
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益
- 继续补退款、支付补偿任务、对账、异常订单处理
- 租户自有商户收款和平台代收/服务商模式。
2. 国内登录和短信

View File

@@ -146,7 +146,7 @@ tenant:<tenantId>: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": "<svipPlanId>",
"quantity": 1,
"payProvider": "wechat_pay | alipay",
"payMethod": "jsapi | wap",
"regionId": "<regionId>"
}
```
返回 `orderNo` 后,再创建支付参数:
```text
POST /api/commerce/payments/create
body: {
"orderNo": "<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=<tenantId>
/api/commerce/payments/notify/alipay?tenantId=<tenantId>
```
前端禁止:
- 传入自定义金额。
- 伪造支付成功状态。
- 保存商户号私钥、API v3 key、支付宝应用私钥。
- 在页面里实现 webhook 验签或权益开通。
## 第一阶段页面建议
1. `pages/bootstrap/index`

View File

@@ -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() {