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);