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