feat(M08-D): 补团购与第三方平台运营
This commit is contained in:
@@ -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
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user