From 6dcabf69fb43c0597dd376c37fba127160b1d5c3 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 8 Jul 2026 11:45:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(solo):=20=E6=94=B6=E5=8F=A3=E6=B2=BB?= =?UTF-8?q?=E7=90=86=E5=85=A5=E5=8F=A3=E5=B9=B6=E8=A3=81=E5=89=AA=20studio?= =?UTF-8?q?=20=E8=A1=A8=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完成 2.0.0 S4 的外部 AI、admin、account 与 solo SQL 收口,并推进 S5 studio 市场/handoff/旧编辑器入口裁剪。S5 的前端单测、构建与 route-only e2e 已留证;MSW-off 创作主线 e2e 仍因本地后端活体启动口径待修正,已在总账标为未完成验收。 --- docs/mvp/进度总账.md | 6 + .../src/router/routes/__tests__/muse.test.ts | 23 + .../src/router/routes/modules/muse.ts | 2 + .../muse/__tests__/governance-pages.test.ts | 14 +- .../views/muse/newapi/__tests__/index.test.ts | 42 ++ .../web-antd/src/views/muse/newapi/index.vue | 327 +--------- muse-cloud/muse-module-ai/.agent | 2 + .../src/main/resources/application.yaml | 75 ++- muse-cloud/muse-module-member/.agent | 1 + .../src/main/resources/application.yaml | 75 ++- .../api/SoloAccountDefaultsGateTest.java | 56 ++ .../api/SoloExternalAiProviderGateTest.java | 118 ++++ .../scripts/dev/solo-compose.env.example | 15 + muse-cloud/scripts/run-p1r-verification.sh | 2 +- ...disable-solo-governance-menus-postgres.sql | 51 ++ ...restore-solo-governance-menus-postgres.sql | 51 ++ .../e2e/account-market-records.spec.ts | 2 + .../e2e/account-newapi-binding.spec.ts | 2 + .../e2e/account-publish-records.spec.ts | 2 + muse-studio/e2e/account-security-ack.spec.ts | 2 + muse-studio/e2e/global-setup.ts | 607 +++++++++--------- muse-studio/e2e/handoff-agent.spec.ts | 2 + muse-studio/e2e/handoff-content.spec.ts | 2 + muse-studio/e2e/handoff-knowledge.spec.ts | 2 + ...nstalled-knowledge-disable-restore.spec.ts | 2 + .../e2e/knowledge-publish-precheck.spec.ts | 2 + muse-studio/e2e/live-read.spec.ts | 9 +- muse-studio/e2e/market-asset-detail.spec.ts | 2 + .../e2e/market-governance-impact.spec.ts | 2 + .../e2e/market-handoff-precheck.spec.ts | 2 + .../e2e/market-install-kb-retrieval.spec.ts | 2 + muse-studio/e2e/market-install.spec.ts | 2 + muse-studio/e2e/market-kb-recall.spec.ts | 2 + muse-studio/e2e/market-publish.spec.ts | 2 + .../e2e/market-recommendations.spec.ts | 2 + muse-studio/e2e/solo-routing.spec.ts | 22 + muse-studio/src/app/routes/index.tsx | 15 +- .../src/app/routes/routes.solo.test.ts | 25 + .../components/layout/Sidebar.solo.test.tsx | 25 + muse-studio/src/components/layout/Sidebar.tsx | 2 - .../components/PersonalCenter.solo.test.tsx | 30 + .../account/components/PersonalCenter.tsx | 236 +------ .../owners/AgentHandoffLanding.test.tsx | 85 --- .../handoff/owners/AgentHandoffLanding.tsx | 108 ---- .../owners/ContentHandoffLanding.test.tsx | 104 --- .../handoff/owners/ContentHandoffLanding.tsx | 136 ---- .../owners/KnowledgeHandoffLanding.test.tsx | 78 --- .../owners/KnowledgeHandoffLanding.tsx | 115 ---- .../handoff/owners/useAgentHandoff.ts | 71 -- .../handoff/owners/useContentHandoff.ts | 77 --- .../handoff/owners/useKnowledgeHandoff.ts | 79 --- .../handoff/pages/HandoffLandingPage.test.tsx | 44 -- .../handoff/pages/HandoffLandingPage.tsx | 72 --- .../components/KnowledgePublishModal.tsx | 117 ---- muse-studio/src/pages/EditorPage.tsx | 147 ----- .../src/pages/KnowledgePage.solo.test.tsx | 36 ++ muse-studio/src/pages/KnowledgePage.tsx | 107 +-- muse-studio/src/pages/LoginPage.tsx | 2 +- muse-studio/src/stores/handoffStore.test.ts | 68 -- muse-studio/src/stores/handoffStore.ts | 72 --- muse-studio/src/test/setup.ts | 24 +- 61 files changed, 964 insertions(+), 2443 deletions(-) create mode 100644 muse-admin/apps/web-antd/src/views/muse/newapi/__tests__/index.test.ts create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloAccountDefaultsGateTest.java create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloExternalAiProviderGateTest.java create mode 100644 muse-cloud/scripts/dev/solo-compose.env.example create mode 100644 muse-cloud/sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql create mode 100644 muse-cloud/sql/solo/2026-07-08-restore-solo-governance-menus-postgres.sql create mode 100644 muse-studio/e2e/solo-routing.spec.ts create mode 100644 muse-studio/src/app/routes/routes.solo.test.ts create mode 100644 muse-studio/src/components/layout/Sidebar.solo.test.tsx create mode 100644 muse-studio/src/features/account/components/PersonalCenter.solo.test.tsx delete mode 100644 muse-studio/src/features/handoff/owners/AgentHandoffLanding.test.tsx delete mode 100644 muse-studio/src/features/handoff/owners/AgentHandoffLanding.tsx delete mode 100644 muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx delete mode 100644 muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx delete mode 100644 muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.test.tsx delete mode 100644 muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.tsx delete mode 100644 muse-studio/src/features/handoff/owners/useAgentHandoff.ts delete mode 100644 muse-studio/src/features/handoff/owners/useContentHandoff.ts delete mode 100644 muse-studio/src/features/handoff/owners/useKnowledgeHandoff.ts delete mode 100644 muse-studio/src/features/handoff/pages/HandoffLandingPage.test.tsx delete mode 100644 muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx delete mode 100644 muse-studio/src/features/knowledge/components/KnowledgePublishModal.tsx delete mode 100644 muse-studio/src/pages/EditorPage.tsx create mode 100644 muse-studio/src/pages/KnowledgePage.solo.test.tsx delete mode 100644 muse-studio/src/stores/handoffStore.test.ts delete mode 100644 muse-studio/src/stores/handoffStore.ts diff --git a/docs/mvp/进度总账.md b/docs/mvp/进度总账.md index 1a6e4c44..093ac7fa 100644 --- a/docs/mvp/进度总账.md +++ b/docs/mvp/进度总账.md @@ -31,6 +31,12 @@ **2.0.0 单人版改造 S3 market 摘装配(2026-07-08)**:已原子拨开关并完成验收。`muse-server` 默认保留 `muse-module-market-api`、停用 `muse-module-market-server`,`market-detached-test-tree` 默认生效;`market-assembled` profile 恢复 market-server 依赖与全测试树回编译。`MonolithFacadeFallbackAutoConfiguration` 为 `MarketHandoffTokenApi`、`MarketAssetSourceApi`、`MarketAssetForkApi` 增加 `@Bean @ConditionalOnMissingBean` fail-closed 兜底:verify 返回 `valid=false/MARKET_DETACHED`,consume 返回 `consumed=false`,asset source 返回 `notFound()`,fork 写回返回 `false`,首次调用 warn 日志;真实 market Bean 存在时不覆盖。覆盖 JSON 将 market 32 条 operation 改为 `dormant`,`summary.completedOperations=210`,未删 operation/testFiles/sourceFiles,未改 OpenAPI/DDL/业务实现/外部凭据。新增 `P1rMarketDetachedAssemblyGateTest` 证明 fallback、真实 Bean 优先、默认摘装配、`/app-api/market/**`/`/admin-api/market/**` 404 形态;Knowledge installed 列表补空结果断言,既有 market_kb binding notFound/fork-not-ready 测试继续覆盖 fail-closed。验收证据:server targeted gate `P1rApiCoverageReportTest`+`P1rMarketDetachedAssemblyGateTest` 13/0F/0E/0S;knowledge targeted `MuseKnowledgeBindingServiceTest`+`MuseInstalledKnowledgeBaseServiceTest` 25/0F/0E/0S;`-Pmarket-assembled -DskipTests test` 47 模块 reactor BUILD SUCCESS;`run-p1r-verification.sh local` 60/0F/0E/0S;`run-p1r-verification.sh real-pg` 真实 `_test` 库 143/0F/0E/0S,外部验收 opt-in 后 `P1rContentImportWizardCompletedApprovalIT` 无 skip 且 provider 200;coverage 计数核验 `total=242/completed=210/marketDormant=32/nonMarketDormant=0`。2026-07-08 裁决:publish-prechecks/snapshots/readiness 保留 Knowledge 本域预检/快照能力,允许写 readiness/snapshot/command 记录,但不得产生 market 资产、安装、授权事实;前端发布入口由 S5 删除。 +**2.0.0 单人版改造 S4 配置与治理收口(2026-07-08)**:已完成 yudao 厂商脚手架、member 默认值、admin 菜单/New-API 页、solo 菜单 SQL 的收口。AI 配置层面,`muse-module-ai-server` 与 `muse-server` 两处 `application.yaml` 将 gemini/doubao/hunyuan/siliconflow/xinghuo/baichuan/midjourney/suno/web-search 九类 yudao provider 全部 `enable=false`,并把历史明文 key、Spring AI 示例 key、Midjourney/Suno URL 改为 env 占位;新增 `SoloExternalAiProviderGateTest` 防默认外部 provider Bean 回漂。member 层面,新增 `SoloAccountDefaultsGateTest` 固化 `muse.account.new-api.enabled=false` 与 publish/attribution/quota-request worker 默认关闭,现有 `@ConditionalOnMissingBean` facade 缺 owner 时 fail-closed;`muse-cloud/scripts/dev/solo-compose.env.example` 给 S9 compose 预留同一 env 口径,租户机制保持开启且请求固定 `tenant-id: 1`。admin 层面,`muse.ts` 隐藏「市场治理」「用户与权限」菜单但保留路由;`views/muse/newapi` 降级为本地用量/余额/外部调用只读观测,删除网关绑定、额度配置、归属 job 三类写入口;demo `leave/pay/ai/bpm/member` 顶级路由已确认隐藏。数据层面新增 `muse-cloud/sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql` 与 restore 脚本,幂等隐藏用户/角色/租户/部门/岗位、会员、支付、公众号、BPM、yudao AI 娱乐面菜单,保留菜单管理;本步未执行任何共享库 SQL。验收证据:新增后端 targeted gate 5/0F/0E/0S;`bash muse-cloud/scripts/run-p1r-verification.sh local` 65/0F/0E/0S;`grep -rn "api-key: sk-\|api-key: AIza" muse-cloud/**/application*.yaml` 零命中;admin targeted Vitest 10/0F/0E/0S;`pnpm --filter @vben/web-antd typecheck` 通过;`NODE_OPTIONS=--localstorage-file=/tmp/muse-admin-node-localstorage pnpm test:unit` 52 files / 356 tests passed;`git diff --check` 通过。遗留提醒:RAGFlow 实例下线与已入 git 历史的 yaml 明文 key 轮换仍需人工处理。 + +**2.0.0 单人版改造 S4 启动级追补(2026-07-08)**:S5 活体 e2e 起栈时暴露 Spring AI Alibaba `DashScopeAgentAutoConfiguration` 仍会在缺 API key 时启动失败;已在 `muse-module-ai-server` 与 `muse-server` 两处 `application.yaml` 显式关闭 `spring.ai.dashscope.agent.enabled`,并把 `spring.ai.model.chat/embedding/image/rerank/video/audio.*` 置为 `none`,避免 DashScope chat/embedding/image/rerank/video/audio 自动装配因 matchIfMissing 回漂。`SoloExternalAiProviderGateTest` 同步扩展到 DashScope Spring AI 自动配置面,targeted gate `SoloExternalAiProviderGateTest,SoloAccountDefaultsGateTest` 为 5/0F/0E/0S。标准启动脚本仍受当前 `infra.env` 指向 `muse_local/muse_dev` 且 Flyway V1 函数 owner 不匹配影响,`muse-server` 活体启动需在后续 S5/S9 统一修正开发库 owner/专用 live 库口径。 + +**2.0.0 单人版改造 S5 studio 入口裁剪推进中(2026-07-08)**:已按执行计划裁掉用户端市场、handoff、旧编辑器入口:路由删除 `/market*`、`/handoff/land/:targetOwner`、旧 `/works/:workId/editor/:chapterId`,侧边栏移除「市场」「治理」,个人中心保留 profile + usage、删除购买/授权/发布/安全事件/New-API 绑定展示,知识页移除「从市场安装」tab、发布到市场入口与预检弹窗,登录文案去市场资产措辞;landing-only handoff 组件、handoff store、旧 `EditorPage` 与 `KnowledgePublishModal` 已删除。新增 solo route/sidebar/account/knowledge 单测与 `/market`、`/handoff/land/*` route-only Playwright。验收已完成部分:`pnpm exec vitest run` 31 files / 117 tests passed;`pnpm run build` 通过;`CI=1 E2E_SKIP_SEED=1 MUSE_STUDIO_E2E_MARKET=false pnpm exec playwright test e2e/solo-routing.spec.ts` 1 passed;S5 隔离 17 个 market/handoff/account-market e2e spec,`market-install-kb-retrieval.spec.ts` 的旧 fixme 位于已 skip 文件内。未完成项:执行计划要求的 MSW-off 创作主线全量 Playwright 尚未跑通,阻塞在本机后端活体启动口径(标准脚本先遇到 `muse_local` Flyway owner 问题;关 Flyway 后暴露并已修 DashScope 自动装配漏关)。因此 S5 当前不得标记完成,下一会话优先补后端起栈/创作主线 e2e 证据。 + **E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business audit;AI owner 不可用时整笔 merge 回滚,避免 Canonical 已写但候选仍 pending。Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive;通知失败不回滚 Canonical 主写,Knowledge confirm 端仍按来源状态 fail-closed。Studio `CandidatePanel` 支持编辑最终正文并按 `accept_as_is`/`modify_then_merge` 提交,IndexedDB 草稿键改为 `workId+blockId+revision` 防跨作品/版本污染。`saveBlock`/`mergeBlockSuggestion` 现在写 `muse_content_block_revision_snapshot`,工作台“历史”Tab 只读展示 Canonical revision 快照;导入可创建真实任务,导出支持范围/格式选择、任务查询与下载凭证消费,知识/记录入口跳转对应工作台。fresh 证据:后端局部单测 `ContentSourceServiceTest` 25/0F/0E、`ContentAppServiceTest` 19/0F/0E、`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E、`AiSuggestionMergeProjectionFacadeTest` 7/0F/0E;契约/覆盖门 `ContractFirstGateTest` 4/0F/0E + `P1rApiCoverageReportTest` 8/0F/0E;real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S;studio `tsc -b --force` 通过、lint 0 errors、Vitest 12/12,追加 `AIPanel.contract.test.tsx` + `sse.test.ts` 22/0F/0E;导出 UI 追加 `useWorks.test.tsx`+`ExportWorkModal.test.tsx` 11/0F/0E、目标 ESLint 0 errors。共享 PG 写入闸门批准后已补跑 MSW-off Playwright 真后端 `accept-suggestion.spec.ts` 2/0F/0E:正路真 New-API 生成 suggestionId=79、authz `rpe-local-*`、Block revision 160→161、AI owner accepted decision archive 非空;负路 stale revision 业务冲突。边界:`muse-studio/src/types/content.ts` 生成类型因 openapi-typescript 版本漂移未同步,hook 内暂维护 `BlockRevision` 最小类型;完整导入向导已在 RC 后补,见下方记录;真后端导出下载 e2e 已在 2026-06-28 补跑通过,FileApi 异常仍按后端合同 fail-closed。 **E2 ai 智能体生命周期与候选处置闭环(2026-06-27)**:已补用户自建 Agent update/archive、初始版本自动创建、版本列表/激活/归档非当前版本、作品槽位 unbind、Studio reject 调后端 AI owner 决策归档。Agent update 会创建下一 active version 并更新 `current_version_id`,archive Agent 同步归档仍 active 的槽位绑定;slot unbind 将绑定行 revision+1 且 `status=archived`,运行时回到默认能力;WorkspacePage “放弃修改”不再只清本地候选,而是真打 `POST /suggestions/{id}/reject` 写 `rejected` 与 decision archive。启动修红:最新单体启动时暴露 `muse.codegen.importEnable` 缺省,已在 `muse-server/src/main/resources/application.yaml` 补 `import-enable:false`,随后 48080 成功启动并 `GET /app-api/muse/agents` smoke 返回 `code=0`。fresh 证据:后端 AI targeted `MuseAgentServiceTest`+`MuseAgentSlotServiceTest`+Controller annotation 63/0F/0E;契约门 `ContractFirstGateTest` 4/0F/0E;studio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0E;MSW-off Playwright 真后端 `agent-create.spec.ts`+`agent-slot-bind.spec.ts`+`accept-suggestion.spec.ts` 5/0F/0E:采纳真生成 suggestionId=80、Block revision 161→162、accepted decision archive 非空;拒绝真生成 suggestionId=81、`status=rejected`、decision archive 非空;Agent 生命周期 DB 核验 v1/v2 current 切换与 v2 archived;Slot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:本轮未跑 studio 全量 e2e。 diff --git a/muse-admin/apps/web-antd/src/router/routes/__tests__/muse.test.ts b/muse-admin/apps/web-antd/src/router/routes/__tests__/muse.test.ts index 3707568d..72ae3ead 100644 --- a/muse-admin/apps/web-antd/src/router/routes/__tests__/muse.test.ts +++ b/muse-admin/apps/web-antd/src/router/routes/__tests__/muse.test.ts @@ -2,7 +2,12 @@ import { createMemoryHistory, createRouter } from 'vue-router'; import { describe, expect, it } from 'vitest'; +import aiRoutes from '../modules/ai'; +import bpmRoutes from '../modules/bpm'; +import leaveRoutes from '../modules/leave'; +import memberRoutes from '../modules/member'; import routes from '../modules/muse'; +import payRoutes from '../modules/pay'; const createMuseRouter = () => createRouter({ @@ -44,4 +49,22 @@ describe('muse admin routes', () => { expect(resolved.fullPath).toBe('/muse/governance/meta-schemas/work%2Fstory'); expect(resolved.meta.activePath).toBe('/muse/governance/meta-schemas'); }); + + it('单人版隐藏市场治理和用户权限菜单但保留路由可解析', () => { + const memoryRouter = createMuseRouter(); + + const market = memoryRouter.resolve({ name: 'MuseMarketGovernance' }); + const account = memoryRouter.resolve({ name: 'MuseAccountGovernance' }); + + expect(market.fullPath).toBe('/muse/market'); + expect(market.meta.hideInMenu).toBe(true); + expect(account.fullPath).toBe('/muse/account'); + expect(account.meta.hideInMenu).toBe(true); + }); + + it('yudao demo 顶级静态路由不漏出到菜单', () => { + for (const moduleRoutes of [leaveRoutes, payRoutes, aiRoutes, bpmRoutes, memberRoutes]) { + expect(moduleRoutes[0]?.meta?.hideInMenu).toBe(true); + } + }); }); diff --git a/muse-admin/apps/web-antd/src/router/routes/modules/muse.ts b/muse-admin/apps/web-antd/src/router/routes/modules/muse.ts index dd9a15d1..97d1b9b5 100644 --- a/muse-admin/apps/web-antd/src/router/routes/modules/muse.ts +++ b/muse-admin/apps/web-antd/src/router/routes/modules/muse.ts @@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [ { component: () => import('#/views/muse/market/index.vue'), meta: { + hideInMenu: true, icon: 'lucide:store', title: '市场治理', }, @@ -77,6 +78,7 @@ const routes: RouteRecordRaw[] = [ { component: () => import('#/views/muse/account/index.vue'), meta: { + hideInMenu: true, icon: 'lucide:users', title: '用户与权限', }, diff --git a/muse-admin/apps/web-antd/src/views/muse/__tests__/governance-pages.test.ts b/muse-admin/apps/web-antd/src/views/muse/__tests__/governance-pages.test.ts index 28010ef7..bab4f5a4 100644 --- a/muse-admin/apps/web-antd/src/views/muse/__tests__/governance-pages.test.ts +++ b/muse-admin/apps/web-antd/src/views/muse/__tests__/governance-pages.test.ts @@ -234,9 +234,6 @@ vi.mock('#/api/muse/jobs', () => ({ })); vi.mock('#/api/muse/newapi', () => ({ - createNewApiCallAttributionJobApi: vi.fn(), - createNewApiGatewayBindingApi: vi.fn(), - createNewApiQuotaRequestApi: vi.fn(), getNewApiIntegrationCallApi: vi.fn(), listNewApiBalanceSnapshotsApi: vi.fn().mockResolvedValue({ list: [{ balance: 100, snapshotId: 'balance-1', userId: 'user-1' }], @@ -274,13 +271,18 @@ describe('muse admin governance pages', () => { expect(text).toContain('Muse 管理员'); }); - it('New-API 页不展示凭据且只处理治理归属', async () => { + it('New-API 页不展示凭据且只保留本地用量只读观测', async () => { const text = await renderPage(() => import('../newapi/index.vue')); - expect(text).toContain('New-API 页面只展示治理和对账摘要'); + expect(text).toContain('New-API 页面只展示本地用量观测和对账摘要'); expect(text).toContain('不展示凭据'); - expect(text).toContain('归属事实由服务端'); + expect(text).toContain('不提供网关绑定、额度配置或归属写命令'); expect(text).toContain('newapi-user-1'); + expect(text).not.toContain('创建绑定'); + expect(text).not.toContain('刷新绑定'); + expect(text).not.toContain('配置额度'); + expect(text).not.toContain('创建归属 job'); + expect(text).not.toContain('处理异常'); }); it('任务治理页要求重验且不代用户重新生成候选', async () => { diff --git a/muse-admin/apps/web-antd/src/views/muse/newapi/__tests__/index.test.ts b/muse-admin/apps/web-antd/src/views/muse/newapi/__tests__/index.test.ts new file mode 100644 index 00000000..7ceaa7ae --- /dev/null +++ b/muse-admin/apps/web-antd/src/views/muse/newapi/__tests__/index.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +describe('muse new-api usage page solo surface', () => { + const source = readFileSync( + resolve(process.cwd(), 'apps/web-antd/src/views/muse/newapi/index.vue'), + 'utf8', + ); + + it('保留本地用量只读观测 API', () => { + expect(source).toContain('listNewApiGatewayUsersApi'); + expect(source).toContain('listNewApiBalanceSnapshotsApi'); + expect(source).toContain('listNewApiUsageRecordsApi'); + expect(source).toContain('getNewApiIntegrationCallApi'); + }); + + it('不暴露 New-API 网关绑定、额度和归属写入口', () => { + const forbiddenFragments = [ + 'createNewApiGatewayBindingApi', + 'createNewApiQuotaRequestApi', + 'createNewApiCallAttributionJobApi', + 'openBindingModal', + 'openQuotaModal', + 'openAttributionModal', + 'submitBindingCommand', + 'submitQuotaRequest', + 'submitAttributionJob', + '创建绑定', + '刷新绑定', + '配置额度', + 'New-API 额度配置请求', + '创建归属 job', + '处理异常', + ]; + + for (const fragment of forbiddenFragments) { + expect(source).not.toContain(fragment); + } + }); +}); diff --git a/muse-admin/apps/web-antd/src/views/muse/newapi/index.vue b/muse-admin/apps/web-antd/src/views/muse/newapi/index.vue index f651b37e..c0ae1fde 100644 --- a/muse-admin/apps/web-antd/src/views/muse/newapi/index.vue +++ b/muse-admin/apps/web-antd/src/views/muse/newapi/index.vue @@ -2,8 +2,8 @@ /** * New-API 网关用户与用量页 * - * 只展示网关绑定、余额快照、调用日志归属和异常处理摘要。 - * 页面不展示任何 New-API 凭据,不把余额快照当作 Muse 本地权威余额。 + * 单人版只展示网关绑定、余额快照、调用日志归属和外部调用状态。 + * 页面不展示任何 New-API 凭据,也不提供绑定、额度或归属写命令入口。 */ import type { AdminUsageRecord, @@ -21,13 +21,9 @@ import { Alert, Button, Card, - Checkbox, Descriptions, - Form, Input, - InputNumber, message, - Modal, Select, Space, Table, @@ -36,9 +32,6 @@ import { } from 'ant-design-vue'; import { - createNewApiCallAttributionJobApi, - createNewApiGatewayBindingApi, - createNewApiQuotaRequestApi, getNewApiIntegrationCallApi, listNewApiBalanceSnapshotsApi, listNewApiGatewayUsersApi, @@ -56,48 +49,6 @@ interface TablePager { /** Ant Table 插槽传入的通用记录。 */ type TableRecord = Record; -/** 创建或刷新网关绑定表单。 */ -interface BindingCommandForm { - /** 目标用户 ID。 */ - userId: string; - /** 幂等键。 */ - commandId: string; - /** 是否强制刷新已有绑定。 */ - forceRefresh: boolean; - /** 操作原因。 */ - reason: string; -} - -/** New-API 额度配置请求表单。 */ -interface QuotaRequestForm { - /** 目标用户 ID。 */ - userId: string; - /** 幂等键。 */ - commandId: string; - /** 请求类型。 */ - requestType: 'balance_config' | 'plan_config' | 'subscription_sync'; - /** 操作原因。 */ - reason: string; - /** 资源类型。 */ - resourceType: string; - /** 目标额度。 */ - limit?: number; -} - -/** 调用归属 job 表单。 */ -interface AttributionJobForm { - /** 幂等键。 */ - commandId: string; - /** New-API 调用关联 ID。 */ - correlationId: string; - /** 调用 ID 白名单,逗号分隔。 */ - callIds: string; - /** 调用审计 revision。 */ - expectedCallRevision?: number; - /** 归属原因。 */ - reason: string; -} - /** 网关绑定状态颜色。 */ const bindingStatusColor: Record = { bound: 'green', @@ -119,11 +70,6 @@ function asNewApiBinding(record: TableRecord) { return record as unknown as NewApiBindingSummary; } -/** 将 Ant Table 通用记录转回用量摘要。 */ -function asUsageRecord(record: TableRecord) { - return record as unknown as AdminUsageRecord; -} - /** 网关绑定状态颜色,兼容模板中的 unknown 记录。 */ function getBindingStatusColor(status: unknown) { return bindingStatusColor[status as NewApiBindingStatus] ?? 'default'; @@ -141,7 +87,7 @@ const bindingColumns = [ { dataIndex: 'boundAt', key: 'boundAt', title: '绑定时间' }, { dataIndex: 'lastSyncAt', key: 'lastSyncAt', title: '最近同步' }, { dataIndex: 'syncErrorMessage', key: 'syncErrorMessage', title: '异常摘要' }, - { key: 'actions', title: '操作', width: 280 }, + { key: 'actions', title: '操作', width: 120 }, ]; const balanceColumns = [ @@ -171,13 +117,11 @@ const usageColumns = [ key: 'failedAttributionCount', title: '归属失败', }, - { key: 'actions', title: '异常处理', width: 180 }, ]; const bindingLoading = ref(false); const balanceLoading = ref(false); const usageLoading = ref(false); -const commandSubmitting = ref(false); const bindings = ref([]); const balanceSnapshots = ref([]); @@ -198,38 +142,8 @@ const bindingPager = reactive({ current: 1, pageSize: 10, total: 0 }); const balancePager = reactive({ current: 1, pageSize: 10, total: 0 }); const usagePager = reactive({ current: 1, pageSize: 10, total: 0 }); -const bindingModalOpen = ref(false); -const quotaModalOpen = ref(false); -const attributionModalOpen = ref(false); const integrationQuery = ref(''); -const bindingForm = reactive({ - commandId: '', - forceRefresh: false, - reason: '', - userId: '', -}); - -const quotaForm = reactive({ - commandId: '', - reason: '', - requestType: 'subscription_sync', - resourceType: '', - userId: '', -}); - -const attributionForm = reactive({ - callIds: '', - commandId: '', - correlationId: '', - reason: '', -}); - -/** 生成前端幂等键,后端负责最终幂等校验。 */ -function createCommandId(prefix: string) { - return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; -} - /** 统一空值展示。 */ function displayText(value?: number | string) { return value === undefined || value === '' ? '-' : String(value); @@ -329,128 +243,11 @@ function handleUsageTableChange(pager: TablePager) { loadUsageRecords(pager.current ?? 1, pager.pageSize ?? usagePager.pageSize); } -/** 打开创建或刷新网关绑定弹窗。 */ -function openBindingModal(record?: NewApiBindingSummary) { - bindingForm.commandId = createCommandId('newapi-bind'); - bindingForm.forceRefresh = record?.bindingStatus === 'sync_failed'; - bindingForm.reason = ''; - bindingForm.userId = record?.userId ?? selectedBinding.value?.userId ?? ''; - bindingModalOpen.value = true; -} - /** 表格事件包装:选择绑定用户。 */ function handleSelectBinding(record: TableRecord) { return selectBinding(asNewApiBinding(record)); } -/** 表格事件包装:打开绑定命令。 */ -function handleOpenBindingModal(record: TableRecord) { - openBindingModal(asNewApiBinding(record)); -} - -/** 表格事件包装:打开额度配置命令。 */ -function handleOpenQuotaModal(record: TableRecord) { - openQuotaModal(asNewApiBinding(record)); -} - -/** 表格事件包装:打开用量归属异常处理命令。 */ -function handleOpenAttributionModal(record: TableRecord) { - openAttributionModal(asUsageRecord(record)); -} - -/** 提交创建或刷新网关绑定命令。 */ -async function submitBindingCommand() { - if (!bindingForm.userId || !bindingForm.reason.trim()) { - message.error('请填写用户 ID 和原因'); - return; - } - commandSubmitting.value = true; - try { - await createNewApiGatewayBindingApi(bindingForm.userId, { - commandId: bindingForm.commandId, - forceRefresh: bindingForm.forceRefresh, - reason: bindingForm.reason, - }); - message.success('网关绑定命令已提交'); - bindingModalOpen.value = false; - await loadBindings(); - } finally { - commandSubmitting.value = false; - } -} - -/** 打开 New-API 额度配置请求弹窗。 */ -function openQuotaModal(record?: NewApiBindingSummary) { - quotaForm.commandId = createCommandId('newapi-quota'); - quotaForm.limit = undefined; - quotaForm.reason = ''; - quotaForm.requestType = 'subscription_sync'; - quotaForm.resourceType = ''; - quotaForm.userId = record?.userId ?? selectedBinding.value?.userId ?? ''; - quotaModalOpen.value = true; -} - -/** 提交 New-API 额度配置请求。 */ -async function submitQuotaRequest() { - if (!quotaForm.userId || !quotaForm.reason.trim()) { - message.error('请填写用户 ID 和原因'); - return; - } - commandSubmitting.value = true; - try { - await createNewApiQuotaRequestApi(quotaForm.userId, { - commandId: quotaForm.commandId, - reason: quotaForm.reason, - requestType: quotaForm.requestType, - targetQuota: quotaForm.resourceType - ? { limit: quotaForm.limit, resourceType: quotaForm.resourceType } - : undefined, - }); - message.success('额度配置请求已创建'); - quotaModalOpen.value = false; - } finally { - commandSubmitting.value = false; - } -} - -/** 打开调用归属 job 弹窗。 */ -function openAttributionModal(record?: AdminUsageRecord) { - attributionForm.callIds = ''; - attributionForm.commandId = createCommandId('newapi-attr'); - attributionForm.correlationId = ''; - attributionForm.expectedCallRevision = undefined; - attributionForm.reason = record?.recordId - ? `处理用量记录 ${record.recordId} 的归属异常` - : ''; - attributionModalOpen.value = true; -} - -/** 提交调用归属 job。 */ -async function submitAttributionJob() { - if (!attributionForm.correlationId || !attributionForm.reason.trim()) { - message.error('请填写 correlationId 和归属原因'); - return; - } - commandSubmitting.value = true; - try { - await createNewApiCallAttributionJobApi({ - callIds: attributionForm.callIds - .split(',') - .map((item) => item.trim()) - .filter(Boolean), - commandId: attributionForm.commandId, - correlationId: attributionForm.correlationId, - expectedCallRevision: attributionForm.expectedCallRevision, - reason: attributionForm.reason, - verificationMode: 'strict', - }); - message.success('调用归属 job 已创建'); - attributionModalOpen.value = false; - } finally { - commandSubmitting.value = false; - } -} - /** 按 correlationId 查询外部调用状态。 */ async function queryIntegrationCall() { if (!integrationQuery.value.trim()) { @@ -474,7 +271,7 @@ onMounted(() => { @@ -498,7 +295,6 @@ onMounted(() => { > 查询 - { - - @@ -572,7 +362,7 @@ onMounted(() => { - + { > 查询 -
{ :loading="usageLoading" :pagination="usagePager" @change="handleUsageTableChange" - > - -
+ /> @@ -658,101 +439,5 @@ onMounted(() => {
- - -
- - - - - - - - - 同步失败或人工补偿时强制刷新 - - - - - -
-
- - -
- - - - - - - - - - - - - - - - - - -
-
- - - -
- - - - - - - - - - - - - - - -
-
diff --git a/muse-cloud/muse-module-ai/.agent b/muse-cloud/muse-module-ai/.agent index b06cff6d..c7aca70f 100644 --- a/muse-cloud/muse-module-ai/.agent +++ b/muse-cloud/muse-module-ai/.agent @@ -13,5 +13,7 @@ - **Dify Adapter 最小闭环(2026-06-29)**:系统 Agent 版本配置新增 `runtimeProvider` / `providerRef.dify` / `outputContract`;新发布版本必须显式 provider,旧版本缺省 runtime 仅兼容为 `new-api`。`RoutingMuseAiRuntimeClient` 按 provider 失败关闭路由,`RealDifyMuseAiRuntimeClient` 支持 Dify blocking chat/workflow 调用、错误分类、timeout、脱敏、无隐式 fallback;Dify 内部知识上下文标记 `untraceable_provider_context`,当输出合同要求 Muse source refs 时直接失败关闭。Dify 密钥不入库,只通过 `credentialRef` 从后端配置取;Admin 发布页只保存 Dify app/workflow/credentialRef 引用,`consoleUrl` 由后端按 allowlist 拼装。验证:`MuseAgentServiceTest`+`MuseAiRuntimeClientTest` 51/0F/0E;Admin AI 目标 Vitest 2 files / 9 tests passed。后续仍需真实 Dify live 验证与用量进入 New-API/Member 归属链路后再宣称生产级可计费。 - **2.0.0 S1 Dify live 验证(2026-07-07)**:Dify 1.15.0 已在 `mini-infra` 独立部署,写作 chat app `75f105e8-dbca-4aa6-a296-2113a65ad005` 通过 `P1rDifyChatLiveAcceptanceIT` 真跑;默认不设置 `MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 时 honest skipped,显式 opt-in 后真实 `POST /v1/chat-messages` 返回 `status=success`、`messageIdPresent=true`、`summarySha256Prefix=565339bc4d33`。后端配置新增 `muse.ai.dify.*` env 占位,密钥仍只走 `credentialRef`/env,不进 DB;S1 只证明 Dify app 可调用,New-API attribution 仍显示 `requires_new_api_attribution`,后续接 Dify runtime 归属/计费前不得宣称 Member 用量已闭环。 - **2.0.0 S3 market 摘装配影响(2026-07-07)**:默认单体无 market-server,历史 E4 market agent handoff 物化链路进入 dormant;AI owner 自建 Agent、槽位、生成、SSE、Dify/New-API runtime 不依赖 market-server,仍按自身门禁验证。恢复 market agent 安装/物化验收时用 `muse-server -Pmarket-assembled`。 +- **2.0.0 S4 单人版外部 AI 脚手架关停(2026-07-08)**:`muse-module-ai-server` 与 `muse-server` 两处 `application.yaml` 已把 yudao 继承的 gemini/doubao/hunyuan/siliconflow/xinghuo/baichuan/midjourney/suno/web-search 九类 provider `enable` 关为 false,并把历史明文 key/base-url/notify-url 改为 env 占位;Spring AI 示例 `qianfan/zhipuai/openai/anthropic/stabilityai/dashscope/minimax/moonshot/deepseek` key 也改为 env 占位以满足全仓 application yaml grep 门。新增 `SoloExternalAiProviderGateTest` 并纳入 `run-p1r-verification.sh local`,断言默认 ApplicationContext 不注册九类外部 provider Bean,仅保留 Dify/New-API runtime router 壳。验收:`run-p1r-verification.sh local` 64/0F/0E/0S,`grep -rn "api-key: sk-\|api-key: AIza" muse-cloud/**/application*.yaml` 零命中。注意:已入 git 历史的旧 key 仍需人工轮换;Midjourney/Suno 的 DB 模型驱动路径不是本步范围,后续只在显式配置下可用。 +- **2.0.0 S4 单人版外部 AI 脚手架关停(2026-07-08)**:`muse-module-ai-server` 与 `muse-server` 两处 `application.yaml` 已把 yudao 继承的 gemini/doubao/hunyuan/siliconflow/xinghuo/baichuan/midjourney/suno/web-search 九类 provider `enable` 关为 false,并把历史明文 key/base-url/notify-url 改为 env 占位;Spring AI 示例 `qianfan/zhipuai/openai/anthropic/stabilityai/dashscope/minimax/moonshot/deepseek` key 也改为 env 占位以满足全仓 application yaml grep 门。S5 起栈追补已显式关闭 Spring AI Alibaba DashScope agent,并把 `spring.ai.model.chat/embedding/image/rerank/video/audio.*` 置为 `none`,避免 DashScope 自动配置因 matchIfMissing 在无 API key 时阻断单体启动。新增 `SoloExternalAiProviderGateTest` 并纳入 `run-p1r-verification.sh local`,断言默认 ApplicationContext 不注册九类外部 provider Bean、DashScope Spring AI 自动配置 Bean,仅保留 Dify/New-API runtime router 壳。验收:`run-p1r-verification.sh local` 65/0F/0E/0S;追补 targeted gate `SoloExternalAiProviderGateTest,SoloAccountDefaultsGateTest` 5/0F/0E/0S;`grep -rn "api-key: sk-\|api-key: AIza" muse-cloud/**/application*.yaml` 零命中。注意:已入 git 历史的旧 key 仍需人工轮换;Midjourney/Suno 的 DB 模型驱动路径不是本步范围,后续只在显式配置下可用。 - **RC 后完整导入解析 owner(2026-06-28)**:新增 `MuseAiImportParseApi` 与 `MuseAiImportParseService`,由 AI owner 持久化 `muse_ai_parse_job` / `muse_ai_chapter_parse_result`,读取 Content 传入的稳定 storageRef 后支持 txt/markdown/docx/epub 文本抽取;默认按 Markdown/CN/EN 标题做确定性章节拆分,`parseConfig.mode=full_book` 或 `strategy=llm_full_book` 时调用 New-API LLM 全书解析,并对 timeout/429/5xx/连接失败做可重试处理。解析只输出 pending-review Shadow 章节并支持确认/驳回/replay,不写 Content Canonical,不写 Knowledge Local KB。验证:`MuseAiImportParseServiceTest`、`NewApiMuseAiImportLlmParserTest`、`P1rImportLlmNewApiLiveAcceptanceIT` live 与完整导入链路 real-PG/live 测试通过。 - **关键风险 / TODO**:**AI runtime 生成 e2e 已端到端真验(2026-06-24)**——方案1(d647f0c 剥离 sourceSnapshotId 解授权死结)+ D(4dadc7b `DefaultDBFieldHandler` 无登录兜底 `system`,根治 scheduled worker 写 creator/updater null 违约 NOT NULL;BaseDO 带 jdbcType 强制入列致 insert/update 同病)+ agent_version config 补 modelKey=MiniMax-M2.5 + 启动 source `scripts/dev/p1r-external-acceptance.env`(New-API/RAGFlow 配置)→ task5 completed+suggestion+MiniMax-M2.5 真 LLM 生成(P-B envelope/approved、P-C contextAssembly 固化 ✓)。**P-A/P-B/P-C 完整 e2e 已端到端真验打通(2026-06-24,task7)**:P-A 检索命中根因=knowledge 模块 bug——`HttpRagFlowKnowledgeRuntimeClient.retrieveChunks` 把 `document_ids` 以 null 入 retrieval body,RAGFlow 要求其为 list 故拒(code 102 "documents should be a list")→检索 fail-closed→chunkCount=0(task3/4/5/6 全 0 即此 bug,非 RAGFlow 数据/凭据/timeout)。已修(commit 86c6dba:null 时不入 body + 回归测试)。task7 contextAssembly `chunkCount=1/retrievedKbIds=[2]/authorizationSnapshotIds=[authsnap-e2e-1]`,LLM(MiniMax-M2.5)基于 grounding 续写。**02D 槽位首次创建死锁已修+真验(2026-06-24,commit 9115d4f)**:根因=precheck/bind 都 `requireSlotBinding`(缺行抛 forbidden),而"替换"是设计中唯一创建入口("替换"=首次写入、无独立创建端点是设计有意,见后端-04 line748/产品-02D line507)→死锁,首次绑定永不可达(work4 seed binding 实为 E2E 绕此 bug 手插)。修:precheck 缺行放行 + bind 抽 `persistBinding` upsert(无行 insert revision=1/有行 updateById 沿用乐观锁)+ V29 `uk_muse_agent_slot_binding_active` partial unique index(同 work+slot 仅一条 active 并发兜底)+ 前端 mock protected 口径对齐 `protected:` 前缀。真验(muse_slice_live):work1 `writing.continuation` precheck(不再 forbidden)→bind 首次建 binding(slotRevision=1 active)→GET 槽位显示 agent1→AI task 用 `agentSlotKey`(非 agentOverrideRef)→agent1 解析 + runtime 授权过门 → completed + suggestion7(MiniMax-M2.5 真 LLM"星环大陆魔法体系"续写、finishReason stop)。**前端 MVP(df063cb)agentSlotKey 路径真后端端到端走通**;单测 `MuseAgentSlotServiceTest` 26/26 + Controller 4/4 + 前端 4/4;V29 由 Flyway 启动日志证 applied。**E2 智能体生命周期与候选拒绝已补并真验(2026-06-27)**:用户自建 Agent create 会同步建 v1 active version;update 创建下一 active version并更新 `current_version_id`;版本面板支持 list/activate/archive 非当前版本;archive Agent 同步 archive active 槽位绑定;slot unbind 将 binding revision+1、status=`archived`、响应 `sourceStatus=unbound`;WorkspacePage reject 调 AI owner `POST /suggestions/{id}/reject` 写 `rejected` 与 decision archive。验证:AI targeted 单测+Controller 63/0F/0E,Studio `tsc`/目标 ESLint/targeted Vitest 29/0F/0E,MSW-off Playwright 真后端 5/0F/0E(采纳真生成 suggestionId=80、拒绝真生成 suggestionId=81、Agent 生命周期 DB 核验、Slot unbind DB 核验)。**仍缺/线 B**:Override Slot Contract 主数据(方案 B:合法 slot 校验/系统默认 agent/listWorkAgentSlots 预设骨架未做,slotKey 暂自由字符串、listSlots 缺行仅返回已绑定);前端 agent "运行试用"等深链;Quality eval 真实评估集管理/外发授权/LLM judge 仍后置;grant/runtime 完整物理包拆分仍未做。SSE `event:` 解析漂移已修复并有 targeted Vitest 证据(2026-06-19:`sse.test.ts` + `AIPanel.contract.test.tsx` 17/17);`muse-module-ai-contract-server` 空壳孤儿 submodule 已清理(2026-06-19:从 AI reactor 移除并删除唯一 pom,`P1rAiRouteOwnershipTest` 加回潮门禁)。**2026-06-25:AI 生成候选采纳断层已修(ADR-020 方案 A,commit a16c596)**——`MuseAiRuntimeProjectionService` 此前用 `numericEnvelopeId(Long.parseLong)` 落 authz 快照,但真 runtime envelope 是字符串 `rpe-local-uuid`→解析失败落 null→suggestion 恒不可采纳(merge 1041001001);改直落字符串 envelope(删 numericEnvelopeId)、`MuseAiSuggestionDO.authorizationSnapshotId` 改 String、`AiSuggestionMergeProjectionFacade.getSuggestion` 校验改 hasText、V30 列 BIGINT→VARCHAR(128)。揭 `P1rContentMergeGeneratedSuggestionIT` 假绿(注入数值 9001、真 runtime 不产数值),改真字符串 envelope 转真绿。活体真验:真生成 suggestion authz=rpe-local 字符串→采纳 merge code=0+block revision 自增,生成→采纳→Canonical 全链通。详见总账 2026-06-25 / ADR-020 / memory muse-ai-accept-authz-snapshot-fix。**2026-06-27:E1 accepted decision owner 归档已补**——`AiSuggestionMergeProjectionFacade.markSuggestionAccepted` 作为 AI owner 写 `muse_ai_suggestion_decision`、更新 suggestion `status=accepted/decision_archive_id`、记录 AI command 与 business audit;幂等 replay 不重复写,非 pending 候选返回 conflict 且不预占 command。注意边界:AI 仍只归档 Shadow 决策与审计,不写 Canonical 正文,不保存 `finalContent` 或 provider 原文。P1r 真 PG 切片 6/0F/0E/0S。**2026-06-25(同日续):槽位绑定授权快照孪生收口(C1,commit 854f18c)**——三路盘点发现 `muse_agent_slot_binding.authorization_snapshot_id` 是采纳断层唯一漏网 binding 表(列 BIGINT、DO Long,bind 时 parseLong 对字符串 envelope `rpe-local-` 返 null→授权快照静默丢失、绑定溯源链断);V31 两列(authz+source)BIGINT→VARCHAR(128)、DO Long→String、bind 两处(insert/updateById)直透传删 parseLong(`sameAuthorizationSnapshot` 的 parseLong 保留,字符串 envelope 走 fallback 字符串比较正确)。活体真验(muse_slice_live):真 envelope 经 precheck→bind,binding authz 列存非空 rpe-local(insert id=51+updateById id=50 两路径),列类型 varchar(128)、flyway V31 success;修前恒 null。**此前模块"仍缺"只列方案 B/深链/物理拆分,从未记此类型错配——盘点补上的盲区**。至此 ADR-020 全部 binding 表(knowledge V14 + suggestion/attribution V30 + slot V31)收口完成。 **2026-06-25(同日续):AI 流 SSE 长任务候选丢失已修(commit c77b006、A1+候选③、design-docs/临时-01 review 批准后执行)**——studio e2e ai-generation 真红根因=后端 `MuseAiTaskStreamServiceImpl` SSE 死线 30s 硬编码 < LLM 真时延(MiniMax 11-60s)、更 < 上游自己 `MUSE_AI_NEW_API_TOTAL_TIMEOUT_SECONDS=180`,poll 30s 后 `emitter.complete()` 静默关连接不补 done;前端 `connectAIStream` 一次性连接无重连(重连只在 connectEventStream)→LLM>30s 候选永丢(ai-gen 真红/accept flaky 是同 bug 时延两侧)。修:① 后端删 `DEFAULT_TIMEOUT_MILLIS` 常量→`@Value("${muse.ai.sse.task-timeout-millis:240000}")`(连接死线:70+poll deadline:198 同引用、application.yaml 配置项);② 前端退避重连续 poll + 按 SSE id 去重(后端不读 lastEventId、每次 seq0 全量回放→`lastSeenSequenceNo` 丢弃 id≤已见,避免重复渲染候选)+总超时 300s `onError(SSE_TIMEOUT)` 不静默卡死(返回仍 AbortController、AIPanel 零改动);③ A1 `scripts/dev/start-muse-server-infra.sh` 固化追加 source acceptance env(防漏 `MUSE_AI_NEW_API_*`→`UnavailableMuseAiRuntimeClient` 兜底秒失败 `AI_NEW_API_UNAVAILABLE`)。验证:后端 SSE 单测 14/14+前端 107/107(含重连去重用例)、活体 e2e ai-generation+accept 连跑 3 轮 6/6、重连幂等方法 A 活体证;残留=本机 LLM 6.8-19s 未自然>30s、慢路径靠「240s 数学覆盖上游 180s+单测+方法 A 重连活体证」三重保证。**通则(避免重踩"后端比上游先关门")**:SSE 长任务死线须 ≥ 上游 TOTAL_TIMEOUT;AI 流重连必须以 SSE id 幂等去重(后端 seq0 全量回放、无 lastEventId)。详见 memory muse-ai-generation-sse-timeout-bug。 diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml index 4133c42b..a895a24a 100644 --- a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml @@ -122,37 +122,46 @@ spring: host: 127.0.0.1 port: 19530 qianfan: # 文心一言 - api-key: x0cuLZ7XsaTCU08vuJWO87Lg - secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK + api-key: ${MUSE_AI_QIANFAN_API_KEY:} + secret-key: ${MUSE_AI_QIANFAN_SECRET_KEY:} zhipuai: # 智谱 AI - api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs + api-key: ${MUSE_AI_ZHIPUAI_API_KEY:} openai: # OpenAI 官方 - api-key: sk-aN6nWn3fILjrgLFT0fC4Aa60B72e4253826c77B29dC94f17 - base-url: https://api.gptsapi.net + api-key: ${MUSE_AI_OPENAI_API_KEY:} + base-url: ${MUSE_AI_OPENAI_BASE_URL:https://api.openai.com} azure: # OpenAI 微软 openai: endpoint: https://eastusprejade.openai.azure.com anthropic: # Anthropic Claude - api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942 + api-key: ${MUSE_AI_ANTHROPIC_API_KEY:} ollama: base-url: http://127.0.0.1:11434 chat: model: llama3 stabilityai: - api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx + api-key: ${MUSE_AI_STABILITYAI_API_KEY:} dashscope: # 通义千问 - api-key: sk-47aa124781be4bfb95244cc62f6xxxx + api-key: ${MUSE_AI_DASHSCOPE_API_KEY:} + agent: + enabled: false minimax: # Minimax:https://www.minimaxi.com/ - api-key: xxxx + api-key: ${MUSE_AI_MINIMAX_API_KEY:} moonshot: # 月之暗面(KIMI) - api-key: sk-abc + api-key: ${MUSE_AI_MOONSHOT_API_KEY:} deepseek: # DeepSeek - api-key: sk-e94db327cc7d457d99a8de8810fc6b12 + api-key: ${MUSE_AI_DEEPSEEK_API_KEY:} chat: options: model: deepseek-chat model: - rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启 + chat: none + embedding: none + image: none + rerank: none + video: none + audio: + speech: none + transcription: none mcp: server: enabled: false @@ -174,43 +183,43 @@ spring: muse: ai: gemini: # 谷歌 Gemini - enable: true - api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ + enable: false + api-key: ${MUSE_AI_GEMINI_API_KEY:} model: gemini-2.5-flash doubao: # 字节豆包 - enable: true - api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272 + enable: false + api-key: ${MUSE_AI_DOUBAO_API_KEY:} model: doubao-1-5-lite-32k-250115 hunyuan: # 腾讯混元 - enable: true - api-key: sk-abc + enable: false + api-key: ${MUSE_AI_HUNYUAN_API_KEY:} model: hunyuan-turbo siliconflow: # 硅基流动 - enable: true - api-key: sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz + enable: false + api-key: ${MUSE_AI_SILICONFLOW_API_KEY:} model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B xinghuo: # 讯飞星火 - enable: true - appKey: 75b161ed2aef4719b275d6e7f2a4d4cd - secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz + enable: false + appKey: ${MUSE_AI_XINGHUO_APP_KEY:} + secretKey: ${MUSE_AI_XINGHUO_SECRET_KEY:} model: x1 baichuan: # 百川智能 - enable: true - api-key: sk-abc + enable: false + api-key: ${MUSE_AI_BAICHUAN_API_KEY:} model: Baichuan4-Turbo midjourney: - enable: true + enable: false # base-url: https://api.holdai.top/mj-relax/mj - base-url: https://api.holdai.top/mj - api-key: sk-dZEPiVaNcT3FHhef51996bAa0bC74806BeAb620dA5Da10Bf - notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify + base-url: ${MUSE_AI_MIDJOURNEY_BASE_URL:} + api-key: ${MUSE_AI_MIDJOURNEY_API_KEY:} + notify-url: ${MUSE_AI_MIDJOURNEY_NOTIFY_URL:} suno: - enable: true + enable: false # base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app - base-url: http://127.0.0.1:3001 + base-url: ${MUSE_AI_SUNO_BASE_URL:} web-search: - enable: true - api-key: sk-40500e52840f4d24b956d0b1d80d9abe + enable: false + api-key: ${MUSE_AI_WEB_SEARCH_API_KEY:} new-api: enabled: ${MUSE_AI_NEW_API_ENABLED:false} base-url: ${MUSE_AI_NEW_API_BASE_URL:} diff --git a/muse-cloud/muse-module-member/.agent b/muse-cloud/muse-module-member/.agent index 647b1f34..71a00db2 100644 --- a/muse-cloud/muse-module-member/.agent +++ b/muse-cloud/muse-module-member/.agent @@ -10,4 +10,5 @@ - **2026-06-27 RC 后补安全事件处理入口**:Studio 个人中心安全事件从单一“确认”扩为契约内 `acknowledged/password_changed/session_revoked/false_positive` 四类处理动作,写 `POST /account/security-events/{eventId}/acknowledge` 时带 commandId 和 note,成功后展示后端 `riskSummary/nextSteps` 并刷新列表。验证:Studio `useAccount.test.tsx` 5/0F/0E,组合目标 Vitest 4 files / 11 tests passed,`tsc -b --force` 与目标 ESLint 通过。边界:这不是完整改密/2FA/session service,会话吊销真实联动仍后置;偏好通知仍无 app 写契约,不伪造。 - **2.0.0 S2 market detached 测试树影响(2026-07-07)**:`P1rAccountAdminPurchaseRecordsCompletedApprovalIT` 与 `P1rAccountMarketRecordsCompletedApprovalIT` 依赖 market 单体装配投影,已随 `market-detached-test-tree` 显式 profile 一并剥离;默认构建不跳过,`market-assembled` 可恢复全量回编译。本步未改 Account 业务实现、coverage JSON 或 Account completed 口径,纯 Account gate 仍随 local 门禁运行。 - **2.0.0 S3 market 默认摘装配影响(2026-07-07)**:`market-detached-test-tree` 已默认生效,Account 侧两组 market/account 混合 IT 默认不再编译/运行;Account owner 自身的 profile、usage、quota、security、New-API binding 能力不因 market dormant 变化。恢复市场相关账户记录验收时显式使用 `muse-server -Pmarket-assembled`。 +- **2.0.0 S4 单人版 member 固化(2026-07-08)**:Account New-API 管理口保持 `muse.account.new-api.enabled=false` 默认关闭,多用户 `publish-worker/attribution-worker/quota-request-worker` 默认关闭;新增 `SoloAccountDefaultsGateTest` 纳入 local 门禁,断言这些默认值与 `NewApiAccountFacade`、`MarketAccountProjectionFacade`、`AccountFileServiceFacade` 缺 owner 时的 fail-closed 兜底。`muse-cloud/scripts/dev/solo-compose.env.example` 固化 S9 compose 可复用 env 口径:租户机制保持开启、请求固定 `tenant-id: 1`、Account 外部管理口/worker 默认 false。注意:entitlement 是本地纯读逻辑,不是 `@ConditionalOnMissingBean` 外部兜底链;不要为 S4 硬造新抽象。 - **关键风险 / TODO**:account needs_verification 验收债**已清零**:末位 adminListPurchaseRecords 经 `P1rAccountAdminPurchaseRecordsCompletedApprovalIT`(跨模块 base-package=cn.iocoder.muse.module + import MarketAccountProjectionProvider)真实 PG 2/2 绿补证;批3(市场记录读)+ 批7-14 account needs_verification op 全部补真实 PG 证据、证据已人工批准并翻 completed。截至 2026-06-17,content/market/account 三域 needs_verification 验收债均已补齐真实 PG 证据,coverage JSON completed flip 与覆盖门禁批准集合已按人工批准同步,2026-06-19 已补 `testFiles` 证据锚点门禁。前端仍有深页未全覆盖真实后端。 diff --git a/muse-cloud/muse-server/src/main/resources/application.yaml b/muse-cloud/muse-server/src/main/resources/application.yaml index 8d3e7fa5..e1cd2f1e 100644 --- a/muse-cloud/muse-server/src/main/resources/application.yaml +++ b/muse-cloud/muse-server/src/main/resources/application.yaml @@ -196,37 +196,46 @@ spring: host: 127.0.0.1 port: 19530 qianfan: # 文心一言 - api-key: x0cuLZ7XsaTCU08vuJWO87Lg - secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK + api-key: ${MUSE_AI_QIANFAN_API_KEY:} + secret-key: ${MUSE_AI_QIANFAN_SECRET_KEY:} zhipuai: # 智谱 AI - api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs + api-key: ${MUSE_AI_ZHIPUAI_API_KEY:} openai: # OpenAI 官方 - api-key: sk-aN6nWn3fILjrgLFT0fC4Aa60B72e4253826c77B29dC94f17 - base-url: https://api.gptsapi.net + api-key: ${MUSE_AI_OPENAI_API_KEY:} + base-url: ${MUSE_AI_OPENAI_BASE_URL:https://api.openai.com} azure: # OpenAI 微软 openai: endpoint: https://eastusprejade.openai.azure.com anthropic: # Anthropic Claude - api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942 + api-key: ${MUSE_AI_ANTHROPIC_API_KEY:} ollama: base-url: http://127.0.0.1:11434 chat: model: llama3 stabilityai: - api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx + api-key: ${MUSE_AI_STABILITYAI_API_KEY:} dashscope: # 通义千问 - api-key: sk-47aa124781be4bfb95244cc62f6xxxx + api-key: ${MUSE_AI_DASHSCOPE_API_KEY:} + agent: + enabled: false minimax: # Minimax:https://www.minimaxi.com/ - api-key: xxxx + api-key: ${MUSE_AI_MINIMAX_API_KEY:} moonshot: # 月之暗面(KIMI) - api-key: sk-abc + api-key: ${MUSE_AI_MOONSHOT_API_KEY:} deepseek: # DeepSeek - api-key: sk-e94db327cc7d457d99a8de8810fc6b12 + api-key: ${MUSE_AI_DEEPSEEK_API_KEY:} chat: options: model: deepseek-chat model: - rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启 + chat: none + embedding: none + image: none + rerank: none + video: none + audio: + speech: none + transcription: none mcp: server: enabled: false @@ -248,43 +257,43 @@ spring: muse: ai: gemini: # 谷歌 Gemini - enable: true - api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ + enable: false + api-key: ${MUSE_AI_GEMINI_API_KEY:} model: gemini-2.5-flash doubao: # 字节豆包 - enable: true - api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272 + enable: false + api-key: ${MUSE_AI_DOUBAO_API_KEY:} model: doubao-1-5-lite-32k-250115 hunyuan: # 腾讯混元 - enable: true - api-key: sk-abc + enable: false + api-key: ${MUSE_AI_HUNYUAN_API_KEY:} model: hunyuan-turbo siliconflow: # 硅基流动 - enable: true - api-key: sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz + enable: false + api-key: ${MUSE_AI_SILICONFLOW_API_KEY:} model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B xinghuo: # 讯飞星火 - enable: true - appKey: 75b161ed2aef4719b275d6e7f2a4d4cd - secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz + enable: false + appKey: ${MUSE_AI_XINGHUO_APP_KEY:} + secretKey: ${MUSE_AI_XINGHUO_SECRET_KEY:} model: x1 baichuan: # 百川智能 - enable: true - api-key: sk-abc + enable: false + api-key: ${MUSE_AI_BAICHUAN_API_KEY:} model: Baichuan4-Turbo midjourney: - enable: true + enable: false # base-url: https://api.holdai.top/mj-relax/mj - base-url: https://api.holdai.top/mj - api-key: sk-dZEPiVaNcT3FHhef51996bAa0bC74806BeAb620dA5Da10Bf - notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify + base-url: ${MUSE_AI_MIDJOURNEY_BASE_URL:} + api-key: ${MUSE_AI_MIDJOURNEY_API_KEY:} + notify-url: ${MUSE_AI_MIDJOURNEY_NOTIFY_URL:} suno: - enable: true + enable: false # base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app - base-url: http://127.0.0.1:3001 + base-url: ${MUSE_AI_SUNO_BASE_URL:} web-search: - enable: true - api-key: sk-40500e52840f4d24b956d0b1d80d9abe + enable: false + api-key: ${MUSE_AI_WEB_SEARCH_API_KEY:} --- #################### 芋道相关配置 #################### diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloAccountDefaultsGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloAccountDefaultsGateTest.java new file mode 100644 index 00000000..8eef3b8e --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloAccountDefaultsGateTest.java @@ -0,0 +1,56 @@ +package cn.iocoder.muse.server.framework.api; + +import cn.iocoder.muse.autoconfigure.member.account.AccountFileServiceFacadeAutoConfiguration; +import cn.iocoder.muse.autoconfigure.member.account.MarketAccountProjectionFacadeAutoConfiguration; +import cn.iocoder.muse.autoconfigure.member.account.NewApiAccountFacadeAutoConfiguration; +import cn.iocoder.muse.module.member.api.account.MarketAccountProjectionFacade; +import cn.iocoder.muse.module.member.application.account.facade.AccountFileServiceFacade; +import cn.iocoder.muse.module.member.application.account.facade.NewApiAccountFacade; +import cn.iocoder.muse.module.member.application.account.facade.UnavailableAccountFileServiceFacade; +import cn.iocoder.muse.module.member.application.account.facade.UnavailableMarketAccountProjectionFacade; +import cn.iocoder.muse.module.member.application.account.facade.UnavailableNewApiAccountFacade; +import cn.iocoder.muse.module.member.framework.config.MuseAccountEventsProperties; +import cn.iocoder.muse.module.member.framework.config.MuseAccountNewApiProperties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * 2.0.0 S4 member 单人版默认值门禁。 + * + *

