feat(M08-D): 补团购与第三方平台运营

This commit is contained in:
Codex
2026-08-10 12:10:15 +08:00
parent e8bd176914
commit 7b978089d5
9 changed files with 493 additions and 50 deletions
+59 -49
View File
@@ -39,6 +39,7 @@ const recordsQuery = z.object({
status: z.string().min(1).max(32).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const setupQuery = z.object({ provider: providerSchema.optional() });
const configSchema = z.object({
provider: providerSchema,
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
@@ -83,56 +84,49 @@ export async function registerThirdPartyRoutes(
}));
});
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
}));
});
for (const path of [
'/admin-api/group-vouchers/redeem',
'/app-api/management/group-vouchers/redeem'
]) {
app.post(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
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.redeemVoucherAsManager({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
}
app.post('/app-api/management/group-vouchers/redeem', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
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.redeemVoucherAsManager({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/app-api/management/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
}));
});
for (const path of [
'/admin-api/group-vouchers/redeem-manual',
'/app-api/management/group-vouchers/redeem-manual'
]) {
app.post(path, 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',
@@ -192,6 +186,22 @@ export async function registerThirdPartyRoutes(
}));
});
app.get('/admin-api/third-party/setup', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = setupQuery.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.listSetup({
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined
}),
traceId: request.traceId
}));
});
app.get('/app-api/management/third-party/records', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = recordsQuery.safeParse(request.query);
+60
View File
@@ -345,6 +345,55 @@ export class ThirdPartyService {
return { bookings, redemptions };
}
async listSetup(input: {
tenantId: string;
access: AccessProfile;
provider?: ThirdPartyProvider;
}) {
if (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
}
const params = input.provider ? [input.tenantId, input.provider] : [input.tenantId];
const providerFilter = input.provider ? 'AND provider = ?' : '';
const [configs] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, store_id AS storeId, mode, enabled,
credential_ref AS credentialRef, settings, updated_at AS updatedAt
FROM qipai_third_party_configs
WHERE tenant_id = ? ${providerFilter}
ORDER BY provider, store_id IS NULL DESC, store_id, id`,
params
);
const [mappings] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, resource_type AS resourceType,
external_ref AS externalRef, local_resource_id AS localResourceId
FROM qipai_third_party_mappings
WHERE tenant_id = ? ${providerFilter}
ORDER BY provider, resource_type, id`,
params
);
return {
configs: configs.map((row) => ({
id: String(row.id),
provider: row.provider,
storeId: row.storeId === null ? null : String(row.storeId),
mode: row.mode,
enabled: Boolean(row.enabled),
credentialRef: String(row.credentialRef || ''),
credentialConfigured: Boolean(row.credentialRef),
settings: sanitizeSettings(row.settings),
updatedAt: row.updatedAt
})),
mappings: mappings.map((row) => ({
id: String(row.id),
provider: row.provider,
resourceType: row.resourceType,
externalRef: row.externalRef,
localResourceId: String(row.localResourceId)
}))
};
}
async saveConfig(input: {
tenantId: string;
actorId: string;
@@ -724,6 +773,17 @@ function sanitizePayload(value: Record<string, unknown>) {
return copy;
}
function sanitizeSettings(value: unknown): Record<string, unknown> {
let source: unknown = value;
if (typeof source === 'string') {
try { source = JSON.parse(source); } catch { return {}; }
}
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
return Object.fromEntries(Object.entries(source as Record<string, unknown>)
.filter(([key]) => !/(secret|token|password|private|api.?key|credential)/i.test(key))
.map(([key, item]) => [key, item]));
}
function normalizeBooking(row: BookingRow) {
return {
bookingId: String(row.id),
+29
View File
@@ -66,6 +66,7 @@ let managerRedeemInput;
let manualRedeemInput;
let recordsInput;
let notifyInput;
let setupInput;
const routeApp = await buildApp({
thirdParty: {
jwtSecret: secret,
@@ -116,6 +117,13 @@ const routeApp = await buildApp({
recordsInput = input;
return { bookings: [], redemptions: [] };
},
async listSetup(input) {
setupInput = input;
return {
configs: [{ id: '81', provider: 'MEITUAN', credentialConfigured: true, settings: {} }],
mappings: [{ id: '91', provider: 'MEITUAN', resourceType: 'STORE' }]
};
},
async saveConfig() {
return { configId: '81', created: true };
},
@@ -153,6 +161,18 @@ assert.equal(managerRedeemed.statusCode, 200);
assert.equal(managerRedeemInput.actorId, '21');
assert.equal(managerRedeemInput.provider, 'DOUYIN');
const adminRedeemed = await routeApp.inject({
method: 'POST',
url: '/admin-api/group-vouchers/redeem',
headers: { authorization: `Bearer ${token}` },
payload: {
provider: 'MEITUAN', voucherCode: '1122334455', orderId: '31',
clientRequestId: 'admin-redeem-request-001'
}
});
assert.equal(adminRedeemed.statusCode, 200);
assert.equal(managerRedeemInput.provider, 'MEITUAN');
const manualRedeemed = await routeApp.inject({
method: 'POST',
url: '/app-api/management/group-vouchers/redeem-manual',
@@ -177,6 +197,15 @@ assert.equal(recordsInput.storeId, '11');
assert.equal(recordsInput.provider, 'MEITUAN');
assert.equal(recordsInput.status, 'SUCCEEDED');
const setup = await routeApp.inject({
method: 'GET',
url: '/admin-api/third-party/setup?provider=MEITUAN',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(setup.statusCode, 200);
assert.equal(setup.json().data.configs[0].credentialConfigured, true);
assert.equal(setupInput.provider, 'MEITUAN');
const bookingPayload = {
eventId: 'event-001',
externalBookingNo: 'booking-001',