feat(solo): 收口治理入口并裁剪 studio 表面

完成 2.0.0 S4 的外部 AI、admin、account 与 solo SQL 收口,并推进 S5 studio 市场/handoff/旧编辑器入口裁剪。S5 的前端单测、构建与 route-only e2e 已留证;MSW-off 创作主线 e2e 仍因本地后端活体启动口径待修正,已在总账标为未完成验收。
This commit is contained in:
zizi 2026-07-08 11:45:26 +08:00
parent c8a88e71b3
commit 6dcabf69fb
61 changed files with 964 additions and 2443 deletions

View File

@ -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/0Sknowledge 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 200coverage 计数核验 `total=242/completed=210/marketDormant=32/nonMarketDormant=0`。2026-07-08 裁决:publish-prechecks/snapshots/readiness 保留 Knowledge 本域预检/快照能力,允许写 readiness/snapshot/command 记录,但不得产生 market 资产、安装、授权事实;前端发布入口由 S5 删除。 **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/0Sknowledge 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 200coverage 计数核验 `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 passedS5 隔离 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 auditAI 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/0Ereal-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0Sstudio `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。 **E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business auditAI 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/0Ereal-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0Sstudio `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/0Estudio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0EMSW-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 archivedSlot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:本轮未跑 studio 全量 e2e。 **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/0Estudio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0EMSW-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 archivedSlot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:本轮未跑 studio 全量 e2e。

View File

@ -2,7 +2,12 @@ import { createMemoryHistory, createRouter } from 'vue-router';
import { describe, expect, it } from 'vitest'; 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 routes from '../modules/muse';
import payRoutes from '../modules/pay';
const createMuseRouter = () => const createMuseRouter = () =>
createRouter({ createRouter({
@ -44,4 +49,22 @@ describe('muse admin routes', () => {
expect(resolved.fullPath).toBe('/muse/governance/meta-schemas/work%2Fstory'); expect(resolved.fullPath).toBe('/muse/governance/meta-schemas/work%2Fstory');
expect(resolved.meta.activePath).toBe('/muse/governance/meta-schemas'); 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);
}
});
}); });

View File

@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [
{ {
component: () => import('#/views/muse/market/index.vue'), component: () => import('#/views/muse/market/index.vue'),
meta: { meta: {
hideInMenu: true,
icon: 'lucide:store', icon: 'lucide:store',
title: '市场治理', title: '市场治理',
}, },
@ -77,6 +78,7 @@ const routes: RouteRecordRaw[] = [
{ {
component: () => import('#/views/muse/account/index.vue'), component: () => import('#/views/muse/account/index.vue'),
meta: { meta: {
hideInMenu: true,
icon: 'lucide:users', icon: 'lucide:users',
title: '用户与权限', title: '用户与权限',
}, },

View File

@ -234,9 +234,6 @@ vi.mock('#/api/muse/jobs', () => ({
})); }));
vi.mock('#/api/muse/newapi', () => ({ vi.mock('#/api/muse/newapi', () => ({
createNewApiCallAttributionJobApi: vi.fn(),
createNewApiGatewayBindingApi: vi.fn(),
createNewApiQuotaRequestApi: vi.fn(),
getNewApiIntegrationCallApi: vi.fn(), getNewApiIntegrationCallApi: vi.fn(),
listNewApiBalanceSnapshotsApi: vi.fn().mockResolvedValue({ listNewApiBalanceSnapshotsApi: vi.fn().mockResolvedValue({
list: [{ balance: 100, snapshotId: 'balance-1', userId: 'user-1' }], list: [{ balance: 100, snapshotId: 'balance-1', userId: 'user-1' }],
@ -274,13 +271,18 @@ describe('muse admin governance pages', () => {
expect(text).toContain('Muse 管理员'); expect(text).toContain('Muse 管理员');
}); });
it('New-API 页不展示凭据且只处理治理归属', async () => { it('New-API 页不展示凭据且只保留本地用量只读观测', async () => {
const text = await renderPage(() => import('../newapi/index.vue')); 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('不提供网关绑定、额度配置或归属写命令');
expect(text).toContain('newapi-user-1'); 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 () => { it('任务治理页要求重验且不代用户重新生成候选', async () => {

View File

@ -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);
}
});
});

View File

@ -2,8 +2,8 @@
/** /**
* New-API 网关用户与用量页 * New-API 网关用户与用量页
* *
* 只展示网关绑定余额快照调用日志归属和异常处理摘要 * 单人版只展示网关绑定余额快照调用日志归属和外部调用状态
* 页面不展示任何 New-API 凭据不把余额快照当作 Muse 本地权威余额 * 页面不展示任何 New-API 凭据也不提供绑定额度或归属写命令入口
*/ */
import type { import type {
AdminUsageRecord, AdminUsageRecord,
@ -21,13 +21,9 @@ import {
Alert, Alert,
Button, Button,
Card, Card,
Checkbox,
Descriptions, Descriptions,
Form,
Input, Input,
InputNumber,
message, message,
Modal,
Select, Select,
Space, Space,
Table, Table,
@ -36,9 +32,6 @@ import {
} from 'ant-design-vue'; } from 'ant-design-vue';
import { import {
createNewApiCallAttributionJobApi,
createNewApiGatewayBindingApi,
createNewApiQuotaRequestApi,
getNewApiIntegrationCallApi, getNewApiIntegrationCallApi,
listNewApiBalanceSnapshotsApi, listNewApiBalanceSnapshotsApi,
listNewApiGatewayUsersApi, listNewApiGatewayUsersApi,
@ -56,48 +49,6 @@ interface TablePager {
/** Ant Table 插槽传入的通用记录。 */ /** Ant Table 插槽传入的通用记录。 */
type TableRecord = Record<string, unknown>; type TableRecord = Record<string, unknown>;
/** 创建或刷新网关绑定表单。 */
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<NewApiBindingStatus, string> = { const bindingStatusColor: Record<NewApiBindingStatus, string> = {
bound: 'green', bound: 'green',
@ -119,11 +70,6 @@ function asNewApiBinding(record: TableRecord) {
return record as unknown as NewApiBindingSummary; return record as unknown as NewApiBindingSummary;
} }
/** 将 Ant Table 通用记录转回用量摘要。 */
function asUsageRecord(record: TableRecord) {
return record as unknown as AdminUsageRecord;
}
/** 网关绑定状态颜色,兼容模板中的 unknown 记录。 */ /** 网关绑定状态颜色,兼容模板中的 unknown 记录。 */
function getBindingStatusColor(status: unknown) { function getBindingStatusColor(status: unknown) {
return bindingStatusColor[status as NewApiBindingStatus] ?? 'default'; return bindingStatusColor[status as NewApiBindingStatus] ?? 'default';
@ -141,7 +87,7 @@ const bindingColumns = [
{ dataIndex: 'boundAt', key: 'boundAt', title: '绑定时间' }, { dataIndex: 'boundAt', key: 'boundAt', title: '绑定时间' },
{ dataIndex: 'lastSyncAt', key: 'lastSyncAt', title: '最近同步' }, { dataIndex: 'lastSyncAt', key: 'lastSyncAt', title: '最近同步' },
{ dataIndex: 'syncErrorMessage', key: 'syncErrorMessage', title: '异常摘要' }, { dataIndex: 'syncErrorMessage', key: 'syncErrorMessage', title: '异常摘要' },
{ key: 'actions', title: '操作', width: 280 }, { key: 'actions', title: '操作', width: 120 },
]; ];
const balanceColumns = [ const balanceColumns = [
@ -171,13 +117,11 @@ const usageColumns = [
key: 'failedAttributionCount', key: 'failedAttributionCount',
title: '归属失败', title: '归属失败',
}, },
{ key: 'actions', title: '异常处理', width: 180 },
]; ];
const bindingLoading = ref(false); const bindingLoading = ref(false);
const balanceLoading = ref(false); const balanceLoading = ref(false);
const usageLoading = ref(false); const usageLoading = ref(false);
const commandSubmitting = ref(false);
const bindings = ref<NewApiBindingSummary[]>([]); const bindings = ref<NewApiBindingSummary[]>([]);
const balanceSnapshots = ref<BalanceSnapshotEntry[]>([]); const balanceSnapshots = ref<BalanceSnapshotEntry[]>([]);
@ -198,38 +142,8 @@ const bindingPager = reactive({ current: 1, pageSize: 10, total: 0 });
const balancePager = 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 usagePager = reactive({ current: 1, pageSize: 10, total: 0 });
const bindingModalOpen = ref(false);
const quotaModalOpen = ref(false);
const attributionModalOpen = ref(false);
const integrationQuery = ref(''); const integrationQuery = ref('');
const bindingForm = reactive<BindingCommandForm>({
commandId: '',
forceRefresh: false,
reason: '',
userId: '',
});
const quotaForm = reactive<QuotaRequestForm>({
commandId: '',
reason: '',
requestType: 'subscription_sync',
resourceType: '',
userId: '',
});
const attributionForm = reactive<AttributionJobForm>({
callIds: '',
commandId: '',
correlationId: '',
reason: '',
});
/** 生成前端幂等键,后端负责最终幂等校验。 */
function createCommandId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
/** 统一空值展示。 */ /** 统一空值展示。 */
function displayText(value?: number | string) { function displayText(value?: number | string) {
return value === undefined || value === '' ? '-' : String(value); return value === undefined || value === '' ? '-' : String(value);
@ -329,128 +243,11 @@ function handleUsageTableChange(pager: TablePager) {
loadUsageRecords(pager.current ?? 1, pager.pageSize ?? usagePager.pageSize); 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) { function handleSelectBinding(record: TableRecord) {
return selectBinding(asNewApiBinding(record)); 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 查询外部调用状态。 */ /** 按 correlationId 查询外部调用状态。 */
async function queryIntegrationCall() { async function queryIntegrationCall() {
if (!integrationQuery.value.trim()) { if (!integrationQuery.value.trim()) {
@ -474,7 +271,7 @@ onMounted(() => {
<Alert <Alert
show-icon show-icon
type="warning" type="warning"
message="New-API 页面只展示治理和对账摘要;不展示凭据,不伪造余额,不管理模型供应商路由。" message="New-API 页面只展示本地用量观测和对账摘要;不展示凭据,不提供网关绑定、额度配置或归属写命令。"
/> />
<Tabs> <Tabs>
@ -498,7 +295,6 @@ onMounted(() => {
> >
查询 查询
</Button> </Button>
<Button @click="openBindingModal()">创建绑定</Button>
</Space> </Space>
<Table <Table
@ -520,12 +316,6 @@ onMounted(() => {
<Button type="link" @click="handleSelectBinding(record)"> <Button type="link" @click="handleSelectBinding(record)">
余额/用量 余额/用量
</Button> </Button>
<Button type="link" @click="handleOpenBindingModal(record)">
刷新绑定
</Button>
<Button type="link" @click="handleOpenQuotaModal(record)">
配置额度
</Button>
</Space> </Space>
</template> </template>
</template> </template>
@ -572,7 +362,7 @@ onMounted(() => {
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane key="usage" tab="调用归属"> <Tabs.TabPane key="usage" tab="调用归属">
<Card title="调用日志归属与异常处理"> <Card title="调用日志归属摘要">
<Space class="mb-4" wrap> <Space class="mb-4" wrap>
<Input <Input
v-model:value="usageFilters.userId" v-model:value="usageFilters.userId"
@ -596,7 +386,6 @@ onMounted(() => {
> >
查询 查询
</Button> </Button>
<Button @click="openAttributionModal()">创建归属 job</Button>
</Space> </Space>
<Table <Table
@ -606,15 +395,7 @@ onMounted(() => {
:loading="usageLoading" :loading="usageLoading"
:pagination="usagePager" :pagination="usagePager"
@change="handleUsageTableChange" @change="handleUsageTableChange"
> />
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'actions'">
<Button type="link" @click="handleOpenAttributionModal(record)">
处理异常
</Button>
</template>
</template>
</Table>
</Card> </Card>
</Tabs.TabPane> </Tabs.TabPane>
@ -658,101 +439,5 @@ onMounted(() => {
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
</div> </div>
<Modal
v-model:open="bindingModalOpen"
:confirm-loading="commandSubmitting"
title="创建或刷新网关绑定"
@ok="submitBindingCommand"
>
<Form layout="vertical">
<Form.Item label="用户 ID" required>
<Input v-model:value="bindingForm.userId" />
</Form.Item>
<Form.Item label="幂等键" required>
<Input v-model:value="bindingForm.commandId" />
</Form.Item>
<Form.Item label="强制刷新">
<Checkbox v-model:checked="bindingForm.forceRefresh">
同步失败或人工补偿时强制刷新
</Checkbox>
</Form.Item>
<Form.Item label="原因" required>
<Input.TextArea v-model:value="bindingForm.reason" :rows="3" />
</Form.Item>
</Form>
</Modal>
<Modal
v-model:open="quotaModalOpen"
:confirm-loading="commandSubmitting"
title="New-API 额度配置请求"
@ok="submitQuotaRequest"
>
<Form layout="vertical">
<Form.Item label="用户 ID" required>
<Input v-model:value="quotaForm.userId" />
</Form.Item>
<Form.Item label="请求类型" required>
<Select v-model:value="quotaForm.requestType">
<Select.Option value="plan_config">套餐配置</Select.Option>
<Select.Option value="balance_config">余额配置</Select.Option>
<Select.Option value="subscription_sync">订阅同步</Select.Option>
</Select>
</Form.Item>
<Form.Item label="目标额度">
<Space>
<Input
v-model:value="quotaForm.resourceType"
placeholder="资源类型"
/>
<InputNumber v-model:value="quotaForm.limit" placeholder="额度" />
</Space>
</Form.Item>
<Form.Item label="幂等键" required>
<Input v-model:value="quotaForm.commandId" />
</Form.Item>
<Form.Item label="原因" required>
<Input.TextArea v-model:value="quotaForm.reason" :rows="3" />
</Form.Item>
</Form>
</Modal>
<Modal
v-model:open="attributionModalOpen"
:confirm-loading="commandSubmitting"
title="创建调用归属 job"
@ok="submitAttributionJob"
>
<Alert
class="mb-4"
show-icon
type="info"
message="归属事实由服务端根据调用审计记录、任务、作品、资产和授权关系严格校验。"
/>
<Form layout="vertical">
<Form.Item label="correlationId" required>
<Input v-model:value="attributionForm.correlationId" />
</Form.Item>
<Form.Item label="callIds 白名单">
<Input
v-model:value="attributionForm.callIds"
placeholder="多个 callId 用逗号分隔"
/>
</Form.Item>
<Form.Item label="调用 revision">
<InputNumber
v-model:value="attributionForm.expectedCallRevision"
class="w-full"
/>
</Form.Item>
<Form.Item label="幂等键" required>
<Input v-model:value="attributionForm.commandId" />
</Form.Item>
<Form.Item label="原因" required>
<Input.TextArea v-model:value="attributionForm.reason" :rows="3" />
</Form.Item>
</Form>
</Modal>
</Page> </Page>
</template> </template>

File diff suppressed because one or more lines are too long

View File

@ -122,37 +122,46 @@ spring:
host: 127.0.0.1 host: 127.0.0.1
port: 19530 port: 19530
qianfan: # 文心一言 qianfan: # 文心一言
api-key: x0cuLZ7XsaTCU08vuJWO87Lg api-key: ${MUSE_AI_QIANFAN_API_KEY:}
secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK secret-key: ${MUSE_AI_QIANFAN_SECRET_KEY:}
zhipuai: # 智谱 AI zhipuai: # 智谱 AI
api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs api-key: ${MUSE_AI_ZHIPUAI_API_KEY:}
openai: # OpenAI 官方 openai: # OpenAI 官方
api-key: sk-aN6nWn3fILjrgLFT0fC4Aa60B72e4253826c77B29dC94f17 api-key: ${MUSE_AI_OPENAI_API_KEY:}
base-url: https://api.gptsapi.net base-url: ${MUSE_AI_OPENAI_BASE_URL:https://api.openai.com}
azure: # OpenAI 微软 azure: # OpenAI 微软
openai: openai:
endpoint: https://eastusprejade.openai.azure.com endpoint: https://eastusprejade.openai.azure.com
anthropic: # Anthropic Claude anthropic: # Anthropic Claude
api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942 api-key: ${MUSE_AI_ANTHROPIC_API_KEY:}
ollama: ollama:
base-url: http://127.0.0.1:11434 base-url: http://127.0.0.1:11434
chat: chat:
model: llama3 model: llama3
stabilityai: stabilityai:
api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx api-key: ${MUSE_AI_STABILITYAI_API_KEY:}
dashscope: # 通义千问 dashscope: # 通义千问
api-key: sk-47aa124781be4bfb95244cc62f6xxxx api-key: ${MUSE_AI_DASHSCOPE_API_KEY:}
agent:
enabled: false
minimax: # Minimaxhttps://www.minimaxi.com/ minimax: # Minimaxhttps://www.minimaxi.com/
api-key: xxxx api-key: ${MUSE_AI_MINIMAX_API_KEY:}
moonshot: # 月之暗面KIMI moonshot: # 月之暗面KIMI
api-key: sk-abc api-key: ${MUSE_AI_MOONSHOT_API_KEY:}
deepseek: # DeepSeek deepseek: # DeepSeek
api-key: sk-e94db327cc7d457d99a8de8810fc6b12 api-key: ${MUSE_AI_DEEPSEEK_API_KEY:}
chat: chat:
options: options:
model: deepseek-chat model: deepseek-chat
model: model:
rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启 chat: none
embedding: none
image: none
rerank: none
video: none
audio:
speech: none
transcription: none
mcp: mcp:
server: server:
enabled: false enabled: false
@ -174,43 +183,43 @@ spring:
muse: muse:
ai: ai:
gemini: # 谷歌 Gemini gemini: # 谷歌 Gemini
enable: true enable: false
api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ api-key: ${MUSE_AI_GEMINI_API_KEY:}
model: gemini-2.5-flash model: gemini-2.5-flash
doubao: # 字节豆包 doubao: # 字节豆包
enable: true enable: false
api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272 api-key: ${MUSE_AI_DOUBAO_API_KEY:}
model: doubao-1-5-lite-32k-250115 model: doubao-1-5-lite-32k-250115
hunyuan: # 腾讯混元 hunyuan: # 腾讯混元
enable: true enable: false
api-key: sk-abc api-key: ${MUSE_AI_HUNYUAN_API_KEY:}
model: hunyuan-turbo model: hunyuan-turbo
siliconflow: # 硅基流动 siliconflow: # 硅基流动
enable: true enable: false
api-key: sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz api-key: ${MUSE_AI_SILICONFLOW_API_KEY:}
model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
xinghuo: # 讯飞星火 xinghuo: # 讯飞星火
enable: true enable: false
appKey: 75b161ed2aef4719b275d6e7f2a4d4cd appKey: ${MUSE_AI_XINGHUO_APP_KEY:}
secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz secretKey: ${MUSE_AI_XINGHUO_SECRET_KEY:}
model: x1 model: x1
baichuan: # 百川智能 baichuan: # 百川智能
enable: true enable: false
api-key: sk-abc api-key: ${MUSE_AI_BAICHUAN_API_KEY:}
model: Baichuan4-Turbo model: Baichuan4-Turbo
midjourney: midjourney:
enable: true enable: false
# base-url: https://api.holdai.top/mj-relax/mj # base-url: https://api.holdai.top/mj-relax/mj
base-url: https://api.holdai.top/mj base-url: ${MUSE_AI_MIDJOURNEY_BASE_URL:}
api-key: sk-dZEPiVaNcT3FHhef51996bAa0bC74806BeAb620dA5Da10Bf api-key: ${MUSE_AI_MIDJOURNEY_API_KEY:}
notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify notify-url: ${MUSE_AI_MIDJOURNEY_NOTIFY_URL:}
suno: suno:
enable: true enable: false
# base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app # 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: web-search:
enable: true enable: false
api-key: sk-40500e52840f4d24b956d0b1d80d9abe api-key: ${MUSE_AI_WEB_SEARCH_API_KEY:}
new-api: new-api:
enabled: ${MUSE_AI_NEW_API_ENABLED:false} enabled: ${MUSE_AI_NEW_API_ENABLED:false}
base-url: ${MUSE_AI_NEW_API_BASE_URL:} base-url: ${MUSE_AI_NEW_API_BASE_URL:}

View File

@ -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 写契约,不伪造。 - **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 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 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` 证据锚点门禁。前端仍有深页未全覆盖真实后端。 - **关键风险 / 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` 证据锚点门禁。前端仍有深页未全覆盖真实后端。

View File

@ -196,37 +196,46 @@ spring:
host: 127.0.0.1 host: 127.0.0.1
port: 19530 port: 19530
qianfan: # 文心一言 qianfan: # 文心一言
api-key: x0cuLZ7XsaTCU08vuJWO87Lg api-key: ${MUSE_AI_QIANFAN_API_KEY:}
secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK secret-key: ${MUSE_AI_QIANFAN_SECRET_KEY:}
zhipuai: # 智谱 AI zhipuai: # 智谱 AI
api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs api-key: ${MUSE_AI_ZHIPUAI_API_KEY:}
openai: # OpenAI 官方 openai: # OpenAI 官方
api-key: sk-aN6nWn3fILjrgLFT0fC4Aa60B72e4253826c77B29dC94f17 api-key: ${MUSE_AI_OPENAI_API_KEY:}
base-url: https://api.gptsapi.net base-url: ${MUSE_AI_OPENAI_BASE_URL:https://api.openai.com}
azure: # OpenAI 微软 azure: # OpenAI 微软
openai: openai:
endpoint: https://eastusprejade.openai.azure.com endpoint: https://eastusprejade.openai.azure.com
anthropic: # Anthropic Claude anthropic: # Anthropic Claude
api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942 api-key: ${MUSE_AI_ANTHROPIC_API_KEY:}
ollama: ollama:
base-url: http://127.0.0.1:11434 base-url: http://127.0.0.1:11434
chat: chat:
model: llama3 model: llama3
stabilityai: stabilityai:
api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx api-key: ${MUSE_AI_STABILITYAI_API_KEY:}
dashscope: # 通义千问 dashscope: # 通义千问
api-key: sk-47aa124781be4bfb95244cc62f6xxxx api-key: ${MUSE_AI_DASHSCOPE_API_KEY:}
agent:
enabled: false
minimax: # Minimaxhttps://www.minimaxi.com/ minimax: # Minimaxhttps://www.minimaxi.com/
api-key: xxxx api-key: ${MUSE_AI_MINIMAX_API_KEY:}
moonshot: # 月之暗面KIMI moonshot: # 月之暗面KIMI
api-key: sk-abc api-key: ${MUSE_AI_MOONSHOT_API_KEY:}
deepseek: # DeepSeek deepseek: # DeepSeek
api-key: sk-e94db327cc7d457d99a8de8810fc6b12 api-key: ${MUSE_AI_DEEPSEEK_API_KEY:}
chat: chat:
options: options:
model: deepseek-chat model: deepseek-chat
model: model:
rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启 chat: none
embedding: none
image: none
rerank: none
video: none
audio:
speech: none
transcription: none
mcp: mcp:
server: server:
enabled: false enabled: false
@ -248,43 +257,43 @@ spring:
muse: muse:
ai: ai:
gemini: # 谷歌 Gemini gemini: # 谷歌 Gemini
enable: true enable: false
api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ api-key: ${MUSE_AI_GEMINI_API_KEY:}
model: gemini-2.5-flash model: gemini-2.5-flash
doubao: # 字节豆包 doubao: # 字节豆包
enable: true enable: false
api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272 api-key: ${MUSE_AI_DOUBAO_API_KEY:}
model: doubao-1-5-lite-32k-250115 model: doubao-1-5-lite-32k-250115
hunyuan: # 腾讯混元 hunyuan: # 腾讯混元
enable: true enable: false
api-key: sk-abc api-key: ${MUSE_AI_HUNYUAN_API_KEY:}
model: hunyuan-turbo model: hunyuan-turbo
siliconflow: # 硅基流动 siliconflow: # 硅基流动
enable: true enable: false
api-key: sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz api-key: ${MUSE_AI_SILICONFLOW_API_KEY:}
model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
xinghuo: # 讯飞星火 xinghuo: # 讯飞星火
enable: true enable: false
appKey: 75b161ed2aef4719b275d6e7f2a4d4cd appKey: ${MUSE_AI_XINGHUO_APP_KEY:}
secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz secretKey: ${MUSE_AI_XINGHUO_SECRET_KEY:}
model: x1 model: x1
baichuan: # 百川智能 baichuan: # 百川智能
enable: true enable: false
api-key: sk-abc api-key: ${MUSE_AI_BAICHUAN_API_KEY:}
model: Baichuan4-Turbo model: Baichuan4-Turbo
midjourney: midjourney:
enable: true enable: false
# base-url: https://api.holdai.top/mj-relax/mj # base-url: https://api.holdai.top/mj-relax/mj
base-url: https://api.holdai.top/mj base-url: ${MUSE_AI_MIDJOURNEY_BASE_URL:}
api-key: sk-dZEPiVaNcT3FHhef51996bAa0bC74806BeAb620dA5Da10Bf api-key: ${MUSE_AI_MIDJOURNEY_API_KEY:}
notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify notify-url: ${MUSE_AI_MIDJOURNEY_NOTIFY_URL:}
suno: suno:
enable: true enable: false
# base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app # 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: web-search:
enable: true enable: false
api-key: sk-40500e52840f4d24b956d0b1d80d9abe api-key: ${MUSE_AI_WEB_SEARCH_API_KEY:}
--- #################### 芋道相关配置 #################### --- #################### 芋道相关配置 ####################

View File

@ -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 单人版默认值门禁
*
* <p>member 在单人版只保留账号profile用量只读观测和审计New-API 管理口与多用户 worker
* 必须默认关闭market 投影和导出外部依赖缺席时必须 fail-closed</p>
*/
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));
});
}
}

View File

@ -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 厂商装配门禁
*
* <p>yudao 继承的厂商脚手架必须默认关闭Muse 只保留 Dify 与未配置即 fail-closed
* New-API runtime adapter 避免 application.yaml 回漂时启动期重新连出外部厂商</p>
*/
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();
}
}
}

View File

@ -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 切到 DifyS4 保持 New-API/Dify adapter 未配置即 fail-closed。
MUSE_AI_NEW_API_ENABLED=false
MUSE_AI_DIFY_ENABLED=false

View File

@ -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)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)"
CLOUD_DIR="${REPO_ROOT}/muse-cloud" 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}" 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}" EXTERNAL_LIVE_TESTS="${P1R_EXTERNAL_LIVE_TESTS:-P1rAiRuntimeEndToEndLiveAcceptanceIT,P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT,P1rMarketKbForkMaterializationIT}"
MODULE_LIVE_TESTS="${P1R_MODULE_LIVE_TESTS:-P1rNewApiLiveAcceptanceIT,P1rImportLlmNewApiLiveAcceptanceIT,P1rRagFlowLiveAcceptanceIT,P1rDifyChatLiveAcceptanceIT,P1rDifyDatasetsContractLiveIT}" MODULE_LIVE_TESTS="${P1R_MODULE_LIVE_TESTS:-P1rNewApiLiveAcceptanceIT,P1rImportLlmNewApiLiveAcceptanceIT,P1rRagFlowLiveAcceptanceIT,P1rDifyChatLiveAcceptanceIT,P1rDifyDatasetsContractLiveIT}"

View File

@ -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:%'
);

View File

@ -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:%'
);

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* / e2e(,MSW off) * / e2e(,MSW off)
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; 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 * New-API e2e(,MSW off)A :后端 getAppNewApiBinding studio 0
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* e2e(,MSW off) * e2e(,MSW off)
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* e2eMSW off * e2eMSW off
* *

View File

@ -1,4 +1,4 @@
import { Client } from 'pg'; import type { Client as PgClient } from 'pg';
/** /**
* Playwright globalSetup e2e fixture (, /tmp ) * Playwright globalSetup e2e fixture (, /tmp )
@ -30,7 +30,8 @@ async function globalSetup(): Promise<void> {
); );
} }
const client = new Client({ const { Client } = await import('pg');
const client: PgClient = new Client({
host, host,
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'), port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live', database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
@ -42,6 +43,9 @@ async function globalSetup(): Promise<void> {
await client.connect(); await client.connect();
try { try {
const enableMarketFixture = process.env.MUSE_STUDIO_E2E_MARKET !== 'false';
let marketFixtureDetails = 'market/handoff fixtures skipped';
// 0) AI 真生成额度:每轮把 test1(user1) 的 ai_calls 配额复位为可消费态。 // 0) AI 真生成额度:每轮把 test1(user1) 的 ai_calls 配额复位为可消费态。
// WHY:AI 生成 e2e 会真调用 Account 额度预扣,额度属于会被测试消费的状态;若不复位, // WHY:AI 生成 e2e 会真调用 Account 额度预扣,额度属于会被测试消费的状态;若不复位,
// 历史运行耗尽或基础种子缺失都会让 POST /ai/tasks 在业务入口返回「账户额度不足」,形成夹具假红。 // 历史运行耗尽或基础种子缺失都会让 POST /ai/tasks 在业务入口返回「账户额度不足」,形成夹具假红。
@ -172,20 +176,22 @@ async function globalSetup(): Promise<void> {
console.warn('[e2e globalSetup] ⚠️ 未找到"待确认演练"安全事件(account-security-ack 基础种子缺失)'); console.warn('[e2e globalSetup] ⚠️ 未找到"待确认演练"安全事件(account-security-ack 基础种子缺失)');
} }
// 5a) market 申诉提交:刷新被驳回 publish_request 的 reviewed_at 到近期,使申诉 deadline(reviewedAt + 后端 if (enableMarketFixture) {
// APPEAL_DEADLINE_DAYS=7 天)未过。否则种子 request 随日期推进必然过期,「发起申诉」用例提交被后端拒 // 5a) market 申诉提交:刷新被驳回 publish_request 的 reviewed_at 到近期,使申诉 deadline(reviewedAt + 后端
// MARKET_APPEAL_DEADLINE_EXPIRED(1044000027)——此 fixture 老化与代码无关、纯时间推进所致 // APPEAL_DEADLINE_DAYS=7 天)未过。否则种子 request 随日期推进必然过期,「发起申诉」用例提交被后端拒
// (见 MarketAppealServiceImpl.requireWithinDeadline / reviewRejectionRelation 取 request.reviewedAt)。 // MARKET_APPEAL_DEADLINE_EXPIRED(1044000027)——此 fixture 老化与代码无关、纯时间推进所致
await client.query( // (见 MarketAppealServiceImpl.requireWithinDeadline / reviewRejectionRelation 取 request.reviewedAt)。
"UPDATE muse_market_publish_request SET reviewed_at = NOW() WHERE tenant_id=1 AND status IN ('rejected','compliance_blocked')" 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→展示「补充材料」)。 // 5) market 申诉补充材料:把当前用户最早的一条申诉置 supplementing(canSupplement→展示「补充材料」)。
const ap = await client.query( 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)" "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) { if (ap.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到当前用户的申诉(market 补充材料用例需基础申诉种子)'); console.warn('[e2e globalSetup] ⚠️ 未找到当前用户的申诉(market 补充材料用例需基础申诉种子)');
}
} }
// 6) knowledge-bindings:作品 1 一条<生效>的来源绑定读模型投影(此前 global-setup 未覆盖、基础种子缺失, // 6) knowledge-bindings:作品 1 一条<生效>的来源绑定读模型投影(此前 global-setup 未覆盖、基础种子缺失,
@ -322,89 +328,91 @@ async function globalSetup(): Promise<void> {
"DELETE FROM muse_agent WHERE tenant_id=1 AND name LIKE '活体智能体-e2e-%'" "DELETE FROM muse_agent WHERE tenant_id=1 AND name LIKE '活体智能体-e2e-%'"
); );
// 12) handoff-knowledge:清理 e2e 正路兑现产生的 market_kb 绑定(work4+kb1),供兑现闭环幂等重跑。 if (enableMarketFixture) {
// 用 work4(creator=test1、未占 uk);work1+kb1 已被 installed binding(id=1,user_kb)占,故 e2e 用 work4 避 uk 冲突。 // 12) handoff-knowledge:清理 e2e 正路兑现产生的 market_kb 绑定(work4+kb1),供兑现闭环幂等重跑。
// 按 source_snapshot_id 格式(source-market_kb-{sourceId}-v*)精准删 binding;物理 DELETE 释放 uk_muse_knowledge_binding_work_kb。 // 用 work4(creator=test1、未占 uk);work1+kb1 已被 installed binding(id=1,user_kb)占,故 e2e 用 work4 避 uk 冲突。
await client.query( // 按 source_snapshot_id 格式(source-market_kb-{sourceId}-v*)精准删 binding;物理 DELETE 释放 uk_muse_knowledge_binding_work_kb。
"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_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'" 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。 // 13) handoff-agent(E4):seed 发布者侧 user agent + asset1.source_id,供 market→agent handoff 真实物化 e2e。
// 旧形态是预置 agent_type=market 的裸 agent 并把 sourceAgentId 透传给目标侧E4 后目标侧只信 market // 旧形态是预置 agent_type=market 的裸 agent 并把 sourceAgentId 透传给目标侧E4 后目标侧只信 market
// assetId/token,后端按 asset.source_id 找发布者 agent,再物化成安装者本地 user agent。 // assetId/token,后端按 asset.source_id 找发布者 agent,再物化成安装者本地 user agent。
const HANDOFF_AGENT_KEY = 'publisher:handoff:e2e'; const HANDOFF_AGENT_KEY = 'publisher:handoff:e2e';
const HANDOFF_SLOT_KEY = 'handoff-writer'; const HANDOFF_SLOT_KEY = 'handoff-writer';
await client.query( await client.query(
`DELETE FROM muse_agent_version `DELETE FROM muse_agent_version
WHERE tenant_id=1 WHERE tenant_id=1
AND agent_id IN ( AND agent_id IN (
SELECT id FROM muse_agent SELECT id FROM muse_agent
WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false WHERE tenant_id=1 AND owner_user_id=1 AND source_market_asset_id=1 AND deleted=false
)` )`
); );
await client.query( 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" "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', [ await client.query('DELETE FROM muse_agent_slot_binding WHERE tenant_id=1 AND work_id=4 AND slot_key=$1', [
HANDOFF_SLOT_KEY, HANDOFF_SLOT_KEY,
]); ]);
await client.query( 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)', '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] [HANDOFF_AGENT_KEY]
); );
await client.query('DELETE 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( const publisherAgent = await client.query(
`INSERT INTO muse_agent (agent_key, name, description, agent_type, owner_user_id, status, tenant_id) `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`, VALUES ($1,'活体市场智能体-handoff','market agent handoff e2e 发布者来源','user',2,'active',1) RETURNING id`,
[HANDOFF_AGENT_KEY] [HANDOFF_AGENT_KEY]
); );
const publisherAgentId = publisherAgent.rows[0].id as number; const publisherAgentId = publisherAgent.rows[0].id as number;
const publisherVersion = await client.query( const publisherVersion = await client.query(
`INSERT INTO muse_agent_version (agent_id, version, config, status, tenant_id) `INSERT INTO muse_agent_version (agent_id, version, config, status, tenant_id)
VALUES ($1,'1',$2::jsonb,'active',1) RETURNING 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, publisherAgentId,
JSON.stringify({ ]);
modelKey: 'MiniMax-M2.5', await client.query(
promptTemplateVersion: 'prompt-v1', `UPDATE muse_market_asset
}), SET asset_type='agent',
] source_id=$1,
); publisher_id=2,
await client.query('UPDATE muse_agent SET current_version_id=$1 WHERE id=$2', [ category='general',
publisherVersion.rows[0].id, tags=coalesce(tags, '{}'::jsonb) || $2::jsonb,
publisherAgentId, update_time=CURRENT_TIMESTAMP
]); WHERE tenant_id=1 AND id=1 AND deleted=false`,
await client.query( [
`UPDATE muse_market_asset publisherAgentId,
SET asset_type='agent', JSON.stringify({
source_id=$1, sourceOwner: 'agent',
publisher_id=2, sourceType: 'agent',
category='general', sourceRevision: '1',
tags=coalesce(tags, '{}'::jsonb) || $2::jsonb, }),
update_time=CURRENT_TIMESTAMP ]
WHERE tenant_id=1 AND id=1 AND deleted=false`, );
[ // work4 槽位初始绑现有 agent1(被兑现替换成安装者本地 user agent);precheck revision 从 1 起。
publisherAgentId, await client.query(
JSON.stringify({ `INSERT INTO muse_agent_slot_binding (work_id, slot_key, agent_id, agent_version, status, revision, tenant_id)
sourceOwner: 'agent', VALUES (4,$1,1,'1','active',1,1)`,
sourceType: 'agent', [HANDOFF_SLOT_KEY]
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),供兑现闭环幂等重跑。 // 14) handoff-content(P3):清理 e2e 正路兑现产生的 asset_use precheck(work4),供兑现闭环幂等重跑。
// asset_use 不绑目标实体(单表 precheck 即使用凭证),物理删释放 (tenant_id,command_id) uk;asset 1 + work4 无需额外 seed。 // 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'); 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)。 // 15a) AI 真生成主链:复位 work1 writing.continuation 槽位到固定起点(agent1、revision=1、active)。
// accept-suggestion / ai-generation 均依赖 work1 解析到 active Agent该槽位不能被其它 e2e 写坏。 // accept-suggestion / ai-generation 均依赖 work1 解析到 active Agent该槽位不能被其它 e2e 写坏。
@ -432,226 +440,217 @@ async function globalSetup(): Promise<void> {
VALUES (2,'writing.continuation',1,'1','active',1,'user',1)` VALUES (2,'writing.continuation',1,'1','active',1,'user',1)`
); );
// 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的 if (enableMarketFixture) {
// 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链, // 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的
// UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装, // 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链,
// 安装按钮的渲染条件(isAcquired && !isInstalled)不再成立 → 断言必红。故每轮删净 user1 在该资产上的 // UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装,
// authz/installation/command,保证 UI 起点恒为「可获取、未安装」。资产本身幂等保留(只删用户态)。 // 安装按钮的渲染条件(isAcquired && !isInstalled)不再成立 → 断言必红。故每轮删净 user1 在该资产上的
const MARKET_INSTALL_ASSET = '活体市场资产·安装e2e'; // authz/installation/command,保证 UI 起点恒为「可获取、未安装」。资产本身幂等保留(只删用户态)。
let installE2eAssetId: number; const MARKET_INSTALL_ASSET = '活体市场资产·安装e2e';
const existed = await client.query( let installE2eAssetId: number;
"SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false", const existed = await client.query(
[MARKET_INSTALL_ASSET] "SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false",
);
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] [MARKET_INSTALL_ASSET]
); );
installE2eAssetId = a.rows[0].id as number; if (existed.rowCount && existed.rows[0]) {
const v = await client.query( installE2eAssetId = existed.rows[0].id as number;
`INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id) } else {
VALUES ($1,'1.0.0','首发','published','2','2',1) RETURNING id`, // 资产首建:listed agent + 一条 published 版本,并回填 current_version_id(详情/安装需可见且有版本)。
[installE2eAssetId] const a = await client.query(
); `INSERT INTO muse_market_asset
await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE id=$2', [ (name, description, asset_type, category, publisher_id, listing_status, license_type, status, creator, updater, tenant_id)
v.rows[0].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, 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( await client.query(
`UPDATE muse_market_asset 'DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1',
SET description='知识库 handoff e2e 专用资产', [installE2eAssetId]
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 { // UI 随机 commandId 的 purchase/install 命令记录按 target_id 清(target_id 存资产 id 字符串),避免幂等回放。
const asset = await client.query( await client.query("DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1", [
`INSERT INTO muse_market_asset String(installE2eAssetId),
(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', // 17) 复位种子资产 asset1「活体市场资产·测试」为上架态(listing_status='listed'、status='active')。
$3::jsonb,'2','2',1) const asset1Reset = await client.query(
RETURNING id`, "UPDATE muse_market_asset SET listing_status='listed', status='active' WHERE tenant_id=1 AND id=1 AND deleted=false"
[MARKET_KB_HANDOFF_ASSET, handoffPublisherKbId, handoffTags]
); );
handoffKbAssetId = Number(asset.rows[0].id); if (asset1Reset.rowCount === 0) {
} console.warn('[e2e globalSetup] ⚠️ 未找到 asset id=1「活体市场资产·测试」(handoff/market 系列正路用例需该种子资产)');
if (handoffKbVersionId == null) { }
const version = await client.query(
`INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id) // 18) handoff-knowledge / market-handoff-precheck:seed 一个专用 listed knowledge_base 资产。
VALUES ($1,'1.0.0','知识库 handoff e2e 首发','published','2','2',1) const MARKET_KB_HANDOFF_ASSET = '活体市场知识库·handoff e2e';
RETURNING id`, 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] [handoffKbAssetId]
); );
handoffKbVersionId = Number(version.rows[0].id); for (const row of handoffInstalledKbs.rows) {
await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE tenant_id=1 AND id=$2', [ const kbId = Number(row.id);
handoffKbVersionId, 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, handoffKbAssetId,
]); ]);
} else { await client.query('DELETE FROM muse_market_authorization_summary WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [
await client.query( handoffKbAssetId,
`UPDATE muse_market_asset_version ]);
SET version='1.0.0', await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [
change_note='知识库 handoff e2e 首发', handoffKbAssetId,
status='published', ]);
updater='2', await client.query('DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1', [
update_time=CURRENT_TIMESTAMP handoffKbAssetId,
WHERE tenant_id=1 AND id=$1`, ]);
[handoffKbVersionId] 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 槽位(去污)。 // 19) 清理 work1/work2 除 writing.continuation 外的多余 active 槽位(去污)。
// WHY:agent-slot-bind.spec 假设 work2 仅一个开放槽位(writing.continuation,§15b 每轮复位),据此用 // WHY:agent-slot-bind.spec 假设 work2 仅一个开放槽位(writing.continuation,§15b 每轮复位),据此用
@ -736,7 +735,7 @@ async function globalSetup(): Promise<void> {
); );
const uploadKbId = Number(uploadKb.rows[0].id); 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 { } finally {
await client.end(); await client.end();
} }

View File

@ -1,6 +1,8 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* handoff agent e2e绿 * handoff agent e2e绿
* *

View File

@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* handoff content(asset_use) e2e(,绿) * handoff content(asset_use) e2e(,绿)
* *

View File

@ -1,6 +1,8 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* handoff knowledge e2e(,MSW off,绿) * handoff knowledge e2e(,MSW off,绿)
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* /(,MSW off) * /(,MSW off)
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* e2e( blocked,,MSW off) * e2e( blocked,,MSW off)
* *

View File

@ -5,8 +5,7 @@ import { expect, test, type Page } from '@playwright/test';
* *
* 前置:vite VITE_API_MOCK=false ( MSW) muse-server 48080muse_slice_live * 前置:vite VITE_API_MOCK=false ( MSW) muse-server 48080muse_slice_live
* 目的:验证 rendered studio client.ts tenant-id HTTP , * 目的:验证 rendered studio client.ts tenant-id HTTP ,
* content()/market()/account() BC UI * content()/account()/ai() UI
* (MSW mock )
*/ */
const TOKEN = 'test1'; const TOKEN = 'test1';
@ -25,7 +24,7 @@ test.describe('活体只读旅程(真实后端,MSW off)', () => {
await expect(page.getByText('活体验证样书').first()).toBeVisible({ timeout: 15_000 }); await expect(page.getByText('活体验证样书').first()).toBeVisible({ timeout: 15_000 });
}); });
test('市场浏览渲染真实资产(market)', async ({ page }) => { test.skip('市场浏览渲染真实资产(market)', async ({ page }) => {
await seedToken(page); await seedToken(page);
await page.goto('/market'); await page.goto('/market');
// .first():推荐位与资产列表可能同时渲染同一资产(推荐区新增),strict 模式需唯一匹配;渲染即证真实市场数据已加载。 // .first():推荐位与资产列表可能同时渲染同一资产(推荐区新增),strict 模式需唯一匹配;渲染即证真实市场数据已加载。
@ -44,14 +43,14 @@ test.describe('活体只读旅程(真实后端,MSW off)', () => {
await expect(page.getByText('活体测试智能体').first()).toBeVisible({ timeout: 15_000 }); await expect(page.getByText('活体测试智能体').first()).toBeVisible({ timeout: 15_000 });
}); });
test('个人中心渲染真实购买/授权记录(account 深页)', async ({ page }) => { test.skip('个人中心渲染真实购买/授权记录(account 深页)', async ({ page }) => {
await seedToken(page); await seedToken(page);
await page.goto('/account'); await page.goto('/account');
// 个人中心「我的购买 / 我的授权」渲染 market→account 投影的真实资产(后端经 market 跨模块 Facade 提供curl 已证)。 // 个人中心「我的购买 / 我的授权」渲染 market→account 投影的真实资产(后端经 market 跨模块 Facade 提供curl 已证)。
await expect(page.getByText('活体市场资产·测试').first()).toBeVisible({ timeout: 15_000 }); await expect(page.getByText('活体市场资产·测试').first()).toBeVisible({ timeout: 15_000 });
}); });
test('个人中心渲染真实安全事件(account 深页)', async ({ page }) => { test.skip('个人中心渲染真实安全事件(account 深页)', async ({ page }) => {
await seedToken(page); await seedToken(page);
await page.goto('/account'); await page.goto('/account');
// 个人中心「安全事件」区渲染 GET /account/security-events 的真实摘要(按登录用户隔离;种子见 /tmp/SeedSec.javacurl 已证)。 // 个人中心「安全事件」区渲染 GET /account/security-events 的真实摘要(按登录用户隔离;种子见 /tmp/SeedSec.javacurl 已证)。

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* e2e(,,MSW off) * e2e(,,MSW off)
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; 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 ) * e2e(,MSW off)A :后端 getGovernanceImpact studio 0 (C14 )
* *

View File

@ -1,6 +1,8 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* e2e( handoff ,,MSW off) * e2e( handoff ,,MSW off)
* *

View File

@ -1,6 +1,8 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* D0-fork-04 U-verify installed_ref studio e2eMSW off * D0-fork-04 U-verify installed_ref studio e2eMSW off
* *

View File

@ -1,6 +1,8 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* UI e2e(,MSW off,绿) * UI e2e(,MSW off,绿)
* *

View File

@ -1,6 +1,8 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* E4 Market KB Knowledge e2e + PG * E4 Market KB Knowledge e2e + PG
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/** /**
* 稿 e2eMSW off * 稿 e2eMSW off
* *

View File

@ -1,5 +1,7 @@
import { expect, test, type Page } from '@playwright/test'; 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 * e2e(,MSW off)A 类切片:后端 GET /marketplace/recommendations studio
* *

View File

@ -0,0 +1,22 @@
import { expect, test, type Page } from '@playwright/test';
const TOKEN = 'test1';
async function seedToken(page: Page): Promise<void> {
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/');
});
});

View File

@ -1,18 +1,12 @@
import { createBrowserRouter } from 'react-router-dom'; import { createBrowserRouter, Navigate } from 'react-router-dom';
import AuthGuard from '@/app/routes/AuthGuard'; import AuthGuard from '@/app/routes/AuthGuard';
import AppLayout from '@/components/layout/AppLayout'; import AppLayout from '@/components/layout/AppLayout';
import WorkspacePage from '@/pages/WorkspacePage'; import WorkspacePage from '@/pages/WorkspacePage';
import WorkListPage from '@/features/editor/components/WorkListPage'; import WorkListPage from '@/features/editor/components/WorkListPage';
import EditorPage from '@/pages/EditorPage';
import KnowledgePage from '@/pages/KnowledgePage'; import KnowledgePage from '@/pages/KnowledgePage';
import AgentPage from '@/pages/AgentPage'; 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 AccountPage from '@/pages/AccountPage';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
/** /**
* *
@ -32,16 +26,11 @@ export const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <WorkListPage /> }, { index: true, element: <WorkListPage /> },
{ path: 'workspace/:workId', element: <WorkspacePage /> }, { path: 'workspace/:workId', element: <WorkspacePage /> },
{ path: 'works/:workId/editor/:chapterId', element: <EditorPage /> },
{ path: 'knowledge', element: <KnowledgePage /> }, { path: 'knowledge', element: <KnowledgePage /> },
{ path: 'knowledge/:workId', element: <KnowledgePage /> }, { path: 'knowledge/:workId', element: <KnowledgePage /> },
{ path: 'agents', element: <AgentPage /> }, { path: 'agents', element: <AgentPage /> },
{ path: 'market', element: <MarketPage /> },
{ path: 'market/assets/:assetId', element: <MarketAssetDetailPage /> },
{ path: 'market/publish', element: <MarketPublishPage /> },
{ path: 'market/appeals', element: <MarketAppealsPage /> },
{ path: 'account', element: <AccountPage /> }, { path: 'account', element: <AccountPage /> },
{ path: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> }, { path: '*', element: <Navigate to="/" replace /> },
], ],
}, },
], ],

View File

@ -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');
});
});

View File

@ -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(
<MemoryRouter>
<Sidebar />
</MemoryRouter>,
);
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();
});
});

View File

@ -15,8 +15,6 @@ const navItems: NavItem[] = [
{ to: '/', label: '作品', icon: '📝' }, { to: '/', label: '作品', icon: '📝' },
{ to: '/knowledge', label: '知识库', icon: '📚' }, { to: '/knowledge', label: '知识库', icon: '📚' },
{ to: '/agents', label: '智能体', icon: '🤖' }, { to: '/agents', label: '智能体', icon: '🤖' },
{ to: '/market', label: '市场', icon: '🏪' },
{ to: '/market/appeals', label: '治理', icon: '⚖️' },
{ to: '/account', label: '个人', icon: '👤' }, { to: '/account', label: '个人', icon: '👤' },
]; ];

View File

@ -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(
<QueryClientProvider client={queryClient}>
<PersonalCenter />
</QueryClientProvider>,
);
}
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();
});
});

View File

@ -1,65 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
type SecurityEventAction,
useAccountEntitlements, useAccountEntitlements,
useAccountLicenses,
useAccountNewApiBinding,
useAccountProfile, useAccountProfile,
useAccountPublishRecords,
useAccountPurchases,
useAccountSecurityEvents,
useAccountUsage, useAccountUsage,
useAcknowledgeSecurityEvent,
useUpdateAccountProfile, useUpdateAccountProfile,
} from '../hooks/useAccount'; } from '../hooks/useAccount';
import UsageStats from './UsageStats'; 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<SecurityEventAction, string> = {
acknowledged: '知晓',
password_changed: '已改密',
session_revoked: '撤销会话',
false_positive: '误报',
};
/** 安全事件处理动作按钮,严格限于后端契约允许的 action 枚举。 */
const securityActions: SecurityEventAction[] = ['acknowledged', 'password_changed', 'session_revoked', 'false_positive'];
/** New-API 绑定状态 → 中文文案。 */
const newApiBindingLabels: Record<string, string> = {
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() { export default function PersonalCenter() {
const { data: profile, isLoading: isProfileLoading } = useAccountProfile(); const { data: profile, isLoading: isProfileLoading } = useAccountProfile();
const { data: entitlements } = useAccountEntitlements(); const { data: entitlements } = useAccountEntitlements();
const { data: usage } = useAccountUsage('month'); 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 updateProfile = useUpdateAccountProfile();
const [nickname, setNickname] = useState(''); const [nickname, setNickname] = useState('');
const [publicPenName, setPublicPenName] = useState(''); const [publicPenName, setPublicPenName] = useState('');
const [securityActionResult, setSecurityActionResult] = useState<string | null>(null);
const activeNickname = nickname || profile?.nickname || ''; const activeNickname = nickname || profile?.nickname || '';
const activePenName = publicPenName || profile?.publicPenName || ''; 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 ( return (
<div className="min-h-screen bg-slate-50/60 px-6 py-8"> <div className="min-h-screen bg-slate-50/60 px-6 py-8">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
@ -154,178 +92,6 @@ export default function PersonalCenter() {
<div className="mt-6"> <div className="mt-6">
<UsageStats usage={usage} entitlements={entitlements} /> <UsageStats usage={usage} entitlements={entitlements} />
</div> </div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<section className="rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-800"></h2>
{(purchases?.list?.length ?? 0) === 0 ? (
<p className="mt-3 text-sm text-slate-400"></p>
) : (
<ul className="mt-3 divide-y divide-slate-50">
{purchases?.list?.map((rec) => (
<li key={rec.recordId} className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
{rec.assetType}
</span>
<strong className="truncate text-sm font-semibold text-slate-800">{rec.assetName}</strong>
</div>
<div className="mt-1 text-xs text-slate-400">{rec.authorizationResultSummary || rec.status}</div>
</div>
<span className="shrink-0 rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-bold text-emerald-700">
{rec.status}
</span>
</li>
))}
</ul>
)}
</section>
<section className="rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-800"></h2>
{(licenses?.list?.length ?? 0) === 0 ? (
<p className="mt-3 text-sm text-slate-400"></p>
) : (
<ul className="mt-3 divide-y divide-slate-50">
{licenses?.list?.map((rec) => (
<li key={rec.licenseId} className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
{rec.assetType}
</span>
<strong className="truncate text-sm font-semibold text-slate-800">{rec.assetName}</strong>
</div>
<div className="mt-1 text-xs text-slate-400">
{rec.installSummary || rec.licenseStatus}{rec.publisherName ? ` · ${rec.publisherName}` : ''}
</div>
</div>
<span className="shrink-0 rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-bold text-indigo-700">
{rec.licenseStatus}
</span>
</li>
))}
</ul>
)}
</section>
</div>
<section className="mt-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-800"></h2>
{(publishRecords?.list?.length ?? 0) === 0 ? (
<p className="mt-3 text-sm text-slate-400"></p>
) : (
<ul className="mt-3 divide-y divide-slate-50">
{publishRecords?.list?.map((rec) => (
<li key={rec.recordId} className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
{rec.assetType}
</span>
<strong className="truncate text-sm font-semibold text-slate-800">{rec.assetName}</strong>
</div>
<div className="mt-1 text-xs text-slate-400">
{rec.marketStatus ?? '—'}
{rec.blockingReason ? ` · 阻塞:${rec.blockingReason}` : ''}
</div>
</div>
<span className="shrink-0 rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-bold text-amber-700">
{rec.reviewStatus}
</span>
</li>
))}
</ul>
)}
</section>
<section className="mt-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-800"></h2>
{securityActionResult && (
<p className="mt-3 rounded-lg bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-700">
{securityActionResult}
</p>
)}
{acknowledgeEvent.isError && (
<p className="mt-3 rounded-lg bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700">
{acknowledgeEvent.error.message}
</p>
)}
{(securityEvents?.list?.length ?? 0) === 0 ? (
<p className="mt-3 text-sm text-slate-400"></p>
) : (
<ul className="mt-3 divide-y divide-slate-50">
{securityEvents?.list?.map((evt) => (
<li key={evt.eventId} className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
{evt.eventType}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${severityBadgeClass(evt.severity)}`}>
{evt.severity}
</span>
</div>
<div className="mt-1 truncate text-sm font-semibold text-slate-800">
{evt.description || evt.eventType}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{!evt.acknowledged && securityActions.map((action) => (
<button
key={`${evt.eventId}-${action}`}
type="button"
onClick={() => handleSecurityAction(evt.eventId, action)}
disabled={
acknowledgeEvent.isPending &&
typeof acknowledgeEvent.variables !== 'string' &&
acknowledgeEvent.variables?.eventId === evt.eventId
}
className={`rounded-full px-3 py-1 text-[11px] font-semibold transition disabled:bg-slate-300 disabled:text-white ${
action === 'session_revoked'
? 'bg-rose-600 text-white hover:bg-rose-700'
: 'bg-slate-900 text-white hover:bg-indigo-700'
}`}
>
{acknowledgeEvent.isPending &&
typeof acknowledgeEvent.variables !== 'string' &&
acknowledgeEvent.variables?.eventId === evt.eventId &&
acknowledgeEvent.variables.action === action
? '处理中...'
: securityActionLabels[action]}
</button>
))}
<span
className={`rounded-full px-2.5 py-1 text-[11px] font-bold ${
evt.acknowledged ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-700'
}`}
>
{evt.acknowledged ? '已确认' : '待确认'}
</span>
</div>
</li>
))}
</ul>
)}
</section>
{/* New-API 绑定摘要:展示配额/计费外部账户绑定态(bound/unbound/sync_failed/pending)。后端就绪、此前 0 消费。 */}
<section className="mt-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-800">New-API </h2>
{!newApiBinding ? (
<p className="mt-3 text-sm text-slate-400"></p>
) : (
<div className="mt-3 flex items-center gap-3">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${newApiBindingBadgeClass(newApiBinding.bindingStatus)}`}>
{newApiBindingLabels[newApiBinding.bindingStatus] ?? newApiBinding.bindingStatus}
</span>
{newApiBinding.lastSyncAt && (
<span className="text-xs text-slate-400">{newApiBinding.lastSyncAt}</span>
)}
</div>
)}
</section>
</div> </div>
</div> </div>
); );

View File

@ -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/revisionsourceAgentId 必须由后端按 market asset 解析,不能信 URL。
function renderLanding(search = '?slotKey=writer') {
const client = createTestQueryClient();
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[`/handoff/land/agent${search}`]}>
<AgentHandoffLanding />
</MemoryRouter>
</QueryClientProvider>
);
}
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();
});
});

View File

@ -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<string | null>(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 (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-3 text-sm text-emerald-700"> #{targetWorkId} {slotKey}</p>
<button
type="button"
onClick={() => navigate(returnUrl || '/market')}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
>
</button>
</div>
);
}
if (!ready) {
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<p className="text-sm text-amber-700">,</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-3 text-sm text-slate-500">
#{targetWorkId} {slotKey}
</p>
{error && (
<p className="mt-3 text-sm text-rose-600" role="alert">
{error}
</p>
)}
<button
type="button"
onClick={confirmBinding}
disabled={pending}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50"
>
{pending ? '处理中…' : '确认绑定'}
</button>
</div>
);
}

View File

@ -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(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/handoff/land/content']}>
<ContentHandoffLanding />
</MemoryRouter>
</QueryClientProvider>
);
}
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();
});
});

View File

@ -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<string | null>(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<BindPrecheckResult>(`/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 (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900">使</h1>
<p className="mt-3 text-sm text-emerald-700">
#{targetWorkId}( / AI )
</p>
<button
type="button"
onClick={() => navigate(returnUrl || '/market')}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
>
</button>
</div>
);
}
if (!ready) {
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<p className="text-sm text-amber-700">,</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900">使</h1>
<p className="mt-3 text-sm text-slate-500">
#{targetWorkId}( / AI )使
</p>
{error && (
<p className="mt-3 text-sm text-rose-600" role="alert">
{error}
</p>
)}
<button
type="button"
onClick={confirmUse}
disabled={pending}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50"
>
{pending ? '处理中…' : '确认使用'}
</button>
</div>
);
}

