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:
parent
d113f4ee15
commit
7bb15a5cf4
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
77
muse-studio/src/features/handoff/owners/useContentHandoff.ts
Normal file
77
muse-studio/src/features/handoff/owners/useContentHandoff.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user