From 7bb15a5cf4acb71805723f3f4ee04824eef7ef3b Mon Sep 17 00:00:00 2001 From: lili Date: Mon, 22 Jun 2026 22:52:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(handoff):=20P3=20content=20=E5=85=91?= =?UTF-8?q?=E7=8E=B0=E4=BE=A7=E8=90=BD=E5=9C=B0=20+=2002F=20=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E4=BE=A7=E5=8F=91=E8=B5=B7(=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E9=97=AD=E7=8E=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentHandoffLanding(P3 完整闭环):bind-precheck(targetOwner=content)拿可信 source → assetUsePrecheck(market+token,后端 verify+consume)→ setPrecheck(token 用完即弃)→ 读 work revision → createAssetUse(凭 precheckId)→ 完成。useContentHandoff hooks + HandoffLandingPage content dispatch + MarketAssetDetailPage「用于作品(进入作品空间)」发起入口 (asset_use 不依赖物化、sourceAssetId 真实,故来源侧发起完整可用、比 P1/P2 更完整)。 验证:tsc OK、eslint 空、vitest 97→101(ContentHandoffLanding 4:就绪/闭环/核验拒绝/会话失效)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../owners/ContentHandoffLanding.test.tsx | 104 ++++++++++++++ .../handoff/owners/ContentHandoffLanding.tsx | 136 ++++++++++++++++++ .../handoff/owners/useContentHandoff.ts | 77 ++++++++++ .../handoff/pages/HandoffLandingPage.tsx | 6 +- .../src/pages/MarketAssetDetailPage.tsx | 34 +++++ 5 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx create mode 100644 muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx create mode 100644 muse-studio/src/features/handoff/owners/useContentHandoff.ts diff --git a/muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx b/muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx new file mode 100644 index 00000000..68c16a39 --- /dev/null +++ b/muse-studio/src/features/handoff/owners/ContentHandoffLanding.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { server } from '@/test/setup'; +import { useHandoffStore } from '@/stores/handoffStore'; +import ContentHandoffLanding from './ContentHandoffLanding'; + +const createTestQueryClient = () => + new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); + +function renderLanding() { + const client = createTestQueryClient(); + return render( + + + + + + ); +} + +describe('ContentHandoffLanding 兑现闭环', () => { + beforeEach(() => { + useHandoffStore.getState().clearSession(); + // 预设落地态:已从来源空间带入一次性 token + 上下文 + useHandoffStore.getState().setLanding({ + handoffToken: 'handoff_content_token', + targetOwner: 'content', + targetAction: 'asset_use', + assetId: '2', + targetWorkId: '4', + returnUrl: '/market', + }); + server.use( + // 来源侧 bind-precheck(targetOwner=content):后端返回可信 source + http.post('/app-api/muse/marketplace/assets/:assetId/bind-precheck', () => + HttpResponse.json({ + code: 0, + msg: 'success', + data: { + handoffReady: true, + sourceVersion: 1, + sourceStatus: 'available', + authorizationSummaryId: 45, + authorizationSnapshot: 'snap-1', + }, + }) + ), + // 目标侧 asset_use 预检(核验+核销 token) + http.post('/app-api/muse/works/:workId/asset-use-prechecks', () => + HttpResponse.json({ + code: 0, + msg: 'success', + data: { assetUsePrecheckId: 'content-precheck-1', expiresAt: new Date(Date.now() + 300000).toISOString() }, + }) + ), + // 读作品 revision(create 段乐观锁) + http.get('/app-api/muse/works/:workId', () => + HttpResponse.json({ code: 0, msg: 'success', data: { id: 4, revision: 3 } }) + ), + // 确认使用(凭 precheckId) + http.post('/app-api/muse/works/:workId/asset-uses', () => + HttpResponse.json({ code: 0, msg: 'success', data: { assetUseId: 'content-precheck-1', purposes: ['reference'] } }) + ) + ); + }); + + it('就绪态渲染确认页', () => { + renderLanding(); + expect(screen.getByText('确认使用市场资产')).toBeTruthy(); + expect(screen.getByRole('button', { name: /确认使用/ })).toBeTruthy(); + }); + + it('点确认走完 bind-precheck→assetUsePrecheck→createAssetUse 闭环,完成、会话清空', async () => { + renderLanding(); + fireEvent.click(screen.getByRole('button', { name: /确认使用/ })); + // 闭环成功 → 资产使用已确认 + await waitFor(() => expect(screen.getByText('资产使用已确认')).toBeTruthy()); + // 兑现完成后会话清空(token 一次性、用完即弃) + expect(useHandoffStore.getState().handoffToken).toBeNull(); + expect(useHandoffStore.getState().precheckId).toBeNull(); + }); + + it('后端核验拒绝(伪造/过期 token)→ 失败提示,不落使用', async () => { + // 覆盖 precheck mock,模拟后端 verify 拒绝(token 不可用 → CONTENT_ASSET_HANDOFF_UNAVAILABLE) + server.use( + http.post('/app-api/muse/works/:workId/asset-use-prechecks', () => + HttpResponse.json({ code: 1_041_002_004, msg: '作品资产 Market handoff 凭据不可用', data: null }, { status: 400 }) + ) + ); + renderLanding(); + fireEvent.click(screen.getByRole('button', { name: /确认使用/ })); + await waitFor(() => expect(screen.getByText(/Market handoff 凭据不可用/)).toBeTruthy()); + expect(screen.queryByText('资产使用已确认')).toBeNull(); + }); + + it('会话缺凭据(无 token)→ 失效提示', () => { + useHandoffStore.getState().clearSession(); + renderLanding(); + expect(screen.getByText(/会话已失效或缺少凭据/)).toBeTruthy(); + }); +}); diff --git a/muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx b/muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx new file mode 100644 index 00000000..1ba0ba1a --- /dev/null +++ b/muse-studio/src/features/handoff/owners/ContentHandoffLanding.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '@/api/client'; +import { useHandoffStore } from '@/stores/handoffStore'; +import { useAssetUsePrecheck, useCreateAssetUse } from './useContentHandoff'; + +/** 幂等键生成(对齐 handoff 域 createCommandId)。 */ +function createCommandId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +interface BindPrecheckResult { + handoffReady?: boolean; + sourceVersion?: number; + sourceStatus?: string; + authorizationSummaryId?: number | string; + authorizationSnapshot?: string; +} + +/** + * content 目标空间 handoff 落地组件(P3)。 + * + * 兑现闭环(红线"不信客户端 URL 参数"): + * 1. 来源侧 bind-precheck(targetOwner=content):后端查授权返回可信 source(不信客户端自造) + * 2. 目标侧 assetUsePrecheck(带 handoffToken):后端核验 + 核销 token,换 precheckId → setPrecheck(token 用完即弃) + * 3. createAssetUse(只消费 precheckId + work 级乐观锁):落作品资产使用事实 → clearSession → 返回来源 + * + * asset_use 只记录"作品已授权使用市场资产作参考/AI 上下文",不拉内容/不写正文(产品-02F L456)。 + */ +export default function ContentHandoffLanding() { + const navigate = useNavigate(); + const { handoffToken, precheckId, assetId, targetWorkId, returnUrl, setPrecheck, clearSession } = useHandoffStore(); + const assetUsePrecheck = useAssetUsePrecheck(); + const createAssetUse = useCreateAssetUse(); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + // 兑现中仍算就绪:setPrecheck 后 handoffToken 已清(用完即弃),但 precheckId 在 → 仍在兑现流程内 + const ready = Boolean((handoffToken || precheckId) && assetId && targetWorkId); + const pending = assetUsePrecheck.isPending || createAssetUse.isPending; + + async function confirmUse() { + if (!handoffToken || !assetId || !targetWorkId) return; + setError(null); + try { + // 1. 来源侧预检(targetOwner=content):后端查授权,返回可信 source 字段 + const src = await api.post(`/marketplace/assets/${assetId}/bind-precheck`, { + commandId: createCommandId(), + targetOwner: 'content', + targetAction: 'asset_use', + targetWorkId: Number(targetWorkId), + }); + if (!src.handoffReady) { + setError('来源授权未就绪,无法使用'); + return; + } + // 2. 目标侧预检:后端核验 + 核销 handoff token,换 assetUsePrecheckId + const pc = await assetUsePrecheck.mutateAsync({ + workId: targetWorkId, + sourceId: assetId, + sourceVersion: src.sourceVersion ?? 1, + sourceStatus: src.sourceStatus ?? 'available', + handoffToken, + authorizationSummaryId: String(src.authorizationSummaryId ?? ''), + authorizationSnapshotId: src.authorizationSnapshot ?? '', + purposes: ['reference'], + }); + if (!pc.assetUsePrecheckId) { + setError('目标侧预检未返回 precheckId'); + return; + } + setPrecheck(pc.assetUsePrecheckId, pc.expiresAt ?? null); // token 用完即弃 + // 3. 确认使用:读作品 revision 防乐观锁覆盖过期状态(真后端详情含 revision、兜底 1),落 asset_use 使用事实 + const work = await api.get<{ revision?: number }>(`/works/${targetWorkId}`); + await createAssetUse.mutateAsync({ + workId: targetWorkId, + assetUsePrecheckId: pc.assetUsePrecheckId, + expectedWorkRevision: typeof work?.revision === 'number' ? work.revision : 1, + }); + clearSession(); + setDone(true); + } catch (e) { + setError(e instanceof Error ? e.message : '使用失败,请稍后重试'); + } + } + + if (done) { + return ( +
+

资产使用已确认

+

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

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

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

+
+ ); + } + + return ( +
+

确认使用市场资产

+

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

+ {error && ( +

+ {error} +

+ )} + +
+ ); +} diff --git a/muse-studio/src/features/handoff/owners/useContentHandoff.ts b/muse-studio/src/features/handoff/owners/useContentHandoff.ts new file mode 100644 index 00000000..b97930cd --- /dev/null +++ b/muse-studio/src/features/handoff/owners/useContentHandoff.ts @@ -0,0 +1,77 @@ +import { useMutation } from '@tanstack/react-query'; +import { api } from '@/api/client'; + +/** 幂等键生成(测试环境降级,对齐 ai/content 域 createCommandId)。 */ +function createCommandId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** + * 目标侧 content 作品资产使用预检入参(market→content 跨空间 handoff)。 + * + * 红线:handoffToken 必填——后端在 precheck 阶段服务端核验 + 核销 token(消费换 precheckId), + * create 段只凭 precheckId(token 已核销)。asset_use 只记录使用授权事实,不写入正文/参考来源。 + */ +export interface AssetUsePrecheckInput { + workId: string; + sourceId: string; + sourceVersion: number; + sourceStatus: string; + handoffToken: string; + authorizationSummaryId: string; + authorizationSnapshotId: string; + purposes?: string[] | undefined; +} + +export interface AssetUsePrecheckResult { + assetUsePrecheckId?: string; + allowedPurposes?: string[]; + blockedPurposes?: string[]; + blockedReasons?: string[]; + sourceStatus?: string; + expiresAt?: string; +} + +/** + * 作品资产使用预检:POST /works/{workId}/asset-use-prechecks。 + * 后端在此核验 + 核销 handoff token,返回 assetUsePrecheckId(后续 create 唯一可信凭据)。 + */ +export function useAssetUsePrecheck() { + return useMutation({ + mutationFn: ({ workId, ...body }) => + api.post(`/works/${workId}/asset-use-prechecks`, { + commandId: createCommandId(), + sourceType: 'market_asset', + ...body, + }), + }); +} + +export interface CreateAssetUseInput { + workId: string; + assetUsePrecheckId: string; + expectedWorkRevision: number; +} + +export interface AssetUseResult { + assetUseId?: string; + sourceType?: string; + sourceId?: string; + purposes?: string[]; +} + +/** + * 确认作品资产使用:POST /works/{workId}/asset-uses。 + * 只消费 assetUsePrecheckId(token 已在 precheck 核销)+ work 级乐观锁,落使用事实(单表转 consumed)。 + */ +export function useCreateAssetUse() { + return useMutation({ + mutationFn: ({ workId, ...body }) => + api.post(`/works/${workId}/asset-uses`, { + commandId: createCommandId(), + ...body, + }), + }); +} diff --git a/muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx b/muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx index 9a3cb70f..a7a96688 100644 --- a/muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx +++ b/muse-studio/src/features/handoff/pages/HandoffLandingPage.tsx @@ -3,6 +3,7 @@ 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 分发)。 @@ -52,8 +53,11 @@ export default function HandoffLandingPage() { if (targetOwner === 'agent') { return ; } + if (targetOwner === 'content') { + return ; + } - // content 落地组件在 P3 接入 + // 其余 owner(未来扩展)兜底 const hasSession = Boolean(handoffToken || precheckId); return (
diff --git a/muse-studio/src/pages/MarketAssetDetailPage.tsx b/muse-studio/src/pages/MarketAssetDetailPage.tsx index 514576d9..3e719faf 100644 --- a/muse-studio/src/pages/MarketAssetDetailPage.tsx +++ b/muse-studio/src/pages/MarketAssetDetailPage.tsx @@ -99,6 +99,30 @@ export default function MarketAssetDetailPage() { navigate(`/handoff/land/knowledge?${params.toString()}`); } + // 发起跨空间交接到 content(asset_use):同款一次性 token 流,目标 content 空间记录"作品使用市场资产作参考/AI 上下文"。 + // asset_use 不依赖资产物化(sourceAssetId=真实 asset id),故来源侧发起入口完整可用(P3 完整闭环)。 + async function startContentHandoff() { + if (!assetId || !targetWorkId || !precheck?.authorizationSummaryId) return; + const returnPath = `/market/assets/${assetId}`; + const res = await createHandoff.mutateAsync({ + assetId, + targetOwner: 'content', + targetAction: 'asset_use', + targetWorkId: Number(targetWorkId), + authorizationSummaryId: precheck.authorizationSummaryId, + returnUrl: `${window.location.origin}${returnPath}`, + }); + if (!res.handoffToken) return; + const params = new URLSearchParams({ + token: res.handoffToken, + assetId, + targetWorkId, + action: 'asset_use', + returnUrl: returnPath, + }); + navigate(`/handoff/land/content?${params.toString()}`); + } + return (
@@ -271,6 +295,16 @@ export default function MarketAssetDetailPage() { {createHandoff.isPending ? '发起交接中…' : '前往绑定(进入知识空间)'} )} + {precheck?.handoffReady && ( + + )} {createHandoff.isError && (

发起交接失败:{createHandoff.error.message}