View File

@ -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(
<QueryClientProvider client={client}>
<MemoryRouter>
<KnowledgeHandoffLanding />
</MemoryRouter>
</QueryClientProvider>
);
}
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();
});
});

View File

@ -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<string | null>(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 (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-3 text-sm text-emerald-700"> #{targetWorkId}</p>
<button
type="button"
onClick={() => navigate(returnUrl || '/knowledge')}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
>
</button>
</div>
);
}
if (!ready) {
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<p className="text-sm text-amber-700">,</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="mt-3 text-sm text-slate-500">
#{targetWorkId}
</p>
{error && (
<p className="mt-3 text-sm text-rose-600" role="alert">
{error}
</p>
)}
<button
type="button"
onClick={confirmBinding}
disabled={pending}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50"
>
{pending ? '处理中…' : '确认绑定'}
</button>
</div>
);
}

View File

@ -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 (marketagent 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<AgentSlotPrecheckResult, Error, AgentSlotPrecheckInput>({
mutationFn: ({ workId, slotKey, ...body }) =>
api.post<AgentSlotPrecheckResult>(`/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<AgentSlotBindResult, Error, BindAgentSlotInput>({
mutationFn: ({ workId, slotKey, ...body }) =>
api.post<AgentSlotBindResult>(`/works/${workId}/agent-slots/${slotKey}/bind`, {
commandId: createCommandId(),
...body,
}),
});
}

View File

@ -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 使(marketcontent 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<AssetUsePrecheckResult, Error, AssetUsePrecheckInput>({
mutationFn: ({ workId, ...body }) =>
api.post<AssetUsePrecheckResult>(`/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<AssetUseResult, Error, CreateAssetUseInput>({
mutationFn: ({ workId, ...body }) =>
api.post<AssetUseResult>(`/works/${workId}/asset-uses`, {
commandId: createCommandId(),
...body,
}),
});
}

View File

@ -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<KbBindPrecheckResult, Error, KbBindPrecheckInput>({
mutationFn: ({ workId, ...body }) =>
api.post<KbBindPrecheckResult>(`/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<KnowledgeBindingResult, Error, CreateKnowledgeBindingInput>({
mutationFn: ({ workId, kbBindPrecheckId, expectedWorkRevision }) =>
api.post<KnowledgeBindingResult>(`/works/${workId}/knowledge-bindings`, {
commandId: createCommandId(),
kbBindPrecheckId,
expectedWorkRevision,
}),
});
}

View File

@ -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: <HandoffLandingPage /> }],
{ initialEntries: [entry] }
);
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
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());
});
});

View File

@ -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 (
<div className="mx-auto max-w-2xl px-6 py-10">
<p className="text-sm text-slate-500"></p>
</div>
);
}
// 按 targetOwner 分发到对应 owner 落地组件;owner 组件自管兑现/成功/失败/跳转
if (targetOwner === 'knowledge') {
return <KnowledgeHandoffLanding />;
}
if (targetOwner === 'agent') {
return <AgentHandoffLanding />;
}
if (targetOwner === 'content') {
return <ContentHandoffLanding />;
}
// 其余 owner(未来扩展)兜底
const hasSession = Boolean(handoffToken || precheckId);
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900"> · {targetOwner}</h1>
{hasSession ? (
<p className="mt-3 text-sm text-slate-500">({targetOwner})</p>
) : (
<p className="mt-3 text-sm text-amber-700">,</p>
)}
</div>
);
}

View File

@ -1,117 +0,0 @@
import React, { useEffect } from 'react';
import { useKBPublishReadiness } from '@/features/knowledge/hooks/useKnowledge';
/** 权利/隐私/处理三态中文标签(未知码原样)。 */
const STATUS_LABELS: Record<string, string> = {
ready: '已就绪',
needs_verification: '待核验',
failed: '校验失败',
pending: '处理中',
};
/** 受阻原因中文标签(已知码,未知原样回显)。 */
const BLOCKED_REASON_LABELS: Record<string, string> = {
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-bold text-slate-900"> · </h2>
<p className="mt-1 text-sm text-slate-500">
{kbName} / /
</p>
{precheck.isPending && <p className="mt-4 text-sm text-slate-400"></p>}
{precheck.isError && (
<p className="mt-4 text-sm text-rose-600">{precheck.error.message}</p>
)}
{readiness && (
<div className="mt-4 space-y-3">
{/* 诚实结论:blocked 时明示暂不可发布,绝不伪造可发布 */}
<div
className={`rounded-xl px-4 py-3 text-sm font-semibold ${
readiness.blocked ? 'bg-amber-50 text-amber-800' : 'bg-emerald-50 text-emerald-800'
}`}
>
{readiness.blocked ? '⚠️ 暂不可发布' : '✓ 可继续发布'}
</div>
{readiness.blocked && readiness.blockedReasons.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400"></h3>
<ul className="mt-1 space-y-1">
{readiness.blockedReasons.map((r) => (
<li key={r} className="text-sm text-rose-700">
{BLOCKED_REASON_LABELS[r] ?? r}
</li>
))}
</ul>
</div>
)}
<dl className="space-y-1.5 rounded-xl border border-slate-100 p-4 text-sm">
<div className="flex gap-2">
<dt className="w-16 shrink-0 text-slate-400"></dt>
<dd className="font-medium text-slate-700">{statusLabel(readiness.rightsStatus)}</dd>
</div>
<div className="flex gap-2">
<dt className="w-16 shrink-0 text-slate-400"></dt>
<dd className="font-medium text-slate-700">{statusLabel(readiness.privacyStatus)}</dd>
</div>
<div className="flex gap-2">
<dt className="w-16 shrink-0 text-slate-400"></dt>
<dd className="font-medium text-slate-700">{statusLabel(readiness.processingStatus)}</dd>
</div>
</dl>
</div>
)}
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-xl bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-200"
>
</button>
</div>
</div>
</div>
);
};
export default KnowledgePublishModal;

View File

@ -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<MuseEditorInstance | null>(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 (
<div className="flex h-full w-full bg-gray-50 overflow-hidden select-none">
{/* 1. 左侧章节目录大纲面板 (w-64) */}
<div className="w-64 border-r border-gray-200 bg-white flex flex-col h-full z-10 shadow-sm">
<div className="p-4 border-b border-gray-100 bg-gray-50/50">
<h2 className="font-bold text-sm text-gray-800 flex items-center gap-1.5">
<span>📖</span>
</h2>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1">
{chapters.map((ch) => (
<button
key={ch.id}
onClick={() => setSelectedChapterId(ch.id)}
className={`w-full text-left px-3 py-2.5 rounded-xl transition-all flex flex-col gap-1 ${
selectedChapterId === ch.id
? 'bg-purple-50 text-purple-600 font-semibold shadow-inner'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<span className="text-xs truncate">{ch.title}</span>
<span className="text-[10px] opacity-70">{ch.wordCount} </span>
</button>
))}
</div>
</div>
{/* 2. 中间核心编辑器主框架 */}
<div className="flex-1 flex flex-col h-full overflow-hidden bg-white">
{/* 编辑器上方功能展示栏 */}
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between z-10 bg-white shadow-sm">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<span>/</span>
<span className="font-semibold text-gray-800 truncate max-w-[200px]">
{selectedChapter?.title}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-gray-400">
<span>{selectedChapter?.wordCount || 0}</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
</span>
</div>
</div>
{/* Tiptap 编辑器容器 */}
<div className="flex-1 overflow-hidden">
<MuseEditor
blockId={`b-${selectedChapterId}`}
workId="demo-work"
revision={1}
initialContent={null}
onEditorReady={(editor) => {
editorRef.current = editor;
}}
/>
</div>
</div>
{/* 3. 右侧多模式 AI 功能面板 */}
<div className="h-full flex overflow-hidden">
{showCompare ? (
<CandidatePanel
originalText={originalText}
candidateText={candidateText}
onAccept={handleAcceptDiff}
onReject={handleRejectDiff}
/>
) : (
<AIPanel
onCandidateGenerated={handleCandidateGenerated}
workId="demo-work"
blockId={`b-${selectedChapterId}`}
chapterId={selectedChapterId}
/>
)}
</div>
</div>
);
}

View File

@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/knowledge']}>
<Routes>
<Route path="/knowledge" element={<KnowledgePage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
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();
});
});
});

