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:
parent
c8a88e71b3
commit
6dcabf69fb
@ -31,6 +31,12 @@
|
||||
|
||||
**2.0.0 单人版改造 S3 market 摘装配(2026-07-08)**:已原子拨开关并完成验收。`muse-server` 默认保留 `muse-module-market-api`、停用 `muse-module-market-server`,`market-detached-test-tree` 默认生效;`market-assembled` profile 恢复 market-server 依赖与全测试树回编译。`MonolithFacadeFallbackAutoConfiguration` 为 `MarketHandoffTokenApi`、`MarketAssetSourceApi`、`MarketAssetForkApi` 增加 `@Bean @ConditionalOnMissingBean` fail-closed 兜底:verify 返回 `valid=false/MARKET_DETACHED`,consume 返回 `consumed=false`,asset source 返回 `notFound()`,fork 写回返回 `false`,首次调用 warn 日志;真实 market Bean 存在时不覆盖。覆盖 JSON 将 market 32 条 operation 改为 `dormant`,`summary.completedOperations=210`,未删 operation/testFiles/sourceFiles,未改 OpenAPI/DDL/业务实现/外部凭据。新增 `P1rMarketDetachedAssemblyGateTest` 证明 fallback、真实 Bean 优先、默认摘装配、`/app-api/market/**`/`/admin-api/market/**` 404 形态;Knowledge installed 列表补空结果断言,既有 market_kb binding notFound/fork-not-ready 测试继续覆盖 fail-closed。验收证据:server targeted gate `P1rApiCoverageReportTest`+`P1rMarketDetachedAssemblyGateTest` 13/0F/0E/0S;knowledge targeted `MuseKnowledgeBindingServiceTest`+`MuseInstalledKnowledgeBaseServiceTest` 25/0F/0E/0S;`-Pmarket-assembled -DskipTests test` 47 模块 reactor BUILD SUCCESS;`run-p1r-verification.sh local` 60/0F/0E/0S;`run-p1r-verification.sh real-pg` 真实 `_test` 库 143/0F/0E/0S,外部验收 opt-in 后 `P1rContentImportWizardCompletedApprovalIT` 无 skip 且 provider 200;coverage 计数核验 `total=242/completed=210/marketDormant=32/nonMarketDormant=0`。2026-07-08 裁决:publish-prechecks/snapshots/readiness 保留 Knowledge 本域预检/快照能力,允许写 readiness/snapshot/command 记录,但不得产生 market 资产、安装、授权事实;前端发布入口由 S5 删除。
|
||||
|
||||
**2.0.0 单人版改造 S4 配置与治理收口(2026-07-08)**:已完成 yudao 厂商脚手架、member 默认值、admin 菜单/New-API 页、solo 菜单 SQL 的收口。AI 配置层面,`muse-module-ai-server` 与 `muse-server` 两处 `application.yaml` 将 gemini/doubao/hunyuan/siliconflow/xinghuo/baichuan/midjourney/suno/web-search 九类 yudao provider 全部 `enable=false`,并把历史明文 key、Spring AI 示例 key、Midjourney/Suno URL 改为 env 占位;新增 `SoloExternalAiProviderGateTest` 防默认外部 provider Bean 回漂。member 层面,新增 `SoloAccountDefaultsGateTest` 固化 `muse.account.new-api.enabled=false` 与 publish/attribution/quota-request worker 默认关闭,现有 `@ConditionalOnMissingBean` facade 缺 owner 时 fail-closed;`muse-cloud/scripts/dev/solo-compose.env.example` 给 S9 compose 预留同一 env 口径,租户机制保持开启且请求固定 `tenant-id: 1`。admin 层面,`muse.ts` 隐藏「市场治理」「用户与权限」菜单但保留路由;`views/muse/newapi` 降级为本地用量/余额/外部调用只读观测,删除网关绑定、额度配置、归属 job 三类写入口;demo `leave/pay/ai/bpm/member` 顶级路由已确认隐藏。数据层面新增 `muse-cloud/sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql` 与 restore 脚本,幂等隐藏用户/角色/租户/部门/岗位、会员、支付、公众号、BPM、yudao AI 娱乐面菜单,保留菜单管理;本步未执行任何共享库 SQL。验收证据:新增后端 targeted gate 5/0F/0E/0S;`bash muse-cloud/scripts/run-p1r-verification.sh local` 65/0F/0E/0S;`grep -rn "api-key: sk-\|api-key: AIza" muse-cloud/**/application*.yaml` 零命中;admin targeted Vitest 10/0F/0E/0S;`pnpm --filter @vben/web-antd typecheck` 通过;`NODE_OPTIONS=--localstorage-file=/tmp/muse-admin-node-localstorage pnpm test:unit` 52 files / 356 tests passed;`git diff --check` 通过。遗留提醒:RAGFlow 实例下线与已入 git 历史的 yaml 明文 key 轮换仍需人工处理。
|
||||
|
||||
**2.0.0 单人版改造 S4 启动级追补(2026-07-08)**:S5 活体 e2e 起栈时暴露 Spring AI Alibaba `DashScopeAgentAutoConfiguration` 仍会在缺 API key 时启动失败;已在 `muse-module-ai-server` 与 `muse-server` 两处 `application.yaml` 显式关闭 `spring.ai.dashscope.agent.enabled`,并把 `spring.ai.model.chat/embedding/image/rerank/video/audio.*` 置为 `none`,避免 DashScope chat/embedding/image/rerank/video/audio 自动装配因 matchIfMissing 回漂。`SoloExternalAiProviderGateTest` 同步扩展到 DashScope Spring AI 自动配置面,targeted gate `SoloExternalAiProviderGateTest,SoloAccountDefaultsGateTest` 为 5/0F/0E/0S。标准启动脚本仍受当前 `infra.env` 指向 `muse_local/muse_dev` 且 Flyway V1 函数 owner 不匹配影响,`muse-server` 活体启动需在后续 S5/S9 统一修正开发库 owner/专用 live 库口径。
|
||||
|
||||
**2.0.0 单人版改造 S5 studio 入口裁剪推进中(2026-07-08)**:已按执行计划裁掉用户端市场、handoff、旧编辑器入口:路由删除 `/market*`、`/handoff/land/:targetOwner`、旧 `/works/:workId/editor/:chapterId`,侧边栏移除「市场」「治理」,个人中心保留 profile + usage、删除购买/授权/发布/安全事件/New-API 绑定展示,知识页移除「从市场安装」tab、发布到市场入口与预检弹窗,登录文案去市场资产措辞;landing-only handoff 组件、handoff store、旧 `EditorPage` 与 `KnowledgePublishModal` 已删除。新增 solo route/sidebar/account/knowledge 单测与 `/market`、`/handoff/land/*` route-only Playwright。验收已完成部分:`pnpm exec vitest run` 31 files / 117 tests passed;`pnpm run build` 通过;`CI=1 E2E_SKIP_SEED=1 MUSE_STUDIO_E2E_MARKET=false pnpm exec playwright test e2e/solo-routing.spec.ts` 1 passed;S5 隔离 17 个 market/handoff/account-market e2e spec,`market-install-kb-retrieval.spec.ts` 的旧 fixme 位于已 skip 文件内。未完成项:执行计划要求的 MSW-off 创作主线全量 Playwright 尚未跑通,阻塞在本机后端活体启动口径(标准脚本先遇到 `muse_local` Flyway owner 问题;关 Flyway 后暴露并已修 DashScope 自动装配漏关)。因此 S5 当前不得标记完成,下一会话优先补后端起栈/创作主线 e2e 证据。
|
||||
|
||||
**E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business audit;AI owner 不可用时整笔 merge 回滚,避免 Canonical 已写但候选仍 pending。Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive;通知失败不回滚 Canonical 主写,Knowledge confirm 端仍按来源状态 fail-closed。Studio `CandidatePanel` 支持编辑最终正文并按 `accept_as_is`/`modify_then_merge` 提交,IndexedDB 草稿键改为 `workId+blockId+revision` 防跨作品/版本污染。`saveBlock`/`mergeBlockSuggestion` 现在写 `muse_content_block_revision_snapshot`,工作台“历史”Tab 只读展示 Canonical revision 快照;导入可创建真实任务,导出支持范围/格式选择、任务查询与下载凭证消费,知识/记录入口跳转对应工作台。fresh 证据:后端局部单测 `ContentSourceServiceTest` 25/0F/0E、`ContentAppServiceTest` 19/0F/0E、`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E、`AiSuggestionMergeProjectionFacadeTest` 7/0F/0E;契约/覆盖门 `ContractFirstGateTest` 4/0F/0E + `P1rApiCoverageReportTest` 8/0F/0E;real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S;studio `tsc -b --force` 通过、lint 0 errors、Vitest 12/12,追加 `AIPanel.contract.test.tsx` + `sse.test.ts` 22/0F/0E;导出 UI 追加 `useWorks.test.tsx`+`ExportWorkModal.test.tsx` 11/0F/0E、目标 ESLint 0 errors。共享 PG 写入闸门批准后已补跑 MSW-off Playwright 真后端 `accept-suggestion.spec.ts` 2/0F/0E:正路真 New-API 生成 suggestionId=79、authz `rpe-local-*`、Block revision 160→161、AI owner accepted decision archive 非空;负路 stale revision 业务冲突。边界:`muse-studio/src/types/content.ts` 生成类型因 openapi-typescript 版本漂移未同步,hook 内暂维护 `BlockRevision` 最小类型;完整导入向导已在 RC 后补,见下方记录;真后端导出下载 e2e 已在 2026-06-28 补跑通过,FileApi 异常仍按后端合同 fail-closed。
|
||||
|
||||
**E2 ai 智能体生命周期与候选处置闭环(2026-06-27)**:已补用户自建 Agent update/archive、初始版本自动创建、版本列表/激活/归档非当前版本、作品槽位 unbind、Studio reject 调后端 AI owner 决策归档。Agent update 会创建下一 active version 并更新 `current_version_id`,archive Agent 同步归档仍 active 的槽位绑定;slot unbind 将绑定行 revision+1 且 `status=archived`,运行时回到默认能力;WorkspacePage “放弃修改”不再只清本地候选,而是真打 `POST /suggestions/{id}/reject` 写 `rejected` 与 decision archive。启动修红:最新单体启动时暴露 `muse.codegen.importEnable` 缺省,已在 `muse-server/src/main/resources/application.yaml` 补 `import-enable:false`,随后 48080 成功启动并 `GET /app-api/muse/agents` smoke 返回 `code=0`。fresh 证据:后端 AI targeted `MuseAgentServiceTest`+`MuseAgentSlotServiceTest`+Controller annotation 63/0F/0E;契约门 `ContractFirstGateTest` 4/0F/0E;studio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0E;MSW-off Playwright 真后端 `agent-create.spec.ts`+`agent-slot-bind.spec.ts`+`accept-suggestion.spec.ts` 5/0F/0E:采纳真生成 suggestionId=80、Block revision 161→162、accepted decision archive 非空;拒绝真生成 suggestionId=81、`status=rejected`、decision archive 非空;Agent 生命周期 DB 核验 v1/v2 current 切换与 v2 archived;Slot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:本轮未跑 studio 全量 e2e。
|
||||
|
||||
@ -2,7 +2,12 @@ import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import aiRoutes from '../modules/ai';
|
||||
import bpmRoutes from '../modules/bpm';
|
||||
import leaveRoutes from '../modules/leave';
|
||||
import memberRoutes from '../modules/member';
|
||||
import routes from '../modules/muse';
|
||||
import payRoutes from '../modules/pay';
|
||||
|
||||
const createMuseRouter = () =>
|
||||
createRouter({
|
||||
@ -44,4 +49,22 @@ describe('muse admin routes', () => {
|
||||
expect(resolved.fullPath).toBe('/muse/governance/meta-schemas/work%2Fstory');
|
||||
expect(resolved.meta.activePath).toBe('/muse/governance/meta-schemas');
|
||||
});
|
||||
|
||||
it('单人版隐藏市场治理和用户权限菜单但保留路由可解析', () => {
|
||||
const memoryRouter = createMuseRouter();
|
||||
|
||||
const market = memoryRouter.resolve({ name: 'MuseMarketGovernance' });
|
||||
const account = memoryRouter.resolve({ name: 'MuseAccountGovernance' });
|
||||
|
||||
expect(market.fullPath).toBe('/muse/market');
|
||||
expect(market.meta.hideInMenu).toBe(true);
|
||||
expect(account.fullPath).toBe('/muse/account');
|
||||
expect(account.meta.hideInMenu).toBe(true);
|
||||
});
|
||||
|
||||
it('yudao demo 顶级静态路由不漏出到菜单', () => {
|
||||
for (const moduleRoutes of [leaveRoutes, payRoutes, aiRoutes, bpmRoutes, memberRoutes]) {
|
||||
expect(moduleRoutes[0]?.meta?.hideInMenu).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
component: () => import('#/views/muse/market/index.vue'),
|
||||
meta: {
|
||||
hideInMenu: true,
|
||||
icon: 'lucide:store',
|
||||
title: '市场治理',
|
||||
},
|
||||
@ -77,6 +78,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
component: () => import('#/views/muse/account/index.vue'),
|
||||
meta: {
|
||||
hideInMenu: true,
|
||||
icon: 'lucide:users',
|
||||
title: '用户与权限',
|
||||
},
|
||||
|
||||
@ -234,9 +234,6 @@ vi.mock('#/api/muse/jobs', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('#/api/muse/newapi', () => ({
|
||||
createNewApiCallAttributionJobApi: vi.fn(),
|
||||
createNewApiGatewayBindingApi: vi.fn(),
|
||||
createNewApiQuotaRequestApi: vi.fn(),
|
||||
getNewApiIntegrationCallApi: vi.fn(),
|
||||
listNewApiBalanceSnapshotsApi: vi.fn().mockResolvedValue({
|
||||
list: [{ balance: 100, snapshotId: 'balance-1', userId: 'user-1' }],
|
||||
@ -274,13 +271,18 @@ describe('muse admin governance pages', () => {
|
||||
expect(text).toContain('Muse 管理员');
|
||||
});
|
||||
|
||||
it('New-API 页不展示凭据且只处理治理归属', async () => {
|
||||
it('New-API 页不展示凭据且只保留本地用量只读观测', async () => {
|
||||
const text = await renderPage(() => import('../newapi/index.vue'));
|
||||
|
||||
expect(text).toContain('New-API 页面只展示治理和对账摘要');
|
||||
expect(text).toContain('New-API 页面只展示本地用量观测和对账摘要');
|
||||
expect(text).toContain('不展示凭据');
|
||||
expect(text).toContain('归属事实由服务端');
|
||||
expect(text).toContain('不提供网关绑定、额度配置或归属写命令');
|
||||
expect(text).toContain('newapi-user-1');
|
||||
expect(text).not.toContain('创建绑定');
|
||||
expect(text).not.toContain('刷新绑定');
|
||||
expect(text).not.toContain('配置额度');
|
||||
expect(text).not.toContain('创建归属 job');
|
||||
expect(text).not.toContain('处理异常');
|
||||
});
|
||||
|
||||
it('任务治理页要求重验且不代用户重新生成候选', async () => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -2,8 +2,8 @@
|
||||
/**
|
||||
* New-API 网关用户与用量页
|
||||
*
|
||||
* 只展示网关绑定、余额快照、调用日志归属和异常处理摘要。
|
||||
* 页面不展示任何 New-API 凭据,不把余额快照当作 Muse 本地权威余额。
|
||||
* 单人版只展示网关绑定、余额快照、调用日志归属和外部调用状态。
|
||||
* 页面不展示任何 New-API 凭据,也不提供绑定、额度或归属写命令入口。
|
||||
*/
|
||||
import type {
|
||||
AdminUsageRecord,
|
||||
@ -21,13 +21,9 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@ -36,9 +32,6 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
createNewApiCallAttributionJobApi,
|
||||
createNewApiGatewayBindingApi,
|
||||
createNewApiQuotaRequestApi,
|
||||
getNewApiIntegrationCallApi,
|
||||
listNewApiBalanceSnapshotsApi,
|
||||
listNewApiGatewayUsersApi,
|
||||
@ -56,48 +49,6 @@ interface TablePager {
|
||||
/** Ant Table 插槽传入的通用记录。 */
|
||||
type TableRecord = Record<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> = {
|
||||
bound: 'green',
|
||||
@ -119,11 +70,6 @@ function asNewApiBinding(record: TableRecord) {
|
||||
return record as unknown as NewApiBindingSummary;
|
||||
}
|
||||
|
||||
/** 将 Ant Table 通用记录转回用量摘要。 */
|
||||
function asUsageRecord(record: TableRecord) {
|
||||
return record as unknown as AdminUsageRecord;
|
||||
}
|
||||
|
||||
/** 网关绑定状态颜色,兼容模板中的 unknown 记录。 */
|
||||
function getBindingStatusColor(status: unknown) {
|
||||
return bindingStatusColor[status as NewApiBindingStatus] ?? 'default';
|
||||
@ -141,7 +87,7 @@ const bindingColumns = [
|
||||
{ dataIndex: 'boundAt', key: 'boundAt', title: '绑定时间' },
|
||||
{ dataIndex: 'lastSyncAt', key: 'lastSyncAt', title: '最近同步' },
|
||||
{ dataIndex: 'syncErrorMessage', key: 'syncErrorMessage', title: '异常摘要' },
|
||||
{ key: 'actions', title: '操作', width: 280 },
|
||||
{ key: 'actions', title: '操作', width: 120 },
|
||||
];
|
||||
|
||||
const balanceColumns = [
|
||||
@ -171,13 +117,11 @@ const usageColumns = [
|
||||
key: 'failedAttributionCount',
|
||||
title: '归属失败',
|
||||
},
|
||||
{ key: 'actions', title: '异常处理', width: 180 },
|
||||
];
|
||||
|
||||
const bindingLoading = ref(false);
|
||||
const balanceLoading = ref(false);
|
||||
const usageLoading = ref(false);
|
||||
const commandSubmitting = ref(false);
|
||||
|
||||
const bindings = ref<NewApiBindingSummary[]>([]);
|
||||
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 usagePager = reactive({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
const bindingModalOpen = ref(false);
|
||||
const quotaModalOpen = ref(false);
|
||||
const attributionModalOpen = ref(false);
|
||||
const integrationQuery = ref('');
|
||||
|
||||
const bindingForm = reactive<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) {
|
||||
return value === undefined || value === '' ? '-' : String(value);
|
||||
@ -329,128 +243,11 @@ function handleUsageTableChange(pager: TablePager) {
|
||||
loadUsageRecords(pager.current ?? 1, pager.pageSize ?? usagePager.pageSize);
|
||||
}
|
||||
|
||||
/** 打开创建或刷新网关绑定弹窗。 */
|
||||
function openBindingModal(record?: NewApiBindingSummary) {
|
||||
bindingForm.commandId = createCommandId('newapi-bind');
|
||||
bindingForm.forceRefresh = record?.bindingStatus === 'sync_failed';
|
||||
bindingForm.reason = '';
|
||||
bindingForm.userId = record?.userId ?? selectedBinding.value?.userId ?? '';
|
||||
bindingModalOpen.value = true;
|
||||
}
|
||||
|
||||
/** 表格事件包装:选择绑定用户。 */
|
||||
function handleSelectBinding(record: TableRecord) {
|
||||
return selectBinding(asNewApiBinding(record));
|
||||
}
|
||||
|
||||
/** 表格事件包装:打开绑定命令。 */
|
||||
function handleOpenBindingModal(record: TableRecord) {
|
||||
openBindingModal(asNewApiBinding(record));
|
||||
}
|
||||
|
||||
/** 表格事件包装:打开额度配置命令。 */
|
||||
function handleOpenQuotaModal(record: TableRecord) {
|
||||
openQuotaModal(asNewApiBinding(record));
|
||||
}
|
||||
|
||||
/** 表格事件包装:打开用量归属异常处理命令。 */
|
||||
function handleOpenAttributionModal(record: TableRecord) {
|
||||
openAttributionModal(asUsageRecord(record));
|
||||
}
|
||||
|
||||
/** 提交创建或刷新网关绑定命令。 */
|
||||
async function submitBindingCommand() {
|
||||
if (!bindingForm.userId || !bindingForm.reason.trim()) {
|
||||
message.error('请填写用户 ID 和原因');
|
||||
return;
|
||||
}
|
||||
commandSubmitting.value = true;
|
||||
try {
|
||||
await createNewApiGatewayBindingApi(bindingForm.userId, {
|
||||
commandId: bindingForm.commandId,
|
||||
forceRefresh: bindingForm.forceRefresh,
|
||||
reason: bindingForm.reason,
|
||||
});
|
||||
message.success('网关绑定命令已提交');
|
||||
bindingModalOpen.value = false;
|
||||
await loadBindings();
|
||||
} finally {
|
||||
commandSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 New-API 额度配置请求弹窗。 */
|
||||
function openQuotaModal(record?: NewApiBindingSummary) {
|
||||
quotaForm.commandId = createCommandId('newapi-quota');
|
||||
quotaForm.limit = undefined;
|
||||
quotaForm.reason = '';
|
||||
quotaForm.requestType = 'subscription_sync';
|
||||
quotaForm.resourceType = '';
|
||||
quotaForm.userId = record?.userId ?? selectedBinding.value?.userId ?? '';
|
||||
quotaModalOpen.value = true;
|
||||
}
|
||||
|
||||
/** 提交 New-API 额度配置请求。 */
|
||||
async function submitQuotaRequest() {
|
||||
if (!quotaForm.userId || !quotaForm.reason.trim()) {
|
||||
message.error('请填写用户 ID 和原因');
|
||||
return;
|
||||
}
|
||||
commandSubmitting.value = true;
|
||||
try {
|
||||
await createNewApiQuotaRequestApi(quotaForm.userId, {
|
||||
commandId: quotaForm.commandId,
|
||||
reason: quotaForm.reason,
|
||||
requestType: quotaForm.requestType,
|
||||
targetQuota: quotaForm.resourceType
|
||||
? { limit: quotaForm.limit, resourceType: quotaForm.resourceType }
|
||||
: undefined,
|
||||
});
|
||||
message.success('额度配置请求已创建');
|
||||
quotaModalOpen.value = false;
|
||||
} finally {
|
||||
commandSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开调用归属 job 弹窗。 */
|
||||
function openAttributionModal(record?: AdminUsageRecord) {
|
||||
attributionForm.callIds = '';
|
||||
attributionForm.commandId = createCommandId('newapi-attr');
|
||||
attributionForm.correlationId = '';
|
||||
attributionForm.expectedCallRevision = undefined;
|
||||
attributionForm.reason = record?.recordId
|
||||
? `处理用量记录 ${record.recordId} 的归属异常`
|
||||
: '';
|
||||
attributionModalOpen.value = true;
|
||||
}
|
||||
|
||||
/** 提交调用归属 job。 */
|
||||
async function submitAttributionJob() {
|
||||
if (!attributionForm.correlationId || !attributionForm.reason.trim()) {
|
||||
message.error('请填写 correlationId 和归属原因');
|
||||
return;
|
||||
}
|
||||
commandSubmitting.value = true;
|
||||
try {
|
||||
await createNewApiCallAttributionJobApi({
|
||||
callIds: attributionForm.callIds
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
commandId: attributionForm.commandId,
|
||||
correlationId: attributionForm.correlationId,
|
||||
expectedCallRevision: attributionForm.expectedCallRevision,
|
||||
reason: attributionForm.reason,
|
||||
verificationMode: 'strict',
|
||||
});
|
||||
message.success('调用归属 job 已创建');
|
||||
attributionModalOpen.value = false;
|
||||
} finally {
|
||||
commandSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 correlationId 查询外部调用状态。 */
|
||||
async function queryIntegrationCall() {
|
||||
if (!integrationQuery.value.trim()) {
|
||||
@ -474,7 +271,7 @@ onMounted(() => {
|
||||
<Alert
|
||||
show-icon
|
||||
type="warning"
|
||||
message="New-API 页面只展示治理和对账摘要;不展示凭据,不伪造余额,不管理模型供应商路由。"
|
||||
message="New-API 页面只展示本地用量观测和对账摘要;不展示凭据,不提供网关绑定、额度配置或归属写命令。"
|
||||
/>
|
||||
|
||||
<Tabs>
|
||||
@ -498,7 +295,6 @@ onMounted(() => {
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button @click="openBindingModal()">创建绑定</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
@ -520,12 +316,6 @@ onMounted(() => {
|
||||
<Button type="link" @click="handleSelectBinding(record)">
|
||||
余额/用量
|
||||
</Button>
|
||||
<Button type="link" @click="handleOpenBindingModal(record)">
|
||||
刷新绑定
|
||||
</Button>
|
||||
<Button type="link" @click="handleOpenQuotaModal(record)">
|
||||
配置额度
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
@ -572,7 +362,7 @@ onMounted(() => {
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="usage" tab="调用归属">
|
||||
<Card title="调用日志归属与异常处理">
|
||||
<Card title="调用日志归属摘要">
|
||||
<Space class="mb-4" wrap>
|
||||
<Input
|
||||
v-model:value="usageFilters.userId"
|
||||
@ -596,7 +386,6 @@ onMounted(() => {
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button @click="openAttributionModal()">创建归属 job</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
@ -606,15 +395,7 @@ onMounted(() => {
|
||||
:loading="usageLoading"
|
||||
:pagination="usagePager"
|
||||
@change="handleUsageTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'actions'">
|
||||
<Button type="link" @click="handleOpenAttributionModal(record)">
|
||||
处理异常
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
|
||||
@ -658,101 +439,5 @@ onMounted(() => {
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -122,37 +122,46 @@ spring:
|
||||
host: 127.0.0.1
|
||||
port: 19530
|
||||
qianfan: # 文心一言
|
||||
api-key: x0cuLZ7XsaTCU08vuJWO87Lg
|
||||
secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK
|
||||
api-key: ${MUSE_AI_QIANFAN_API_KEY:}
|
||||
secret-key: ${MUSE_AI_QIANFAN_SECRET_KEY:}
|
||||
zhipuai: # 智谱 AI
|
||||
api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs
|
||||
api-key: ${MUSE_AI_ZHIPUAI_API_KEY:}
|
||||
openai: # OpenAI 官方
|
||||
api-key: sk-aN6nWn3fILjrgLFT0fC4Aa60B72e4253826c77B29dC94f17
|
||||
base-url: https://api.gptsapi.net
|
||||
api-key: ${MUSE_AI_OPENAI_API_KEY:}
|
||||
base-url: ${MUSE_AI_OPENAI_BASE_URL:https://api.openai.com}
|
||||
azure: # OpenAI 微软
|
||||
openai:
|
||||
endpoint: https://eastusprejade.openai.azure.com
|
||||
anthropic: # Anthropic Claude
|
||||
api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942
|
||||
api-key: ${MUSE_AI_ANTHROPIC_API_KEY:}
|
||||
ollama:
|
||||
base-url: http://127.0.0.1:11434
|
||||
chat:
|
||||
model: llama3
|
||||
stabilityai:
|
||||
api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx
|
||||
api-key: ${MUSE_AI_STABILITYAI_API_KEY:}
|
||||
dashscope: # 通义千问
|
||||
api-key: sk-47aa124781be4bfb95244cc62f6xxxx
|
||||
api-key: ${MUSE_AI_DASHSCOPE_API_KEY:}
|
||||
agent:
|
||||
enabled: false
|
||||
minimax: # Minimax:https://www.minimaxi.com/
|
||||
api-key: xxxx
|
||||
api-key: ${MUSE_AI_MINIMAX_API_KEY:}
|
||||
moonshot: # 月之暗面(KIMI)
|
||||
api-key: sk-abc
|
||||
api-key: ${MUSE_AI_MOONSHOT_API_KEY:}
|
||||
deepseek: # DeepSeek
|
||||
api-key: sk-e94db327cc7d457d99a8de8810fc6b12
|
||||
api-key: ${MUSE_AI_DEEPSEEK_API_KEY:}
|
||||
chat:
|
||||
options:
|
||||
model: deepseek-chat
|
||||
model:
|
||||
rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启
|
||||
chat: none
|
||||
embedding: none
|
||||
image: none
|
||||
rerank: none
|
||||
video: none
|
||||
audio:
|
||||
speech: none
|
||||
transcription: none
|
||||
mcp:
|
||||
server:
|
||||
enabled: false
|
||||
@ -174,43 +183,43 @@ spring:
|
||||
muse:
|
||||
ai:
|
||||
gemini: # 谷歌 Gemini
|
||||
enable: true
|
||||
api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_GEMINI_API_KEY:}
|
||||
model: gemini-2.5-flash
|
||||
doubao: # 字节豆包
|
||||
enable: true
|
||||
api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_DOUBAO_API_KEY:}
|
||||
model: doubao-1-5-lite-32k-250115
|
||||
hunyuan: # 腾讯混元
|
||||
enable: true
|
||||
api-key: sk-abc
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_HUNYUAN_API_KEY:}
|
||||
model: hunyuan-turbo
|
||||
siliconflow: # 硅基流动
|
||||
enable: true
|
||||
api-key: sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_SILICONFLOW_API_KEY:}
|
||||
model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
|
||||
xinghuo: # 讯飞星火
|
||||
enable: true
|
||||
appKey: 75b161ed2aef4719b275d6e7f2a4d4cd
|
||||
secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz
|
||||
enable: false
|
||||
appKey: ${MUSE_AI_XINGHUO_APP_KEY:}
|
||||
secretKey: ${MUSE_AI_XINGHUO_SECRET_KEY:}
|
||||
model: x1
|
||||
baichuan: # 百川智能
|
||||
enable: true
|
||||
api-key: sk-abc
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_BAICHUAN_API_KEY:}
|
||||
model: Baichuan4-Turbo
|
||||
midjourney:
|
||||
enable: true
|
||||
enable: false
|
||||
# base-url: https://api.holdai.top/mj-relax/mj
|
||||
base-url: https://api.holdai.top/mj
|
||||
api-key: sk-dZEPiVaNcT3FHhef51996bAa0bC74806BeAb620dA5Da10Bf
|
||||
notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify
|
||||
base-url: ${MUSE_AI_MIDJOURNEY_BASE_URL:}
|
||||
api-key: ${MUSE_AI_MIDJOURNEY_API_KEY:}
|
||||
notify-url: ${MUSE_AI_MIDJOURNEY_NOTIFY_URL:}
|
||||
suno:
|
||||
enable: true
|
||||
enable: false
|
||||
# base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app
|
||||
base-url: http://127.0.0.1:3001
|
||||
base-url: ${MUSE_AI_SUNO_BASE_URL:}
|
||||
web-search:
|
||||
enable: true
|
||||
api-key: sk-40500e52840f4d24b956d0b1d80d9abe
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_WEB_SEARCH_API_KEY:}
|
||||
new-api:
|
||||
enabled: ${MUSE_AI_NEW_API_ENABLED:false}
|
||||
base-url: ${MUSE_AI_NEW_API_BASE_URL:}
|
||||
|
||||
@ -10,4 +10,5 @@
|
||||
- **2026-06-27 RC 后补安全事件处理入口**:Studio 个人中心安全事件从单一“确认”扩为契约内 `acknowledged/password_changed/session_revoked/false_positive` 四类处理动作,写 `POST /account/security-events/{eventId}/acknowledge` 时带 commandId 和 note,成功后展示后端 `riskSummary/nextSteps` 并刷新列表。验证:Studio `useAccount.test.tsx` 5/0F/0E,组合目标 Vitest 4 files / 11 tests passed,`tsc -b --force` 与目标 ESLint 通过。边界:这不是完整改密/2FA/session service,会话吊销真实联动仍后置;偏好通知仍无 app 写契约,不伪造。
|
||||
- **2.0.0 S2 market detached 测试树影响(2026-07-07)**:`P1rAccountAdminPurchaseRecordsCompletedApprovalIT` 与 `P1rAccountMarketRecordsCompletedApprovalIT` 依赖 market 单体装配投影,已随 `market-detached-test-tree` 显式 profile 一并剥离;默认构建不跳过,`market-assembled` 可恢复全量回编译。本步未改 Account 业务实现、coverage JSON 或 Account completed 口径,纯 Account gate 仍随 local 门禁运行。
|
||||
- **2.0.0 S3 market 默认摘装配影响(2026-07-07)**:`market-detached-test-tree` 已默认生效,Account 侧两组 market/account 混合 IT 默认不再编译/运行;Account owner 自身的 profile、usage、quota、security、New-API binding 能力不因 market dormant 变化。恢复市场相关账户记录验收时显式使用 `muse-server -Pmarket-assembled`。
|
||||
- **2.0.0 S4 单人版 member 固化(2026-07-08)**:Account New-API 管理口保持 `muse.account.new-api.enabled=false` 默认关闭,多用户 `publish-worker/attribution-worker/quota-request-worker` 默认关闭;新增 `SoloAccountDefaultsGateTest` 纳入 local 门禁,断言这些默认值与 `NewApiAccountFacade`、`MarketAccountProjectionFacade`、`AccountFileServiceFacade` 缺 owner 时的 fail-closed 兜底。`muse-cloud/scripts/dev/solo-compose.env.example` 固化 S9 compose 可复用 env 口径:租户机制保持开启、请求固定 `tenant-id: 1`、Account 外部管理口/worker 默认 false。注意:entitlement 是本地纯读逻辑,不是 `@ConditionalOnMissingBean` 外部兜底链;不要为 S4 硬造新抽象。
|
||||
- **关键风险 / TODO**:account needs_verification 验收债**已清零**:末位 adminListPurchaseRecords 经 `P1rAccountAdminPurchaseRecordsCompletedApprovalIT`(跨模块 base-package=cn.iocoder.muse.module + import MarketAccountProjectionProvider)真实 PG 2/2 绿补证;批3(市场记录读)+ 批7-14 account needs_verification op 全部补真实 PG 证据、证据已人工批准并翻 completed。截至 2026-06-17,content/market/account 三域 needs_verification 验收债均已补齐真实 PG 证据,coverage JSON completed flip 与覆盖门禁批准集合已按人工批准同步,2026-06-19 已补 `testFiles` 证据锚点门禁。前端仍有深页未全覆盖真实后端。
|
||||
|
||||
@ -196,37 +196,46 @@ spring:
|
||||
host: 127.0.0.1
|
||||
port: 19530
|
||||
qianfan: # 文心一言
|
||||
api-key: x0cuLZ7XsaTCU08vuJWO87Lg
|
||||
secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK
|
||||
api-key: ${MUSE_AI_QIANFAN_API_KEY:}
|
||||
secret-key: ${MUSE_AI_QIANFAN_SECRET_KEY:}
|
||||
zhipuai: # 智谱 AI
|
||||
api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs
|
||||
api-key: ${MUSE_AI_ZHIPUAI_API_KEY:}
|
||||
openai: # OpenAI 官方
|
||||
api-key: sk-aN6nWn3fILjrgLFT0fC4Aa60B72e4253826c77B29dC94f17
|
||||
base-url: https://api.gptsapi.net
|
||||
api-key: ${MUSE_AI_OPENAI_API_KEY:}
|
||||
base-url: ${MUSE_AI_OPENAI_BASE_URL:https://api.openai.com}
|
||||
azure: # OpenAI 微软
|
||||
openai:
|
||||
endpoint: https://eastusprejade.openai.azure.com
|
||||
anthropic: # Anthropic Claude
|
||||
api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942
|
||||
api-key: ${MUSE_AI_ANTHROPIC_API_KEY:}
|
||||
ollama:
|
||||
base-url: http://127.0.0.1:11434
|
||||
chat:
|
||||
model: llama3
|
||||
stabilityai:
|
||||
api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx
|
||||
api-key: ${MUSE_AI_STABILITYAI_API_KEY:}
|
||||
dashscope: # 通义千问
|
||||
api-key: sk-47aa124781be4bfb95244cc62f6xxxx
|
||||
api-key: ${MUSE_AI_DASHSCOPE_API_KEY:}
|
||||
agent:
|
||||
enabled: false
|
||||
minimax: # Minimax:https://www.minimaxi.com/
|
||||
api-key: xxxx
|
||||
api-key: ${MUSE_AI_MINIMAX_API_KEY:}
|
||||
moonshot: # 月之暗面(KIMI)
|
||||
api-key: sk-abc
|
||||
api-key: ${MUSE_AI_MOONSHOT_API_KEY:}
|
||||
deepseek: # DeepSeek
|
||||
api-key: sk-e94db327cc7d457d99a8de8810fc6b12
|
||||
api-key: ${MUSE_AI_DEEPSEEK_API_KEY:}
|
||||
chat:
|
||||
options:
|
||||
model: deepseek-chat
|
||||
model:
|
||||
rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启
|
||||
chat: none
|
||||
embedding: none
|
||||
image: none
|
||||
rerank: none
|
||||
video: none
|
||||
audio:
|
||||
speech: none
|
||||
transcription: none
|
||||
mcp:
|
||||
server:
|
||||
enabled: false
|
||||
@ -248,43 +257,43 @@ spring:
|
||||
muse:
|
||||
ai:
|
||||
gemini: # 谷歌 Gemini
|
||||
enable: true
|
||||
api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_GEMINI_API_KEY:}
|
||||
model: gemini-2.5-flash
|
||||
doubao: # 字节豆包
|
||||
enable: true
|
||||
api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_DOUBAO_API_KEY:}
|
||||
model: doubao-1-5-lite-32k-250115
|
||||
hunyuan: # 腾讯混元
|
||||
enable: true
|
||||
api-key: sk-abc
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_HUNYUAN_API_KEY:}
|
||||
model: hunyuan-turbo
|
||||
siliconflow: # 硅基流动
|
||||
enable: true
|
||||
api-key: sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_SILICONFLOW_API_KEY:}
|
||||
model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
|
||||
xinghuo: # 讯飞星火
|
||||
enable: true
|
||||
appKey: 75b161ed2aef4719b275d6e7f2a4d4cd
|
||||
secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz
|
||||
enable: false
|
||||
appKey: ${MUSE_AI_XINGHUO_APP_KEY:}
|
||||
secretKey: ${MUSE_AI_XINGHUO_SECRET_KEY:}
|
||||
model: x1
|
||||
baichuan: # 百川智能
|
||||
enable: true
|
||||
api-key: sk-abc
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_BAICHUAN_API_KEY:}
|
||||
model: Baichuan4-Turbo
|
||||
midjourney:
|
||||
enable: true
|
||||
enable: false
|
||||
# base-url: https://api.holdai.top/mj-relax/mj
|
||||
base-url: https://api.holdai.top/mj
|
||||
api-key: sk-dZEPiVaNcT3FHhef51996bAa0bC74806BeAb620dA5Da10Bf
|
||||
notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify
|
||||
base-url: ${MUSE_AI_MIDJOURNEY_BASE_URL:}
|
||||
api-key: ${MUSE_AI_MIDJOURNEY_API_KEY:}
|
||||
notify-url: ${MUSE_AI_MIDJOURNEY_NOTIFY_URL:}
|
||||
suno:
|
||||
enable: true
|
||||
enable: false
|
||||
# base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app
|
||||
base-url: http://127.0.0.1:3001
|
||||
base-url: ${MUSE_AI_SUNO_BASE_URL:}
|
||||
web-search:
|
||||
enable: true
|
||||
api-key: sk-40500e52840f4d24b956d0b1d80d9abe
|
||||
enable: false
|
||||
api-key: ${MUSE_AI_WEB_SEARCH_API_KEY:}
|
||||
|
||||
--- #################### 芋道相关配置 ####################
|
||||
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
muse-cloud/scripts/dev/solo-compose.env.example
Normal file
15
muse-cloud/scripts/dev/solo-compose.env.example
Normal 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 切到 Dify;S4 保持 New-API/Dify adapter 未配置即 fail-closed。
|
||||
MUSE_AI_NEW_API_ENABLED=false
|
||||
MUSE_AI_DIFY_ENABLED=false
|
||||
@ -10,7 +10,7 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)"
|
||||
CLOUD_DIR="${REPO_ROOT}/muse-cloud"
|
||||
|
||||
LOCAL_TESTS="P1rApiCoverageReportTest,P1r*RealApiGateTest,P1rMarketDetachedAssemblyGateTest,AgentsInfraIntegrityTest,ContractFirstGateTest,BcBoundaryArchTest,AiGrantRuntimeBoundaryArchTest"
|
||||
LOCAL_TESTS="P1rApiCoverageReportTest,P1r*RealApiGateTest,P1rMarketDetachedAssemblyGateTest,SoloExternalAiProviderGateTest,SoloAccountDefaultsGateTest,AgentsInfraIntegrityTest,ContractFirstGateTest,BcBoundaryArchTest,AiGrantRuntimeBoundaryArchTest"
|
||||
REAL_PG_TESTS="${P1R_REAL_PG_TESTS:-P1r*IT,!P1rAiRuntimeEndToEndLiveAcceptanceIT,!P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT,!P1rMarketKbForkMaterializationIT,!P1rImportLlmNewApiLiveAcceptanceIT,!P1rNewApiLiveAcceptanceIT,!P1rRagFlowLiveAcceptanceIT,!P1rDifyChatLiveAcceptanceIT,!P1rDifyDatasetsContractLiveIT}"
|
||||
EXTERNAL_LIVE_TESTS="${P1R_EXTERNAL_LIVE_TESTS:-P1rAiRuntimeEndToEndLiveAcceptanceIT,P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT,P1rMarketKbForkMaterializationIT}"
|
||||
MODULE_LIVE_TESTS="${P1R_MODULE_LIVE_TESTS:-P1rNewApiLiveAcceptanceIT,P1rImportLlmNewApiLiveAcceptanceIT,P1rRagFlowLiveAcceptanceIT,P1rDifyChatLiveAcceptanceIT,P1rDifyDatasetsContractLiveIT}"
|
||||
|
||||
@ -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:%'
|
||||
);
|
||||
@ -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:%'
|
||||
);
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 账户中心「我的购买 / 我的授权」记录 e2e(真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 个人中心 New-API 绑定摘要 e2e(真实后端,MSW off)——A 类:后端 getAppNewApiBinding 就绪、studio 此前 0 消费。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 账户中心「我的发布」记录 e2e(真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 安全事件确认写路 e2e(真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Client } from 'pg';
|
||||
import type { Client as PgClient } from 'pg';
|
||||
|
||||
/**
|
||||
* Playwright globalSetup —— 活体 e2e 每轮 fixture 自动重置(可入库,替代一次性 /tmp 种子脚本)。
|
||||
@ -30,7 +30,8 @@ async function globalSetup(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
const { Client } = await import('pg');
|
||||
const client: PgClient = new Client({
|
||||
host,
|
||||
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
|
||||
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
|
||||
@ -42,6 +43,9 @@ async function globalSetup(): Promise<void> {
|
||||
|
||||
await client.connect();
|
||||
try {
|
||||
const enableMarketFixture = process.env.MUSE_STUDIO_E2E_MARKET !== 'false';
|
||||
let marketFixtureDetails = 'market/handoff fixtures skipped';
|
||||
|
||||
// 0) AI 真生成额度:每轮把 test1(user1) 的 ai_calls 配额复位为可消费态。
|
||||
// WHY:AI 生成 e2e 会真调用 Account 额度预扣,额度属于会被测试消费的状态;若不复位,
|
||||
// 历史运行耗尽或基础种子缺失都会让 POST /ai/tasks 在业务入口返回「账户额度不足」,形成夹具假红。
|
||||
@ -172,6 +176,7 @@ async function globalSetup(): Promise<void> {
|
||||
console.warn('[e2e globalSetup] ⚠️ 未找到"待确认演练"安全事件(account-security-ack 基础种子缺失)');
|
||||
}
|
||||
|
||||
if (enableMarketFixture) {
|
||||
// 5a) market 申诉提交:刷新被驳回 publish_request 的 reviewed_at 到近期,使申诉 deadline(reviewedAt + 后端
|
||||
// APPEAL_DEADLINE_DAYS=7 天)未过。否则种子 request 随日期推进必然过期,「发起申诉」用例提交被后端拒
|
||||
// MARKET_APPEAL_DEADLINE_EXPIRED(1044000027)——此 fixture 老化与代码无关、纯时间推进所致
|
||||
@ -187,6 +192,7 @@ async function globalSetup(): Promise<void> {
|
||||
if (ap.rowCount === 0) {
|
||||
console.warn('[e2e globalSetup] ⚠️ 未找到当前用户的申诉(market 补充材料用例需基础申诉种子)');
|
||||
}
|
||||
}
|
||||
|
||||
// 6) knowledge-bindings:作品 1 一条<生效>的来源绑定读模型投影(此前 global-setup 未覆盖、基础种子缺失,
|
||||
// 本轮固化为可入库;幂等:先删同 projection_id 再插,id 为 identity 自增故重跑不冲突)。
|
||||
@ -322,6 +328,7 @@ async function globalSetup(): Promise<void> {
|
||||
"DELETE FROM muse_agent WHERE tenant_id=1 AND name LIKE '活体智能体-e2e-%'"
|
||||
);
|
||||
|
||||
if (enableMarketFixture) {
|
||||
// 12) handoff-knowledge:清理 e2e 正路兑现产生的 market_kb 绑定(work4+kb1),供兑现闭环幂等重跑。
|
||||
// 用 work4(creator=test1、未占 uk);work1+kb1 已被 installed binding(id=1,user_kb)占,故 e2e 用 work4 避 uk 冲突。
|
||||
// 按 source_snapshot_id 格式(source-market_kb-{sourceId}-v*)精准删 binding;物理 DELETE 释放 uk_muse_knowledge_binding_work_kb。
|
||||
@ -405,6 +412,7 @@ async function globalSetup(): Promise<void> {
|
||||
// 14) handoff-content(P3):清理 e2e 正路兑现产生的 asset_use precheck(work4),供兑现闭环幂等重跑。
|
||||
// asset_use 不绑目标实体(单表 precheck 即使用凭证),物理删释放 (tenant_id,command_id) uk;asset 1 + work4 无需额外 seed。
|
||||
await client.query('DELETE FROM muse_content_work_asset_use_precheck WHERE tenant_id=1 AND work_id=4');
|
||||
}
|
||||
|
||||
// 15a) AI 真生成主链:复位 work1 writing.continuation 槽位到固定起点(agent1、revision=1、active)。
|
||||
// accept-suggestion / ai-generation 均依赖 work1 解析到 active Agent;该槽位不能被其它 e2e 写坏。
|
||||
@ -432,6 +440,7 @@ async function globalSetup(): Promise<void> {
|
||||
VALUES (2,'writing.continuation',1,'1','active',1,'user',1)`
|
||||
);
|
||||
|
||||
if (enableMarketFixture) {
|
||||
// 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的
|
||||
// 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链,
|
||||
// UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装,
|
||||
@ -478,15 +487,6 @@ async function globalSetup(): Promise<void> {
|
||||
]);
|
||||
|
||||
// 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"
|
||||
);
|
||||
@ -495,9 +495,6 @@ async function globalSetup(): Promise<void> {
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -652,6 +649,8 @@ async function globalSetup(): Promise<void> {
|
||||
handoffKbAssetId,
|
||||
]);
|
||||
await client.query('DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1', [handoffKbAssetId]);
|
||||
marketFixtureDetails = `market-install assetId=${installE2eAssetId}/asset1-listed/market-kb-handoff assetId=${handoffKbAssetId}/handoff installed_ref KB ${handoffInstalledKbs.rowCount ?? 0} 条`;
|
||||
}
|
||||
|
||||
// 19) 清理 work1/work2 除 writing.continuation 外的多余 active 槽位(去污)。
|
||||
// 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);
|
||||
|
||||
console.log(`[e2e globalSetup] 已复位每轮 fixture(ai-quota/graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block/kb-status/installed-kb/handoff-bind/handoff-agent/handoff-content/agent-slot-bind/market-install assetId=${installE2eAssetId}/asset1-listed/market-kb-handoff assetId=${handoffKbAssetId}/work-slot-dedup/e3 noDatasetKbId=${noDatasetKbId} uploadKbId=${uploadKbId});清理 e2e 累积 agent ${agentCleanup.rowCount} 条、work1/work2 多余槽位 ${workSlotCleanup.rowCount} 条、handoff installed_ref KB ${handoffInstalledKbs.rowCount ?? 0} 条、E3 no_dataset KB ${cleanedNoDatasetKb} 条、E3 upload KB ${cleanedUploadKb} 条`);
|
||||
console.log(`[e2e globalSetup] 已复位每轮 fixture(ai-quota/graph/confirm-draft/accept/security-ack/binding/meta-schema/block/kb-status/installed-kb/agent-slot-bind/work-slot-dedup/e3 noDatasetKbId=${noDatasetKbId} uploadKbId=${uploadKbId}/${marketFixtureDetails});清理 e2e 累积 agent ${agentCleanup.rowCount} 条、work1/work2 多余槽位 ${workSlotCleanup.rowCount} 条、E3 no_dataset KB ${cleanedNoDatasetKb} 条、E3 upload KB ${cleanedUploadKb} 条`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 跨空间 handoff → agent 物化兑现端到端 e2e(真实后端,反假绿)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 跨空间 handoff → content(asset_use)兑现端到端 e2e(真实后端,反假绿)。
|
||||
*
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 跨空间 handoff → knowledge 兑现端到端 e2e(真实后端,MSW off,反假绿)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 已安装知识库停用/恢复端到端(真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 知识库「发布到市场」可发布性预检 e2e(诚实处理 blocked,真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -5,8 +5,7 @@ import { expect, test, type Page } from '@playwright/test';
|
||||
*
|
||||
* 前置:vite 以 VITE_API_MOCK=false 运行(关 MSW)、真实 muse-server 在 48080、muse_slice_live 已种子。
|
||||
* 目的:验证 rendered studio 经「client.ts 注入 tenant-id 修复」后的 HTTP 客户端,能直连活体单体并把
|
||||
* content(作品列表)/market(资产浏览)/account(个人中心)三个 BC 的真实后端数据在 UI 正确渲染
|
||||
* ——即真实数据形态与页面渲染契约对齐(MSW mock 形态之外的活体校验)。
|
||||
* content(作品列表)/account(个人资料)/ai(智能体列表)的真实后端数据在 UI 正确渲染。
|
||||
*/
|
||||
const TOKEN = 'test1';
|
||||
|
||||
@ -25,7 +24,7 @@ test.describe('活体只读旅程(真实后端,MSW off)', () => {
|
||||
await expect(page.getByText('活体验证样书').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('市场浏览渲染真实资产(market)', async ({ page }) => {
|
||||
test.skip('市场浏览渲染真实资产(market)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
await page.goto('/market');
|
||||
// .first():推荐位与资产列表可能同时渲染同一资产(推荐区新增),strict 模式需唯一匹配;渲染即证真实市场数据已加载。
|
||||
@ -44,14 +43,14 @@ test.describe('活体只读旅程(真实后端,MSW off)', () => {
|
||||
await expect(page.getByText('活体测试智能体').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('个人中心渲染真实购买/授权记录(account 深页)', async ({ page }) => {
|
||||
test.skip('个人中心渲染真实购买/授权记录(account 深页)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
await page.goto('/account');
|
||||
// 个人中心「我的购买 / 我的授权」渲染 market→account 投影的真实资产(后端经 market 跨模块 Facade 提供,curl 已证)。
|
||||
await expect(page.getByText('活体市场资产·测试').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('个人中心渲染真实安全事件(account 深页)', async ({ page }) => {
|
||||
test.skip('个人中心渲染真实安全事件(account 深页)', async ({ page }) => {
|
||||
await seedToken(page);
|
||||
await page.goto('/account');
|
||||
// 个人中心「安全事件」区渲染 GET /account/security-events 的真实摘要(按登录用户隔离;种子见 /tmp/SeedSec.java,curl 已证)。
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 市场资产详情页 e2e(消费端深化,真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 市场资产治理影响 e2e(真实后端,MSW off)——A 类:后端 getGovernanceImpact 就绪、studio 此前 0 消费(C14 读端缺口)。
|
||||
*
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 市场资产「绑定到作品授权预检」e2e(来源侧 handoff 预检,真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* D0-fork(临时-04 U-verify)市场知识库安装 → 物化 installed_ref → 检索打通 studio e2e(真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 市场资产安装 UI e2e(真实后端,MSW off,反假绿)。
|
||||
*
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* E4 Market KB 召回触达 Knowledge 物化副本 e2e(真实后端 + 真 PG)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 市场生产侧发布草稿 e2e(真实后端,MSW off)。
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
|
||||
|
||||
/**
|
||||
* 市场推荐位 e2e(真实后端,MSW off)——A 类切片:后端 GET /marketplace/recommendations 就绪、此前 studio 零调用。
|
||||
*
|
||||
|
||||
22
muse-studio/e2e/solo-routing.spec.ts
Normal file
22
muse-studio/e2e/solo-routing.spec.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
@ -1,18 +1,12 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import AuthGuard from '@/app/routes/AuthGuard';
|
||||
import AppLayout from '@/components/layout/AppLayout';
|
||||
import WorkspacePage from '@/pages/WorkspacePage';
|
||||
import WorkListPage from '@/features/editor/components/WorkListPage';
|
||||
import EditorPage from '@/pages/EditorPage';
|
||||
import KnowledgePage from '@/pages/KnowledgePage';
|
||||
import AgentPage from '@/pages/AgentPage';
|
||||
import MarketPage from '@/pages/MarketPage';
|
||||
import MarketAssetDetailPage from '@/pages/MarketAssetDetailPage';
|
||||
import MarketPublishPage from '@/pages/MarketPublishPage';
|
||||
import MarketAppealsPage from '@/pages/MarketAppealsPage';
|
||||
import AccountPage from '@/pages/AccountPage';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
|
||||
|
||||
/**
|
||||
* 顶级路由配置
|
||||
@ -32,16 +26,11 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <WorkListPage /> },
|
||||
{ path: 'workspace/:workId', element: <WorkspacePage /> },
|
||||
{ path: 'works/:workId/editor/:chapterId', element: <EditorPage /> },
|
||||
{ path: 'knowledge', element: <KnowledgePage /> },
|
||||
{ path: 'knowledge/:workId', element: <KnowledgePage /> },
|
||||
{ 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: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
25
muse-studio/src/app/routes/routes.solo.test.ts
Normal file
25
muse-studio/src/app/routes/routes.solo.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
muse-studio/src/components/layout/Sidebar.solo.test.tsx
Normal file
25
muse-studio/src/components/layout/Sidebar.solo.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -15,8 +15,6 @@ const navItems: NavItem[] = [
|
||||
{ to: '/', label: '作品', icon: '📝' },
|
||||
{ to: '/knowledge', label: '知识库', icon: '📚' },
|
||||
{ to: '/agents', label: '智能体', icon: '🤖' },
|
||||
{ to: '/market', label: '市场', icon: '🏪' },
|
||||
{ to: '/market/appeals', label: '治理', icon: '⚖️' },
|
||||
{ to: '/account', label: '个人', icon: '👤' },
|
||||
];
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -1,65 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
type SecurityEventAction,
|
||||
useAccountEntitlements,
|
||||
useAccountLicenses,
|
||||
useAccountNewApiBinding,
|
||||
useAccountProfile,
|
||||
useAccountPublishRecords,
|
||||
useAccountPurchases,
|
||||
useAccountSecurityEvents,
|
||||
useAccountUsage,
|
||||
useAcknowledgeSecurityEvent,
|
||||
useUpdateAccountProfile,
|
||||
} from '../hooks/useAccount';
|
||||
import UsageStats from './UsageStats';
|
||||
|
||||
/** 安全事件严重程度 → 徽标配色(critical 高危红 / warning 警示橙 / info 中性)。 */
|
||||
function severityBadgeClass(severity: string): string {
|
||||
if (severity === 'critical') return 'bg-rose-50 text-rose-700';
|
||||
if (severity === 'warning') return 'bg-amber-50 text-amber-700';
|
||||
return 'bg-slate-100 text-slate-500';
|
||||
}
|
||||
|
||||
const securityActionLabels: Record<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() {
|
||||
const { data: profile, isLoading: isProfileLoading } = useAccountProfile();
|
||||
const { data: entitlements } = useAccountEntitlements();
|
||||
const { data: usage } = useAccountUsage('month');
|
||||
const { data: purchases } = useAccountPurchases();
|
||||
const { data: licenses } = useAccountLicenses();
|
||||
const { data: publishRecords } = useAccountPublishRecords();
|
||||
const { data: securityEvents } = useAccountSecurityEvents();
|
||||
const { data: newApiBinding } = useAccountNewApiBinding();
|
||||
const acknowledgeEvent = useAcknowledgeSecurityEvent();
|
||||
const updateProfile = useUpdateAccountProfile();
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [publicPenName, setPublicPenName] = useState('');
|
||||
const [securityActionResult, setSecurityActionResult] = useState<string | null>(null);
|
||||
|
||||
const activeNickname = nickname || profile?.nickname || '';
|
||||
const activePenName = publicPenName || profile?.publicPenName || '';
|
||||
@ -74,23 +29,6 @@ export default function PersonalCenter() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSecurityAction = (eventId: string, action: SecurityEventAction) => {
|
||||
setSecurityActionResult(null);
|
||||
acknowledgeEvent.mutate(
|
||||
{
|
||||
eventId,
|
||||
action,
|
||||
note: securityActionLabels[action],
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
const nextSteps = result.nextSteps?.length ? ` 后续:${result.nextSteps.join('、')}` : '';
|
||||
setSecurityActionResult(`${securityActionLabels[action]}已记录。${result.riskSummary ?? ''}${nextSteps}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50/60 px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
@ -154,178 +92,6 @@ export default function PersonalCenter() {
|
||||
<div className="mt-6">
|
||||
<UsageStats usage={usage} entitlements={entitlements} />
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { server } from '@/test/setup';
|
||||
import { useHandoffStore } from '@/stores/handoffStore';
|
||||
import AgentHandoffLanding from './AgentHandoffLanding';
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
|
||||
// agent 特有参数只保留 slotKey/revision;sourceAgentId 必须由后端按 market asset 解析,不能信 URL。
|
||||
function renderLanding(search = '?slotKey=writer') {
|
||||
const client = createTestQueryClient();
|
||||
return render(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
/** 幂等键生成(测试环境降级,对齐 ai 域 createCommandId)。 */
|
||||
function createCommandId(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标侧 agent 槽位预检入参(market→agent 跨空间 handoff)。
|
||||
*
|
||||
* 红线:handoffToken 必填——后端在 precheck 阶段服务端核验 + 核销 token(消费换 session),并据 assetId 解析发布者
|
||||
* agent,再在 bind 阶段物化为安装者本地 user agent。前端不再传 sourceAgentId,避免信任 URL 来源参数。
|
||||
*/
|
||||
export interface AgentSlotPrecheckInput {
|
||||
workId: string;
|
||||
slotKey: string;
|
||||
expectedSlotRevision?: number | undefined;
|
||||
handoffToken: string;
|
||||
sourceId: string;
|
||||
sourceVersion?: number | undefined;
|
||||
authorizationSummaryId: string;
|
||||
}
|
||||
|
||||
export interface AgentSlotPrecheckResult {
|
||||
agentSlotPrecheckId?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标侧 agent 槽位预检:POST /works/{workId}/agent-slots/{slotKey}/prechecks。
|
||||
* 后端在此核验 + 核销 handoff token(消费换 session),返回 agentSlotPrecheckId(后续绑定唯一可信凭据)。
|
||||
*/
|
||||
export function useAgentSlotPrecheck() {
|
||||
return useMutation<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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
/** 幂等键生成(测试环境降级,对齐 ai/content 域 createCommandId)。 */
|
||||
function createCommandId(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标侧 content 作品资产使用预检入参(market→content 跨空间 handoff)。
|
||||
*
|
||||
* 红线:handoffToken 必填——后端在 precheck 阶段服务端核验 + 核销 token(消费换 precheckId),
|
||||
* create 段只凭 precheckId(token 已核销)。asset_use 只记录使用授权事实,不写入正文/参考来源。
|
||||
*/
|
||||
export interface AssetUsePrecheckInput {
|
||||
workId: string;
|
||||
sourceId: string;
|
||||
sourceVersion: number;
|
||||
sourceStatus: string;
|
||||
handoffToken: string;
|
||||
authorizationSummaryId: string;
|
||||
authorizationSnapshotId: string;
|
||||
purposes?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface AssetUsePrecheckResult {
|
||||
assetUsePrecheckId?: string;
|
||||
allowedPurposes?: string[];
|
||||
blockedPurposes?: string[];
|
||||
blockedReasons?: string[];
|
||||
sourceStatus?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 作品资产使用预检:POST /works/{workId}/asset-use-prechecks。
|
||||
* 后端在此核验 + 核销 handoff token,返回 assetUsePrecheckId(后续 create 唯一可信凭据)。
|
||||
*/
|
||||
export function useAssetUsePrecheck() {
|
||||
return useMutation<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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -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());
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
36
muse-studio/src/pages/KnowledgePage.solo.test.tsx
Normal file
36
muse-studio/src/pages/KnowledgePage.solo.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,34 +4,28 @@ import KnowledgeDraftPanel from '@/features/knowledge/components/KnowledgeDraftP
|
||||
import KnowledgeGraphPanel from '@/features/knowledge/components/KnowledgeGraphPanel';
|
||||
import KnowledgeBindingsPanel from '@/features/knowledge/components/KnowledgeBindingsPanel';
|
||||
import KnowledgeRetrievalPanel from '@/features/knowledge/components/KnowledgeRetrievalPanel';
|
||||
import { useKnowledgeList, useKnowledgeDelete, useInstalledKnowledgeUninstall, useKnowledgeDisable, useKnowledgeRestore, useInstalledKnowledgeDisable, useInstalledKnowledgeRestore } from '@/features/knowledge/hooks/useKnowledge';
|
||||
import { useKnowledgeList, useKnowledgeDelete, useKnowledgeDisable, useKnowledgeRestore } from '@/features/knowledge/hooks/useKnowledge';
|
||||
import type { KnowledgeBaseVO } from '@/features/knowledge/hooks/useKnowledge';
|
||||
import CreateKBModal from '@/features/knowledge/components/CreateKBModal';
|
||||
import KnowledgePublishModal from '@/features/knowledge/components/KnowledgePublishModal';
|
||||
import MaterialManager from '@/features/knowledge/components/MaterialManager';
|
||||
import { isKnowledgeBaseSummary } from '@/features/knowledge/utils/typeGuards';
|
||||
|
||||
/**
|
||||
* KnowledgePage 知识库管理主控页面
|
||||
* 包含多页签过滤(自建、已安装、全局公开)、操作流控隔离、资料管理面板跳转以及 Zod 创建表单弹层
|
||||
* 单人版保留自建与全局公共授权库,市场安装与发布入口不在前端暴露。
|
||||
*/
|
||||
export default function KnowledgePage() {
|
||||
// 作品维度路由(/knowledge/:workId)携带 workId 时,展示该作品的"待确认知识草稿"工作台(先审后入)。
|
||||
const { workId } = useParams<{ workId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<'user' | 'installed' | 'global'>('user');
|
||||
const [activeTab, setActiveTab] = useState<'user' | 'global'>('user');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedKbId, setSelectedKbId] = useState<string | null>(null);
|
||||
// 发布到市场:当前选中预检的知识库(自建库专属);为空时不渲染预检弹层。
|
||||
const [publishKb, setPublishKb] = useState<KnowledgeBaseVO | null>(null);
|
||||
|
||||
// 1. 获取知识库列表及对应的删除与卸载 Mutation Hooks
|
||||
// 1. 获取知识库列表及对应的删除 Mutation Hooks
|
||||
const { data, isLoading, isError } = useKnowledgeList(activeTab);
|
||||
const { mutate: deleteKB } = useKnowledgeDelete();
|
||||
const { mutate: uninstallKB } = useInstalledKnowledgeUninstall();
|
||||
const { mutate: disableKB } = useKnowledgeDisable();
|
||||
const { mutate: restoreKB } = useKnowledgeRestore();
|
||||
const { mutate: disableInstalledKB } = useInstalledKnowledgeDisable();
|
||||
const { mutate: restoreInstalledKB } = useInstalledKnowledgeRestore();
|
||||
|
||||
// 2. 严格的运行期类型收窄与防空校验
|
||||
const rawList = data?.list || [];
|
||||
@ -53,13 +47,6 @@ export default function KnowledgePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstallKB = (e: React.MouseEvent, installId: string, name: string) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`确认卸载已安装的知识库《${name}》吗?\n卸载后作品绑定的设定参考将在生成中失效。`)) {
|
||||
uninstallKB(installId);
|
||||
}
|
||||
};
|
||||
|
||||
// 停用:软关闭知识库(资产保留可恢复,区别于物理删除);恢复:重新启用。均阻止冒泡防误触跳转资料面板。
|
||||
const handleDisableKB = (e: React.MouseEvent, kbId: string, name: string) => {
|
||||
e.stopPropagation();
|
||||
@ -73,19 +60,6 @@ export default function KnowledgePage() {
|
||||
restoreKB(kbId);
|
||||
};
|
||||
|
||||
// 已安装知识库停用/恢复:软状态切换,区别于卸载(物理删除本地安装)。停用前 confirm,恢复直接。
|
||||
const handleDisableInstalled = (e: React.MouseEvent, installId: string, name: string) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`确认停用已安装知识库《${name}》吗?\n停用后该库在生成中不可用,但安装记录保留,可随时恢复。`)) {
|
||||
disableInstalledKB(installId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreInstalled = (e: React.MouseEvent, installId: string) => {
|
||||
e.stopPropagation();
|
||||
restoreInstalledKB(installId);
|
||||
};
|
||||
|
||||
// 3. 拦截状态:如果选中了具体知识库,渲染资料文件管理器 (Detail/Materials Panel)
|
||||
if (selectedKbId) {
|
||||
const activeKb = knowledgeBases.find((kb) => kb.kbId === selectedKbId);
|
||||
@ -150,16 +124,6 @@ export default function KnowledgePage() {
|
||||
>
|
||||
我创建的
|
||||
</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
|
||||
onClick={() => setActiveTab('global')}
|
||||
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">
|
||||
{activeTab === 'user'
|
||||
? '暂无自建知识库'
|
||||
: activeTab === 'installed'
|
||||
? '暂无已安装知识库'
|
||||
: '无公开授权知识库'}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 mt-2 text-center max-w-sm">
|
||||
{activeTab === 'user'
|
||||
? '知识库可以帮助 AI 深刻理解您小说的大纲设定。赶快创建一个世界观或人物卡知识库吧!'
|
||||
: activeTab === 'installed'
|
||||
? '您可以前往创作市场,下载行业专家整理的背景资料、神话体系大百科等现成资产。'
|
||||
: '管理员暂未发布任何全局公共授权库。'}
|
||||
</p>
|
||||
{activeTab === 'user' && (
|
||||
@ -239,20 +199,8 @@ export default function KnowledgePage() {
|
||||
|
||||
{/* 操作按钮权限硬流控隔离 */}
|
||||
{kb.source === 'user' ? (
|
||||
// 自建:发布到市场(可发布性预检入口)+ 删除
|
||||
// 自建:停用/恢复 + 删除
|
||||
<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 显示停用,否则显示恢复);软状态管理,区别于物理删除。 */}
|
||||
{kb.status === 'active' ? (
|
||||
<button
|
||||
@ -286,41 +234,6 @@ export default function KnowledgePage() {
|
||||
</svg>
|
||||
</button>
|
||||
</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">
|
||||
@ -375,16 +288,6 @@ export default function KnowledgePage() {
|
||||
|
||||
{/* 创建新知识库的弹框 */}
|
||||
<CreateKBModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
|
||||
{/* 知识库发布到市场:可发布性预检弹层(诚实呈现受阻态,不伪造可发布) */}
|
||||
{publishKb && (
|
||||
<KnowledgePublishModal
|
||||
kbId={publishKb.kbId}
|
||||
kbName={publishKb.name}
|
||||
version={publishKb.currentVersion}
|
||||
onClose={() => setPublishKb(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ export default function LoginPage() {
|
||||
登录后进入创作工作台
|
||||
</h1>
|
||||
<p className="mt-4 max-w-lg text-sm leading-6 text-slate-200">
|
||||
作品、知识库、智能体和市场资产都按账户隔离;未登录请求会被后端拒绝。
|
||||
作品、知识库和智能体都按账户隔离;未登录请求会被后端拒绝。
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-slate-300">
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
},
|
||||
}));
|
||||
@ -38,21 +38,17 @@ function createStorageMock(): Storage {
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.localStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: createStorageMock(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!globalThis.sessionStorage) {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: createStorageMock(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 聚合所有子功能模块的 Mock 规则
|
||||
export const server = setupServer(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user