feat(M05-C): 完成团购验券与第三方直订

This commit is contained in:
Codex
2026-06-22 11:28:50 +08:00
parent 3ec74bb751
commit cda640baa0
17 changed files with 1654 additions and 13 deletions
+1
View File
@@ -14,6 +14,7 @@ QIPAI_SESSION_TTL_SECONDS=604800
QIPAI_WECHAT_APP_SECRETS={}
QIPAI_TEST_PAYMENT_ENABLED=false
QIPAI_WECHAT_PAY_CREDENTIALS={}
QIPAI_THIRD_PARTY_CREDENTIALS={}
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
QIPAI_MQTT_USERNAME=
QIPAI_MQTT_PASSWORD=
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+7
View File
@@ -38,6 +38,9 @@ import {
} from './routes/order-management.js';
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js';
import {
registerThirdPartyRoutes, type ThirdPartyRouteOptions
} from './routes/third-party.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -53,6 +56,7 @@ export interface BuildAppOptions {
orderManagement?: OrderManagementRouteOptions;
orderShare?: OrderShareRouteOptions;
payment?: PaymentRouteOptions;
thirdParty?: ThirdPartyRouteOptions;
}
declare module 'fastify' {
@@ -133,6 +137,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.payment) {
await registerPaymentRoutes(app, options.payment);
}
if (options.thirdParty) {
await registerThirdPartyRoutes(app, options.thirdParty);
}
return app;
}
+4
View File
@@ -18,6 +18,7 @@ const configSchema = z.object({
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
QIPAI_MQTT_USERNAME: z.string().default(''),
QIPAI_MQTT_PASSWORD: z.string().default('')
@@ -60,6 +61,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS
},
thirdParty: {
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
},
mqtt: {
url: parsed.QIPAI_MQTT_URL,
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0,
+7 -3
View File
@@ -36,7 +36,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
'database/migrations/2026062014_m04d_order_shares.up.sql',
'database/migrations/2026062015_m05a_payment_domain.up.sql',
'database/migrations/2026062216_m05b_wechat_refunds.up.sql'
'database/migrations/2026062216_m05b_wechat_refunds.up.sql',
'database/migrations/2026062217_m05c_third_party.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -54,9 +55,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
'database/migrations/2026062014_m04d_order_shares.verify.sql',
'database/migrations/2026062015_m05a_payment_domain.verify.sql',
'database/migrations/2026062216_m05b_wechat_refunds.verify.sql'
'database/migrations/2026062216_m05b_wechat_refunds.verify.sql',
'database/migrations/2026062217_m05c_third_party.verify.sql'
],
down: [
'database/migrations/2026062217_m05c_third_party.down.sql',
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
'database/migrations/2026062015_m05a_payment_domain.down.sql',
'database/migrations/2026062014_m04d_order_shares.down.sql',
@@ -204,7 +207,8 @@ export async function executeMigrationPlan(
2, 2, 1, 2, 1,
1, 8, 3, 1,
5, 8, 4, 1,
1, 5, 3, 1
1, 5, 3, 1,
5, 6, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+244
View File
@@ -0,0 +1,244 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
ThirdPartyError, type ThirdPartyProvider
} from '../third-party/third-party-client.js';
import type { ThirdPartyService } from '../third-party/third-party-service.js';
const providerSchema = z.enum(['MEITUAN', 'DIANPING', 'DOUYIN', 'KUAISHOU']);
const redeemSchema = z.object({
provider: providerSchema,
voucherCode: z.string().min(4).max(128),
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
clientRequestId: z.string().min(8).max(128)
}).strict();
const manualSchema = redeemSchema.extend({
amountCents: z.number().int().positive(),
note: z.string().min(1).max(512)
}).strict();
const notifyParams = z.object({
tenantId: z.string().regex(/^[1-9]\d{0,19}$/),
provider: providerSchema
});
const bookingSchema = z.object({
eventId: z.string().min(4).max(128),
externalBookingNo: z.string().min(4).max(128),
externalStoreRef: z.string().min(1).max(128),
externalRoomRef: z.string().min(1).max(128),
customerRef: z.string().max(255).default(''),
startsAt: z.coerce.date(),
endsAt: z.coerce.date(),
amountCents: z.number().int().nonnegative()
}).strict().refine((value) => value.endsAt > value.startsAt);
const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) });
const recordsQuery = z.object({
provider: providerSchema.optional(),
status: z.string().min(1).max(32).optional()
});
const configSchema = z.object({
provider: providerSchema,
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
mode: z.enum(['MANUAL', 'MOCK', 'API']),
enabled: z.boolean().default(true),
credentialRef: z.string().max(255).default(''),
settings: z.record(z.unknown()).default({})
}).strict();
const mappingSchema = z.object({
provider: providerSchema,
resourceType: z.enum(['STORE', 'ROOM']),
externalRef: z.string().min(1).max(128),
localResourceId: z.string().regex(/^[1-9]\d{0,19}$/)
}).strict();
export interface ThirdPartyRouteOptions {
service: ThirdPartyService;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: {
getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile>;
};
jwtSecret: string;
}
export async function registerThirdPartyRoutes(
app: FastifyInstance,
options: ThirdPartyRouteOptions
) {
app.post('/app-api/group-vouchers/redeem', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, false);
const body = redeemSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucher({
tenantId: auth.tenantId,
userId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = manualSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucherManually({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
app.post(
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
async (request, reply) => {
const params = notifyParams.safeParse(request.params);
const body = bookingSchema.safeParse(request.body);
const signature = request.headers['x-third-party-signature'];
if (!params.success || !body.success || typeof signature !== 'string') {
return invalid(reply, request.traceId);
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.receiveDirectBooking({
tenantId: params.data.tenantId,
provider: params.data.provider,
signature,
rawBody: JSON.stringify(request.body),
payload: request.body as Record<string, unknown>,
...body.data
}),
traceId: request.traceId
}));
}
);
app.post('/app-api/third-party/bookings/:bookingId/claim', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, false);
const params = bookingParams.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.claimDirectBooking({
tenantId: auth.tenantId,
userId: auth.userId,
bookingId: params.data.bookingId
}),
traceId: request.traceId
}));
});
app.get('/admin-api/third-party/records', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = recordsQuery.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listRecords({
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined,
status: query.data.status
}),
traceId: request.traceId
}));
});
app.put('/admin-api/third-party/config', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = configSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.saveConfig({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/third-party/mappings', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = mappingSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.saveMapping({
tenantId: auth.tenantId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
}
async function authenticate(
authorization: string | undefined,
options: ThirdPartyRouteOptions,
withAccess: boolean
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
const tenantId = result.session.tenantId;
const userId = result.session.user.id;
return {
tenantId,
userId,
access: withAccess
? await options.accessControl.getAccessProfile(tenantId, userId)
: undefined
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ThirdPartyError)) throw error;
const status = error.code.includes('NOT_FOUND') ? 404
: error.code.includes('CONFLICT') || error.code.includes('ALREADY') ? 409
: error.code.includes('FORBIDDEN') ? 403
: error.code.includes('SIGNATURE') ? 401 : 400;
return reply.status(status).send({
code: error.code,
message: 'The third-party request is not available.',
traceId
});
}
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_THIRD_PARTY_REQUEST',
message: 'The third-party request is invalid.',
traceId
});
}
+16
View File
@@ -21,6 +21,10 @@ import {
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
} from './payments/wechat-pay-client.js';
import { WechatPaymentService } from './payments/wechat-payment-service.js';
import {
FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient
} from './third-party/third-party-client.js';
import { ThirdPartyService } from './third-party/third-party-service.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -29,6 +33,7 @@ const accessControl = new RbacRepository(pool);
const orderManagementRepository = new OrderManagementRepository(pool);
const paymentRepository = new PaymentRepository(pool);
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
const app = await buildApp({
config,
platformConfigRepository: new PlatformConfigRepository(pool),
@@ -106,6 +111,17 @@ const app = await buildApp({
accessControl,
jwtSecret: config.auth.jwtSecret,
testAdapterEnabled: config.payment.testAdapterEnabled
},
thirdParty: {
service: new ThirdPartyService(
pool,
new PricingRepository(pool),
new ThirdPartyClient(new FetchThirdPartyTransport()),
thirdPartyCredentials
),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+174
View File
@@ -0,0 +1,174 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU';
export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API';
export interface ThirdPartyCredential {
webhookSecret?: string;
apiToken?: string;
}
export interface ThirdPartyTransport {
request(input: {
url: string;
method: 'POST';
headers: Record<string, string>;
body: string;
}): Promise<{ status: number; body: string }>;
}
export interface VoucherRedeemResult {
status: 'SUCCEEDED' | 'FAILED' | 'PENDING';
amountCents: number;
externalProductId?: string;
failureCode?: string;
response?: Record<string, unknown>;
}
export class ThirdPartyError extends Error {
constructor(public readonly code: string, message = code) {
super(message);
}
}
export class FetchThirdPartyTransport implements ThirdPartyTransport {
async request(input: {
url: string;
method: 'POST';
headers: Record<string, string>;
body: string;
}) {
const response = await fetch(input.url, {
method: input.method,
headers: input.headers,
body: input.body
});
return { status: response.status, body: await response.text() };
}
}
export class ThirdPartyClient {
constructor(private readonly transport: ThirdPartyTransport) {}
verifyWebhook(secret: string, rawBody: string, signature: string) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const left = Buffer.from(expected, 'utf8');
const right = Buffer.from(signature.toLowerCase(), 'utf8');
if (left.length !== right.length || !timingSafeEqual(left, right)) {
throw new ThirdPartyError('THIRD_PARTY_SIGNATURE_INVALID');
}
}
async redeemVoucher(input: {
mode: ThirdPartyMode;
provider: ThirdPartyProvider;
voucherCode: string;
orderNo: string;
expectedAmountCents: number;
settings: Record<string, unknown>;
credential?: ThirdPartyCredential;
}): Promise<VoucherRedeemResult> {
if (input.mode === 'MANUAL') {
throw new ThirdPartyError('THIRD_PARTY_MANUAL_ONLY');
}
if (input.mode === 'MOCK') {
if (input.voucherCode.startsWith('FAIL-')) {
return {
status: 'FAILED',
amountCents: 0,
failureCode: 'MOCK_VOUCHER_REJECTED',
response: { adapter: 'mock', accepted: false }
};
}
return {
status: 'SUCCEEDED',
amountCents: input.expectedAmountCents,
externalProductId: 'mock-product',
response: { adapter: 'mock', accepted: true }
};
}
const endpoint = input.settings.redeemEndpoint;
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
throw new ThirdPartyError('THIRD_PARTY_ENDPOINT_INVALID');
}
if (!input.credential?.apiToken) {
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_NOT_CONFIGURED');
}
const response = await this.transport.request({
url: endpoint,
method: 'POST',
headers: {
Authorization: `Bearer ${input.credential.apiToken}`,
'Content-Type': 'application/json',
'User-Agent': 'qipai-backend/0.1'
},
body: JSON.stringify({
voucherCode: input.voucherCode,
orderNo: input.orderNo
})
});
if (response.status < 200 || response.status >= 300) {
return {
status: 'PENDING',
amountCents: 0,
failureCode: `THIRD_PARTY_HTTP_${response.status}`,
response: { httpStatus: response.status }
};
}
const payload = parseObject(response.body);
const accepted = payload.accepted === true;
const amountCents = Number(payload.amountCents);
return {
status: accepted ? 'SUCCEEDED' : 'FAILED',
amountCents: Number.isSafeInteger(amountCents) && amountCents >= 0
? amountCents : 0,
externalProductId: typeof payload.productId === 'string' ? payload.productId : '',
failureCode: accepted ? '' : stringValue(payload.failureCode, 'VOUCHER_REJECTED'),
response: sanitizeResponse(payload)
};
}
}
export function parseThirdPartyCredentials(value: string) {
const raw = parseObject(value);
const result = new Map<string, ThirdPartyCredential>();
for (const [key, item] of Object.entries(raw)) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_INVALID');
}
const credential = item as Record<string, unknown>;
result.set(key, {
webhookSecret: optionalString(credential.webhookSecret),
apiToken: optionalString(credential.apiToken)
});
}
return result;
}
function parseObject(value: string) {
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('object required');
}
return parsed as Record<string, unknown>;
} catch {
throw new ThirdPartyError('THIRD_PARTY_JSON_INVALID');
}
}
function optionalString(value: unknown) {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function stringValue(value: unknown, fallback: string) {
return typeof value === 'string' && value.length > 0 ? value : fallback;
}
function sanitizeResponse(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.voucherCode;
delete copy.token;
delete copy.secret;
return copy;
}
+676
View File
@@ -0,0 +1,676 @@
import { createHash, randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { PricingRepository } from '../orders/pricing-repository.js';
import {
ThirdPartyClient, ThirdPartyError, type ThirdPartyCredential,
type ThirdPartyMode, type ThirdPartyProvider
} from './third-party-client.js';
interface OrderRow extends RowDataPacket {
id: string;
orderNo: string;
storeId: string;
status: string;
totalAmountCents: number;
paidAmountCents: number;
}
interface ConfigRow extends RowDataPacket {
id: string;
mode: ThirdPartyMode;
credentialRef: string;
settings: string | Record<string, unknown>;
}
interface RedemptionRow extends RowDataPacket {
id: string;
orderId: string;
status: string;
voucherMasked: string;
}
interface BookingRow extends RowDataPacket {
id: string;
tenantId: string;
provider: ThirdPartyProvider;
storeId: string | null;
roomId: string | null;
startsAt: Date;
endsAt: Date;
amountCents: number;
status: string;
orderId: string | null;
}
export class ThirdPartyService {
constructor(
private readonly pool: MySqlPool,
private readonly pricing: PricingRepository,
private readonly client: ThirdPartyClient,
private readonly credentials: ReadonlyMap<string, ThirdPartyCredential>
) {}
async redeemVoucher(input: {
tenantId: string;
userId: string;
provider: ThirdPartyProvider;
voucherCode: string;
orderId: string;
clientRequestId: string;
}) {
const existing = await this.findRedemptionByRequest(
input.tenantId, input.clientRequestId, input.orderId
);
if (existing) return { ...existing, idempotent: true };
const order = await this.loadOwnedOrder(input.tenantId, input.userId, input.orderId);
const config = await this.resolveConfig(input.tenantId, order.storeId, input.provider);
const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
if (expectedAmountCents <= 0) throw new ThirdPartyError('PAYMENT_NOT_REQUIRED');
const result = await this.client.redeemVoucher({
mode: config.mode,
provider: input.provider,
voucherCode: input.voucherCode,
orderNo: order.orderNo,
expectedAmountCents,
settings: config.settings,
credential: this.resolveCredential(config.credentialRef)
});
return this.recordRedemption({
...input,
actorId: input.userId,
mode: config.mode,
amountCents: result.amountCents,
expectedAmountCents,
externalProductId: result.externalProductId ?? '',
status: result.status,
failureCode: result.failureCode ?? '',
response: result.response ?? {}
});
}
async redeemVoucherManually(input: {
tenantId: string;
actorId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
voucherCode: string;
orderId: string;
amountCents: number;
clientRequestId: string;
note: string;
}) {
const existing = await this.findRedemptionByRequest(
input.tenantId, input.clientRequestId, input.orderId
);
if (existing) return { ...existing, idempotent: true };
const order = await this.loadOrder(input.tenantId, input.orderId);
assertStoreAccess(input.access, order.storeId);
const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
if (input.amountCents !== expectedAmountCents) {
throw new ThirdPartyError('VOUCHER_AMOUNT_INVALID');
}
return this.recordRedemption({
...input,
mode: 'MANUAL',
expectedAmountCents,
externalProductId: '',
status: 'SUCCEEDED',
failureCode: '',
response: { manual: true, note: input.note.slice(0, 200) }
});
}
async receiveDirectBooking(input: {
tenantId: string;
provider: ThirdPartyProvider;
signature: string;
rawBody: string;
eventId: string;
externalBookingNo: string;
externalStoreRef: string;
externalRoomRef: string;
customerRef: string;
startsAt: Date;
endsAt: Date;
amountCents: number;
payload: Record<string, unknown>;
}) {
const config = await this.resolveConfig(input.tenantId, null, input.provider);
const credential = this.resolveCredential(config.credentialRef);
if (!credential?.webhookSecret) {
throw new ThirdPartyError('THIRD_PARTY_WEBHOOK_NOT_CONFIGURED');
}
this.client.verifyWebhook(credential.webhookSecret, input.rawBody, input.signature);
const [existing] = await this.pool.execute<BookingRow[]>(
`SELECT id, tenant_id AS tenantId, provider, store_id AS storeId,
room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt,
amount_cents AS amountCents, status, order_id AS orderId
FROM qipai_direct_bookings
WHERE tenant_id = ? AND provider = ? AND event_id = ? LIMIT 1`,
[input.tenantId, input.provider, input.eventId]
);
if (existing[0]) return { ...normalizeBooking(existing[0]), idempotent: true };
const mapping = await this.resolveMappings(
input.tenantId, input.provider, input.externalStoreRef, input.externalRoomRef
);
const status = mapping.storeId && mapping.roomId ? 'READY_TO_CLAIM' : 'PENDING_MAPPING';
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_direct_bookings
(tenant_id, provider, event_id, external_booking_no,
external_store_ref, external_room_ref, store_id, room_id,
customer_ref_hash, starts_at, ends_at, amount_cents, status, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
[input.tenantId, input.provider, input.eventId, input.externalBookingNo,
input.externalStoreRef, input.externalRoomRef, mapping.storeId, mapping.roomId,
hashValue(input.customerRef), input.startsAt, input.endsAt, input.amountCents,
status, JSON.stringify(sanitizePayload(input.payload))]
);
return {
bookingId: String(result.insertId),
status,
mapped: status === 'READY_TO_CLAIM',
idempotent: false
};
}
async claimDirectBooking(input: {
tenantId: string;
userId: string;
bookingId: string;
}) {
const [rows] = await this.pool.execute<BookingRow[]>(
`SELECT id, tenant_id AS tenantId, provider, store_id AS storeId,
room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt,
amount_cents AS amountCents, status, order_id AS orderId
FROM qipai_direct_bookings
WHERE tenant_id = ? AND id = ? LIMIT 1`,
[input.tenantId, input.bookingId]
);
const booking = rows[0];
if (!booking) throw new ThirdPartyError('DIRECT_BOOKING_NOT_FOUND');
if (booking.status === 'CLAIMED') {
return { bookingId: input.bookingId, orderId: String(booking.orderId), idempotent: true };
}
if (booking.status !== 'READY_TO_CLAIM' || !booking.roomId) {
throw new ThirdPartyError('DIRECT_BOOKING_NOT_READY');
}
const quote = await this.pricing.quote({
tenantId: input.tenantId,
roomId: String(booking.roomId),
startAt: booking.startsAt,
endAt: booking.endsAt,
pricingMode: 'HOURLY'
});
if (Number(booking.amountCents) > quote.totalCents) {
throw new ThirdPartyError('DIRECT_BOOKING_AMOUNT_MISMATCH');
}
const order = await this.pricing.reserve({
tenantId: input.tenantId,
userId: input.userId,
roomId: String(booking.roomId),
startAt: booking.startsAt,
endAt: booking.endsAt,
pricingMode: 'HOURLY',
adjustment: { discountCents: quote.totalCents - Number(booking.amountCents) }
});
try {
await this.transaction(async (connection) => {
const [claim] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_direct_bookings
SET status = 'CLAIMED', order_id = ?, claimed_user_id = ?,
claimed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = 'READY_TO_CLAIM'`,
[order.orderId, input.userId, input.tenantId, input.bookingId]
);
if (claim.affectedRows !== 1) throw new ThirdPartyError('DIRECT_BOOKING_CLAIM_CONFLICT');
await this.insertSuccessfulPayment(connection, {
tenantId: input.tenantId,
orderId: order.orderId,
storeId: order.storeId,
amountCents: order.quote.totalCents,
providerReference: `BOOKING-${input.bookingId}`
});
});
} catch (error) {
await this.releaseClaimOrder(input.tenantId, order.orderId);
throw error;
}
return { bookingId: input.bookingId, orderId: order.orderId, idempotent: false };
}
async listRecords(input: {
tenantId: string;
access: AccessProfile;
provider?: ThirdPartyProvider;
status?: string;
}) {
const storeFilter = input.access.capabilities.includes('tenant.manage')
|| input.access.roles.includes('PLATFORM_ADMIN')
? null : input.access.storeIds;
if (storeFilter && storeFilter.length === 0) {
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
const params: string[] = [input.tenantId];
const filters = ['tenant_id = ?'];
if (input.provider) {
filters.push('provider = ?');
params.push(input.provider);
}
if (input.status) {
filters.push('status = ?');
params.push(input.status);
}
if (storeFilter) {
filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
params.push(...storeFilter);
}
const [bookings] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, external_booking_no AS externalBookingNo,
store_id AS storeId, room_id AS roomId, starts_at AS startsAt,
ends_at AS endsAt, amount_cents AS amountCents, status,
order_id AS orderId, failure_code AS failureCode, created_at AS createdAt
FROM qipai_direct_bookings
WHERE ${filters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
params
);
const [redemptions] = await this.pool.execute<RowDataPacket[]>(
`SELECT r.id, v.provider, v.voucher_masked AS voucherMasked,
r.order_id AS orderId, r.store_id AS storeId, r.redemption_mode AS mode,
r.status, r.failure_code AS failureCode, r.created_at AS createdAt
FROM qipai_group_redemptions r
INNER JOIN qipai_group_vouchers v
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
WHERE r.tenant_id = ?
${storeFilter ? `AND r.store_id IN (${storeFilter.map(() => '?').join(',')})` : ''}
ORDER BY r.id DESC LIMIT 100`,
storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId]
);
return { bookings, redemptions };
}
async saveConfig(input: {
tenantId: string;
actorId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
storeId: string | null;
mode: ThirdPartyMode;
enabled: boolean;
credentialRef: string;
settings: Record<string, unknown>;
}) {
if (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
}
if (input.credentialRef && !/^env:[A-Z0-9_:-]+$/.test(input.credentialRef)) {
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_REF_INVALID');
}
if (input.storeId) {
const [stores] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, input.storeId]
);
if (!stores[0]) throw new ThirdPartyError('STORE_NOT_FOUND');
}
const [existing] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_third_party_configs
WHERE tenant_id = ? AND provider = ? AND store_id <=> ? LIMIT 1`,
[input.tenantId, input.provider, input.storeId]
);
if (existing[0]) {
await this.pool.execute(
`UPDATE qipai_third_party_configs
SET mode = ?, enabled = ?, credential_ref = ?, settings = CAST(? AS JSON)
WHERE tenant_id = ? AND id = ?`,
[input.mode, input.enabled, input.credentialRef, JSON.stringify(input.settings),
input.tenantId, existing[0].id]
);
return { configId: String(existing[0].id), created: false };
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_third_party_configs
(tenant_id, store_id, provider, mode, enabled, credential_ref, settings)
VALUES (?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
[input.tenantId, input.storeId, input.provider, input.mode, input.enabled,
input.credentialRef, JSON.stringify(input.settings)]
);
return { configId: String(result.insertId), created: true };
}
async saveMapping(input: {
tenantId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
resourceType: 'STORE' | 'ROOM';
externalRef: string;
localResourceId: string;
}) {
if (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
}
const table = input.resourceType === 'STORE' ? 'qipai_stores' : 'qipai_rooms';
const [resources] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM ${table}
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, input.localResourceId]
);
if (!resources[0]) throw new ThirdPartyError(`${input.resourceType}_NOT_FOUND`);
await this.pool.execute(
`INSERT INTO qipai_third_party_mappings
(tenant_id, provider, resource_type, external_ref, local_resource_id)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE local_resource_id = VALUES(local_resource_id)`,
[input.tenantId, input.provider, input.resourceType,
input.externalRef, input.localResourceId]
);
return { mapped: true };
}
private async recordRedemption(input: {
tenantId: string;
actorId: string;
provider: ThirdPartyProvider;
voucherCode: string;
orderId: string;
clientRequestId: string;
mode: ThirdPartyMode;
amountCents: number;
expectedAmountCents: number;
externalProductId: string;
status: 'SUCCEEDED' | 'FAILED' | 'PENDING';
failureCode: string;
response: Record<string, unknown>;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(input.tenantId, input.orderId, connection, true);
const [existing] = await connection.execute<RedemptionRow[]>(
`SELECT r.id, r.order_id AS orderId, r.status,
v.voucher_masked AS voucherMasked
FROM qipai_group_redemptions r
INNER JOIN qipai_group_vouchers v
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
WHERE r.tenant_id = ? AND r.client_request_id = ? LIMIT 1`,
[input.tenantId, input.clientRequestId]
);
if (existing[0]) {
if (String(existing[0].orderId) !== input.orderId) {
throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT');
}
return { ...existing[0], idempotent: true };
}
const voucherHash = hashValue(input.voucherCode);
const [voucherResult] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_group_vouchers
(tenant_id, provider, voucher_hash, voucher_masked,
external_product_id, status, amount_cents, redeemed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL))
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[input.tenantId, input.provider, voucherHash, maskVoucher(input.voucherCode),
input.externalProductId, input.status === 'SUCCEEDED' ? 'REDEEMED' : input.status,
input.amountCents, input.status]
);
const voucherId = String(voucherResult.insertId);
const [used] = await connection.execute<RowDataPacket[]>(
`SELECT order_id AS orderId FROM qipai_group_redemptions
WHERE tenant_id = ? AND voucher_id = ? LIMIT 1`,
[input.tenantId, voucherId]
);
if (used[0]) throw new ThirdPartyError('VOUCHER_ALREADY_REDEEMED');
const [redemption] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_group_redemptions
(tenant_id, voucher_id, order_id, store_id, actor_id,
redemption_mode, client_request_id, status, failure_code,
provider_response, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON),
IF(? IN ('SUCCEEDED', 'FAILED'), UTC_TIMESTAMP(3), NULL))`,
[input.tenantId, voucherId, input.orderId, order.storeId, input.actorId,
input.mode, input.clientRequestId, input.status, input.failureCode,
JSON.stringify(input.response), input.status]
);
if (input.status === 'SUCCEEDED') {
if (input.amountCents !== input.expectedAmountCents) {
throw new ThirdPartyError('VOUCHER_AMOUNT_MISMATCH');
}
await this.insertSuccessfulPayment(connection, {
tenantId: input.tenantId,
orderId: input.orderId,
storeId: order.storeId,
amountCents: input.amountCents,
providerReference: `VOUCHER-${voucherId}`
});
}
return {
redemptionId: String(redemption.insertId),
voucherMasked: maskVoucher(input.voucherCode),
status: input.status,
failureCode: input.failureCode,
idempotent: false
};
});
}
private async findRedemptionByRequest(
tenantId: string,
clientRequestId: string,
orderId: string
) {
const [rows] = await this.pool.execute<RedemptionRow[]>(
`SELECT r.id, r.order_id AS orderId, r.status,
v.voucher_masked AS voucherMasked
FROM qipai_group_redemptions r
INNER JOIN qipai_group_vouchers v
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
WHERE r.tenant_id = ? AND r.client_request_id = ? LIMIT 1`,
[tenantId, clientRequestId]
);
if (!rows[0]) return null;
if (String(rows[0].orderId) !== orderId) {
throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT');
}
return rows[0];
}
private async insertSuccessfulPayment(
connection: PoolConnection,
input: {
tenantId: string;
orderId: string;
storeId: string;
amountCents: number;
providerReference: string;
}
) {
const paymentNo = `GRP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
const [payment] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_payments
(tenant_id, order_id, store_id, payment_no, channel, provider,
client_request_id, status, amount_cents, provider_payment_id, paid_at)
VALUES (?, ?, ?, ?, 'GROUP_BUY', 'GROUP_BUY', ?, 'SUCCEEDED', ?, ?,
UTC_TIMESTAMP(3))`,
[input.tenantId, input.orderId, input.storeId, paymentNo,
input.providerReference, input.amountCents, input.providerReference]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = paid_amount_cents + ?,
status = IF(paid_amount_cents + ? >= total_amount_cents, 'PAID', status),
status_version = IF(paid_amount_cents + ? >= total_amount_cents,
status_version + 1, status_version),
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[input.amountCents, input.amountCents, input.amountCents,
input.tenantId, input.orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
[input.tenantId, input.orderId]
);
await connection.execute(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
NULL, 'PAYMENT', 'Verified group-buy payment', ?,
JSON_OBJECT('paymentId', ?, 'providerReference', ?))`,
[input.tenantId, input.orderId, `group-payment-${payment.insertId}`,
payment.insertId, input.providerReference]
);
}
private async resolveConfig(
tenantId: string, storeId: string | null, provider: ThirdPartyProvider
) {
const [rows] = await this.pool.execute<ConfigRow[]>(
`SELECT id, mode, credential_ref AS credentialRef, settings
FROM qipai_third_party_configs
WHERE tenant_id = ? AND provider = ? AND enabled = 1
AND (store_id IS NULL OR store_id = ?)
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
[tenantId, provider, storeId]
);
if (!rows[0]) throw new ThirdPartyError('THIRD_PARTY_CONFIG_NOT_FOUND');
return {
id: String(rows[0].id),
mode: rows[0].mode,
credentialRef: rows[0].credentialRef,
settings: typeof rows[0].settings === 'string'
? JSON.parse(rows[0].settings) : rows[0].settings
};
}
private resolveCredential(reference: string) {
if (!reference) return undefined;
return this.credentials.get(reference)
?? this.credentials.get(reference.replace(/^env:/, ''));
}
private async resolveMappings(
tenantId: string,
provider: ThirdPartyProvider,
externalStoreRef: string,
externalRoomRef: string
) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT resource_type AS resourceType, local_resource_id AS localResourceId
FROM qipai_third_party_mappings
WHERE tenant_id = ? AND provider = ?
AND ((resource_type = 'STORE' AND external_ref = ?)
OR (resource_type = 'ROOM' AND external_ref = ?))`,
[tenantId, provider, externalStoreRef, externalRoomRef]
);
const storeId = rows.find((row) => row.resourceType === 'STORE')?.localResourceId;
const roomId = rows.find((row) => row.resourceType === 'ROOM')?.localResourceId;
if (storeId && roomId) {
const [valid] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_rooms
WHERE tenant_id = ? AND id = ? AND store_id = ? AND deleted_at IS NULL`,
[tenantId, roomId, storeId]
);
if (!valid[0]) return { storeId: null, roomId: null };
}
return {
storeId: storeId ? String(storeId) : null,
roomId: roomId ? String(roomId) : null
};
}
private async loadOwnedOrder(tenantId: string, userId: string, orderId: string) {
const [access] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
[tenantId, orderId, userId]
);
if (!access[0]) throw new ThirdPartyError('ORDER_ACCESS_FORBIDDEN');
return this.loadOrder(tenantId, orderId);
}
private async loadOrder(
tenantId: string,
orderId: string,
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool,
lock = false
) {
const [rows] = await connection.execute<OrderRow[]>(
`SELECT id, order_no AS orderNo, store_id AS storeId, status,
total_amount_cents AS totalAmountCents,
paid_amount_cents AS paidAmountCents
FROM qipai_orders
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, orderId]
);
if (!rows[0]) throw new ThirdPartyError('ORDER_NOT_FOUND');
if (rows[0].status !== 'PENDING_PAYMENT') {
throw new ThirdPartyError('ORDER_NOT_PAYABLE');
}
return rows[0];
}
private async releaseClaimOrder(tenantId: string, orderId: string) {
await this.pool.execute(
`UPDATE qipai_room_reservations
SET status = 'RELEASED', released_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND order_id = ?`,
[tenantId, orderId]
);
await this.pool.execute(
`UPDATE qipai_orders SET status = 'CLOSED'
WHERE tenant_id = ? AND id = ? AND status = 'PENDING_PAYMENT'`,
[tenantId, orderId]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
function assertStoreAccess(access: AccessProfile, storeId: string) {
if (access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| (access.capabilities.includes('store.operation.write')
&& access.storeIds.includes(String(storeId)))) return;
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
function hashValue(value: string) {
return createHash('sha256').update(value).digest('hex');
}
function maskVoucher(value: string) {
if (value.length <= 4) return '*'.repeat(value.length);
return `${value.slice(0, 2)}${'*'.repeat(Math.min(8, value.length - 4))}${value.slice(-2)}`;
}
function sanitizePayload(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.voucherCode;
delete copy.phone;
delete copy.openid;
delete copy.token;
return copy;
}
function normalizeBooking(row: BookingRow) {
return {
bookingId: String(row.id),
status: row.status,
orderId: row.orderId ? String(row.orderId) : null,
mapped: Boolean(row.storeId && row.roomId)
};
}
+16 -1
View File
@@ -57,6 +57,9 @@ const paymentVerifySql = read('database/migrations/2026062015_m05a_payment_domai
const wechatRefundUpSql = read('database/migrations/2026062216_m05b_wechat_refunds.up.sql');
const wechatRefundDownSql = read('database/migrations/2026062216_m05b_wechat_refunds.down.sql');
const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_refunds.verify.sql');
const thirdPartyUpSql = read('database/migrations/2026062217_m05c_third_party.up.sql');
const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql');
const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_party.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -252,5 +255,17 @@ assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_client_request/);
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_callback/);
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_reconciliation_request/);
assert.doesNotMatch(wechatRefundUpSql, /private_key|api_v3_key|certificate_pem/i);
for (const table of [
'qipai_third_party_configs', 'qipai_third_party_mappings',
'qipai_group_vouchers', 'qipai_group_redemptions', 'qipai_direct_bookings'
]) {
assert.match(thirdPartyUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(thirdPartyDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(thirdPartyVerifySql, new RegExp(`'${table}'`));
}
assert.match(thirdPartyUpSql, /voucher_hash CHAR\(64\)/);
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_group_redemption_voucher/);
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/);
assert.doesNotMatch(thirdPartyUpSql, /voucher_code|api_token|webhook_secret/i);
console.log('PASS: M01-B through M05-B migration contracts are present.');
console.log('PASS: M01-B through M05-C migration contracts are present.');
+2 -1
View File
@@ -27,7 +27,8 @@ assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/);
assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/);
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql$/);
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/);
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -30,6 +31,8 @@ import {
import {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -42,6 +45,9 @@ const expectedTables = [
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_devices',
'qipai_direct_bookings',
'qipai_group_redemptions',
'qipai_group_vouchers',
'qipai_holiday_calendar',
'qipai_legacy_table_mappings',
'qipai_media_assets',
@@ -77,6 +83,8 @@ const expectedTables = [
'qipai_tenant_apps',
'qipai_tenant_configs',
'qipai_tenants',
'qipai_third_party_configs',
'qipai_third_party_mappings',
'qipai_user_admin_profiles',
'qipai_user_identities',
'qipai_user_roles',
@@ -102,12 +110,12 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216']
'2026062015', '2026062216', '2026062217']
);
return rows;
}
@@ -1152,6 +1160,186 @@ async function assertPaymentDomain(pool, context) {
assert.equal(configRows.some((row) => /secret|private.key/i.test(row.settings)), false);
}
async function assertThirdPartyDomain(pool, context) {
const [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [roomRows] = await pool.query(
`SELECT store_id AS storeId, id AS roomId FROM qipai_rooms
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
[context.tenantId]
);
const customerId = String(customerRows[0].id);
const storeId = String(roomRows[0].storeId);
const roomId = String(roomRows[0].roomId);
await pool.query(
`INSERT INTO qipai_third_party_configs
(tenant_id, store_id, provider, mode, credential_ref, settings)
VALUES (?, NULL, 'MEITUAN', 'MOCK', 'env:TP_TEST', JSON_OBJECT())`,
[context.tenantId]
);
const pricing = new PricingRepository(pool);
const service = new ThirdPartyService(
pool,
pricing,
new ThirdPartyClient({
async request() {
throw new Error('Live M05-C test must not call an external provider.');
}
}),
new Map([['TP_TEST', { webhookSecret: 'm05c-webhook-secret' }]])
);
const startAt = new Date(Date.now() + 27 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await pricing.reserve({
tenantId: context.tenantId,
userId: customerId,
roomId,
startAt,
endAt,
pricingMode: 'HOURLY'
});
const redeemed = await service.redeemVoucher({
tenantId: context.tenantId,
userId: customerId,
provider: 'MEITUAN',
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
orderId: order.orderId,
clientRequestId: 'm05c-redeem-request-001'
});
assert.equal(redeemed.status, 'SUCCEEDED');
const duplicate = await service.redeemVoucher({
tenantId: context.tenantId,
userId: customerId,
provider: 'MEITUAN',
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
orderId: order.orderId,
clientRequestId: 'm05c-redeem-request-001'
});
assert.equal(duplicate.idempotent, true);
const [voucherRows] = await pool.query(
`SELECT voucher_hash AS voucherHash, voucher_masked AS voucherMasked,
(SELECT COUNT(*) FROM qipai_group_redemptions r
WHERE r.voucher_id = v.id) AS redemptionCount
FROM qipai_group_vouchers v WHERE tenant_id = ?`,
[context.tenantId]
);
assert.match(voucherRows[0].voucherHash, /^[a-f0-9]{64}$/);
assert.equal(voucherRows[0].voucherMasked.includes('SENSITIVE'), false);
assert.equal(Number(voucherRows[0].redemptionCount), 1);
await pool.query(
`INSERT INTO qipai_third_party_mappings
(tenant_id, provider, resource_type, external_ref, local_resource_id)
VALUES (?, 'MEITUAN', 'STORE', 'external-store-001', ?),
(?, 'MEITUAN', 'ROOM', 'external-room-001', ?)`,
[context.tenantId, storeId, context.tenantId, roomId]
);
const bookingStart = new Date(Date.now() + 29 * 86400000);
bookingStart.setUTCHours(2, 0, 0, 0);
const bookingEnd = new Date(bookingStart.getTime() + 2 * 3600000);
const quote = await pricing.quote({
tenantId: context.tenantId,
roomId,
startAt: bookingStart,
endAt: bookingEnd,
pricingMode: 'HOURLY'
});
const payload = {
eventId: 'm05c-booking-event-001',
externalBookingNo: 'm05c-booking-001',
externalStoreRef: 'external-store-001',
externalRoomRef: 'external-room-001',
customerRef: 'private-customer-ref',
startsAt: bookingStart.toISOString(),
endsAt: bookingEnd.toISOString(),
amountCents: quote.totalCents
};
const rawBody = JSON.stringify(payload);
const signature = createHmac('sha256', 'm05c-webhook-secret')
.update(rawBody).digest('hex');
const received = await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature,
rawBody,
eventId: payload.eventId,
externalBookingNo: payload.externalBookingNo,
externalStoreRef: payload.externalStoreRef,
externalRoomRef: payload.externalRoomRef,
customerRef: payload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: payload.amountCents,
payload
});
assert.equal(received.status, 'READY_TO_CLAIM');
assert.equal((await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature,
rawBody,
eventId: payload.eventId,
externalBookingNo: payload.externalBookingNo,
externalStoreRef: payload.externalStoreRef,
externalRoomRef: payload.externalRoomRef,
customerRef: payload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: payload.amountCents,
payload
})).idempotent, true);
const claimed = await service.claimDirectBooking({
tenantId: context.tenantId,
userId: customerId,
bookingId: received.bookingId
});
const [claimedRows] = await pool.query(
`SELECT b.status, b.customer_ref_hash AS customerRefHash,
o.status AS orderStatus, o.paid_amount_cents AS paidAmountCents,
o.total_amount_cents AS totalAmountCents
FROM qipai_direct_bookings b
INNER JOIN qipai_orders o ON o.id = b.order_id AND o.tenant_id = b.tenant_id
WHERE b.id = ?`,
[received.bookingId]
);
assert.equal(claimedRows[0].status, 'CLAIMED');
assert.match(claimedRows[0].customerRefHash, /^[a-f0-9]{64}$/);
assert.equal(claimedRows[0].orderStatus, 'PAID');
assert.equal(claimedRows[0].paidAmountCents, claimedRows[0].totalAmountCents);
assert.match(claimed.orderId, /^[1-9]\d*$/);
const unmappedPayload = {
...payload,
eventId: 'm05c-booking-event-unmapped',
externalBookingNo: 'm05c-booking-unmapped',
externalRoomRef: 'missing-room'
};
const unmappedBody = JSON.stringify(unmappedPayload);
const unmapped = await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature: createHmac('sha256', 'm05c-webhook-secret')
.update(unmappedBody).digest('hex'),
rawBody: unmappedBody,
eventId: unmappedPayload.eventId,
externalBookingNo: unmappedPayload.externalBookingNo,
externalStoreRef: unmappedPayload.externalStoreRef,
externalRoomRef: unmappedPayload.externalRoomRef,
customerRef: unmappedPayload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: unmappedPayload.amountCents,
payload: unmappedPayload
});
assert.equal(unmapped.status, 'PENDING_MAPPING');
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -1255,7 +1443,8 @@ try {
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1270,13 +1459,14 @@ try {
await assertOrderAdjustments(pool, loginContext);
await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext);
await assertThirdPartyDomain(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M05-B tables.');
console.log('PASS: down removed all M01-B through M05-C tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1297,7 +1487,8 @@ try {
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1380,6 +1571,11 @@ try {
'test adapter explicit non-production gate',
'Wechat refund idempotency and callback indexes',
'Wechat reconciliation request history'
,
'group voucher hash-only storage and single redemption',
'third-party booking webhook idempotency',
'mapped booking claim creates a paid order',
'unmapped booking enters manual queue'
]
}, null, 2));
} finally {
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import {
ThirdPartyClient, ThirdPartyError
} from '../dist/third-party/third-party-client.js';
const client = new ThirdPartyClient({
async request() {
return {
status: 200,
body: JSON.stringify({
accepted: true,
amountCents: 3600,
productId: 'product-sanitized',
voucherCode: 'must-be-redacted'
})
};
}
});
const body = JSON.stringify({ eventId: 'event-001', amountCents: 3600 });
const signature = createHmac('sha256', 'test-webhook-secret')
.update(body).digest('hex');
client.verifyWebhook('test-webhook-secret', body, signature);
assert.throws(
() => client.verifyWebhook('test-webhook-secret', `${body} `, signature),
(error) => error instanceof ThirdPartyError
&& error.code === 'THIRD_PARTY_SIGNATURE_INVALID'
);
assert.equal((await client.redeemVoucher({
mode: 'MOCK',
provider: 'MEITUAN',
voucherCode: 'MOCK-ACCEPT',
orderNo: 'QP001',
expectedAmountCents: 3600,
settings: {}
})).status, 'SUCCEEDED');
assert.equal((await client.redeemVoucher({
mode: 'MOCK',
provider: 'DOUYIN',
voucherCode: 'FAIL-REJECT',
orderNo: 'QP002',
expectedAmountCents: 3600,
settings: {}
})).status, 'FAILED');
const apiResult = await client.redeemVoucher({
mode: 'API',
provider: 'MEITUAN',
voucherCode: 'API-ACCEPT',
orderNo: 'QP003',
expectedAmountCents: 3600,
settings: { redeemEndpoint: 'https://partner.example.test/redeem' },
credential: { apiToken: 'test-token' }
});
assert.equal(apiResult.amountCents, 3600);
assert.equal('voucherCode' in apiResult.response, false);
const secret = 'third-party-route-secret-with-32-characters';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let redeemInput;
let notifyInput;
const routeApp = await buildApp({
thirdParty: {
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() {
return {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage'],
storeIds: []
};
}
},
service: {
async redeemVoucher(input) {
redeemInput = input;
return { redemptionId: '51', status: 'SUCCEEDED' };
},
async redeemVoucherManually() {
return { redemptionId: '52', status: 'SUCCEEDED' };
},
async receiveDirectBooking(input) {
notifyInput = input;
return { bookingId: '61', status: 'PENDING_MAPPING' };
},
async claimDirectBooking() {
return { bookingId: '61', orderId: '71' };
},
async listRecords() {
return { bookings: [], redemptions: [] };
},
async saveConfig() {
return { configId: '81', created: true };
},
async saveMapping() {
return { mapped: true };
}
}
}
});
const redeemed = await routeApp.inject({
method: 'POST',
url: '/app-api/group-vouchers/redeem',
headers: { authorization: `Bearer ${token}` },
payload: {
provider: 'MEITUAN',
voucherCode: '1234567890',
orderId: '31',
clientRequestId: 'redeem-request-001'
}
});
assert.equal(redeemed.statusCode, 200);
assert.equal(redeemInput.userId, '21');
const bookingPayload = {
eventId: 'event-001',
externalBookingNo: 'booking-001',
externalStoreRef: 'store-external',
externalRoomRef: 'room-external',
customerRef: 'customer-private',
startsAt: new Date(Date.now() + 86400000).toISOString(),
endsAt: new Date(Date.now() + 90000000).toISOString(),
amountCents: 3600
};
const notified = await routeApp.inject({
method: 'POST',
url: '/app-api/third-party/MEITUAN/tenants/7/bookings/notify',
headers: { 'x-third-party-signature': 'signature-sanitized' },
payload: bookingPayload
});
assert.equal(notified.statusCode, 200);
assert.equal(notifyInput.tenantId, '7');
assert.equal(notifyInput.provider, 'MEITUAN');
assert.equal(notifyInput.rawBody, JSON.stringify(bookingPayload));
await routeApp.close();
console.log('PASS: M05-C adapters, webhook authentication and third-party routes.');