feat(sites): 站点请求失败留样(site_failure_sample,每站最近 5 份)

- 迁移 0077 新表 site_failure_sample:类型(parse_error / challenge)、请求类型、脱敏 URL、状态码、
  关键响应头、错误文案、命中规则、脱敏页面开头(≤ 64 KB);样本几十 KB,不放进每条请求都读的 site.config
- 适配器 onFailureSample 回调接到 runtime.siteAdapter,异步写入并删到最近 5 份,写失败只记日志
- GET /sites/{id}/failure-samples(不带正文)与 /failure-samples/{sampleId}(带正文)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:41:00 +08:00
1 parent 1779e1a0f2
commit 0088c1c9e7
10 files changed
+355

No files matched your search

+29
View File
@@ -701,6 +701,35 @@ export const SiteSnapshot = EntityBase.extend({
});
export type SiteSnapshot = z.infer<typeof SiteSnapshot>;
/** 失败留样的类型:页面结构无法识别 / 人机验证 */
export const SiteFailureSampleKind = z.enum(["parse_error", "challenge"]);
export type SiteFailureSampleKind = z.infer<typeof SiteFailureSampleKind>;
/**
* 站点请求失败留样(列表 / 搜索 / 详情报 parse_error / challenge 时存的脱敏页面开头,每站最近 5 份)。
* requestKind:search / list / detail / health / other;headers 只有 server、cf-mitigated、cf-ray、content-type。
*/
export const SiteFailureSample = z.object({
id: Id,
siteId: Id,
kind: SiteFailureSampleKind,
requestKind: z.string(),
/** 已脱敏 */
url: z.string(),
httpStatus: z.number().int().nullable(),
headers: z.record(z.string(), z.string()),
message: z.string(),
rule: z.string().nullable(),
/** 脱敏后的页面开头(≤ 64 KB) */
body: z.string(),
truncated: z.boolean(),
createdAt: Timestamp,
});
export type SiteFailureSample = z.infer<typeof SiteFailureSample>;
/** 列表里不带页面正文,只给字节数 */
export const SiteFailureSampleSummary = SiteFailureSample.omit({ body: true }).extend({ bodyBytes: NonNegInt });
export type SiteFailureSampleSummary = z.infer<typeof SiteFailureSampleSummary>;
/** 站点访问默认(站点定义 umt.defaults 与 settings.sites 合并后,取更保守的一方) */
export const SiteAccessDefaults = z.object({
snapshotIntervalSec: z.number().int(),