member 在单人版只保留账号、profile、用量只读观测和审计;New-API 管理口与多用户 worker + * 必须默认关闭,market 投影和导出外部依赖缺席时必须 fail-closed。

+ */ +class SoloAccountDefaultsGateTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + NewApiAccountFacadeAutoConfiguration.class, + MarketAccountProjectionFacadeAutoConfiguration.class, + AccountFileServiceFacadeAutoConfiguration.class)); + + @Test + void should_keep_new_api_management_and_multi_user_workers_disabled_by_default() { + MuseAccountNewApiProperties newApiProperties = new MuseAccountNewApiProperties(); + MuseAccountEventsProperties eventsProperties = new MuseAccountEventsProperties(); + + assertFalse(newApiProperties.isEnabled(), "New-API 管理口默认必须关闭"); + assertFalse(eventsProperties.getPublishWorker().isEnabled(), "Account outbox worker 默认必须关闭"); + assertFalse(eventsProperties.getAttributionWorker().isEnabled(), "调用归因 worker 默认必须关闭"); + assertFalse(eventsProperties.getQuotaRequestWorker().isEnabled(), "配额请求 worker 默认必须关闭"); + } + + @Test + void should_keep_external_member_facades_fail_closed_when_owners_are_missing() { + contextRunner.run(context -> { + assertInstanceOf(UnavailableNewApiAccountFacade.class, context.getBean(NewApiAccountFacade.class)); + assertInstanceOf(UnavailableMarketAccountProjectionFacade.class, + context.getBean(MarketAccountProjectionFacade.class)); + assertInstanceOf(UnavailableAccountFileServiceFacade.class, + context.getBean(AccountFileServiceFacade.class)); + }); + } +} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloExternalAiProviderGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloExternalAiProviderGateTest.java new file mode 100644 index 00000000..9719772d --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/SoloExternalAiProviderGateTest.java @@ -0,0 +1,118 @@ +package cn.iocoder.muse.server.framework.api; + +import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient; +import cn.iocoder.muse.module.ai.application.muse.facade.RoutingMuseAiRuntimeClient; +import cn.iocoder.muse.module.ai.framework.ai.config.AiAutoConfiguration; +import cn.iocoder.muse.module.ai.framework.ai.core.model.baichuan.BaiChuanChatModel; +import cn.iocoder.muse.module.ai.framework.ai.core.model.doubao.DouBaoChatModel; +import cn.iocoder.muse.module.ai.framework.ai.core.model.gemini.GeminiChatModel; +import cn.iocoder.muse.module.ai.framework.ai.core.model.hunyuan.HunYuanChatModel; +import cn.iocoder.muse.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import cn.iocoder.muse.module.ai.framework.ai.core.model.siliconflow.SiliconFlowChatModel; +import cn.iocoder.muse.module.ai.framework.ai.core.model.suno.api.SunoApi; +import cn.iocoder.muse.module.ai.framework.ai.core.model.xinghuo.XingHuoChatModel; +import cn.iocoder.muse.module.ai.framework.ai.core.webserch.AiWebSearchClient; +import cn.iocoder.muse.module.ai.tool.method.PersonService; +import cn.iocoder.muse.module.ai.tool.method.PersonServiceImpl; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAudioSpeechAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAudioTranscriptionAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeChatAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeEmbeddingAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeImageAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeRerankAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeVideoAutoConfiguration; +import com.alibaba.cloud.ai.dashscope.agent.DashScopeAgent; +import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel; +import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel; +import com.alibaba.cloud.ai.dashscope.image.DashScopeImageModel; +import com.alibaba.cloud.ai.model.RerankModel; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * 2.0.0 S4 单人版外部 AI 厂商装配门禁。 + * + *

