import { z } from "zod"; import { cursorPage, Id, IdParam } from "../common.ts"; import * as policy from "../policy.ts"; import { crud, del, ep, listQuery, R } from "./shared.ts"; export const rulesApi = { // ── 规则 ── subscriptions: { ...crud( "/subscriptions", "订阅", { row: policy.Subscription, create: policy.SubscriptionCreate, update: policy.SubscriptionUpdate, filters: { status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional(), mediaKind: policy.SubscriptionMediaKind.optional(), /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ q: z.string().max(200).optional(), }, }, R, ), /** 日历(静态路径,须先于 /subscriptions/{id} 注册) */ calendar: ep({ method: "get", path: "/subscriptions/calendar", scope: "read", summary: "订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与整理状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天", query: z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), mediaKind: policy.SubscriptionMediaKind.optional(), }), response: policy.SubscriptionCalendar, }), reset: ep({ method: "post", path: "/subscriptions/{id}/reset", scope: "admin", summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已整理文件保留", params: IdParam, response: policy.SubscriptionResetResult, }), /** 总览:在 crud 列表上加分组/排序/筛选,条目带卡片派生字段,另给各分组计数(docs/design/subscriptions.md §1) */ list: ep({ method: "get", path: "/subscriptions", scope: "read", summary: "订阅总览(分组、进度、各版本摘要、上次更新、下次搜索、站点、规则组;cancelled 只在显式 status=cancelled 时返回)", query: listQuery({ status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional(), mediaKind: policy.SubscriptionMediaKind.optional(), /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ q: z.string().max(200).optional(), group: policy.SubscriptionGroup.optional(), sort: policy.SubscriptionSort.optional(), order: z.enum(["asc", "desc"]).optional(), siteId: Id.optional(), ruleGroupId: Id.optional(), origin: policy.SubscriptionOrigin.optional(), upgrading: z.enum(["true", "false"]).optional(), }), response: policy.SubscriptionOverviewPage, }), coverage: ep({ method: "get", path: "/subscriptions/{id}/coverage", scope: "read", summary: "覆盖矩阵:季 → 集 × 收录版本(电影:内容版本 × 收录版本),每格 have/inflight/missing/skipped;缓存 30s,refresh=true 强制重算", params: IdParam, query: z.object({ refresh: z.enum(["true", "false"]).optional() }), response: policy.SubscriptionCoverage, }), pause: ep({ method: "post", path: "/subscriptions/{id}/pause", scope: "admin", summary: "暂停订阅(不评估、不搜索)", params: IdParam, response: policy.Subscription, }), resume: ep({ method: "post", path: "/subscriptions/{id}/resume", scope: "admin", summary: "恢复已暂停的订阅(TMDB 未就绪回到 pending),立即重评并排上搜索", params: IdParam, response: policy.Subscription, }), resubscribe: ep({ method: "post", path: "/subscriptions/{id}/resubscribe", scope: "admin", summary: "重新订阅已完成/已取消的订阅(历史保留),立即重评并搜索", params: IdParam, response: policy.Subscription, }), batch: ep({ method: "post", path: "/subscriptions/batch", scope: "admin", summary: "批量:立即搜索 / 暂停 / 恢复 / 删除(删除 = 取消,写审计)", body: policy.SubscriptionBatch, response: policy.SubscriptionBatchResult, }), fillSlot: ep({ method: "post", path: "/subscriptions/{id}/slots/{slotId}/fill", scope: "admin", summary: "手动补全某收录版本(不受自动补全开关与补全预算限制)", params: z.object({ id: Id, slotId: Id }), body: policy.SlotFillRequest, response: z.object({ runId: z.string(), deduplicated: z.boolean() }), }), slotOrder: ep({ method: "put", path: "/subscriptions/{id}/slot-order", scope: "admin", summary: "收录版本排序(第一个为主版本)", params: IdParam, body: policy.SlotOrderSet, response: z.array(policy.AcquisitionSlot), }), search: ep({ method: "post", path: "/subscriptions/{id}/search", scope: "admin", summary: "手动主动搜索(按标题/别名在已开启站点搜索,结果走同一识别评估链)", params: IdParam, response: z.object({ runId: z.string(), deduplicated: z.boolean() }), }), progress: ep({ method: "get", path: "/subscriptions/{id}/progress", scope: "read", summary: "订阅进度:季总集数、每集状态(已覆盖/在途/缺失/疑似缺集)、收录版本在途与延后", params: IdParam, response: policy.SubscriptionProgress, }), setEpisodeTotal: ep({ method: "post", path: "/subscriptions/{id}/episode-total", scope: "admin", summary: "手动设置季总集数(只升;locked 锁定后刷新不再改写)", params: IdParam, body: policy.EpisodeTotalSet, response: policy.SubscriptionProgress, }), }, subscriptionHistory: crud( "/subscription-history", "订阅完成历史", { row: policy.SubscriptionHistory, filters: { mediaEntityId: Id.optional(), subscriptionId: Id.optional() }, }, R, ), policyRevisions: { ...crud( "/policy-revisions", "策略版本", { row: policy.PolicyRevision, create: policy.PolicyRevisionCreate, update: policy.PolicyRevisionUpdate, filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }, }, R, ), preview: ep({ method: "post", path: "/policy-revisions/{id}/preview", scope: "read", summary: "历史回放预览", params: IdParam, response: policy.PolicyRevision, }), publish: ep({ method: "post", path: "/policy-revisions/{id}/publish", scope: "admin", summary: "发布策略版本", params: IdParam, body: z.object({ expectedRevision: z.number().int().min(1) }), response: policy.PolicyRevision, }), rollback: ep({ method: "post", path: "/policy-revisions/{id}/rollback", scope: "admin", summary: "回滚到该版本", params: IdParam, response: policy.PolicyRevision, }), }, acquisitionSlots: crud( "/acquisition-slots", "收录版本", { row: policy.AcquisitionSlot, create: policy.AcquisitionSlotCreate, update: policy.AcquisitionSlotUpdate, filters: { mediaEntityId: Id.optional(), status: policy.SlotStatus.optional(), subscriptionId: Id.optional(), }, }, R, ), preferenceTags: { ...crud( "/preference-tags", "偏好标签", { row: policy.PreferenceTag, create: policy.PreferenceTagCreate, update: policy.PreferenceTagUpdate, filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() }, }, R, ), /** 删除 = 标记 deleted + 写抑制记录 */ remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"), revisions: ep({ method: "get", path: "/preference-tags/{id}/revisions", scope: "read", summary: "偏好标签修订历史(新→旧;取自审计事件)", params: IdParam, response: policy.PreferenceTagRevisionList, }), }, preferenceSuppressions: crud( "/preference-suppressions", "偏好抑制", { row: policy.PreferenceSuppression, create: policy.PreferenceSuppressionCreate, update: policy.PreferenceSuppressionUpdate, filters: { active: z.enum(["true", "false"]).optional() }, }, 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, ), } as const;