Commit e214172c by luoqi

merge: fix/push-body-limit → test(push 请求体上限 10MB + 超限回执归 10002)

parents 1788ab19 2e315869
Pipeline #3457 failed in 0 seconds
......@@ -221,7 +221,7 @@ const sig = require('crypto').createHmac('sha256', SECRET).update(`${ts}.${body
| ------------------ | ------- | ----------------------------------------------------------------------- |
| 成功 | `0` | — |
| 验签失败 | `10106` | 检查 secret、时钟、签名串 |
| 字段校验失败 / 批量超限 | `10002` | 修正后重发 |
| 字段校验失败 / 批量超限(条数或字节) | `10002` | 修正后重发(字节超限见 §10,减小单批行数) |
| source 表名不识别 | `10001` | 核对表名 |
| 数据形态漂移(含整批全部失败) | `30802` | 核对数据字典,修正后原批重推 |
| 并发超限 | `10003` | 退避后重发本批 |
......@@ -249,11 +249,22 @@ PAC 侧对推送中断有告警。
| 项 | 约定 |
|---|---|
| 批量 | 两形态 ≤ 500 条/请求 |
| 批量(条数) | 两形态 ≤ 500 条/请求 |
| 批量(字节) | **请求体 ≤ 10 MB**,超出返 `10002`,减小单批行数后重发 |
| 并发 | 同 host 有并发额度,超限返 `10003`,退避重发即可 |
| 编码 | UTF-8 |
| 重试 | 幂等,可安全重试,建议指数退避 |
⚠️ **两条上限都要满足,先到先约束** —— 行的大小按表差很多,别只按条数切批:
| 表类型 | 单行典型大小 | 10 MB 约能装 | 建议单批 |
|---|---|---|---|
| 病历 / 诊断(含自由文本:主诉、检查、处置、医嘱) | 1.5-2.2 KB | ~5000 行 | **50-100 行** |
| 预约 / 接诊 / 结算(纯结构化字段) | ~400 B | ~25000 行 | 500 行(条数先到顶) |
病历类按 500 条切批很容易做出 700 KB 以上的包;虽然仍在 10 MB 内,但单请求处理时间会显著变长
(实测有整批超时被客户端断连的情况),所以建议按上表压到 50-100 行。
---
## 11. 接入清单
......
......@@ -30,9 +30,11 @@ import { BizError } from '../errors/biz-error';
* Code mapping policy (priority highest → lowest):
* 1. BizError → use its explicit 5-digit code
* 2. ZodValidationException → CLIENT_VALIDATION_FAILED (10002)
* 3. ContractDriftError → SYNC_CONTRACT_DRIFT (30802)
* 4. NestJS HttpException → mapped by class (UnauthorizedException → 10106, etc.)
* 5. Plain Error → INTERNAL_ERROR (90000), HTTP 500
* 3. PayloadTooLargeError → CLIENT_VALIDATION_FAILED (10002), HTTP 200 —— 裸 Error,
* 必须排在第 5 条之前拦下,否则对面只看到 500/90000
* 4. ContractDriftError → SYNC_CONTRACT_DRIFT (30802)
* 5. NestJS HttpException → mapped by class (UnauthorizedException → 10106, etc.)
* 6. Plain Error → INTERNAL_ERROR (90000), HTTP 500
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
......@@ -63,6 +65,18 @@ export class AllExceptionsFilter implements ExceptionFilter {
message: i.message,
}));
}
} else if (isPayloadTooLargeError(exception)) {
// body-parser 的 PayloadTooLargeError 是**裸 Error**(不是 HttpException)→ 不拦的话掉进
// 下面 500/90000 分支,对面只看到"PAC 内部错误"、按文档去退避重试,永远重试不好
// (2026-07 FRIDAY 增量 push 整点重试连挂 25 次就是这么来的)。
// 归 10002:接入文档 §9.1 把「字段校验失败 / 批量超限」定为 10002「修正后重发」——
// 包超限正是"这批得改小了再发",语义与处置动作都对得上。
// HTTP 保持 200:同文档「HTTP 恒为 200(仅进程级故障返回 5xx)」,客户端只认 body.code。
// 也**不上报 Sentry**:这是对面发包过大,不是 PAC 故障。
code = ApiCode.CLIENT_VALIDATION_FAILED;
msg = `请求体超出上限(${PAC_BODY_LIMIT_HINT}),请减小单批行数后重发`;
details = { limit: PAC_BODY_LIMIT_HINT, hint: '病历类行含自由文本,建议单批 50-100 行' };
this.logger.warn(`${req.method} ${req.url} → 请求体超限,已按 10002 回执`);
} else if (exception instanceof ContractDriftError) {
code = ApiCode.SYNC_CONTRACT_DRIFT;
msg = exception.message;
......@@ -122,3 +136,18 @@ function mapNestExceptionToCode(exc: HttpException, status: number): number {
if (status === 429) return ApiCode.CLIENT_RATE_LIMITED;
return ApiCode.INTERNAL_ERROR;
}
/// 请求体上限的展示串 —— 仅用于回执文案 / details。真值在 main.ts 的 PAC_BODY_LIMIT,
/// 两处都改才对得上(这里不 import main.ts:那会把 bootstrap 副作用拖进过滤器)。
const PAC_BODY_LIMIT_HINT = '10MB';
/**
* body-parser(raw-body)在请求体超过 limit 时抛的错 —— **裸 Error,不是 HttpException**,
* 也不导出类型可供 instanceof。稳定特征是 `type === 'entity.too.large'`(raw-body 固定写入),
* 辅以 status/statusCode 413 兜底(不同版本字段名有出入)。
*/
function isPayloadTooLargeError(e: unknown): boolean {
if (!(e instanceof Error)) return false;
const x = e as Error & { type?: string; status?: number; statusCode?: number };
return x.type === 'entity.too.large' || x.status === 413 || x.statusCode === 413;
}
......@@ -4,6 +4,7 @@ import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { apiReference } from '@scalar/nestjs-api-reference';
import type { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
......@@ -13,9 +14,26 @@ import {
buildPacOpenApiDocument,
} from './openapi/build-document';
/// 请求体上限(json / urlencoded 共用)。见 bootstrap 内注释:不显式设就是 100KB 默认值。
const PAC_BODY_LIMIT = '10mb';
async function bootstrap() {
// rawBody: true → req.rawBody 保留原始 bytes,push HMAC 验签需要(parsed JSON 字段顺序不稳)
const app = await NestFactory.create(AppModule, { bufferLogs: true, rawBody: true });
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bufferLogs: true,
rawBody: true,
});
// ⭐ 请求体上限 —— **必须显式设**:不设就吃 body-parser 出厂默认 100KB。
// 2026-07 线上(测试机)踩雷:FRIDAY 增量 push `med_emr_info` 整点重试连挂 25 次,
// 报 `PayloadTooLargeError` → 兜成 500/90000,对面完全看不出是自己包太大。
// 接入文档 §10 只约束"≤500 条/请求",没约束字节 —— 而病历行带自由文本(实测均 1.4KB、
// 峰值 2.2KB),500 条 ≈ 710KB,超默认值 7 倍必挂;连 405B 的预约行 500 条(≈198KB)也超。
// 取 10MB:覆盖 500 条病历十几倍余量,又远低于网关 client_max_body_size 50M(不会把
// 拦截点推到网关,那层返 HTML 413,对面更难排查)。rawBody 由上面的 create 选项保留,
// useBodyParser 重设 limit 不影响验签。
app.useBodyParser('json', { limit: PAC_BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: PAC_BODY_LIMIT, extended: true });
app.use(helmet({ contentSecurityPolicy: false }));
......
import { ArgumentsHost, HttpStatus } from '@nestjs/common';
import { ApiCode } from '@pac/types';
import { AllExceptionsFilter } from '../src/common/filters/all-exceptions.filter';
/**
* 请求体超限回执契约(2026-07 FRIDAY 增量 push 线上问题)。
*
* body-parser 的 PayloadTooLargeError 是**裸 Error**,不拦就掉进 500/90000 分支 ——
* 对面按接入文档 §9.1 把 9xxxx 当"PAC 内部错误 → 退避重试",于是整点重试永远重试不好。
* 本文件锁住:超限必须回 10002 + HTTP 200,且带可执行的提示。
*/
function makeHost() {
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const host = {
switchToHttp: () => ({
getResponse: () => ({ status }),
getRequest: () => ({ method: 'POST', url: '/pac/v1/push/rows' }),
}),
} as unknown as ArgumentsHost;
return { host, status, json };
}
/** 复刻 raw-body 抛的形状(它不导出类型,只能按特征字段构造) */
function payloadTooLarge(shape: 'type' | 'status' | 'statusCode') {
const e = new Error('request entity too large') as Error & Record<string, unknown>;
if (shape === 'type') e.type = 'entity.too.large';
if (shape === 'status') e.status = 413;
if (shape === 'statusCode') e.statusCode = 413;
return e;
}
describe('AllExceptionsFilter — 请求体超限', () => {
const filter = new AllExceptionsFilter();
it.each(['type', 'status', 'statusCode'] as const)(
'⭐ 按 %s 特征识别 → 10002 + HTTP 200(不是 90000/500)',
(shape) => {
const { host, status, json } = makeHost();
filter.catch(payloadTooLarge(shape), host);
expect(status).toHaveBeenCalledWith(HttpStatus.OK); // 文档:HTTP 恒为 200
const body = json.mock.calls[0][0];
expect(body.code).toBe(ApiCode.CLIENT_VALIDATION_FAILED); // 10002 = 批量超限,修正后重发
expect(body.code).not.toBe(ApiCode.INTERNAL_ERROR); // 不能再是 90000
expect(body.data).toBeNull();
},
);
it('回执要能指导对面动作(带上限 + 建议批量)', () => {
const { host, json } = makeHost();
filter.catch(payloadTooLarge('type'), host);
const body = json.mock.calls[0][0];
expect(body.msg).toContain('10MB');
expect(body.msg).toContain('减小单批行数');
expect(body.details).toMatchObject({ limit: '10MB' });
});
it('普通 Error 不受影响 —— 仍是 90000 + HTTP 500(基础设施要能看见真故障)', () => {
const { host, status, json } = makeHost();
filter.catch(new Error('boom'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(json.mock.calls[0][0].code).toBe(ApiCode.INTERNAL_ERROR);
});
it('不能误伤:仅消息里含 "too large" 但无特征字段的普通 Error 仍走 500', () => {
const { host, status } = makeHost();
filter.catch(new Error('some payload is too large for the queue'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment