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