forked from wangziqi/gongxue-base
feat: add payment provider webhooks
This commit is contained in:
@@ -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);
|
||||
@@ -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';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
providerEndpoint,
|
||||
requirePublicString,
|
||||
requireSecretString,
|
||||
} from './provider-config.js';
|
||||
} from '../../core/tenant-provider-config.js';
|
||||
import {
|
||||
assertChinaPhone,
|
||||
clientIpFrom,
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
420
apps/api/src/features/commerce/providers.ts
Normal file
420
apps/api/src/features/commerce/providers.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 套餐。
|
||||
|
||||
@@ -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、验签、幂等、退款、补偿 |
|
||||
| 退款/补偿/对账 | 待补齐 | 需退款接口、支付补偿任务、对账、异常订单处理 |
|
||||
|
||||
## 租户后台与平台后台
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
### P1 商用功能完善
|
||||
|
||||
1. 支付
|
||||
- 微信支付、支付宝、XPay 或实际使用的支付网关 adapter。
|
||||
- webhook 验签、幂等、退款、支付补偿任务。
|
||||
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
||||
- 继续补退款、支付补偿任务、对账、异常订单处理。
|
||||
- 租户自有商户收款和平台代收/服务商模式。
|
||||
|
||||
2. 国内登录和短信
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user