View File

@ -4,34 +4,28 @@ import KnowledgeDraftPanel from '@/features/knowledge/components/KnowledgeDraftP
import KnowledgeGraphPanel from '@/features/knowledge/components/KnowledgeGraphPanel'; import KnowledgeGraphPanel from '@/features/knowledge/components/KnowledgeGraphPanel';
import KnowledgeBindingsPanel from '@/features/knowledge/components/KnowledgeBindingsPanel'; import KnowledgeBindingsPanel from '@/features/knowledge/components/KnowledgeBindingsPanel';
import KnowledgeRetrievalPanel from '@/features/knowledge/components/KnowledgeRetrievalPanel'; 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 type { KnowledgeBaseVO } from '@/features/knowledge/hooks/useKnowledge';
import CreateKBModal from '@/features/knowledge/components/CreateKBModal'; import CreateKBModal from '@/features/knowledge/components/CreateKBModal';
import KnowledgePublishModal from '@/features/knowledge/components/KnowledgePublishModal';
import MaterialManager from '@/features/knowledge/components/MaterialManager'; import MaterialManager from '@/features/knowledge/components/MaterialManager';
import { isKnowledgeBaseSummary } from '@/features/knowledge/utils/typeGuards'; import { isKnowledgeBaseSummary } from '@/features/knowledge/utils/typeGuards';
/** /**
* KnowledgePage * KnowledgePage
* Zod *
*/ */
export default function KnowledgePage() { export default function KnowledgePage() {
// 作品维度路由(/knowledge/:workId)携带 workId 时,展示该作品的"待确认知识草稿"工作台(先审后入)。 // 作品维度路由(/knowledge/:workId)携带 workId 时,展示该作品的"待确认知识草稿"工作台(先审后入)。
const { workId } = useParams<{ workId: string }>(); 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 [isModalOpen, setIsModalOpen] = useState(false);
const [selectedKbId, setSelectedKbId] = useState<string | null>(null); const [selectedKbId, setSelectedKbId] = useState<string | null>(null);
// 发布到市场:当前选中预检的知识库(自建库专属);为空时不渲染预检弹层。
const [publishKb, setPublishKb] = useState<KnowledgeBaseVO | null>(null);
// 1. 获取知识库列表及对应的删除与卸载 Mutation Hooks // 1. 获取知识库列表及对应的删除 Mutation Hooks
const { data, isLoading, isError } = useKnowledgeList(activeTab); const { data, isLoading, isError } = useKnowledgeList(activeTab);
const { mutate: deleteKB } = useKnowledgeDelete(); const { mutate: deleteKB } = useKnowledgeDelete();
const { mutate: uninstallKB } = useInstalledKnowledgeUninstall();
const { mutate: disableKB } = useKnowledgeDisable(); const { mutate: disableKB } = useKnowledgeDisable();
const { mutate: restoreKB } = useKnowledgeRestore(); const { mutate: restoreKB } = useKnowledgeRestore();
const { mutate: disableInstalledKB } = useInstalledKnowledgeDisable();
const { mutate: restoreInstalledKB } = useInstalledKnowledgeRestore();
// 2. 严格的运行期类型收窄与防空校验 // 2. 严格的运行期类型收窄与防空校验
const rawList = data?.list || []; 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) => { const handleDisableKB = (e: React.MouseEvent, kbId: string, name: string) => {
e.stopPropagation(); e.stopPropagation();
@ -73,19 +60,6 @@ export default function KnowledgePage() {
restoreKB(kbId); 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) // 3. 拦截状态:如果选中了具体知识库,渲染资料文件管理器 (Detail/Materials Panel)
if (selectedKbId) { if (selectedKbId) {
const activeKb = knowledgeBases.find((kb) => kb.kbId === selectedKbId); const activeKb = knowledgeBases.find((kb) => kb.kbId === selectedKbId);
@ -150,16 +124,6 @@ export default function KnowledgePage() {
> >
</button> </button>
<button
onClick={() => setActiveTab('installed')}
className={`pb-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'installed'
? 'border-teal-600 text-teal-600'
: 'border-transparent text-slate-400 hover:text-slate-600'
}`}
>
</button>
<button <button
onClick={() => setActiveTab('global')} onClick={() => setActiveTab('global')}
className={`pb-3 text-sm font-semibold border-b-2 transition-all ${ className={`pb-3 text-sm font-semibold border-b-2 transition-all ${
@ -194,15 +158,11 @@ export default function KnowledgePage() {
<h3 className="text-lg font-bold text-slate-800"> <h3 className="text-lg font-bold text-slate-800">
{activeTab === 'user' {activeTab === 'user'
? '暂无自建知识库' ? '暂无自建知识库'
: activeTab === 'installed'
? '暂无已安装知识库'
: '无公开授权知识库'} : '无公开授权知识库'}
</h3> </h3>
<p className="text-sm text-slate-500 mt-2 text-center max-w-sm"> <p className="text-sm text-slate-500 mt-2 text-center max-w-sm">
{activeTab === 'user' {activeTab === 'user'
? '知识库可以帮助 AI 深刻理解您小说的大纲设定。赶快创建一个世界观或人物卡知识库吧!' ? '知识库可以帮助 AI 深刻理解您小说的大纲设定。赶快创建一个世界观或人物卡知识库吧!'
: activeTab === 'installed'
? '您可以前往创作市场,下载行业专家整理的背景资料、神话体系大百科等现成资产。'
: '管理员暂未发布任何全局公共授权库。'} : '管理员暂未发布任何全局公共授权库。'}
</p> </p>
{activeTab === 'user' && ( {activeTab === 'user' && (
@ -239,20 +199,8 @@ export default function KnowledgePage() {
{/* 操作按钮权限硬流控隔离 */} {/* 操作按钮权限硬流控隔离 */}
{kb.source === 'user' ? ( {kb.source === 'user' ? (
// 自建:发布到市场(可发布性预检入口)+ 删除 // 自建:停用/恢复 + 删除
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setPublishKb(kb);
}}
type="button"
title="发布到市场"
aria-label={`发布知识库 ${kb.name}`}
className="rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-teal-600 transition-all hover:bg-teal-50 hover:text-teal-800"
>
</button>
{/* 停用/恢复:按 status 切换(active 显示停用,否则显示恢复);软状态管理,区别于物理删除。 */} {/* 停用/恢复:按 status 切换(active 显示停用,否则显示恢复);软状态管理,区别于物理删除。 */}
{kb.status === 'active' ? ( {kb.status === 'active' ? (
<button <button
@ -286,41 +234,6 @@ export default function KnowledgePage() {
</svg> </svg>
</button> </button>
</div> </div>
) : kb.source === 'installed' ? (
// 已安装:停用/恢复(按 status 切换)+ 卸载
<div className="flex items-center gap-1">
{kb.status === 'active' ? (
<button
onClick={(e) => handleDisableInstalled(e, kb.installId ?? kb.kbId, kb.name)}
type="button"
title="停用已安装知识库"
aria-label={`停用已安装知识库 ${kb.name}`}
className="rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 transition-all hover:bg-amber-50 hover:text-amber-800"
>
</button>
) : (
<button
onClick={(e) => handleRestoreInstalled(e, kb.installId ?? kb.kbId)}
type="button"
title="恢复已安装知识库"
aria-label={`恢复已安装知识库 ${kb.name}`}
className="rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 transition-all hover:bg-emerald-50 hover:text-emerald-800"
>
</button>
)}
<button
onClick={(e) => handleUninstallKB(e, kb.installId ?? kb.kbId, kb.name)}
type="button"
title="卸载知识库"
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-rose-600 p-1.5 hover:bg-rose-50 rounded-lg transition-all duration-200"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M10 14l2-2 2 2m0-4l-2 2-2-2m5-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
) : ( ) : (
// 全局只读,隐藏所有删除/卸载按钮 // 全局只读,隐藏所有删除/卸载按钮
<span className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider bg-slate-50 px-2 py-0.5 rounded border border-slate-100"> <span className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider bg-slate-50 px-2 py-0.5 rounded border border-slate-100">
@ -375,16 +288,6 @@ export default function KnowledgePage() {
{/* 创建新知识库的弹框 */} {/* 创建新知识库的弹框 */}
<CreateKBModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} /> <CreateKBModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
{/* 知识库发布到市场:可发布性预检弹层(诚实呈现受阻态,不伪造可发布) */}
{publishKb && (
<KnowledgePublishModal
kbId={publishKb.kbId}
kbName={publishKb.name}
version={publishKb.currentVersion}
onClose={() => setPublishKb(null)}
/>
)}
</div> </div>
); );
} }

View File

@ -59,7 +59,7 @@ export default function LoginPage() {
</h1> </h1>
<p className="mt-4 max-w-lg text-sm leading-6 text-slate-200"> <p className="mt-4 max-w-lg text-sm leading-6 text-slate-200">
</p> </p>
</div> </div>
<p className="text-xs leading-5 text-slate-300"> <p className="text-xs leading-5 text-slate-300">

View File

@ -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);
});
});

View File

@ -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<HandoffState>()((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();
},
}));

View File

@ -38,21 +38,17 @@ function createStorageMock(): Storage {
}; };
} }
if (!globalThis.localStorage) { Object.defineProperty(globalThis, 'localStorage', {
Object.defineProperty(globalThis, 'localStorage', { value: createStorageMock(),
value: createStorageMock(), writable: true,
writable: true, configurable: true,
configurable: true, });
});
}
if (!globalThis.sessionStorage) { Object.defineProperty(globalThis, 'sessionStorage', {
Object.defineProperty(globalThis, 'sessionStorage', { value: createStorageMock(),
value: createStorageMock(), writable: true,
writable: true, configurable: true,
configurable: true, });
});
}
// 聚合所有子功能模块的 Mock 规则 // 聚合所有子功能模块的 Mock 规则
export const server = setupServer( export const server = setupServer(