From 91cc150f17e2009189b256fddbc01a78919b4df3 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 15:51:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8B=E4=B8=80=E8=BD=AE=E7=AC=AC=203=20?= =?UTF-8?q?=E6=B3=A2=E7=9A=84=E5=A5=91=E7=BA=A6=E9=AA=A8=E6=9E=B6=EF=BC=9A?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=89=88=E6=9C=AC=E5=8A=A0=E8=A1=A8?= =?UTF-8?q?=E7=9A=84=E7=BB=93=E6=9E=84=E7=89=88=E6=9C=AC=EF=BC=9B=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E9=9D=9E=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=8C=89=E7=A7=8D=E7=B1=BB=E4=BA=A4=E7=BB=99=E5=90=84?= =?UTF-8?q?=E8=87=AA=E7=9A=84=E6=A8=A1=E5=9D=97=EF=BC=88=E8=AF=84=E6=B5=8B?= =?UTF-8?q?=E9=A2=98=E9=9B=86=20app/eval/scheduled=E3=80=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E7=BB=B4=E6=8A=A4=20app/data/source=5Fmainte?= =?UTF-8?q?nance=EF=BC=8C=E5=85=88=E6=94=BE=E7=85=A7=E5=AE=9E=E8=AE=B0?= =?UTF-8?q?=E3=80=8C=E8=BF=98=E4=B8=8D=E8=83=BD=E5=8F=91=E8=B5=B7=E3=80=8D?= =?UTF-8?q?=E7=9A=84=E5=8D=A0=E4=BD=8D=EF=BC=89=E3=80=81record=5Ffire=20?= =?UTF-8?q?=E5=85=B1=E7=94=A8=EF=BC=9B=E5=88=86=E4=BA=AB=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E3=80=81=E6=95=B0=E6=8D=AE=E6=BA=90=E5=8F=98=E5=8C=96=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E3=80=81=E5=8F=A3=E5=BE=84=E8=B4=A6=E6=9C=AC=E6=8C=89?= =?UTF-8?q?=E7=AD=94=E6=A1=88=E5=87=BA=E5=85=B7=E4=B8=8E=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=E7=9A=84=E8=B7=AF=E7=94=B1=E5=85=88=E7=99=BB=E8=AE=B0=E5=A5=BD?= =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/ledger.py | 3 ++ backend/app/api/shares.py | 10 ++++++ backend/app/api/source_changes.py | 9 ++++++ backend/app/data/source_maintenance.py | 21 +++++++++++++ backend/app/db/base.py | 2 ++ backend/app/db/models.py | 3 ++ backend/app/eval/scheduled.py | 21 +++++++++++++ backend/app/main.py | 8 +++++ backend/app/scheduler/service.py | 42 ++++++++++++++++++++++++-- 9 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/shares.py create mode 100644 backend/app/api/source_changes.py create mode 100644 backend/app/data/source_maintenance.py create mode 100644 backend/app/eval/scheduled.py diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 2fb98348..1af77f15 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -26,6 +26,9 @@ router = APIRouter(prefix="/api/ledger", tags=["ledger"]) #: 口径账本里按运行读的那几个(一个答案里每个数的口径标签等,docs/design/next-round/02-口径账本.md):挂在 #: /api/runs/{run_id}/ledger… 下,和证据接口同一道门(main.py 挂 evidence_scope:使用者只读、而且要能读这次运行) runs_router = APIRouter(prefix="/api/runs", tags=["ledger"]) +#: 按口径正式出具、口径对账这类要写的(docs/design/next-round/02-口径账本.md 做法 5、6):使用者也能发起(总方案拍板 +#: 账本-L5:对账都可以,正式出具只限有权使用的应用固定着的口径版本),所以 main.py 只挂登录即可,接口里按运行和应用授权再判 +answers_router = APIRouter(prefix="/api/ledger/answers", tags=["ledger"]) @router.get("/workflow-versions/{workflow_id}/{version}") diff --git a/backend/app/api/shares.py b/backend/app/api/shares.py new file mode 100644 index 00000000..b001ac79 --- /dev/null +++ b/backend/app/api/shares.py @@ -0,0 +1,10 @@ +"""分享链接(docs/design/next-round/04-对外集成与触达.md C2)。 + +读分享页的接口不用登录,只认请求头里的分享 key(中间件把 /api/shares/ 当公开前缀,第 3 波 W3-d 加);建、撤销分享在 +/api/apps/{id}/shares 下,要应用的 manage 权限。第 3 波 W3-d 填。 +""" +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(prefix="/api/shares", tags=["shares"]) diff --git a/backend/app/api/source_changes.py b/backend/app/api/source_changes.py new file mode 100644 index 00000000..e41f05c1 --- /dev/null +++ b/backend/app/api/source_changes.py @@ -0,0 +1,9 @@ +"""数据源的变化记录(docs/design/next-round/03-数据接入与目录维护.md B3):结构变化、取值变化,查看与处理。 + +挂在 /api/datasources/{id}/changes 下,要在 datasources 的通配 /{source_id} 之前登记(main.py)。第 3 波 W3-a 填。 +""" +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(prefix="/api/datasources", tags=["source-changes"]) diff --git a/backend/app/data/source_maintenance.py b/backend/app/data/source_maintenance.py new file mode 100644 index 00000000..24efde1a --- /dev/null +++ b/backend/app/data/source_maintenance.py @@ -0,0 +1,21 @@ +"""数据源的定时维护:探查 → 剖析需要的表 → 取值观察(docs/design/next-round/03-数据接入与目录维护.md B4)。 + +scheduler/service.py 按目标种类导入本模块(_TARGET_MODULES):fire 到点发起一次,check_target 建任务时核对目标。 +第 3 波 W3-a 填;填之前照实记「当前版本还不能按时发起这类目标」,不发起。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +async def fire(*, schedule_id: str, fire_at: datetime, kind: str, actor: str | None, target_id: str, name: str, + tz: str, cron_expr: str) -> Any: + from app.scheduler import service + + reason = service.TARGET_UNSUPPORTED.format(kind=service.TARGET_LABEL["source_maintenance"]) + return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", reason) + + +async def check_target(session: Any, target_id: str) -> str | None: + return "当前版本还不能按时发起「数据源维护」这类目标" diff --git a/backend/app/db/base.py b/backend/app/db/base.py index b45bbade..fe940284 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -248,6 +248,8 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ # 第 2 波:服务账号(users.kind);剖析上传表格源时的快照 ("users", "kind", "VARCHAR(20) DEFAULT 'person'"), ("catalog_profile_tasks", "snapshot_id", "VARCHAR(32)"), + # 第 3 波:发布时这一版引用的表的结构版本 + ("workflow_versions", "schema_versions", "JSON"), ] #: 迁移补上的列要建的索引:(索引名, 表, 列或多列)。create_all 不碰已有的表,表上新列的索引也就不会建。 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 8232536d..b570945c 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -629,6 +629,9 @@ class WorkflowVersion(Base, TimestampMixin): retire_note: Mapped[str | None] = mapped_column(Text, default=None) # 发布时这一版用到的能力的治理版本:{能力名: version}。正式运行发起时有漂移只提醒、不拦 capability_versions: Mapped[dict[str, Any] | None] = mapped_column(default=None) + # 发布时这一版直接引用的表的结构版本 {源名: {表名: 结构版本}}(next-round 03 B1):正式运行发起时比对,变了只提醒、 + # 不拦(schema.drift),写法照 catalog_versions。加这一列之前发布的版本为空 + schema_versions: Mapped[dict[str, Any] | None] = mapped_column(default=None) workflow: Mapped[Workflow] = relationship(back_populates="versions") diff --git a/backend/app/eval/scheduled.py b/backend/app/eval/scheduled.py new file mode 100644 index 00000000..f2ba0d43 --- /dev/null +++ b/backend/app/eval/scheduled.py @@ -0,0 +1,21 @@ +"""评测题集的定时(docs/design/next-round/01-效果度量与回归评测.md 做法 15)。 + +scheduler/service.py 按目标种类导入本模块(_TARGET_MODULES):fire 到点发起一次,check_target 建任务时核对目标。 +第 3 波 W3-c 填;填之前照实记「当前版本还不能按时发起这类目标」,不发起。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +async def fire(*, schedule_id: str, fire_at: datetime, kind: str, actor: str | None, target_id: str, name: str, + tz: str, cron_expr: str) -> Any: + from app.scheduler import service + + reason = service.TARGET_UNSUPPORTED.format(kind=service.TARGET_LABEL["eval_suite"]) + return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", reason) + + +async def check_target(session: Any, target_id: str) -> str | None: + return "当前版本还不能按时发起「评测题集」这类目标" diff --git a/backend/app/main.py b/backend/app/main.py index e9e7aed1..60d2f860 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,8 @@ from app.api import ( ) # 下一轮第 2 波(docs/design/next-round):评测、访问密钥 from app.api import access_keys as access_keys_api, evals as evals_api +# 第 3 波:分享链接、数据源的变化记录 +from app.api import shares as shares_api, source_changes as source_changes_api # 普通用户建应用(docs/design/normal-user-apps.md §3.10):蓝图、模板、文件、个人应用。权限在接口里按蓝图的范围和拥有者判 from app.api import ( blueprints as blueprints_api, copilot_blueprint, files as files_api, personal as personal_api, @@ -255,6 +257,8 @@ for router, guard in ( (table_imports.router, _BUILDER), (source_versions.router, _BUILDER), (catalog.router, _BUILDER), + # 数据源的变化记录(/api/datasources/{id}/changes…):在通配 /{source_id} 之前登记 + (source_changes_api.router, _BUILDER), (datasources.router, _BUILDER), # 应用平台。登录、登出、OIDC、JWKS 匿名可用,设置在接口里要管理员;入站钩子只认签名 (auth_api.router, []), @@ -276,10 +280,14 @@ for router, guard in ( (tasks_api.router, _USER), (actions_api.router, _USER), (hooks_api.router, []), + # 分享链接的读接口只认分享 key(不用登录,中间件放行 /api/shares/) + (shares_api.router, []), (notify_api.router, _USER), (ledger_api.router, _BUILDER), # 下一轮第 2 波:评测只给搭建者;访问密钥登录即可,接口里再按本人 / 管理员判 (evals_api.router, _BUILDER), + # 口径账本里按答案出具、对账的(第 3 波):登录即可,接口里按运行和应用授权判 + (ledger_api.answers_router, _USER), (access_keys_api.router, _USER), ): app.include_router(router, dependencies=guard) diff --git a/backend/app/scheduler/service.py b/backend/app/scheduler/service.py index 87d0ff18..76bc5056 100644 --- a/backend/app/scheduler/service.py +++ b/backend/app/scheduler/service.py @@ -86,6 +86,40 @@ def target_of(schedule: Any) -> tuple[str, str]: return kind, getattr(schedule, "target_id", None) or "" +#: 非工作流目标各由一个模块处理(next-round X4):模块里有 +#: async def fire(*, schedule_id, fire_at, kind, actor, target_id, name, tz, cron_expr) -> ScheduleFire | None +#: 到点发起一次(持着 launch_lock 调用);触发记录用本模块的 record_fire 写,和工作流同一套去重、同一种状态词 +#: async def check_target(session, target_id) -> str | None +#: 建任务、改目标时核对目标(存在、可用),不合规返回给人看的一句,合规返回 None +#: 按需导入(评测、数据源维护的模块要导入本模块,模块级互相导入会成环) +_TARGET_MODULES = {"eval_suite": "app.eval.scheduled", "source_maintenance": "app.data.source_maintenance"} + + +def target_handler(kind: str) -> Any | None: + """处理这种目标的模块;不认识的种类为 None。""" + import importlib + + module = _TARGET_MODULES.get(kind) + return importlib.import_module(module) if module else None + + +async def record_fire(schedule_id: str, fire_at: datetime, kind: str, status: str, reason: str | None, + inputs: dict[str, Any] | None = None, **columns: Any) -> ScheduleFire | None: + """非工作流目标写触发记录(同 _record:同一次触发已经记过就交回已有的那一条)。columns 写额外的列,比如评测的 + eval_run_id。""" + fire = await _record(schedule_id, fire_at, kind, status, reason, inputs or {}) + if fire is not None and columns: + async with SessionLocal() as session: + row = await session.get(ScheduleFire, fire.id) + if row is not None: + for key, value in columns.items(): + setattr(row, key, value) + await session.commit() + await session.refresh(row) + return row + return fire + + def target_columns(kind: str, target_id: str) -> dict[str, str]: """写目标的几列:{target_kind, target_id, workflow_id}。workflow 时 target_id 同 workflow_id;别的种类 workflow_id 存空串 (老表的非空约束)。种类不认识、id 为空抛 ValueError。""" @@ -325,8 +359,12 @@ async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: st name, run_class = schedule.name, schedule.run_class tz, cron_expr, template = schedule.timezone, schedule.cron, schedule.input_template if target_kind != "workflow": - reason = TARGET_UNSUPPORTED.format(kind=TARGET_LABEL.get(target_kind, target_kind)) - return await _record(schedule_id, fire_at, kind, "failed_to_start", reason, {}) + handler = target_handler(target_kind) + if handler is None: + reason = TARGET_UNSUPPORTED.format(kind=TARGET_LABEL.get(target_kind, target_kind)) + return await _record(schedule_id, fire_at, kind, "failed_to_start", reason, {}) + return await handler.fire(schedule_id=schedule_id, fire_at=fire_at, kind=kind, actor=actor, + target_id=target_id, name=name, tz=tz, cron_expr=cron_expr) return await _fire_workflow(schedule_id, fire_at, kind, actor=actor, name=name, workflow_id=target_id, run_class=run_class, tz=tz, cron_expr=cron_expr, template=template)