router.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { Router, type Request, type Response } from 'express';
  2. import { randomUUID } from 'node:crypto';
  3. import { ZodError, z } from 'zod';
  4. import { ApiError } from '../http/api-error.js';
  5. import { assertCloudAction, CLOUD_ROUTES, requireWorkspaceId } from './action-registry.js';
  6. const requestSchema = z.object({
  7. action: z.string().min(1).max(100),
  8. workspaceId: z.string().min(1).max(200).optional(),
  9. platform: z.string().min(1).max(30).optional(),
  10. payload: z.record(z.string(), z.unknown()).optional().default({}),
  11. idempotencyKey: z.string().min(8).max(200).optional(),
  12. }).strict();
  13. type Dispatcher = (request: Request, response: Response, actionPath: string, method: string, body: Record<string, unknown>, requestId: string) => Promise<void>;
  14. type SpecialActionHandler = (request: Request, response: Response, action: string, body: Record<string, unknown>, requestId: string) => Promise<boolean>;
  15. export function createCloudFunctionRouter(input: {
  16. defaultWorkspaceId: string;
  17. dispatch: Dispatcher;
  18. specialAction?: SpecialActionHandler;
  19. }): Router {
  20. const router = Router();
  21. router.get('/test', (_request, response) => response.json({ success: true, data: { service: 'saas-voc-cloud-functions' }, requestId: randomUUID() }));
  22. router.post('/', async (request, response) => {
  23. const requestId = randomUUID();
  24. try {
  25. const parsed = requestSchema.parse(unwrapManagedFunctionEnvelope(request.body));
  26. const action = assertCloudAction(parsed.action);
  27. const payload: Record<string, unknown> = { ...(parsed.payload as Record<string, unknown>), ...(parsed.platform ? { platform: parsed.platform } : {}) };
  28. const workspaceId = requireWorkspaceId(parsed.workspaceId ?? payload['workspaceId'], input.defaultWorkspaceId);
  29. // The authenticated workspace is authoritative; never let a nested
  30. // payload replace it after it has been validated.
  31. payload['workspaceId'] = workspaceId;
  32. const route = CLOUD_ROUTES[action];
  33. if (!route) {
  34. if (input.specialAction && await input.specialAction(request, response, action, { ...payload, workspaceId }, requestId)) return;
  35. throw new ApiError(501, 'cloud_action_not_implemented');
  36. }
  37. const actionPath = route.path(payload, workspaceId);
  38. const query = route.query?.(payload, workspaceId) ?? {};
  39. const queryString = new URLSearchParams(
  40. Object.entries(query).filter(([, value]) => value !== undefined && value !== null && value !== '')
  41. .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value)]),
  42. ).toString();
  43. const fullPath = queryString ? `${actionPath}?${queryString}` : actionPath;
  44. const body = { ...payload, workspaceId, ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}) };
  45. await input.dispatch(request, response, fullPath, route.method, body, requestId);
  46. } catch (error) {
  47. sendCloudError(response, requestId, error);
  48. }
  49. });
  50. return router;
  51. }
  52. export async function dispatchThroughRouter(
  53. businessRouter: Router,
  54. request: Request,
  55. response: Response,
  56. actionPath: string,
  57. method: string,
  58. body: Record<string, unknown>,
  59. requestId: string,
  60. ): Promise<void> {
  61. const originalUrl = request.url;
  62. const originalMethod = request.method;
  63. const originalBody = request.body;
  64. const originalJson = response.json.bind(response);
  65. const originalSend = response.send.bind(response);
  66. let settled = false;
  67. request.url = actionPath;
  68. request.method = method;
  69. request.body = body;
  70. await new Promise<void>((resolve, reject) => {
  71. const finish = (error?: unknown) => {
  72. if (settled) return;
  73. settled = true;
  74. if (error) reject(error);
  75. else resolve();
  76. };
  77. response.json = ((value: unknown) => {
  78. response.json = originalJson;
  79. if (response.statusCode >= 400 || (value && typeof value === 'object' && 'error' in value)) {
  80. finish(new ApiError(response.statusCode >= 400 ? response.statusCode : 500, extractErrorCode(value)));
  81. } else {
  82. const output = isCloudResponse(value) ? value : { success: true, data: value, requestId };
  83. response.status(200).json(output);
  84. finish();
  85. }
  86. return response;
  87. }) as Response['json'];
  88. response.send = ((value: unknown) => {
  89. response.send = originalSend;
  90. let parsed: unknown = value;
  91. if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
  92. try { parsed = JSON.parse(Buffer.from(value).toString('utf8')); } catch { parsed = { value: '[non-json response]' }; }
  93. } else if (typeof value === 'string') {
  94. try { parsed = JSON.parse(value); } catch { parsed = { value: '[non-json response]' }; }
  95. }
  96. if (response.statusCode >= 400 || (parsed && typeof parsed === 'object' && 'error' in parsed)) {
  97. finish(new ApiError(response.statusCode >= 400 ? response.statusCode : 502, extractErrorCode(parsed)));
  98. } else {
  99. const output = isCloudResponse(parsed) ? parsed : { success: true, data: parsed, requestId };
  100. response.status(200).json(output);
  101. finish();
  102. }
  103. return response;
  104. }) as Response['send'];
  105. (businessRouter as unknown as { handle: (request: Request, response: Response, next: (error?: unknown) => void) => void })
  106. .handle(request, response, (error?: unknown) => finish(error ?? new ApiError(404, 'not_found')));
  107. }).finally(() => {
  108. request.url = originalUrl;
  109. request.method = originalMethod;
  110. request.body = originalBody;
  111. response.json = originalJson;
  112. response.send = originalSend;
  113. });
  114. }
  115. /**
  116. * FmodeCloud.function posts `{ path, params, _ApplicationId, _InstallationId }`.
  117. * The hosted evaluator unwraps `params` before invoking the Function, so the
  118. * local adapter mirrors that transport step for the same browser client.
  119. */
  120. function unwrapManagedFunctionEnvelope(body: unknown): unknown {
  121. if (!body || typeof body !== 'object' || Array.isArray(body)) return body;
  122. const value = body as Record<string, unknown>;
  123. if (value['path'] !== '/saas-voc-gateway') return body;
  124. const params = value['params'];
  125. return params && typeof params === 'object' && !Array.isArray(params) ? params : body;
  126. }
  127. function extractErrorCode(value: unknown): string {
  128. if (value && typeof value === 'object' && 'error' in value) {
  129. const error = (value as Record<string, unknown>).error;
  130. if (typeof error === 'string') return error;
  131. }
  132. return 'cloud_action_failed';
  133. }
  134. function isCloudResponse(value: unknown): value is { success: boolean; data: unknown; requestId: string } {
  135. return Boolean(value && typeof value === 'object' && 'success' in value && 'requestId' in value && 'data' in value);
  136. }
  137. function sendCloudError(response: Response, requestId: string, error: unknown): void {
  138. if (response.headersSent) return;
  139. const status = error instanceof ApiError ? error.status : error instanceof ZodError ? 400 : 500;
  140. const code = error instanceof ApiError
  141. ? error.code
  142. : error instanceof ZodError ? 'invalid_request' : 'internal_error';
  143. const details = error instanceof ZodError
  144. ? error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))
  145. : undefined;
  146. if (status >= 500) console.error('[cloud-function]', requestId, code, error instanceof Error ? error.message : error);
  147. response.status(status).json({ success: false, code, message: publicMessage(code), requestId, ...(details ? { details } : {}) });
  148. }
  149. function publicMessage(code: string): string {
  150. const messages: Record<string, string> = {
  151. cloud_action_not_allowed: '不支持的业务操作',
  152. cloud_action_not_implemented: '业务操作暂未配置',
  153. invalid_request: '请求参数错误',
  154. internal_error: '服务暂不可用,请稍后重试',
  155. };
  156. return messages[code] ?? code;
  157. }