feat(rules): rule_group/rule_atom (0033), /rules endpoints, evaluation via referenced rule groups

- migration 0033: rule_group (layers, hard excludes, scope, source, revision,
  fingerprint, default_order) and rule_atom; acquisition_slot.rule_group_ids;
  preference tags migrated into rule groups (same id/fingerprint), suppressions
  linked; seeded default groups for acquisition / upgrade / search
- contracts: RuleGroup/RuleAtom/RuleExpr/RuleDefaults, parse/serialize/preview
  schemas; Api.rules, Api.ruleAtoms, Api.rulePolicies, Api.ruleSuppressions
- server: GET/POST/PATCH/DELETE /rules, /rules/{id}/revisions, /rules/{id}/preview,
  /rules/parse, /rules/serialize, /rules/keywords, /rules/defaults, /rules/atoms;
  disable/delete writes suppression, agent re-add -> 409
- /preference-tags now aliases rule groups; /policy-revisions and
  /preference-suppressions kept as aliases of /rules/policies, /rules/suppressions
- evaluateAcquisition resolves slot -> subscription -> default chain (+ upgrade
  defaults) and records pri / rule_group@revision / per-layer trace

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:08:00 +08:00
1 parent a0946bf480
commit 0105e5c6ac
11 files changed
+2296 -340

No files matched your search

