import type { NextRequest } from 'next/server'; import type { IService } from 'soma-core'; import { aiErrorResponse, aiSuccessResponse } from '../../../domain/ai-shared/AiResponse'; import { AiApiError, AiErrorCodes } from '../../../domain/ai-shared/AiError'; import { readProgress } from '../../../domain/ai-page/GenerationProgress'; import { callerKey, parseRateLimit, sharedRateLimiter } from '../../common/RateLimiter'; import { readQueryParams, runService } from '../../common/ServiceRuntime'; import { resolveTrustedRequestContext } from '../../common/TrustedRequestContext'; /** * GET /api/ai-page/progress?taskId= — 生成任务现在到哪一步了。 * * 为什么是轮询而不是 SSE:等待方要的是"每秒知道一次现在在干嘛",一次查询就是一次 * 内存读,便宜到可以忽略;而 SSE 要穿过 nginx / 各种代理的缓冲,在这个部署形态下 * 徒增一类只在生产才复现的故障。真需要推送时,这份快照存储直接能接 SSE, * 端点契约不用改。 * * 任务不存在(号还没登记 / 号写错 / 已过期)回 **200 + state: 'unknown'**,不回 404。 * 这不是含糊其辞:客户端在 POST 发出的同一瞬间就开始轮询,而服务端要等请求真正进到 * 生成链才登记任务号——开头那几次查询"查无此任务"是**正常时序**,不是错误。 * 用 404 表达它会在浏览器控制台留下一串刺眼的红色(仓库还有"控制台零错误"的硬断言), * 把正常时序渲染成故障。客户端据 `state` 决定是否继续轮询。 */ export default class ProgressService implements IService { async get(req: NextRequest): Promise { return runService(async () => { try { // 轮询天然高频(每秒一次 × 每个等待中的用户),额度给得比写端点宽得多; // 它只读内存,贵的是被当成放大器打别的东西——所以仍然要有闸。 const rule = parseRateLimit(process.env.SOMA_AI_PROGRESS_RATE, { limit: 600, windowMs: 60_000 }); const actor = await resolveTrustedRequestContext(req); const decision = await sharedRateLimiter.hit(`ai-progress:${callerKey(actor)}`, rule); if (!decision.allowed) { const rejected = aiErrorResponse(new AiApiError( AiErrorCodes.AI_RATE_LIMITED, `进度查询过于频繁,请 ${decision.retryAfterSeconds} 秒后重试`, )); return new Response(rejected.body, { status: 429, headers: { ...Object.fromEntries(rejected.headers), 'retry-after': String(decision.retryAfterSeconds), }, }); } const params = readQueryParams(req); const snapshot = readProgress(String(params.taskId ?? '')); if (!snapshot) { return aiSuccessResponse({ state: 'unknown' }); } return aiSuccessResponse({ state: 'known', ...snapshot, // 已用时由服务端算:客户端时钟与服务端可能差很多,让两边各算一份必然对不上 elapsedMs: Math.max(0, snapshot.updatedAt - snapshot.startedAt), }); } catch (error) { return aiErrorResponse(error); } }); } }