import { Router, type Request, type Response } from 'express'; import { randomUUID } from 'node:crypto'; import { ZodError, z } from 'zod'; import { ApiError } from '../http/api-error.js'; import { assertCloudAction, CLOUD_ROUTES, requireWorkspaceId } from './action-registry.js'; const requestSchema = z.object({ action: z.string().min(1).max(100), workspaceId: z.string().min(1).max(200).optional(), platform: z.string().min(1).max(30).optional(), payload: z.record(z.string(), z.unknown()).optional().default({}), idempotencyKey: z.string().min(8).max(200).optional(), }).strict(); type Dispatcher = (request: Request, response: Response, actionPath: string, method: string, body: Record, requestId: string) => Promise; type SpecialActionHandler = (request: Request, response: Response, action: string, body: Record, requestId: string) => Promise; export function createCloudFunctionRouter(input: { defaultWorkspaceId: string; dispatch: Dispatcher; specialAction?: SpecialActionHandler; }): Router { const router = Router(); router.get('/test', (_request, response) => response.json({ success: true, data: { service: 'saas-voc-cloud-functions' }, requestId: randomUUID() })); router.post('/', async (request, response) => { const requestId = randomUUID(); try { const parsed = requestSchema.parse(unwrapManagedFunctionEnvelope(request.body)); const action = assertCloudAction(parsed.action); const payload: Record = { ...(parsed.payload as Record), ...(parsed.platform ? { platform: parsed.platform } : {}) }; const workspaceId = requireWorkspaceId(parsed.workspaceId ?? payload['workspaceId'], input.defaultWorkspaceId); // The authenticated workspace is authoritative; never let a nested // payload replace it after it has been validated. payload['workspaceId'] = workspaceId; const route = CLOUD_ROUTES[action]; if (!route) { if (input.specialAction && await input.specialAction(request, response, action, { ...payload, workspaceId }, requestId)) return; throw new ApiError(501, 'cloud_action_not_implemented'); } const actionPath = route.path(payload, workspaceId); const query = route.query?.(payload, workspaceId) ?? {}; const queryString = new URLSearchParams( Object.entries(query).filter(([, value]) => value !== undefined && value !== null && value !== '') .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value)]), ).toString(); const fullPath = queryString ? `${actionPath}?${queryString}` : actionPath; const body = { ...payload, workspaceId, ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}) }; await input.dispatch(request, response, fullPath, route.method, body, requestId); } catch (error) { sendCloudError(response, requestId, error); } }); return router; } export async function dispatchThroughRouter( businessRouter: Router, request: Request, response: Response, actionPath: string, method: string, body: Record, requestId: string, ): Promise { const originalUrl = request.url; const originalMethod = request.method; const originalBody = request.body; const originalJson = response.json.bind(response); const originalSend = response.send.bind(response); let settled = false; request.url = actionPath; request.method = method; request.body = body; await new Promise((resolve, reject) => { const finish = (error?: unknown) => { if (settled) return; settled = true; if (error) reject(error); else resolve(); }; response.json = ((value: unknown) => { response.json = originalJson; if (response.statusCode >= 400 || (value && typeof value === 'object' && 'error' in value)) { finish(new ApiError(response.statusCode >= 400 ? response.statusCode : 500, extractErrorCode(value))); } else { const output = isCloudResponse(value) ? value : { success: true, data: value, requestId }; response.status(200).json(output); finish(); } return response; }) as Response['json']; response.send = ((value: unknown) => { response.send = originalSend; let parsed: unknown = value; if (Buffer.isBuffer(value) || value instanceof Uint8Array) { try { parsed = JSON.parse(Buffer.from(value).toString('utf8')); } catch { parsed = { value: '[non-json response]' }; } } else if (typeof value === 'string') { try { parsed = JSON.parse(value); } catch { parsed = { value: '[non-json response]' }; } } if (response.statusCode >= 400 || (parsed && typeof parsed === 'object' && 'error' in parsed)) { finish(new ApiError(response.statusCode >= 400 ? response.statusCode : 502, extractErrorCode(parsed))); } else { const output = isCloudResponse(parsed) ? parsed : { success: true, data: parsed, requestId }; response.status(200).json(output); finish(); } return response; }) as Response['send']; (businessRouter as unknown as { handle: (request: Request, response: Response, next: (error?: unknown) => void) => void }) .handle(request, response, (error?: unknown) => finish(error ?? new ApiError(404, 'not_found'))); }).finally(() => { request.url = originalUrl; request.method = originalMethod; request.body = originalBody; response.json = originalJson; response.send = originalSend; }); } /** * FmodeCloud.function posts `{ path, params, _ApplicationId, _InstallationId }`. * The hosted evaluator unwraps `params` before invoking the Function, so the * local adapter mirrors that transport step for the same browser client. */ function unwrapManagedFunctionEnvelope(body: unknown): unknown { if (!body || typeof body !== 'object' || Array.isArray(body)) return body; const value = body as Record; if (value['path'] !== '/saas-voc-gateway') return body; const params = value['params']; return params && typeof params === 'object' && !Array.isArray(params) ? params : body; } function extractErrorCode(value: unknown): string { if (value && typeof value === 'object' && 'error' in value) { const error = (value as Record).error; if (typeof error === 'string') return error; } return 'cloud_action_failed'; } function isCloudResponse(value: unknown): value is { success: boolean; data: unknown; requestId: string } { return Boolean(value && typeof value === 'object' && 'success' in value && 'requestId' in value && 'data' in value); } function sendCloudError(response: Response, requestId: string, error: unknown): void { if (response.headersSent) return; const status = error instanceof ApiError ? error.status : error instanceof ZodError ? 400 : 500; const code = error instanceof ApiError ? error.code : error instanceof ZodError ? 'invalid_request' : 'internal_error'; const details = error instanceof ZodError ? error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) : undefined; if (status >= 500) console.error('[cloud-function]', requestId, code, error instanceof Error ? error.message : error); response.status(status).json({ success: false, code, message: publicMessage(code), requestId, ...(details ? { details } : {}) }); } function publicMessage(code: string): string { const messages: Record = { cloud_action_not_allowed: '不支持的业务操作', cloud_action_not_implemented: '业务操作暂未配置', invalid_request: '请求参数错误', internal_error: '服务暂不可用,请稍后重试', }; return messages[code] ?? code; }