routes.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  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. let device: TenantDevice | null = await findDevice(tenantId, guid);
  348. if (!device) {
  349. const fmodeToken = getHeader(req.headers as Record<string, string | string[] | undefined>, 'x-fmode-token');
  350. const tenant = req.tenant!;
  351. const assignedDevices = await findDevicesByGuid(guid);
  352. const foreignDevices = assignedDevices.filter((item) => item.tenantId !== tenantId);
  353. // 已绑定 Fmode 身份的租户注册新设备时必须重新证明账号身份。
  354. // 历史管理租户仍可注册未被占用的设备,以保持向后兼容。
  355. if ((tenant.fmodeUserId || foreignDevices.length > 0) && !fmodeToken) {
  356. res.status(401).json({ error: 'Fmode token is required to register or transfer this device' });
  357. return;
  358. }
  359. if (fmodeToken) {
  360. const fmodeUser = await verifyFmodeToken(fmodeToken, guid);
  361. if (tenant.fmodeUserId && tenant.fmodeUserId !== fmodeUser.userId) {
  362. res.status(403).json({ error: 'Fmode account does not match this tenant' });
  363. return;
  364. }
  365. for (const assigned of foreignDevices) {
  366. const owner = await findTenantById(assigned.tenantId);
  367. if (!owner?.fmodeUserId || owner.fmodeUserId !== fmodeUser.userId) {
  368. res.status(409).json({ error: 'Device is already assigned to another Fmode account' });
  369. return;
  370. }
  371. }
  372. if (!tenant.fmodeUserId) {
  373. updateObject('Tenant', tenantId, { fmodeUserId: fmodeUser.userId });
  374. }
  375. for (const assigned of foreignDevices) {
  376. updateObject('TenantDevice', assigned.objectId, { status: 'inactive' });
  377. console.log(`[Relay] 同账号设备迁移: guid=${guid}, from=${assigned.tenantId}, to=${tenantId}`);
  378. }
  379. }
  380. }
  381. const now = new Date().toISOString();
  382. if (!device) {
  383. device = createObject('TenantDevice', {
  384. tenantId,
  385. guid,
  386. relaySecret: generateRelaySecret(),
  387. deviceName: deviceName || null,
  388. lastOnlineAt: now,
  389. status: 'active',
  390. }) as TenantDevice;
  391. console.log(`[Relay] 注册设备: tenant=${tenantId}, guid=${guid}`);
  392. } else {
  393. updateObject('TenantDevice', device.objectId, {
  394. lastOnlineAt: now,
  395. deviceName: deviceName || device.deviceName,
  396. status: 'active',
  397. });
  398. }
  399. res.json({
  400. success: true,
  401. tenantId,
  402. guid,
  403. relaySecret: device.relaySecret,
  404. deviceName: device.deviceName,
  405. lastOnlineAt: device.lastOnlineAt,
  406. });
  407. } catch (err: unknown) {
  408. const msg = err instanceof Error ? err.message : String(err);
  409. res.status(500).json({ error: msg });
  410. }
  411. });
  412. // ============================================================
  413. // Webhook 接收接口
  414. // ============================================================
  415. /**
  416. * The upstream callback is token-wide, so this endpoint routes each payload by
  417. * its real device guid before encrypting it for the owning tenant.
  418. */
  419. relayRoutes.post('/webhook/ingest', async (req: Request, res: Response) => {
  420. const startTime = Date.now();
  421. const rawBody: string | undefined = (req as any).rawBody;
  422. const callbackSecret = getUpstreamCallbackSecret();
  423. try {
  424. if (!callbackSecret) {
  425. res.status(503).json({ error: 'Global callback is not configured' });
  426. return;
  427. }
  428. const signature = extractSignature(req.headers as Record<string, string | string[] | undefined>);
  429. if (!signature || !rawBody || !verifyHmacSignature(callbackSecret, rawBody, signature)) {
  430. const auth = req.headers.authorization as string | undefined;
  431. if (!auth || !verifyBearerToken(callbackSecret, auth)) {
  432. console.warn('[Relay] 全局回调签名验证失败');
  433. res.status(401).json({ error: 'Invalid signature' });
  434. return;
  435. }
  436. }
  437. if (isCallbackSetupProbe(req.body)) {
  438. res.json({ success: true, probe: true, queued: 0 });
  439. return;
  440. }
  441. const groups = splitGlobalPayload(req.body);
  442. if (groups.length === 0) {
  443. console.warn('[Relay] 全局回调缺少可路由的 guid');
  444. res.status(400).json({ error: 'Missing device guid in callback payload' });
  445. return;
  446. }
  447. const resolved: Array<{
  448. tenant: Tenant;
  449. device: TenantDevice;
  450. body: unknown;
  451. rawBody: string;
  452. }> = [];
  453. for (const group of groups) {
  454. const device = await findDeviceByGuid(group.guid);
  455. if (!device) {
  456. console.warn(`[Relay] 全局回调设备未注册: guid=${group.guid}`);
  457. res.status(409).json({ error: 'Device is not registered' });
  458. return;
  459. }
  460. const tenant = await findTenantById(device.tenantId);
  461. if (!tenant || tenant.status !== 'active') {
  462. res.status(409).json({ error: 'Tenant is not active' });
  463. return;
  464. }
  465. resolved.push({ tenant, device, body: group.body, rawBody: group.rawBody });
  466. }
  467. let queued = 0;
  468. let duplicates = 0;
  469. for (const item of resolved) {
  470. const result = await storeWebhookEvent(
  471. item.tenant,
  472. item.device,
  473. item.rawBody,
  474. item.body,
  475. );
  476. if (result.duplicate) duplicates += 1;
  477. else queued += 1;
  478. }
  479. console.log(
  480. `[Relay] 全局回调完成: devices=${resolved.length}, queued=${queued}, duplicates=${duplicates}, elapsed=${Date.now() - startTime}ms`,
  481. );
  482. res.status(200).json({ code: 0, msg: 'received', queued, duplicates });
  483. } catch (err: unknown) {
  484. const msg = err instanceof Error ? err.message : String(err);
  485. console.error('[Relay] 全局回调接收失败:', msg);
  486. res.status(500).json({ error: 'Global ingest failed' });
  487. }
  488. });
  489. /** POST /api/webhook/ingest/:tenantId/:deviceGuid */
  490. relayRoutes.post('/webhook/ingest/:tenantId/:deviceGuid', async (req: Request, res: Response) => {
  491. const startTime = Date.now();
  492. const { tenantId, deviceGuid } = req.params;
  493. const rawBody: string | undefined = (req as any).rawBody;
  494. try {
  495. // 1. 查找设备与租户
  496. const device = await findDevice(tenantId, deviceGuid);
  497. if (!device) {
  498. res.status(404).json({ error: 'Device not found' });
  499. return;
  500. }
  501. const tenant = await findTenantById(tenantId);
  502. if (!tenant || tenant.status !== 'active') {
  503. res.status(404).json({ error: 'Tenant not found or inactive' });
  504. return;
  505. }
  506. // 2. 验证 HMAC 签名(来自 qiweapi 或本地测试)
  507. const signature = extractSignature(req.headers as Record<string, string | string[] | undefined>);
  508. const relaySecret = device.relaySecret as string;
  509. if (!signature || !rawBody || !verifyHmacSignature(relaySecret, rawBody, signature)) {
  510. // 兼容直接 Bearer secret 的测试方式
  511. const auth = req.headers.authorization as string | undefined;
  512. if (!auth || !verifyBearerToken(relaySecret, auth)) {
  513. console.warn(`[Relay] 签名验证失败: tenant=${tenantId}, guid=${deviceGuid}`);
  514. res.status(401).json({ error: 'Invalid signature' });
  515. return;
  516. }
  517. }
  518. if (!rawBody) {
  519. res.status(400).json({ error: 'Missing raw body' });
  520. return;
  521. }
  522. const result = await storeWebhookEvent(tenant, device, rawBody, req.body);
  523. const elapsed = Date.now() - startTime;
  524. console.log(`[Relay] 接收事件: tenant=${tenantId}, guid=${deviceGuid}, eventId=${result.eventId}, elapsed=${elapsed}ms`);
  525. res.status(200).json({
  526. code: 0,
  527. msg: result.duplicate ? 'duplicate event' : 'received',
  528. eventId: result.eventId,
  529. });
  530. } catch (err: unknown) {
  531. const msg = err instanceof Error ? err.message : String(err);
  532. console.error('[Relay] 接收事件失败:', msg);
  533. res.status(500).json({ error: `Ingest failed: ${msg}` });
  534. }
  535. });
  536. // ============================================================
  537. // Relay 拉取接口
  538. // ============================================================
  539. /** POST /api/poll 与 /api/relay/poll(别名) */
  540. async function handlePoll(req: AuthenticatedRequest, res: Response): Promise<void> {
  541. const tenantId = req.tenant!.objectId;
  542. const { guid, batchSize = getPollBatchSize(), waitMs = getPollWaitMs() } = req.body;
  543. if (!guid || typeof guid !== 'string') {
  544. res.status(400).json({ error: 'Missing guid' });
  545. return;
  546. }
  547. // 校验 guid 属于当前租户
  548. const device = await findDevice(tenantId, guid);
  549. if (!device) {
  550. res.status(404).json({ error: 'Device not registered for this tenant' });
  551. return;
  552. }
  553. // 更新最后在线时间
  554. updateObject('TenantDevice', device.objectId, {
  555. lastOnlineAt: new Date().toISOString(),
  556. });
  557. // 长轮询:最多等待 waitMs,有新事件或超时返回
  558. const deadline = Date.now() + Math.min(waitMs, getPollWaitMs());
  559. const pollInterval = 500;
  560. while (Date.now() < deadline) {
  561. const events = await fetchPendingEvents(tenantId, guid, Math.min(batchSize, getPollBatchSize()));
  562. if (events.length > 0) {
  563. res.json({ success: true, events });
  564. return;
  565. }
  566. await sleep(pollInterval);
  567. }
  568. // 超时返回空
  569. res.json({ success: true, events: [] });
  570. }
  571. relayRoutes.post('/poll', tenantAuth, handlePoll);
  572. relayRoutes.post('/relay/poll', tenantAuth, handlePoll);
  573. /** POST /api/ack 与 /api/relay/ack(别名) */
  574. async function handleAck(req: AuthenticatedRequest, res: Response): Promise<void> {
  575. const tenantId = req.tenant!.objectId;
  576. const { eventIds, guid } = req.body;
  577. if (!Array.isArray(eventIds) || eventIds.length === 0) {
  578. res.status(400).json({ error: 'eventIds must be a non-empty array' });
  579. return;
  580. }
  581. const db = getDb();
  582. const stmt = db.prepare(
  583. `UPDATE "WebhookRelayEvent" SET status = 'delivered', deliveredAt = ? WHERE tenantId = ? AND eventId IN (${eventIds.map(() => '?').join(',')})`
  584. );
  585. stmt.run(new Date().toISOString(), tenantId, ...eventIds);
  586. // 可选:校验 guid 归属
  587. console.log(`[Relay] ACK: tenant=${tenantId}, guid=${guid}, count=${eventIds.length}`);
  588. res.json({ success: true, ackedCount: eventIds.length });
  589. }
  590. relayRoutes.post('/ack', tenantAuth, handleAck);
  591. relayRoutes.post('/relay/ack', tenantAuth, handleAck);
  592. // ============================================================
  593. // 健康检查
  594. // ============================================================
  595. relayRoutes.get('/health', (_req: Request, res: Response) => {
  596. res.json({ ok: true, timestamp: new Date().toISOString() });
  597. });
  598. // ============================================================
  599. // 辅助函数
  600. // ============================================================
  601. async function findTenantByApiSecret(apiSecret: string): Promise<Tenant | null> {
  602. return new Query<Tenant>('Tenant').equalTo('apiSecretHash', hashApiSecret(apiSecret)).first();
  603. }
  604. async function findTenantById(tenantId: string): Promise<Tenant | null> {
  605. return new Query<Tenant>('Tenant').equalTo('objectId', tenantId).first();
  606. }
  607. async function findTenantByFmodeUserId(fmodeUserId: string): Promise<Tenant | null> {
  608. return new Query<Tenant>('Tenant').equalTo('fmodeUserId', fmodeUserId).first();
  609. }
  610. async function findDevice(tenantId: string, guid: string): Promise<TenantDevice | null> {
  611. return new Query<TenantDevice>('TenantDevice')
  612. .equalTo('tenantId', tenantId)
  613. .equalTo('guid', guid)
  614. .equalTo('status', 'active')
  615. .first();
  616. }
  617. async function findDeviceByGuid(guid: string): Promise<TenantDevice | null> {
  618. const devices = await findDevicesByGuid(guid);
  619. if (devices.length > 1) {
  620. const tenants = await Promise.all(devices.map((device) => findTenantById(device.tenantId)));
  621. const owners = new Set(tenants.filter(Boolean).map((tenant) => tenant!.fmodeUserId).filter(Boolean));
  622. if (tenants.some((tenant) => !tenant) || owners.size !== 1) {
  623. throw new Error(`Device guid is assigned to multiple tenants: ${guid}`);
  624. }
  625. devices.sort((left, right) => String(right.lastOnlineAt || right.updatedAt || '').localeCompare(String(left.lastOnlineAt || left.updatedAt || '')));
  626. console.warn(`[Relay] 收敛同一 Fmode 用户的重复设备路由: guid=${guid}, selectedTenant=${devices[0].tenantId}`);
  627. }
  628. return devices[0] || null;
  629. }
  630. async function findDevicesByGuid(guid: string): Promise<TenantDevice[]> {
  631. return new Query<TenantDevice>('TenantDevice')
  632. .equalTo('guid', guid)
  633. .equalTo('status', 'active')
  634. .find();
  635. }
  636. function extractGuid(value: unknown): string {
  637. if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
  638. const item = value as Record<string, unknown>;
  639. const direct = item.guid || item.deviceGuid || item.device_guid;
  640. if (typeof direct === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(direct.trim())) {
  641. return direct.trim();
  642. }
  643. if (item.meta && typeof item.meta === 'object') return extractGuid(item.meta);
  644. return '';
  645. }
  646. function isCallbackSetupProbe(body: unknown): boolean {
  647. if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
  648. const value = body as Record<string, unknown>;
  649. return typeof value.callBackUrl === 'string'
  650. && typeof value.msg === 'string'
  651. && value.msg.includes('设置订阅成功');
  652. }
  653. function splitGlobalPayload(body: unknown): Array<{ guid: string; body: unknown; rawBody: string }> {
  654. if (Array.isArray(body)) {
  655. const groups = new Map<string, unknown[]>();
  656. for (const item of body) {
  657. const guid = extractGuid(item);
  658. if (!guid) return [];
  659. const values = groups.get(guid) || [];
  660. values.push(item);
  661. groups.set(guid, values);
  662. }
  663. return [...groups.entries()].map(([guid, values]) => ({
  664. guid,
  665. body: values,
  666. rawBody: JSON.stringify(values),
  667. }));
  668. }
  669. if (!body || typeof body !== 'object') return [];
  670. const wrapper = body as Record<string, unknown>;
  671. const wrapperGuid = extractGuid(wrapper);
  672. if (!Array.isArray(wrapper.data)) {
  673. return wrapperGuid ? [{ guid: wrapperGuid, body, rawBody: JSON.stringify(body) }] : [];
  674. }
  675. const groups = new Map<string, unknown[]>();
  676. for (const item of wrapper.data) {
  677. const guid = extractGuid(item) || wrapperGuid;
  678. if (!guid) return [];
  679. const values = groups.get(guid) || [];
  680. values.push(item);
  681. groups.set(guid, values);
  682. }
  683. return [...groups.entries()].map(([guid, values]) => {
  684. const groupedBody = { ...wrapper, data: values };
  685. return { guid, body: groupedBody, rawBody: JSON.stringify(groupedBody) };
  686. });
  687. }
  688. function extractSignature(headers: Record<string, string | string[] | undefined>): string | null {
  689. const auth = getHeader(headers, 'authorization');
  690. if (auth) {
  691. const match = auth.match(/^Bearer\s+(.+)$/i);
  692. if (match) return match[1].trim();
  693. return auth.trim();
  694. }
  695. const sigHeaders = ['x-qiwe-signature', 'x-qiweapi-signature', 'x-signature'];
  696. for (const name of sigHeaders) {
  697. const val = getHeader(headers, name);
  698. if (val) return val;
  699. }
  700. return null;
  701. }
  702. function getHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
  703. const lower = name.toLowerCase();
  704. for (const [key, value] of Object.entries(headers)) {
  705. if (key.toLowerCase() === lower) {
  706. return Array.isArray(value) ? value[0] : value;
  707. }
  708. }
  709. return undefined;
  710. }
  711. function extractEventId(body: unknown, guid: string): string {
  712. if (!body || typeof body !== 'object') return `${guid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
  713. const b = body as Record<string, unknown>;
  714. // v1: data[].msgUniqueIdentifier
  715. if (Array.isArray(b.data)) {
  716. const items = b.data as Array<Record<string, unknown>>;
  717. const first = items[0];
  718. if (first?.msgUniqueIdentifier) return `${guid}_${first.msgUniqueIdentifier}`;
  719. }
  720. // v2: meta.seq / meta.token_id
  721. if (b.version === '2.0' && b.meta && typeof b.meta === 'object') {
  722. const meta = b.meta as Record<string, unknown>;
  723. return `${guid}_${meta.token_id || ''}_${meta.seq || Date.now()}`;
  724. }
  725. return `${guid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
  726. }
  727. async function storeWebhookEvent(
  728. tenant: Tenant,
  729. device: TenantDevice,
  730. rawBody: string,
  731. body: unknown,
  732. ): Promise<{ eventId: string; duplicate: boolean }> {
  733. const eventId = extractEventId(body, device.guid);
  734. const existing = await new Query('WebhookRelayEvent')
  735. .equalTo('tenantId', tenant.objectId)
  736. .equalTo('eventId', eventId)
  737. .first();
  738. if (existing) return { eventId, duplicate: true };
  739. const encryptedPayload = encryptWithPublicKey(tenant.publicKey, rawBody);
  740. const payloadHash = sha256Hash(rawBody);
  741. await enforcePendingEventLimit(tenant.objectId);
  742. const expiresAt = new Date(Date.now() + getEventTtlSeconds() * 1000).toISOString();
  743. createObject('WebhookRelayEvent', {
  744. tenantId: tenant.objectId,
  745. deviceGuid: device.guid,
  746. eventId,
  747. status: 'pending',
  748. encryptedPayload,
  749. payloadHash,
  750. retryCount: 0,
  751. expiresAt,
  752. });
  753. recordMetric(tenant.objectId, device.guid, 'webhook_event');
  754. return { eventId, duplicate: false };
  755. }
  756. async function enforcePendingEventLimit(tenantId: string): Promise<void> {
  757. const max = getMaxPendingEventsPerTenant();
  758. const db = getDb();
  759. const countRow = db
  760. .prepare('SELECT COUNT(*) as count FROM "WebhookRelayEvent" WHERE tenantId = ? AND status = ?')
  761. .get(tenantId, 'pending') as { count: number };
  762. if (countRow.count >= max) {
  763. // 删除最旧的 pending 事件
  764. const toDrop = countRow.count - max + 1;
  765. db.prepare(
  766. `DELETE FROM "WebhookRelayEvent" WHERE objectId IN (
  767. SELECT objectId FROM "WebhookRelayEvent"
  768. WHERE tenantId = ? AND status = ?
  769. ORDER BY createdAt ASC
  770. LIMIT ?
  771. )`
  772. ).run(tenantId, 'pending', toDrop);
  773. console.warn(`[Relay] 租户 ${tenantId} pending 事件超过上限,已丢弃 ${toDrop} 条最旧事件`);
  774. }
  775. }
  776. async function fetchPendingEvents(tenantId: string, guid: string, limit: number): Promise<WebhookRelayEvent[]> {
  777. return new Query<WebhookRelayEvent>('WebhookRelayEvent')
  778. .equalTo('tenantId', tenantId)
  779. .equalTo('deviceGuid', guid)
  780. .equalTo('status', 'pending')
  781. .ascending('createdAt')
  782. .limit(limit)
  783. .find();
  784. }
  785. function recordMetric(tenantId: string, deviceGuid: string, metricName: string, value = 1): void {
  786. try {
  787. const metricDate = new Date().toISOString().slice(0, 10);
  788. createObject('UsageMetric', {
  789. tenantId,
  790. deviceGuid,
  791. metricName,
  792. metricValue: value,
  793. metricDate,
  794. });
  795. } catch (err: unknown) {
  796. console.warn('[Relay] 用量埋点失败:', err instanceof Error ? err.message : String(err));
  797. }
  798. }
  799. async function countMetric(tenantId: string, metricName: string, metricDate: string): Promise<number> {
  800. try {
  801. const rows = await new Query<UsageMetricRow>('UsageMetric')
  802. .equalTo('tenantId', tenantId)
  803. .equalTo('metricName', metricName)
  804. .equalTo('metricDate', metricDate)
  805. .find();
  806. return rows.reduce((sum, row) => sum + (row.metricValue || 0), 0);
  807. } catch {
  808. return 0;
  809. }
  810. }
  811. function sleep(ms: number): Promise<void> {
  812. return new Promise((resolve) => setTimeout(resolve, ms));
  813. }