From d1a6325ed7d68f7ead9516b9d8545d063cffa26b Mon Sep 17 00:00:00 2001 From: eezd Date: Mon, 26 Oct 2026 14:22:40 +0800 Subject: [PATCH] Fix workspace detail races and provider default handling Keep analysis and Skill detail responses bound to the selected request, reject stale publish confirmations, and clear failed position-history results with retry controls. Reject removal of the current default provider with HTTP 409 and reflect that contract in the form. Track the OpenAPI page and API module by restricting the docs ignore rule to the repository root. Validate the staged provider contract with four isolated tests. Reuse the matching candidate's frontend lint, type check, 26 test groups, and production build results. --- .gitignore | 2 +- backend/src/alphapulse/api/settings.py | 3 + backend/tests/test_provider_settings.py | 53 ++++++ web/src/common/apis/docs/index.ts | 13 ++ web/src/common/apis/docs/type.ts | 6 + web/src/common/apis/settings/type.ts | 1 + .../components/SkillVersionDrawer/index.vue | 49 ++++-- web/src/pages/docs/index.vue | 69 ++++++++ .../settings/components/ProviderSettings.vue | 6 +- .../trading/components/AnalysisDrawer.vue | 35 +++- .../components/PositionHistoryDrawer.vue | 25 ++- web/tests/workspaces.test.ts | 166 ++++++++++++++++++ 12 files changed, 405 insertions(+), 23 deletions(-) create mode 100644 backend/tests/test_provider_settings.py create mode 100644 web/src/common/apis/docs/index.ts create mode 100644 web/src/common/apis/docs/type.ts create mode 100644 web/src/pages/docs/index.vue diff --git a/.gitignore b/.gitignore index 51a87af..d1d9e06 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ CONTEXT.md backups/ .venv/ __pycache__/ -docs/ +/docs/ diff --git a/backend/src/alphapulse/api/settings.py b/backend/src/alphapulse/api/settings.py index 263ee66..65f5805 100644 --- a/backend/src/alphapulse/api/settings.py +++ b/backend/src/alphapulse/api/settings.py @@ -67,6 +67,7 @@ class ProviderPatch(BaseModel): context_window: Annotated[int | None, Field(ge=4096, le=2000000)] = None # 显式传 null 表示不传思考强度 reasoning_effort: llm.Effort | None = None + # 只能将另一模型设为默认,不能直接取消当前默认模型。 is_default: bool | None = None @@ -120,6 +121,8 @@ async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Dep @router.patch("/providers/{provider_id}") async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ProviderOut]: provider = await get_provider(session, provider_id) + if body.is_default is False and provider.is_default: + raise HTTPException(status.HTTP_409_CONFLICT, "不能直接取消默认模型,请将其他模型设为默认") values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"}) # reasoning_effort 显式传 null 表示清除,其他字段为 null 时忽略 for field, value in values.items(): diff --git a/backend/tests/test_provider_settings.py b/backend/tests/test_provider_settings.py new file mode 100644 index 0000000..58b730a --- /dev/null +++ b/backend/tests/test_provider_settings.py @@ -0,0 +1,53 @@ +"""模型默认配置的接口契约;数据库和认证均隔离。""" + +import asyncio +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + + +@pytest.mark.parametrize("initial,requested,status", [ + (True, False, 409), (False, False, 200), (False, True, 200), (True, True, 200), +]) +def test_patch_provider_default_contract(trading, monkeypatch, initial, requested, status): + from alphapulse.api import settings + from alphapulse.api.main import app + from alphapulse.core.auth import require_admin + from alphapulse.core.db import get_session + + provider = SimpleNamespace( + id=1, name="test", protocol="openai", base_url="https://example.test", + api_key_hint="test", model="test", max_tokens=8192, context_window=200000, + reasoning_effort=None, is_default=initial, updated_at=datetime.now(UTC), + ) + session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock(), execute=AsyncMock()) + monkeypatch.setattr(settings, "get_provider", AsyncMock(return_value=provider)) + + async def isolated_session(): + yield session + + app.dependency_overrides[require_admin] = lambda: None + app.dependency_overrides[get_session] = isolated_session + + async def check(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client: + response = await client.patch("/api/settings/providers/1", json={"is_default": requested}) + assert response.status_code == status + body = response.json() + if status == 409: + assert body == {"code": 409, "data": None, "message": "不能直接取消默认模型,请将其他模型设为默认"} + assert provider.is_default is initial + session.commit.assert_not_awaited() + session.execute.assert_not_awaited() + else: + assert body["code"] == 0 and body["data"]["is_default"] is requested + session.commit.assert_awaited_once() + assert session.execute.await_count == int(requested) + + try: + asyncio.run(check()) + finally: + app.dependency_overrides.clear() diff --git a/web/src/common/apis/docs/index.ts b/web/src/common/apis/docs/index.ts new file mode 100644 index 0000000..1fb8cfb --- /dev/null +++ b/web/src/common/apis/docs/index.ts @@ -0,0 +1,13 @@ +import type * as Docs from "./type" +import { request } from "@/http/axios" + +/** OpenAPI 按原始规范返回,不使用普通 JSON 外壳。 */ +export function getOpenApiApi(signal?: AbortSignal) { + return request({ + url: "/openapi.json", + method: "get", + rawResponse: true, + notify: false, + signal + }) +} diff --git a/web/src/common/apis/docs/type.ts b/web/src/common/apis/docs/type.ts new file mode 100644 index 0000000..ddca7e0 --- /dev/null +++ b/web/src/common/apis/docs/type.ts @@ -0,0 +1,6 @@ +export interface OpenApiDocument { + openapi: string + info: { title: string, version: string } + paths: Record + components?: Record +} diff --git a/web/src/common/apis/settings/type.ts b/web/src/common/apis/settings/type.ts index 87a08f1..8ff82d6 100644 --- a/web/src/common/apis/settings/type.ts +++ b/web/src/common/apis/settings/type.ts @@ -30,6 +30,7 @@ export interface ProviderForm { context_window: number /** 为空表示不传,由模型决定 */ reasoning_effort: Effort | null + /** 修改时不能直接取消当前默认模型;通过将其他模型设为默认切换。 */ is_default: boolean } diff --git a/web/src/common/components/SkillVersionDrawer/index.vue b/web/src/common/components/SkillVersionDrawer/index.vue index 7352ba6..532fb88 100644 --- a/web/src/common/components/SkillVersionDrawer/index.vue +++ b/web/src/common/components/SkillVersionDrawer/index.vue @@ -3,6 +3,7 @@ import type { SkillNode, SkillVersion } from "@@/apis/skills/type" import { listSkillVersionsApi, publishSkillVersionApi } from "@@/apis/skills" import { useSessionScope } from "@@/composables/useSessionScope" import { diffLines, formatTime } from "@@/utils/text" +import { CanceledError } from "axios" const props = defineProps<{ skill: SkillNode | null }>() @@ -16,32 +17,53 @@ const visible = defineModel({ required: true }) const versions = ref([]) const selectedId = ref(null) const publishing = ref(false) +const loading = ref(false) +const failed = ref(false) +let requestSeq = 0 const published = computed(() => versions.value.find(v => v.version_no === props.skill?.published_version_no) ?? null) const selected = computed(() => versions.value.find(v => v.id === selectedId.value) ?? null) const diff = computed(() => selected.value ? diffLines((published.value?.content ?? "").split("\n"), selected.value.content.split("\n")) : []) async function load() { - if (!props.skill) - return - versions.value = await listSkillVersionsApi(props.skill.id) - // 默认选中最新版本,直接看到草稿相对已发布版本的改动 - selectedId.value = versions.value[0]?.id ?? null + const seq = ++requestSeq + const id = props.skill?.id + versions.value = [] + selectedId.value = null + failed.value = false + loading.value = false + if (!visible.value || id === undefined) return + loading.value = true + try { + const result = await listSkillVersionsApi(id) + if (seq !== requestSeq || !visible.value || id !== props.skill?.id) return + versions.value = result + // 默认选中最新版本,直接看到草稿相对已发布版本的改动 + selectedId.value = result[0]?.id ?? null + } catch (error) { + if (seq === requestSeq) failed.value = true + throw error + } finally { + if (seq === requestSeq) loading.value = false + } } -watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], ([open]) => { - if (open) - load() -}) +watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], load) +onBeforeUnmount(() => ++requestSeq) async function publish(version: SkillVersion) { + const skillId = props.skill!.id + const seq = requestSeq const current = props.skill?.published_version_no const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布" await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" }) sessionScope.assertCurrent() + if (seq !== requestSeq || !visible.value || skillId !== props.skill?.id) + throw new CanceledError("查看的 Skill 已切换") publishing.value = true try { - await publishSkillVersionApi(props.skill!.id, version.id) + await publishSkillVersionApi(skillId, version.id) + if (seq !== requestSeq) return ElMessage.success(`已${action} v${version.version_no}`) emit("published") } finally { @@ -52,7 +74,12 @@ async function publish(version: SkillVersion) {