feat(handoff): P3 content 兑现侧落地 + 02F 来源侧发起(完整闭环)

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) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-22 22:52:16 -07:00
parent d113f4ee15
commit 7bb15a5cf4
5 changed files with 356 additions and 1 deletions

View File

@ -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(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/handoff/land/content']}>
<ContentHandoffLanding />
</MemoryRouter>
</QueryClientProvider>
);
}
describe('ContentHandoffLanding 兑现闭环', () => {
beforeEach(() => {
useHandoffStore.getState().clearSession();
// 预设落地态:已从来源空间带入一次性 token + 上下文
useHandoffStore.getState().setLanding({
handoffToken: 'handoff_content_token',
targetOwner: 'content',
targetAction: 'asset_use',
assetId: '2',
targetWorkId: '4',
returnUrl: '/market',
});
server.use(
// 来源侧 bind-precheck(targetOwner=content):后端返回可信 source
http.post('/app-api/muse/marketplace/assets/:assetId/bind-precheck', () =>
HttpResponse.json({
code: 0,
msg: 'success',
data: {
handoffReady: true,
sourceVersion: 1,
sourceStatus: 'available',
authorizationSummaryId: 45,
authorizationSnapshot: 'snap-1',
},
})
),
// 目标侧 asset_use 预检(核验+核销 token)
http.post('/app-api/muse/works/:workId/asset-use-prechecks', () =>
HttpResponse.json({
code: 0,
msg: 'success',
data: { assetUsePrecheckId: 'content-precheck-1', expiresAt: new Date(Date.now() + 300000).toISOString() },
})
),
// 读作品 revision(create 段乐观锁)
http.get('/app-api/muse/works/:workId', () =>
HttpResponse.json({ code: 0, msg: 'success', data: { id: 4, revision: 3 } })
),
// 确认使用(凭 precheckId)
http.post('/app-api/muse/works/:workId/asset-uses', () =>
HttpResponse.json({ code: 0, msg: 'success', data: { assetUseId: 'content-precheck-1', purposes: ['reference'] } })
)
);
});
it('就绪态渲染确认页', () => {
renderLanding();
expect(screen.getByText('确认使用市场资产')).toBeTruthy();
expect(screen.getByRole('button', { name: /确认使用/ })).toBeTruthy();
});
it('点确认走完 bind-precheck→assetUsePrecheck→createAssetUse 闭环,完成、会话清空', async () => {
renderLanding();
fireEvent.click(screen.getByRole('button', { name: /确认使用/ }));
// 闭环成功 → 资产使用已确认
await waitFor(() => expect(screen.getByText('资产使用已确认')).toBeTruthy());
// 兑现完成后会话清空(token 一次性、用完即弃)
expect(useHandoffStore.getState().handoffToken).toBeNull();
expect(useHandoffStore.getState().precheckId).toBeNull();
});
it('后端核验拒绝(伪造/过期 token)→ 失败提示,不落使用', async () => {
// 覆盖 precheck mock,模拟后端 verify 拒绝(token 不可用 → CONTENT_ASSET_HANDOFF_UNAVAILABLE)
server.use(
http.post('/app-api/muse/works/:workId/asset-use-prechecks', () =>
HttpResponse.json({ code: 1_041_002_004, msg: '作品资产 Market handoff 凭据不可用', data: null }, { status: 400 })
)
);
renderLanding();
fireEvent.click(screen.getByRole('button', { name: /确认使用/ }));
await waitFor(() => expect(screen.getByText(/Market handoff 凭据不可用/)).toBeTruthy());
expect(screen.queryByText('资产使用已确认')).toBeNull();
});
it('会话缺凭据(无 token)→ 失效提示', () => {
useHandoffStore.getState().clearSession();
renderLanding();
expect(screen.getByText(/会话已失效或缺少凭据/)).toBeTruthy();
});
});

View File

