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

@@ -0,0 +1,617 @@
import { Api, type RuleGroupCreate, type RuleGroupUpdate, type RuleKeyword } from "@umt/contracts";
import { type DB, type RuleGroupRow, sql, type Transaction } from "@umt/db";
import {
atomsOf,
BUILTIN_ATOM_MAP,
BUILTIN_ATOMS,
parseRuleString,
type RuleExpr,
type RuleLayer,
serializeLayers,
} from "@umt/domain";
import type { Context } from "hono";
import type { AuthEnv } from "../../../http/auth.ts";
import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { contextOf, parsedOf } from "../services/acquisition.ts";
import type { AoDeps } from "../services/common.ts";
import {
checkAtomPatterns,
loadRuleCatalog,
normalizeGroupInput,
previewRuleGroup,
ruleAtomToApi,
ruleFingerprint,
ruleGroupToApi,
ruleStringOf,
} from "../services/rules.ts";
import { actorOf, conflict, jsonb, notFound } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
// 规则页后端(W9-D):规则组 CRUD / 修订 / 试算、规则串解析与序列化、关键字表、自定义条件、三套默认链。
// 停用或删除规则组写抑制记录(同偏好标签):source=agent 的新建命中有效抑制 → 409;手动/对话新建则解除抑制。
// 注意注册顺序:静态路径(/rules/keywords 等)必须先于 /rules/{id}。
const TAG = "rules";
type Trx = Transaction<DB>;
/** 写审计(修订历史端点据此回放) */
export async function auditRuleGroup(
trx: Trx,
c: Context<AuthEnv>,
action: "created" | "updated" | "deleted",
row: RuleGroupRow,
changedFields: string[],
) {
const actor = c.get("actor");
await trx
.insertInto("audit_event")
.values({
actor_type: actor?.type === "api_key" ? "api_key" : "user",
actor_id: actor?.id ?? null,
action: `rule_group.${action}`,
target_type: "rule_group",
target_id: row.id,
payload: jsonb({ revision: row.revision, changedFields, snapshot: ruleGroupToApi(row) }),
})
.execute();
}
/** 写抑制记录;preference_tag_id 只在该 id 仍是旧偏好标签行时填(外键) */
export async function suppressRuleGroup(trx: Trx, groupId: string, fingerprint: string, by: string, reason: string) {
await trx
.insertInto("preference_suppression")
.values({
preference_tag_id: sql<string | null>`(select id from preference_tag where id = ${groupId})`,
rule_group_id: groupId,
fingerprint,
reason,
evidence: jsonb({ ruleGroupId: groupId }),
suppressed_by: by,
})
.execute();
}
export async function liftSuppressions(trx: Trx, fingerprint: string, by: string) {
await trx
.updateTable("preference_suppression")
.set({ active: false, lifted_at: new Date(), lifted_by: by })
.where("fingerprint", "=", fingerprint)
.where("active", "=", true)
.execute();
}
/** 新建前的抑制检查:agent 命中有效抑制 → 409;其余来源解除抑制 */
export async function checkSuppression(trx: Trx, fingerprint: string, source: string, by: string) {
const sup = await trx
.selectFrom("preference_suppression")
.select("id")
.where("fingerprint", "=", fingerprint)
.where("active", "=", true)
.executeTakeFirst();
if (!sup) return;
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
await liftSuppressions(trx, fingerprint, by);
}
/** 被版本目标 / 订阅显式引用的次数 */
async function referenceCount(trx: Trx, id: string): Promise<number> {
const r = await sql<{ n: number }>`
select (select count(*) from acquisition_slot where rule_group_ids @> ${JSON.stringify([id])}::jsonb)
+ (select count(*) from subscription where params -> 'ruleGroupIds' @> ${JSON.stringify([id])}::jsonb)
as n`.execute(trx);
return Number(r.rows[0]?.n ?? 0);
}
async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly string[]) {
if (!ids.length) return;
const rows = await exec
.selectFrom("rule_group")
.select("id")
.where("id", "in", [...ids])
.where("deleted_at", "is", null)
.execute();
const have = new Set(rows.map((r) => r.id));
const missing = ids.filter((x) => !have.has(x));
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
}
/** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
export const assertRuleGroupIds = checkIdsExist;
const TRACKED_FIELDS = [
"name",
"description",
"kind",
"scope",
"layers",
"hardExcludes",
"ladderRequired",
"enabled",
"evidence",
"autoFill",
] as const;
export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
const db = () => d.ctx.db;
// ── 静态路径 ──
mount(
app,
Api.rules.keywords,
async () => {
const custom = await db().selectFrom("rule_atom").selectAll().orderBy("key").execute();
const items: RuleKeyword[] = [
...BUILTIN_ATOMS.map((a) => ({
id: a.id,
name: a.name,
description: a.description,
builtin: true,
supported: a.supported,
basis: a.basis,
mp: { ...a.mp } as Record<string, unknown>,
})),
...custom.map((a) => ({
id: a.key,
name: a.name ?? a.key,
description: a.description ?? "自定义条件",
builtin: false,
supported: true,
basis: "custom" as const,
mp: null,
})),
];
return { items };
},
{ tag: TAG },
);
mount(
app,
Api.rules.parse,
async (_c, { body }) => {
const atoms = await db().selectFrom("rule_atom").select("key").execute();
const p = parseRuleString(body.ruleString, { customAtoms: atoms.map((a) => a.key) });
return { layers: p.layers, ruleString: p.errors.length ? null : serializeLayers(p.layers), errors: p.errors };
},
{ tag: TAG, status: 200 },
);
mount(
app,
Api.rules.serialize,
async (_c, { body }) => ({ ruleString: serializeLayers(body.layers as RuleLayer[]) }),
{ tag: TAG, status: 200 },
);
mount(
app,
Api.rules.defaults,
async () => {
const cat = await loadRuleCatalog(db());
const pick = (k: string) =>
[...cat.groups.values()]
.filter((g) => g.kind === k && g.default_order !== null)
.sort((a, b) => a.default_order! - b.default_order!)
.map((g) => g.id);
return { acquisition: pick("acquisition"), upgrade: pick("upgrade"), search: pick("search") };
},
{ tag: TAG },
);
mount(
app,
Api.rules.setDefaults,
async (_c, { body }) => {
await db()
.transaction()
.execute(async (trx) => {
const all = [...body.acquisition, ...body.upgrade, ...body.search];
await checkIdsExist(trx, all);
const rows = await trx
.selectFrom("rule_group")
.select(["id", "kind"])
.where("id", "in", all.length ? all : ["00000000-0000-0000-0000-000000000000"])
.execute();
for (const k of ["acquisition", "upgrade", "search"] as const) {
const wrong = rows.filter((r) => body[k].includes(r.id) && r.kind !== k);
if (wrong.length)
throw new ApiException("bad_request", `规则组类型不符(${k}):${wrong.map((w) => w.id).join(", ")}`);
}
await trx
.updateTable("rule_group")
.set({ default_order: null })
.where("default_order", "is not", null)
.execute();
for (const k of ["acquisition", "upgrade", "search"] as const)
for (const [i, id] of body[k].entries())
await trx.updateTable("rule_group").set({ default_order: i }).where("id", "=", id).execute();
});
return body;
},
{ tag: TAG },
);
// ── 自定义条件 ──
mount(
app,
Api.ruleAtoms.list,
async (_c, { query }) => {
const p = await paginate(db().selectFrom("rule_atom").selectAll(), query);
return { items: p.rows.map(ruleAtomToApi), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.ruleAtoms.get,
async (_c, { params }) => {
const r = await db().selectFrom("rule_atom").selectAll().where("id", "=", params.id).executeTakeFirst();
return ruleAtomToApi(r ?? notFound("自定义条件"));
},
{ tag: TAG },
);
mount(
app,
Api.ruleAtoms.create,
async (_c, { body }) => {
const key = String(body.key).toUpperCase();
if (BUILTIN_ATOM_MAP.has(key)) conflict(`${key} 是内置关键字,不能覆盖`);
checkAtomPatterns(body.include);
checkAtomPatterns(body.exclude);
const r = await db()
.insertInto("rule_atom")
.values({
key,
name: body.name ?? null,
description: body.description ?? null,
include: JSON.stringify(body.include ?? []),
exclude: JSON.stringify(body.exclude ?? []),
match_fields: JSON.stringify(body.matchFields ?? []),
unit_size_mb: body.unitSizeMb ? jsonb(body.unitSizeMb) : null,
min_seeders: body.minSeeders ?? null,
publish_delay_minutes: body.publishDelayMinutes ? jsonb(body.publishDelayMinutes) : null,
free: body.free ?? false,
})
.onConflict((oc) => oc.column("key").doNothing())
.returningAll()
.executeTakeFirst();
if (!r) conflict(`自定义条件 ${key} 已存在`);
return ruleAtomToApi(r!);
},
{ tag: TAG },
);
mount(
app,
Api.ruleAtoms.update,
async (_c, { params, body }) => {
checkAtomPatterns(body.include);
checkAtomPatterns(body.exclude);
const r = await db()
.updateTable("rule_atom")
.set({
...(body.name !== undefined ? { name: body.name } : {}),
...(body.description !== undefined ? { description: body.description } : {}),
...(body.include !== undefined ? { include: JSON.stringify(body.include) } : {}),
...(body.exclude !== undefined ? { exclude: JSON.stringify(body.exclude) } : {}),
...(body.matchFields !== undefined ? { match_fields: JSON.stringify(body.matchFields) } : {}),
...(body.unitSizeMb !== undefined ? { unit_size_mb: body.unitSizeMb ? jsonb(body.unitSizeMb) : null } : {}),
...(body.minSeeders !== undefined ? { min_seeders: body.minSeeders } : {}),
...(body.publishDelayMinutes !== undefined
? { publish_delay_minutes: body.publishDelayMinutes ? jsonb(body.publishDelayMinutes) : null }
: {}),
...(body.free !== undefined ? { free: body.free } : {}),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirst();
return ruleAtomToApi(r ?? notFound("自定义条件"));
},
{ tag: TAG },
);
mount(
app,
Api.ruleAtoms.remove,
async (_c, { params }) => {
const a = await db().selectFrom("rule_atom").select(["id", "key"]).where("id", "=", params.id).executeTakeFirst();
if (!a) notFound("自定义条件");
const cat = await loadRuleCatalog(db());
const users = [...cat.groups.values()].filter((g) =>
[...((g.layers ?? []) as RuleLayer[]).map((l) => l.expr), ...((g.hard_excludes ?? []) as RuleExpr[])].some(
(e) => atomsOf(e).has(a!.key),
),
);
if (users.length) conflict(`自定义条件 ${a!.key} 被规则组引用:${users.map((u) => u.name).join("、")}`);
await db().deleteFrom("rule_atom").where("id", "=", params.id).execute();
return { ok: true as const };
},
{ tag: TAG, status: 200 },
);
// ── 规则组 ──
mount(
app,
Api.rules.list,
async (_c, { query }) => {
let q = db().selectFrom("rule_group").selectAll();
if (query.includeDeleted !== "true") q = q.where("deleted_at", "is", null);
if (query.kind) q = q.where("kind", "=", query.kind);
if (query.source) q = q.where("source", "=", query.source);
if (query.enabled) q = q.where("enabled", "=", query.enabled === "true");
if (query.q) q = q.where("name", "ilike", `%${String(query.q).replace(/[\\%_]/g, (m) => `\\${m}`)}%`);
const p = await paginate(q, query);
return { items: p.rows.map(ruleGroupToApi), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.rules.get,
async (_c, { params }) => {
const r = await db().selectFrom("rule_group").selectAll().where("id", "=", params.id).executeTakeFirst();
return ruleGroupToApi(r ?? notFound("规则组"));
},
{ tag: TAG },
);
mount(
app,
Api.rules.create,
async (c, { body }) => {
const b = body as RuleGroupCreate;
const by = actorOf(c);
const r = await db()
.transaction()
.execute(async (trx) => {
const atoms = await trx.selectFrom("rule_atom").select("key").execute();
const n = normalizeGroupInput(
b,
atoms.map((a) => a.key),
);
const layers = n.layers ?? [];
const hardExcludes = n.hardExcludes ?? [];
const kind = b.kind ?? "acquisition";
const scope = b.scope ?? {};
const ladderRequired = b.ladderRequired ?? true;
const source = b.source ?? "manual";
const fp = ruleFingerprint({ kind, scope, layers, hardExcludes, ladderRequired });
await sql`select pg_advisory_xact_lock(hashtext(${`rule_group:${fp}`}))`.execute(trx);
const dupName = await trx
.selectFrom("rule_group")
.select("id")
.where("name", "=", b.name)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (dupName) conflict(`已有同名规则组「${b.name}」`);
if (source !== "manual") {
const dup = await trx
.selectFrom("rule_group")
.select("id")
.where("fingerprint", "=", fp)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (dup) conflict("已存在同一语义的规则组");
}
await checkSuppression(trx, fp, source, by);
const row = await trx
.insertInto("rule_group")
.values({
name: b.name,
description: b.description ?? null,
kind,
scope: jsonb(scope),
layers: JSON.stringify(layers),
hard_excludes: JSON.stringify(hardExcludes),
ladder_required: ladderRequired,
rule_string: ruleStringOf(layers),
source,
enabled: b.enabled ?? true,
fingerprint: fp,
evidence: jsonb(b.evidence),
auto_fill: b.autoFill ?? null,
})
.returningAll()
.executeTakeFirstOrThrow();
await auditRuleGroup(trx, c, "created", row, []);
return row;
});
return ruleGroupToApi(r);
},
{ tag: TAG },
);
mount(
app,
Api.rules.update,
async (c, { params, body }) => {
const b = body as RuleGroupUpdate;
const by = actorOf(c);
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("rule_group")
.selectAll()
.where("id", "=", params.id)
.where("deleted_at", "is", null)
.forUpdate()
.executeTakeFirst();
if (!cur) notFound("规则组");
if (b.expectedRevision !== undefined && b.expectedRevision !== cur!.revision)
conflict(`revision 不匹配(当前 ${cur!.revision})`);
if (b.name !== undefined && b.name !== cur!.name) {
const dupName = await trx
.selectFrom("rule_group")
.select("id")
.where("name", "=", b.name)
.where("deleted_at", "is", null)
.where("id", "!=", cur!.id)
.executeTakeFirst();
if (dupName) conflict(`已有同名规则组「${b.name}」`);
}
const atoms = await trx.selectFrom("rule_atom").select("key").execute();
const n = normalizeGroupInput(
b,
atoms.map((a) => a.key),
);
const next = {
kind: b.kind ?? cur!.kind,
scope: b.scope ?? cur!.scope,
layers: n.layers ?? (cur!.layers as RuleLayer[]),
hardExcludes: n.hardExcludes ?? (cur!.hard_excludes as RuleExpr[]),
ladderRequired: b.ladderRequired ?? cur!.ladder_required,
};
if (b.kind !== undefined && b.kind !== cur!.kind && cur!.default_order !== null)
conflict("该规则组在默认链里,先从默认值移出再改类型");
// 偏好标签迁来的组沿用旧指纹口径,规则没变就不重算
const semanticChanged =
b.kind !== undefined ||
b.scope !== undefined ||
n.layers !== undefined ||
n.hardExcludes !== undefined ||
b.ladderRequired !== undefined;
const fp = semanticChanged ? ruleFingerprint(next) : cur!.fingerprint;
const enabled = b.enabled ?? cur!.enabled;
let suppressedAt = cur!.suppressed_at;
if (!enabled && cur!.enabled) {
await suppressRuleGroup(trx, cur!.id, cur!.fingerprint, by, "disabled");
suppressedAt = new Date();
}
if (enabled && !cur!.enabled) {
await liftSuppressions(trx, fp, by);
suppressedAt = null;
}
// 对话 / Agent 来源的规则被用户改掉:旧语义留抑制记录,Agent 不能据旧证据改回
if (fp !== cur!.fingerprint && cur!.enabled && enabled && cur!.source !== "manual")
await suppressRuleGroup(trx, cur!.id, cur!.fingerprint, by, "edited");
const before = ruleGroupToApi(cur!) as unknown as Record<string, unknown>;
const row = await trx
.updateTable("rule_group")
.set({
...(b.name !== undefined ? { name: b.name } : {}),
...(b.description !== undefined ? { description: b.description } : {}),
kind: next.kind,
scope: jsonb(next.scope),
layers: JSON.stringify(next.layers),
hard_excludes: JSON.stringify(next.hardExcludes),
ladder_required: next.ladderRequired,
rule_string: ruleStringOf(next.layers),
enabled,
fingerprint: fp,
suppressed_at: suppressedAt,
...(b.evidence !== undefined ? { evidence: jsonb(b.evidence) } : {}),
...(b.autoFill !== undefined ? { auto_fill: b.autoFill } : {}),
revision: cur!.revision + 1,
})
.where("id", "=", cur!.id)
.returningAll()
.executeTakeFirstOrThrow();
const after = ruleGroupToApi(row) as unknown as Record<string, unknown>;
const changed = TRACKED_FIELDS.filter((k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]));
await auditRuleGroup(trx, c, "updated", row, changed);
return row;
});
await kickReevaluate(d, r.id);
return ruleGroupToApi(r);
},
{ tag: TAG },
);
mount(
app,
Api.rules.remove,
async (c, { params }) => {
await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("rule_group")
.selectAll()
.where("id", "=", params.id)
.where("deleted_at", "is", null)
.forUpdate()
.executeTakeFirst();
if (!cur) notFound("规则组");
const refs = await referenceCount(trx, cur!.id);
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
const row = await trx
.updateTable("rule_group")
.set({
deleted_at: new Date(),
enabled: false,
default_order: null,
suppressed_at: cur!.suppressed_at ?? new Date(),
revision: cur!.revision + 1,
})
.where("id", "=", cur!.id)
.returningAll()
.executeTakeFirstOrThrow();
await auditRuleGroup(trx, c, "deleted", row, []);
if (cur!.enabled) await suppressRuleGroup(trx, cur!.id, cur!.fingerprint, actorOf(c), "deleted");
});
return { ok: true as const };
},
{ tag: TAG, status: 200 },
);
mount(
app,
Api.rules.revisions,
async (_c, { params }) => {
const g = await db().selectFrom("rule_group").select("id").where("id", "=", params.id).executeTakeFirst();
if (!g) notFound("规则组");
const rows = await db()
.selectFrom("audit_event")
.selectAll()
.where("target_id", "=", params.id)
.where((eb) => eb.or([eb("target_type", "=", "rule_group"), eb("target_type", "=", "preference_tag")]))
.orderBy("at", "desc")
.orderBy("id", "desc")
.limit(200)
.execute();
return {
items: rows.map((r) => {
const p = (r.payload ?? {}) as {
revision?: number;
changedFields?: string[];
snapshot?: Record<string, unknown>;
};
return {
id: r.id,
at: r.at.toISOString(),
action: r.action,
actorType: r.actor_type,
actorId: r.actor_id,
revision: p.revision ?? null,
changedFields: p.changedFields ?? [],
snapshot: p.snapshot ?? {},
};
}),
};
},
{ tag: TAG },
);
mount(
app,
Api.rules.preview,
async (_c, { params, body }) => {
const row = await db().selectFrom("rule_group").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!row) notFound("规则组");
return previewRuleGroup(d, row!, body ?? {}, { parsedOf, contextOf });
},
{ tag: TAG, status: 200 },
);
}
/** 规则组改动后重评引用它的版本目标(显式引用;默认链改动由周期重评覆盖) */
async function kickReevaluate(d: AoDeps, groupId: string) {
try {
const slots = await d.ctx.db
.selectFrom("acquisition_slot")
.select("id")
.where(sql<boolean>`rule_group_ids @> ${JSON.stringify([groupId])}::jsonb`)
.limit(50)
.execute();
for (const s of slots)
await d.ctx.engine.enqueue(
"evaluateAcquisition",
{ acquisitionSlotId: s.id },
{ idempotencyKey: `reeval:${s.id}:${Date.now()}`, traceId: s.id },
);
} catch (err) {
d.ctx.logger.debug({ err: { message: (err as Error).message } }, "规则组重评入队失败");
}
}
@@ -8,20 +8,29 @@ import {
type Subscription,
type SubscriptionHistory,
} from "@umt/contracts";
import { type DB, sql, type Transaction } from "@umt/db";
import { evaluateCandidate, parseRelease } from "@umt/domain";
import { type DB, type PreferenceSuppressionRow, type RuleGroupRow, sql, type Transaction } from "@umt/db";
import { evaluateCandidate, parseRelease, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
import { slotSpecFromRow } from "@umt/mapping";
import type { Context } from "hono";
import type { AuthEnv } from "../../../http/auth.ts";
import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { createSlotsFromTemplates, publishedAcquisitionPolicies } from "../services/acquisition.ts";
import type { AoDeps } from "../services/common.ts";
import type { AoDeps, Exec } from "../services/common.ts";
import { normalizeGroupInput } from "../services/rules.ts";
import { applyEpisodeTotal, enqueueSubscriptionSearch, subscriptionProgress } from "../services/subscriptions.ts";
import { actorOf, conflict, errMessage, hashJson, jsonb, notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
import {
assertRuleGroupIds,
checkSuppression,
liftSuppressions,
registerRuleGroupRoutes,
suppressRuleGroup,
} from "./rule-groups.ts";
// 规则 API:订阅、收录 Slot、策略版本(草案→发布→回滚,发布即持续授权)、偏好标签与抑制记录。
// 规则 API:订阅、收录 Slot、策略版本(草案→发布→回滚,发布即持续授权)、偏好标签(规则组别名)与抑制记录;
// 规则组本体见 ./rule-groups.ts。
const TAG = "rules";
@@ -385,6 +394,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", body.mediaEntityId)
.executeTakeFirst();
if (!e) throw new ApiException("bad_request", "mediaEntityId 不存在");
await assertRuleGroupIds(db(), body.ruleGroupIds ?? []);
const r = await db()
.insertInto("acquisition_slot")
.values({
@@ -400,6 +410,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
completion: jsonb(body.completion),
distinct_group: body.distinctGroup ?? null,
require_distinct_asset: body.requireDistinctAsset ?? false,
rule_group_ids: JSON.stringify(body.ruleGroupIds ?? []),
})
.onConflict((oc) => oc.doNothing())
.returningAll()
@@ -415,6 +426,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
Api.acquisitionSlots.update,
async (_c, { params, body }) => {
const { expectedRevision, ...b } = body;
if (b.ruleGroupIds !== undefined) await assertRuleGroupIds(db(), b.ruleGroupIds);
const r = await db()
.updateTable("acquisition_slot")
.set({
@@ -427,6 +439,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
...(b.completion !== undefined ? { completion: jsonb(b.completion) } : {}),
...(b.distinctGroup !== undefined ? { distinct_group: b.distinctGroup } : {}),
...(b.requireDistinctAsset !== undefined ? { require_distinct_asset: b.requireDistinctAsset } : {}),
...(b.ruleGroupIds !== undefined ? { rule_group_ids: JSON.stringify(b.ruleGroupIds) } : {}),
revision: sql`revision + 1`,
})
.where("id", "=", params.id)
@@ -448,199 +461,257 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG },
);
// ── 策略版本 ──
mount(
app,
Api.policyRevisions.list,
async (_c, { query }) => {
let q = db().selectFrom("policy_revision").selectAll();
if (query.policyKey) q = q.where("policy_key", "=", query.policyKey);
if (query.status) q = q.where("status", "=", query.status);
const p = await paginate(q, query);
return { items: toApiList<PolicyRevision>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.get,
async (_c, { params }) => {
const r = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<PolicyRevision>(r ?? notFound("策略版本"));
},
{ tag: TAG },
);
const checkRules = (kind: string, rules: unknown) => {
if (kind !== "acquisition") return;
const r = AcquisitionPolicyRules.safeParse(rules);
if (!r.success)
throw new ApiException(
"validation_failed",
`rules 不符合收录策略结构: ${r.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
);
};
mount(
app,
Api.policyRevisions.create,
async (c, { body }) => {
checkRules(body.kind, body.rules);
const r = await db()
.transaction()
.execute(async (trx) => {
await sql`select pg_advisory_xact_lock(hashtext(${`policy:${body.policyKey}`}))`.execute(trx);
const prev = await trx
.selectFrom("policy_revision")
.select(["kind"])
.select(sql<number>`max(revision)`.as("max"))
.where("policy_key", "=", body.policyKey)
.groupBy("kind")
.executeTakeFirst();
if (prev && prev.kind !== body.kind) conflict(`policyKey ${body.policyKey} 已属于 ${prev.kind}`);
return trx
.insertInto("policy_revision")
.values({
policy_key: body.policyKey,
revision: (prev?.max ?? 0) + 1,
kind: body.kind,
status: "draft",
title: body.title,
summary: body.summary ?? null,
scope: jsonb(body.scope),
rules: jsonb(body.rules),
based_on_revision_id: body.basedOnRevisionId ?? null,
created_by: actorOf(c),
})
.returningAll()
.executeTakeFirstOrThrow();
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.update,
async (_c, { params, body }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案");
if (body.rules !== undefined) checkRules(cur!.kind, body.rules);
const r = await db()
.updateTable("policy_revision")
.set({
...(body.title !== undefined ? { title: body.title } : {}),
...(body.summary !== undefined ? { summary: body.summary } : {}),
...(body.scope !== undefined ? { scope: jsonb(body.scope) } : {}),
...(body.rules !== undefined ? { rules: jsonb(body.rules) } : {}),
...(body.basedOnRevisionId !== undefined ? { based_on_revision_id: body.basedOnRevisionId } : {}),
preview: null,
})
.where("id", "=", params.id)
.where("status", "=", "draft")
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.preview,
async (_c, { params }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
const preview = await previewPolicy(d, cur!.rules);
const r = await db()
.updateTable("policy_revision")
.set({ preview: jsonb(preview) })
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.publish,
async (c, { params, body }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.revision !== body.expectedRevision) conflict(`revision 不匹配(当前 ${cur!.revision})`);
if (cur!.status !== "draft") conflict(`只能发布草案(当前 ${cur!.status})`);
checkRules(cur!.kind, cur!.rules);
return publishRevision(trx, cur!.id, cur!.policy_key, actorOf(c), "superseded");
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.rollback,
async (c, { params }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const target = await trx
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", params.id)
.executeTakeFirst();
if (!target) notFound("策略版本");
if (target!.status === "published") conflict("该版本已是当前发布版本");
if (target!.status === "draft") conflict("草案不能作为回滚目标");
await sql`select pg_advisory_xact_lock(hashtext(${`policy:${target!.policy_key}`}))`.execute(trx);
const max = await trx
.selectFrom("policy_revision")
.select(sql<number>`max(revision)`.as("m"))
.where("policy_key", "=", target!.policy_key)
.executeTakeFirstOrThrow();
// 回滚 = 以目标版本内容发布一个新版本(历史单调递增,可审计)
const clone = await trx
.insertInto("policy_revision")
.values({
policy_key: target!.policy_key,
revision: max.m + 1,
kind: target!.kind,
status: "draft",
title: target!.title,
summary: `回滚到 r${target!.revision}`,
scope: jsonb(target!.scope),
rules: jsonb(target!.rules),
based_on_revision_id: target!.id,
created_by: actorOf(c),
})
.returning("id")
.executeTakeFirstOrThrow();
return publishRevision(trx, clone.id, target!.policy_key, actorOf(c), "rolled_back");
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
// ── 策略版本(/rules/policies 为新路径,/policy-revisions 保留为别名,见 docs/runbook/rules.md)──
for (const P of [Api.rulePolicies, Api.policyRevisions]) {
mount(
app,
P.list,
async (_c, { query }) => {
let q = db().selectFrom("policy_revision").selectAll();
if (query.policyKey) q = q.where("policy_key", "=", query.policyKey);
if (query.status) q = q.where("status", "=", query.status);
const p = await paginate(q, query);
return { items: toApiList<PolicyRevision>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
P.get,
async (_c, { params }) => {
const r = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<PolicyRevision>(r ?? notFound("策略版本"));
},
{ tag: TAG },
);
const checkRules = (kind: string, rules: unknown) => {
if (kind !== "acquisition") return;
const r = AcquisitionPolicyRules.safeParse(rules);
if (!r.success)
throw new ApiException(
"validation_failed",
`rules 不符合收录策略结构: ${r.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
);
};
mount(
app,
P.create,
async (c, { body }) => {
checkRules(body.kind, body.rules);
const r = await db()
.transaction()
.execute(async (trx) => {
await sql`select pg_advisory_xact_lock(hashtext(${`policy:${body.policyKey}`}))`.execute(trx);
const prev = await trx
.selectFrom("policy_revision")
.select(["kind"])
.select(sql<number>`max(revision)`.as("max"))
.where("policy_key", "=", body.policyKey)
.groupBy("kind")
.executeTakeFirst();
if (prev && prev.kind !== body.kind) conflict(`policyKey ${body.policyKey} 已属于 ${prev.kind}`);
return trx
.insertInto("policy_revision")
.values({
policy_key: body.policyKey,
revision: (prev?.max ?? 0) + 1,
kind: body.kind,
status: "draft",
title: body.title,
summary: body.summary ?? null,
scope: jsonb(body.scope),
rules: jsonb(body.rules),
based_on_revision_id: body.basedOnRevisionId ?? null,
created_by: actorOf(c),
})
.returningAll()
.executeTakeFirstOrThrow();
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
P.update,
async (_c, { params, body }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案");
if (body.rules !== undefined) checkRules(cur!.kind, body.rules);
const r = await db()
.updateTable("policy_revision")
.set({
...(body.title !== undefined ? { title: body.title } : {}),
...(body.summary !== undefined ? { summary: body.summary } : {}),
...(body.scope !== undefined ? { scope: jsonb(body.scope) } : {}),
...(body.rules !== undefined ? { rules: jsonb(body.rules) } : {}),
...(body.basedOnRevisionId !== undefined ? { based_on_revision_id: body.basedOnRevisionId } : {}),
preview: null,
})
.where("id", "=", params.id)
.where("status", "=", "draft")
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
P.preview,
async (_c, { params }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
const preview = await previewPolicy(d, cur!.rules);
const r = await db()
.updateTable("policy_revision")
.set({ preview: jsonb(preview) })
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
P.publish,
async (c, { params, body }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.revision !== body.expectedRevision) conflict(`revision 不匹配(当前 ${cur!.revision})`);
if (cur!.status !== "draft") conflict(`只能发布草案(当前 ${cur!.status})`);
checkRules(cur!.kind, cur!.rules);
return publishRevision(trx, cur!.id, cur!.policy_key, actorOf(c), "superseded");
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
P.rollback,
async (c, { params }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const target = await trx
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", params.id)
.executeTakeFirst();
if (!target) notFound("策略版本");
if (target!.status === "published") conflict("该版本已是当前发布版本");
if (target!.status === "draft") conflict("草案不能作为回滚目标");
await sql`select pg_advisory_xact_lock(hashtext(${`policy:${target!.policy_key}`}))`.execute(trx);
const max = await trx
.selectFrom("policy_revision")
.select(sql<number>`max(revision)`.as("m"))
.where("policy_key", "=", target!.policy_key)
.executeTakeFirstOrThrow();
// 回滚 = 以目标版本内容发布一个新版本(历史单调递增,可审计)
const clone = await trx
.insertInto("policy_revision")
.values({
policy_key: target!.policy_key,
revision: max.m + 1,
kind: target!.kind,
status: "draft",
title: target!.title,
summary: `回滚到 r${target!.revision}`,
scope: jsonb(target!.scope),
rules: jsonb(target!.rules),
based_on_revision_id: target!.id,
created_by: actorOf(c),
})
.returning("id")
.executeTakeFirstOrThrow();
return publishRevision(trx, clone.id, target!.policy_key, actorOf(c), "rolled_back");
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
}
// ── 偏好标签 ──
// ── 偏好标签(已并入规则组;旧端点保留为别名,见 docs/runbook/rules.md)──
// 标签 = legacy_kind 非空的规则组;指纹沿用旧口径 hash(kind+scope+rule),旧抑制记录照常命中。
// rule.ruleString(可选)会编译成规则:hard → 硬排除,soft → 单层软偏好;其余只作记录。
const fingerprint = (b: { kind: string; scope?: unknown; rule?: unknown }) =>
hashJson({ kind: b.kind, scope: b.scope ?? {}, rule: b.rule ?? {} });
const tagStatus = (r: RuleGroupRow) => (r.deleted_at ? "deleted" : r.enabled ? "active" : "disabled");
const toTag = (r: RuleGroupRow): PreferenceTag => ({
id: r.id,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
label: r.name,
kind: (r.legacy_kind ?? "soft") as PreferenceTag["kind"],
scope: (r.scope ?? {}) as Record<string, unknown>,
rule: (r.legacy_rule ?? {}) as Record<string, unknown>,
fingerprint: r.fingerprint,
origin: r.source === "chat" ? "conversation" : r.source === "agent" ? "agent_inferred" : "manual",
description: r.description,
status: tagStatus(r),
revision: r.revision,
evidence: (r.evidence ?? {}) as Record<string, unknown>,
policyRevisionId: null,
});
const sourceOf = (o: string | undefined) =>
o === "conversation" ? "chat" : o === "agent_inferred" ? "agent" : "manual";
/** 旧标签 rule → 规则组层 / 硬排除 */
const compileTag = async (kind: string, rule: unknown) => {
const rs = (rule as { ruleString?: unknown } | null)?.ruleString;
if (typeof rs !== "string" || !rs.trim() || (kind !== "hard" && kind !== "soft"))
return { layers: [], hardExcludes: [] };
const atoms = await db().selectFrom("rule_atom").select("key").execute();
const keys = atoms.map((a) => a.key);
return kind === "hard"
? {
layers: [],
hardExcludes:
normalizeGroupInput({ hardExcludes: rs.split(">").map((x) => x.trim()) }, keys).hardExcludes ?? [],
}
: { layers: normalizeGroupInput({ ruleString: rs }, keys).layers ?? [], hardExcludes: [] };
};
const tagSnapshot = (r: RuleGroupRow) => ({
id: r.id,
label: r.name,
kind: r.legacy_kind ?? "soft",
status: tagStatus(r),
scope: r.scope,
rule: r.legacy_rule ?? {},
description: r.description,
revision: r.revision,
});
const liveTag = (trx: Exec, id: string) =>
trx
.selectFrom("rule_group")
.selectAll()
.where("id", "=", id)
.where("legacy_kind", "is not", null)
.where("deleted_at", "is", null);
mount(
app,
Api.preferenceTags.list,
async (_c, { query }) => {
let q = db().selectFrom("preference_tag").selectAll();
if (query.status) q = q.where("status", "=", query.status);
else q = q.where("status", "!=", "deleted");
if (query.kind) q = q.where("kind", "=", query.kind);
let q = db().selectFrom("rule_group").selectAll().where("legacy_kind", "is not", null);
if (query.status === "deleted") q = q.where("deleted_at", "is not", null);
else {
q = q.where("deleted_at", "is", null);
if (query.status) q = q.where("enabled", "=", query.status === "active");
}
if (query.kind) q = q.where("legacy_kind", "=", query.kind);
const p = await paginate(q, query);
return { items: toApiList<PreferenceTag>(p.rows), nextCursor: p.nextCursor };
return { items: p.rows.map(toTag), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
@@ -648,8 +719,13 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
app,
Api.preferenceTags.get,
async (_c, { params }) => {
const r = await db().selectFrom("preference_tag").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<PreferenceTag>(r ?? notFound("偏好标签"));
const r = await db()
.selectFrom("rule_group")
.selectAll()
.where("id", "=", params.id)
.where("legacy_kind", "is not", null)
.executeTakeFirst();
return toTag(r ?? notFound("偏好标签"));
},
{ tag: TAG },
);
@@ -658,45 +734,43 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
Api.preferenceTags.create,
async (c, { body }) => {
const fp = fingerprint(body);
const origin = body.origin ?? "manual";
const source = sourceOf(body.origin);
const compiled = await compileTag(body.kind, body.rule);
const r = await db()
.transaction()
.execute(async (trx) => {
const sup = await trx
.selectFrom("preference_suppression")
.selectAll()
await sql`select pg_advisory_xact_lock(hashtext(${`rule_group:${fp}`}))`.execute(trx);
await checkSuppression(trx, fp, source, actorOf(c));
const dup = await trx
.selectFrom("rule_group")
.select("id")
.where("fingerprint", "=", fp)
.where("active", "=", true)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (sup) {
// Agent 不得据旧证据自动加回;用户手动(或对话确认)重新添加即解除抑制
if (origin === "agent_inferred") conflict("用户已取消该偏好,Agent 不能自动加回");
await trx
.updateTable("preference_suppression")
.set({ active: false, lifted_at: new Date(), lifted_by: actorOf(c) })
.where("id", "=", sup.id)
.execute();
}
if (dup) conflict("已存在同一语义的偏好标签");
const ins = await trx
.insertInto("preference_tag")
.insertInto("rule_group")
.values({
label: body.label,
kind: body.kind,
scope: jsonb(body.scope),
rule: jsonb(body.rule),
fingerprint: fp,
origin,
name: body.label,
description: body.description ?? null,
kind: "acquisition",
scope: jsonb(body.scope),
layers: JSON.stringify(compiled.layers),
hard_excludes: JSON.stringify(compiled.hardExcludes),
ladder_required: body.kind !== "soft",
rule_string: compiled.layers.length ? serializeLayers(compiled.layers) : null,
source,
fingerprint: fp,
evidence: jsonb(body.evidence),
legacy_kind: body.kind,
legacy_rule: jsonb(body.rule),
})
.onConflict((oc) => oc.doNothing())
.returningAll()
.executeTakeFirst();
if (!ins) conflict("已存在同一语义的偏好标签");
await auditPref(trx, c, "created", ins!, []);
return ins!;
.executeTakeFirstOrThrow();
await auditPref(trx, c, "created", tagSnapshot(ins), []);
return ins;
});
return toApi<PreferenceTag>(r);
return toTag(r);
},
{ tag: TAG },
);
@@ -704,54 +778,56 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
app,
Api.preferenceTags.update,
async (c, { params, body }) => {
const by = actorOf(c);
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("preference_tag")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur || cur.status === "deleted") notFound("偏好标签");
const next = { kind: cur!.kind, scope: body.scope ?? cur!.scope, rule: body.rule ?? cur!.rule };
const cur = await liveTag(trx, params.id).forUpdate().executeTakeFirst();
if (!cur) notFound("偏好标签");
const kind = body.kind ?? cur!.legacy_kind!;
const next = { kind, scope: body.scope ?? cur!.scope, rule: body.rule ?? cur!.legacy_rule ?? {} };
const fp = fingerprint(next);
const status = body.status ?? cur!.status;
if (status !== "active" && cur!.status === "active")
await suppress(trx, cur!.id, cur!.fingerprint, actorOf(c), `status:${status}`);
if (status === "active" && cur!.status !== "active") {
await trx
.updateTable("preference_suppression")
.set({ active: false, lifted_at: new Date(), lifted_by: actorOf(c) })
.where("fingerprint", "=", fp)
.where("active", "=", true)
.execute();
}
if (fp !== cur!.fingerprint && cur!.status === "active")
await suppress(trx, cur!.id, cur!.fingerprint, actorOf(c), "edited");
const changed = (["label", "kind", "scope", "rule", "description", "evidence", "status"] as const).filter(
(k) => body[k] !== undefined && JSON.stringify(body[k]) !== JSON.stringify(toApi<PreferenceTag>(cur!)[k]),
);
const next_ = await trx
.updateTable("preference_tag")
const status = body.status ?? tagStatus(cur!);
if (status === "deleted") throw new ApiException("bad_request", "删除用 DELETE");
const enabled = status === "active";
if (!enabled && cur!.enabled) await suppressRuleGroup(trx, cur!.id, cur!.fingerprint, by, `status:${status}`);
if (enabled && !cur!.enabled) await liftSuppressions(trx, fp, by);
if (fp !== cur!.fingerprint && cur!.enabled)
await suppressRuleGroup(trx, cur!.id, cur!.fingerprint, by, "edited");
const compiled =
body.rule !== undefined || body.kind !== undefined
? await compileTag(kind, next.rule)
: { layers: cur!.layers as RuleLayer[], hardExcludes: cur!.hard_excludes as RuleExpr[] };
const before = toTag(cur!);
const row = await trx
.updateTable("rule_group")
.set({
...(body.label !== undefined ? { label: body.label } : {}),
...(body.kind !== undefined ? { kind: body.kind } : {}),
...(body.scope !== undefined ? { scope: jsonb(body.scope) } : {}),
...(body.rule !== undefined ? { rule: jsonb(body.rule) } : {}),
...(body.label !== undefined ? { name: body.label } : {}),
...(body.description !== undefined ? { description: body.description } : {}),
...(body.evidence !== undefined ? { evidence: jsonb(body.evidence) } : {}),
scope: jsonb(next.scope),
legacy_kind: kind,
legacy_rule: jsonb(next.rule),
layers: JSON.stringify(compiled.layers),
hard_excludes: JSON.stringify(compiled.hardExcludes),
ladder_required: kind !== "soft",
rule_string: compiled.layers.length ? serializeLayers(compiled.layers) : null,
fingerprint: fp,
status,
enabled,
suppressed_at: enabled ? null : (cur!.suppressed_at ?? new Date()),
revision: cur!.revision + 1,
})
.where("id", "=", params.id)
.where("id", "=", cur!.id)
.returningAll()
.executeTakeFirstOrThrow();
await auditPref(trx, c, "updated", next_, changed);
return next_;
const after = toTag(row);
const changed = (["label", "kind", "scope", "rule", "description", "evidence", "status"] as const).filter(
(k) => body[k] !== undefined && JSON.stringify(after[k]) !== JSON.stringify(before[k]),
);
await auditPref(trx, c, "updated", tagSnapshot(row), changed);
return row;
});
return toApi<PreferenceTag>(r);
return toTag(r);
},
{ tag: TAG },
);
@@ -762,20 +838,22 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("preference_tag")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur || cur.status === "deleted") notFound("偏好标签");
await trx
.updateTable("preference_tag")
.set({ status: "deleted", revision: cur!.revision + 1 })
.where("id", "=", params.id)
.execute();
await auditPref(trx, c, "deleted", { ...cur!, status: "deleted", revision: cur!.revision + 1 }, []);
await suppress(trx, cur!.id, cur!.fingerprint, actorOf(c), "deleted");
const cur = await liveTag(trx, params.id).forUpdate().executeTakeFirst();
if (!cur) notFound("偏好标签");
const row = await trx
.updateTable("rule_group")
.set({
deleted_at: new Date(),
enabled: false,
default_order: null,
suppressed_at: cur!.suppressed_at ?? new Date(),
revision: cur!.revision + 1,
})
.where("id", "=", cur!.id)
.returningAll()
.executeTakeFirstOrThrow();
await auditPref(trx, c, "deleted", tagSnapshot(row), []);
await suppressRuleGroup(trx, cur!.id, cur!.fingerprint, actorOf(c), "deleted");
});
return { ok: true as const };
},
@@ -786,7 +864,12 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
app,
Api.preferenceTags.revisions,
async (_c, { params }) => {
const tag = await db().selectFrom("preference_tag").select("id").where("id", "=", params.id).executeTakeFirst();
const tag = await db()
.selectFrom("rule_group")
.select("id")
.where("id", "=", params.id)
.where("legacy_kind", "is not", null)
.executeTakeFirst();
if (!tag) notFound("偏好标签");
const rows = await db()
.selectFrom("audit_event")
@@ -829,76 +912,81 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG },
);
// ── 抑制记录 ──
mount(
app,
Api.preferenceSuppressions.list,
async (_c, { query }) => {
let q = db().selectFrom("preference_suppression").selectAll();
if (query.active) q = q.where("active", "=", query.active === "true");
const p = await paginate(q, query);
return { items: toApiList<PreferenceSuppression>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.preferenceSuppressions.get,
async (_c, { params }) => {
const r = await db()
.selectFrom("preference_suppression")
.selectAll()
.where("id", "=", params.id)
.executeTakeFirst();
return toApi<PreferenceSuppression>(r ?? notFound("抑制记录"));
},
{ tag: TAG },
);
mount(
app,
Api.preferenceSuppressions.create,
async (c, { body }) => {
const r = await db()
.insertInto("preference_suppression")
.values({
preference_tag_id: body.preferenceTagId ?? null,
fingerprint: body.fingerprint,
reason: body.reason ?? null,
evidence: jsonb(body.evidence),
suppressed_by: actorOf(c),
})
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PreferenceSuppression>(r);
},
{ tag: TAG },
);
mount(
app,
Api.preferenceSuppressions.update,
async (c, { params, body }) => {
const r = await db()
.updateTable("preference_suppression")
.set({
active: false,
lifted_at: new Date(),
lifted_by: actorOf(c),
...(body.reason ? { reason: body.reason } : {}),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirst();
return toApi<PreferenceSuppression>(r ?? notFound("抑制记录"));
},
{ tag: TAG },
);
}
// ── 抑制记录(/rules/suppressions 为新路径,/preference-suppressions 保留为别名)──
// preferenceTagId:旧标签行的 id;新建的规则组只有 ruleGroupId,为兼容旧界面同时回填到 preferenceTagId
const supToApi = (r: PreferenceSuppressionRow) => {
const o = toApi<PreferenceSuppression>(r);
return { ...o, preferenceTagId: r.preference_tag_id ?? r.rule_group_id };
};
for (const S of [Api.ruleSuppressions, Api.preferenceSuppressions]) {
mount(
app,
S.list,
async (_c, { query }) => {
let q = db().selectFrom("preference_suppression").selectAll();
if (query.active) q = q.where("active", "=", query.active === "true");
const p = await paginate(q, query);
return { items: p.rows.map(supToApi), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
S.get,
async (_c, { params }) => {
const r = await db()
.selectFrom("preference_suppression")
.selectAll()
.where("id", "=", params.id)
.executeTakeFirst();
return supToApi(r ?? notFound("抑制记录"));
},
{ tag: TAG },
);
mount(
app,
S.create,
async (c, { body }) => {
const gid: string | null = body.ruleGroupId ?? body.preferenceTagId ?? null;
const r = await db()
.insertInto("preference_suppression")
.values({
preference_tag_id: gid ? sql<string | null>`(select id from preference_tag where id = ${gid})` : null,
rule_group_id: gid ? sql<string | null>`(select id from rule_group where id = ${gid})` : null,
fingerprint: body.fingerprint,
reason: body.reason ?? null,
evidence: jsonb(body.evidence),
suppressed_by: actorOf(c),
})
.returningAll()
.executeTakeFirstOrThrow();
return supToApi(r);
},
{ tag: TAG },
);
mount(
app,
S.update,
async (c, { params, body }) => {
const r = await db()
.updateTable("preference_suppression")
.set({
active: false,
lifted_at: new Date(),
lifted_by: actorOf(c),
...(body.reason ? { reason: body.reason } : {}),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirst();
return supToApi(r ?? notFound("抑制记录"));
},
{ tag: TAG },
);
}
async function suppress(trx: Transaction<DB>, tagId: string, fingerprint: string, by: string, reason: string) {
await trx
.insertInto("preference_suppression")
.values({ preference_tag_id: tagId, fingerprint, reason, evidence: jsonb({ tagId }), suppressed_by: by })
.execute();
// 规则组 /rules/*:含 /rules/{id},必须最后注册(静态的 /rules/policies、/rules/suppressions 在前)
registerRuleGroupRoutes(app, d);
}
/** 发布:旧 published → superseded/rolled_back;吊销旧授权;新版本生成持续授权 automation_grant */
@@ -29,6 +29,7 @@ import {
import { fromParsedColumns, slotSpecFromRow, toContractSlotEvaluation } from "@umt/mapping";
import { hashJson, jsonb, startOfDay, toApi } from "../util.ts";
import { type AoDeps, automationBlock, type Exec, enqueueTracked, loadInstances, recordCandidate } from "./common.ts";
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog, resolveRuleChain } from "./rules.ts";
import {
clearDeferral,
rangeOf,
@@ -192,6 +193,7 @@ export async function createSlotsFromTemplates(
fallback: jsonb(t.fallback),
upgrade_threshold: jsonb(t.upgradeThreshold),
completion: jsonb(t.completion),
rule_group_ids: JSON.stringify(t.ruleGroupIds ?? []),
})
.onConflict((oc) => oc.doNothing())
.execute();
@@ -222,6 +224,10 @@ interface Ctx {
now: Date;
out: EvaluateOutcome;
draft: boolean;
/** 规则组目录(一次评估内复用) */
rules?: Promise<RuleCatalog>;
/** 作品原语言缓存(规则组 CNSUB/CNVOI 放行) */
langs?: Map<string, Promise<string | undefined>>;
}
async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
@@ -464,8 +470,8 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
}
}
/** 站点字段 → 评估上下文(做种数、促销下载系数、副标题/标签、站点 id) */
function contextOf(rel: SourceReleaseRow): EvaluationContext {
/** 站点字段 → 评估上下文(做种数、促销下载系数、副标题/标签、站点 id、发布后分钟数) */
export function contextOf(rel: SourceReleaseRow, now: Date = new Date()): EvaluationContext {
const promo = (rel.promotion ?? {}) as { kind?: string; raw?: { downloadFactor?: number | null } };
const factor =
typeof promo.raw?.downloadFactor === "number"
@@ -485,6 +491,9 @@ function contextOf(rel: SourceReleaseRow): EvaluationContext {
labels: rel.tags_raw ?? [],
...(rel.seeders !== null && rel.seeders !== undefined ? { seeders: rel.seeders } : {}),
...(factor !== undefined ? { downloadVolumeFactor: factor } : {}),
...(rel.source_published_at
? { publishedMinutesAgo: Math.max(0, (now.getTime() - rel.source_published_at.getTime()) / 60_000) }
: {}),
};
}
@@ -521,7 +530,26 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
return done;
}
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
const ctx = contextOf(rel);
const ctx = contextOf(rel, now);
// 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
c.rules ??= loadRuleCatalog(db);
const chain = resolveRuleChain(await c.rules, {
kind: "acquisition",
slotRuleGroupIds: (slot.rule_group_ids ?? []) as string[],
subRuleGroupIds: params.ruleGroupIds,
upgrade: !!spec.upgrade?.enabled,
mediaKind: subKindOf(p.entity.kind),
entityId: p.entity.id,
siteId: rel.site_id,
subscriptionId: sub?.id,
});
if (chain.groups.length) {
spec.rules = { groups: chain.groups, atoms: chain.atoms };
c.langs ??= new Map();
if (!c.langs.has(p.entity.id)) c.langs.set(p.entity.id, entityOriginalLanguage(db, p.entity.id));
const lang = await c.langs.get(p.entity.id);
if (lang) ctx.originalLanguage = lang;
}
const inflight = await slotInflightPlans(db, slot.id);
const inflightKeys = inflight.flatMap((x) => x.unitKeys);
const defer = async (reason: string, unitKeys: string[], mode: AcquisitionMode | null = null) => {
@@ -0,0 +1,347 @@
import type {
RuleExpr as ApiRuleExpr,
RuleAtom,
RuleGroup,
RuleGroupCreate,
RuleGroupKind,
RulePreviewRequest,
RulePreviewResult,
} from "@umt/contracts";
import type { RuleAtomRow, RuleGroupRow, SourceReleaseRow } from "@umt/db";
import {
atomsOf,
BUILTIN_ATOM_MAP,
type CustomAtom,
catalogOf,
compileMpPattern,
type EvaluationContext,
evaluateRuleChain,
normalizeExpr,
type ParsedRelease,
parseExpr,
parseRelease,
parseRuleString,
type RuleExpr,
type RuleGroupSpec,
type RuleLayer,
type RuleParseError,
type SlotRuleChain,
serializeExpr,
serializeLayers,
validateKeywordPattern,
} from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { hashJson, toApi } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts";
// 规则组(W9-D):目录加载、引用链解析(版本目标 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
export interface RuleCatalog {
/** 未删除的全部规则组(含停用,解析链时再过滤) */
groups: Map<string, RuleGroupRow>;
atoms: CustomAtom[];
}
export function atomFromRow(
r: Pick<
RuleAtomRow,
| "key"
| "name"
| "include"
| "exclude"
| "match_fields"
| "unit_size_mb"
| "min_seeders"
| "publish_delay_minutes"
| "free"
>,
): CustomAtom {
const arr = (v: unknown) => (Array.isArray(v) ? (v as string[]) : []);
const range = (v: unknown) => (v && typeof v === "object" ? (v as { min?: number; max?: number }) : undefined);
const out: CustomAtom = { id: r.key };
if (r.name) out.name = r.name;
if (arr(r.include).length) out.include = arr(r.include);
if (arr(r.exclude).length) out.exclude = arr(r.exclude);
if (arr(r.match_fields).length) out.matchFields = arr(r.match_fields) as CustomAtom["matchFields"];
const size = range(r.unit_size_mb);
if (size) out.unitSizeMB = size;
if (r.min_seeders !== null) out.minSeeders = r.min_seeders;
const pd = range(r.publish_delay_minutes);
if (pd) out.publishDelayMinutes = pd;
if (r.free) out.free = true;
return out;
}
export async function loadRuleCatalog(exec: Exec): Promise<RuleCatalog> {
const [groups, atoms] = await Promise.all([
exec.selectFrom("rule_group").selectAll().where("deleted_at", "is", null).execute(),
exec.selectFrom("rule_atom").selectAll().execute(),
]);
return { groups: new Map(groups.map((g) => [g.id, g])), atoms: atoms.map(atomFromRow) };
}
export function groupSpecOf(r: RuleGroupRow): RuleGroupSpec {
return {
id: r.id,
name: r.name,
revision: r.revision,
layers: (r.layers ?? []) as RuleLayer[],
hardExcludes: (r.hard_excludes ?? []) as RuleExpr[],
ladderRequired: r.ladder_required,
};
}
export function ruleGroupToApi(r: RuleGroupRow): RuleGroup {
const { legacy_rule: _legacy, ...rest } = r;
return toApi<RuleGroup>(rest);
}
export const ruleAtomToApi = (r: RuleAtomRow) => toApi<RuleAtom>(r);
export interface RuleTarget {
mediaKind?: "movie" | "series" | "variety" | undefined;
entityId?: string | undefined;
siteId?: string | undefined;
subscriptionId?: string | undefined;
}
interface Scope {
mediaTypes?: string[];
entityIds?: string[];
siteIds?: string[];
subscriptionId?: string;
}
/** scope 过滤;空 = 全部。私有组(subscriptionId)只对该订阅生效 */
export function inScope(r: RuleGroupRow, t: RuleTarget): boolean {
const s = (r.scope ?? {}) as Scope;
if (s.subscriptionId && s.subscriptionId !== t.subscriptionId) return false;
if (s.mediaTypes?.length && t.mediaKind && !s.mediaTypes.includes(t.mediaKind)) return false;
if (s.entityIds?.length && (!t.entityId || !s.entityIds.includes(t.entityId))) return false;
if (s.siteIds?.length && t.siteId && !s.siteIds.includes(t.siteId)) return false;
return true;
}
/** 某类默认链(启用、未删除、default_order 升序) */
export function defaultChainIds(cat: RuleCatalog, kind: RuleGroupKind): string[] {
return [...cat.groups.values()]
.filter((g) => g.kind === kind && g.default_order !== null && g.enabled)
.sort((a, b) => a.default_order! - b.default_order! || a.created_at.getTime() - b.created_at.getTime())
.map((g) => g.id);
}
export interface ResolveInput extends RuleTarget {
/** 版本目标 rule_group_ids(非空时优先) */
slotRuleGroupIds?: readonly string[] | undefined;
/** 订阅 params.ruleGroupIds */
subRuleGroupIds?: readonly string[] | undefined;
kind: "acquisition" | "search";
/** 版本目标开了升级:叠加洗版默认链 */
upgrade?: boolean | undefined;
}
/**
* 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。
*/
export function resolveRuleChain(cat: RuleCatalog, input: ResolveInput): SlotRuleChain & { ids: string[] } {
const explicit = input.slotRuleGroupIds?.length ? input.slotRuleGroupIds : (input.subRuleGroupIds ?? []);
const ids: string[] = [];
const push = (id: string) => {
if (!ids.includes(id)) ids.push(id);
};
if (input.subscriptionId)
for (const g of cat.groups.values())
if ((g.scope as Scope | null)?.subscriptionId === input.subscriptionId) push(g.id);
for (const id of explicit.length ? explicit : defaultChainIds(cat, input.kind)) push(id);
if (input.kind === "acquisition" && input.upgrade) for (const id of defaultChainIds(cat, "upgrade")) push(id);
const rows = ids
.map((id) => cat.groups.get(id))
.filter((g): g is RuleGroupRow => !!g && g.enabled && inScope(g, input));
return { ids: rows.map((r) => r.id), groups: rows.map(groupSpecOf), atoms: cat.atoms };
}
/** 语义指纹:类型 + 范围 + 层 + 硬排除(规范化规则串)+ 是否必须命中 */
export function ruleFingerprint(g: {
kind: string;
scope: unknown;
layers: readonly RuleLayer[];
hardExcludes: readonly RuleExpr[];
ladderRequired: boolean;
}): string {
return hashJson({
kind: g.kind,
scope: g.scope ?? {},
layers: g.layers.map((l) => serializeExpr(normalizeExpr(l.expr))),
hardExcludes: g.hardExcludes.map((x) => serializeExpr(normalizeExpr(x))).sort(),
ladderRequired: g.ladderRequired,
});
}
const fmtErrors = (errs: readonly RuleParseError[]) =>
errs.map((e) => `${e.message}(第 ${e.layer + 1} 层,位置 ${e.position})`).join(";");
/** 表达式里的原子都要存在(内置且支持,或自定义) */
function checkAtoms(e: RuleExpr, custom: ReadonlySet<string>): string[] {
const bad: string[] = [];
for (const id of atomsOf(e)) {
const b = BUILTIN_ATOM_MAP.get(id);
if (b ? !b.supported : !custom.has(id)) bad.push(b ? `${id}(不支持)` : `${id}(不存在)`);
}
return bad;
}
/** 请求体 → 规范化的层 / 硬排除(ruleString 优先于 layers;硬排除元素可为单层规则串) */
export function normalizeGroupInput(
body: Pick<RuleGroupCreate, "layers" | "ruleString" | "hardExcludes">,
customKeys: Iterable<string>,
): { layers?: RuleLayer[]; hardExcludes?: RuleExpr[] } {
const custom = new Set([...customKeys].map((k) => k.toUpperCase()));
const out: { layers?: RuleLayer[]; hardExcludes?: RuleExpr[] } = {};
if (body.ruleString !== undefined) {
if (body.ruleString.trim() === "") out.layers = [];
else {
const p = parseRuleString(body.ruleString, { customAtoms: custom });
if (p.errors.length) throw new ApiException("validation_failed", `规则串解析失败:${fmtErrors(p.errors)}`);
out.layers = p.layers;
}
} else if (body.layers !== undefined) {
out.layers = body.layers.map((l) => ({
...(l.name ? { name: l.name } : {}),
expr: normalizeExpr(l.expr as RuleExpr),
}));
}
if (body.hardExcludes !== undefined) {
out.hardExcludes = body.hardExcludes.map((x: string | ApiRuleExpr, i: number) => {
if (typeof x !== "string") return normalizeExpr(x as RuleExpr);
if (x.includes(">")) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条不能分层(去掉 >)`);
const p = parseExpr(x, { customAtoms: custom });
if (!p.expr) throw new ApiException("validation_failed", `硬排除第 ${i + 1} 条解析失败:${fmtErrors(p.errors)}`);
return p.expr;
});
}
const bad = [...(out.layers ?? []).map((l) => l.expr), ...(out.hardExcludes ?? [])].flatMap((e) =>
checkAtoms(e, custom),
);
if (bad.length) throw new ApiException("validation_failed", `条件无效:${[...new Set(bad)].join("、")}`);
return out;
}
export const ruleStringOf = (layers: readonly RuleLayer[]) => (layers.length ? serializeLayers(layers) : null);
/** 自定义条件正则:MP 语法(去掉 (?i))+ 防灾难回溯白名单 */
export function checkAtomPatterns(ps: readonly string[] | undefined): void {
for (const p of ps ?? []) {
const stripped = p.replace(/\(\?i\)/g, "");
const why = validateKeywordPattern(stripped) ?? (compileMpPattern(p) ? null : "invalid");
if (why) throw new ApiException("validation_failed", `正则不可用(${why}):${p}`);
}
}
/** 作品原语言(元数据字段 originalLanguage,最近一次) */
export async function entityOriginalLanguage(exec: Exec, entityId: string | null): Promise<string | undefined> {
if (!entityId) return undefined;
const r = await exec
.selectFrom("metadata_field")
.select("value")
.where("media_entity_id", "=", entityId)
.where("field", "=", "originalLanguage")
.orderBy("fetched_at", "desc")
.limit(1)
.executeTakeFirst();
return typeof r?.value === "string" && r.value ? r.value : undefined;
}
/** 试算:对最近 N 条已识别资源(或指定资源 / 粘贴标题)跑本组 */
export async function previewRuleGroup(
d: AoDeps,
row: RuleGroupRow,
body: RulePreviewRequest,
helpers: {
parsedOf: (r: SourceReleaseRow) => ParsedRelease;
contextOf: (r: SourceReleaseRow) => EvaluationContext;
},
): Promise<RulePreviewResult> {
const db = d.ctx.db;
const cat = await loadRuleCatalog(db);
const spec = groupSpecOf(row);
if (body.draft) {
const n = normalizeGroupInput(
body.draft,
cat.atoms.map((a) => a.id),
);
if (n.layers) spec.layers = n.layers;
if (n.hardExcludes) spec.hardExcludes = n.hardExcludes;
if (body.draft.ladderRequired !== undefined) spec.ladderRequired = body.draft.ladderRequired;
}
const catalog = catalogOf(cat.atoms);
const samples: {
id: string | null;
title: string;
subtitle: string | null;
parsed: ParsedRelease;
ctx: EvaluationContext;
}[] = [];
for (const t of body.titles ?? [])
samples.push({
id: null,
title: t.title,
subtitle: t.subtitle ?? null,
parsed: parseRelease(t.title, t.subtitle ? { subtitle: t.subtitle } : {}),
ctx: t.subtitle ? { subtitle: t.subtitle } : {},
});
// 指定资源 → 只取这些;否则取最近 N 条(只粘贴标题且没给 limit 时不取)
if (body.sourceReleaseIds?.length || !body.titles?.length || body.limit !== undefined) {
let q = db.selectFrom("source_release").selectAll();
q = body.sourceReleaseIds?.length
? q.where("id", "in", body.sourceReleaseIds)
: q
.where("resolution_status", "=", "resolved")
.orderBy("first_seen_at", "desc")
.limit(body.limit ?? 50);
const langs = new Map<string, string | undefined>();
for (const r of await q.execute()) {
const ctx = helpers.contextOf(r);
if (r.media_entity_id) {
if (!langs.has(r.media_entity_id))
langs.set(r.media_entity_id, await entityOriginalLanguage(db, r.media_entity_id));
const lang = langs.get(r.media_entity_id);
if (lang) ctx.originalLanguage = lang;
}
samples.push({ id: r.id, title: r.title, subtitle: r.subtitle, parsed: helpers.parsedOf(r), ctx });
}
}
const items = samples.map((s) => {
const res = evaluateRuleChain([spec], s.parsed, s.ctx, catalog);
const g = res.groups[0]!;
const reasons = [
...(g.excludedBy.length ? ["hard_exclude"] : []),
...(g.pri !== null && g.level === null && spec.ladderRequired !== false ? ["no_layer_matched"] : []),
];
return {
sourceReleaseId: s.id,
title: s.title,
subtitle: s.subtitle,
passed: res.passed,
pri: res.passed ? (res.pri ?? 100) : null,
level: g.level,
excludedBy: g.excludedBy,
reasons,
trace: res.trace,
};
});
const hist = new Map<number | null, number>();
for (const it of items) hist.set(it.pri, (hist.get(it.pri) ?? 0) + 1);
return {
ruleGroupId: row.id,
revision: row.revision,
evaluatedAt: d.ports.now().toISOString(),
sampleSize: items.length,
passed: items.filter((i) => i.passed).length,
rejected: items.filter((i) => !i.passed).length,
priHistogram: [...hist.entries()]
.sort((a, b) => (b[0] ?? -1) - (a[0] ?? -1))
.map(([pri, count]) => ({ pri, count })),
items,
};
}