yudao 继承的厂商脚手架必须默认关闭;Muse 只保留 Dify 与未配置即 fail-closed 的 + * New-API runtime adapter 壳,避免 application.yaml 回漂时启动期重新连出外部厂商。

+ */ +class SoloExternalAiProviderGateTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + AiAutoConfiguration.class, + DashScopeAgentAutoConfiguration.class, + DashScopeAudioSpeechAutoConfiguration.class, + DashScopeAudioTranscriptionAutoConfiguration.class, + DashScopeChatAutoConfiguration.class, + DashScopeEmbeddingAutoConfiguration.class, + DashScopeImageAutoConfiguration.class, + DashScopeRerankAutoConfiguration.class, + DashScopeVideoAutoConfiguration.class)) + .withUserConfiguration(TestToolConfiguration.class) + .withPropertyValues( + "muse.ai.gemini.enable=false", + "muse.ai.doubao.enable=false", + "muse.ai.hunyuan.enable=false", + "muse.ai.siliconflow.enable=false", + "muse.ai.xinghuo.enable=false", + "muse.ai.baichuan.enable=false", + "muse.ai.midjourney.enable=false", + "muse.ai.suno.enable=false", + "muse.ai.web-search.enable=false", + "spring.ai.dashscope.agent.enabled=false", + "spring.ai.model.chat=none", + "spring.ai.model.embedding=none", + "spring.ai.model.image=none", + "spring.ai.model.rerank=none", + "spring.ai.model.video=none", + "spring.ai.model.audio.speech=none", + "spring.ai.model.audio.transcription=none"); + + @Test + void should_not_register_yudao_external_provider_beans_in_solo_mode() { + contextRunner.run(context -> { + assertEquals(0, context.getBeansOfType(GeminiChatModel.class).size()); + assertEquals(0, context.getBeansOfType(DouBaoChatModel.class).size()); + assertEquals(0, context.getBeansOfType(HunYuanChatModel.class).size()); + assertEquals(0, context.getBeansOfType(SiliconFlowChatModel.class).size()); + assertEquals(0, context.getBeansOfType(XingHuoChatModel.class).size()); + assertEquals(0, context.getBeansOfType(BaiChuanChatModel.class).size()); + assertEquals(0, context.getBeansOfType(MidjourneyApi.class).size()); + assertEquals(0, context.getBeansOfType(SunoApi.class).size()); + assertEquals(0, context.getBeansOfType(AiWebSearchClient.class).size()); + }); + } + + @Test + void should_keep_runtime_router_shell_for_dify_and_new_api_fail_closed_paths() { + contextRunner.run(context -> + assertInstanceOf(RoutingMuseAiRuntimeClient.class, context.getBean(MuseAiRuntimeClient.class))); + } + + @Test + void should_not_register_dashscope_spring_ai_auto_configuration_beans_in_solo_mode() { + contextRunner.run(context -> { + assertEquals(0, context.getBeansOfType(DashScopeAgent.class).size()); + assertEquals(0, context.getBeansOfType(DashScopeChatModel.class).size()); + assertEquals(0, context.getBeansOfType(DashScopeEmbeddingModel.class).size()); + assertEquals(0, context.getBeansOfType(DashScopeImageModel.class).size()); + assertEquals(0, context.getBeansOfType(RerankModel.class).size()); + }); + } + + @Configuration(proxyBeanMethods = false) + static class TestToolConfiguration { + + @Bean + PersonService personService() { + return new PersonServiceImpl(); + } + } +} diff --git a/muse-cloud/scripts/dev/solo-compose.env.example b/muse-cloud/scripts/dev/solo-compose.env.example new file mode 100644 index 00000000..c8776c79 --- /dev/null +++ b/muse-cloud/scripts/dev/solo-compose.env.example @@ -0,0 +1,15 @@ +# Muse 2.0.0 单人版 compose env 样例。 +# S9 的 docker-compose.solo.yml 引用本文件;S4 只固化默认开关,不在这里提交真实凭据。 + +# 租户机制保持开启,单人部署所有 app/admin 请求固定使用 tenant-id: 1。 +MUSE_TENANT_ENABLE=true + +# Account New-API 管理口与多用户 worker 默认关闭。 +MUSE_ACCOUNT_NEW_API_ENABLED=false +MUSE_ACCOUNT_EVENTS_PUBLISH_WORKER_ENABLED=false +MUSE_ACCOUNT_EVENTS_ATTRIBUTION_WORKER_ENABLED=false +MUSE_ACCOUNT_EVENTS_QUOTA_REQUEST_WORKER_ENABLED=false + +# AI 运行时后续由 S7/S8 切到 Dify;S4 保持 New-API/Dify adapter 未配置即 fail-closed。 +MUSE_AI_NEW_API_ENABLED=false +MUSE_AI_DIFY_ENABLED=false diff --git a/muse-cloud/scripts/run-p1r-verification.sh b/muse-cloud/scripts/run-p1r-verification.sh index e2e82a33..d764589f 100644 --- a/muse-cloud/scripts/run-p1r-verification.sh +++ b/muse-cloud/scripts/run-p1r-verification.sh @@ -10,7 +10,7 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)" CLOUD_DIR="${REPO_ROOT}/muse-cloud" -LOCAL_TESTS="P1rApiCoverageReportTest,P1r*RealApiGateTest,P1rMarketDetachedAssemblyGateTest,AgentsInfraIntegrityTest,ContractFirstGateTest,BcBoundaryArchTest,AiGrantRuntimeBoundaryArchTest" +LOCAL_TESTS="P1rApiCoverageReportTest,P1r*RealApiGateTest,P1rMarketDetachedAssemblyGateTest,SoloExternalAiProviderGateTest,SoloAccountDefaultsGateTest,AgentsInfraIntegrityTest,ContractFirstGateTest,BcBoundaryArchTest,AiGrantRuntimeBoundaryArchTest" REAL_PG_TESTS="${P1R_REAL_PG_TESTS:-P1r*IT,!P1rAiRuntimeEndToEndLiveAcceptanceIT,!P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT,!P1rMarketKbForkMaterializationIT,!P1rImportLlmNewApiLiveAcceptanceIT,!P1rNewApiLiveAcceptanceIT,!P1rRagFlowLiveAcceptanceIT,!P1rDifyChatLiveAcceptanceIT,!P1rDifyDatasetsContractLiveIT}" EXTERNAL_LIVE_TESTS="${P1R_EXTERNAL_LIVE_TESTS:-P1rAiRuntimeEndToEndLiveAcceptanceIT,P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT,P1rMarketKbForkMaterializationIT}" MODULE_LIVE_TESTS="${P1R_MODULE_LIVE_TESTS:-P1rNewApiLiveAcceptanceIT,P1rImportLlmNewApiLiveAcceptanceIT,P1rRagFlowLiveAcceptanceIT,P1rDifyChatLiveAcceptanceIT,P1rDifyDatasetsContractLiveIT}" diff --git a/muse-cloud/sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql b/muse-cloud/sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql new file mode 100644 index 00000000..fada7cda --- /dev/null +++ b/muse-cloud/sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql @@ -0,0 +1,51 @@ +-- Muse 2.0.0 S4 单人版菜单收口脚本。 +-- 只隐藏多用户与 yudao 娱乐面菜单;保留菜单管理与管理员自身改密入口。 +-- 幂等:重复执行只会把同一批 system_menu 置为不可见/停用。 + +WITH RECURSIVE target_roots AS ( + SELECT id + FROM system_menu + WHERE deleted = FALSE + AND ( + path IN ('/member', 'member', '/pay', 'pay', '/mp', 'mp', '/bpm', 'bpm', '/ai', 'ai') + OR COALESCE(component, '') LIKE 'member/%' + OR COALESCE(component, '') LIKE 'pay/%' + OR COALESCE(component, '') LIKE 'mp/%' + OR COALESCE(component, '') LIKE 'bpm/%' + OR COALESCE(component, '') LIKE 'ai/%' + OR COALESCE(permission, '') LIKE 'member:%' + OR COALESCE(permission, '') LIKE 'pay:%' + OR COALESCE(permission, '') LIKE 'mp:%' + OR COALESCE(permission, '') LIKE 'bpm:%' + OR COALESCE(permission, '') LIKE 'ai:%' + OR ( + name IN ('用户管理', '角色管理', '租户管理', '租户套餐', '部门管理', '岗位管理') + AND ( + COALESCE(component, '') LIKE 'system/%' + OR COALESCE(permission, '') LIKE 'system:%' + OR path IN ('user', 'role', 'tenant', 'tenant-package', 'dept', 'post') + ) + ) + ) +), +target_tree AS ( + SELECT id + FROM target_roots + UNION ALL + SELECT child.id + FROM system_menu child + JOIN target_tree parent ON child.parent_id = parent.id + WHERE child.deleted = FALSE +) +UPDATE system_menu +SET status = 1, + visible = FALSE, + updater = 'solo-s4-menu-hide', + update_time = CURRENT_TIMESTAMP +WHERE id IN (SELECT id FROM target_tree) + AND deleted = FALSE + AND NOT ( + path = 'menu' + OR COALESCE(component, '') = 'system/menu/index' + OR COALESCE(permission, '') LIKE 'system:menu:%' + ); diff --git a/muse-cloud/sql/solo/2026-07-08-restore-solo-governance-menus-postgres.sql b/muse-cloud/sql/solo/2026-07-08-restore-solo-governance-menus-postgres.sql new file mode 100644 index 00000000..945fbfb5 --- /dev/null +++ b/muse-cloud/sql/solo/2026-07-08-restore-solo-governance-menus-postgres.sql @@ -0,0 +1,51 @@ +-- Muse 2.0.0 S4 单人版菜单收口回滚脚本。 +-- 与 disable 脚本使用同一选择器,恢复被隐藏的多用户与 yudao 娱乐面菜单。 +-- 幂等:重复执行只会把同一批 system_menu 置为可见/启用。 + +WITH RECURSIVE target_roots AS ( + SELECT id + FROM system_menu + WHERE deleted = FALSE + AND ( + path IN ('/member', 'member', '/pay', 'pay', '/mp', 'mp', '/bpm', 'bpm', '/ai', 'ai') + OR COALESCE(component, '') LIKE 'member/%' + OR COALESCE(component, '') LIKE 'pay/%' + OR COALESCE(component, '') LIKE 'mp/%' + OR COALESCE(component, '') LIKE 'bpm/%' + OR COALESCE(component, '') LIKE 'ai/%' + OR COALESCE(permission, '') LIKE 'member:%' + OR COALESCE(permission, '') LIKE 'pay:%' + OR COALESCE(permission, '') LIKE 'mp:%' + OR COALESCE(permission, '') LIKE 'bpm:%' + OR COALESCE(permission, '') LIKE 'ai:%' + OR ( + name IN ('用户管理', '角色管理', '租户管理', '租户套餐', '部门管理', '岗位管理') + AND ( + COALESCE(component, '') LIKE 'system/%' + OR COALESCE(permission, '') LIKE 'system:%' + OR path IN ('user', 'role', 'tenant', 'tenant-package', 'dept', 'post') + ) + ) + ) +), +target_tree AS ( + SELECT id + FROM target_roots + UNION ALL + SELECT child.id + FROM system_menu child + JOIN target_tree parent ON child.parent_id = parent.id + WHERE child.deleted = FALSE +) +UPDATE system_menu +SET status = 0, + visible = TRUE, + updater = 'solo-s4-menu-restore', + update_time = CURRENT_TIMESTAMP +WHERE id IN (SELECT id FROM target_tree) + AND deleted = FALSE + AND NOT ( + path = 'menu' + OR COALESCE(component, '') = 'system/menu/index' + OR COALESCE(permission, '') LIKE 'system:menu:%' + ); diff --git a/muse-studio/e2e/account-market-records.spec.ts b/muse-studio/e2e/account-market-records.spec.ts index fe6b5da3..225a4502 100644 --- a/muse-studio/e2e/account-market-records.spec.ts +++ b/muse-studio/e2e/account-market-records.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 账户中心「我的购买 / 我的授权」记录 e2e(真实后端,MSW off)。 * diff --git a/muse-studio/e2e/account-newapi-binding.spec.ts b/muse-studio/e2e/account-newapi-binding.spec.ts index 061821ab..266bc154 100644 --- a/muse-studio/e2e/account-newapi-binding.spec.ts +++ b/muse-studio/e2e/account-newapi-binding.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 个人中心 New-API 绑定摘要 e2e(真实后端,MSW off)——A 类:后端 getAppNewApiBinding 就绪、studio 此前 0 消费。 * diff --git a/muse-studio/e2e/account-publish-records.spec.ts b/muse-studio/e2e/account-publish-records.spec.ts index 7956643a..f17fed7f 100644 --- a/muse-studio/e2e/account-publish-records.spec.ts +++ b/muse-studio/e2e/account-publish-records.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 账户中心「我的发布」记录 e2e(真实后端,MSW off)。 * diff --git a/muse-studio/e2e/account-security-ack.spec.ts b/muse-studio/e2e/account-security-ack.spec.ts index 0d32bb9b..8ba90527 100644 --- a/muse-studio/e2e/account-security-ack.spec.ts +++ b/muse-studio/e2e/account-security-ack.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 安全事件确认写路 e2e(真实后端,MSW off)。 * diff --git a/muse-studio/e2e/global-setup.ts b/muse-studio/e2e/global-setup.ts index 9265f6e7..080844ca 100644 --- a/muse-studio/e2e/global-setup.ts +++ b/muse-studio/e2e/global-setup.ts @@ -1,4 +1,4 @@ -import { Client } from 'pg'; +import type { Client as PgClient } from 'pg'; /** * Playwright globalSetup —— 活体 e2e 每轮 fixture 自动重置(可入库,替代一次性 /tmp 种子脚本)。 @@ -30,7 +30,8 @@ async function globalSetup(): Promise { ); } - const client = new Client({ + const { Client } = await import('pg'); + const client: PgClient = new Client({ host, port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'), database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live', @@ -42,6 +43,9 @@ async function globalSetup(): Promise { await client.connect(); try { + const enableMarketFixture = process.env.MUSE_STUDIO_E2E_MARKET !== 'false'; + let marketFixtureDetails = 'market/handoff fixtures skipped'; + // 0) AI 真生成额度:每轮把 test1(user1) 的 ai_calls 配额复位为可消费态。 // WHY:AI 生成 e2e 会真调用 Account 额度预扣,额度属于会被测试消费的状态;若不复位, // 历史运行耗尽或基础种子缺失都会让 POST /ai/tasks 在业务入口返回「账户额度不足」,形成夹具假红。 @@ -172,20 +176,22 @@ async function globalSetup(): Promise { console.warn('[e2e globalSetup] ⚠️ 未找到"待确认演练"安全事件(account-security-ack 基础种子缺失)'); } - // 5a) market 申诉提交:刷新被驳回 publish_request 的 reviewed_at 到近期,使申诉 deadline(reviewedAt + 后端 - // APPEAL_DEADLINE_DAYS=7 天)未过。否则种子 request 随日期推进必然过期,「发起申诉」用例提交被后端拒 - // MARKET_APPEAL_DEADLINE_EXPIRED(1044000027)——此 fixture 老化与代码无关、纯时间推进所致 - // (见 MarketAppealServiceImpl.requireWithinDeadline / reviewRejectionRelation 取 request.reviewedAt)。 - await client.query( - "UPDATE muse_market_publish_request SET reviewed_at = NOW() WHERE tenant_id=1 AND status IN ('rejected','compliance_blocked')" - ); + if (enableMarketFixture) { + // 5a) market 申诉提交:刷新被驳回 publish_request 的 reviewed_at 到近期,使申诉 deadline(reviewedAt + 后端 + // APPEAL_DEADLINE_DAYS=7 天)未过。否则种子 request 随日期推进必然过期,「发起申诉」用例提交被后端拒 + // MARKET_APPEAL_DEADLINE_EXPIRED(1044000027)——此 fixture 老化与代码无关、纯时间推进所致 + // (见 MarketAppealServiceImpl.requireWithinDeadline / reviewRejectionRelation 取 request.reviewedAt)。 + await client.query( + "UPDATE muse_market_publish_request SET reviewed_at = NOW() WHERE tenant_id=1 AND status IN ('rejected','compliance_blocked')" + ); - // 5) market 申诉补充材料:把当前用户最早的一条申诉置 supplementing(canSupplement→展示「补充材料」)。 - const ap = await client.query( - "UPDATE muse_market_appeal SET status='supplementing' WHERE tenant_id=1 AND id=(SELECT min(id) FROM muse_market_appeal WHERE tenant_id=1 AND user_id=1)" - ); - if (ap.rowCount === 0) { - console.warn('[e2e globalSetup] ⚠️ 未找到当前用户的申诉(market 补充材料用例需基础申诉种子)'); + // 5) market 申诉补充材料:把当前用户最早的一条申诉置 supplementing(canSupplement→展示「补充材料」)。 + const ap = await client.query( + "UPDATE muse_market_appeal SET status='supplementing' WHERE tenant_id=1 AND id=(SELECT min(id) FROM muse_market_appeal WHERE tenant_id=1 AND user_id=1)" + ); + if (ap.rowCount === 0) { + console.warn('[e2e globalSetup] ⚠️ 未找到当前用户的申诉(market 补充材料用例需基础申诉种子)'); + } } // 6) knowledge-bindings:作品 1 一条<生效>的来源绑定读模型投影(此前 global-setup 未覆盖、基础种子缺失, @@ -322,89 +328,91 @@ async function globalSetup(): Promise { "DELETE FROM muse_agent WHERE tenant_id=1 AND name LIKE '活体智能体-e2e-%'" ); - // 12) handoff-knowledge:清理 e2e 正路兑现产生的 market_kb 绑定(work4+kb1),供兑现闭环幂等重跑。 - // 用 work4(creator=test1、未占 uk);work1+kb1 已被 installed binding(id=1,user_kb)占,故 e2e 用 work4 避 uk 冲突。 - // 按 source_snapshot_id 格式(source-market_kb-{sourceId}-v*)精准删 binding;物理 DELETE 释放 uk_muse_knowledge_binding_work_kb。 - await client.query( - "DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id LIKE 'source-market_kb-1-%'" - ); - await client.query( - "DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND work_id=4 AND kb_id=1 AND source_type='market_kb'" - ); + if (enableMarketFixture) { + // 12) handoff-knowledge:清理 e2e 正路兑现产生的 market_kb 绑定(work4+kb1),供兑现闭环幂等重跑。 + // 用 work4(creator=test1、未占 uk);work1+kb1 已被 installed binding(id=1,user_kb)占,故 e2e 用 work4 避 uk 冲突。 + // 按 source_snapshot_id 格式(source-market_kb-{sourceId}-v*)精准删 binding;物理 DELETE 释放 uk_muse_knowledge_binding_work_kb。 + await client.query( + "DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id LIKE 'source-market_kb-1-%'" + ); + await client.query( + "DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND work_id=4 AND kb_id=1 AND source_type='market_kb'" + ); - // 13) handoff-agent(E4):seed 发布者侧 user agent + asset1.source_id,供 market→agent handoff 真实物化 e2e。 - // 旧形态是预置 agent_type=market 的裸 agent 并把 sourceAgentId 透传给目标侧;E4 后目标侧只信 market - // assetId/token,后端按 asset.source_id 找发布者 agent,再物化成安装者本地 user agent。 - const HANDOFF_AGENT_KEY = 'publisher:handoff:e2e'; - const HANDOFF_SLOT_KEY = 'handoff-writer'; - await client.query( - `DELETE FROM muse_agent_version - WHERE tenant_id=1 - AND agent_id IN ( - SELECT id FROM muse_agent - WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false - )` - ); - await client.query( - "DELETE FROM muse_agent WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false" - ); - await client.query('DELETE FROM muse_agent_slot_binding WHERE tenant_id=1 AND work_id=4 AND slot_key=$1', [ - HANDOFF_SLOT_KEY, - ]); - await client.query( - 'DELETE FROM muse_agent_version WHERE tenant_id=1 AND agent_id IN (SELECT id FROM muse_agent WHERE tenant_id=1 AND agent_key=$1)', - [HANDOFF_AGENT_KEY] - ); - await client.query('DELETE FROM muse_agent WHERE tenant_id=1 AND agent_key=$1', [HANDOFF_AGENT_KEY]); - const publisherAgent = await client.query( - `INSERT INTO muse_agent (agent_key, name, description, agent_type, owner_user_id, status, tenant_id) - VALUES ($1,'活体市场智能体-handoff','market agent handoff e2e 发布者来源','user',2,'active',1) RETURNING id`, - [HANDOFF_AGENT_KEY] - ); - const publisherAgentId = publisherAgent.rows[0].id as number; - const publisherVersion = await client.query( - `INSERT INTO muse_agent_version (agent_id, version, config, status, tenant_id) - VALUES ($1,'1',$2::jsonb,'active',1) RETURNING id`, - [ + // 13) handoff-agent(E4):seed 发布者侧 user agent + asset1.source_id,供 market→agent handoff 真实物化 e2e。 + // 旧形态是预置 agent_type=market 的裸 agent 并把 sourceAgentId 透传给目标侧;E4 后目标侧只信 market + // assetId/token,后端按 asset.source_id 找发布者 agent,再物化成安装者本地 user agent。 + const HANDOFF_AGENT_KEY = 'publisher:handoff:e2e'; + const HANDOFF_SLOT_KEY = 'handoff-writer'; + await client.query( + `DELETE FROM muse_agent_version + WHERE tenant_id=1 + AND agent_id IN ( + SELECT id FROM muse_agent + WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false + )` + ); + await client.query( + "DELETE FROM muse_agent WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false" + ); + await client.query('DELETE FROM muse_agent_slot_binding WHERE tenant_id=1 AND work_id=4 AND slot_key=$1', [ + HANDOFF_SLOT_KEY, + ]); + await client.query( + 'DELETE FROM muse_agent_version WHERE tenant_id=1 AND agent_id IN (SELECT id FROM muse_agent WHERE tenant_id=1 AND agent_key=$1)', + [HANDOFF_AGENT_KEY] + ); + await client.query('DELETE FROM muse_agent WHERE tenant_id=1 AND agent_key=$1', [HANDOFF_AGENT_KEY]); + const publisherAgent = await client.query( + `INSERT INTO muse_agent (agent_key, name, description, agent_type, owner_user_id, status, tenant_id) + VALUES ($1,'活体市场智能体-handoff','market agent handoff e2e 发布者来源','user',2,'active',1) RETURNING id`, + [HANDOFF_AGENT_KEY] + ); + const publisherAgentId = publisherAgent.rows[0].id as number; + const publisherVersion = await client.query( + `INSERT INTO muse_agent_version (agent_id, version, config, status, tenant_id) + VALUES ($1,'1',$2::jsonb,'active',1) RETURNING id`, + [ + publisherAgentId, + JSON.stringify({ + modelKey: 'MiniMax-M2.5', + promptTemplateVersion: 'prompt-v1', + }), + ] + ); + await client.query('UPDATE muse_agent SET current_version_id=$1 WHERE id=$2', [ + publisherVersion.rows[0].id, publisherAgentId, - JSON.stringify({ - modelKey: 'MiniMax-M2.5', - promptTemplateVersion: 'prompt-v1', - }), - ] - ); - await client.query('UPDATE muse_agent SET current_version_id=$1 WHERE id=$2', [ - publisherVersion.rows[0].id, - publisherAgentId, - ]); - await client.query( - `UPDATE muse_market_asset - SET asset_type='agent', - source_id=$1, - publisher_id=2, - category='general', - tags=coalesce(tags, '{}'::jsonb) || $2::jsonb, - update_time=CURRENT_TIMESTAMP - WHERE tenant_id=1 AND id=1 AND deleted=false`, - [ - publisherAgentId, - JSON.stringify({ - sourceOwner: 'agent', - sourceType: 'agent', - sourceRevision: '1', - }), - ] - ); - // work4 槽位初始绑现有 agent1(被兑现替换成安装者本地 user agent);precheck revision 从 1 起。 - await client.query( - `INSERT INTO muse_agent_slot_binding (work_id, slot_key, agent_id, agent_version, status, revision, tenant_id) - VALUES (4,$1,1,'1','active',1,1)`, - [HANDOFF_SLOT_KEY] - ); + ]); + await client.query( + `UPDATE muse_market_asset + SET asset_type='agent', + source_id=$1, + publisher_id=2, + category='general', + tags=coalesce(tags, '{}'::jsonb) || $2::jsonb, + update_time=CURRENT_TIMESTAMP + WHERE tenant_id=1 AND id=1 AND deleted=false`, + [ + publisherAgentId, + JSON.stringify({ + sourceOwner: 'agent', + sourceType: 'agent', + sourceRevision: '1', + }), + ] + ); + // work4 槽位初始绑现有 agent1(被兑现替换成安装者本地 user agent);precheck revision 从 1 起。 + await client.query( + `INSERT INTO muse_agent_slot_binding (work_id, slot_key, agent_id, agent_version, status, revision, tenant_id) + VALUES (4,$1,1,'1','active',1,1)`, + [HANDOFF_SLOT_KEY] + ); - // 14) handoff-content(P3):清理 e2e 正路兑现产生的 asset_use precheck(work4),供兑现闭环幂等重跑。 - // asset_use 不绑目标实体(单表 precheck 即使用凭证),物理删释放 (tenant_id,command_id) uk;asset 1 + work4 无需额外 seed。 - await client.query('DELETE FROM muse_content_work_asset_use_precheck WHERE tenant_id=1 AND work_id=4'); + // 14) handoff-content(P3):清理 e2e 正路兑现产生的 asset_use precheck(work4),供兑现闭环幂等重跑。 + // asset_use 不绑目标实体(单表 precheck 即使用凭证),物理删释放 (tenant_id,command_id) uk;asset 1 + work4 无需额外 seed。 + await client.query('DELETE FROM muse_content_work_asset_use_precheck WHERE tenant_id=1 AND work_id=4'); + } // 15a) AI 真生成主链:复位 work1 writing.continuation 槽位到固定起点(agent1、revision=1、active)。 // accept-suggestion / ai-generation 均依赖 work1 解析到 active Agent;该槽位不能被其它 e2e 写坏。 @@ -432,226 +440,217 @@ async function globalSetup(): Promise { VALUES (2,'writing.continuation',1,'1','active',1,'user',1)` ); - // 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的 - // 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链, - // UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装, - // 安装按钮的渲染条件(isAcquired && !isInstalled)不再成立 → 断言必红。故每轮删净 user1 在该资产上的 - // authz/installation/command,保证 UI 起点恒为「可获取、未安装」。资产本身幂等保留(只删用户态)。 - const MARKET_INSTALL_ASSET = '活体市场资产·安装e2e'; - let installE2eAssetId: number; - const existed = await client.query( - "SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false", - [MARKET_INSTALL_ASSET] - ); - if (existed.rowCount && existed.rows[0]) { - installE2eAssetId = existed.rows[0].id as number; - } else { - // 资产首建:listed agent + 一条 published 版本,并回填 current_version_id(详情/安装需可见且有版本)。 - const a = await client.query( - `INSERT INTO muse_market_asset - (name, description, asset_type, category, publisher_id, listing_status, license_type, status, creator, updater, tenant_id) - VALUES ($1,'安装 e2e 专用资产','agent','general',2,'listed','standard','active','2','2',1) RETURNING id`, + if (enableMarketFixture) { + // 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的 + // 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链, + // UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装, + // 安装按钮的渲染条件(isAcquired && !isInstalled)不再成立 → 断言必红。故每轮删净 user1 在该资产上的 + // authz/installation/command,保证 UI 起点恒为「可获取、未安装」。资产本身幂等保留(只删用户态)。 + const MARKET_INSTALL_ASSET = '活体市场资产·安装e2e'; + let installE2eAssetId: number; + const existed = await client.query( + "SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false", [MARKET_INSTALL_ASSET] ); - installE2eAssetId = a.rows[0].id as number; - const v = await client.query( - `INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id) - VALUES ($1,'1.0.0','首发','published','2','2',1) RETURNING id`, - [installE2eAssetId] - ); - await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE id=$2', [ - v.rows[0].id, + if (existed.rowCount && existed.rows[0]) { + installE2eAssetId = existed.rows[0].id as number; + } else { + // 资产首建:listed agent + 一条 published 版本,并回填 current_version_id(详情/安装需可见且有版本)。 + const a = await client.query( + `INSERT INTO muse_market_asset + (name, description, asset_type, category, publisher_id, listing_status, license_type, status, creator, updater, tenant_id) + VALUES ($1,'安装 e2e 专用资产','agent','general',2,'listed','standard','active','2','2',1) RETURNING id`, + [MARKET_INSTALL_ASSET] + ); + installE2eAssetId = a.rows[0].id as number; + const v = await client.query( + `INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id) + VALUES ($1,'1.0.0','首发','published','2','2',1) RETURNING id`, + [installE2eAssetId] + ); + await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE id=$2', [ + v.rows[0].id, + installE2eAssetId, + ]); + } + // 复位 user1 在该资产上的获取/安装/命令到干净起点(未获取、未安装),令安装按钮渲染条件每轮可复现。 + await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [ installE2eAssetId, ]); - } - // 复位 user1 在该资产上的获取/安装/命令到干净起点(未获取、未安装),令安装按钮渲染条件每轮可复现。 - await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [ - installE2eAssetId, - ]); - await client.query( - 'DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', - [installE2eAssetId] - ); - // UI 随机 commandId 的 purchase/install 命令记录按 target_id 清(target_id 存资产 id 字符串),避免幂等回放。 - await client.query("DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1", [ - String(installE2eAssetId), - ]); - - // 17) 复位种子资产 asset1「活体市场资产·测试」为上架态(listing_status='listed'、status='active')。 - // WHY:asset1 是 handoff-agent/handoff-content 正路(bind-precheck/createHandoff 的来源资产)、 - // market-governance-impact(详情页 /market/assets/1)、live-read「市场浏览」的共同依赖。 - // 这些 spec 默认 asset1 可被消费(在售/可绑/可见)。若 asset1 被历史操作下架(实测 listing_status/status='delisted'), - // 则:① bind-precheck 直接返「Market 资产不存在(1044000003)」→ handoff 三条正路 + market-handoff-precheck 红; - // ② 市场列表不含该资产 → live-read「市场浏览」断言渲染该资产 timeout 红; - // ③ 详情页对下架资产不渲染绑定/治理控件 → governance-impact、handoff-knowledge 填「目标作品 ID」timeout 红。 - // 无任何 spec 主动下架 asset1(grep 全 spec 确认),故其下架属环境污染,此处每轮复位为上架态修正。 - // 注:asset1 上的治理结果记录(resultType=delist 演练)保留不动——governance-impact spec 依真实响应断言其展示, - // 上架态 + 留存治理记录两者并存正是该 spec 的预期数据形态(curl 已证 governanceResult 仍随上架资产返回)。 - const asset1Reset = await client.query( - "UPDATE muse_market_asset SET listing_status='listed', status='active' WHERE tenant_id=1 AND id=1 AND deleted=false" - ); - if (asset1Reset.rowCount === 0) { - console.warn('[e2e globalSetup] ⚠️ 未找到 asset id=1「活体市场资产·测试」(handoff/market 系列正路用例需该种子资产)'); - } - - // 18) handoff-knowledge / market-handoff-precheck:seed 一个专用 listed knowledge_base 资产。 - // WHY:asset1 是 agent 类型,不能再拿它冒充 knowledge_base;知识域 D0-fork 已 fail-closed 校验资产类型与 fork ready。 - // 本 fixture 复用稳定的公开副本 dataset(端到端检索验证KB),把 forkStatus=ready 写在 market 自有 tags,只验证 - // handoff→knowledge 兑现链路,不伪装为 D0-fork 物化本身的验证。 - const MARKET_KB_HANDOFF_ASSET = '活体市场知识库·handoff e2e'; - const handoffDataset = await client.query( - `SELECT kb.id AS kb_id, rb.ragflow_dataset_id - FROM muse_knowledge_ragflow_binding rb - JOIN muse_knowledge_base kb - ON kb.id=rb.kb_id AND kb.tenant_id=rb.tenant_id AND kb.deleted=false - WHERE rb.tenant_id=1 - AND rb.deleted=false - AND rb.document_id IS NULL - AND rb.status='active' - AND rb.ragflow_dataset_id IS NOT NULL - AND kb.name='端到端检索验证KB' - ORDER BY rb.id DESC - LIMIT 1` - ); - if (handoffDataset.rowCount === 0) { - throw new Error('[e2e globalSetup] 缺少稳定 active RAGFlow dataset: 端到端检索验证KB'); - } - const handoffPublicDatasetId = String(handoffDataset.rows[0].ragflow_dataset_id); - let handoffPublisherKbId: number; - const handoffPublisherKb = await client.query( - "SELECT id FROM muse_knowledge_base WHERE tenant_id=1 AND name='E3 market handoff publisher KB' AND deleted=false ORDER BY id DESC LIMIT 1" - ); - if (handoffPublisherKb.rowCount && handoffPublisherKb.rows[0]) { - handoffPublisherKbId = Number(handoffPublisherKb.rows[0].id); - } else { - const kb = await client.query( - `INSERT INTO muse_knowledge_base - (name, description, kb_type, owner_user_id, status, active_version, command_id, revision, creator, updater, tenant_id) - VALUES ('E3 market handoff publisher KB','市场 KB handoff e2e 发布者侧来源占位','user',2,'active',1, - 'e2e-market-kb-handoff-publisher',1,'2','2',1) - RETURNING id` - ); - handoffPublisherKbId = Number(kb.rows[0].id); - } - const handoffTags = JSON.stringify({ - publicForkDatasetId: handoffPublicDatasetId, - forkStatus: 'ready', - forkAssetVersion: '1.0.0', - sourceOwner: 'knowledge', - sourceType: 'knowledge_base', - actionPolicy: 'allowed', - }); - let handoffKbAssetId: number; - let handoffKbVersionId: number | null = null; - const handoffAsset = await client.query( - "SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false ORDER BY id DESC LIMIT 1", - [MARKET_KB_HANDOFF_ASSET] - ); - if (handoffAsset.rowCount && handoffAsset.rows[0]) { - handoffKbAssetId = Number(handoffAsset.rows[0].id); - handoffKbVersionId = handoffAsset.rows[0].current_version_id == null - ? null - : Number(handoffAsset.rows[0].current_version_id); await client.query( - `UPDATE muse_market_asset - SET description='知识库 handoff e2e 专用资产', - asset_type='knowledge_base', - category='knowledge', - source_id=$2, - publisher_id=2, - listing_status='listed', - license_type='standard', - status='active', - tags=$3::jsonb, - updater='2', - update_time=CURRENT_TIMESTAMP - WHERE tenant_id=1 AND id=$1`, - [handoffKbAssetId, handoffPublisherKbId, handoffTags] + 'DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', + [installE2eAssetId] ); - } else { - const asset = await client.query( - `INSERT INTO muse_market_asset - (name, description, asset_type, category, source_id, publisher_id, listing_status, license_type, status, - tags, creator, updater, tenant_id) - VALUES ($1,'知识库 handoff e2e 专用资产','knowledge_base','knowledge',$2,2,'listed','standard','active', - $3::jsonb,'2','2',1) - RETURNING id`, - [MARKET_KB_HANDOFF_ASSET, handoffPublisherKbId, handoffTags] + // UI 随机 commandId 的 purchase/install 命令记录按 target_id 清(target_id 存资产 id 字符串),避免幂等回放。 + await client.query("DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1", [ + String(installE2eAssetId), + ]); + + // 17) 复位种子资产 asset1「活体市场资产·测试」为上架态(listing_status='listed'、status='active')。 + const asset1Reset = await client.query( + "UPDATE muse_market_asset SET listing_status='listed', status='active' WHERE tenant_id=1 AND id=1 AND deleted=false" ); - handoffKbAssetId = Number(asset.rows[0].id); - } - if (handoffKbVersionId == null) { - const version = await client.query( - `INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id) - VALUES ($1,'1.0.0','知识库 handoff e2e 首发','published','2','2',1) - RETURNING id`, + if (asset1Reset.rowCount === 0) { + console.warn('[e2e globalSetup] ⚠️ 未找到 asset id=1「活体市场资产·测试」(handoff/market 系列正路用例需该种子资产)'); + } + + // 18) handoff-knowledge / market-handoff-precheck:seed 一个专用 listed knowledge_base 资产。 + const MARKET_KB_HANDOFF_ASSET = '活体市场知识库·handoff e2e'; + const handoffDataset = await client.query( + `SELECT kb.id AS kb_id, rb.ragflow_dataset_id + FROM muse_knowledge_ragflow_binding rb + JOIN muse_knowledge_base kb + ON kb.id=rb.kb_id AND kb.tenant_id=rb.tenant_id AND kb.deleted=false + WHERE rb.tenant_id=1 + AND rb.deleted=false + AND rb.document_id IS NULL + AND rb.status='active' + AND rb.ragflow_dataset_id IS NOT NULL + AND kb.name='端到端检索验证KB' + ORDER BY rb.id DESC + LIMIT 1` + ); + if (handoffDataset.rowCount === 0) { + throw new Error('[e2e globalSetup] 缺少稳定 active RAGFlow dataset: 端到端检索验证KB'); + } + const handoffPublicDatasetId = String(handoffDataset.rows[0].ragflow_dataset_id); + let handoffPublisherKbId: number; + const handoffPublisherKb = await client.query( + "SELECT id FROM muse_knowledge_base WHERE tenant_id=1 AND name='E3 market handoff publisher KB' AND deleted=false ORDER BY id DESC LIMIT 1" + ); + if (handoffPublisherKb.rowCount && handoffPublisherKb.rows[0]) { + handoffPublisherKbId = Number(handoffPublisherKb.rows[0].id); + } else { + const kb = await client.query( + `INSERT INTO muse_knowledge_base + (name, description, kb_type, owner_user_id, status, active_version, command_id, revision, creator, updater, tenant_id) + VALUES ('E3 market handoff publisher KB','市场 KB handoff e2e 发布者侧来源占位','user',2,'active',1, + 'e2e-market-kb-handoff-publisher',1,'2','2',1) + RETURNING id` + ); + handoffPublisherKbId = Number(kb.rows[0].id); + } + const handoffTags = JSON.stringify({ + publicForkDatasetId: handoffPublicDatasetId, + forkStatus: 'ready', + forkAssetVersion: '1.0.0', + sourceOwner: 'knowledge', + sourceType: 'knowledge_base', + actionPolicy: 'allowed', + }); + let handoffKbAssetId: number; + let handoffKbVersionId: number | null = null; + const handoffAsset = await client.query( + "SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false ORDER BY id DESC LIMIT 1", + [MARKET_KB_HANDOFF_ASSET] + ); + if (handoffAsset.rowCount && handoffAsset.rows[0]) { + handoffKbAssetId = Number(handoffAsset.rows[0].id); + handoffKbVersionId = handoffAsset.rows[0].current_version_id == null + ? null + : Number(handoffAsset.rows[0].current_version_id); + await client.query( + `UPDATE muse_market_asset + SET description='知识库 handoff e2e 专用资产', + asset_type='knowledge_base', + category='knowledge', + source_id=$2, + publisher_id=2, + listing_status='listed', + license_type='standard', + status='active', + tags=$3::jsonb, + updater='2', + update_time=CURRENT_TIMESTAMP + WHERE tenant_id=1 AND id=$1`, + [handoffKbAssetId, handoffPublisherKbId, handoffTags] + ); + } else { + const asset = await client.query( + `INSERT INTO muse_market_asset + (name, description, asset_type, category, source_id, publisher_id, listing_status, license_type, status, + tags, creator, updater, tenant_id) + VALUES ($1,'知识库 handoff e2e 专用资产','knowledge_base','knowledge',$2,2,'listed','standard','active', + $3::jsonb,'2','2',1) + RETURNING id`, + [MARKET_KB_HANDOFF_ASSET, handoffPublisherKbId, handoffTags] + ); + handoffKbAssetId = Number(asset.rows[0].id); + } + if (handoffKbVersionId == null) { + const version = await client.query( + `INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id) + VALUES ($1,'1.0.0','知识库 handoff e2e 首发','published','2','2',1) + RETURNING id`, + [handoffKbAssetId] + ); + handoffKbVersionId = Number(version.rows[0].id); + await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE tenant_id=1 AND id=$2', [ + handoffKbVersionId, + handoffKbAssetId, + ]); + } else { + await client.query( + `UPDATE muse_market_asset_version + SET version='1.0.0', + change_note='知识库 handoff e2e 首发', + status='published', + updater='2', + update_time=CURRENT_TIMESTAMP + WHERE tenant_id=1 AND id=$1`, + [handoffKbVersionId] + ); + } + // 复位 user1 对该知识资产的消费态,确保 purchase→bind-precheck→handoff→knowledge bind 每轮从干净起点执行。 + const handoffSourceSnapshotId = `source-market_kb-${handoffKbAssetId}-v1`; + await client.query( + 'DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id=$1', + [handoffSourceSnapshotId] + ); + await client.query( + 'DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id=$1', + [handoffSourceSnapshotId] + ); + const handoffInstalledKbs = await client.query( + `SELECT id FROM muse_knowledge_base + WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=$1 AND kb_type='installed_ref' AND deleted=false`, [handoffKbAssetId] ); - handoffKbVersionId = Number(version.rows[0].id); - await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE tenant_id=1 AND id=$2', [ - handoffKbVersionId, + for (const row of handoffInstalledKbs.rows) { + const kbId = Number(row.id); + await client.query('DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND kb_id=$1', [kbId]); + await client.query('DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]); + await client.query('DELETE FROM muse_knowledge_ragflow_call WHERE tenant_id=1 AND kb_id=$1', [kbId]); + await client.query('DELETE FROM muse_knowledge_processing_task WHERE tenant_id=1 AND kb_id=$1', [kbId]); + await client.query('DELETE FROM muse_knowledge_ragflow_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]); + await client.query( + `DELETE FROM muse_knowledge_document_version + WHERE tenant_id=1 + AND document_id IN (SELECT id FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1)`, + [kbId] + ); + await client.query('DELETE FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1', [kbId]); + await client.query('DELETE FROM muse_knowledge_base WHERE tenant_id=1 AND id=$1', [kbId]); + } + await client.query( + 'DELETE FROM muse_knowledge_bind_precheck WHERE tenant_id=1 AND owner_user_id=1 AND work_id=4 AND source_snapshot_id=$1', + [handoffSourceSnapshotId] + ); + await client.query('DELETE FROM muse_market_handoff_event WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [ handoffKbAssetId, ]); - } else { - await client.query( - `UPDATE muse_market_asset_version - SET version='1.0.0', - change_note='知识库 handoff e2e 首发', - status='published', - updater='2', - update_time=CURRENT_TIMESTAMP - WHERE tenant_id=1 AND id=$1`, - [handoffKbVersionId] - ); + await client.query('DELETE FROM muse_market_authorization_summary WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [ + handoffKbAssetId, + ]); + await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [ + handoffKbAssetId, + ]); + await client.query('DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [ + handoffKbAssetId, + ]); + await client.query('DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1', [handoffKbAssetId]); + marketFixtureDetails = `market-install assetId=${installE2eAssetId}/asset1-listed/market-kb-handoff assetId=${handoffKbAssetId}/handoff installed_ref KB ${handoffInstalledKbs.rowCount ?? 0} 条`; } - // 复位 user1 对该知识资产的消费态,确保 purchase→bind-precheck→handoff→knowledge bind 每轮从干净起点执行。 - const handoffSourceSnapshotId = `source-market_kb-${handoffKbAssetId}-v1`; - await client.query( - 'DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id=$1', - [handoffSourceSnapshotId] - ); - await client.query( - 'DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND work_id=4 AND source_snapshot_id=$1', - [handoffSourceSnapshotId] - ); - const handoffInstalledKbs = await client.query( - `SELECT id FROM muse_knowledge_base - WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=$1 AND kb_type='installed_ref' AND deleted=false`, - [handoffKbAssetId] - ); - for (const row of handoffInstalledKbs.rows) { - const kbId = Number(row.id); - await client.query('DELETE FROM muse_knowledge_source_binding_projection WHERE tenant_id=1 AND kb_id=$1', [kbId]); - await client.query('DELETE FROM muse_knowledge_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]); - await client.query('DELETE FROM muse_knowledge_ragflow_call WHERE tenant_id=1 AND kb_id=$1', [kbId]); - await client.query('DELETE FROM muse_knowledge_processing_task WHERE tenant_id=1 AND kb_id=$1', [kbId]); - await client.query('DELETE FROM muse_knowledge_ragflow_binding WHERE tenant_id=1 AND kb_id=$1', [kbId]); - await client.query( - `DELETE FROM muse_knowledge_document_version - WHERE tenant_id=1 - AND document_id IN (SELECT id FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1)`, - [kbId] - ); - await client.query('DELETE FROM muse_knowledge_document WHERE tenant_id=1 AND kb_id=$1', [kbId]); - await client.query('DELETE FROM muse_knowledge_base WHERE tenant_id=1 AND id=$1', [kbId]); - } - await client.query( - 'DELETE FROM muse_knowledge_bind_precheck WHERE tenant_id=1 AND owner_user_id=1 AND work_id=4 AND source_snapshot_id=$1', - [handoffSourceSnapshotId] - ); - await client.query('DELETE FROM muse_market_handoff_event WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [ - handoffKbAssetId, - ]); - await client.query('DELETE FROM muse_market_authorization_summary WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [ - handoffKbAssetId, - ]); - await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [ - handoffKbAssetId, - ]); - await client.query('DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [ - handoffKbAssetId, - ]); - await client.query('DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1', [handoffKbAssetId]); // 19) 清理 work1/work2 除 writing.continuation 外的多余 active 槽位(去污)。 // WHY:agent-slot-bind.spec 假设 work2 仅一个开放槽位(writing.continuation,§15b 每轮复位),据此用 @@ -736,7 +735,7 @@ async function globalSetup(): Promise { ); const uploadKbId = Number(uploadKb.rows[0].id); - console.log(`[e2e globalSetup] 已复位每轮 fixture(ai-quota/graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block/kb-status/installed-kb/handoff-bind/handoff-agent/handoff-content/agent-slot-bind/market-install assetId=${installE2eAssetId}/asset1-listed/market-kb-handoff assetId=${handoffKbAssetId}/work-slot-dedup/e3 noDatasetKbId=${noDatasetKbId} uploadKbId=${uploadKbId});清理 e2e 累积 agent ${agentCleanup.rowCount} 条、work1/work2 多余槽位 ${workSlotCleanup.rowCount} 条、handoff installed_ref KB ${handoffInstalledKbs.rowCount ?? 0} 条、E3 no_dataset KB ${cleanedNoDatasetKb} 条、E3 upload KB ${cleanedUploadKb} 条`); + console.log(`[e2e globalSetup] 已复位每轮 fixture(ai-quota/graph/confirm-draft/accept/security-ack/binding/meta-schema/block/kb-status/installed-kb/agent-slot-bind/work-slot-dedup/e3 noDatasetKbId=${noDatasetKbId} uploadKbId=${uploadKbId}/${marketFixtureDetails});清理 e2e 累积 agent ${agentCleanup.rowCount} 条、work1/work2 多余槽位 ${workSlotCleanup.rowCount} 条、E3 no_dataset KB ${cleanedNoDatasetKb} 条、E3 upload KB ${cleanedUploadKb} 条`); } finally { await client.end(); } diff --git a/muse-studio/e2e/handoff-agent.spec.ts b/muse-studio/e2e/handoff-agent.spec.ts index 30ec51dc..c2bea3bd 100644 --- a/muse-studio/e2e/handoff-agent.spec.ts +++ b/muse-studio/e2e/handoff-agent.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from '@playwright/test'; import { Client } from 'pg'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 跨空间 handoff → agent 物化兑现端到端 e2e(真实后端,反假绿)。 * diff --git a/muse-studio/e2e/handoff-content.spec.ts b/muse-studio/e2e/handoff-content.spec.ts index 92d6bd22..dc7a0df5 100644 --- a/muse-studio/e2e/handoff-content.spec.ts +++ b/muse-studio/e2e/handoff-content.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 跨空间 handoff → content(asset_use)兑现端到端 e2e(真实后端,反假绿)。 * diff --git a/muse-studio/e2e/handoff-knowledge.spec.ts b/muse-studio/e2e/handoff-knowledge.spec.ts index 4ad31f5c..963d6e54 100644 --- a/muse-studio/e2e/handoff-knowledge.spec.ts +++ b/muse-studio/e2e/handoff-knowledge.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page } from '@playwright/test'; import { Client } from 'pg'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 跨空间 handoff → knowledge 兑现端到端 e2e(真实后端,MSW off,反假绿)。 * diff --git a/muse-studio/e2e/installed-knowledge-disable-restore.spec.ts b/muse-studio/e2e/installed-knowledge-disable-restore.spec.ts index c40ca1a2..1c42dc79 100644 --- a/muse-studio/e2e/installed-knowledge-disable-restore.spec.ts +++ b/muse-studio/e2e/installed-knowledge-disable-restore.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 已安装知识库停用/恢复端到端(真实后端,MSW off)。 * diff --git a/muse-studio/e2e/knowledge-publish-precheck.spec.ts b/muse-studio/e2e/knowledge-publish-precheck.spec.ts index 6bcd63f0..f2441bff 100644 --- a/muse-studio/e2e/knowledge-publish-precheck.spec.ts +++ b/muse-studio/e2e/knowledge-publish-precheck.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 知识库「发布到市场」可发布性预检 e2e(诚实处理 blocked,真实后端,MSW off)。 * diff --git a/muse-studio/e2e/live-read.spec.ts b/muse-studio/e2e/live-read.spec.ts index 12b45514..8f4c3c6c 100644 --- a/muse-studio/e2e/live-read.spec.ts +++ b/muse-studio/e2e/live-read.spec.ts @@ -5,8 +5,7 @@ import { expect, test, type Page } from '@playwright/test'; * * 前置:vite 以 VITE_API_MOCK=false 运行(关 MSW)、真实 muse-server 在 48080、muse_slice_live 已种子。 * 目的:验证 rendered studio 经「client.ts 注入 tenant-id 修复」后的 HTTP 客户端,能直连活体单体并把 - * content(作品列表)/market(资产浏览)/account(个人中心)三个 BC 的真实后端数据在 UI 正确渲染 - * ——即真实数据形态与页面渲染契约对齐(MSW mock 形态之外的活体校验)。 + * content(作品列表)/account(个人资料)/ai(智能体列表)的真实后端数据在 UI 正确渲染。 */ const TOKEN = 'test1'; @@ -25,7 +24,7 @@ test.describe('活体只读旅程(真实后端,MSW off)', () => { await expect(page.getByText('活体验证样书').first()).toBeVisible({ timeout: 15_000 }); }); - test('市场浏览渲染真实资产(market)', async ({ page }) => { + test.skip('市场浏览渲染真实资产(market)', async ({ page }) => { await seedToken(page); await page.goto('/market'); // .first():推荐位与资产列表可能同时渲染同一资产(推荐区新增),strict 模式需唯一匹配;渲染即证真实市场数据已加载。 @@ -44,14 +43,14 @@ test.describe('活体只读旅程(真实后端,MSW off)', () => { await expect(page.getByText('活体测试智能体').first()).toBeVisible({ timeout: 15_000 }); }); - test('个人中心渲染真实购买/授权记录(account 深页)', async ({ page }) => { + test.skip('个人中心渲染真实购买/授权记录(account 深页)', async ({ page }) => { await seedToken(page); await page.goto('/account'); // 个人中心「我的购买 / 我的授权」渲染 market→account 投影的真实资产(后端经 market 跨模块 Facade 提供,curl 已证)。 await expect(page.getByText('活体市场资产·测试').first()).toBeVisible({ timeout: 15_000 }); }); - test('个人中心渲染真实安全事件(account 深页)', async ({ page }) => { + test.skip('个人中心渲染真实安全事件(account 深页)', async ({ page }) => { await seedToken(page); await page.goto('/account'); // 个人中心「安全事件」区渲染 GET /account/security-events 的真实摘要(按登录用户隔离;种子见 /tmp/SeedSec.java,curl 已证)。 diff --git a/muse-studio/e2e/market-asset-detail.spec.ts b/muse-studio/e2e/market-asset-detail.spec.ts index 673ec027..59dce295 100644 --- a/muse-studio/e2e/market-asset-detail.spec.ts +++ b/muse-studio/e2e/market-asset-detail.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 市场资产详情页 e2e(消费端深化,真实后端,MSW off)。 * diff --git a/muse-studio/e2e/market-governance-impact.spec.ts b/muse-studio/e2e/market-governance-impact.spec.ts index e391c29a..ab79d161 100644 --- a/muse-studio/e2e/market-governance-impact.spec.ts +++ b/muse-studio/e2e/market-governance-impact.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 市场资产治理影响 e2e(真实后端,MSW off)——A 类:后端 getGovernanceImpact 就绪、studio 此前 0 消费(C14 读端缺口)。 * diff --git a/muse-studio/e2e/market-handoff-precheck.spec.ts b/muse-studio/e2e/market-handoff-precheck.spec.ts index 67ca96d5..232d82c3 100644 --- a/muse-studio/e2e/market-handoff-precheck.spec.ts +++ b/muse-studio/e2e/market-handoff-precheck.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page } from '@playwright/test'; import { Client } from 'pg'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 市场资产「绑定到作品授权预检」e2e(来源侧 handoff 预检,真实后端,MSW off)。 * diff --git a/muse-studio/e2e/market-install-kb-retrieval.spec.ts b/muse-studio/e2e/market-install-kb-retrieval.spec.ts index 76e35cf3..056338af 100644 --- a/muse-studio/e2e/market-install-kb-retrieval.spec.ts +++ b/muse-studio/e2e/market-install-kb-retrieval.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page } from '@playwright/test'; import { Client } from 'pg'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * D0-fork(临时-04 U-verify)市场知识库安装 → 物化 installed_ref → 检索打通 studio e2e(真实后端,MSW off)。 * diff --git a/muse-studio/e2e/market-install.spec.ts b/muse-studio/e2e/market-install.spec.ts index ca157d93..0d5678da 100644 --- a/muse-studio/e2e/market-install.spec.ts +++ b/muse-studio/e2e/market-install.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page } from '@playwright/test'; import { Client } from 'pg'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 市场资产安装 UI e2e(真实后端,MSW off,反假绿)。 * diff --git a/muse-studio/e2e/market-kb-recall.spec.ts b/muse-studio/e2e/market-kb-recall.spec.ts index b748cdc5..f99ba7f9 100644 --- a/muse-studio/e2e/market-kb-recall.spec.ts +++ b/muse-studio/e2e/market-kb-recall.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from '@playwright/test'; import { Client } from 'pg'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * E4 Market KB 召回触达 Knowledge 物化副本 e2e(真实后端 + 真 PG)。 * diff --git a/muse-studio/e2e/market-publish.spec.ts b/muse-studio/e2e/market-publish.spec.ts index 8b567e96..014bdf29 100644 --- a/muse-studio/e2e/market-publish.spec.ts +++ b/muse-studio/e2e/market-publish.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 市场生产侧发布草稿 e2e(真实后端,MSW off)。 * diff --git a/muse-studio/e2e/market-recommendations.spec.ts b/muse-studio/e2e/market-recommendations.spec.ts index 80db1389..af2d24be 100644 --- a/muse-studio/e2e/market-recommendations.spec.ts +++ b/muse-studio/e2e/market-recommendations.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; +test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); + /** * 市场推荐位 e2e(真实后端,MSW off)——A 类切片:后端 GET /marketplace/recommendations 就绪、此前 studio 零调用。 * diff --git a/muse-studio/e2e/solo-routing.spec.ts b/muse-studio/e2e/solo-routing.spec.ts new file mode 100644 index 00000000..4bed1556 --- /dev/null +++ b/muse-studio/e2e/solo-routing.spec.ts @@ -0,0 +1,22 @@ +import { expect, test, type Page } from '@playwright/test'; + +const TOKEN = 'test1'; + +async function seedToken(page: Page): Promise { + await page.addInitScript((t) => { + window.localStorage.setItem('accessToken', t); + window.localStorage.setItem('tenantId', '1'); + }, TOKEN); +} + +test.describe('S5 单人版入口收口', () => { + test('手输 market / handoff 路径应重定向到作品首页', async ({ page }) => { + await seedToken(page); + + await page.goto('/market'); + await expect(page).toHaveURL('http://127.0.0.1:5173/'); + + await page.goto('/handoff/land/knowledge'); + await expect(page).toHaveURL('http://127.0.0.1:5173/'); + }); +}); diff --git a/muse-studio/src/app/routes/index.tsx b/muse-studio/src/app/routes/index.tsx index b0d3dd3e..46cf7732 100644 --- a/muse-studio/src/app/routes/index.tsx +++ b/muse-studio/src/app/routes/index.tsx @@ -1,18 +1,12 @@ -import { createBrowserRouter } from 'react-router-dom'; +import { createBrowserRouter, Navigate } from 'react-router-dom'; import AuthGuard from '@/app/routes/AuthGuard'; import AppLayout from '@/components/layout/AppLayout'; import WorkspacePage from '@/pages/WorkspacePage'; import WorkListPage from '@/features/editor/components/WorkListPage'; -import EditorPage from '@/pages/EditorPage'; import KnowledgePage from '@/pages/KnowledgePage'; import AgentPage from '@/pages/AgentPage'; -import MarketPage from '@/pages/MarketPage'; -import MarketAssetDetailPage from '@/pages/MarketAssetDetailPage'; -import MarketPublishPage from '@/pages/MarketPublishPage'; -import MarketAppealsPage from '@/pages/MarketAppealsPage'; import AccountPage from '@/pages/AccountPage'; import LoginPage from '@/pages/LoginPage'; -import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage'; /** * 顶级路由配置 @@ -32,16 +26,11 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: 'workspace/:workId', element: }, - { path: 'works/:workId/editor/:chapterId', element: }, { path: 'knowledge', element: }, { path: 'knowledge/:workId', element: }, { path: 'agents', element: }, - { path: 'market', element: }, - { path: 'market/assets/:assetId', element: }, - { path: 'market/publish', element: }, - { path: 'market/appeals', element: }, { path: 'account', element: }, - { path: 'handoff/land/:targetOwner', element: }, + { path: '*', element: }, ], }, ], diff --git a/muse-studio/src/app/routes/routes.solo.test.ts b/muse-studio/src/app/routes/routes.solo.test.ts new file mode 100644 index 00000000..7cfeffe1 --- /dev/null +++ b/muse-studio/src/app/routes/routes.solo.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { router } from './index'; + +function collectPaths(routes: typeof router.routes): string[] { + return routes.flatMap((route) => [ + route.path ?? '', + ...collectPaths(route.children ?? []), + ]); +} + +describe('studio 单人版路由裁剪', () => { + it('不再注册 market / handoff / 旧 EditorPage 路由', () => { + const paths = collectPaths(router.routes); + + expect(paths).toContain('knowledge'); + expect(paths).toContain('agents'); + expect(paths).toContain('account'); + expect(paths).not.toContain('market'); + expect(paths).not.toContain('market/assets/:assetId'); + expect(paths).not.toContain('market/publish'); + expect(paths).not.toContain('market/appeals'); + expect(paths).not.toContain('handoff/land/:targetOwner'); + expect(paths).not.toContain('works/:workId/editor/:chapterId'); + }); +}); diff --git a/muse-studio/src/components/layout/Sidebar.solo.test.tsx b/muse-studio/src/components/layout/Sidebar.solo.test.tsx new file mode 100644 index 00000000..25338d0e --- /dev/null +++ b/muse-studio/src/components/layout/Sidebar.solo.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import Sidebar from './Sidebar'; + +vi.mock('@/features/auth/useAuth', () => ({ + logoutLocally: vi.fn(), +})); + +describe('Sidebar 单人版导航', () => { + it('保留创作主线入口并隐藏市场治理入口', () => { + render( + + + , + ); + + expect(screen.getByText('作品')).toBeInTheDocument(); + expect(screen.getByText('知识库')).toBeInTheDocument(); + expect(screen.getByText('智能体')).toBeInTheDocument(); + expect(screen.getByText('个人')).toBeInTheDocument(); + expect(screen.queryByText('市场')).not.toBeInTheDocument(); + expect(screen.queryByText('治理')).not.toBeInTheDocument(); + }); +}); diff --git a/muse-studio/src/components/layout/Sidebar.tsx b/muse-studio/src/components/layout/Sidebar.tsx index e7f7b8ae..9262cb86 100644 --- a/muse-studio/src/components/layout/Sidebar.tsx +++ b/muse-studio/src/components/layout/Sidebar.tsx @@ -15,8 +15,6 @@ const navItems: NavItem[] = [ { to: '/', label: '作品', icon: '📝' }, { to: '/knowledge', label: '知识库', icon: '📚' }, { to: '/agents', label: '智能体', icon: '🤖' }, - { to: '/market', label: '市场', icon: '🏪' }, - { to: '/market/appeals', label: '治理', icon: '⚖️' }, { to: '/account', label: '个人', icon: '👤' }, ]; diff --git a/muse-studio/src/features/account/components/PersonalCenter.solo.test.tsx b/muse-studio/src/features/account/components/PersonalCenter.solo.test.tsx new file mode 100644 index 00000000..0349f61e --- /dev/null +++ b/muse-studio/src/features/account/components/PersonalCenter.solo.test.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import PersonalCenter from './PersonalCenter'; + +function renderPersonalCenter() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + render( + + + , + ); +} + +describe('PersonalCenter 单人版裁剪', () => { + it('只保留资料与用量观测,不暴露多用户和 New-API 入口', async () => { + renderPersonalCenter(); + + expect(await screen.findByText('账户总览')).toBeInTheDocument(); + expect(screen.getByText('保存资料')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '我的购买' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '我的授权' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '我的发布' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '安全事件' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'New-API 绑定' })).not.toBeInTheDocument(); + }); +}); diff --git a/muse-studio/src/features/account/components/PersonalCenter.tsx b/muse-studio/src/features/account/components/PersonalCenter.tsx index 54c600f0..38a2d960 100644 --- a/muse-studio/src/features/account/components/PersonalCenter.tsx +++ b/muse-studio/src/features/account/components/PersonalCenter.tsx @@ -1,65 +1,20 @@ import { useState } from 'react'; import { - type SecurityEventAction, useAccountEntitlements, - useAccountLicenses, - useAccountNewApiBinding, useAccountProfile, - useAccountPublishRecords, - useAccountPurchases, - useAccountSecurityEvents, useAccountUsage, - useAcknowledgeSecurityEvent, useUpdateAccountProfile, } from '../hooks/useAccount'; import UsageStats from './UsageStats'; -/** 安全事件严重程度 → 徽标配色(critical 高危红 / warning 警示橙 / info 中性)。 */ -function severityBadgeClass(severity: string): string { - if (severity === 'critical') return 'bg-rose-50 text-rose-700'; - if (severity === 'warning') return 'bg-amber-50 text-amber-700'; - return 'bg-slate-100 text-slate-500'; -} - -const securityActionLabels: Record = { - acknowledged: '知晓', - password_changed: '已改密', - session_revoked: '撤销会话', - false_positive: '误报', -}; - -/** 安全事件处理动作按钮,严格限于后端契约允许的 action 枚举。 */ -const securityActions: SecurityEventAction[] = ['acknowledged', 'password_changed', 'session_revoked', 'false_positive']; - -/** New-API 绑定状态 → 中文文案。 */ -const newApiBindingLabels: Record = { - bound: '已绑定', - unbound: '未绑定', - sync_failed: '同步失败', - pending: '同步中', -}; -/** New-API 绑定状态 → 徽标配色(bound 绿 / sync_failed 红 / 其余中性)。 */ -function newApiBindingBadgeClass(status: string): string { - if (status === 'bound') return 'bg-emerald-50 text-emerald-700'; - if (status === 'sync_failed') return 'bg-rose-50 text-rose-700'; - return 'bg-slate-100 text-slate-500'; -} - -/** 个人中心首页,聚合资料、用量、权益和账户状态。 */ +/** 个人中心首页,单人版仅保留资料与本地用量观测。 */ export default function PersonalCenter() { const { data: profile, isLoading: isProfileLoading } = useAccountProfile(); const { data: entitlements } = useAccountEntitlements(); const { data: usage } = useAccountUsage('month'); - const { data: purchases } = useAccountPurchases(); - const { data: licenses } = useAccountLicenses(); - const { data: publishRecords } = useAccountPublishRecords(); - const { data: securityEvents } = useAccountSecurityEvents(); - const { data: newApiBinding } = useAccountNewApiBinding(); - const acknowledgeEvent = useAcknowledgeSecurityEvent(); const updateProfile = useUpdateAccountProfile(); const [nickname, setNickname] = useState(''); const [publicPenName, setPublicPenName] = useState(''); - const [securityActionResult, setSecurityActionResult] = useState(null); const activeNickname = nickname || profile?.nickname || ''; const activePenName = publicPenName || profile?.publicPenName || ''; @@ -74,23 +29,6 @@ export default function PersonalCenter() { }); }; - const handleSecurityAction = (eventId: string, action: SecurityEventAction) => { - setSecurityActionResult(null); - acknowledgeEvent.mutate( - { - eventId, - action, - note: securityActionLabels[action], - }, - { - onSuccess: (result) => { - const nextSteps = result.nextSteps?.length ? ` 后续:${result.nextSteps.join('、')}` : ''; - setSecurityActionResult(`${securityActionLabels[action]}已记录。${result.riskSummary ?? ''}${nextSteps}`); - }, - } - ); - }; - return (
@@ -154,178 +92,6 @@ export default function PersonalCenter() {
- -
-
-

我的购买

- {(purchases?.list?.length ?? 0) === 0 ? ( -

暂无购买记录

- ) : ( -
    - {purchases?.list?.map((rec) => ( -
  • -
    -
    - - {rec.assetType} - - {rec.assetName} -
    -
    {rec.authorizationResultSummary || rec.status}
    -
    - - {rec.status} - -
  • - ))} -
- )} -
- -
-

我的授权

- {(licenses?.list?.length ?? 0) === 0 ? ( -

暂无授权记录

- ) : ( -
    - {licenses?.list?.map((rec) => ( -
  • -
    -
    - - {rec.assetType} - - {rec.assetName} -
    -
    - {rec.installSummary || rec.licenseStatus}{rec.publisherName ? ` · ${rec.publisherName}` : ''} -
    -
    - - {rec.licenseStatus} - -
  • - ))} -
- )} -
-
- -
-

我的发布

- {(publishRecords?.list?.length ?? 0) === 0 ? ( -

暂无发布记录

- ) : ( -
    - {publishRecords?.list?.map((rec) => ( -
  • -
    -
    - - {rec.assetType} - - {rec.assetName} -
    -
    - 市场状态:{rec.marketStatus ?? '—'} - {rec.blockingReason ? ` · 阻塞:${rec.blockingReason}` : ''} -
    -
    - - {rec.reviewStatus} - -
  • - ))} -
- )} -
- -
-

安全事件

- {securityActionResult && ( -

- {securityActionResult} -

- )} - {acknowledgeEvent.isError && ( -

- 处理失败:{acknowledgeEvent.error.message} -

- )} - {(securityEvents?.list?.length ?? 0) === 0 ? ( -

暂无安全事件

- ) : ( -
    - {securityEvents?.list?.map((evt) => ( -
  • -
    -
    - - {evt.eventType} - - - {evt.severity} - -
    -
    - {evt.description || evt.eventType} -
    -
    -
    - {!evt.acknowledged && securityActions.map((action) => ( - - ))} - - {evt.acknowledged ? '已确认' : '待确认'} - -
    -
  • - ))} -
- )} -
- - {/* New-API 绑定摘要:展示配额/计费外部账户绑定态(bound/unbound/sync_failed/pending)。后端就绪、此前 0 消费。 */} -
-

New-API 绑定

- {!newApiBinding ? ( -

暂无绑定信息

- ) : ( -
- - {newApiBindingLabels[newApiBinding.bindingStatus] ?? newApiBinding.bindingStatus} - - {newApiBinding.lastSyncAt && ( - 上次同步:{newApiBinding.lastSyncAt} - )} -
- )} -
); diff --git a/muse-studio/src/features/handoff/owners/AgentHandoffLanding.test.tsx b/muse-studio/src/features/handoff/owners/AgentHandoffLanding.test.tsx deleted file mode 100644 index b43c8fb5..00000000 --- a/muse-studio/src/features/handoff/owners/AgentHandoffLanding.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { http, HttpResponse } from 'msw'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; -import { server } from '@/test/setup'; -import { useHandoffStore } from '@/stores/handoffStore'; -import AgentHandoffLanding from './AgentHandoffLanding'; - -const createTestQueryClient = () => - new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); - -// agent 特有参数只保留 slotKey/revision;sourceAgentId 必须由后端按 market asset 解析,不能信 URL。 -function renderLanding(search = '?slotKey=writer') { - const client = createTestQueryClient(); - return render( - - - - - - ); -} - -describe('AgentHandoffLanding 兑现闭环', () => { - beforeEach(() => { - useHandoffStore.getState().clearSession(); - // 预设落地态:已从来源空间带入一次性 token + 上下文 - useHandoffStore.getState().setLanding({ - handoffToken: 'handoff_agent_token', - targetOwner: 'agent', - targetAction: 'bind', - assetId: '2', - targetWorkId: '4', - returnUrl: '/market', - }); - server.use( - http.post('/app-api/muse/works/:workId/agent-slots/:slotKey/prechecks', () => - HttpResponse.json({ - code: 0, - msg: 'success', - data: { agentSlotPrecheckId: 'agent-precheck-1', expiresAt: new Date(Date.now() + 300000).toISOString() }, - }) - ), - http.post('/app-api/muse/works/:workId/agent-slots/:slotKey/bind', () => - HttpResponse.json({ code: 0, msg: 'success', data: { slotRevision: 4, sourceStatus: 'active' } }) - ) - ); - }); - - it('就绪态渲染确认页', () => { - renderLanding(); - expect(screen.getByText('确认绑定市场智能体')).toBeTruthy(); - expect(screen.getByRole('button', { name: /确认绑定/ })).toBeTruthy(); - }); - - it('点确认走完 agentSlotPrecheck→bindAgentSlot 闭环,落地完成、会话清空', async () => { - renderLanding(); - fireEvent.click(screen.getByRole('button', { name: /确认绑定/ })); - // 闭环成功 → 绑定完成 - await waitFor(() => expect(screen.getByText('绑定完成')).toBeTruthy()); - // 兑现完成后会话清空(token 一次性、用完即弃) - expect(useHandoffStore.getState().handoffToken).toBeNull(); - expect(useHandoffStore.getState().precheckId).toBeNull(); - }); - - it('后端核验拒绝(伪造/过期 token)→ 失败提示,不落绑定', async () => { - // 覆盖 precheck mock,模拟后端 verify 拒绝(token 不可用 → AI_MARKET_HANDOFF_UNAVAILABLE) - server.use( - http.post('/app-api/muse/works/:workId/agent-slots/:slotKey/prechecks', () => - HttpResponse.json({ code: 1_040_100_024, msg: 'AI Market handoff 凭据不可用', data: null }, { status: 400 }) - ) - ); - renderLanding(); - fireEvent.click(screen.getByRole('button', { name: /确认绑定/ })); - await waitFor(() => expect(screen.getByText(/Market handoff 凭据不可用/)).toBeTruthy()); - expect(screen.queryByText('绑定完成')).toBeNull(); - }); - - it('会话缺凭据(无 token)→ 失效提示', () => { - useHandoffStore.getState().clearSession(); - renderLanding(); - expect(screen.getByText(/会话已失效或缺少凭据/)).toBeTruthy(); - }); -}); diff --git a/muse-studio/src/features/handoff/owners/AgentHandoffLanding.tsx b/muse-studio/src/features/handoff/owners/AgentHandoffLanding.tsx deleted file mode 100644 index eae6662c..00000000 --- a/muse-studio/src/features/handoff/owners/AgentHandoffLanding.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useHandoffStore } from '@/stores/handoffStore'; -import { useAgentSlotPrecheck, useBindAgentSlot } from './useAgentHandoff'; - -/** - * agent 目标空间 handoff 落地组件(P2)。 - * - * 兑现闭环(红线"不信客户端 URL 参数"): - * 1. agent 槽位预检(带 handoffToken + sourceType=market_agent):后端核验 token,按 assetId 解析发布者 agent - * 2. setPrecheck(token 用完即弃)→ bindAgentSlot(只消费 precheckId):后端物化安装者本地 user agent 并绑定槽位 - */ -export default function AgentHandoffLanding() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { handoffToken, precheckId, assetId, targetWorkId, returnUrl, setPrecheck, clearSession } = useHandoffStore(); - const agentSlotPrecheck = useAgentSlotPrecheck(); - const bindAgentSlot = useBindAgentSlot(); - const [error, setError] = useState(null); - const [done, setDone] = useState(false); - - // agent 特有上下文经 URL 透传(token 已 replaceState 清除,其余 params 保留),不再信任 URL 传入 sourceAgentId。 - const slotKey = searchParams.get('slotKey') ?? ''; - const expectedSlotRevision = searchParams.get('expectedSlotRevision'); - - // 兑现中仍算就绪:setPrecheck 后 handoffToken 已清(用完即弃),但 precheckId 在 → 仍在兑现流程内 - const ready = Boolean((handoffToken || precheckId) && assetId && targetWorkId && slotKey); - const pending = agentSlotPrecheck.isPending || bindAgentSlot.isPending; - - async function confirmBinding() { - if (!handoffToken || !assetId || !targetWorkId || !slotKey) return; - setError(null); - try { - // 1. agent 槽位预检:后端核验 + 核销 handoff token,按 assetId 解析发布者 agent。 - const precheck = await agentSlotPrecheck.mutateAsync({ - workId: targetWorkId, - slotKey, - expectedSlotRevision: expectedSlotRevision ? Number(expectedSlotRevision) : undefined, - handoffToken, - sourceId: assetId, - sourceVersion: 1, - authorizationSummaryId: '', - }); - if (!precheck.agentSlotPrecheckId) { - setError('目标侧预检未返回 precheckId'); - return; - } - setPrecheck(precheck.agentSlotPrecheckId, precheck.expiresAt ?? null); // token 用完即弃 - // 2. 确认绑定:只消费 precheckId(token 已核销),落槽位绑定 - await bindAgentSlot.mutateAsync({ - workId: targetWorkId, - slotKey, - agentSlotPrecheckId: precheck.agentSlotPrecheckId, - expectedSlotRevision: expectedSlotRevision ? Number(expectedSlotRevision) : undefined, - }); - clearSession(); - setDone(true); - } catch (e) { - setError(e instanceof Error ? e.message : '绑定失败,请稍后重试'); - } - } - - if (done) { - return ( -
-

