89 lines
3.8 KiB
TypeScript
89 lines
3.8 KiB
TypeScript
// 不自动运行:需要先 npm install(express 5)。Node 23.6+ 可以直接运行 .ts(类型擦除)
|
||
// Express 5 BFF 示例:请求 id、输入校验、统一错误处理、async 路由(Express 5 会自动捕获 Promise 拒绝)、SSE 流式转发
|
||
import express, { type NextFunction, type Request, type Response } from "express";
|
||
import { randomUUID } from "node:crypto";
|
||
|
||
export class HttpError extends Error {
|
||
status: number;
|
||
code: string;
|
||
constructor(status: number, code: string, message: string) {
|
||
super(message);
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
export type Deps = {
|
||
// 上游 AI 服务:返回逐段文本的异步迭代器;真实项目里是对 Python 服务的 fetch 流式调用
|
||
askLLM: (question: string, signal: AbortSignal) => AsyncIterable<string>;
|
||
findOrder: (id: string) => Promise<{ id: string; status: string } | null>;
|
||
};
|
||
|
||
export function createApp(deps: Deps) {
|
||
const app = express();
|
||
app.use(express.json({ limit: "100kb" })); // 限制请求体大小
|
||
|
||
app.use((req, res, next) => { // 请求 id:贯穿日志和上游调用,便于排查
|
||
const id = req.get("x-request-id") ?? randomUUID();
|
||
res.locals.requestId = id;
|
||
res.set("x-request-id", id);
|
||
next();
|
||
});
|
||
|
||
app.get("/healthz", (_req, res) => {
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// Express 5:async 处理函数抛出的错误会自动交给错误处理中间件(Express 4 需要手动 next(err))
|
||
app.get("/orders/:id", async (req, res) => {
|
||
const id = req.params.id;
|
||
if (!/^[A-Z]\d{3,}$/.test(id)) throw new HttpError(400, "BAD_ORDER_ID", "订单号格式不正确");
|
||
const order = await deps.findOrder(id);
|
||
if (!order) throw new HttpError(404, "ORDER_NOT_FOUND", "订单不存在");
|
||
res.json(order);
|
||
});
|
||
|
||
app.post("/chat", async (req, res) => {
|
||
const question: unknown = req.body?.question;
|
||
if (typeof question !== "string" || question.length === 0 || question.length > 500) {
|
||
throw new HttpError(400, "BAD_QUESTION", "question 必须是 1~500 字的字符串");
|
||
}
|
||
const controller = new AbortController();
|
||
res.on("close", () => controller.abort()); // 客户端断开:通知上游停止生成
|
||
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
|
||
res.flushHeaders();
|
||
try {
|
||
for await (const token of deps.askLLM(question, controller.signal)) {
|
||
if (controller.signal.aborted) break;
|
||
res.write(`data: ${JSON.stringify({ token })}\n\n`);
|
||
}
|
||
res.write("event: done\ndata: {}\n\n");
|
||
} catch (e) {
|
||
// 响应头已经发出,不能再改状态码:用一个 error 事件告诉前端
|
||
res.write(`event: error\ndata: ${JSON.stringify({ message: "生成失败,请重试" })}\n\n`);
|
||
console.error(res.locals.requestId, e);
|
||
} finally {
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
app.use((_req, _res, next) => next(new HttpError(404, "NOT_FOUND", "接口不存在")));
|
||
|
||
// 统一错误处理:4 个参数。不把内部错误细节返回给客户端
|
||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||
if (err instanceof HttpError) {
|
||
res.status(err.status).json({ code: err.code, message: err.message, requestId: res.locals.requestId });
|
||
return;
|
||
}
|
||
const status = (err as { status?: number }).status;
|
||
if (status === 400 || status === 413) { // express.json 的解析错误 / 请求体过大
|
||
res.status(status).json({ code: "BAD_REQUEST", message: "请求体无效", requestId: res.locals.requestId });
|
||
return;
|
||
}
|
||
console.error(res.locals.requestId, err);
|
||
res.status(500).json({ code: "INTERNAL", message: "服务器内部错误", requestId: res.locals.requestId });
|
||
});
|
||
|
||
return app;
|
||
}
|