# 规则运行手册(W9-D) 实现:`packages/domain/src/rules/`(规则串解析/序列化、内置关键字、规则组链求值)、`packages/domain/src/slot/evaluate.ts`(接入版本目标评估)、 `apps/server/src/modules/acquisition-organize/{routes/rule-groups.ts, routes/rules.ts, services/rules.ts, services/acquisition.ts}`、迁移 `0033_rule_groups`。 设计:`docs/design/subscriptions.md` §6;MP 对照:`docs/porting/mp-rust-logic-parity.md` §6、`docs/porting/moviepilot-subscribe.md` §6。 ## 模型 **规则组**(`rule_group`)= 名称 + 类型 + 适用范围 + 结构化层 + 硬排除。规则串(`SPECSUB & CNSUB > 4K`)只是层的文本表示,存库的是结构化表达式,`rule_string` 是规范化后的缓存。 | 字段 | 含义 | | --- | --- | | `kind` | `acquisition` 订阅 / `upgrade` 洗版 / `search` 搜索 | | `scope` | `mediaTypes[]`(movie/series/variety)、`entityIds[]`、`siteIds[]`、`subscriptionId`(订阅私有组);空 = 全部 | | `layers[]` | 每层 `{name?, expr}`,`expr` 是 `{op:atom,id}` / `{op:not,arg}` / `{op:and\|or,args[]}` 树;第 k 层(0 起)命中 pri = 100 − k | | `hardExcludes[]` | 表达式列表,任一命中即拒绝 | | `ladderRequired` | 一层都不中即拒绝(MP 语义,缺省 true);false = 软偏好,不中只是 pri=0 | | `source` | `manual` 手动 / `chat` 对话中用户明确说的 / `agent` Agent 推断 | | `enabled`、`revision`、`fingerprint`、`suppressedAt`、`deletedAt` | 每次修改 revision+1 并写审计 `rule_group.*`;指纹 = hash(kind+scope+规范化层+硬排除+ladderRequired) | | `defaultOrder` | 非 null = 属于该类默认链 | | `legacyKind` | 由偏好标签迁来或经旧 `/preference-tags` 写入 | **自定义条件**(`rule_atom`):对齐 MP 自定义规则 7 字段 —— `key`(大写)、`include[]`/`exclude[]`(正则,MP 语法,`(?i)` 会去掉,统一不区分大小写;过防灾难回溯白名单)、`matchFields`、`unitSizeMb {min,max}`(剧集按每集)、`minSeeders`、`publishDelayMinutes {min,max}`、`free`。**不允许与内置关键字同名**(偏离 MP 的"同 id 覆盖",避免静默改义)。 ## 求值语义(对齐 MP `FilterModule`) 1. 组内:硬排除任一命中 → 拒绝(`rule.hard_exclude`);逐层匹配,首个命中层给 pri;有层且 `ladderRequired` 而一层不中 → 拒绝(`rule.no_layer_matched`)。 2. 多组串联取交集:任一组拒绝即拒绝;**pri 取链上最后一个带层的组**(没有层的组只起过滤作用)。 3. 层内 `!` > `&` > `|`,可加括号;表达式短路求值。未知条件视为不匹配(只可能出现在迁移前的旧数据里,新写入会被拒)。 4. 版本目标自身的 `hardConditions` / `softPreferences.tiers` 保留,视为链首的匿名组;规则组有层时 pri 以规则组为准,升级比较口径自动切到 pri(对照 MP 洗版 `pri_order > current_priority`)。 **引用链**(`services/rules.ts resolveRuleChain`):订阅私有组 → 版本目标 `ruleGroupIds`(非空时)|| 订阅 `params.ruleGroupIds` || 订阅默认链 → 版本目标开了升级时再叠加洗版默认链;逐个按 scope 过滤,跳过停用/已删。搜索用 `kind: "search"` 取搜索默认链(给 `release.search` / 手动搜索接入)。 候选评估记录(artifact `candidate_evaluation`)里:`pri` 为最终 pri;`reasons` 带 `rule_group:@`;`trace` 逐组逐层写明命中/未命中及每个原子的依据,如 `规则组「片源」@r1 第1层 4K:未命中(4K✗ 分辨率 1080p)`、`规则组「片源」@r1 硬排除 REMUX 命中(REMUX✓ 来源 REMUX)`。 ## 内置关键字 `GET /rules/keywords` 返回全表(含 MP 上游正则/条件原文与我们的判定依据 `basis`)。 | 关键字 | 我们的判定 | basis | | --- | --- | --- | | 4K / 1080P / 720P | 解析出的分辨率(1080P 含 1080i) | field | | BLU / REMUX / WEBDL | 解析出的来源(原盘 / Remux / WEB-DL+WEBRip) | field | | BLURAY | 来源属蓝光族(原盘、压制、标题带 Blu-ray 的 Remux) | field | | UHD | 标题 UHD / UltraHD | field | | H265 / H264 | 视频编码 | field | | HDR / DOLBY | HDR10/HDR10+/HDR Vivid;杜比视界 | field | | ATMOS / 60FPS | 音轨 Atmos;帧率 ≥ 60(均兼容 MP 正则) | field | | CNSUB | 字幕含中文;作品原语言 zh/cn 直接满足(MP tmdb 条件) | field | | CNVOI / HKVOI / SPECSUB / SDR / 3D | MP 正则;CNVOI 原语言 zh 直接满足 | regex | | GZ / FREE | 站点标签 官方/官种/官组;下载系数 = 0 | site | | 音乐 13 条(HIRES、FLAC、AAC…) | 不支持,规则串里出现报 `unsupported_atom` | — | ## 端点 | 端点 | 说明 | | --- | --- | | `GET /rules/keywords` | 内置 + 自定义条件 | | `POST /rules/parse {ruleString}` | → `{layers, ruleString(规范化,有错为 null), errors[{code, message, position, layer, atom?, suggestions?}]}`;code:`empty` `empty_layer` `unknown_atom` `unsupported_atom` `unbalanced_paren` `unexpected_token` `invalid_char` | | `POST /rules/serialize {layers}` | → `{ruleString}`(原子大写,只加必要括号) | | `GET / PUT /rules/defaults` | `{acquisition[], upgrade[], search[]}`,按数组顺序串联;类型不符 400 | | `GET/POST /rules`、`GET/PATCH/DELETE /rules/{id}` | 列表筛选 `kind source enabled includeDeleted q`;写入可给 `ruleString`(优先)或 `layers`,`hardExcludes` 元素可以是表达式或单层规则串;PATCH 可带 `expectedRevision`(不匹配 409);同名 409;条件无效 422;DELETE 被版本目标/订阅引用 409 | | `GET /rules/{id}/revisions` | 审计回放(含迁移前的 `preference_tag.*`) | | `POST /rules/{id}/preview` | `{limit?, sourceReleaseIds?, titles?[{title, subtitle?}], draft?}`;缺省最近 50 条已识别资源;`draft` 试算未保存的层/硬排除 → `{sampleSize, passed, rejected, priHistogram[{pri,count}], items[{title, passed, pri, level, excludedBy, reasons, trace}]}` | | `GET/POST /rules/atoms`、`GET/PATCH/DELETE /rules/atoms/{id}` | 自定义条件;与内置同名 409;被规则组引用不能删 409 | | `/rules/policies*` | 收录策略版本(草案→预览→发布→回滚),同 `/policy-revisions` | | `/rules/suppressions*` | 抑制记录,同 `/preference-suppressions`;行多了 `ruleGroupId` | | 版本目标 `ruleGroupIds` | `POST/PATCH /acquisition-slots` 可写,引用不存在的组 400;`SlotTemplate.ruleGroupIds` 建目标时写入 | | 订阅 `params.ruleGroupIds` | `PATCH /subscriptions/{id}` 的 params 键 | ## 抑制记录(偏好标签语义保留) - 停用(`enabled: false`)或删除规则组 → 写抑制记录(指纹 = 停用前的指纹),`suppressedAt` 置位。 - 对话/Agent 来源的组被用户改掉语义 → 旧指纹也写抑制("edited");手动组的编辑不写。 - `POST /rules` `source=agent` 命中有效抑制 → **409**;`manual`/`chat` 新建同指纹 → 解除抑制。非手动来源的同指纹重复新建 409。 ## 旧端点(弃用,保留到 v0.3) | 旧 | 新 | 备注 | | --- | --- | --- | | `/preference-tags*` | `/rules*` | 别名直接读写 `rule_group`(`legacy_kind` 非空的行);`rule.ruleString` 会编译:hard → 硬排除,soft → 单层软偏好;其余 rule 键只作记录、不参与评估。指纹沿用旧口径 hash(kind+scope+rule),旧抑制记录照常命中 | | `/policy-revisions*` | `/rules/policies*` | 同一张表、同一套处理器 | | `/preference-suppressions*` | `/rules/suppressions*` | 同上;`preferenceTagId` 对新建规则组回填为 `ruleGroupId` | `preference_tag` 表在 0033 后不再写入(数据已按原 id 迁到 `rule_group`,抑制记录按 `preference_tag_id` 回填 `rule_group_id`),回滚迁移前保留。 ## 差分测试 - 生成:`MP_ROOT=/opt/moviepilot-plus/upstream/MoviePilot $MP_ROOT/.venv/bin/python scripts/mp-sync/gen-rules-differential.py`,写 `packages/domain/test/differential/rules-cases.json`(登记的 deviations 重跑保留)。 - 覆盖:66 条规则链(旧 filter-cases 的 42 条 + 19 条新规则串 + 5 条多组串联)× 32 个种子 = 2112 case;64 个规则串的 pyparsing 结构。 - 结果:解析结构 64/64 一致、序列化往返一致;pri 2090/2112 一致(98.96%),22 处全是 **mp_bug**:无来源 2160p HEVC 被 BLU 正则误判原盘(t08)、副标题"国语/国配"被 CNSUB 当中字(t26/t32)、`.HDR-` 不被 HDR 正则命中(t02)。原 filter-cases 里 15 条"表达不了"的规则现在全部可表达,只剩音乐条件不支持。 - 临时看差异:`UMT_RULES_DUMP=1 deno run -A npm:vitest run test/differential/rules.test.ts`(在 `packages/domain` 下)。