绑定完成

-

市场智能体已绑定到作品 #{targetWorkId} 的槽位 {slotKey}。

- -
- ); - } - - if (!ready) { - return ( -
-

交接会话已失效或缺少凭据,请返回来源页重新发起。

-
- ); - } - - return ( -
-

确认绑定市场智能体

-

- 将市场智能体绑定到作品 #{targetWorkId} 的槽位 {slotKey}。确认后系统会服务端核验交接凭据并完成绑定。 -

- {error && ( -

- {error} -

- )} - -
- ); -} diff --git a/muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx b/muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx deleted file mode 100644 index 68c16a39..00000000 --- a/muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { http, HttpResponse } from 'msw'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; -import { server } from '@/test/setup'; -import { useHandoffStore } from '@/stores/handoffStore'; -import ContentHandoffLanding from './ContentHandoffLanding'; - -const createTestQueryClient = () => - new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); - -function renderLanding() { - const client = createTestQueryClient(); - return render( - - - - - - ); -} - -describe('ContentHandoffLanding 兑现闭环', () => { - beforeEach(() => { - useHandoffStore.getState().clearSession(); - // 预设落地态:已从来源空间带入一次性 token + 上下文 - useHandoffStore.getState().setLanding({ - handoffToken: 'handoff_content_token', - targetOwner: 'content', - targetAction: 'asset_use', - assetId: '2', - targetWorkId: '4', - returnUrl: '/market', - }); - server.use( - // 来源侧 bind-precheck(targetOwner=content):后端返回可信 source - http.post('/app-api/muse/marketplace/assets/:assetId/bind-precheck', () => - HttpResponse.json({ - code: 0, - msg: 'success', - data: { - handoffReady: true, - sourceVersion: 1, - sourceStatus: 'available', - authorizationSummaryId: 45, - authorizationSnapshot: 'snap-1', - }, - }) - ), - // 目标侧 asset_use 预检(核验+核销 token) - http.post('/app-api/muse/works/:workId/asset-use-prechecks', () => - HttpResponse.json({ - code: 0, - msg: 'success', - data: { assetUsePrecheckId: 'content-precheck-1', expiresAt: new Date(Date.now() + 300000).toISOString() }, - }) - ), - // 读作品 revision(create 段乐观锁) - http.get('/app-api/muse/works/:workId', () => - HttpResponse.json({ code: 0, msg: 'success', data: { id: 4, revision: 3 } }) - ), - // 确认使用(凭 precheckId) - http.post('/app-api/muse/works/:workId/asset-uses', () => - HttpResponse.json({ code: 0, msg: 'success', data: { assetUseId: 'content-precheck-1', purposes: ['reference'] } }) - ) - ); - }); - - it('就绪态渲染确认页', () => { - renderLanding(); - expect(screen.getByText('确认使用市场资产')).toBeTruthy(); - expect(screen.getByRole('button', { name: /确认使用/ })).toBeTruthy(); - }); - - it('点确认走完 bind-precheck→assetUsePrecheck→createAssetUse 闭环,完成、会话清空', async () => { - renderLanding(); - fireEvent.click(screen.getByRole('button', { name: /确认使用/ })); - // 闭环成功 → 资产使用已确认 - await waitFor(() => expect(screen.getByText('资产使用已确认')).toBeTruthy()); - // 兑现完成后会话清空(token 一次性、用完即弃) - expect(useHandoffStore.getState().handoffToken).toBeNull(); - expect(useHandoffStore.getState().precheckId).toBeNull(); - }); - - it('后端核验拒绝(伪造/过期 token)→ 失败提示,不落使用', async () => { - // 覆盖 precheck mock,模拟后端 verify 拒绝(token 不可用 → CONTENT_ASSET_HANDOFF_UNAVAILABLE) - server.use( - http.post('/app-api/muse/works/:workId/asset-use-prechecks', () => - HttpResponse.json({ code: 1_041_002_004, msg: '作品资产 Market handoff 凭据不可用', data: null }, { status: 400 }) - ) - ); - renderLanding(); - fireEvent.click(screen.getByRole('button', { name: /确认使用/ })); - await waitFor(() => expect(screen.getByText(/Market handoff 凭据不可用/)).toBeTruthy()); - expect(screen.queryByText('资产使用已确认')).toBeNull(); - }); - - it('会话缺凭据(无 token)→ 失效提示', () => { - useHandoffStore.getState().clearSession(); - renderLanding(); - expect(screen.getByText(/会话已失效或缺少凭据/)).toBeTruthy(); - }); -}); diff --git a/muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx b/muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx deleted file mode 100644 index 1ba0ba1a..00000000 --- a/muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { api } from '@/api/client'; -import { useHandoffStore } from '@/stores/handoffStore'; -import { useAssetUsePrecheck, useCreateAssetUse } from './useContentHandoff'; - -/** 幂等键生成(对齐 handoff 域 createCommandId)。 */ -function createCommandId(): string { - return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -interface BindPrecheckResult { - handoffReady?: boolean; - sourceVersion?: number; - sourceStatus?: string; - authorizationSummaryId?: number | string; - authorizationSnapshot?: string; -} - -/** - * content 目标空间 handoff 落地组件(P3)。 - * - * 兑现闭环(红线"不信客户端 URL 参数"): - * 1. 来源侧 bind-precheck(targetOwner=content):后端查授权返回可信 source(不信客户端自造) - * 2. 目标侧 assetUsePrecheck(带 handoffToken):后端核验 + 核销 token,换 precheckId → setPrecheck(token 用完即弃) - * 3. createAssetUse(只消费 precheckId + work 级乐观锁):落作品资产使用事实 → clearSession → 返回来源 - * - * asset_use 只记录"作品已授权使用市场资产作参考/AI 上下文",不拉内容/不写正文(产品-02F L456)。 - */ -export default function ContentHandoffLanding() { - const navigate = useNavigate(); - const { handoffToken, precheckId, assetId, targetWorkId, returnUrl, setPrecheck, clearSession } = useHandoffStore(); - const assetUsePrecheck = useAssetUsePrecheck(); - const createAssetUse = useCreateAssetUse(); - const [error, setError] = useState(null); - const [done, setDone] = useState(false); - - // 兑现中仍算就绪:setPrecheck 后 handoffToken 已清(用完即弃),但 precheckId 在 → 仍在兑现流程内 - const ready = Boolean((handoffToken || precheckId) && assetId && targetWorkId); - const pending = assetUsePrecheck.isPending || createAssetUse.isPending; - - async function confirmUse() { - if (!handoffToken || !assetId || !targetWorkId) return; - setError(null); - try { - // 1. 来源侧预检(targetOwner=content):后端查授权,返回可信 source 字段 - const src = await api.post(`/marketplace/assets/${assetId}/bind-precheck`, { - commandId: createCommandId(), - targetOwner: 'content', - targetAction: 'asset_use', - targetWorkId: Number(targetWorkId), - }); - if (!src.handoffReady) { - setError('来源授权未就绪,无法使用'); - return; - } - // 2. 目标侧预检:后端核验 + 核销 handoff token,换 assetUsePrecheckId - const pc = await assetUsePrecheck.mutateAsync({ - workId: targetWorkId, - sourceId: assetId, - sourceVersion: src.sourceVersion ?? 1, - sourceStatus: src.sourceStatus ?? 'available', - handoffToken, - authorizationSummaryId: String(src.authorizationSummaryId ?? ''), - authorizationSnapshotId: src.authorizationSnapshot ?? '', - purposes: ['reference'], - }); - if (!pc.assetUsePrecheckId) { - setError('目标侧预检未返回 precheckId'); - return; - } - setPrecheck(pc.assetUsePrecheckId, pc.expiresAt ?? null); // token 用完即弃 - // 3. 确认使用:读作品 revision 防乐观锁覆盖过期状态(真后端详情含 revision、兜底 1),落 asset_use 使用事实 - const work = await api.get<{ revision?: number }>(`/works/${targetWorkId}`); - await createAssetUse.mutateAsync({ - workId: targetWorkId, - assetUsePrecheckId: pc.assetUsePrecheckId, - expectedWorkRevision: typeof work?.revision === 'number' ? work.revision : 1, - }); - clearSession(); - setDone(true); - } catch (e) { - setError(e instanceof Error ? e.message : '使用失败,请稍后重试'); - } - } - - if (done) { - return ( -
-

资产使用已确认

-

- 市场资产已授权用于作品 #{targetWorkId}(参考 / AI 上下文)。 -

- -
- ); - } - - if (!ready) { - return ( -
-

交接会话已失效或缺少凭据,请返回来源页重新发起。

-
- ); - } - - return ( -
-

确认使用市场资产

-

- 将市场资产授权用于作品 #{targetWorkId}(作参考 / AI 上下文)。确认后系统会服务端核验交接凭据并记录使用。 -

- {error && ( -

- {error} -

- )} - -
- ); -} diff --git a/muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.test.tsx b/muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.test.tsx deleted file mode 100644 index a85a1f72..00000000 --- a/muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { http, HttpResponse } from 'msw'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; -import { server } from '@/test/setup'; -import { useHandoffStore } from '@/stores/handoffStore'; -import KnowledgeHandoffLanding from './KnowledgeHandoffLanding'; - -const createTestQueryClient = () => - new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); - -function renderLanding() { - const client = createTestQueryClient(); - return render( - - - - - - ); -} - -describe('KnowledgeHandoffLanding 兑现闭环', () => { - beforeEach(() => { - useHandoffStore.getState().clearSession(); - // 预设落地态:已从来源空间带入一次性 token + 上下文 - useHandoffStore.getState().setLanding({ - handoffToken: 'handoff_test_token', - targetOwner: 'knowledge', - targetAction: 'bind', - assetId: '1', - targetWorkId: '1', - returnUrl: '/knowledge/1', - }); - // work detail mock:落地组件读 revision 防乐观锁覆盖 - server.use( - http.get('/app-api/muse/works/1', () => - HttpResponse.json({ code: 0, msg: 'success', data: { id: '1', revision: 3 } }) - ) - ); - }); - - it('就绪态渲染确认页', () => { - renderLanding(); - expect(screen.getByText('确认绑定市场知识来源')).toBeTruthy(); - expect(screen.getByRole('button', { name: /确认绑定/ })).toBeTruthy(); - }); - - it('点确认走完 bind-precheck→kbBindPrecheck→createBinding 闭环,落地完成、会话清空', async () => { - renderLanding(); - fireEvent.click(screen.getByRole('button', { name: /确认绑定/ })); - // 闭环成功 → 绑定完成 - await waitFor(() => expect(screen.getByText('绑定完成')).toBeTruthy()); - // 兑现完成后会话清空(token 一次性、用完即弃) - expect(useHandoffStore.getState().handoffToken).toBeNull(); - expect(useHandoffStore.getState().precheckId).toBeNull(); - }); - - it('后端核验拒绝(伪造/过期 token)→ 失败提示,不落绑定', async () => { - // 覆盖 precheck mock,模拟后端 verify 拒绝(token 不可用) - server.use( - http.post('/app-api/muse/works/:workId/knowledge-bindings/prechecks', () => - HttpResponse.json({ code: 1_004_002_010, msg: 'Market handoff 不可用', data: null }, { status: 400 }) - ) - ); - renderLanding(); - fireEvent.click(screen.getByRole('button', { name: /确认绑定/ })); - await waitFor(() => expect(screen.getByText(/Market handoff 不可用/)).toBeTruthy()); - expect(screen.queryByText('绑定完成')).toBeNull(); - }); - - it('会话缺凭据(无 token)→ 失效提示', () => { - useHandoffStore.getState().clearSession(); - renderLanding(); - expect(screen.getByText(/会话已失效或缺少凭据/)).toBeTruthy(); - }); -}); diff --git a/muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.tsx b/muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.tsx deleted file mode 100644 index 1a008cf3..00000000 --- a/muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { api } from '@/api/client'; -import { useHandoffStore } from '@/stores/handoffStore'; -import { useBindPrecheck } from '@/features/market/hooks/useMarket'; -import { useKbBindPrecheck, useCreateKnowledgeBinding } from './useKnowledgeHandoff'; - -/** - * knowledge 目标空间 handoff 落地组件(P1)。 - * - * 兑现闭环(红线"不信客户端 URL 参数"): - * 1. 来源侧 bind-precheck:后端查授权返回可信 source(authorizationSummaryId/Snapshot/sourceVersion/sourceStatus) - * 2. 目标侧 kbBindPrecheck(带 handoffToken):后端在此核验 + 核销 token,换 kbBindPrecheckId → setPrecheck(token 用完即弃) - * 3. createKnowledgeBinding(只消费 kbBindPrecheckId):落 knowledge_binding → clearSession → 返回来源 - * - * 全部 source 字段取后端 bind-precheck 返回值(不信客户端自造);跨空间安全由后端 verify/consume 保证。 - */ -export default function KnowledgeHandoffLanding() { - const navigate = useNavigate(); - const { handoffToken, precheckId, assetId, targetWorkId, returnUrl, setPrecheck, clearSession } = useHandoffStore(); - const bindPrecheck = useBindPrecheck(assetId ?? ''); - const kbBindPrecheck = useKbBindPrecheck(); - const createBinding = useCreateKnowledgeBinding(); - const [error, setError] = useState(null); - const [done, setDone] = useState(false); - - // 兑现中仍算就绪:setPrecheck 后 handoffToken 已清(用完即弃),但 precheckId 在 → 仍在兑现流程内,不应退回失效态 - const ready = Boolean((handoffToken || precheckId) && assetId && targetWorkId); - const pending = bindPrecheck.isPending || kbBindPrecheck.isPending || createBinding.isPending; - - async function confirmBinding() { - if (!handoffToken || !assetId || !targetWorkId) return; - setError(null); - try { - // 1. 来源侧预检:后端查授权,返回可信 source 字段(不信客户端自造) - const src = await bindPrecheck.mutateAsync(Number(targetWorkId)); - if (!src.handoffReady) { - setError('来源授权未就绪,无法绑定'); - return; - } - // 2. 目标侧预检:后端在此核验 + 核销 handoff token,换 kbBindPrecheckId - const kb = await kbBindPrecheck.mutateAsync({ - workId: targetWorkId, - sourceId: assetId, - sourceVersion: src.sourceVersion ?? 1, - sourceStatus: src.sourceStatus ?? 'available', - handoffToken, - authorizationSummaryId: String(src.authorizationSummaryId ?? ''), - authorizationSnapshotId: src.authorizationSnapshot ?? '', - }); - if (!kb.kbBindPrecheckId) { - setError('目标侧预检未返回 precheckId'); - return; - } - setPrecheck(kb.kbBindPrecheckId, kb.expiresAt ?? null); // token 用完即弃 - // 3. 确认绑定:读作品 revision 防乐观锁覆盖过期状态(真后端作品详情含 revision、列表 VO 无,兜底 1),落 knowledge_binding - const work = await api.get<{ revision?: number }>(`/works/${targetWorkId}`); - await createBinding.mutateAsync({ - workId: targetWorkId, - kbBindPrecheckId: kb.kbBindPrecheckId, - expectedWorkRevision: typeof work?.revision === 'number' ? work.revision : 1, - }); - clearSession(); - setDone(true); - } catch (e) { - setError(e instanceof Error ? e.message : '绑定失败,请稍后重试'); - } - } - - if (done) { - return ( -
-

绑定完成

-

市场知识来源已绑定到作品 #{targetWorkId}。

- -
- ); - } - - if (!ready) { - return ( -
-

交接会话已失效或缺少凭据,请返回来源页重新发起。

-
- ); - } - - return ( -
-

确认绑定市场知识来源

-

- 将市场资产绑定到作品 #{targetWorkId}。确认后系统会服务端核验交接凭据并完成绑定。 -

- {error && ( -

- {error} -

- )} - -
- ); -} diff --git a/muse-studio/src/features/handoff/owners/useAgentHandoff.ts b/muse-studio/src/features/handoff/owners/useAgentHandoff.ts deleted file mode 100644 index b57c9ff6..00000000 --- a/muse-studio/src/features/handoff/owners/useAgentHandoff.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { api } from '@/api/client'; - -/** 幂等键生成(测试环境降级,对齐 ai 域 createCommandId)。 */ -function createCommandId(): string { - return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -/** - * 目标侧 agent 槽位预检入参(market→agent 跨空间 handoff)。 - * - * 红线:handoffToken 必填——后端在 precheck 阶段服务端核验 + 核销 token(消费换 session),并据 assetId 解析发布者 - * agent,再在 bind 阶段物化为安装者本地 user agent。前端不再传 sourceAgentId,避免信任 URL 来源参数。 - */ -export interface AgentSlotPrecheckInput { - workId: string; - slotKey: string; - expectedSlotRevision?: number | undefined; - handoffToken: string; - sourceId: string; - sourceVersion?: number | undefined; - authorizationSummaryId: string; -} - -export interface AgentSlotPrecheckResult { - agentSlotPrecheckId?: string; - expiresAt?: string; -} - -/** - * 目标侧 agent 槽位预检:POST /works/{workId}/agent-slots/{slotKey}/prechecks。 - * 后端在此核验 + 核销 handoff token(消费换 session),返回 agentSlotPrecheckId(后续绑定唯一可信凭据)。 - */ -export function useAgentSlotPrecheck() { - return useMutation({ - mutationFn: ({ workId, slotKey, ...body }) => - api.post(`/works/${workId}/agent-slots/${slotKey}/prechecks`, { - commandId: createCommandId(), - sourceType: 'market_agent', - ...body, - }), - }); -} - -export interface BindAgentSlotInput { - workId: string; - slotKey: string; - agentSlotPrecheckId: string; - expectedSlotRevision?: number | undefined; -} - -export interface AgentSlotBindResult { - slotRevision?: number; - sourceStatus?: string; -} - -/** - * 确认绑定:POST /works/{workId}/agent-slots/{slotKey}/bind。 - * 只消费 agentSlotPrecheckId(token 已在 precheck 阶段核销),落 agent 槽位绑定事实。 - */ -export function useBindAgentSlot() { - return useMutation({ - mutationFn: ({ workId, slotKey, ...body }) => - api.post(`/works/${workId}/agent-slots/${slotKey}/bind`, { - commandId: createCommandId(), - ...body, - }), - }); -} diff --git a/muse-studio/src/features/handoff/owners/useContentHandoff.ts b/muse-studio/src/features/handoff/owners/useContentHandoff.ts deleted file mode 100644 index b97930cd..00000000 --- a/muse-studio/src/features/handoff/owners/useContentHandoff.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { api } from '@/api/client'; - -/** 幂等键生成(测试环境降级,对齐 ai/content 域 createCommandId)。 */ -function createCommandId(): string { - return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -/** - * 目标侧 content 作品资产使用预检入参(market→content 跨空间 handoff)。 - * - * 红线:handoffToken 必填——后端在 precheck 阶段服务端核验 + 核销 token(消费换 precheckId), - * create 段只凭 precheckId(token 已核销)。asset_use 只记录使用授权事实,不写入正文/参考来源。 - */ -export interface AssetUsePrecheckInput { - workId: string; - sourceId: string; - sourceVersion: number; - sourceStatus: string; - handoffToken: string; - authorizationSummaryId: string; - authorizationSnapshotId: string; - purposes?: string[] | undefined; -} - -export interface AssetUsePrecheckResult { - assetUsePrecheckId?: string; - allowedPurposes?: string[]; - blockedPurposes?: string[]; - blockedReasons?: string[]; - sourceStatus?: string; - expiresAt?: string; -} - -/** - * 作品资产使用预检:POST /works/{workId}/asset-use-prechecks。 - * 后端在此核验 + 核销 handoff token,返回 assetUsePrecheckId(后续 create 唯一可信凭据)。 - */ -export function useAssetUsePrecheck() { - return useMutation({ - mutationFn: ({ workId, ...body }) => - api.post(`/works/${workId}/asset-use-prechecks`, { - commandId: createCommandId(), - sourceType: 'market_asset', - ...body, - }), - }); -} - -export interface CreateAssetUseInput { - workId: string; - assetUsePrecheckId: string; - expectedWorkRevision: number; -} - -export interface AssetUseResult { - assetUseId?: string; - sourceType?: string; - sourceId?: string; - purposes?: string[]; -} - -/** - * 确认作品资产使用:POST /works/{workId}/asset-uses。 - * 只消费 assetUsePrecheckId(token 已在 precheck 核销)+ work 级乐观锁,落使用事实(单表转 consumed)。 - */ -export function useCreateAssetUse() { - return useMutation({ - mutationFn: ({ workId, ...body }) => - api.post(`/works/${workId}/asset-uses`, { - commandId: createCommandId(), - ...body, - }), - }); -} diff --git a/muse-studio/src/features/handoff/owners/useKnowledgeHandoff.ts b/muse-studio/src/features/handoff/owners/useKnowledgeHandoff.ts deleted file mode 100644 index a2169f15..00000000 --- a/muse-studio/src/features/handoff/owners/useKnowledgeHandoff.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { api } from '@/api/client'; - -/** 幂等键生成(测试环境降级,对齐 knowledge 域 createCommandId)。 */ -function createCommandId(): string { - return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -/** - * market_kb 绑定预检入参(目标侧 knowledge 预检)。 - * - * 红线:handoffToken 必填——后端在 precheck 阶段服务端核验 + 核销 token(消费换 session), - * 而非信任本入参里的 source/授权字段。source/授权信息为"实时对比"用途(来源版本快照)。 - */ -export interface KbBindPrecheckInput { - workId: string; - sourceId: string; - sourceVersion: number; - sourceStatus: string; - handoffToken: string; - authorizationSummaryId: string; - authorizationSnapshotId: string; - purposes?: string[]; -} - -export interface KbBindPrecheckResult { - kbBindPrecheckId?: string; - allowedPurposes?: string[]; - blockedPurposes?: string[]; - blockedReasons?: string[]; - sourceStatus?: string; - expiresAt?: string; -} - -/** - * 目标侧 knowledge 绑定预检:POST /works/{workId}/knowledge-bindings/prechecks。 - * 后端在此阶段核验 + 核销 handoff token(消费换 session),返回 kbBindPrecheckId(后续绑定唯一可信凭据)。 - */ -export function useKbBindPrecheck() { - return useMutation({ - mutationFn: ({ workId, ...body }) => - api.post(`/works/${workId}/knowledge-bindings/prechecks`, { - commandId: createCommandId(), - sourceType: 'market_kb', - ...body, - }), - }); -} - -export interface CreateKnowledgeBindingInput { - workId: string; - kbBindPrecheckId: string; - expectedWorkRevision: number; -} - -export interface KnowledgeBindingResult { - bindingId?: string; - sourceType?: string; - sourceId?: string; - purposes?: string[]; - createdAt?: string; -} - -/** - * 确认绑定:POST /works/{workId}/knowledge-bindings。 - * 只消费 kbBindPrecheckId(token 已在 precheck 阶段核销),落 knowledge_binding 事实。 - */ -export function useCreateKnowledgeBinding() { - return useMutation({ - mutationFn: ({ workId, kbBindPrecheckId, expectedWorkRevision }) => - api.post(`/works/${workId}/knowledge-bindings`, { - commandId: createCommandId(), - kbBindPrecheckId, - expectedWorkRevision, - }), - }); -} diff --git a/muse-studio/src/features/handoff/pages/HandoffLandingPage.test.tsx b/muse-studio/src/features/handoff/pages/HandoffLandingPage.test.tsx deleted file mode 100644 index 6fb46bd0..00000000 --- a/muse-studio/src/features/handoff/pages/HandoffLandingPage.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import { createMemoryRouter, RouterProvider } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import HandoffLandingPage from './HandoffLandingPage'; -import { useHandoffStore } from '@/stores/handoffStore'; - -function renderAt(entry: string) { - // knowledge owner 落地组件含 useMutation,需 QueryClientProvider 包裹 - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const router = createMemoryRouter( - [{ path: '/handoff/land/:targetOwner', element: }], - { initialEntries: [entry] } - ); - render( - - - - ); - return router; -} - -describe('HandoffLandingPage 落地 token 卫生', () => { - beforeEach(() => { - useHandoffStore.getState().clearSession(); - }); - - it('落地解析 token 存入 store,并从 URL 移除 token(防重复消费)、保留其余上下文', async () => { - const router = renderAt('/handoff/land/knowledge?token=handoff_xyz&action=bind&assetId=2&targetWorkId=1'); - - await waitFor(() => expect(useHandoffStore.getState().handoffToken).toBe('handoff_xyz')); - expect(useHandoffStore.getState().targetOwner).toBe('knowledge'); - expect(useHandoffStore.getState().targetAction).toBe('bind'); - expect(useHandoffStore.getState().targetWorkId).toBe('1'); - // 红线:token 已从 URL 移除,其余上下文保留 - await waitFor(() => expect(router.state.location.search).not.toContain('token')); - expect(router.state.location.search).toContain('assetId=2'); - }); - - it('无 token 落地展示失效提示', async () => { - renderAt('/handoff/land/knowledge'); - await waitFor(() => expect(screen.getByText(/会话已失效或缺少凭据/)).toBeTruthy()); - }); -}); diff --git a/muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx b/muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx deleted file mode 100644 index a7a96688..00000000 --- a/muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect } from 'react'; -import { useParams, useSearchParams } from 'react-router-dom'; -import { useHandoffStore } from '@/stores/handoffStore'; -import KnowledgeHandoffLanding from '@/features/handoff/owners/KnowledgeHandoffLanding'; -import AgentHandoffLanding from '@/features/handoff/owners/AgentHandoffLanding'; -import ContentHandoffLanding from '@/features/handoff/owners/ContentHandoffLanding'; - -/** - * 跨空间 handoff 目标落地页(P0 基座 + P1 owner 分发)。 - * - * 红线(前端-01 §9):解析 URL ?token → 立即从 URL 移除 token(防刷新重复消费)→ 存 HandoffStore → - * 按 targetOwner 分发到对应 owner 落地组件。knowledge 已接入,agent/content 在 P2/P3。 - */ -export default function HandoffLandingPage() { - const { targetOwner } = useParams<{ targetOwner: string }>(); - const [searchParams, setSearchParams] = useSearchParams(); - const setLanding = useHandoffStore((s) => s.setLanding); - const handoffToken = useHandoffStore((s) => s.handoffToken); - const precheckId = useHandoffStore((s) => s.precheckId); - - useEffect(() => { - const token = searchParams.get('token'); - if (!token) return; - // 落地:存来源上下文 + 一次性 token - setLanding({ - handoffToken: token, - targetOwner: targetOwner ?? '', - targetAction: searchParams.get('action') ?? '', - assetId: searchParams.get('assetId'), - targetWorkId: searchParams.get('targetWorkId'), - returnUrl: searchParams.get('returnUrl'), - }); - // 红线:立即从 URL 移除 token(防刷新重复消费),其余上下文保留。 - // setSearchParams 触发 searchParams 变更使本 effect 再跑,届时 token 已不在 URL → 提前返回,不重复落地。 - const next = new URLSearchParams(searchParams); - next.delete('token'); - setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams, setLanding, targetOwner]); - - // token 仍在 URL → useEffect 即将存 store 并清除 token,先显示过渡态(避免误判失效) - if (searchParams.get('token')) { - return ( -
-

正在进入目标空间…

-
- ); - } - - // 按 targetOwner 分发到对应 owner 落地组件;owner 组件自管兑现/成功/失败/跳转 - if (targetOwner === 'knowledge') { - return ; - } - if (targetOwner === 'agent') { - return ; - } - if (targetOwner === 'content') { - return ; - } - - // 其余 owner(未来扩展)兜底 - const hasSession = Boolean(handoffToken || precheckId); - return ( -
-

跨空间交接 · {targetOwner}

- {hasSession ? ( -

该目标空间({targetOwner})的落地组件将在后续阶段接入。

- ) : ( -

交接会话已失效或缺少凭据,请返回来源页重新发起。

- )} -
- ); -} diff --git a/muse-studio/src/features/knowledge/components/KnowledgePublishModal.tsx b/muse-studio/src/features/knowledge/components/KnowledgePublishModal.tsx deleted file mode 100644 index 198a3a76..00000000 --- a/muse-studio/src/features/knowledge/components/KnowledgePublishModal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useEffect } from 'react'; -import { useKBPublishReadiness } from '@/features/knowledge/hooks/useKnowledge'; - -/** 权利/隐私/处理三态中文标签(未知码原样)。 */ -const STATUS_LABELS: Record = { - ready: '已就绪', - needs_verification: '待核验', - failed: '校验失败', - pending: '处理中', -}; - -/** 受阻原因中文标签(已知码,未知原样回显)。 */ -const BLOCKED_REASON_LABELS: Record = { - EXTERNAL_RIGHTS_PRIVACY_VALIDATOR_NOT_CONFIGURED: '外部权利/隐私校验器未配置(平台侧待接入)', -}; - -function statusLabel(status: string): string { - return STATUS_LABELS[status] ?? status; -} - -/** - * 知识库「发布到市场」可发布性预检弹层。 - * - * 打开即对知识库做源侧 publish-prechecks(权利/隐私/处理就绪),据 blocked 诚实呈现结论: - * 当前平台外部权利隐私校验器未配置 → 预检恒受阻,故只如实展示受阻原因与三态,不提供伪造的「确认发布」入口。 - */ -const KnowledgePublishModal: React.FC<{ - kbId: string; - kbName: string; - version: number; - onClose: () => void; -}> = ({ kbId, kbName, version, onClose }) => { - const precheck = useKBPublishReadiness(kbId); - const { mutate } = precheck; - - // 打开即触发一次源侧可发布性预检。 - useEffect(() => { - mutate(version); - }, [mutate, version]); - - const readiness = precheck.data; - - return ( -
-
e.stopPropagation()} - > -

