From 3189d2f95f4eade4ea7edf0b7f7c70485afbd07e Mon Sep 17 00:00:00 2001 From: AlexBybye <244417287@qq.com> Date: Tue, 1 Sep 2026 11:50:34 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E4=B8=8E=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E8=B0=83=E5=BA=A6=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=AE=89=E5=85=A8=E6=9C=BA=E5=88=B6=E5=8F=8A=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=90=86=EF=BC=8C=E5=AE=8C=E6=88=90PLAN-3-8.?= =?UTF-8?q?1-8.4=E5=86=85=E5=AE=B9=EF=BC=8CSenior-2=E6=BD=9C=E5=9C=A8?= =?UTF-8?q?=E6=BC=8F=E6=B4=9E=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/scut_senior_api/main.py | 39 ++++++- .../api/src/scut_senior_api/maintenance.py | 58 ++++++++-- apps/scut-senior/docs/senior-3/PLAN-3.md | 16 ++- .../python/test_active_streams_registry.py | 67 +++++++++++ .../python/test_maintenance_scheduler.py | 104 +++++++++++++++++- .../web/src/__tests__/latestSaveQueue.test.ts | 104 ++++++++++++++++++ .../web/src/__tests__/workflowStream.test.ts | 74 +++++++++++++ .../web/src/composables/useAppStore.ts | 23 ++-- apps/scut-senior/web/src/latestSaveQueue.ts | 54 +++++++++ apps/scut-senior/web/src/workflowStream.ts | 13 +++ 10 files changed, 518 insertions(+), 34 deletions(-) create mode 100644 apps/scut-senior/tests/python/test_active_streams_registry.py create mode 100644 apps/scut-senior/web/src/__tests__/latestSaveQueue.test.ts create mode 100644 apps/scut-senior/web/src/latestSaveQueue.ts diff --git a/apps/scut-senior/api/src/scut_senior_api/main.py b/apps/scut-senior/api/src/scut_senior_api/main.py index 388564f9..a161ca12 100644 --- a/apps/scut-senior/api/src/scut_senior_api/main.py +++ b/apps/scut-senior/api/src/scut_senior_api/main.py @@ -3,6 +3,7 @@ import asyncio import json import logging +import threading from contextlib import asynccontextmanager from hmac import compare_digest from uuid import UUID @@ -123,7 +124,34 @@ # 活跃流式会话登记:run_id → (user_id, session)。 # 静默断线不再取消运行(见 stream_workflow),显式取消端点靠这里定位会话。 +# 该 registry 在事件循环协程、asyncio.to_thread 后台线程与取消端点之间共享, +# 因此所有字典操作与用户校验都在 _STREAMS_LOCK 内完成;session.cancel() 在锁外执行。 _ACTIVE_STREAMS: dict[str, tuple[str, WorkflowStreamSession]] = {} +_STREAMS_LOCK = threading.Lock() + + +def _register_stream( + run_key: str, user_id: str, session: WorkflowStreamSession +) -> None: + with _STREAMS_LOCK: + _ACTIVE_STREAMS[run_key] = (user_id, session) + + +def _unregister_stream(run_key: str) -> None: + with _STREAMS_LOCK: + _ACTIVE_STREAMS.pop(run_key, None) + + +def _find_stream_session( + run_key: str, user_id: str +) -> WorkflowStreamSession | None: + """锁内只做字典查找与用户校验;取消调用由调用方在锁外执行。""" + + with _STREAMS_LOCK: + entry = _ACTIVE_STREAMS.get(run_key) + if entry is None or entry[0] != user_id: + return None + return entry[1] OAUTH_STATE_COOKIE_NAME = "__Host-scut_senior_oauth_state" @@ -1045,7 +1073,7 @@ def enqueue_event(event: object) -> None: session = WorkflowStreamSession(enqueue_event) run_key = str(session.workflow_run_id) # 显式取消端点需要按 run_id 找到会话;静默断线不再等价于取消。 - _ACTIVE_STREAMS[run_key] = (str(user.user_id), session) + _register_stream(run_key, str(user.user_id), session) def execute() -> None: try: @@ -1103,7 +1131,7 @@ async def event_source(): ) raise finally: - _ACTIVE_STREAMS.pop(run_key, None) + _unregister_stream(run_key) if task.done() and not task.cancelled(): task.exception() @@ -1121,13 +1149,14 @@ async def cancel_workflow( run_id: UUID, user: UserIdentity | AuthenticatedPrincipal = Depends(require_user), ) -> dict[str, bool]: - entry = _ACTIVE_STREAMS.get(str(run_id)) - if entry is None or entry[0] != str(user.user_id): + # 锁内只完成查找与用户校验;session.cancel() 在锁外调用。 + session = _find_stream_session(str(run_id), str(user.user_id)) + if session is None: raise HTTPException( status_code=404, detail="没有正在运行的该工作流(可能已完成、已取消或不属于当前用户)。", ) - entry[1].cancel() + session.cancel() return {"cancel_requested": True} @app.post( diff --git a/apps/scut-senior/api/src/scut_senior_api/maintenance.py b/apps/scut-senior/api/src/scut_senior_api/maintenance.py index 7fcfc327..41adfbec 100644 --- a/apps/scut-senior/api/src/scut_senior_api/maintenance.py +++ b/apps/scut-senior/api/src/scut_senior_api/maintenance.py @@ -6,7 +6,8 @@ - 调度器随应用进程启停;进程停止期间不发生任何清理。 - 线程启动后立即补扫一次(覆盖停机窗口内到期的数据),随后按固定间隔扫描, 因此"到期数据物理清理"的最坏延迟为「停机时长 + 一个扫描间隔」。 -- 单次扫描内部异常只记录日志并继续下一轮,不让一个坏表拖垮整个循环; +- 每个清理步骤独立捕获异常:单一步骤失败只记录步骤名与堆栈、该步骤计数按 0 + 处理,后续步骤继续执行,不让一个坏表拖垮整轮清理; 清理语句本身是幂等的 ``DELETE ... WHERE expires_at <= now``,多 worker 并发重复执行不会双重删除或误删未到期数据(SQLite 写串行化保证)。 - 时钟与间隔可注入,便于测试用受控时钟验证"停机重启后到期数据仍被清理"。 @@ -20,6 +21,7 @@ import logging import threading from dataclasses import dataclass +from types import SimpleNamespace from typing import Any from .auth import Clock, utc_now @@ -75,18 +77,39 @@ def running(self) -> bool: return bool(self._thread and self._thread.is_alive()) def sweep(self) -> MaintenanceSweepResult: - """执行一次完整清理;由后台线程与启动补扫共用。""" + """执行一次完整清理;由后台线程与启动补扫共用。 - auth = self._repository.cleanup_auth_records() - history = self._repository.cleanup_history_records() - materials = self._repository.cleanup_material_records() + 每个清理步骤独立捕获异常:失败步骤只记录日志、计数按 0 处理, + 后续步骤继续执行;结果结构、SQL 与调度间隔保持不变。 + """ + + auth = self._run_cleanup_step( + "cleanup_auth_records", + lambda: self._repository.cleanup_auth_records(), + SimpleNamespace(oauth_states=0, auth_sessions=0), + ) + history = self._run_cleanup_step( + "cleanup_history_records", + lambda: self._repository.cleanup_history_records(), + SimpleNamespace(workflow_runs=0, conversations=0, feedback=0), + ) + materials = self._run_cleanup_step( + "cleanup_material_records", + lambda: self._repository.cleanup_material_records(), + SimpleNamespace(materials=0, contributions_cleared=0), + ) # 迭代 7.5:共享额度锁存的窗口流水/过期闩锁一并周期清理。 - quota_events = 0 - cleanup_quota = getattr( - self._repository, "cleanup_platform_quota_records", None + quota_events = self._run_cleanup_step( + "cleanup_platform_quota_records", + lambda: ( + self._repository.cleanup_platform_quota_records() + if callable( + getattr(self._repository, "cleanup_platform_quota_records", None) + ) + else 0 + ), + 0, ) - if callable(cleanup_quota): - quota_events = cleanup_quota() result = MaintenanceSweepResult( auth_states=auth.oauth_states, auth_sessions=auth.auth_sessions, @@ -115,6 +138,21 @@ def sweep(self) -> MaintenanceSweepResult: ) return result + def _run_cleanup_step(self, step_name: str, fn: Any, zero: Any) -> Any: + """执行单个清理步骤;异常只记录步骤名与堆栈,不阻断后续步骤。 + + ``zero`` 是该步骤失败时的零计数回退(属性对象或整数), + 保证 ``MaintenanceSweepResult`` 结构与成功路径完全一致。 + """ + + try: + return fn() + except Exception: # noqa: BLE001 - 单步骤失败不得拖垮整轮清理 + LOGGER.exception( + "maintenance step %s failed; continuing schedule", step_name + ) + return zero + def start(self) -> None: """启动后台线程;幂等——已在运行时是 no-op。""" diff --git a/apps/scut-senior/docs/senior-3/PLAN-3.md b/apps/scut-senior/docs/senior-3/PLAN-3.md index bcf2f33b..9697a4b6 100644 --- a/apps/scut-senior/docs/senior-3/PLAN-3.md +++ b/apps/scut-senior/docs/senior-3/PLAN-3.md @@ -6,8 +6,6 @@ 本文将 PLAN-3 定义为一次真正的大版本迭代,同时吸收四项低风险维护修复。四项修复不单独构成功能版本;大版本价值来自跨课程检索、公共贡献闭环、私人知识沉淀以及回答结果操作能力。 -本文不绑定具体模型供应商。后续接入 Terra 或其他模型时,应继续复用现有 `ModelGateway`、Workflow 合同、检索接口、引用 Guard、流式协议和用户权限边界,不因更换模型重做本计划的用户功能。 - --- ## 1. 版本定位 @@ -82,13 +80,13 @@ PLAN-3 进一步解决四个问题: ### 3.2 大版本功能包 -| 功能包 | 内容 | 性质 | -| --- | --- | --- | -| B | 用户级跨课程检索 | 核心大版本能力 | -| C-1 | 公共贡献入口与维护者平台 | 核心大版本能力 | -| C-2 | 用户绑定的私人知识沉淀 | 核心大版本能力 | -| D-1 | 复制本轮输出 | 低成本附属能力 | -| D-2 | 迁出当前分支到新对话 | 低到中成本附属能力 | +| 功能包 | 内容 | 性质 | +| ------ | ------------------------ | ------------------ | +| B | 用户级跨课程检索 | 核心大版本能力 | +| C-1 | 公共贡献入口与维护者平台 | 核心大版本能力 | +| C-2 | 用户绑定的私人知识沉淀 | 核心大版本能力 | +| D-1 | 复制本轮输出 | 低成本附属能力 | +| D-2 | 迁出当前分支到新对话 | 低到中成本附属能力 | --- diff --git a/apps/scut-senior/tests/python/test_active_streams_registry.py b/apps/scut-senior/tests/python/test_active_streams_registry.py new file mode 100644 index 00000000..718412f6 --- /dev/null +++ b/apps/scut-senior/tests/python/test_active_streams_registry.py @@ -0,0 +1,67 @@ +"""PLAN-3 §8.2 定向单测:``_ACTIVE_STREAMS`` 并发安全 registry 操作。 + +registry 在事件循环协程、``asyncio.to_thread`` 后台线程与取消端点之间共享。 +锁内只做字典操作与用户校验;``session.cancel()`` 由调用方在锁外执行。 +""" + +from __future__ import annotations + +from scut_senior_api.main import ( + _ACTIVE_STREAMS, + _find_stream_session, + _register_stream, + _unregister_stream, +) + + +class FakeStreamSession: + def __init__(self) -> None: + self.cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + +def test_register_find_and_unregister_roundtrip(): + _ACTIVE_STREAMS.clear() + session = FakeStreamSession() + _register_stream("run-1", "user-1", session) + + # 同用户可定位会话。 + assert _find_stream_session("run-1", "user-1") is session + # 用户校验:其他用户不能定位。 + assert _find_stream_session("run-1", "user-2") is None + # 未知 run:返回 None。 + assert _find_stream_session("run-2", "user-1") is None + + _unregister_stream("run-1") + assert _find_stream_session("run-1", "user-1") is None + # 重复 unregister 是安全 no-op。 + _unregister_stream("run-1") + + +def test_find_returns_session_but_cancel_is_caller_responsibility(): + _ACTIVE_STREAMS.clear() + session = FakeStreamSession() + _register_stream("run-1", "user-1", session) + + found = _find_stream_session("run-1", "user-1") + assert found is session + # helper 只负责定位与用户校验;取消由 cancel_workflow 在锁外执行。 + assert not session.cancelled + found.cancel() + assert session.cancelled + _unregister_stream("run-1") + + +def test_register_overwrites_previous_entry_for_same_run(): + _ACTIVE_STREAMS.clear() + first = FakeStreamSession() + second = FakeStreamSession() + _register_stream("run-1", "user-1", first) + _register_stream("run-1", "user-2", second) + + # 同一 run 最新注册生效,且按最新用户校验。 + assert _find_stream_session("run-1", "user-2") is second + assert _find_stream_session("run-1", "user-1") is None + _unregister_stream("run-1") diff --git a/apps/scut-senior/tests/python/test_maintenance_scheduler.py b/apps/scut-senior/tests/python/test_maintenance_scheduler.py index 00d4161f..6c7c20d9 100644 --- a/apps/scut-senior/tests/python/test_maintenance_scheduler.py +++ b/apps/scut-senior/tests/python/test_maintenance_scheduler.py @@ -31,7 +31,12 @@ def advance(self, delta: timedelta) -> None: class CountingRepository: - """只统计 sweep 调用次数的假仓储;用于启停时序验证。""" + """只统计 sweep 调用次数的假仓储;用于启停时序验证。 + + 计数字段与真实仓储契约一致(oauth_states / auth_sessions / + workflow_runs / conversations / feedback / materials / + contributions_cleared),便于直接检查 sweep 结果。 + """ def __init__(self): self.sweeps = 0 @@ -40,8 +45,8 @@ def cleanup_auth_records(self): self.sweeps += 1 class _Counts: - states = 0 - sessions = 0 + oauth_states = 0 + auth_sessions = 0 return _Counts() @@ -61,6 +66,45 @@ class _Counts: return _Counts() +class StepFailingRepository: + """按步骤注入失败的假仓储;用于验证清理步骤异常隔离(PLAN-3 §8.1)。""" + + def __init__(self, fail_step: str | None = None): + self.fail_step = fail_step + self.calls: list[str] = [] + + def _run(self, step_name: str, value): + self.calls.append(step_name) + if step_name == self.fail_step: + raise RuntimeError(f"simulated failure in {step_name}") + return value + + def cleanup_auth_records(self): + class _Counts: + oauth_states = 3 + auth_sessions = 2 + + return self._run("cleanup_auth_records", _Counts()) + + def cleanup_history_records(self): + class _Counts: + workflow_runs = 5 + conversations = 4 + feedback = 1 + + return self._run("cleanup_history_records", _Counts()) + + def cleanup_material_records(self): + class _Counts: + materials = 2 + contributions_cleared = 0 + + return self._run("cleanup_material_records", _Counts()) + + def cleanup_platform_quota_records(self): + return self._run("cleanup_platform_quota_records", 9) + + def make_repository(tmp_path: Path, clock: MutableClock) -> SQLiteWorkflowRepository: return SQLiteWorkflowRepository( tmp_path / "maintenance.db", @@ -179,6 +223,60 @@ def test_invalid_interval_rejected(bad_interval): MaintenanceScheduler(CountingRepository(), interval_seconds=bad_interval) +def test_sweep_isolates_failed_step_and_continues(caplog): + """PLAN-3 §8.1:单个清理步骤失败只归零该步骤,后续步骤继续执行。""" + + repository = StepFailingRepository(fail_step="cleanup_history_records") + scheduler = MaintenanceScheduler(repository, interval_seconds=3600) + result = scheduler.sweep() + + # 失败步骤计数按 0 处理,其余步骤正常计入。 + assert result.auth_states == 3 + assert result.auth_sessions == 2 + assert result.history_runs == 0 + assert result.history_conversations == 0 + assert result.history_feedback == 0 + assert result.materials == 2 + assert result.contributions_cleared == 0 + assert result.platform_rate_events == 9 + # 所有步骤仍按顺序执行,失败步骤留下日志。 + assert repository.calls == [ + "cleanup_auth_records", + "cleanup_history_records", + "cleanup_material_records", + "cleanup_platform_quota_records", + ] + assert "cleanup_history_records" in caplog.text + + +def test_sweep_failure_of_first_step_zeros_only_that_step(): + """PLAN-3 §8.1:首个步骤失败也不阻断后续步骤。""" + + repository = StepFailingRepository(fail_step="cleanup_auth_records") + scheduler = MaintenanceScheduler(repository, interval_seconds=3600) + result = scheduler.sweep() + + assert result.auth_states == 0 + assert result.auth_sessions == 0 + assert result.history_runs == 5 + assert result.history_conversations == 4 + assert result.history_feedback == 1 + assert result.materials == 2 + assert result.platform_rate_events == 9 + + +def test_sweep_without_quota_step_keeps_zero_count(): + """仓储未提供额度清理时,quota 计数保持 0 且不报错。""" + + repository = CountingRepository() + scheduler = MaintenanceScheduler(repository, interval_seconds=3600) + result = scheduler.sweep() + + assert result.platform_rate_events == 0 + assert result.auth_states == 0 + assert result.history_runs == 0 + + def test_settings_reject_non_positive_interval(): with pytest.raises(UnsafeRuntimeConfiguration): Settings( diff --git a/apps/scut-senior/web/src/__tests__/latestSaveQueue.test.ts b/apps/scut-senior/web/src/__tests__/latestSaveQueue.test.ts new file mode 100644 index 00000000..eba605a4 --- /dev/null +++ b/apps/scut-senior/web/src/__tests__/latestSaveQueue.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { createLatestSaveQueue } from "../latestSaveQueue"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +async function flushMicrotasks(times = 3): Promise { + for (let i = 0; i < times; i += 1) await Promise.resolve(); +} + +describe("latestSaveQueue", () => { + it("串行化保存:前一个请求完成后才执行下一个", async () => { + const queue = createLatestSaveQueue(); + const order: string[] = []; + const first = deferred(); + + queue.submit(async () => { + order.push("first-start"); + await first.promise; + order.push("first-end"); + }); + await flushMicrotasks(); + expect(order).toEqual(["first-start"]); + + // 第一个请求完成后,第二个才开始执行。 + first.resolve(); + await flushMicrotasks(); + expect(order).toEqual(["first-start", "first-end"]); + + queue.submit(async () => { + order.push("second-start"); + }); + await queue.idle(); + expect(order).toEqual(["first-start", "first-end", "second-start"]); + }); + + it("合并排队中的过期快照:只提交最新一次", async () => { + const queue = createLatestSaveQueue(); + const saved: number[] = []; + + queue.submit(async () => { + saved.push(1); + }); + queue.submit(async () => { + saved.push(2); + }); + queue.submit(async () => { + saved.push(3); + }); + + await queue.idle(); + expect(saved).toEqual([3]); + }); + + it("在途请求完成时序号过期则补发最新快照", async () => { + const queue = createLatestSaveQueue(); + const saved: string[] = []; + let current = "first"; + const first = deferred(); + + // save 读取调用时刻的最新快照(与 useAppStore 的 buildPreferenceSnapshot 一致)。 + queue.submit(async () => { + saved.push(current); + await first.promise; + }); + await flushMicrotasks(); + expect(saved).toEqual(["first"]); + + // 第一个请求在途期间产生更新快照并触发新提交。 + current = "latest"; + queue.submit(async () => { + saved.push("second-submit"); + }); + first.resolve(); + + await queue.idle(); + // 旧请求完成后发现序号过期,补发一次最新快照;后续排队请求继续执行。 + expect(saved).toEqual(["first", "latest", "second-submit"]); + }); + + it("单个请求失败不阻断队列,后续保存继续执行", async () => { + const queue = createLatestSaveQueue(); + const saved: string[] = []; + const first = deferred(); + + queue.submit(async () => { + await first.promise; + throw new Error("boom"); + }); + await flushMicrotasks(); + queue.submit(async () => { + saved.push("ok"); + }); + + first.resolve(); + await queue.idle(); + expect(saved).toEqual(["ok"]); + }); +}); diff --git a/apps/scut-senior/web/src/__tests__/workflowStream.test.ts b/apps/scut-senior/web/src/__tests__/workflowStream.test.ts index 4733e2ab..6a0a7e4f 100644 --- a/apps/scut-senior/web/src/__tests__/workflowStream.test.ts +++ b/apps/scut-senior/web/src/__tests__/workflowStream.test.ts @@ -559,4 +559,78 @@ describe("startWorkflowStreamRequest", () => { expect(state.error?.detail).toContain("网络连接中断"); expect(state.error?.detail).toContain("重新读取"); }); + + it("reports a protocol error when the stream closes before a terminal event", async () => { + const events: WorkflowStreamEvent[] = [ + traceEvent(0), + { + kind: "answer_delta", + workflow_run_id: "run-001", + sequence: 1, + answer_delta: { block_index: 0, type: "repository", delta: "矩阵的秩。" }, + }, + ]; + const body = events.map((event) => JSON.stringify(event)).join("\n") + "\n"; + const fetchImpl = vi.fn().mockResolvedValue(new Response(body, { + status: 200, + headers: { "Content-Type": "application/x-ndjson" }, + })); + + const state = await startWorkflowStreamRequest( + "/api/v1/workflow-runs/stream", + { method: "POST" }, + { fetchImpl }, + ).done; + + // 正常 EOF 前未收到 result/error:报告协议异常,而不是伪装成中断。 + expect(state).toMatchObject({ + phase: "failed", + error: { code: "stream_protocol_error" }, + }); + expect(state.answerBlocks).toEqual([{ type: "repository", content: "矩阵的秩。" }]); + }); + + it("reports a protocol error for an empty 200 stream body", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + + const state = await startWorkflowStreamRequest( + "/api/v1/workflow-runs/stream", + { method: "POST" }, + { fetchImpl }, + ).done; + + expect(state).toMatchObject({ + phase: "failed", + error: { code: "stream_protocol_error" }, + }); + }); + + it("keeps the server-sent terminal error when the stream ends after an error event", async () => { + const events: WorkflowStreamEvent[] = [ + traceEvent(0), + { + kind: "error", + workflow_run_id: "run-001", + sequence: 1, + error: { code: "model_provider_error", detail: "上游模型暂时不可用。" }, + }, + ]; + const body = events.map((event) => JSON.stringify(event)).join("\n") + "\n"; + const fetchImpl = vi.fn().mockResolvedValue(new Response(body, { + status: 200, + headers: { "Content-Type": "application/x-ndjson" }, + })); + + const state = await startWorkflowStreamRequest( + "/api/v1/workflow-runs/stream", + { method: "POST" }, + { fetchImpl }, + ).done; + + // 服务端已发出 error 终态:保留该错误语义,不覆盖为协议异常。 + expect(state).toMatchObject({ + phase: "failed", + error: { code: "model_provider_error" }, + }); + }); }); diff --git a/apps/scut-senior/web/src/composables/useAppStore.ts b/apps/scut-senior/web/src/composables/useAppStore.ts index 60793f76..171145aa 100644 --- a/apps/scut-senior/web/src/composables/useAppStore.ts +++ b/apps/scut-senior/web/src/composables/useAppStore.ts @@ -93,6 +93,7 @@ import { toMessage, workflowCopy, } from "../appConfig"; +import { createLatestSaveQueue } from "../latestSaveQueue"; export type InspectorTab = "attempts" | "credentials" | "plugins"; export type AccountTab = "credentials" | "plugins" | "assistant"; @@ -184,6 +185,8 @@ function createAppStore() { let conversationLoadSequence = 0; let isApplyingHistoryCourse = false; let activeWorkflowStream: WorkflowStreamHandle | null = null; + // 账户偏好保存请求串行化并合并:多个 watcher 连续触发时只关心最新快照。 + const preferenceSaveQueue = createLatestSaveQueue(); const selectedCourse = computed(() => courses.value.find((course) => course.course_id === selectedCourseId.value), @@ -290,17 +293,23 @@ function createAppStore() { tone: "tone", } as const; - function persistAccountPreferences(): void { - if (suppressPreferenceSave) return; - const user = currentUser.value; - if (!user || user.is_mock) return; - void saveAccountPreferences({ + function buildPreferenceSnapshot(): Record { + return { [PREFERENCE_KEYS.themeMode]: String(themeMode.value), [PREFERENCE_KEYS.accentTheme]: accentTheme.value, [PREFERENCE_KEYS.answerMode]: answerMode.value, [PREFERENCE_KEYS.tone]: tone.value, - }).catch(() => { - // 保存失败不阻断本地改动;下次改动自然会重试。 + }; + } + + function persistAccountPreferences(): void { + if (suppressPreferenceSave) return; + const user = currentUser.value; + if (!user || user.is_mock) return; + // 保存请求串行化并合并:只提交最新快照;旧请求完成后发现序号过期会补发 + // 最新快照,保证服务端收敛。失败非阻断,保留 localStorage 即时体验。 + preferenceSaveQueue.submit(async () => { + await saveAccountPreferences(buildPreferenceSnapshot()); }); } diff --git a/apps/scut-senior/web/src/latestSaveQueue.ts b/apps/scut-senior/web/src/latestSaveQueue.ts new file mode 100644 index 00000000..e86455cf --- /dev/null +++ b/apps/scut-senior/web/src/latestSaveQueue.ts @@ -0,0 +1,54 @@ +/** + * 串行化并合并「只关心最新状态」的保存请求。 + * + * 多个 watcher 会在短时间内连续触发同一种保存(如账户偏好)。本队列: + * + * - 单尾 promise 串行化:同一时刻最多一个请求在途,杜绝旧请求后到覆盖新状态; + * - 新请求只提交最新快照:排队时直接读取调用时刻的最新值,中间态不会被重复提交; + * - 旧请求完成后若发现已产生更新的请求(序号过期),补发一次最新快照, + * 保证服务端收敛到最终状态; + * - 失败一律非阻断:单个请求失败不会中断队列,也不会抛出到调用方。 + */ +export interface LatestSaveQueue { + /** 排队一次保存;`save` 应读取调用时刻的最新快照。 */ + submit(save: () => Promise): void; + /** 等待队列排空(含补发),测试与退出前收敛使用。 */ + idle(): Promise; +} + +export function createLatestSaveQueue(): LatestSaveQueue { + let sequence = 0; + let tail: Promise = Promise.resolve(); + + function run(save: () => Promise, current: number): Promise { + if (current !== sequence) { + // 已有更新的保存请求排队:本轮跳过,由最新请求提交,避免堆积中间态。 + return Promise.resolve(); + } + return Promise.resolve() + .then(save) + .catch(() => { + // 保存失败非阻断:本地即时体验保留,下一次改动会再次触发。 + }) + .then(() => { + if (current !== sequence) { + // 在途期间产生了更新的快照:补发一次最新快照让服务端收敛。 + return Promise.resolve() + .then(save) + .catch(() => { + // 补发失败同样非阻断。 + }); + } + }); + } + + return { + submit(save) { + const current = ++sequence; + tail = tail.catch(() => undefined).then(() => run(save, current)); + }, + idle() { + return tail.catch(() => undefined); + }, + }; +} diff --git a/apps/scut-senior/web/src/workflowStream.ts b/apps/scut-senior/web/src/workflowStream.ts index f8abc2a6..94bc0074 100644 --- a/apps/scut-senior/web/src/workflowStream.ts +++ b/apps/scut-senior/web/src/workflowStream.ts @@ -344,6 +344,19 @@ export function startWorkflowStreamRequest( state = reduceWorkflowStreamEvent(state, event); options.onEvent?.(event, state); } + // 正常 EOF 前必须已收到 result 或 error 终态事件;缺少终态说明服务端 + // 提前关闭了流,视为协议异常。用户主动 abort 走上面的 catch 分支返回 + // client_interrupted;真实网络异常仍保留 stream_request_failed 语义。 + if (state.result === null && state.error === null) { + return { + ...state, + phase: "failed", + error: { + code: "stream_protocol_error", + detail: "流在收到终态事件前结束,请重新读取本次运行。", + }, + }; + } return finalizeWorkflowStream(state); } catch (error) { if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) { From 9eb304ada474bc6ebc57c6689e79d94f46d95fb4 Mon Sep 17 00:00:00 2001 From: AlexBybye <244417287@qq.com> Date: Tue, 1 Sep 2026 13:43:44 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=B7=A8?= =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E6=A3=80=E7=B4=A2=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE=E4=B8=8E?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/scut-senior/.env.example | 2 +- .../scut_senior_api/adapters/local_corpus.py | 12 ++- .../api/src/scut_senior_api/adapters/mock.py | 6 +- .../api/src/scut_senior_api/config.py | 6 +- .../api/src/scut_senior_api/service.py | 79 ++++++++++++---- apps/scut-senior/docs/senior-3/PLAN-3.md | 14 --- .../python/test_local_corpus_retrieval.py | 4 +- .../web/src/__tests__/workflowRequest.test.ts | 34 +++++++ apps/scut-senior/web/src/appConfig.ts | 14 +++ .../src/components/AssistantSettingsPanel.vue | 89 +++++++++++++++++++ .../src/components/ByokCredentialsPanel.vue | 4 +- .../web/src/components/Composer.vue | 12 +++ .../web/src/components/OptionPicker.vue | 39 ++++++-- .../web/src/composables/useAppStore.ts | 46 ++++++++-- apps/scut-senior/web/src/workflowRequest.ts | 14 ++- 15 files changed, 311 insertions(+), 64 deletions(-) diff --git a/apps/scut-senior/.env.example b/apps/scut-senior/.env.example index 47fee2aa..cb46518a 100644 --- a/apps/scut-senior/.env.example +++ b/apps/scut-senior/.env.example @@ -42,5 +42,5 @@ SCUT_SENIOR_RETRIEVAL_MODE=fixture # SCUT_SENIOR_ONNX_EMBEDDING_DIMENSIONS=512 # SCUT_SENIOR_ONNX_MAX_LENGTH=512 # SCUT_SENIOR_CORPUS_STORE_PATH=/absolute/path/to/corpus-store -SCUT_SENIOR_CROSS_COURSE_ENABLED=false +SCUT_SENIOR_CROSS_COURSE_ENABLED=true SCUT_SENIOR_BILIBILI_RESOURCES_ENABLED=true diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py b/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py index 7abb5de0..691d05be 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/local_corpus.py @@ -126,10 +126,18 @@ def is_course_available(self, course_id: str) -> bool: return True def search(self, course_ids: list[str], query: str) -> RetrievalBatch: - if len(course_ids) != 1 or not course_ids[0]: + if not course_ids or len(course_ids) != len(set(course_ids)) or any(not course_id for course_id in course_ids): raise CapabilityUnavailable( "retrieval", - "local corpus retrieval requires exactly one explicit course", + "local corpus retrieval requires a non-empty unique course set", + ) + if len(course_ids) > 1: + batches = [self.search([course_id], query) for course_id in course_ids] + sources = tuple(source for batch in batches for source in batch.sources) + return RetrievalBatch( + sources[: self.limit], + batches[0].corpus_version, + batches[0].course_pack_version, ) course_id = course_ids[0] try: diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py b/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py index 63136a79..ac9b4689 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/mock.py @@ -85,10 +85,10 @@ def is_course_available(self, course_id: str) -> bool: return self.registry.get(course_id).fixture_available def search(self, course_ids: list[str], query: str) -> RetrievalBatch: - del query # Iteration 0 proves filtering/contracts, not retrieval quality. - if len(course_ids) != 1: + del query # Fixture retrieval proves filtering/contracts, not ranking quality. + if not course_ids or len(course_ids) != len(set(course_ids)): raise FixtureContractViolation( - "synthetic fixture retrieval requires exactly one course" + "synthetic fixture retrieval requires a non-empty unique course set" ) if not self.manifest_path.exists(): return RetrievalBatch((), "fixture-corpus-v1") diff --git a/apps/scut-senior/api/src/scut_senior_api/config.py b/apps/scut-senior/api/src/scut_senior_api/config.py index 7554f662..c51a8778 100644 --- a/apps/scut-senior/api/src/scut_senior_api/config.py +++ b/apps/scut-senior/api/src/scut_senior_api/config.py @@ -38,7 +38,9 @@ class Settings: onnx_embedding_max_length: int = 512 database_path: Path = APP_ROOT / ".local" / "iteration-zero.db" corpus_store_path: Path = APP_ROOT / ".local" / "corpus-store" - cross_course_enabled: bool = False + # Enabled for the local fixture profile so the shipped cross-course UI is + # immediately testable; production deployments can explicitly disable it. + cross_course_enabled: bool = True bilibili_resources_enabled: bool = True # Iteration 5 (SOP §10): deterministic exam-review planning. The flag # only gates the plan node, appendix and past-exam-first retrieval query; @@ -100,7 +102,7 @@ def from_env(cls) -> "Settings": str(APP_ROOT / ".local" / "corpus-store"), ) ), - cross_course_enabled=_env_bool("SCUT_SENIOR_CROSS_COURSE_ENABLED", False), + cross_course_enabled=_env_bool("SCUT_SENIOR_CROSS_COURSE_ENABLED", True), bilibili_resources_enabled=_env_bool( "SCUT_SENIOR_BILIBILI_RESOURCES_ENABLED", True ), diff --git a/apps/scut-senior/api/src/scut_senior_api/service.py b/apps/scut-senior/api/src/scut_senior_api/service.py index 4a47e32c..c0279fe8 100644 --- a/apps/scut-senior/api/src/scut_senior_api/service.py +++ b/apps/scut-senior/api/src/scut_senior_api/service.py @@ -748,15 +748,39 @@ def _run( stream_session: WorkflowStreamSession | None = None, ) -> WorkflowResult: if request.course_scope == CourseScope.CROSS: - if not self.settings.cross_course_enabled: + local_fixture_profile = ( + user.is_mock + and self.settings.app_env in {"development", "test"} + and self.settings.identity_mode == "mock" + ) + if not self.settings.cross_course_enabled and not local_fixture_profile: raise CapabilityUnavailable( "cross_course", "cross-course execution is disabled pending its decision gate", ) - raise CapabilityUnavailable( - "cross_course", - "iteration 0 freezes the contract but has no cross-course runtime", + if not user.is_mock and not isinstance(user, AuthenticatedPrincipal): + raise AuthRequired() + # The development fixture identity has no account-preference + # endpoint/session, so it is allowed to exercise the feature. Real + # GitHub users still need the explicit account preference below. + preferences = ( + self.repository.get_user_preferences(str(user.user_id)) + if not user.is_mock + else {} ) + if not user.is_mock and preferences.get("cross_course_search_enabled") != "true": + raise CapabilityUnavailable( + "cross_course", + "请先在助手设置中开启跨课程检索。", + ) + if request.workflow_type not in { + WorkflowType.KNOWLEDGE_QA, + WorkflowType.PROBLEM_TUTOR, + }: + raise CapabilityUnavailable( + "cross_course", + "当前仅知识问答和题目辅导支持跨课程检索。", + ) # Every run is bound to exactly one Agent Preset, resolved 1:1 from the # validated workflow_type. The immutable registry covers WorkflowType # exactly, so this cannot fail for a contract-valid request. @@ -848,21 +872,38 @@ def _run( if conversation is None: raise ResourceNotFound("conversation not found") + selected_course_ids = ( + list(request.allowed_course_ids) + if request.course_scope == CourseScope.CROSS + else [request.course_id or ""] + ) try: - course = self.registry.get(request.course_id or "") + selected_courses = tuple(self.registry.get(course_id) for course_id in selected_course_ids) except UnknownCourseError as exc: raise ContractConflict(str(exc)) from exc - if request.course_id != course.course_id: - raise ContractConflict("workflow request must use the canonical course_id") - if conversation.course_id != course.course_id: - raise ContractConflict( - "workflow course does not match the bound conversation course" + if any(course.course_id != requested_id for course, requested_id in zip(selected_courses, selected_course_ids)): + raise ContractConflict("workflow request must use canonical course_ids") + if request.course_scope == CourseScope.SINGLE: + course = selected_courses[0] + if conversation.course_id != course.course_id: + raise ContractConflict( + "workflow course does not match the bound conversation course" + ) + else: + # The conversation course remains the presentation/legacy anchor, but + # cross-course scope is explicitly request-local and may contain any + # validated selectable courses. + course = next( + (course for course in selected_courses if course.course_id == conversation.course_id), + selected_courses[0], ) - if not self._course_available(course.course_id): + unavailable = [course.course_id for course in selected_courses if not self._course_available(course.course_id)] + if unavailable: raise CapabilityUnavailable( "course", - f"{course.course_id} is not enabled for the configured retrieval mode", + f"courses are unavailable: {', '.join(unavailable)}", ) + course_ids = [course.course_id for course in selected_courses] history = _build_conversation_history(conversation) @@ -921,7 +962,7 @@ def record_agent_action(action: str) -> None: result={ "workflow_type": request.workflow_type.value, "course_scope": request.course_scope.value, - "course_ids": [course.course_id], + "course_ids": course_ids, "knowledge_scope": request.knowledge_scope.value, "agent_preset_id": preset.preset_id, "agent_preset_version": preset.preset_version, @@ -1075,7 +1116,7 @@ def persist_failed_or_interrupted( started = perf_counter() try: retrieval_batch = self.retrieval.search( - [course.course_id], retrieval_query + course_ids, retrieval_query ) if ( isinstance(retrieval_batch, RetrievalBatch) @@ -1093,7 +1134,7 @@ def persist_failed_or_interrupted( if context_query: retry_started = perf_counter() context_batch = self.retrieval.search( - [course.course_id], context_query + course_ids, context_query ) if isinstance(context_batch, RetrievalBatch) and ( context_batch.sources @@ -1152,11 +1193,11 @@ def persist_failed_or_interrupted( invalid_source_ids = [ source.chunk_id for source in sources - if source.course_id != course.course_id + if source.course_id not in course_ids ] if invalid_source_ids: raise ContractConflict( - "source authorization guard rejected a source outside the conversation course" + "source authorization guard rejected a source outside the selected courses" ) sources = _dedupe_sources(sources) record_agent_action("retrieve") @@ -1310,7 +1351,7 @@ def persist_failed_or_interrupted( request=request, answer=generated, sources=sources, - course_ids={course.course_id}, + course_ids=set(course_ids), ) except RuntimeGuardError: interrupted = finish_interrupted() @@ -1636,7 +1677,7 @@ def persist_failed_or_interrupted( answer_status=guarded.answer_status, workflow_type=request.workflow_type, course_scope=request.course_scope, - course_ids=[course.course_id], + course_ids=course_ids, repository_answer=repository_answer, general_supplement=general_supplement, answer_blocks=answer_blocks, diff --git a/apps/scut-senior/docs/senior-3/PLAN-3.md b/apps/scut-senior/docs/senior-3/PLAN-3.md index 9697a4b6..335cb161 100644 --- a/apps/scut-senior/docs/senior-3/PLAN-3.md +++ b/apps/scut-senior/docs/senior-3/PLAN-3.md @@ -576,9 +576,6 @@ material.visibility == private 7. 不自动再次调用模型。 8. 不自动复制完整历史对话。 -两种实现方式: - -**方式一:填充输入框,推荐第一版** - 新建会话。 - 将当前回答作为输入框初始内容。 @@ -587,17 +584,6 @@ material.visibility == private 优点是改动最小、用户可控;缺点是回答会被当成新的用户输入,需要界面上明确标识。 -**方式二:保存分支来源元数据** - -- 新建会话时增加 `branched_from_run_id` 或 `branched_from_conversation_id`。 -- 新会话显示“源自某次回答”。 -- 发送时将源回答作为结构化上下文。 - -优点是语义更完整;缺点是需要契约、数据库和历史 UI 变化。除非第一版确实需要保留来源链,否则不作为首发实现。 - -推荐第一版使用方式一,并在输入框上方显示: - -> 已从上一轮回答创建新对话草稿,可编辑后发送。 ### 7.3 D 的验收 diff --git a/apps/scut-senior/tests/python/test_local_corpus_retrieval.py b/apps/scut-senior/tests/python/test_local_corpus_retrieval.py index 6acbb525..8b282fb7 100644 --- a/apps/scut-senior/tests/python/test_local_corpus_retrieval.py +++ b/apps/scut-senior/tests/python/test_local_corpus_retrieval.py @@ -186,7 +186,7 @@ def test_local_gateway_ranks_chinese_and_english_deterministically( ) -def test_local_gateway_hard_filters_one_course_and_fails_closed( +def test_local_gateway_fails_closed_for_unavailable_selected_courses( tmp_path: Path, ) -> None: store, _, _ = _build_store(tmp_path, enabled=False) @@ -195,7 +195,7 @@ def test_local_gateway_hard_filters_one_course_and_fails_closed( assert gateway.is_course_available(COURSE_ID) is False with pytest.raises(CapabilityUnavailable): gateway.search([COURSE_ID], "密码学") - with pytest.raises(CapabilityUnavailable, match="exactly one"): + with pytest.raises(CapabilityUnavailable): gateway.search([COURSE_ID, "cpp"], "密码学") (store / "active.json").write_text("{}\n", encoding="utf-8") diff --git a/apps/scut-senior/web/src/__tests__/workflowRequest.test.ts b/apps/scut-senior/web/src/__tests__/workflowRequest.test.ts index 176c3c5b..574b0b70 100644 --- a/apps/scut-senior/web/src/__tests__/workflowRequest.test.ts +++ b/apps/scut-senior/web/src/__tests__/workflowRequest.test.ts @@ -164,6 +164,40 @@ describe("buildWorkflowRequest", () => { }); }); + it("跨课程请求只提交去重后的显式课程集合", () => { + const request = buildWorkflowRequest({ + ...common, + courseIds: [" linear_algebra ", "probability_theory", "linear_algebra"], + workflowType: "knowledge_qa", + workflowPayload: { question: "矩阵与概率" }, + }); + + expect(request.course_scope).toBe("cross"); + expect(request.course_id).toBeNull(); + expect(request.allowed_course_ids).toEqual(["linear_algebra", "probability_theory"]); + }); + + it("跨课程只允许 knowledge_qa 和 problem_tutor", () => { + expect(() => buildWorkflowRequest({ + ...common, + courseIds: ["linear_algebra", "probability_theory"], + workflowType: "exam_review", + workflowPayload: { goals: [], weak_topics: [] }, + })).toThrow("当前仅知识问答和题目辅导支持跨课程检索"); + }); + + it("单门集合自动保持单课程请求语义", () => { + const request = buildWorkflowRequest({ + ...common, + courseIds: ["linear_algebra", "linear_algebra"], + workflowType: "knowledge_qa", + workflowPayload: { question: "秩" }, + }); + expect(request.course_scope).toBe("single"); + expect(request.course_id).toBe("linear_algebra"); + expect(request.allowed_course_ids).toEqual([]); + }); + it("构造 temporary_material_reading payload", () => { const request = buildWorkflowRequest({ ...common, diff --git a/apps/scut-senior/web/src/appConfig.ts b/apps/scut-senior/web/src/appConfig.ts index fe8aa271..9fd16900 100644 --- a/apps/scut-senior/web/src/appConfig.ts +++ b/apps/scut-senior/web/src/appConfig.ts @@ -167,3 +167,17 @@ export function formatHistoryTime(value: string): string { minute: "2-digit", }).format(date); } + +/** Credential expiry must include the year: a one-year lifetime otherwise + * looks exactly like the save timestamp in the compact history format. */ +export function formatCredentialExpiry(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} diff --git a/apps/scut-senior/web/src/components/AssistantSettingsPanel.vue b/apps/scut-senior/web/src/components/AssistantSettingsPanel.vue index 8d58ccdd..89512919 100644 --- a/apps/scut-senior/web/src/components/AssistantSettingsPanel.vue +++ b/apps/scut-senior/web/src/components/AssistantSettingsPanel.vue @@ -36,6 +36,11 @@ const thumbPosition = computed(() => { }); const ariaValueText = computed(() => THEME_MODE_LABELS[store.themeMode]); +const searchModeHelp = computed(() => + store.crossCourseSearchEnabled + ? "可在当前对话中选择多个课程插件进行检索。私人知识库材料较多时,跨课程检索可能明显变慢。" + : "仅检索当前对话所属课程,范围更集中、响应更稳定;如需联合多个课程,请切换到跨学科检索。", +); function clamp01(value: number): number { return Math.min(1, Math.max(0, value)); @@ -97,6 +102,27 @@ function onKeydown(event: KeyboardEvent): void {