@ -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<string | null>(null);
const [done, setDone] = useState(false);
// 兑现中仍算就绪:setPrecheck 后 handoffToken 已清(用完即弃),但 precheckId 在 → 仍在兑现流程内
const ready = Boolean((handoffToken || precheckId) && assetId && targetWorkId);
const pending = assetUsePrecheck.isPending || createAssetUse.isPending;
async function confirmUse() {
if (!handoffToken || !assetId || !targetWorkId) return;
setError(null);
try {
// 1. 来源侧预检(targetOwner=content):后端查授权,返回可信 source 字段
const src = await api.post<BindPrecheckResult>(`/marketplace/assets/${assetId}/bind-precheck`, {
commandId: createCommandId(),
targetOwner: 'content',
targetAction: 'asset_use',
targetWorkId: Number(targetWorkId),
});
if (!src.handoffReady) {
setError('来源授权未就绪,无法使用');
return;
}
// 2. 目标侧预检:后端核验 + 核销 handoff token,换 assetUsePrecheckId
const pc = await assetUsePrecheck.mutateAsync({
workId: targetWorkId,
sourceId: assetId,
sourceVersion: src.sourceVersion ?? 1,
sourceStatus: src.sourceStatus ?? 'available',
handoffToken,
authorizationSummaryId: String(src.authorizationSummaryId ?? ''),
authorizationSnapshotId: src.authorizationSnapshot ?? '',
purposes: ['reference'],
});
if (!pc.assetUsePrecheckId) {
setError('目标侧预检未返回 precheckId');
return;
}
setPrecheck(pc.assetUsePrecheckId, pc.expiresAt ?? null); // token 用完即弃
// 3. 确认使用:读作品 revision 防乐观锁覆盖过期状态(真后端详情含 revision、兜底 1),落 asset_use 使用事实
const work = await api.get<{ revision?: number }>(`/works/${targetWorkId}`);
await createAssetUse.mutateAsync({
workId: targetWorkId,
assetUsePrecheckId: pc.assetUsePrecheckId,
expectedWorkRevision: typeof work?.revision === 'number' ? work.revision : 1,
});
clearSession();
setDone(true);
} catch (e) {
setError(e instanceof Error ? e.message : '使用失败,请稍后重试');
}
}
if (done) {
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900">资产使用已确认</h1>
<p className="mt-3 text-sm text-emerald-700">
市场资产已授权用于作品 #{targetWorkId}(参考 / AI 上下文)。
</p>
<button
type="button"
onClick={() => navigate(returnUrl || '/market')}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
>
返回
</button>
</div>
);
}
if (!ready) {
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<p className="text-sm text-amber-700">交接会话已失效或缺少凭据,请返回来源页重新发起。</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-xl font-bold text-slate-900">确认使用市场资产</h1>
<p className="mt-3 text-sm text-slate-500">
将市场资产授权用于作品 #{targetWorkId}(作参考 / AI 上下文)。确认后系统会服务端核验交接凭据并记录使用。
</p>
{error && (
<p className="mt-3 text-sm text-rose-600" role="alert">
{error}
</p>
)}
<button
type="button"
onClick={confirmUse}
disabled={pending}
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50"
>
{pending ? '处理中…' : '确认使用'}
</button>
</div>
);
}

View File

@ -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<AssetUsePrecheckResult, Error, AssetUsePrecheckInput>({
mutationFn: ({ workId, ...body }) =>
api.post<AssetUsePrecheckResult>(`/works/${workId}/asset-use-prechecks`, {
commandId: createCommandId(),
sourceType: 'market_asset',
...body,
}),
});
}
export interface CreateAssetUseInput {
workId: string;
assetUsePrecheckId: string;
expectedWorkRevision: number;
}
export interface AssetUseResult {
assetUseId?: string;
sourceType?: string;
sourceId?: string;
purposes?: string[];
}
/**
* 确认作品资产使用:POST /works/{workId}/asset-uses。
* 只消费 assetUsePrecheckId(token 已在 precheck 核销)+ work 级乐观锁,落使用事实(单表转 consumed)。
*/
export function useCreateAssetUse() {
return useMutation<AssetUseResult, Error, CreateAssetUseInput>({
mutationFn: ({ workId, ...body }) =>
api.post<AssetUseResult>(`/works/${workId}/asset-uses`, {
commandId: createCommandId(),
...body,
}),
});
}

View File

@ -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 <AgentHandoffLanding />;
}
if (targetOwner === 'content') {
return <ContentHandoffLanding />;
}
// content 落地组件在 P3 接入
// 其余 owner(未来扩展)兜底
const hasSession = Boolean(handoffToken || precheckId);
return (
<div className="mx-auto max-w-2xl px-6 py-10">

View File

@ -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 (
<div className="mx-auto max-w-3xl px-6 py-10">
<Link to="/market" className="text-sm font-semibold text-indigo-600 hover:text-indigo-700">
@ -271,6 +295,16 @@ export default function MarketAssetDetailPage() {
{createHandoff.isPending ? '发起交接中…' : '前往绑定(进入知识空间)'}
</button>
)}
{precheck?.handoffReady && (
<button
type="button"
onClick={startContentHandoff}
disabled={createHandoff.isPending}
className="mt-3 ml-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{createHandoff.isPending ? '发起交接中…' : '用于作品(进入作品空间)'}
</button>
)}
{createHandoff.isError && (
<p className="mt-3 text-xs font-medium text-rose-600">
发起交接失败:{createHandoff.error.message}