routes.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876
  1. /**
  2. * Relay API 路由
  3. */
  4. import { Router, Request, Response } from 'express';
  5. import { getDb, Query, createObject, updateObject, type DbRow } from './lib/db.js';
  6. import {
  7. generateApiCredentials,
  8. generateKeyPair,
  9. generateRelaySecret,
  10. hashApiSecret,
  11. encryptWithPublicKey,
  12. verifyHmacSignature,
  13. verifyBearerToken,
  14. sha256Hash,
  15. } from './lib/crypto.js';
  16. import {
  17. getAdminKey,
  18. getUpstreamCallbackSecret,
  19. getEventTtlSeconds,
  20. getMaxPendingEventsPerTenant,
  21. getPollBatchSize,
  22. getPollWaitMs,
  23. getRelayPublicUrl,
  24. getRegisterRateLimitSeconds,
  25. getRegisterRateLimitPerIp,
  26. getRegisterRateLimitPerUser,
  27. } from './lib/config.js';
  28. import { verifyFmodeToken, type FmodeUser } from './lib/fmode-auth.js';
  29. // ============================================================
  30. // 类型定义
  31. // ============================================================
  32. interface Tenant extends DbRow {
  33. apiKey: string;
  34. apiSecretHash: string;
  35. publicKey: string;
  36. fmodeUserId: string | null;
  37. name: string | null;
  38. description: string | null;
  39. plan: string;
  40. status: string;
  41. maxDevices: number;
  42. dailyEventLimit: number;
  43. }
  44. interface TenantDevice extends DbRow {
  45. tenantId: string;
  46. guid: string;
  47. relaySecret: string;
  48. deviceName: string | null;
  49. lastOnlineAt: string | null;
  50. status: string;
  51. }
  52. interface WebhookRelayEvent extends DbRow {
  53. tenantId: string;
  54. deviceGuid: string;
  55. eventId: string;
  56. status: string;
  57. encryptedPayload: string;
  58. payloadHash: string;
  59. retryCount: number;
  60. deliveredAt: string | null;
  61. expiresAt: string;
  62. error: string | null;
  63. }
  64. interface UsageMetricRow extends DbRow {
  65. metricValue: number;
  66. }
  67. interface AuditLogRow extends DbRow {
  68. action: string;
  69. tenantId: string | null;
  70. fmodeUserId: string | null;
  71. ip: string | null;
  72. details: string | null;
  73. }
  74. export const relayRoutes = Router();
  75. // ============================================================
  76. // 中间件
  77. // ============================================================
  78. interface AuthenticatedRequest extends Request {
  79. tenant?: Tenant;
  80. }
  81. /** 管理员鉴权 */
  82. function adminAuth(req: Request, res: Response, next: () => void): void {
  83. const key = req.headers['x-admin-key'] as string | undefined;
  84. if (!key || !getAdminKey() || key !== getAdminKey()) {
  85. res.status(401).json({ error: 'Unauthorized' });
  86. return;
  87. }
  88. next();
  89. }
  90. /** 租户鉴权:Bearer apiSecret */
  91. async function tenantAuth(req: AuthenticatedRequest, res: Response, next: () => void): Promise<void> {
  92. const auth = req.headers.authorization;
  93. if (!auth) {
  94. res.status(401).json({ error: 'Missing Authorization' });
  95. return;
  96. }
  97. const match = auth.match(/^Bearer\s+(.+)$/i);
  98. if (!match) {
  99. res.status(401).json({ error: 'Invalid Authorization format' });
  100. return;
  101. }
  102. const apiSecret = match[1];
  103. const tenant = await findTenantByApiSecret(apiSecret);
  104. if (!tenant) {
  105. res.status(401).json({ error: 'Invalid credentials' });
  106. return;
  107. }
  108. if (tenant.status !== 'active') {
  109. res.status(403).json({ error: 'Tenant inactive' });
  110. return;
  111. }
  112. req.tenant = tenant;
  113. next();
  114. }
  115. // ============================================================
  116. // 限流(内存实现,单实例足够;如后续多实例可改 Redis)
  117. // ============================================================
  118. interface RateLimitBucket {
  119. count: number;
  120. resetAt: number;
  121. }
  122. const rateLimitMap = new Map<string, RateLimitBucket>();
  123. function checkRateLimit(key: string, windowMs: number, maxCount: number): boolean {
  124. const now = Date.now();
  125. const bucket = rateLimitMap.get(key);
  126. if (!bucket || now >= bucket.resetAt) {
  127. rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
  128. return true;
  129. }
  130. if (bucket.count >= maxCount) {
  131. return false;
  132. }
  133. bucket.count += 1;
  134. return true;
  135. }
  136. function getClientIp(req: Request): string {
  137. const forwarded = req.headers['x-forwarded-for'];
  138. if (typeof forwarded === 'string' && forwarded) {
  139. return forwarded.split(',')[0].trim();
  140. }
  141. if (typeof forwarded === 'object' && forwarded.length > 0) {
  142. return forwarded[0].trim();
  143. }
  144. return req.socket.remoteAddress || 'unknown';
  145. }
  146. // ============================================================
  147. // 管理员接口
  148. // ============================================================
  149. /** POST /api/admin/tenant — 创建租户 */
  150. relayRoutes.post('/admin/tenant', adminAuth, (req: Request, res: Response) => {
  151. try {
  152. const { name, plan } = req.body;
  153. const { apiKey, apiSecret } = generateApiCredentials();
  154. const keyPair = generateKeyPair();
  155. const tenant = createObject('Tenant', {
  156. apiKey,
  157. apiSecretHash: hashApiSecret(apiSecret),
  158. publicKey: keyPair.publicKey,
  159. name: name || null,
  160. plan: plan || 'free',
  161. status: 'active',
  162. metadata: JSON.stringify({ privateKey: keyPair.privateKey }),
  163. });
  164. console.log(`[Relay] 创建租户: ${tenant.objectId}, apiKey=${apiKey}`);
  165. res.json({
  166. success: true,
  167. tenantId: tenant.objectId,
  168. apiKey,
  169. apiSecret,
  170. privateKey: keyPair.privateKey,
  171. publicKey: keyPair.publicKey,
  172. name: tenant.name,
  173. plan: tenant.plan,
  174. });
  175. } catch (err: unknown) {
  176. const msg = err instanceof Error ? err.message : String(err);
  177. console.error('[Relay] 创建租户失败:', msg);
  178. res.status(500).json({ error: `创建租户失败: ${msg}` });
  179. }
  180. });
  181. // ============================================================
  182. // 租户自助注册接口
  183. // ============================================================
  184. /** POST /api/tenant/register — 使用 Fmode token 自助注册 */
  185. relayRoutes.post('/tenant/register', async (req: Request, res: Response) => {
  186. try {
  187. const auth = req.headers.authorization || '';
  188. const token = auth.replace(/^Bearer\s+/i, '');
  189. if (!token) {
  190. res.status(401).json({
  191. success: false,
  192. error: '缺少 Fmode token',
  193. code: 'MISSING_TOKEN',
  194. });
  195. return;
  196. }
  197. const ip = getClientIp(req);
  198. const rateWindowMs = getRegisterRateLimitSeconds() * 1000;
  199. if (!checkRateLimit(`register:ip:${ip}`, rateWindowMs, getRegisterRateLimitPerIp())) {
  200. res.status(429).json({
  201. success: false,
  202. error: '该 IP 注册过于频繁,请稍后再试',
  203. code: 'RATE_LIMITED',
  204. });
  205. return;
  206. }
  207. const { description, deviceGuid } = req.body || {};
  208. const descriptionStr = typeof description === 'string' ? description.trim() : '';
  209. // 1. 校验 Fmode token
  210. const fmodeUser: FmodeUser = await verifyFmodeToken(token, deviceGuid);
  211. // 2. 风控:已注册则直接返回已有租户
  212. const existing = await findTenantByFmodeUserId(fmodeUser.userId);
  213. if (existing) {
  214. res.status(409).json({
  215. success: false,
  216. error: '该 Fmode 账号已开通 Relay',
  217. code: 'ALREADY_REGISTERED',
  218. tenantId: existing.objectId,
  219. });
  220. return;
  221. }
  222. // 3. 单个 Fmode 用户限流
  223. if (!checkRateLimit(`register:user:${fmodeUser.userId}`, rateWindowMs, getRegisterRateLimitPerUser())) {
  224. res.status(429).json({
  225. success: false,
  226. error: '该 Fmode 账号注册过于频繁',
  227. code: 'RATE_LIMITED',
  228. });
  229. return;
  230. }
  231. // 3. 生成租户凭证
  232. const { apiKey, apiSecret } = generateApiCredentials();
  233. const keyPair = generateKeyPair();
  234. const tenant = createObject('Tenant', {
  235. apiKey,
  236. apiSecretHash: hashApiSecret(apiSecret),
  237. publicKey: keyPair.publicKey,
  238. fmodeUserId: fmodeUser.userId,
  239. name: descriptionStr.slice(0, 100) || null,
  240. description: descriptionStr.slice(0, 255) || null,
  241. plan: 'free',
  242. status: 'active',
  243. maxDevices: 10,
  244. dailyEventLimit: 100000,
  245. metadata: JSON.stringify({
  246. registeredFrom: 'self-service',
  247. fmodeNickname: fmodeUser.nickname,
  248. fmodeMobile: fmodeUser.mobile,
  249. }),
  250. });
  251. // 4. 可选:预注册设备
  252. let registeredDevice: { guid: string; relaySecret: string } | null = null;
  253. if (deviceGuid && typeof deviceGuid === 'string') {
  254. const device = createObject('TenantDevice', {
  255. tenantId: tenant.objectId,
  256. guid: deviceGuid.trim(),
  257. relaySecret: generateRelaySecret(),
  258. deviceName: descriptionStr.slice(0, 100) || null,
  259. lastOnlineAt: new Date().toISOString(),
  260. status: 'active',
  261. }) as TenantDevice;
  262. registeredDevice = { guid: device.guid, relaySecret: device.relaySecret };
  263. }
  264. // 5. 审计日志
  265. createObject('AuditLog', {
  266. action: 'tenant.register',
  267. tenantId: tenant.objectId,
  268. fmodeUserId: fmodeUser.userId,
  269. ip,
  270. details: JSON.stringify({
  271. description: descriptionStr,
  272. deviceGuid: registeredDevice?.guid || null,
  273. }),
  274. });
  275. console.log(`[Relay] 自助注册租户: ${tenant.objectId}, fmodeUser=${fmodeUser.userId}, ip=${ip}`);
  276. res.json({
  277. success: true,
  278. relayBaseUrl: getRelayPublicUrl(),
  279. tenantId: tenant.objectId,
  280. apiKey,
  281. apiSecret,
  282. privateKey: keyPair.privateKey,
  283. publicKey: keyPair.publicKey,
  284. createdAt: tenant.createdAt,
  285. ...(registeredDevice
  286. ? { deviceGuid: registeredDevice.guid, relaySecret: registeredDevice.relaySecret }
  287. : {}),
  288. });
  289. } catch (err: unknown) {
  290. const msg = err instanceof Error ? err.message : String(err);
  291. console.error('[Relay] 租户自助注册失败:', msg);
  292. const code = /Fmode token 无效|Missing Fmode token/i.test(msg)
  293. ? 'INVALID_TOKEN'
  294. : 'REGISTER_FAILED';
  295. const status = code === 'INVALID_TOKEN' ? 401 : 500;
  296. res.status(status).json({ success: false, error: msg, code });
  297. }
  298. });
  299. // ============================================================
  300. // 租户接口
  301. // ============================================================
  302. /** GET /api/tenant/status — 租户状态 */
  303. relayRoutes.get('/tenant/status', tenantAuth, async (req: AuthenticatedRequest, res: Response) => {
  304. try {
  305. const tenantId = req.tenant!.objectId;
  306. const devices = await new Query<TenantDevice>('TenantDevice')
  307. .equalTo('tenantId', tenantId)
  308. .equalTo('status', 'active')
  309. .find();
  310. const pendingCount = await new Query('WebhookRelayEvent')
  311. .equalTo('tenantId', tenantId)
  312. .equalTo('status', 'pending')
  313. .count();
  314. const deliveredToday = await countMetric(tenantId, 'webhook_event', new Date().toISOString().slice(0, 10));
  315. res.json({
  316. success: true,
  317. tenantId,
  318. createdAt: req.tenant!.createdAt,
  319. name: req.tenant!.name,
  320. plan: req.tenant!.plan,
  321. status: req.tenant!.status,
  322. deviceCount: devices.length,
  323. devices: devices.map((d) => ({
  324. guid: d.guid,
  325. deviceName: d.deviceName,
  326. lastOnlineAt: d.lastOnlineAt,
  327. })),
  328. pendingEvents: pendingCount,
  329. pendingEventCount: pendingCount,
  330. webhookEventsToday: deliveredToday,
  331. eventCount24h: deliveredToday,
  332. });
  333. } catch (err: unknown) {
  334. const msg = err instanceof Error ? err.message : String(err);
  335. res.status(500).json({ success: false, error: msg });
  336. }
  337. });
  338. /** POST /api/tenant/device — 注册/更新设备 */
  339. relayRoutes.post('/tenant/device', tenantAuth, async (req: AuthenticatedRequest, res: Response) => {
  340. try {
  341. const tenantId = req.tenant!.objectId;
  342. const { guid, deviceName } = req.body;
  343. if (!guid || typeof guid !== 'string') {
  344. res.status(400).json({ error: 'Missing guid' });
  345. return;
  346. }
  347. const assigned = await findDeviceByGuid(guid);
  348. if (assigned && assigned.tenantId !== tenantId) {
  349. res.status(409).json({ error: 'Device is already assigned to another tenant' });
  350. return;
  351. }
  352. let device: TenantDevice | null = await findDevice(tenantId, guid);
  353. const now = new Date().toISOString();
  354. if (!device) {
  355. device = createObject('TenantDevice', {
  356. tenantId,
  357. guid,
  358. relaySecret: generateRelaySecret(),
  359. deviceName: deviceName || null,
  360. lastOnlineAt: now,
  361. status: 'active',
  362. }) as TenantDevice;
  363. console.log(`[Relay] 注册设备: tenant=${tenantId}, guid=${guid}`);
  364. } else {
  365. updateObject('TenantDevice', device.objectId, {
  366. lastOnlineAt: now,
  367. deviceName: deviceName || device.deviceName,
  368. status: 'active',
  369. });
  370. }
  371. res.json({
  372. success: true,
  373. tenantId,
  374. guid,
  375. relaySecret: device.relaySecret,
  376. deviceName: device.deviceName,
  377. lastOnlineAt: device.lastOnlineAt,
  378. });
  379. } catch (err: unknown) {
  380. const msg = err instanceof Error ? err.message : String(err);
  381. res.status(500).json({ error: msg });
  382. }
  383. });
  384. // ============================================================
  385. // Webhook 接收接口
  386. // ============================================================
  387. /**
  388. * The upstream callback is token-wide, so this endpoint routes each payload by
  389. * its real device guid before encrypting it for the owning tenant.
  390. */
  391. relayRoutes.post('/webhook/ingest', async (req: Request, res: Response) => {
  392. const startTime = Date.now();
  393. const rawBody: string | undefined = (req as any).rawBody;
  394. const callbackSecret = getUpstreamCallbackSecret();
  395. try {
  396. if (!callbackSecret) {
  397. res.status(503).json({ error: 'Global callback is not configured' });
  398. return;
  399. }
  400. const signature = extractSignature(req.headers as Record<string, string | string[] | undefined>);
  401. if (!signature || !rawBody || !verifyHmacSignature(callbackSecret, rawBody, signature)) {
  402. const auth = req.headers.authorization as string | undefined;
  403. if (!auth || !verifyBearerToken(callbackSecret, auth)) {
  404. console.warn('[Relay] 全局回调签名验证失败');
  405. res.status(401).json({ error: 'Invalid signature' });
  406. return;
  407. }
  408. }
  409. const groups = splitGlobalPayload(req.body);
  410. if (groups.length === 0) {
  411. console.warn('[Relay] 全局回调缺少可路由的 guid');
  412. res.status(400).json({ error: 'Missing device guid in callback payload' });
  413. return;
  414. }
  415. const resolved: Array<{
  416. tenant: Tenant;
  417. device: TenantDevice;
  418. body: unknown;
  419. rawBody: string;
  420. }> = [];
  421. for (const group of groups) {
  422. const device = await findDeviceByGuid(group.guid);
  423. if (!device) {
  424. console.warn(`[Relay] 全局回调设备未注册: guid=${group.guid}`);
  425. res.status(409).json({ error: 'Device is not registered' });
  426. return;
  427. }
  428. const tenant = await findTenantById(device.tenantId);
  429. if (!tenant || tenant.status !== 'active') {
  430. res.status(409).json({ error: 'Tenant is not active' });
  431. return;
  432. }
  433. resolved.push({ tenant, device, body: group.body, rawBody: group.rawBody });
  434. }
  435. let queued = 0;
  436. let duplicates = 0;
  437. for (const item of resolved) {
  438. const result = await storeWebhookEvent(
  439. item.tenant,
  440. item.device,
  441. item.rawBody,
  442. item.body,
  443. );
  444. if (result.duplicate) duplicates += 1;
  445. else queued += 1;
  446. }
  447. console.log(
  448. `[Relay] 全局回调完成: devices=${resolved.length}, queued=${queued}, duplicates=${duplicates}, elapsed=${Date.now() - startTime}ms`,
  449. );
  450. res.status(200).json({ code: 0, msg: 'received', queued, duplicates });
  451. } catch (err: unknown) {
  452. const msg = err instanceof Error ? err.message : String(err);
  453. console.error('[Relay] 全局回调接收失败:', msg);
  454. res.status(500).json({ error: 'Global ingest failed' });
  455. }
  456. });
  457. /** POST /api/webhook/ingest/:tenantId/:deviceGuid */
  458. relayRoutes.post('/webhook/ingest/:tenantId/:deviceGuid', async (req: Request, res: Response) => {
  459. const startTime = Date.now();
  460. const { tenantId, deviceGuid } = req.params;
  461. const rawBody: string | undefined = (req as any).rawBody;
  462. try {
  463. // 1. 查找设备与租户
  464. const device = await findDevice(tenantId, deviceGuid);
  465. if (!device) {
  466. res.status(404).json({ error: 'Device not found' });
  467. return;
  468. }
  469. const tenant = await findTenantById(tenantId);
  470. if (!tenant || tenant.status !== 'active') {
  471. res.status(404).json({ error: 'Tenant not found or inactive' });
  472. return;
  473. }
  474. // 2. 验证 HMAC 签名(来自 qiweapi 或本地测试)
  475. const signature = extractSignature(req.headers as Record<string, string | string[] | undefined>);
  476. const relaySecret = device.relaySecret as string;
  477. if (!signature || !rawBody || !verifyHmacSignature(relaySecret, rawBody, signature)) {
  478. // 兼容直接 Bearer secret 的测试方式
  479. const auth = req.headers.authorization as string | undefined;
  480. if (!auth || !verifyBearerToken(relaySecret, auth)) {
  481. console.warn(`[Relay] 签名验证失败: tenant=${tenantId}, guid=${deviceGuid}`);
  482. res.status(401).json({ error: 'Invalid signature' });
  483. return;
  484. }
  485. }
  486. if (!rawBody) {
  487. res.status(400).json({ error: 'Missing raw body' });
  488. return;
  489. }
  490. const result = await storeWebhookEvent(tenant, device, rawBody, req.body);
  491. const elapsed = Date.now() - startTime;
  492. console.log(`[Relay] 接收事件: tenant=${tenantId}, guid=${deviceGuid}, eventId=${result.eventId}, elapsed=${elapsed}ms`);
  493. res.status(200).json({
  494. code: 0,
  495. msg: result.duplicate ? 'duplicate event' : 'received',
  496. eventId: result.eventId,
  497. });
  498. } catch (err: unknown) {
  499. const msg = err instanceof Error ? err.message : String(err);
  500. console.error('[Relay] 接收事件失败:', msg);
  501. res.status(500).json({ error: `Ingest failed: ${msg}` });
  502. }
  503. });
  504. // ============================================================
  505. // Relay 拉取接口
  506. // ============================================================
  507. /** POST /api/poll 与 /api/relay/poll(别名) */
  508. async function handlePoll(req: AuthenticatedRequest, res: Response): Promise<void> {
  509. const tenantId = req.tenant!.objectId;
  510. const { guid, batchSize = getPollBatchSize(), waitMs = getPollWaitMs() } = req.body;
  511. if (!guid || typeof guid !== 'string') {
  512. res.status(400).json({ error: 'Missing guid' });
  513. return;
  514. }
  515. // 校验 guid 属于当前租户
  516. const device = await findDevice(tenantId, guid);
  517. if (!device) {
  518. res.status(404).json({ error: 'Device not registered for this tenant' });
  519. return;
  520. }
  521. // 更新最后在线时间
  522. updateObject('TenantDevice', device.objectId, {
  523. lastOnlineAt: new Date().toISOString(),
  524. });
  525. // 长轮询:最多等待 waitMs,有新事件或超时返回
  526. const deadline = Date.now() + Math.min(waitMs, getPollWaitMs());
  527. const pollInterval = 500;
  528. while (Date.now() < deadline) {
  529. const events = await fetchPendingEvents(tenantId, guid, Math.min(batchSize, getPollBatchSize()));
  530. if (events.length > 0) {
  531. res.json({ success: true, events });
  532. return;
  533. }
  534. await sleep(pollInterval);
  535. }
  536. // 超时返回空
  537. res.json({ success: true, events: [] });
  538. }
  539. relayRoutes.post('/poll', tenantAuth, handlePoll);
  540. relayRoutes.post('/relay/poll', tenantAuth, handlePoll);
  541. /** POST /api/ack 与 /api/relay/ack(别名) */
  542. async function handleAck(req: AuthenticatedRequest, res: Response): Promise<void> {
  543. const tenantId = req.tenant!.objectId;
  544. const { eventIds, guid } = req.body;
  545. if (!Array.isArray(eventIds) || eventIds.length === 0) {
  546. res.status(400).json({ error: 'eventIds must be a non-empty array' });
  547. return;
  548. }
  549. const db = getDb();
  550. const stmt = db.prepare(
  551. `UPDATE "WebhookRelayEvent" SET status = 'delivered', deliveredAt = ? WHERE tenantId = ? AND eventId IN (${eventIds.map(() => '?').join(',')})`
  552. );
  553. stmt.run(new Date().toISOString(), tenantId, ...eventIds);
  554. // 可选:校验 guid 归属
  555. console.log(`[Relay] ACK: tenant=${tenantId}, guid=${guid}, count=${eventIds.length}`);
  556. res.json({ success: true, ackedCount: eventIds.length });
  557. }
  558. relayRoutes.post('/ack', tenantAuth, handleAck);
  559. relayRoutes.post('/relay/ack', tenantAuth, handleAck);
  560. // ============================================================
  561. // 健康检查
  562. // ============================================================
  563. relayRoutes.get('/health', (_req: Request, res: Response) => {
  564. res.json({ ok: true, timestamp: new Date().toISOString() });
  565. });
  566. // ============================================================
  567. // 辅助函数
  568. // ============================================================
  569. async function findTenantByApiSecret(apiSecret: string): Promise<Tenant | null> {
  570. return new Query<Tenant>('Tenant').equalTo('apiSecretHash', hashApiSecret(apiSecret)).first();
  571. }
  572. async function findTenantById(tenantId: string): Promise<Tenant | null> {
  573. return new Query<Tenant>('Tenant').equalTo('objectId', tenantId).first();
  574. }
  575. async function findTenantByFmodeUserId(fmodeUserId: string): Promise<Tenant | null> {
  576. return new Query<Tenant>('Tenant').equalTo('fmodeUserId', fmodeUserId).first();
  577. }
  578. async function findDevice(tenantId: string, guid: string): Promise<TenantDevice | null> {
  579. return new Query<TenantDevice>('TenantDevice')
  580. .equalTo('tenantId', tenantId)
  581. .equalTo('guid', guid)
  582. .equalTo('status', 'active')
  583. .first();
  584. }
  585. async function findDeviceByGuid(guid: string): Promise<TenantDevice | null> {
  586. const devices = await new Query<TenantDevice>('TenantDevice')
  587. .equalTo('guid', guid)
  588. .equalTo('status', 'active')
  589. .find();
  590. if (devices.length > 1) {
  591. throw new Error(`Device guid is assigned to multiple tenants: ${guid}`);
  592. }
  593. return devices[0] || null;
  594. }
  595. function extractGuid(value: unknown): string {
  596. if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
  597. const item = value as Record<string, unknown>;
  598. const direct = item.guid || item.deviceGuid || item.device_guid;
  599. if (typeof direct === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(direct.trim())) {
  600. return direct.trim();
  601. }
  602. if (item.meta && typeof item.meta === 'object') return extractGuid(item.meta);
  603. return '';
  604. }
  605. function splitGlobalPayload(body: unknown): Array<{ guid: string; body: unknown; rawBody: string }> {
  606. if (Array.isArray(body)) {
  607. const groups = new Map<string, unknown[]>();
  608. for (const item of body) {
  609. const guid = extractGuid(item);
  610. if (!guid) return [];
  611. const values = groups.get(guid) || [];
  612. values.push(item);
  613. groups.set(guid, values);
  614. }
  615. return [...groups.entries()].map(([guid, values]) => ({
  616. guid,
  617. body: values,
  618. rawBody: JSON.stringify(values),
  619. }));
  620. }
  621. if (!body || typeof body !== 'object') return [];
  622. const wrapper = body as Record<string, unknown>;
  623. const wrapperGuid = extractGuid(wrapper);
  624. if (!Array.isArray(wrapper.data)) {
  625. return wrapperGuid ? [{ guid: wrapperGuid, body, rawBody: JSON.stringify(body) }] : [];
  626. }
  627. const groups = new Map<string, unknown[]>();
  628. for (const item of wrapper.data) {
  629. const guid = extractGuid(item) || wrapperGuid;
  630. if (!guid) return [];
  631. const values = groups.get(guid) || [];
  632. values.push(item);
  633. groups.set(guid, values);
  634. }
  635. return [...groups.entries()].map(([guid, values]) => {
  636. const groupedBody = { ...wrapper, data: values };
  637. return { guid, body: groupedBody, rawBody: JSON.stringify(groupedBody) };
  638. });
  639. }
  640. function extractSignature(headers: Record<string, string | string[] | undefined>): string | null {
  641. const auth = getHeader(headers, 'authorization');
  642. if (auth) {
  643. const match = auth.match(/^Bearer\s+(.+)$/i);
  644. if (match) return match[1].trim();
  645. return auth.trim();
  646. }
  647. const sigHeaders = ['x-qiwe-signature', 'x-qiweapi-signature', 'x-signature'];
  648. for (const name of sigHeaders) {
  649. const val = getHeader(headers, name);
  650. if (val) return val;
  651. }
  652. return null;
  653. }
  654. function getHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
  655. const lower = name.toLowerCase();
  656. for (const [key, value] of Object.entries(headers)) {
  657. if (key.toLowerCase() === lower) {
  658. return Array.isArray(value) ? value[0] : value;
  659. }
  660. }
  661. return undefined;
  662. }
  663. function extractEventId(body: unknown, guid: string): string {
  664. if (!body || typeof body !== 'object') return `${guid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
  665. const b = body as Record<string, unknown>;
  666. // v1: data[].msgUniqueIdentifier
  667. if (Array.isArray(b.data)) {
  668. const items = b.data as Array<Record<string, unknown>>;
  669. const first = items[0];
  670. if (first?.msgUniqueIdentifier) return `${guid}_${first.msgUniqueIdentifier}`;
  671. }
  672. // v2: meta.seq / meta.token_id
  673. if (b.version === '2.0' && b.meta && typeof b.meta === 'object') {
  674. const meta = b.meta as Record<string, unknown>;
  675. return `${guid}_${meta.token_id || ''}_${meta.seq || Date.now()}`;
  676. }
  677. return `${guid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
  678. }
  679. async function storeWebhookEvent(
  680. tenant: Tenant,
  681. device: TenantDevice,
  682. rawBody: string,
  683. body: unknown,
  684. ): Promise<{ eventId: string; duplicate: boolean }> {
  685. const eventId = extractEventId(body, device.guid);
  686. const existing = await new Query('WebhookRelayEvent')
  687. .equalTo('tenantId', tenant.objectId)
  688. .equalTo('eventId', eventId)
  689. .first();
  690. if (existing) return { eventId, duplicate: true };
  691. const encryptedPayload = encryptWithPublicKey(tenant.publicKey, rawBody);
  692. const payloadHash = sha256Hash(rawBody);
  693. await enforcePendingEventLimit(tenant.objectId);
  694. const expiresAt = new Date(Date.now() + getEventTtlSeconds() * 1000).toISOString();
  695. createObject('WebhookRelayEvent', {
  696. tenantId: tenant.objectId,
  697. deviceGuid: device.guid,
  698. eventId,
  699. status: 'pending',
  700. encryptedPayload,
  701. payloadHash,
  702. retryCount: 0,
  703. expiresAt,
  704. });
  705. recordMetric(tenant.objectId, device.guid, 'webhook_event');
  706. return { eventId, duplicate: false };
  707. }
  708. async function enforcePendingEventLimit(tenantId: string): Promise<void> {
  709. const max = getMaxPendingEventsPerTenant();
  710. const db = getDb();
  711. const countRow = db
  712. .prepare('SELECT COUNT(*) as count FROM "WebhookRelayEvent" WHERE tenantId = ? AND status = ?')
  713. .get(tenantId, 'pending') as { count: number };
  714. if (countRow.count >= max) {
  715. // 删除最旧的 pending 事件
  716. const toDrop = countRow.count - max + 1;
  717. db.prepare(
  718. `DELETE FROM "WebhookRelayEvent" WHERE objectId IN (
  719. SELECT objectId FROM "WebhookRelayEvent"
  720. WHERE tenantId = ? AND status = ?
  721. ORDER BY createdAt ASC
  722. LIMIT ?
  723. )`
  724. ).run(tenantId, 'pending', toDrop);
  725. console.warn(`[Relay] 租户 ${tenantId} pending 事件超过上限,已丢弃 ${toDrop} 条最旧事件`);
  726. }
  727. }
  728. async function fetchPendingEvents(tenantId: string, guid: string, limit: number): Promise<WebhookRelayEvent[]> {
  729. return new Query<WebhookRelayEvent>('WebhookRelayEvent')
  730. .equalTo('tenantId', tenantId)
  731. .equalTo('deviceGuid', guid)
  732. .equalTo('status', 'pending')
  733. .ascending('createdAt')
  734. .limit(limit)
  735. .find();
  736. }
  737. function recordMetric(tenantId: string, deviceGuid: string, metricName: string, value = 1): void {
  738. try {
  739. const metricDate = new Date().toISOString().slice(0, 10);
  740. createObject('UsageMetric', {
  741. tenantId,
  742. deviceGuid,
  743. metricName,
  744. metricValue: value,
  745. metricDate,
  746. });
  747. } catch (err: unknown) {
  748. console.warn('[Relay] 用量埋点失败:', err instanceof Error ? err.message : String(err));
  749. }
  750. }
  751. async function countMetric(tenantId: string, metricName: string, metricDate: string): Promise<number> {
  752. try {
  753. const rows = await new Query<UsageMetricRow>('UsageMetric')
  754. .equalTo('tenantId', tenantId)
  755. .equalTo('metricName', metricName)
  756. .equalTo('metricDate', metricDate)
  757. .find();
  758. return rows.reduce((sum, row) => sum + (row.metricValue || 0), 0);
  759. } catch {
  760. return 0;
  761. }
  762. }
  763. function sleep(ms: number): Promise<void> {
  764. return new Promise((resolve) => setTimeout(resolve, ms));
  765. }