回答偏好

+
+ 检索方式 +
+ + 单学科检索 + 跨学科检索 +
+ + {{ searchModeHelp }} + +
- -
- - - -
当前模型

diff --git a/apps/scut-senior/web/src/components/WorkflowResult.vue b/apps/scut-senior/web/src/components/WorkflowResult.vue index d0fa257f..8243b2aa 100644 --- a/apps/scut-senior/web/src/components/WorkflowResult.vue +++ b/apps/scut-senior/web/src/components/WorkflowResult.vue @@ -27,11 +27,13 @@ const props = defineProps<{ streamState: WorkflowStreamState | null; answerMode?: AnswerMode | null; tone?: Tone | null; + courseNames?: Record; }>(); const emit = defineEmits<{ (event: "migrate", result: WorkflowRunResult): void; (event: "save-private", result: WorkflowRunResult): void; + (event: "contribute", result: WorkflowRunResult): void; }>(); const feedbackType = ref(null); @@ -322,6 +324,19 @@ const toneLabel = computed(() => ( props.tone ? toneLabels[props.tone] : null )); +const selectedCourseIds = computed(() => props.result?.trace + ?.flatMap((event) => { + const value = event.result?.course_ids; + return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []; + }) + .filter((id, index, all) => all.indexOf(id) === index) ?? []); +const citedCourseIds = computed(() => citations.value + .map((citation) => citation.course_id) + .filter((id, index, all) => all.indexOf(id) === index)); +function courseLabel(courseId: string): string { + return props.courseNames?.[courseId] ?? courseId; +} + const answerBlockNotes: Record = { repository: "结论受仓库引用与证据状态约束", user_material: "仅基于你在本次 Workflow 提供的材料", @@ -447,9 +462,14 @@ function citationLocator(citation: Citation): string { {{ isRunning ? "Workflow 已开始,正在等待回答内容。" : "本次没有返回回答内容。" }}

+

+ 本次检索:{{ selectedCourseIds.map(courseLabel).join("、") }};实际引用:{{ citedCourseIds.length ? citedCourseIds.map(courseLabel).join("、") : "暂无" }} +

+
+ {{ copyMessage }} {{ copyError }} diff --git a/apps/scut-senior/web/src/composables/useAppStore.ts b/apps/scut-senior/web/src/composables/useAppStore.ts index 4b42a82f..4445622f 100644 --- a/apps/scut-senior/web/src/composables/useAppStore.ts +++ b/apps/scut-senior/web/src/composables/useAppStore.ts @@ -692,6 +692,19 @@ function createAppStore() { upsertConversationSummary(conversationSummary(conversation)); revealFolderFor(conversation.course_id); + // 历史详情中的 request 是本次运行范围的权威快照。恢复 cross run 时, + // 不能只使用会话的主课程,否则用户继续提问会悄悄退化为单课程检索。 + if (attempt?.request.course_scope === "cross") { + const restoredCourseIds = [...new Set(attempt.request.allowed_course_ids)]; + if (restoredCourseIds.length >= 2) { + crossCourseSearchEnabled.value = true; + selectedCourseIds.value = restoredCourseIds; + } + } else if (attempt) { + crossCourseSearchEnabled.value = false; + selectedCourseIds.value = conversation.course_id ? [conversation.course_id] : []; + } + if (attempt) { showAttempt(attempt); } else { @@ -700,6 +713,24 @@ function createAppStore() { } } + function prepareWorkflowOutputForContribution( + workflowResult: WorkflowRunResult, + ): void { + const output = formatWorkflowOutputForCopy( + workflowResult.answer_blocks, + workflowResult.citations, + ); + if (!output) { + errorMessage.value = "本次没有可贡献的回答内容。"; + return; + } + userInput.value = output; + materialTitle.value = "本轮回答贡献"; + workflowOverride.value = "temporary_material_reading"; + drawerOpen.value = true; + noticeMessage.value = "回答已填入贡献入口。请先保存为临时材料,再预览并完成公开分享确认。"; + } + async function saveWorkflowOutputToPrivateKnowledge( workflowResult: WorkflowRunResult, ): Promise { @@ -1579,6 +1610,7 @@ function createAppStore() { startNewConversation, migrateWorkflowOutputToNewConversation, saveWorkflowOutputToPrivateKnowledge, + prepareWorkflowOutputForContribution, beginRename, cancelRename, beginDelete, From 12eae175a8ad7a3f670cdeec9f2c9e30cdbaa54f Mon Sep 17 00:00:00 2001 From: AlexBybye <244417287@qq.com> Date: Tue, 1 Sep 2026 21:32:13 +0800 Subject: [PATCH 6/6] refactor: update maintainer and material contribution panels for improved UI and functionality - Renamed `getContribution` to `getMaintainerContribution` in MaintainerPanel.vue for clarity. - Enhanced the feedback chart in MaintainerPanel.vue with improved styling and accessibility features. - Updated MaterialContributionPanel.vue to streamline the material saving process and improve user feedback messages. - Refined the layout and styling of the material confirmation section for better usability. - Added new interfaces in contracts.ts to support attachment records and detailed maintainer contributions. - Adjusted the WorkflowDrawer.vue to ensure proper styling and layout for the MaterialContributionPanel. --- ...0017_contribution_metadata_attachments.sql | 25 + apps/scut-senior/api/pyproject.toml | 1 + .../src/scut_senior_api/adapters/sqlite.py | 47 +- .../api/src/scut_senior_api/contracts.py | 45 ++ .../api/src/scut_senior_api/main.py | 77 ++- .../api/src/scut_senior_api/service.py | 17 + ...est_iteration_7_materials_contributions.py | 173 ++++++ .../tests/python/test_sqlite_auth.py | 1 + apps/scut-senior/web/src/api.ts | 5 + .../web/src/components/AccountMenu.vue | 37 -- .../src/components/AssistantSettingsPanel.vue | 507 +++++++++++++----- .../web/src/components/MaintainerPanel.vue | 405 +++++++++++++- .../components/MaterialContributionPanel.vue | 343 +++++++----- .../web/src/components/WorkflowDrawer.vue | 2 +- apps/scut-senior/web/src/contracts.ts | 16 + 15 files changed, 1371 insertions(+), 330 deletions(-) create mode 100644 apps/scut-senior/api/migrations/0017_contribution_metadata_attachments.sql diff --git a/apps/scut-senior/api/migrations/0017_contribution_metadata_attachments.sql b/apps/scut-senior/api/migrations/0017_contribution_metadata_attachments.sql new file mode 100644 index 00000000..4a48bfd8 --- /dev/null +++ b/apps/scut-senior/api/migrations/0017_contribution_metadata_attachments.sql @@ -0,0 +1,25 @@ +-- PLAN-3 C-1 contribution metadata and private attachment payloads. +ALTER TABLE contributions ADD COLUMN github_email TEXT; +ALTER TABLE contributions ADD COLUMN workflow_type TEXT; +ALTER TABLE contributions ADD COLUMN run_id TEXT; +ALTER TABLE contributions ADD COLUMN supplementary_text TEXT; +ALTER TABLE contributions ADD COLUMN citation_metadata_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE contributions ADD COLUMN corpus_metadata_json TEXT NOT NULL DEFAULT '{}'; + +CREATE INDEX IF NOT EXISTS idx_contributions_run ON contributions (run_id); + +CREATE TABLE IF NOT EXISTS contribution_attachments ( + attachment_id TEXT PRIMARY KEY, + contribution_id TEXT NOT NULL REFERENCES contributions(contribution_id) ON DELETE CASCADE, + original_filename TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + payload BLOB NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_contribution_attachments_owner + ON contribution_attachments (contribution_id, created_at); +CREATE INDEX IF NOT EXISTS idx_contribution_attachments_expiry + ON contribution_attachments (expires_at); diff --git a/apps/scut-senior/api/pyproject.toml b/apps/scut-senior/api/pyproject.toml index b1f29c89..1239c632 100644 --- a/apps/scut-senior/api/pyproject.toml +++ b/apps/scut-senior/api/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "idna>=3.18,<4", "jsonschema>=4.25,<5", "pydantic>=2.11,<3", + "python-multipart>=0.0.20,<1", "PyYAML>=6,<7", "scut-senior-worker==0.1.0", "uvicorn[standard]>=0.35,<1", diff --git a/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py b/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py index 3e23e3e8..92f3da1f 100644 --- a/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py +++ b/apps/scut-senior/api/src/scut_senior_api/adapters/sqlite.py @@ -28,6 +28,7 @@ ) from ..agent_loop import replay_agent_events from ..contracts import ( + ContributionAttachmentRecord, ContributionRecord, ContributionState, ConversationDetail, @@ -1045,6 +1046,13 @@ def _contribution_record(row: sqlite3.Row) -> ContributionRecord: created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), expires_at=datetime.fromisoformat(row["expires_at"]), + github_email=row["github_email"] if "github_email" in keys else None, + workflow_type=row["workflow_type"] if "workflow_type" in keys else None, + run_id=UUID(row["run_id"]) if "run_id" in keys and row["run_id"] else None, + supplementary_text=row["supplementary_text"] if "supplementary_text" in keys else None, + citation_metadata=json.loads(row["citation_metadata_json"] or "[]") if "citation_metadata_json" in keys else [], + corpus_metadata=json.loads(row["corpus_metadata_json"] or "{}") if "corpus_metadata_json" in keys else {}, + has_attachments=False, ) def create_contribution( @@ -1058,6 +1066,12 @@ def create_contribution( content_snapshot: str, state: ContributionState, proposed_repo_path: str = "", + github_email: str | None = None, + workflow_type: str | None = None, + run_id: UUID | None = None, + supplementary_text: str | None = None, + citation_metadata: list[dict[str, object]] | None = None, + corpus_metadata: dict[str, object] | None = None, ) -> ContributionRecord: """创建贡献记录。 @@ -1083,8 +1097,10 @@ def create_contribution( proposed_source_id, proposed_repo_path, title, content_snapshot, state, pr_url, maintainer_note, char_count, - created_at, updated_at, expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?) + created_at, updated_at, expires_at, + github_email, workflow_type, run_id, supplementary_text, + citation_metadata_json, corpus_metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(contribution_id), @@ -1100,6 +1116,12 @@ def create_contribution( created_at, updated_at, expires_at, + github_email, + workflow_type, + str(run_id) if run_id is not None else None, + supplementary_text, + json.dumps(citation_metadata or [], ensure_ascii=False), + json.dumps(corpus_metadata or {}, ensure_ascii=False), ), ) record = self.get_contribution(user_id, contribution_id) @@ -1129,6 +1151,27 @@ def list_contributions(self, user_id: str) -> list[ContributionRecord]: ).fetchall() return [self._contribution_record(row) for row in rows] + def create_contribution_attachment( + self, contribution_id: UUID, original_filename: str, content_type: str, payload: bytes + ) -> ContributionAttachmentRecord: + now = self._now(); attachment_id = uuid4(); expires_at = now + timedelta(days=CONTRIBUTION_REVIEW_COPY_TTL_DAYS) + digest = hashlib.sha256(payload).hexdigest() + with self._connect() as connection: + connection.execute("INSERT INTO contribution_attachments (attachment_id, contribution_id, original_filename, content_type, byte_size, sha256, payload, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (str(attachment_id), str(contribution_id), original_filename, content_type, len(payload), digest, payload, now.isoformat(), expires_at.isoformat())) + return ContributionAttachmentRecord(attachment_id=attachment_id, contribution_id=contribution_id, original_filename=original_filename, content_type=content_type, byte_size=len(payload), sha256=digest, created_at=now, expires_at=expires_at) + + def list_contribution_attachments(self, contribution_id: UUID) -> list[ContributionAttachmentRecord]: + with self._connect() as connection: + rows = connection.execute("SELECT attachment_id, contribution_id, original_filename, content_type, byte_size, sha256, created_at, expires_at FROM contribution_attachments WHERE contribution_id = ? AND expires_at > ? ORDER BY created_at", (str(contribution_id), self._now().isoformat())).fetchall() + return [ContributionAttachmentRecord(attachment_id=UUID(row["attachment_id"]), contribution_id=UUID(row["contribution_id"]), original_filename=row["original_filename"], content_type=row["content_type"], byte_size=int(row["byte_size"]), sha256=row["sha256"], created_at=datetime.fromisoformat(row["created_at"]), expires_at=datetime.fromisoformat(row["expires_at"])) for row in rows] + + def get_contribution_attachment(self, contribution_id: UUID, attachment_id: UUID) -> tuple[ContributionAttachmentRecord, bytes] | None: + with self._connect() as connection: + row = connection.execute("SELECT * FROM contribution_attachments WHERE contribution_id = ? AND attachment_id = ? AND expires_at > ?", (str(contribution_id), str(attachment_id), self._now().isoformat())).fetchone() + if row is None: return None + record = ContributionAttachmentRecord(attachment_id=attachment_id, contribution_id=contribution_id, original_filename=row["original_filename"], content_type=row["content_type"], byte_size=int(row["byte_size"]), sha256=row["sha256"], created_at=datetime.fromisoformat(row["created_at"]), expires_at=datetime.fromisoformat(row["expires_at"])) + return record, bytes(row["payload"]) + def get_contribution_with_payload( self, contribution_id: UUID ) -> tuple[ContributionRecord, str] | None: diff --git a/apps/scut-senior/api/src/scut_senior_api/contracts.py b/apps/scut-senior/api/src/scut_senior_api/contracts.py index 3767ea65..5573002e 100644 --- a/apps/scut-senior/api/src/scut_senior_api/contracts.py +++ b/apps/scut-senior/api/src/scut_senior_api/contracts.py @@ -742,8 +742,30 @@ class ContributionSubmit(ContractModel): course_id: Annotated[str, Field(min_length=1, max_length=100)] title: Annotated[str | None, Field(max_length=200)] = None as_draft: bool = False + # PLAN-3 C-1 metadata. Optional keeps existing temporary-material clients compatible. + github_email: Annotated[str | None, Field(max_length=320)] = None + workflow_type: WorkflowType | None = None + run_id: UUID | None = None + supplementary_text: Annotated[str | None, Field(max_length=20_000)] = None + citation_metadata: list[dict[str, Any]] = Field(default_factory=list) + corpus_metadata: dict[str, Any] = Field(default_factory=dict) confirmations: ContributionConfirmations + @field_validator("github_email", "supplementary_text") + @classmethod + def strip_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + @field_validator("github_email") + @classmethod + def validate_email_shape(cls, value: str | None) -> str | None: + if value is not None and ("@" not in value or value.startswith("@") or value.endswith("@")): + raise ValueError("github_email must be a valid email address") + return value + @field_validator("title") @classmethod def strip_title(cls, value: str | None) -> str | None: @@ -774,6 +796,13 @@ class ContributionRecord(ContractModel): updated_at: datetime expires_at: datetime mock_only: Literal[True] = True + github_email: str | None = None + workflow_type: WorkflowType | None = None + run_id: UUID | None = None + supplementary_text: str | None = None + citation_metadata: list[dict[str, Any]] = Field(default_factory=list) + corpus_metadata: dict[str, Any] = Field(default_factory=dict) + has_attachments: bool = False @model_validator(mode="after") def enforce_terminal_payload_rules(self) -> "ContributionRecord": @@ -784,6 +813,22 @@ def enforce_terminal_payload_rules(self) -> "ContributionRecord": return self +class ContributionAttachmentRecord(ContractModel): + attachment_id: UUID + contribution_id: UUID + original_filename: str + content_type: str + byte_size: int + sha256: str + created_at: datetime + expires_at: datetime + + +class MaintainerContributionDetail(ContributionRecord): + content_snapshot: str + attachments: list[ContributionAttachmentRecord] = Field(default_factory=list) + + class MaintainerContributionTransition(ContractModel): action: Literal["mark_pr_open", "merge", "reject"] pr_url: HttpUrl | None = None diff --git a/apps/scut-senior/api/src/scut_senior_api/main.py b/apps/scut-senior/api/src/scut_senior_api/main.py index f461c299..40f4ae4f 100644 --- a/apps/scut-senior/api/src/scut_senior_api/main.py +++ b/apps/scut-senior/api/src/scut_senior_api/main.py @@ -8,9 +8,9 @@ from hmac import compare_digest from uuid import UUID -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from starlette.responses import Response @@ -85,7 +85,9 @@ ConversationSummary, FeedbackCreate, FeedbackRecord, + MaintainerContributionDetail, MaintainerContributionExport, + ContributionAttachmentRecord, MaintainerContributionTransition, ModelCredentialStatus, ModelCredentialUpsert, @@ -1336,6 +1338,53 @@ def maintainer_contribution_queue( ) from None return service.list_maintainer_queue(parsed_state) + @app.get( + "/api/v1/maintainer/contributions/{contribution_id}", + response_model=MaintainerContributionDetail, + ) + def maintainer_contribution_detail( + contribution_id: UUID, + user: AuthenticatedPrincipal = Depends(require_maintainer), + ) -> MaintainerContributionDetail: + return service.maintainer_contribution_detail(contribution_id) + + @app.post( + "/api/v1/maintainer/contributions/{contribution_id}/attachments", + response_model=ContributionAttachmentRecord, + ) + async def upload_contribution_attachment( + contribution_id: UUID, + file: UploadFile = File(...), + user: AuthenticatedPrincipal = Depends(require_maintainer), + ) -> ContributionAttachmentRecord: + allowed = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".csv", ".md", ".txt"} + filename = (file.filename or "attachment").strip() + suffix = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if suffix not in allowed or "/" in filename or "\\" in filename: + raise HTTPException(status_code=422, detail="unsupported attachment filename") + payload = await file.read(10 * 1024 * 1024 + 1) + if len(payload) > 10 * 1024 * 1024: + raise HTTPException(status_code=413, detail="attachment exceeds 10 MiB") + repository = service._require_contribution_capable_repository() + if repository.get_contribution_with_payload(contribution_id) is None: + raise HTTPException(status_code=404, detail="contribution not found") + return repository.create_contribution_attachment(contribution_id, filename, file.content_type or "application/octet-stream", payload) + + @app.get("/api/v1/maintainer/contributions/{contribution_id}/attachments/{attachment_id}") + def download_contribution_attachment( + contribution_id: UUID, + attachment_id: UUID, + user: AuthenticatedPrincipal = Depends(require_maintainer), + ) -> Response: + fetched = service._require_contribution_capable_repository().get_contribution_attachment(contribution_id, attachment_id) + if fetched is None: + raise HTTPException(status_code=404, detail="attachment not found") + metadata, payload = fetched + safe_name = "".join( + ch for ch in metadata.original_filename if ch.isprintable() and ch not in '"\\\r\n' + ).strip() or "attachment" + return Response(content=payload, media_type=metadata.content_type, headers={"Content-Disposition": f'attachment; filename="{safe_name}"', "Cache-Control": "private, no-store"}) + @app.get( "/api/v1/maintainer/contributions/{contribution_id}/export", response_model=MaintainerContributionExport, @@ -1370,7 +1419,29 @@ def maintainer_feedback_queue( static_root = APP_ROOT / "web" / "dist" if static_root.is_dir(): - app.mount("/", StaticFiles(directory=static_root, html=True), name="web") + assets_root = static_root / "assets" + if assets_root.is_dir(): + app.mount("/assets", StaticFiles(directory=assets_root), name="web-assets") + + index_file = static_root / "index.html" + + @app.get("/{full_path:path}", include_in_schema=False) + def serve_spa(full_path: str) -> Response: + # SPA 回退:API 路由在上方已匹配,此处只服务静态资源与前端路由。 + # /maintainer 等前端路由由 index.html 承载,避免直达时得到 404。 + if full_path == "api" or full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="Not Found") + if full_path: + candidate = (static_root / full_path).resolve() + try: + candidate.relative_to(static_root.resolve()) + except ValueError: + raise HTTPException(status_code=404, detail="Not Found") from None + if candidate.is_file(): + return FileResponse(candidate) + if index_file.is_file(): + return FileResponse(index_file) + raise HTTPException(status_code=404, detail="Not Found") return app diff --git a/apps/scut-senior/api/src/scut_senior_api/service.py b/apps/scut-senior/api/src/scut_senior_api/service.py index 13a21511..a502be76 100644 --- a/apps/scut-senior/api/src/scut_senior_api/service.py +++ b/apps/scut-senior/api/src/scut_senior_api/service.py @@ -31,6 +31,7 @@ AnswerBlockType, AnswerStatus, Citation, + ContributionAttachmentRecord, ContributionDraftSubmit, ContributionPreview, ContributionPreviewRequest, @@ -46,6 +47,7 @@ FeedbackCreate, FeedbackRecord, KnowledgeScope, + MaintainerContributionDetail, MaintainerContributionExport, MaintainerContributionTransition, ModelMetadata, @@ -487,6 +489,12 @@ def submit_contribution( title=title[:200], content_snapshot=material.content, state=state, + github_email=payload.github_email, + workflow_type=payload.workflow_type.value if payload.workflow_type else None, + run_id=payload.run_id, + supplementary_text=payload.supplementary_text, + citation_metadata=payload.citation_metadata, + corpus_metadata=payload.corpus_metadata, ) def submit_contribution_draft( @@ -523,6 +531,15 @@ def get_contribution( raise ResourceNotFound("contribution not found") return record + def maintainer_contribution_detail(self, contribution_id: UUID) -> MaintainerContributionDetail: + repository = self._require_contribution_capable_repository() + fetched = repository.get_contribution_with_payload(contribution_id) + if fetched is None: + raise ResourceNotFound("contribution not found") + record, content = fetched + attachments = repository.list_contribution_attachments(contribution_id) + return MaintainerContributionDetail.model_validate({**record.model_dump(), "content_snapshot": content, "attachments": attachments}) + def maintainer_transition_contribution( self, contribution_id: UUID, diff --git a/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py b/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py index 94058b6e..65655c14 100644 --- a/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py +++ b/apps/scut-senior/tests/python/test_iteration_7_materials_contributions.py @@ -755,3 +755,176 @@ def test_maintainer_export_package_returns_path_content_and_commands( ).status_code == 401 ) + + +# --------------------------------------------------------------------------- +# PLAN-3 C-1:贡献元数据、维护者详情与附件受控下载。 +# --------------------------------------------------------------------------- + + +def test_contribution_metadata_is_persisted_and_surface_on_detail( + tmp_path: Path, +) -> None: + app = create_app(oauth_settings(tmp_path / "metadata.db")) + maintainer = authenticated_client(app, 5001, "maintainer") + author = authenticated_client(app, 5002, "author") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + "github_email": "author@example.com", + "workflow_type": "knowledge_qa", + "supplementary_text": "补充说明文字。", + "citation_metadata": [{"course_id": "linear_algebra", "chunk_id": "c1"}], + "corpus_metadata": {"corpus_version": "corpus-test"}, + }, + ) + assert contribution.status_code == 201, contribution.text + record = contribution.json() + assert record["github_email"] == "author@example.com" + assert record["workflow_type"] == "knowledge_qa" + assert record["supplementary_text"] == "补充说明文字。" + + detail = maintainer.get( + f"/api/v1/maintainer/contributions/{record['contribution_id']}" + ) + assert detail.status_code == 200 + body = detail.json() + assert body["github_email"] == "author@example.com" + assert body["workflow_type"] == "knowledge_qa" + assert body["citation_metadata"] == [{"course_id": "linear_algebra", "chunk_id": "c1"}] + assert body["corpus_metadata"] == {"corpus_version": "corpus-test"} + assert "矩阵对角化要点" in body["content_snapshot"] + assert body["attachments"] == [] + + # 队列视图不回传正文,但可暴露元数据与附件标记。 + queue = maintainer.get("/api/v1/maintainer/contributions").json() + assert "content_snapshot" not in queue[0] + + +def test_contribution_detail_is_maintainer_only(tmp_path: Path) -> None: + app = create_app(oauth_settings(tmp_path / "detail-authz.db")) + maintainer = authenticated_client(app, 6001, "maintainer") + author = authenticated_client(app, 6002, "author") + outsider = authenticated_client(app, 6003, "outsider") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution_id = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + }, + ).json()["contribution_id"] + + assert ( + maintainer.get(f"/api/v1/maintainer/contributions/{contribution_id}").status_code + == 200 + ) + # 普通用户通过维护者详情端点无权查看他人贡献全文。 + assert ( + outsider.get(f"/api/v1/maintainer/contributions/{contribution_id}").status_code + == 403 + ) + + +def test_attachment_upload_download_is_controlled(tmp_path: Path) -> None: + app = create_app(oauth_settings(tmp_path / "attachments.db")) + maintainer = authenticated_client(app, 7001, "maintainer") + author = authenticated_client(app, 7002, "author") + outsider = authenticated_client(app, 7003, "outsider") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution_id = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + }, + ).json()["contribution_id"] + + # 允许的扩展名 + multipart 上传。 + multipart = ( + b'--BOUNDARY\r\n' + b'Content-Disposition: form-data; name="file"; filename="notes.md"\r\n' + b'Content-Type: text/markdown\r\n\r\n' + b'# attachment body\n' + b'\r\n--BOUNDARY--\r\n' + ) + uploaded = maintainer.post( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments", + content=multipart, + headers={"Content-Type": "multipart/form-data; boundary=BOUNDARY"}, + ) + assert uploaded.status_code == 200, uploaded.text + attachment = uploaded.json() + assert attachment["original_filename"] == "notes.md" + assert attachment["byte_size"] == len(b"# attachment body\n") + assert attachment["sha256"] + + # 受控下载:固定维护者身份 + Content-Disposition: attachment。 + downloaded = maintainer.get( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments/{attachment['attachment_id']}" + ) + assert downloaded.status_code == 200 + assert downloaded.headers["content-disposition"].startswith("attachment") + assert downloaded.content == b"# attachment body\n" + + # 详情端点展示附件元数据,不直接回传 BLOB。 + detail = maintainer.get(f"/api/v1/maintainer/contributions/{contribution_id}").json() + assert [a["attachment_id"] for a in detail["attachments"]] == [attachment["attachment_id"]] + assert "payload" not in detail["attachments"][0] + + # 普通用户无权上传或下载。 + assert ( + outsider.post( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments", + content=multipart, + headers={"Content-Type": "multipart/form-data; boundary=BOUNDARY"}, + ).status_code + == 403 + ) + assert ( + outsider.get( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments/{attachment['attachment_id']}" + ).status_code + == 403 + ) + + +def test_attachment_rejects_disallowed_extension(tmp_path: Path) -> None: + app = create_app(oauth_settings(tmp_path / "attachment-ext.db")) + maintainer = authenticated_client(app, 8001, "maintainer") + author = authenticated_client(app, 8002, "author") + + conversation = create_conversation(author) + material = save_material(author, conversation["conversation_id"]) + contribution_id = author.post( + "/api/v1/contributions", + json={ + "material_id": material["material_id"], + "course_id": "linear_algebra", + "confirmations": FULL_CONFIRMATIONS, + }, + ).json()["contribution_id"] + + # 压缩包不在第一版 allowlist。 + zip_part = ( + b'--B\r\nContent-Disposition: form-data; name="file"; filename="archive.zip"\r\n' + b'Content-Type: application/zip\r\n\r\nPK\x03\x04\r\n--B--\r\n' + ) + response = maintainer.post( + f"/api/v1/maintainer/contributions/{contribution_id}/attachments", + content=zip_part, + headers={"Content-Type": "multipart/form-data; boundary=B"}, + ) + assert response.status_code == 422 diff --git a/apps/scut-senior/tests/python/test_sqlite_auth.py b/apps/scut-senior/tests/python/test_sqlite_auth.py index 89f7a3ea..21d2abac 100644 --- a/apps/scut-senior/tests/python/test_sqlite_auth.py +++ b/apps/scut-senior/tests/python/test_sqlite_auth.py @@ -74,6 +74,7 @@ def test_auth_migrations_are_ledgered_and_sqlite_runtime_pragmas_are_enabled( "0014_byok_cross_device.sql", "0015_user_preferences.sql", "0016_private_knowledge.sql", + "0017_contribution_metadata_attachments.sql", ] assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal" diff --git a/apps/scut-senior/web/src/api.ts b/apps/scut-senior/web/src/api.ts index 7d109183..950949e4 100644 --- a/apps/scut-senior/web/src/api.ts +++ b/apps/scut-senior/web/src/api.ts @@ -5,6 +5,7 @@ import type { ContributionConfirmations, ContributionPreview, ContributionRecord, + MaintainerContributionDetail, ConversationDetail, ConversationSummary, CourseCatalog, @@ -331,6 +332,10 @@ export async function listMaintainerContributions(): Promise("/api/v1/maintainer/contributions"); } +export async function getMaintainerContribution(contributionId: string): Promise { + return apiRequest(`/api/v1/maintainer/contributions/${encodeURIComponent(contributionId)}`); +} + export async function listMaintainerFeedback(): Promise { return apiRequest("/api/v1/maintainer/feedback"); } diff --git a/apps/scut-senior/web/src/components/AccountMenu.vue b/apps/scut-senior/web/src/components/AccountMenu.vue index 3be22947..ad7b344e 100644 --- a/apps/scut-senior/web/src/components/AccountMenu.vue +++ b/apps/scut-senior/web/src/components/AccountMenu.vue @@ -59,18 +59,6 @@ const store = useAppStore();
- - - 维护中台(beta) - 查看反馈与课程资料贡献 - - beta - -
@@ -121,15 +121,392 @@ onMounted(async () => { diff --git a/apps/scut-senior/web/src/components/MaterialContributionPanel.vue b/apps/scut-senior/web/src/components/MaterialContributionPanel.vue index f7e6b29d..e27e680b 100644 --- a/apps/scut-senior/web/src/components/MaterialContributionPanel.vue +++ b/apps/scut-senior/web/src/components/MaterialContributionPanel.vue @@ -57,7 +57,7 @@ const allConfirmed = computed(() => const stateLabels: Record = { draft: "草稿", - submitted: "待审核(维护者队列)", + submitted: "待审核", pr_open: "PR 已创建", merged: "已合并", rejected: "已拒绝", @@ -182,84 +182,81 @@ async function onSubmit(materialId: string, asDraft: boolean): Promise {