发布到市场 · 可发布性预检

-

- 知识库《{kbName}》源侧权利 / 隐私 / 处理就绪检查。 -

- - {precheck.isPending &&

正在预检…

} - {precheck.isError && ( -

预检失败:{precheck.error.message}

- )} - - {readiness && ( -
- {/* 诚实结论:blocked 时明示暂不可发布,绝不伪造可发布 */} -
- {readiness.blocked ? '⚠️ 暂不可发布' : '✓ 可继续发布'} -
- - {readiness.blocked && readiness.blockedReasons.length > 0 && ( -
-

受阻原因

-
    - {readiness.blockedReasons.map((r) => ( -
  • - • {BLOCKED_REASON_LABELS[r] ?? r} -
  • - ))} -
-
- )} - -
-
-
权利
-
{statusLabel(readiness.rightsStatus)}
-
-
-
隐私
-
{statusLabel(readiness.privacyStatus)}
-
-
-
处理
-
{statusLabel(readiness.processingStatus)}
-
-
-
- )} - -
- -
-
-
- ); -}; - -export default KnowledgePublishModal; diff --git a/muse-studio/src/pages/EditorPage.tsx b/muse-studio/src/pages/EditorPage.tsx deleted file mode 100644 index 23858f19..00000000 --- a/muse-studio/src/pages/EditorPage.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useState, useRef } from 'react'; -import MuseEditor from '@/features/editor/components/MuseEditor'; -import type { MuseEditorInstance } from '@/features/editor/components/MuseEditor'; -import AIPanel from '@/features/editor/components/AIPanel'; -import CandidatePanel from '@/features/editor/components/CandidatePanel'; - -// 模拟演示的章节列表数据 -interface Chapter { - /** 章节 ID */ - id: string; - /** 章节标题 */ - title: string; - /** 正文字数估计 */ - wordCount: number; -} - -const chapters: Chapter[] = [ - { id: '1', title: '第一章:星海中闪烁的遗迹', wordCount: 3500 }, - { id: '2', title: '第二章:捕获未知的空间波段', wordCount: 4200 }, - { id: '3', title: '第三章:虚空中潜伏的观察者', wordCount: 2800 }, -]; - -/** - * 协同创作写作台总装页面 - * 整合左侧章节目录导航、中部 Tiptap 编辑视区、以及右侧 AI 双通道助手与 Diff 候选面板 - */ -export default function EditorPage() { - const [selectedChapterId, setSelectedChapterId] = useState('1'); - const [candidateText, setCandidateText] = useState(''); - const [originalText, setOriginalText] = useState(''); - const [showCompare, setShowCompare] = useState(false); - - // 用 ref 持有 Tiptap 编辑器的底层实例,以实现外部对正文内容的替换操作 - const editorRef = useRef(null); - - const selectedChapter = chapters.find((c) => c.id === selectedChapterId) || chapters[0]; - - // 当 AI 流式生成完毕时被触发,缓存文本并转为差异对比视图。 - // 注意:本页是早期演示页(绕过 Canonical 主权,仅本地 setContent),不需要 suggestionId; - // 真实采纳链路见 WorkspacePage。这里只取首参 text——少参函数可安全赋给 (content, suggestionId) 类型的回调。 - const handleCandidateGenerated = (text: string) => { - // 在事件回调中读取编辑器 ref,避免渲染期访问 ref 触发 React Hooks 静态规则。 - setOriginalText(editorRef.current ? editorRef.current.getText() : ''); - setCandidateText(text); - setShowCompare(true); - }; - - // 采纳建议:将当前编辑器内容替换为 AI 优化后内容,并切回 AI 对话面板 - const handleAcceptDiff = (finalContent: string) => { - if (editorRef.current) { - // Tiptap 接收纯文本并自动格式化段落插入 - editorRef.current.commands.setContent(finalContent); - } - setOriginalText(''); - setCandidateText(''); - setShowCompare(false); - }; - - // 放弃建议:直接返回 AI 面板 - const handleRejectDiff = () => { - setOriginalText(''); - setCandidateText(''); - setShowCompare(false); - }; - - return ( -
- {/* 1. 左侧章节目录大纲面板 (w-64) */} -
-
-