+160
View File
@@ -751,6 +751,166 @@ export const Api = {
R,
),
// ── 规则(W9-D):规则组 = 收录过滤条件 + 偏好标签合一。静态路径先于 /rules/{id} 注册 ──
rules: {
keywords: ep({
method: "get",
path: "/rules/keywords",
scope: "read",
summary: "关键字表:内置(对齐 MP,含判定依据与上游正则)+ 自定义条件",
response: policy.RuleKeywordList,
}),
parse: ep({
method: "post",
path: "/rules/parse",
scope: "read",
summary: "规则串 → 结构化层(`>` 分层,层内 ! & | 与括号);错误逐条给位置与相近名",
body: policy.RuleParseRequest,
response: policy.RuleParseResult,
}),
serialize: ep({
method: "post",
path: "/rules/serialize",
scope: "read",
summary: "结构化层 → 规范化规则串",
body: policy.RuleSerializeRequest,
response: policy.RuleSerializeResult,
}),
defaults: ep({
method: "get",
path: "/rules/defaults",
scope: "read",
summary: "三套默认规则组链:订阅 / 洗版 / 搜索",
response: policy.RuleDefaults,
}),
setDefaults: ep({
method: "put",
path: "/rules/defaults",
scope: "admin",
summary: "设置默认规则组链(按数组顺序串联,pri 取最后一个带层的组)",
body: policy.RuleDefaults,
response: policy.RuleDefaults,
}),
...crud(
"/rules",
"规则组",
{
row: policy.RuleGroup,
create: policy.RuleGroupCreate,
update: policy.RuleGroupUpdate,
filters: {
kind: policy.RuleGroupKind.optional(),
source: policy.RuleGroupSource.optional(),
enabled: z.enum(["true", "false"]).optional(),
includeDeleted: z.enum(["true", "false"]).optional(),
q: z.string().max(80).optional(),
},
},
R,
),
/** 删除 = 标记 deleted_at + 写抑制记录 */
remove: del("/rules/{id}", "删除规则组(留抑制记录;被引用时 409)"),
revisions: ep({
method: "get",
path: "/rules/{id}/revisions",
scope: "read",
summary: "规则组修订历史(新→旧;取自审计事件)",
params: IdParam,
response: policy.RuleGroupRevisionList,
}),
preview: ep({
method: "post",
path: "/rules/{id}/preview",
scope: "read",
summary: "试算:对最近 N 条已识别资源(或指定资源/标题)跑本组,给 pri、命中层与拒绝原因",
params: IdParam,
body: policy.RulePreviewRequest,
response: policy.RulePreviewResult,
}),
},
ruleAtoms: {
...crud(
"/rules/atoms",
"自定义条件",
{ row: policy.RuleAtom, create: policy.RuleAtomCreate, update: policy.RuleAtomUpdate },
R,
),
remove: del("/rules/atoms/{id}", "删除自定义条件(被规则组引用时 409)"),
},
/** /policy-revisions 的新路径(旧路径保留为别名,见 docs/runbook/rules.md) */
rulePolicies: {
list: ep({
method: "get",
path: "/rules/policies",
scope: "read",
summary: "收录策略版本列表",
query: listQuery({ policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }),
response: cursorPage(policy.PolicyRevision),
}),
get: ep({
method: "get",
path: "/rules/policies/{id}",
scope: "read",
summary: "收录策略版本",
params: IdParam,
response: policy.PolicyRevision,
}),
create: ep({
method: "post",
path: "/rules/policies",
scope: "admin",
summary: "新建策略草案",
body: policy.PolicyRevisionCreate,
response: policy.PolicyRevision,
}),
update: ep({
method: "patch",
path: "/rules/policies/{id}",
scope: "admin",
summary: "修改策略草案",
params: IdParam,
body: policy.PolicyRevisionUpdate,
response: policy.PolicyRevision,
}),
preview: ep({
method: "post",
path: "/rules/policies/{id}/preview",
scope: "read",
summary: "预览策略草案",
params: IdParam,
response: policy.PolicyRevision,
}),
publish: ep({
method: "post",
path: "/rules/policies/{id}/publish",
scope: "admin",
summary: "发布策略版本",
params: IdParam,
body: z.object({ expectedRevision: z.number().int().min(1) }),
response: policy.PolicyRevision,
}),
rollback: ep({
method: "post",
path: "/rules/policies/{id}/rollback",
scope: "admin",
summary: "回滚到该策略版本",
params: IdParam,
response: policy.PolicyRevision,
}),
},
/** /preference-suppressions 的新路径(旧路径保留为别名) */
ruleSuppressions: crud(
"/rules/suppressions",
"规则抑制记录",
{
row: policy.PreferenceSuppression,
create: policy.PreferenceSuppressionCreate,
update: policy.PreferenceSuppressionUpdate,
filters: { active: z.enum(["true", "false"]).optional() },
},
R,
),
// ── 执行 ──
tasks: {
...crud(
+283
View File
@@ -101,6 +101,8 @@ export const SubscriptionParams = z
.optional(),
stopAfter: z.string().optional(),
downloaderInstanceId: Id.optional(),
/** 引用的规则组(有序,串联取交集,pri 取最后一个带层的组);空 = 订阅默认组 */
ruleGroupIds: z.array(Id).optional(),
})
.passthrough();
export type SubscriptionParams = z.infer<typeof SubscriptionParams>;
@@ -266,6 +268,8 @@ export const AcquisitionSlot = EntityBase.extend({
lastEvaluatedAt: Timestamp.nullable(),
nextEvaluateAt: Timestamp.nullable(),
revision: z.number().int().min(1),
/** 引用的规则组(有序);空 = 订阅的 ruleGroupIds,再空 = 订阅默认组(开升级时叠加洗版默认组) */
ruleGroupIds: z.array(Id),
/** 派生字段(列表返回):当前资产的交付特征摘要,无资产为 null */
currentAsset: z
.object({
@@ -293,6 +297,7 @@ export const AcquisitionSlotCreate = AcquisitionSlot.pick({
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
ruleGroupIds: true,
}).partial({
contentUnitId: true,
subscriptionId: true,
@@ -304,6 +309,7 @@ export const AcquisitionSlotCreate = AcquisitionSlot.pick({
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
ruleGroupIds: true,
});
export type AcquisitionSlotCreate = z.infer<typeof AcquisitionSlotCreate>;
/** 规则可改;status/currentAsset/reservedPlan 只由状态机推进,不经 Update */
@@ -407,6 +413,8 @@ export type PreferenceTagUpdate = z.infer<typeof PreferenceTagUpdate>;
/** 手动取消标签留下的抑制记录:Agent 不得据同一旧证据自动加回 */
export const PreferenceSuppression = EntityBase.extend({
preferenceTagId: Id.nullable(),
/** 被停用/删除的规则组(偏好标签迁移后与 preferenceTagId 相同) */
ruleGroupId: Id.nullable().optional(),
fingerprint: z.string().min(1),
reason: z.string().nullable(),
evidence: JsonObject,
@@ -433,6 +441,8 @@ export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUp
/** Slot 模板:策略规则里用于自动建 Slot(新作品收录 / 订阅缺省 Slot);字段同 AcquisitionSlot 规则列 */
export const SlotTemplate = z.object({
name: z.string().min(1).max(64),
/** 建版本目标时写入 ruleGroupIds */
ruleGroupIds: z.array(Id).optional(),
hardConditions: JsonObject.optional(),
softPreferences: JsonObject.optional(),
fallback: JsonObject.optional(),
@@ -484,3 +494,276 @@ export const CandidateEvaluation = z.object({
unitKeys: z.array(z.string()).optional(),
});
export type CandidateEvaluation = z.infer<typeof CandidateEvaluation>;
// ── 规则组(W9-D):收录策略的过滤条件 + 偏好标签合一 ──
/** 条件表达式:内置关键字 / 自定义条件的 AND / OR / NOT 树(与 @umt/domain RuleExpr 同形) */
export type RuleExpr =
| { op: "atom"; id: string }
| { op: "not"; arg: RuleExpr }
| { op: "and"; args: RuleExpr[] }
| { op: "or"; args: RuleExpr[] };
function isRuleExpr(v: unknown, depth = 0): v is RuleExpr {
if (depth > 32 || !v || typeof v !== "object") return false;
const o = v as Record<string, unknown>;
switch (o.op) {
case "atom":
return typeof o.id === "string" && /^[A-Za-z0-9]{1,32}$/.test(o.id);
case "not":
return isRuleExpr(o.arg, depth + 1);
case "and":
case "or":
return Array.isArray(o.args) && o.args.length >= 1 && o.args.every((a) => isRuleExpr(a, depth + 1));
default:
return false;
}
}
/** 递归结构在 refine 里校验(OpenAPI 只展开一层) */
export const RuleExpr = z
.object({
op: z.enum(["atom", "not", "and", "or"]),
/** op=atom:关键字或自定义条件 id */
id: z
.string()
.regex(/^[A-Za-z0-9]{1,32}$/)
.optional(),
/** op=not */
arg: JsonObject.optional(),
/** op=and/or */
args: z.array(JsonObject).optional(),
})
.refine(isRuleExpr, {
message: "条件表达式结构不对:{op:atom,id} / {op:not,arg} / {op:and|or,args[]}",
}) as unknown as z.ZodType<RuleExpr>;
/** 优先级阶梯的一层;第 k 层(0 起)命中 pri = 100 - k */
export const RuleLayer = z.object({ name: z.string().max(40).optional(), expr: RuleExpr });
export type RuleLayer = z.infer<typeof RuleLayer>;
/** acquisition 订阅 / upgrade 洗版 / search 搜索 */
export const RuleGroupKind = z.enum(["acquisition", "upgrade", "search"]);
export type RuleGroupKind = z.infer<typeof RuleGroupKind>;
/** 来源:manual 手动 / chat 对话中用户明确说的 / agent Agent 推断 */
export const RuleGroupSource = z.enum(["manual", "chat", "agent"]);
export type RuleGroupSource = z.infer<typeof RuleGroupSource>;
/** 适用范围;空 = 全部。subscriptionId = 订阅私有组 */
export const RuleGroupScope = z
.object({
mediaTypes: z.array(SubscriptionMediaKind).optional(),
entityIds: z.array(Id).optional(),
siteIds: z.array(Id).optional(),
subscriptionId: Id.optional(),
})
.passthrough();
export type RuleGroupScope = z.infer<typeof RuleGroupScope>;
export const RuleGroup = EntityBase.extend({
name: z.string().min(1).max(80),
description: z.string().nullable(),
kind: RuleGroupKind,
scope: RuleGroupScope,
layers: z.array(RuleLayer),
/** 任一命中即拒绝 */
hardExcludes: z.array(RuleExpr),
/** 一层都不命中即拒绝(MP 语义);false = 软偏好,不中只是 pri 低 */
ladderRequired: z.boolean(),
/** layers 的规范化规则串(无层为 null) */
ruleString: z.string().nullable(),
source: RuleGroupSource,
enabled: z.boolean(),
revision: z.number().int().min(1),
/** 语义指纹(kind + scope + 层 + 硬排除),停用/删除后写抑制记录 */
fingerprint: z.string(),
evidence: JsonObject,
/** 非 null = 属于该类默认链,按此排序 */
defaultOrder: z.number().int().nullable(),
/** 引用本组的订阅缺省是否自动补全其他版本;null = 不表态 */
autoFill: z.boolean().nullable(),
/** 由偏好标签迁来/经旧端点写入时的旧 kind */
legacyKind: PreferenceKind.nullable(),
suppressedAt: Timestamp.nullable(),
deletedAt: Timestamp.nullable(),
});
export type RuleGroup = z.infer<typeof RuleGroup>;
/** 层与硬排除可直接给规则串:ruleString 覆盖 layers;hardExcludes 的元素可以是表达式或单层规则串 */
export const RuleGroupCreate = z.object({
name: z.string().min(1).max(80),
description: z.string().max(500).nullable().optional(),
kind: RuleGroupKind.optional(),
scope: RuleGroupScope.optional(),
layers: z.array(RuleLayer).max(50).optional(),
ruleString: z.string().max(2000).optional(),
hardExcludes: z
.array(z.union([z.string().min(1).max(500), RuleExpr]))
.max(50)
.optional(),
ladderRequired: z.boolean().optional(),
source: RuleGroupSource.optional(),
enabled: z.boolean().optional(),
evidence: JsonObject.optional(),
autoFill: z.boolean().nullable().optional(),
});
export type RuleGroupCreate = z.infer<typeof RuleGroupCreate>;
export const RuleGroupUpdate = RuleGroupCreate.omit({ source: true })
.partial()
.extend({ expectedRevision: z.number().int().min(1).optional() });
export type RuleGroupUpdate = z.infer<typeof RuleGroupUpdate>;
/** 规则组修订历史(审计事件回放,新→旧) */
export const RuleGroupRevisionList = z.object({
items: z.array(
z.object({
id: Id,
at: Timestamp,
action: z.string(),
actorType: z.string(),
actorId: z.string().nullable(),
revision: z.number().int().nullable(),
changedFields: z.array(z.string()),
snapshot: JsonObject,
}),
),
});
export type RuleGroupRevisionList = z.infer<typeof RuleGroupRevisionList>;
export const RuleParseError = z.object({
code: z.enum([
"empty",
"empty_layer",
"unknown_atom",
"unsupported_atom",
"unbalanced_paren",
"unexpected_token",
"invalid_char",
]),
message: z.string(),
/** 在整个规则串里的字符偏移(0 起) */
position: z.number().int(),
/** 第几层(0 起) */
layer: z.number().int(),
atom: z.string().optional(),
suggestions: z.array(z.string()).optional(),
});
export type RuleParseError = z.infer<typeof RuleParseError>;
export const RuleParseRequest = z.object({ ruleString: z.string().max(2000) });
export const RuleParseResult = z.object({
layers: z.array(RuleLayer),
/** 规范化后的规则串(有错误时为 null) */
ruleString: z.string().nullable(),
errors: z.array(RuleParseError),
});
export type RuleParseResult = z.infer<typeof RuleParseResult>;
export const RuleSerializeRequest = z.object({ layers: z.array(RuleLayer).max(50) });
export const RuleSerializeResult = z.object({ ruleString: z.string() });
/** 试算:缺省取最近 N 条已识别资源;也可指定资源或粘贴标题;draft 覆盖未保存的修改 */
export const RulePreviewRequest = z.object({
limit: z.number().int().min(1).max(200).optional(),
sourceReleaseIds: z.array(Id).max(200).optional(),
titles: z
.array(z.object({ title: z.string().min(1).max(500), subtitle: z.string().max(500).optional() }))
.max(50)
.optional(),
draft: RuleGroupCreate.pick({ layers: true, ruleString: true, hardExcludes: true, ladderRequired: true })
.partial()
.optional(),
});
export type RulePreviewRequest = z.infer<typeof RulePreviewRequest>;
export const RulePreviewItem = z.object({
sourceReleaseId: Id.nullable(),
title: z.string(),
subtitle: z.string().nullable(),
passed: z.boolean(),
/** 命中层的 pri;被拒绝为 null;无层为 100 */
pri: z.number().int().nullable(),
/** 命中的层(0 起) */
level: z.number().int().nullable(),
excludedBy: z.array(z.string()),
/** 拒绝原因摘要:hard_exclude / no_layer_matched */
reasons: z.array(z.string()),
trace: z.array(z.string()),
});
export const RulePreviewResult = z.object({
ruleGroupId: Id,
revision: z.number().int(),
evaluatedAt: Timestamp,
sampleSize: z.number().int(),
passed: z.number().int(),
rejected: z.number().int(),
/** 各 pri 的条数(pri=null 为被拒绝) */
priHistogram: z.array(z.object({ pri: z.number().int().nullable(), count: z.number().int() })),
items: z.array(RulePreviewItem),
});
export type RulePreviewResult = z.infer<typeof RulePreviewResult>;
/** 关键字表:内置(对齐 MP)+ 自定义条件 */
export const RuleKeyword = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
builtin: z.boolean(),
/** false = 音乐等未迁移条件,规则串里出现会报错 */
supported: z.boolean(),
/** 我们的判定依据:field 解析字段 / regex MP 正则 / site 站点事实 / custom 自定义条件 */
basis: z.enum(["field", "regex", "site", "custom"]),
/** MP 上游定义(include/exclude/match/tmdb/downloadvolumefactor) */
mp: JsonObject.nullable(),
});
export type RuleKeyword = z.infer<typeof RuleKeyword>;
export const RuleKeywordList = z.object({ items: z.array(RuleKeyword) });
/** 自定义条件(对齐 MP 自定义规则;不允许覆盖内置关键字) */
export const RuleAtom = EntityBase.extend({
/** 大写存储;写入时不区分大小写 */
key: z
.string()
.max(32)
.regex(/^(?:[A-Za-z][A-Za-z0-9]*|[0-9]+[A-Za-z][A-Za-z0-9]*)$/),
name: z.string().nullable(),
description: z.string().nullable(),
include: z.array(z.string()),
exclude: z.array(z.string()),
matchFields: z.array(z.enum(["title", "subtitle", "labels"])),
/** MB,剧集按每集 */
unitSizeMb: z.object({ min: z.number().min(0).optional(), max: z.number().min(0).optional() }).nullable(),
minSeeders: z.number().int().min(0).nullable(),
publishDelayMinutes: z.object({ min: z.number().min(0).optional(), max: z.number().min(0).optional() }).nullable(),
free: z.boolean(),
});
export type RuleAtom = z.infer<typeof RuleAtom>;
export const RuleAtomCreate = RuleAtom.pick({
key: true,
name: true,
description: true,
include: true,
exclude: true,
matchFields: true,
unitSizeMb: true,
minSeeders: true,
publishDelayMinutes: true,
free: true,
}).partial({
name: true,
description: true,
include: true,
exclude: true,
matchFields: true,
unitSizeMb: true,
minSeeders: true,
publishDelayMinutes: true,
free: true,
});
export type RuleAtomCreate = z.infer<typeof RuleAtomCreate>;
export const RuleAtomUpdate = RuleAtomCreate.omit({ key: true }).partial();
export type RuleAtomUpdate = z.infer<typeof RuleAtomUpdate>;
/** 三套默认链:订阅 / 洗版(开升级时叠加)/ 搜索;按数组顺序串联 */
export const RuleDefaults = z.object({
acquisition: z.array(Id),
upgrade: z.array(Id),
search: z.array(Id),
});
export type RuleDefaults = z.infer<typeof RuleDefaults>;
+5
View File
@@ -112,6 +112,9 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
ScheduledJobRun: { row: C.ScheduledJobRun },
// W7-A Pi 上游(0027):secret_ref 不出 API
PiUpstream: { row: C.PiUpstreamRow, dbOnly: ["secretRef"] },
// W9-D 规则组(0033):legacy_rule 只供旧 /preference-tags 别名回放
RuleGroup: { row: C.RuleGroup, dbOnly: ["legacyRule"] },
RuleAtom: { row: C.RuleAtom },
};
/** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */
@@ -199,6 +202,8 @@ const ENUMS: Record<string, z.ZodEnum> = {
PolicyRevisionStatus: C.PolicyStatus,
PreferenceTagKind: C.PreferenceKind,
PreferenceTagOrigin: C.PreferenceOrigin,
RuleGroupKind: C.RuleGroupKind,
RuleGroupSource: C.RuleGroupSource,
PreferenceTagStatus: C.PreferenceStatus,
ReconcileRunKind: C.ReconcileKind,
ReconcileRunMode: C.ReconcileMode,
@@ -0,0 +1,90 @@
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// W9-D 规则组:收录策略的过滤条件 + 偏好标签合一(docs/design/subscriptions.md §6)。
// rule_group = 结构化层(每层一个 AND/OR/NOT 表达式,第 k 层 pri = 100 - k)+ 硬排除;规则串只是它的文本表示。
// rule_atom = 自定义条件(对齐 MP 自定义规则 7 字段,不允许覆盖内置关键字)。
// 偏好标签原样迁成规则组(同 id、同指纹,legacy_kind/legacy_rule 保留旧语义),旧端点改为别名;抑制记录挂到规则组。
// 版本目标 rule_group_ids 有序引用规则组;default_order 非空 = 该类(订阅/洗版/搜索)默认链的一员。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"rule_group",
`
name text not null check (length(name) between 1 and 80),
description text,
kind text not null default 'acquisition' ${oneOf("kind", ["acquisition", "upgrade", "search"])},
scope jsonb not null default '{}'::jsonb,
layers jsonb not null default '[]'::jsonb,
hard_excludes jsonb not null default '[]'::jsonb,
ladder_required boolean not null default true,
rule_string text,
source text not null default 'manual' ${oneOf("source", ["manual", "chat", "agent"])},
enabled boolean not null default true,
revision integer not null default 1 check (revision >= 1),
fingerprint text not null,
evidence jsonb not null default '{}'::jsonb,
default_order integer,
auto_fill boolean,
legacy_kind text check (legacy_kind is null or legacy_kind in ('soft', 'hard', 'version', 'target')),
legacy_rule jsonb,
suppressed_at timestamptz,
deleted_at timestamptz`,
)}
create index rule_group_live_idx on rule_group (kind, default_order) where deleted_at is null;
create index rule_group_fingerprint_idx on rule_group (fingerprint) where deleted_at is null;
${entityTable(
"rule_atom",
`
key text not null unique check (key ~ '^([A-Z][A-Z0-9]*|[0-9]+[A-Z][A-Z0-9]*)$'),
name text,
description text,
include jsonb not null default '[]'::jsonb,
exclude jsonb not null default '[]'::jsonb,
match_fields jsonb not null default '[]'::jsonb,
unit_size_mb jsonb,
min_seeders integer check (min_seeders is null or min_seeders >= 0),
publish_delay_minutes jsonb,
free boolean not null default false`,
)}
alter table acquisition_slot add column rule_group_ids jsonb not null default '[]'::jsonb check (jsonb_typeof(rule_group_ids) = 'array');
alter table preference_suppression add column rule_group_id uuid references rule_group (id) on delete set null;
create index preference_suppression_rule_group_idx on preference_suppression (rule_group_id) where rule_group_id is not null;
insert into rule_group (id, name, description, kind, scope, layers, hard_excludes, ladder_required, source, enabled,
revision, fingerprint, evidence, legacy_kind, legacy_rule, suppressed_at, deleted_at,
created_at, updated_at)
select id, label, description, 'acquisition', scope, '[]'::jsonb, '[]'::jsonb, kind <> 'soft',
case origin when 'manual' then 'manual' when 'conversation' then 'chat' else 'agent' end,
status = 'active', revision, fingerprint, evidence, kind, rule,
case when status <> 'active' then updated_at end,
case when status = 'deleted' then updated_at end,
created_at, updated_at
from preference_tag;
update preference_suppression set rule_group_id = preference_tag_id where preference_tag_id is not null;
insert into rule_group (name, description, kind, source, fingerprint, default_order) values
('订阅默认', '订阅没有指定规则组时使用', 'acquisition', 'manual', 'seed:acquisition', 0),
('洗版默认', '版本目标开启升级时额外叠加', 'upgrade', 'manual', 'seed:upgrade', 0),
('搜索默认', '手动搜索与 Agent 搜索资源时使用', 'search', 'manual', 'seed:search', 0);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table preference_suppression drop column if exists rule_group_id;
alter table acquisition_slot drop column if exists rule_group_ids;
${dropTables(["rule_atom", "rule_group"])}
`,
);
}
+53
View File
@@ -206,6 +206,8 @@ export type ResolutionAttemptOutcome =
| "manual"
| "skipped_locked";
export type ResolutionAttemptActorType = "user" | "agent" | "system" | "api_key";
export type RuleGroupKind = "acquisition" | "upgrade" | "search";
export type RuleGroupSource = "manual" | "chat" | "agent";
export type ScheduledJobBoardGroup = "collect" | "download" | "organize" | "maintain" | "agent";
export type ScheduledJobKind = "task" | "agent_check";
export type ScheduledJobLastStatus = "ok" | "failed" | "skipped" | "running";
@@ -289,6 +291,7 @@ export interface AcquisitionSlotTable {
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
reserved_plan_id: string | null;
rule_group_ids: Generated<Json>;
}
export interface AdminSessionTable {
@@ -1056,6 +1059,7 @@ export interface PreferenceSuppressionTable {
lifted_by: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
rule_group_id: string | null;
}
export interface PreferenceTagTable {
@@ -1108,6 +1112,47 @@ export interface ResolutionAttemptTable {
updated_at: GeneratedTimestamptz;
}
export interface RuleAtomTable {
id: Generated<string>;
key: string;
name: string | null;
description: string | null;
include: Generated<Json>;
exclude: Generated<Json>;
match_fields: Generated<Json>;
unit_size_mb: Json | null;
min_seeders: number | null;
publish_delay_minutes: Json | null;
free: Generated<boolean>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface RuleGroupTable {
id: Generated<string>;
name: string;
description: string | null;
kind: Generated<RuleGroupKind>;
scope: Generated<Json>;
layers: Generated<Json>;
hard_excludes: Generated<Json>;
ladder_required: Generated<boolean>;
rule_string: string | null;
source: Generated<RuleGroupSource>;
enabled: Generated<boolean>;
revision: Generated<number>;
fingerprint: string;
evidence: Generated<Json>;
default_order: number | null;
auto_fill: boolean | null;
legacy_kind: string | null;
legacy_rule: Json | null;
suppressed_at: Timestamptz | null;
deleted_at: Timestamptz | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface ScheduledJobTable {
id: string;
name: string;
@@ -1515,6 +1560,8 @@ export interface DB {
preference_tag: PreferenceTagTable;
reconcile_run: ReconcileRunTable;
resolution_attempt: ResolutionAttemptTable;
rule_atom: RuleAtomTable;
rule_group: RuleGroupTable;
scheduled_job: ScheduledJobTable;
scheduled_job_run: ScheduledJobRunTable;
secret: SecretTable;
@@ -1682,6 +1729,12 @@ export type ReconcileRunUpdate = Updateable<ReconcileRunTable>;
export type ResolutionAttemptRow = Selectable<ResolutionAttemptTable>;
export type NewResolutionAttempt = Insertable<ResolutionAttemptTable>;
export type ResolutionAttemptUpdate = Updateable<ResolutionAttemptTable>;
export type RuleAtomRow = Selectable<RuleAtomTable>;
export type NewRuleAtom = Insertable<RuleAtomTable>;
export type RuleAtomUpdate = Updateable<RuleAtomTable>;
export type RuleGroupRow = Selectable<RuleGroupTable>;
export type NewRuleGroup = Insertable<RuleGroupTable>;
export type RuleGroupUpdate = Updateable<RuleGroupTable>;
export type ScheduledJobRow = Selectable<ScheduledJobTable>;
export type NewScheduledJob = Insertable<ScheduledJobTable>;
export type ScheduledJobUpdate = Updateable<ScheduledJobTable>;
+3
View File
@@ -99,6 +99,9 @@ const EXPECTED_TABLES = [
// W7-C 定时任务注册表(0028)
"scheduled_job",
"scheduled_job_run",
// W9-D 规则组(0033)
"rule_group",
"rule_atom",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */