feat(M08-A): 接入充值微信支付调起
This commit is contained in:
@@ -43,7 +43,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062220_m06c_iot_messages.up.sql',
|
'database/migrations/2026062220_m06c_iot_messages.up.sql',
|
||||||
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
|
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
|
||||||
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
|
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
|
||||||
'database/migrations/2026062423_m07c_benefits.up.sql'
|
'database/migrations/2026062423_m07c_benefits.up.sql',
|
||||||
|
'database/migrations/2026062524_m08a_recharge_wechat.up.sql'
|
||||||
],
|
],
|
||||||
verify: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -68,9 +69,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062220_m06c_iot_messages.verify.sql',
|
'database/migrations/2026062220_m06c_iot_messages.verify.sql',
|
||||||
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
|
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
|
||||||
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
|
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
|
||||||
'database/migrations/2026062423_m07c_benefits.verify.sql'
|
'database/migrations/2026062423_m07c_benefits.verify.sql',
|
||||||
|
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql'
|
||||||
],
|
],
|
||||||
down: [
|
down: [
|
||||||
|
'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
|
||||||
'database/migrations/2026062423_m07c_benefits.down.sql',
|
'database/migrations/2026062423_m07c_benefits.down.sql',
|
||||||
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
||||||
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
|
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyReply, preParsingHookHandler } from 'fastify';
|
||||||
|
import { Transform } from 'node:stream';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
import { RechargeError, type RechargeService } from '../wallets/recharge-service.js';
|
import { RechargeError, type RechargeService } from '../wallets/recharge-service.js';
|
||||||
|
import { WechatPayError } from '../payments/wechat-pay-client.js';
|
||||||
|
|
||||||
const listSchema = z.object({
|
const listSchema = z.object({
|
||||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||||
@@ -13,9 +15,15 @@ const createSchema = z.object({
|
|||||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
|
||||||
clientRequestId: z.string().min(8).max(128)
|
clientRequestId: z.string().min(8).max(128)
|
||||||
}).strict();
|
}).strict();
|
||||||
|
const orderParams = z.object({
|
||||||
|
rechargeOrderId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||||
|
});
|
||||||
|
|
||||||
export interface RechargeRouteOptions {
|
export interface RechargeRouteOptions {
|
||||||
service: Pick<RechargeService, 'listAvailablePlans' | 'createRechargeOrder'>;
|
service: Pick<
|
||||||
|
RechargeService,
|
||||||
|
'listAvailablePlans' | 'createRechargeOrder' | 'createWechatPrepay' | 'processWechatNotification'
|
||||||
|
>;
|
||||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
@@ -58,6 +66,36 @@ export async function registerRechargeRoutes(
|
|||||||
traceId: request.traceId
|
traceId: request.traceId
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/recharge/orders/:rechargeOrderId/wechat-prepay', async (request, reply) => {
|
||||||
|
const auth = await authenticate(request.headers.authorization, options);
|
||||||
|
const params = orderParams.safeParse(request.params);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.createWechatPrepay({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
platformAppId: auth.platformAppId,
|
||||||
|
userId: auth.userId,
|
||||||
|
rechargeOrderId: params.data.rechargeOrderId
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/recharge/wechat/notify', {
|
||||||
|
preParsing: captureRawBody
|
||||||
|
}, async (request, reply) => {
|
||||||
|
return handle(reply, request.traceId, async () => {
|
||||||
|
const data = await options.service.processWechatNotification(
|
||||||
|
notificationHeaders(request.headers),
|
||||||
|
request.rawBody,
|
||||||
|
request.traceId
|
||||||
|
);
|
||||||
|
return reply.send({ code: 'SUCCESS', message: '成功', data });
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authenticate(
|
async function authenticate(
|
||||||
@@ -77,6 +115,7 @@ async function authenticate(
|
|||||||
if (!access.capabilities.includes('profile.read')) return null;
|
if (!access.capabilities.includes('profile.read')) return null;
|
||||||
return {
|
return {
|
||||||
tenantId: result.session.tenantId,
|
tenantId: result.session.tenantId,
|
||||||
|
platformAppId: result.session.platformAppId,
|
||||||
userId: result.session.user.id
|
userId: result.session.user.id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -85,11 +124,12 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
|||||||
try {
|
try {
|
||||||
return await work();
|
return await work();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof RechargeError)) throw error;
|
if (!(error instanceof RechargeError) && !(error instanceof WechatPayError)) throw error;
|
||||||
const status = error.code === 'RECHARGE_PLAN_NOT_FOUND'
|
const status = error.code === 'RECHARGE_PLAN_NOT_FOUND'
|
||||||
|| error.code === 'RECHARGE_ORDER_NOT_FOUND'
|
|| error.code === 'RECHARGE_ORDER_NOT_FOUND'
|
||||||
? 404
|
? 404
|
||||||
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409 : 400;
|
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409
|
||||||
|
: error.code.includes('FORBIDDEN') ? 403 : 400;
|
||||||
return reply.status(status).send({
|
return reply.status(status).send({
|
||||||
code: error.code,
|
code: error.code,
|
||||||
message: 'The recharge request is not available.',
|
message: 'The recharge request is not available.',
|
||||||
@@ -98,6 +138,39 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const capture = new Transform({
|
||||||
|
transform(chunk, _encoding, callback) {
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
flush(callback) {
|
||||||
|
request.rawBody = Buffer.concat(chunks).toString('utf8');
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const transformed = payload.pipe(capture) as typeof payload;
|
||||||
|
transformed.receivedEncodedLength = payload.receivedEncodedLength;
|
||||||
|
done(null, transformed);
|
||||||
|
};
|
||||||
|
|
||||||
|
function notificationHeaders(headers: Record<string, unknown>) {
|
||||||
|
const read = (name: string) => {
|
||||||
|
const value = headers[name];
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
throw new WechatPayError('WECHAT_NOTIFICATION_HEADER_INVALID');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
timestamp: read('wechatpay-timestamp'),
|
||||||
|
nonce: read('wechatpay-nonce'),
|
||||||
|
serial: read('wechatpay-serial'),
|
||||||
|
signature: read('wechatpay-signature')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function unauthorized(reply: FastifyReply, traceId: string) {
|
function unauthorized(reply: FastifyReply, traceId: string) {
|
||||||
return reply.status(401).send({
|
return reply.status(401).send({
|
||||||
code: 'AUTH_SESSION_INVALID',
|
code: 'AUTH_SESSION_INVALID',
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ const app = await buildApp({
|
|||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
},
|
},
|
||||||
recharge: {
|
recharge: {
|
||||||
service: new RechargeService(pool, walletLedgerService),
|
service: new RechargeService(pool, walletLedgerService, {
|
||||||
|
paymentRepository,
|
||||||
|
client: wechatPayClient,
|
||||||
|
credentials: wechatCredentials
|
||||||
|
}),
|
||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
import type { MySqlPool } from '../db/mysql.js';
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
import type { PaymentRepository } from '../payments/payment-repository.js';
|
||||||
|
import {
|
||||||
|
WechatPayClient,
|
||||||
|
WechatPayError,
|
||||||
|
type WechatNotificationHeaders,
|
||||||
|
type WechatPayCredential
|
||||||
|
} from '../payments/wechat-pay-client.js';
|
||||||
import type { WalletLedgerService } from './wallet-ledger-service.js';
|
import type { WalletLedgerService } from './wallet-ledger-service.js';
|
||||||
|
|
||||||
interface PlanRow extends RowDataPacket {
|
interface PlanRow extends RowDataPacket {
|
||||||
@@ -26,6 +33,7 @@ interface RechargeOrderRow extends RowDataPacket {
|
|||||||
payAmountCents: number;
|
payAmountCents: number;
|
||||||
giftAmountCents: number;
|
giftAmountCents: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
providerPaymentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountRow extends RowDataPacket { total: number }
|
interface CountRow extends RowDataPacket { total: number }
|
||||||
@@ -37,7 +45,12 @@ export class RechargeError extends Error {
|
|||||||
export class RechargeService {
|
export class RechargeService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pool: MySqlPool,
|
private readonly pool: MySqlPool,
|
||||||
private readonly wallet: Pick<WalletLedgerService, 'credit'>
|
private readonly wallet: Pick<WalletLedgerService, 'credit'>,
|
||||||
|
private readonly wechat?: {
|
||||||
|
paymentRepository: Pick<PaymentRepository, 'resolveConfig'>;
|
||||||
|
client: WechatPayClient;
|
||||||
|
credentials: ReadonlyMap<string, WechatPayCredential>;
|
||||||
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listAvailablePlans(input: {
|
async listAvailablePlans(input: {
|
||||||
@@ -158,6 +171,113 @@ export class RechargeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createWechatPrepay(input: {
|
||||||
|
tenantId: string;
|
||||||
|
platformAppId: string;
|
||||||
|
userId: string;
|
||||||
|
rechargeOrderId: string;
|
||||||
|
}) {
|
||||||
|
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
|
||||||
|
const order = await this.loadOwnedRechargeOrder(input, false);
|
||||||
|
if (order.status === 'CREDITED') throw new RechargeError('RECHARGE_ALREADY_CREDITED');
|
||||||
|
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
|
||||||
|
throw new RechargeError('RECHARGE_STATUS_INVALID');
|
||||||
|
}
|
||||||
|
const config = await this.wechat.paymentRepository.resolveConfig(
|
||||||
|
this.pool,
|
||||||
|
input.tenantId,
|
||||||
|
input.platformAppId,
|
||||||
|
order.storeId ?? '0',
|
||||||
|
'WECHAT'
|
||||||
|
);
|
||||||
|
const credential = this.resolveWechatCredential(config.credentialRef);
|
||||||
|
const settings = config.settings as Record<string, unknown>;
|
||||||
|
const [identityRows] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT openid FROM qipai_user_identities
|
||||||
|
WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ?
|
||||||
|
AND provider = 'WECHAT' LIMIT 1`,
|
||||||
|
[input.tenantId, input.platformAppId, input.userId]
|
||||||
|
);
|
||||||
|
if (!identityRows[0]?.openid) throw new WechatPayError('WECHAT_OPENID_NOT_FOUND');
|
||||||
|
const result = await this.wechat.client.createJsapiPrepay(credential, {
|
||||||
|
description: typeof settings.rechargeDescription === 'string'
|
||||||
|
? settings.rechargeDescription : `棋牌室余额充值 ${order.rechargeNo}`,
|
||||||
|
outTradeNo: order.rechargeNo,
|
||||||
|
notifyUrl: settingUrl(
|
||||||
|
settings, 'rechargeNotifyUrl', 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
|
||||||
|
),
|
||||||
|
amountCents: Number(order.payAmountCents),
|
||||||
|
payerOpenId: String(identityRows[0].openid)
|
||||||
|
});
|
||||||
|
await this.pool.execute(
|
||||||
|
`UPDATE qipai_recharge_orders
|
||||||
|
SET payment_provider = 'WECHAT', provider_prepay_id = ?
|
||||||
|
WHERE tenant_id = ? AND id = ? AND user_id = ?`,
|
||||||
|
[result.prepayId, input.tenantId, input.rechargeOrderId, input.userId]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
rechargeOrderId: input.rechargeOrderId,
|
||||||
|
rechargeNo: order.rechargeNo,
|
||||||
|
amountCents: Number(order.payAmountCents),
|
||||||
|
...result.paymentParams
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async processWechatNotification(
|
||||||
|
headers: WechatNotificationHeaders,
|
||||||
|
rawBody: string,
|
||||||
|
traceId: string
|
||||||
|
) {
|
||||||
|
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
|
||||||
|
const data = this.verifyWithConfiguredCredential(headers, rawBody);
|
||||||
|
const rechargeNo = requiredString(data, 'out_trade_no');
|
||||||
|
const transactionId = requiredString(data, 'transaction_id');
|
||||||
|
const tradeState = requiredString(data, 'trade_state');
|
||||||
|
const amount = objectValue(data.amount);
|
||||||
|
const total = requiredNumber(amount, 'total');
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const order = await this.loadRechargeOrderByNo(connection, rechargeNo, true);
|
||||||
|
const callbackId = `${headers.serial}:${transactionId}:${tradeState}`;
|
||||||
|
const [callback] = await connection.execute<ResultSetHeader>(
|
||||||
|
`INSERT IGNORE INTO qipai_payment_callbacks
|
||||||
|
(tenant_id, payment_id, provider, callback_id, callback_type,
|
||||||
|
verified, payload)
|
||||||
|
VALUES (?, NULL, 'WECHAT_RECHARGE', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`,
|
||||||
|
[order.tenantId, callbackId, JSON.stringify(redactNotification(data))]
|
||||||
|
);
|
||||||
|
if (callback.affectedRows === 0) {
|
||||||
|
return { rechargeOrderId: order.id, status: order.status, idempotent: true };
|
||||||
|
}
|
||||||
|
if (tradeState !== 'SUCCESS' || total !== Number(order.payAmountCents)) {
|
||||||
|
const code = tradeState !== 'SUCCESS'
|
||||||
|
? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH';
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_payment_callbacks
|
||||||
|
SET processing_status = 'REJECTED', error_code = ?,
|
||||||
|
processed_at = UTC_TIMESTAMP(3)
|
||||||
|
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
|
||||||
|
[code, order.tenantId, callbackId]
|
||||||
|
);
|
||||||
|
return { rechargeOrderId: order.id, status: 'REJECTED', code, idempotent: false };
|
||||||
|
}
|
||||||
|
const credited = await this.creditRechargeOrder(connection, {
|
||||||
|
tenantId: String(order.tenantId),
|
||||||
|
rechargeOrderId: String(order.id),
|
||||||
|
providerTransactionId: transactionId,
|
||||||
|
providerCallbackId: callbackId,
|
||||||
|
rawNotify: redactNotification(data),
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_payment_callbacks
|
||||||
|
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
|
||||||
|
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
|
||||||
|
[order.tenantId, callbackId]
|
||||||
|
);
|
||||||
|
return credited;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) {
|
private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) {
|
||||||
const [rows] = await connection.execute<PlanRow[]>(
|
const [rows] = await connection.execute<PlanRow[]>(
|
||||||
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
|
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
|
||||||
@@ -217,6 +337,128 @@ export class RechargeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadOwnedRechargeOrder(
|
||||||
|
input: { tenantId: string; userId: string; rechargeOrderId: string },
|
||||||
|
lock: boolean
|
||||||
|
) {
|
||||||
|
const [rows] = await this.pool.execute<RechargeOrderRow[]>(
|
||||||
|
`SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId,
|
||||||
|
recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents,
|
||||||
|
gift_amount_cents AS giftAmountCents, status,
|
||||||
|
provider_payment_id AS providerPaymentId
|
||||||
|
FROM qipai_recharge_orders
|
||||||
|
WHERE tenant_id = ? AND id = ? AND user_id = ?
|
||||||
|
${lock ? 'FOR UPDATE' : ''}`,
|
||||||
|
[input.tenantId, input.rechargeOrderId, input.userId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
|
||||||
|
return normalizeRechargeOrder(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadRechargeOrderByNo(
|
||||||
|
connection: PoolConnection,
|
||||||
|
rechargeNo: string,
|
||||||
|
lock: boolean
|
||||||
|
) {
|
||||||
|
const [rows] = await connection.execute<Array<RechargeOrderRow & { tenantId: string }>>(
|
||||||
|
`SELECT id, tenant_id AS tenantId, user_id AS userId,
|
||||||
|
store_id AS storeId, plan_id AS planId, recharge_no AS rechargeNo,
|
||||||
|
pay_amount_cents AS payAmountCents,
|
||||||
|
gift_amount_cents AS giftAmountCents, status,
|
||||||
|
provider_payment_id AS providerPaymentId
|
||||||
|
FROM qipai_recharge_orders
|
||||||
|
WHERE recharge_no = ?
|
||||||
|
${lock ? 'FOR UPDATE' : ''}`,
|
||||||
|
[rechargeNo]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
|
||||||
|
return { ...normalizeRechargeOrder(rows[0]), tenantId: String(rows[0].tenantId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async creditRechargeOrder(
|
||||||
|
connection: PoolConnection,
|
||||||
|
input: {
|
||||||
|
tenantId: string;
|
||||||
|
rechargeOrderId: string;
|
||||||
|
providerTransactionId: string;
|
||||||
|
providerCallbackId: string;
|
||||||
|
rawNotify: Record<string, unknown>;
|
||||||
|
traceId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const order = await this.loadRechargeOrder(connection, input, true);
|
||||||
|
if (order.status === 'CREDITED') {
|
||||||
|
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: true };
|
||||||
|
}
|
||||||
|
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
|
||||||
|
throw new RechargeError('RECHARGE_STATUS_INVALID');
|
||||||
|
}
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_recharge_orders
|
||||||
|
SET status = 'PAID',
|
||||||
|
provider_payment_id = ?,
|
||||||
|
provider_callback_id = ?,
|
||||||
|
raw_notify = CAST(? AS JSON),
|
||||||
|
paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3))
|
||||||
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[
|
||||||
|
input.providerTransactionId,
|
||||||
|
input.providerCallbackId,
|
||||||
|
JSON.stringify(input.rawNotify),
|
||||||
|
input.tenantId,
|
||||||
|
order.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const plan = await this.loadPlan(connection, input.tenantId, order.planId);
|
||||||
|
await this.wallet.credit({
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
userId: String(order.userId),
|
||||||
|
scopeType: plan.scopeType,
|
||||||
|
storeId: plan.scopeType === 'STORE' ? order.storeId : null,
|
||||||
|
businessType: 'RECHARGE',
|
||||||
|
businessId: order.id,
|
||||||
|
entryType: 'RECHARGE',
|
||||||
|
cashDeltaCents: Number(order.payAmountCents),
|
||||||
|
giftDeltaCents: Number(order.giftAmountCents),
|
||||||
|
traceId: input.traceId,
|
||||||
|
metadata: {
|
||||||
|
provider: 'WECHAT',
|
||||||
|
providerTransactionId: input.providerTransactionId,
|
||||||
|
rechargeNo: order.rechargeNo
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_recharge_orders
|
||||||
|
SET status = 'CREDITED', credited_at = UTC_TIMESTAMP(3)
|
||||||
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[input.tenantId, order.id]
|
||||||
|
);
|
||||||
|
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveWechatCredential(reference: string) {
|
||||||
|
const credential = this.wechat?.credentials.get(reference)
|
||||||
|
?? this.wechat?.credentials.get(reference.replace(/^env:/, ''));
|
||||||
|
if (!credential) throw new WechatPayError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyWithConfiguredCredential(
|
||||||
|
headers: WechatNotificationHeaders,
|
||||||
|
rawBody: string
|
||||||
|
) {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const credential of this.wechat?.credentials.values() ?? []) {
|
||||||
|
if (!credential.platformCertificates[headers.serial]) continue;
|
||||||
|
try {
|
||||||
|
return this.wechat!.client.verifyAndDecrypt(credential, headers, rawBody);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
private async assertPurchaseLimit(
|
private async assertPurchaseLimit(
|
||||||
connection: PoolConnection,
|
connection: PoolConnection,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -276,3 +518,52 @@ function rechargeResponse(row: RechargeOrderRow, idempotent: boolean) {
|
|||||||
idempotent
|
idempotent
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRechargeOrder(row: RechargeOrderRow): RechargeOrderRow {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
id: String(row.id),
|
||||||
|
userId: String(row.userId),
|
||||||
|
storeId: row.storeId === null ? null : String(row.storeId),
|
||||||
|
planId: String(row.planId),
|
||||||
|
payAmountCents: Number(row.payAmountCents),
|
||||||
|
giftAmountCents: Number(row.giftAmountCents)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingUrl(
|
||||||
|
settings: Record<string, unknown>, key: string, fallback: string
|
||||||
|
) {
|
||||||
|
const value = settings[key];
|
||||||
|
return typeof value === 'string' && value.startsWith('https://') ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: Record<string, unknown>, key: string) {
|
||||||
|
const field = value[key];
|
||||||
|
if (typeof field !== 'string' || field.length === 0) {
|
||||||
|
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredNumber(value: Record<string, unknown>, key: string) {
|
||||||
|
const field = value[key];
|
||||||
|
if (typeof field !== 'number' || !Number.isInteger(field)) {
|
||||||
|
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactNotification(value: Record<string, unknown>) {
|
||||||
|
const copy = { ...value };
|
||||||
|
delete copy.payer;
|
||||||
|
delete copy.user_received_account;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_pla
|
|||||||
const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql');
|
const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql');
|
||||||
const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql');
|
const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql');
|
||||||
const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql');
|
const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql');
|
||||||
|
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
|
||||||
|
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
|
||||||
|
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
|
||||||
|
|
||||||
const coreTables = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -364,4 +367,15 @@ assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/);
|
|||||||
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/);
|
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/);
|
||||||
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/);
|
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/);
|
||||||
|
|
||||||
console.log('PASS: M01-B through M07-C migration contracts are present.');
|
assert.match(rechargeWechatUpSql, /ALTER TABLE qipai_recharge_orders/);
|
||||||
|
assert.match(rechargeWechatUpSql, /provider_prepay_id VARCHAR\(128\) NULL/);
|
||||||
|
assert.match(rechargeWechatUpSql, /provider_payment_id VARCHAR\(128\) NULL/);
|
||||||
|
assert.match(rechargeWechatUpSql, /provider_callback_id VARCHAR\(128\) NULL/);
|
||||||
|
assert.match(rechargeWechatUpSql, /raw_notify JSON NULL/);
|
||||||
|
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_payment/);
|
||||||
|
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callback/);
|
||||||
|
assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
|
||||||
|
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
|
||||||
|
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
|
||||||
|
|
||||||
|
console.log('PASS: M01-B through M08-A migration contracts are present.');
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
|
|||||||
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
|
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
|
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/);
|
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql$/);
|
||||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const token = signAccessToken({
|
|||||||
|
|
||||||
let createdInput;
|
let createdInput;
|
||||||
let listInput;
|
let listInput;
|
||||||
|
let prepayInput;
|
||||||
|
let notifyInput;
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
recharge: {
|
recharge: {
|
||||||
jwtSecret: secret,
|
jwtSecret: secret,
|
||||||
@@ -61,6 +63,23 @@ const app = await buildApp({
|
|||||||
giftAmountCents: 2000,
|
giftAmountCents: 2000,
|
||||||
idempotent: false
|
idempotent: false
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
async createWechatPrepay(input) {
|
||||||
|
prepayInput = input;
|
||||||
|
return {
|
||||||
|
rechargeOrderId: input.rechargeOrderId,
|
||||||
|
rechargeNo: 'RCHTEST',
|
||||||
|
amountCents: 10000,
|
||||||
|
timeStamp: '1782360000',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=wx-recharge-prepay',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'signed'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async processWechatNotification(headers, rawBody, traceId) {
|
||||||
|
notifyInput = { headers, rawBody, traceId };
|
||||||
|
return { rechargeOrderId: '901', status: 'CREDITED', idempotent: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +110,33 @@ assert.equal(createdInput.userId, '21');
|
|||||||
assert.equal(createdInput.planId, '31');
|
assert.equal(createdInput.planId, '31');
|
||||||
assert.equal(createdInput.traceId, 'm08a-recharge-order');
|
assert.equal(createdInput.traceId, 'm08a-recharge-order');
|
||||||
|
|
||||||
|
const prepay = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/app-api/recharge/orders/901/wechat-prepay',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(prepay.statusCode, 200);
|
||||||
|
assert.equal(prepayInput.tenantId, '7');
|
||||||
|
assert.equal(prepayInput.platformAppId, '9');
|
||||||
|
assert.equal(prepayInput.userId, '21');
|
||||||
|
assert.equal(prepay.json().data.package, 'prepay_id=wx-recharge-prepay');
|
||||||
|
|
||||||
|
const notify = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/app-api/recharge/wechat/notify',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'wechatpay-timestamp': '1782360001',
|
||||||
|
'wechatpay-nonce': 'notify-nonce',
|
||||||
|
'wechatpay-serial': 'PLATFORM-SERIAL',
|
||||||
|
'wechatpay-signature': 'signature'
|
||||||
|
},
|
||||||
|
payload: '{"resource":"encrypted"}'
|
||||||
|
});
|
||||||
|
assert.equal(notify.statusCode, 200);
|
||||||
|
assert.equal(notifyInput.headers.serial, 'PLATFORM-SERIAL');
|
||||||
|
assert.equal(notifyInput.rawBody, '{"resource":"encrypted"}');
|
||||||
|
|
||||||
const invalid = await app.inject({
|
const invalid = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/app-api/recharge/orders',
|
url: '/app-api/recharge/orders',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function createHarness(overrides = {}) {
|
|||||||
...overrides.plan
|
...overrides.plan
|
||||||
},
|
},
|
||||||
orders: [],
|
orders: [],
|
||||||
|
callbacks: [],
|
||||||
nextOrderId: 900,
|
nextOrderId: 900,
|
||||||
purchaseCount: overrides.purchaseCount ?? 0,
|
purchaseCount: overrides.purchaseCount ?? 0,
|
||||||
walletCredits: []
|
walletCredits: []
|
||||||
@@ -71,12 +72,60 @@ function createHarness(overrides = {}) {
|
|||||||
state.orders.push(order);
|
state.orders.push(order);
|
||||||
return [{ insertId: order.id, affectedRows: 1 }, []];
|
return [{ insertId: order.id, affectedRows: 1 }, []];
|
||||||
}
|
}
|
||||||
|
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('user_id = ?')) {
|
||||||
|
const order = state.orders.find((item) =>
|
||||||
|
item.tenantId === String(params[0])
|
||||||
|
&& item.id === String(params[1])
|
||||||
|
&& item.userId === String(params[2])
|
||||||
|
);
|
||||||
|
return [order ? [toOrderRow(order)] : [], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT openid FROM qipai_user_identities')) {
|
||||||
|
return [[{ openid: 'openid-recharge-test' }], []];
|
||||||
|
}
|
||||||
|
if (sql.includes('provider_prepay_id')) {
|
||||||
|
const order = state.orders.find((item) =>
|
||||||
|
item.tenantId === String(params[1])
|
||||||
|
&& item.id === String(params[2])
|
||||||
|
&& item.userId === String(params[3])
|
||||||
|
);
|
||||||
|
order.providerPrepayId = params[0];
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('recharge_no = ?')) {
|
||||||
|
const order = state.orders.find((item) => item.rechargeNo === String(params[0]));
|
||||||
|
return [order ? [{ ...toOrderRow(order), tenantId: order.tenantId }] : [], []];
|
||||||
|
}
|
||||||
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) {
|
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) {
|
||||||
const order = state.orders.find((item) =>
|
const order = state.orders.find((item) =>
|
||||||
item.tenantId === String(params[0]) && item.id === String(params[1])
|
item.tenantId === String(params[0]) && item.id === String(params[1])
|
||||||
);
|
);
|
||||||
return [order ? [toOrderRow(order)] : [], []];
|
return [order ? [toOrderRow(order)] : [], []];
|
||||||
}
|
}
|
||||||
|
if (sql.includes('INSERT IGNORE INTO qipai_payment_callbacks')) {
|
||||||
|
const duplicate = state.callbacks.some((item) =>
|
||||||
|
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
|
||||||
|
);
|
||||||
|
if (!duplicate) {
|
||||||
|
state.callbacks.push({
|
||||||
|
tenantId: String(params[0]),
|
||||||
|
callbackId: String(params[1]),
|
||||||
|
status: 'RECEIVED',
|
||||||
|
payload: params[2]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [{ affectedRows: duplicate ? 0 : 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('provider_payment_id')) {
|
||||||
|
const order = state.orders.find((item) =>
|
||||||
|
item.tenantId === String(params[3]) && item.id === String(params[4])
|
||||||
|
);
|
||||||
|
order.status = 'PAID';
|
||||||
|
order.providerPaymentId = String(params[0]);
|
||||||
|
order.providerCallbackId = String(params[1]);
|
||||||
|
order.rawNotify = params[2];
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
if (sql.includes("SET status = 'PAID'")) {
|
if (sql.includes("SET status = 'PAID'")) {
|
||||||
const order = state.orders.find((item) =>
|
const order = state.orders.find((item) =>
|
||||||
item.tenantId === String(params[1]) && item.id === String(params[2])
|
item.tenantId === String(params[1]) && item.id === String(params[2])
|
||||||
@@ -92,11 +141,23 @@ function createHarness(overrides = {}) {
|
|||||||
order.status = 'CREDITED';
|
order.status = 'CREDITED';
|
||||||
return [{ affectedRows: 1 }, []];
|
return [{ affectedRows: 1 }, []];
|
||||||
}
|
}
|
||||||
|
if (sql.includes('UPDATE qipai_payment_callbacks')) {
|
||||||
|
const callback = state.callbacks.find((item) =>
|
||||||
|
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
|
||||||
|
) ?? state.callbacks.find((item) =>
|
||||||
|
item.tenantId === String(params[1]) && item.callbackId === String(params[2])
|
||||||
|
);
|
||||||
|
if (callback) callback.status = sql.includes('REJECTED') ? 'REJECTED' : 'PROCESSED';
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
throw new Error(`Unexpected SQL: ${sql}`);
|
throw new Error(`Unexpected SQL: ${sql}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pool = {
|
const pool = {
|
||||||
|
async execute(sql, params) {
|
||||||
|
return connection.execute(sql, params);
|
||||||
|
},
|
||||||
async getConnection() {
|
async getConnection() {
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
@@ -113,7 +174,58 @@ function createHarness(overrides = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { service: new RechargeService(pool, wallet), state };
|
const wechat = {
|
||||||
|
paymentRepository: {
|
||||||
|
async resolveConfig(_connection, tenantId, platformAppId, storeId, provider) {
|
||||||
|
state.resolvedConfig = { tenantId, platformAppId, storeId, provider };
|
||||||
|
return {
|
||||||
|
credentialRef: 'wechat-test',
|
||||||
|
settings: {
|
||||||
|
rechargeDescription: '测试充值',
|
||||||
|
rechargeNotifyUrl: 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
async createJsapiPrepay(_credential, input) {
|
||||||
|
state.prepayInput = input;
|
||||||
|
return {
|
||||||
|
prepayId: 'wx-recharge-prepay',
|
||||||
|
paymentParams: {
|
||||||
|
timeStamp: '1782360000',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=wx-recharge-prepay',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'signed'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
verifyAndDecrypt(_credential, headers, rawBody) {
|
||||||
|
state.verifiedNotification = { headers, rawBody };
|
||||||
|
return {
|
||||||
|
out_trade_no: state.orders[0].rechargeNo,
|
||||||
|
transaction_id: 'wx-recharge-transaction',
|
||||||
|
trade_state: 'SUCCESS',
|
||||||
|
amount: { total: state.orders[0].payAmountCents },
|
||||||
|
payer: { openid: 'redacted' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credentials: new Map([[
|
||||||
|
'wechat-test',
|
||||||
|
{
|
||||||
|
appId: 'wx-test-app',
|
||||||
|
merchantId: '1900000109',
|
||||||
|
serialNo: 'MERCHANT-SERIAL',
|
||||||
|
privateKeyPem: 'test-private-key',
|
||||||
|
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||||
|
platformCertificates: { 'PLATFORM-SERIAL': 'test-cert' }
|
||||||
|
}
|
||||||
|
]])
|
||||||
|
};
|
||||||
|
|
||||||
|
return { service: new RechargeService(pool, wallet, wechat), state };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOrderRow(order) {
|
function toOrderRow(order) {
|
||||||
@@ -217,4 +329,41 @@ const baseOrder = {
|
|||||||
assert.equal(state.walletCredits.length, 1);
|
assert.equal(state.walletCredits.length, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { service, state } = createHarness();
|
||||||
|
const created = await service.createRechargeOrder(baseOrder);
|
||||||
|
const prepay = await service.createWechatPrepay({
|
||||||
|
tenantId: '7',
|
||||||
|
platformAppId: '9',
|
||||||
|
userId: '21',
|
||||||
|
rechargeOrderId: created.rechargeOrderId
|
||||||
|
});
|
||||||
|
assert.equal(prepay.package, 'prepay_id=wx-recharge-prepay');
|
||||||
|
assert.equal(state.resolvedConfig.storeId, '11');
|
||||||
|
assert.equal(state.prepayInput.outTradeNo, created.rechargeNo);
|
||||||
|
assert.equal(state.prepayInput.amountCents, 10000);
|
||||||
|
assert.equal(state.orders[0].providerPrepayId, 'wx-recharge-prepay');
|
||||||
|
|
||||||
|
const notified = await service.processWechatNotification({
|
||||||
|
timestamp: '1782360001',
|
||||||
|
nonce: 'notify-nonce',
|
||||||
|
serial: 'PLATFORM-SERIAL',
|
||||||
|
signature: 'signed'
|
||||||
|
}, '{"resource":"encrypted"}', 'recharge-notify');
|
||||||
|
assert.equal(notified.status, 'CREDITED');
|
||||||
|
assert.equal(state.orders[0].providerPaymentId, 'wx-recharge-transaction');
|
||||||
|
assert.equal(state.orders[0].providerCallbackId, 'PLATFORM-SERIAL:wx-recharge-transaction:SUCCESS');
|
||||||
|
assert.equal(state.walletCredits.length, 1);
|
||||||
|
assert.equal(JSON.parse(state.orders[0].rawNotify).payer, undefined);
|
||||||
|
|
||||||
|
const duplicate = await service.processWechatNotification({
|
||||||
|
timestamp: '1782360001',
|
||||||
|
nonce: 'notify-nonce',
|
||||||
|
serial: 'PLATFORM-SERIAL',
|
||||||
|
signature: 'signed'
|
||||||
|
}, '{"resource":"encrypted"}', 'recharge-notify');
|
||||||
|
assert.equal(duplicate.idempotent, true);
|
||||||
|
assert.equal(state.walletCredits.length, 1);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.');
|
console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.');
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE qipai_recharge_orders
|
||||||
|
DROP KEY uq_qipai_recharge_provider_callback,
|
||||||
|
DROP KEY uq_qipai_recharge_provider_payment,
|
||||||
|
DROP COLUMN raw_notify,
|
||||||
|
DROP COLUMN provider_callback_id,
|
||||||
|
DROP COLUMN provider_payment_id,
|
||||||
|
DROP COLUMN provider_prepay_id,
|
||||||
|
DROP COLUMN payment_provider;
|
||||||
|
|
||||||
|
DELETE FROM qipai_schema_migrations
|
||||||
|
WHERE version = '2026062524';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE qipai_recharge_orders
|
||||||
|
ADD COLUMN payment_provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT' AFTER payment_id,
|
||||||
|
ADD COLUMN provider_prepay_id VARCHAR(128) NULL AFTER payment_provider,
|
||||||
|
ADD COLUMN provider_payment_id VARCHAR(128) NULL AFTER provider_prepay_id,
|
||||||
|
ADD COLUMN provider_callback_id VARCHAR(128) NULL AFTER provider_payment_id,
|
||||||
|
ADD COLUMN raw_notify JSON NULL AFTER provider_callback_id,
|
||||||
|
ADD UNIQUE KEY uq_qipai_recharge_provider_payment
|
||||||
|
(tenant_id, payment_provider, provider_payment_id),
|
||||||
|
ADD UNIQUE KEY uq_qipai_recharge_provider_callback
|
||||||
|
(tenant_id, payment_provider, provider_callback_id);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||||
|
VALUES ('2026062524', 'm08a_recharge_wechat');
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
SELECT COUNT(*) AS expected_columns
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_recharge_orders'
|
||||||
|
AND column_name IN (
|
||||||
|
'payment_provider',
|
||||||
|
'provider_prepay_id',
|
||||||
|
'provider_payment_id',
|
||||||
|
'provider_callback_id',
|
||||||
|
'raw_notify'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS expected_indexes
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_recharge_orders'
|
||||||
|
AND index_name IN (
|
||||||
|
'uq_qipai_recharge_provider_payment',
|
||||||
|
'uq_qipai_recharge_provider_callback'
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# M08-A 充值微信支付 API
|
||||||
|
|
||||||
|
- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay`
|
||||||
|
- 鉴权:顾客登录态,需 `profile.read`。
|
||||||
|
- 行为:校验充值单属于当前用户,读取微信支付配置和当前用户 openid,创建 JSAPI 预支付。
|
||||||
|
- 返回:`rechargeOrderId`、`rechargeNo`、`amountCents`、`timeStamp`、`nonceStr`、`package`、`signType`、`paySign`。
|
||||||
|
|
||||||
|
- `POST /app-api/recharge/wechat/notify`
|
||||||
|
- 鉴权:微信支付平台证书签名和 AES-GCM 资源解密。
|
||||||
|
- 行为:按 `out_trade_no` 定位充值单,校验金额,写入 `qipai_payment_callbacks` 幂等记录,成功后给钱包现金/赠送余额入账。
|
||||||
|
- 隐私:通知 payload 入库前移除 `payer` 等敏感对象。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# M08-A 充值微信支付字段
|
||||||
|
|
||||||
|
- 迁移:`database/migrations/2026062524_m08a_recharge_wechat.*.sql`
|
||||||
|
- 目的:让充值单独立记录微信预支付、交易号、回调幂等键和脱敏通知,不复用订单支付的 `order_id` 语义。
|
||||||
|
- 正向变更:`qipai_recharge_orders` 新增 `payment_provider`、`provider_prepay_id`、`provider_payment_id`、`provider_callback_id`、`raw_notify`,并增加交易号与回调唯一键。
|
||||||
|
- 回滚:删除新增唯一键和字段,删除迁移版本 `2026062524`。
|
||||||
|
- 验证:检查 5 个新增字段和 2 个唯一索引存在。
|
||||||
@@ -157,3 +157,32 @@ M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单
|
|||||||
|
|
||||||
- 工程提交:`e24920c`
|
- 工程提交:`e24920c`
|
||||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||||
|
|
||||||
|
## 2026-06-25 续接:顾客端充值微信支付调起
|
||||||
|
|
||||||
|
- 数据库新增 `2026062524_m08a_recharge_wechat` 迁移,为 `qipai_recharge_orders` 增加微信预支付 ID、微信交易号、回调幂等 ID 和脱敏通知 JSON,避免把充值单伪造成订单支付。
|
||||||
|
- `RechargeService` 新增 `createWechatPrepay` 和 `processWechatNotification`,充值预支付复用微信支付配置、顾客 openid 和 JSAPI 参数生成;微信通知验签解密后按充值单号入账,重复通知不重复加余额。
|
||||||
|
- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay` 暴露给小程序,校验当前登录顾客只能为本人充值单调起支付。
|
||||||
|
- `POST /app-api/recharge/wechat/notify` 新增充值微信支付通知入口,通知 payload 入库前移除 `payer`。
|
||||||
|
- 小程序充值页新增“微信支付”按钮,创建充值单后可调起微信支付;到账结果以后端通知和个人中心余额为准。
|
||||||
|
- 迁移 runner 白名单纳入新迁移,并补充 API/DB changelog。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- `npm run build`(`backend/`):PASS。
|
||||||
|
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||||
|
- `node tests/recharge-service.test.mjs`:PASS。
|
||||||
|
- `node tests/recharge-route.test.mjs`:PASS。
|
||||||
|
- `node tests/migration-contract.test.mjs`:PASS。
|
||||||
|
- `node tests/migration-runner.test.mjs`:PASS。
|
||||||
|
- `node tests/wechat-pay.test.mjs`:PASS。
|
||||||
|
- `npm test`(`backend/`):PASS。
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
- M08-A 仍为 `PARTIAL`;余额/套餐/优惠券下单抵扣深度集成、真机合法域名、真实微信支付和实物开门验证待继续。
|
||||||
|
|
||||||
|
提交:
|
||||||
|
|
||||||
|
- 工程提交:待本轮 commit 后记录。
|
||||||
|
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const {
|
|||||||
ensureLogin,
|
ensureLogin,
|
||||||
cents,
|
cents,
|
||||||
clientRequestId,
|
clientRequestId,
|
||||||
|
requestWechatPayment,
|
||||||
} = require('../../utils/api.js')
|
} = require('../../utils/api.js')
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
@@ -76,6 +77,21 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async payRechargeOrder() {
|
||||||
|
if (!this.data.rechargeOrder || !this.data.rechargeOrder.rechargeOrderId) {
|
||||||
|
this.setData({ errorMessage: '请先创建充值单' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.withRequest(async () => {
|
||||||
|
const response = await request(
|
||||||
|
`/recharge/orders/${encodeURIComponent(this.data.rechargeOrder.rechargeOrderId)}/wechat-prepay`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
await requestWechatPayment(response.data)
|
||||||
|
this.setData({ successMessage: '微信支付已提交,到账结果以服务端通知为准' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
async withRequest(work) {
|
async withRequest(work) {
|
||||||
this.setData({ loading: true, errorMessage: '', successMessage: '' })
|
this.setData({ loading: true, errorMessage: '', successMessage: '' })
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<view class="muted">状态:{{rechargeOrder.status}}</view>
|
<view class="muted">状态:{{rechargeOrder.status}}</view>
|
||||||
<view class="muted">实付:{{rechargeOrder.payText}}</view>
|
<view class="muted">实付:{{rechargeOrder.payText}}</view>
|
||||||
<view class="muted">赠送:{{rechargeOrder.giftText}}</view>
|
<view class="muted">赠送:{{rechargeOrder.giftText}}</view>
|
||||||
|
<button loading="{{loading}}" bindtap="payRechargeOrder">微信支付</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ const recharge = read('miniapp/pages/recharge/index.js')
|
|||||||
for (const pattern of [
|
for (const pattern of [
|
||||||
'/recharge/plans',
|
'/recharge/plans',
|
||||||
'/recharge/orders',
|
'/recharge/orders',
|
||||||
|
'/wechat-prepay',
|
||||||
|
'requestWechatPayment',
|
||||||
'clientRequestId',
|
'clientRequestId',
|
||||||
'selectedPlanId',
|
'selectedPlanId',
|
||||||
'rechargeOrder'
|
'rechargeOrder'
|
||||||
|
|||||||
Reference in New Issue
Block a user