- 📖 章节目录 -

-
-
- {chapters.map((ch) => ( - - ))} -
-
- - {/* 2. 中间核心编辑器主框架 */} -
- {/* 编辑器上方功能展示栏 */} -
-
- 我的作品 - / - - {selectedChapter?.title} - -
-
- 估算字数:{selectedChapter?.wordCount || 0} - - - 自动保存安全网保护中 - -
-
- - {/* Tiptap 编辑器容器 */} -
- { - editorRef.current = editor; - }} - /> -
-
- - {/* 3. 右侧多模式 AI 功能面板 */} -
- {showCompare ? ( - - ) : ( - - )} -
-
- ); -} diff --git a/muse-studio/src/pages/KnowledgePage.solo.test.tsx b/muse-studio/src/pages/KnowledgePage.solo.test.tsx new file mode 100644 index 00000000..44eef0f5 --- /dev/null +++ b/muse-studio/src/pages/KnowledgePage.solo.test.tsx @@ -0,0 +1,36 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import KnowledgePage from './KnowledgePage'; + +function renderKnowledgePage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + render( + + + + } /> + + + , + ); +} + +describe('KnowledgePage 单人版裁剪', () => { + it('不再暴露市场安装 tab 或发布到市场入口', async () => { + renderKnowledgePage(); + + expect(await screen.findByRole('button', { name: '我创建的' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '全局公共授权' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '从市场安装的' })).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /发布知识库/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/发布到市场/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/muse-studio/src/pages/KnowledgePage.tsx b/muse-studio/src/pages/KnowledgePage.tsx index 7ef067a8..0e8b5e44 100644 --- a/muse-studio/src/pages/KnowledgePage.tsx +++ b/muse-studio/src/pages/KnowledgePage.tsx @@ -4,34 +4,28 @@ import KnowledgeDraftPanel from '@/features/knowledge/components/KnowledgeDraftP import KnowledgeGraphPanel from '@/features/knowledge/components/KnowledgeGraphPanel'; import KnowledgeBindingsPanel from '@/features/knowledge/components/KnowledgeBindingsPanel'; import KnowledgeRetrievalPanel from '@/features/knowledge/components/KnowledgeRetrievalPanel'; -import { useKnowledgeList, useKnowledgeDelete, useInstalledKnowledgeUninstall, useKnowledgeDisable, useKnowledgeRestore, useInstalledKnowledgeDisable, useInstalledKnowledgeRestore } from '@/features/knowledge/hooks/useKnowledge'; +import { useKnowledgeList, useKnowledgeDelete, useKnowledgeDisable, useKnowledgeRestore } from '@/features/knowledge/hooks/useKnowledge'; import type { KnowledgeBaseVO } from '@/features/knowledge/hooks/useKnowledge'; import CreateKBModal from '@/features/knowledge/components/CreateKBModal'; -import KnowledgePublishModal from '@/features/knowledge/components/KnowledgePublishModal'; import MaterialManager from '@/features/knowledge/components/MaterialManager'; import { isKnowledgeBaseSummary } from '@/features/knowledge/utils/typeGuards'; /** * KnowledgePage 知识库管理主控页面 - * 包含多页签过滤(自建、已安装、全局公开)、操作流控隔离、资料管理面板跳转以及 Zod 创建表单弹层 + * 单人版保留自建与全局公共授权库,市场安装与发布入口不在前端暴露。 */ export default function KnowledgePage() { // 作品维度路由(/knowledge/:workId)携带 workId 时,展示该作品的"待确认知识草稿"工作台(先审后入)。 const { workId } = useParams<{ workId: string }>(); - const [activeTab, setActiveTab] = useState<'user' | 'installed' | 'global'>('user'); + const [activeTab, setActiveTab] = useState<'user' | 'global'>('user'); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedKbId, setSelectedKbId] = useState(null); - // 发布到市场:当前选中预检的知识库(自建库专属);为空时不渲染预检弹层。 - const [publishKb, setPublishKb] = useState(null); - // 1. 获取知识库列表及对应的删除与卸载 Mutation Hooks + // 1. 获取知识库列表及对应的删除 Mutation Hooks const { data, isLoading, isError } = useKnowledgeList(activeTab); const { mutate: deleteKB } = useKnowledgeDelete(); - const { mutate: uninstallKB } = useInstalledKnowledgeUninstall(); const { mutate: disableKB } = useKnowledgeDisable(); const { mutate: restoreKB } = useKnowledgeRestore(); - const { mutate: disableInstalledKB } = useInstalledKnowledgeDisable(); - const { mutate: restoreInstalledKB } = useInstalledKnowledgeRestore(); // 2. 严格的运行期类型收窄与防空校验 const rawList = data?.list || []; @@ -53,13 +47,6 @@ export default function KnowledgePage() { } }; - const handleUninstallKB = (e: React.MouseEvent, installId: string, name: string) => { - e.stopPropagation(); - if (window.confirm(`确认卸载已安装的知识库《${name}》吗?\n卸载后作品绑定的设定参考将在生成中失效。`)) { - uninstallKB(installId); - } - }; - // 停用:软关闭知识库(资产保留可恢复,区别于物理删除);恢复:重新启用。均阻止冒泡防误触跳转资料面板。 const handleDisableKB = (e: React.MouseEvent, kbId: string, name: string) => { e.stopPropagation(); @@ -73,19 +60,6 @@ export default function KnowledgePage() { restoreKB(kbId); }; - // 已安装知识库停用/恢复:软状态切换,区别于卸载(物理删除本地安装)。停用前 confirm,恢复直接。 - const handleDisableInstalled = (e: React.MouseEvent, installId: string, name: string) => { - e.stopPropagation(); - if (window.confirm(`确认停用已安装知识库《${name}》吗?\n停用后该库在生成中不可用,但安装记录保留,可随时恢复。`)) { - disableInstalledKB(installId); - } - }; - - const handleRestoreInstalled = (e: React.MouseEvent, installId: string) => { - e.stopPropagation(); - restoreInstalledKB(installId); - }; - // 3. 拦截状态:如果选中了具体知识库,渲染资料文件管理器 (Detail/Materials Panel) if (selectedKbId) { const activeKb = knowledgeBases.find((kb) => kb.kbId === selectedKbId); @@ -150,16 +124,6 @@ export default function KnowledgePage() { > 我创建的 - {/* 停用/恢复:按 status 切换(active 显示停用,否则显示恢复);软状态管理,区别于物理删除。 */} {kb.status === 'active' ? ( - ) : kb.source === 'installed' ? ( - // 已安装:停用/恢复(按 status 切换)+ 卸载 -
- {kb.status === 'active' ? ( - - ) : ( - - )} - -
) : ( // 全局只读,隐藏所有删除/卸载按钮 @@ -375,16 +288,6 @@ export default function KnowledgePage() { {/* 创建新知识库的弹框 */} setIsModalOpen(false)} /> - - {/* 知识库发布到市场:可发布性预检弹层(诚实呈现受阻态,不伪造可发布) */} - {publishKb && ( - setPublishKb(null)} - /> - )} ); } diff --git a/muse-studio/src/pages/LoginPage.tsx b/muse-studio/src/pages/LoginPage.tsx index 0181a9dd..a00bb6e9 100644 --- a/muse-studio/src/pages/LoginPage.tsx +++ b/muse-studio/src/pages/LoginPage.tsx @@ -59,7 +59,7 @@ export default function LoginPage() { 登录后进入创作工作台

- 作品、知识库、智能体和市场资产都按账户隔离;未登录请求会被后端拒绝。 + 作品、知识库和智能体都按账户隔离;未登录请求会被后端拒绝。

diff --git a/muse-studio/src/stores/handoffStore.test.ts b/muse-studio/src/stores/handoffStore.test.ts deleted file mode 100644 index c70afe09..00000000 --- a/muse-studio/src/stores/handoffStore.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { useHandoffStore } from './handoffStore'; - -describe('handoffStore 跨空间 handoff 会话态', () => { - beforeEach(() => { - useHandoffStore.getState().clearSession(); - }); - - it('setLanding 存来源上下文 + 一次性 token,但尚无有效 session', () => { - useHandoffStore.getState().setLanding({ - handoffToken: 'handoff_abc', - targetOwner: 'knowledge', - targetAction: 'bind', - assetId: '2', - targetWorkId: '1', - returnUrl: '/market/assets/2', - }); - const s = useHandoffStore.getState(); - expect(s.handoffToken).toBe('handoff_abc'); - expect(s.targetOwner).toBe('knowledge'); - expect(s.targetWorkId).toBe('1'); - // 还没换 precheckId,session 无效 - expect(s.isSessionValid()).toBe(false); - }); - - it('setPrecheck 换得 precheckId 后清除 token(一次性用完即弃),session 有效', () => { - useHandoffStore.getState().setLanding({ - handoffToken: 'handoff_abc', - targetOwner: 'knowledge', - targetAction: 'bind', - assetId: '2', - targetWorkId: '1', - returnUrl: null, - }); - useHandoffStore.getState().setPrecheck('precheck-1', new Date(Date.now() + 60_000).toISOString()); - const s = useHandoffStore.getState(); - expect(s.precheckId).toBe('precheck-1'); - expect(s.handoffToken).toBeNull(); // token 用完即弃 - expect(s.isSessionValid()).toBe(true); - }); - - it('过期 precheck → isSessionValid 为 false', () => { - useHandoffStore.getState().setPrecheck('precheck-2', new Date(Date.now() - 1000).toISOString()); - expect(useHandoffStore.getState().isSessionValid()).toBe(false); - }); - - it('无过期信息的 precheck 视为有效(由后端兑现时再校验)', () => { - useHandoffStore.getState().setPrecheck('precheck-3'); - expect(useHandoffStore.getState().isSessionValid()).toBe(true); - }); - - it('clearSession 清空全部会话态', () => { - useHandoffStore.getState().setLanding({ - handoffToken: 't', - targetOwner: 'knowledge', - targetAction: 'bind', - assetId: '2', - targetWorkId: '1', - returnUrl: null, - }); - useHandoffStore.getState().clearSession(); - const s = useHandoffStore.getState(); - expect(s.handoffToken).toBeNull(); - expect(s.precheckId).toBeNull(); - expect(s.targetOwner).toBeNull(); - expect(s.isSessionValid()).toBe(false); - }); -}); diff --git a/muse-studio/src/stores/handoffStore.ts b/muse-studio/src/stores/handoffStore.ts deleted file mode 100644 index cfd6b3f9..00000000 --- a/muse-studio/src/stores/handoffStore.ts +++ /dev/null @@ -1,72 +0,0 @@ -import create from 'zustand'; - -/** 落地上下文:来源空间发起 handoff 后,传入目标空间落地页的非敏感上下文 + 一次性 token。 */ -export interface HandoffLanding { - handoffToken: string; - targetOwner: string; - targetAction: string; - assetId: string | null; - targetWorkId: string | null; - returnUrl: string | null; -} - -/** - * 跨空间 handoff 客户端会话态(对齐 design-docs 前端-05 §25.3)。 - * - * 只承载客户端会话,不承载授权事实(授权事实在目标 owner 的 precheck 快照里、由后端持有)。 - * 流程:落地 setLanding(存 token + 上下文;URL 随即 replaceState 清除 token)→ 用 token 调 owner precheck - * 换得 precheckId 后 setPrecheck(token 用完即弃,清除)→ 兑现/取消/失败后 clearSession。 - */ -interface HandoffState { - handoffToken: string | null; - targetOwner: string | null; - targetAction: string | null; - assetId: string | null; - targetWorkId: string | null; - returnUrl: string | null; - precheckId: string | null; - precheckExpiry: string | null; - /** 落地:存来源上下文 + 一次性 token(调用方须在存入后立即从 URL 清除 token)。 */ - setLanding: (landing: HandoffLanding) => void; - /** 消费 token 换得 owner precheckId 后存档;同时清除 token(一次性、用完即弃,防重复消费)。 */ - setPrecheck: (precheckId: string, precheckExpiry?: string | null) => void; - /** 清空会话(兑现完成 / 取消 / 失败)。 */ - clearSession: () => void; - /** precheckId 存在且未过期(无过期信息视为有效,由后端兑现时再校验)。 */ - isSessionValid: () => boolean; -} - -const EMPTY = { - handoffToken: null, - targetOwner: null, - targetAction: null, - assetId: null, - targetWorkId: null, - returnUrl: null, - precheckId: null, - precheckExpiry: null, -} as const; - -export const useHandoffStore = create()((set, get) => ({ - ...EMPTY, - setLanding: (landing) => - set({ - handoffToken: landing.handoffToken, - targetOwner: landing.targetOwner, - targetAction: landing.targetAction, - assetId: landing.assetId, - targetWorkId: landing.targetWorkId, - returnUrl: landing.returnUrl, - precheckId: null, - precheckExpiry: null, - }), - setPrecheck: (precheckId, precheckExpiry = null) => - set({ precheckId, precheckExpiry, handoffToken: null }), - clearSession: () => set({ ...EMPTY }), - isSessionValid: () => { - const { precheckId, precheckExpiry } = get(); - if (!precheckId) return false; - if (!precheckExpiry) return true; - return new Date(precheckExpiry).getTime() > Date.now(); - }, -})); diff --git a/muse-studio/src/test/setup.ts b/muse-studio/src/test/setup.ts index 501163fc..fb45b175 100644 --- a/muse-studio/src/test/setup.ts +++ b/muse-studio/src/test/setup.ts @@ -38,21 +38,17 @@ function createStorageMock(): Storage { }; } -if (!globalThis.localStorage) { - Object.defineProperty(globalThis, 'localStorage', { - value: createStorageMock(), - writable: true, - configurable: true, - }); -} +Object.defineProperty(globalThis, 'localStorage', { + value: createStorageMock(), + writable: true, + configurable: true, +}); -if (!globalThis.sessionStorage) { - Object.defineProperty(globalThis, 'sessionStorage', { - value: createStorageMock(), - writable: true, - configurable: true, - }); -} +Object.defineProperty(globalThis, 'sessionStorage', { + value: createStorageMock(), + writable: true, + configurable: true, +}); // 聚合所有子功能模块的 Mock 规则 export const server = setupServer(