feat(market): studio 资产详情页加「绑定到作品授权预检」(来源侧 handoff 预检) + e2e

P2 真缺口补全(5/7):资产详情页新增「绑定到作品」区——获取授权 + 来源侧 bind-precheck(展示授权摘要/兼容性/就绪态/绑定策略)。useBindPrecheck→POST /assets/{id}/bind-precheck(targetOwner=knowledge/targetAction=bind/targetWorkId,commandId 走 body);详情页接 useAcquireMarketAsset 获取授权 + 预检结果回显。

诚实边界:只做来源侧只读预检(返回授权摘要,不产生绑定事实);完整交接 token 由知识域消费(Market 不消费 token),消费端 UI 不在本切片——故不做 create handoff token(避免一侧设计)。

反假绿:probe 真后端确认链路——bind-precheck 需先 acquire(否则 1044000006 授权不存在);asset 须 listed(asset 2 not_listed→1044000004);agent 资产 targetOwner=knowledge/action=bind 通(ai/install 报 1044000021 模板不支持)。

验证:vitest market 4/4(加 bind-precheck URL+body 契约)、tsc 0 错;新增 e2e market-handoff-precheck.spec.ts 真后端绿(acquire→预检→handoffReady:true),全量 28/28 无回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-20 20:31:27 -07:00
parent 7a16d19211
commit 9c84c05e62
4 changed files with 191 additions and 2 deletions

View File

@ -0,0 +1,50 @@
import { expect, test, type Page } from '@playwright/test';
/**
* e2e( handoff ,,MSW off)
*
* 前置:vite VITE_API_MOCK=false muse-server 48080muse_slice_live listed (asset 1)
* 目的:资产详情页先获取授权(acquire) bind-precheck(targetOwner=knowledge/targetAction=bind),
* (handoffReady),(绿)
* :完整交接 token ,(Market token)
*/
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('已授权资产绑定到作品授权预检 → 来源侧就绪(真实后端)', async ({ page, request }) => {
await seedToken(page);
// 前提:确保 asset 1 已获取授权(幂等 best-effort;bind-precheck 需授权存在,否则返「Market 授权不存在」)。
await request
.post('http://localhost:48080/app-api/muse/marketplace/assets/1/purchase', {
headers: { Authorization: 'Bearer test1', 'tenant-id': '1', 'X-API-Version': '1' },
data: { commandId: `e2e-acq-${Date.now()}` },
})
.catch(() => undefined);
await page.goto('/market/assets/1');
// 输入目标作品 + 授权预检,捕获真实 bind-precheck 响应(打真后端,非 mock)。
await page.getByLabel('目标作品 ID').fill('1');
const [resp] = await Promise.all([
page.waitForResponse(
(r) => /\/assets\/1\/bind-precheck$/.test(r.url()) && r.request().method() === 'POST',
{ timeout: 15_000 }
),
page.getByRole('button', { name: '授权预检' }).click(),
]);
// Yudao 口径:成功为 HTTP 200 + CommonResult{code:0}。
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body.code).toBe(0);
expect(body.data?.handoffReady).toBe(true);
// 回显:预检就绪态(可绑定)。
await expect(page.getByText('可绑定(就绪)')).toBeVisible({ timeout: 15_000 });
});

View File

@ -7,6 +7,7 @@ import { resetMockDb } from '@/api/mocks/handlers/market';
import { server } from '@/test/setup';
import {
useAcquireMarketAsset,
useBindPrecheck,
useFavoriteAsset,
useInstallMarketAsset,
useMarketAssetDetail,
@ -93,4 +94,34 @@ describe('useMarket API Hooks 单元测试', () => {
expect(requestedUrls[0]).toContain('/app-api/muse/marketplace/assets/a1');
expect(requestedUrls[1]).toContain('/app-api/muse/marketplace/assets/a1/favorite');
});
it('绑定预检向来源侧端点提交 owner/action/work + commandId', async () => {
const bodies: Record<string, unknown>[] = [];
const urls: string[] = [];
server.use(
http.post('/app-api/muse/marketplace/assets/:assetId/bind-precheck', async ({ request }) => {
urls.push(request.url);
bodies.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({
code: 0,
msg: '',
data: { handoffReady: true, sourceStatus: 'available', targetWorkId: 1, compatibilityResult: 'source_summary_ready' },
});
})
);
const { result } = renderHook(() => useBindPrecheck('1'), { wrapper });
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.handoffReady).toBe(true);
// 契约:命中 bind-precheck 端点;body 带 targetOwner=knowledge/targetAction=bind/targetWorkId + commandId。
expect(urls[0]).toContain('/app-api/muse/marketplace/assets/1/bind-precheck');
expect(bodies[0]).toMatchObject({
targetOwner: 'knowledge',
targetAction: 'bind',
targetWorkId: 1,
commandId: expect.any(String),
});
});
});

View File

@ -124,3 +124,37 @@ export function useFavoriteAsset(assetId: string) {
},
});
}
/** 市场资产绑定授权预检结果(来源侧:授权摘要/兼容性/就绪态/动作策略)。 */
export interface MarketBindPrecheckResult {
authorizationSummaryId?: number;
assetId?: string;
sourceStatus?: string;
targetOwner?: string;
targetAction?: string;
targetWorkId?: number;
authorizationSnapshot?: string;
handoffReady?: boolean;
compatibilityResult?: string;
actionPolicy?: { installPolicy?: string; bindPolicy?: string; recheckReasons?: string[] };
expiresAt?: number;
}
/**
* ()://(handoffReady)
*
* WHY:这是 Market (handoff), token
* ( token ,Market ;)
* (Market );commandId body( favorite header)
*/
export function useBindPrecheck(assetId: string) {
return useMutation({
mutationFn: (targetWorkId: number) =>
api.post<MarketBindPrecheckResult>(`/marketplace/assets/${assetId}/bind-precheck`, {
targetOwner: 'knowledge',
targetAction: 'bind',
targetWorkId,
commandId: crypto.randomUUID(),
}),
});
}

View File

@ -1,16 +1,26 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useMarketAssetDetail, useFavoriteAsset } from '@/features/market/hooks/useMarket';
import {
useAcquireMarketAsset,
useBindPrecheck,
useFavoriteAsset,
useMarketAssetDetail,
} from '@/features/market/hooks/useMarket';
/**
* ()
*
* (MarketBrowse) licenseSummary ; GET /marketplace/assets/{id},
* (licenseInfo:类型////),(POST favorite)
* (licenseInfo)(POST favorite),
* ( bind-precheck:展示授权摘要//; token ,)
*/
export default function MarketAssetDetailPage() {
const { assetId } = useParams<{ assetId: string }>();
const { data, isLoading, isError } = useMarketAssetDetail(assetId ?? null);
const favorite = useFavoriteAsset(assetId ?? '');
const acquire = useAcquireMarketAsset();
const bindPrecheck = useBindPrecheck(assetId ?? '');
const [targetWorkId, setTargetWorkId] = useState('');
if (isLoading) {
return (
@ -34,6 +44,7 @@ export default function MarketAssetDetailPage() {
}
const license = data.licenseInfo;
const precheck = bindPrecheck.data;
return (
<div className="mx-auto max-w-3xl px-6 py-10">
@ -102,6 +113,69 @@ export default function MarketAssetDetailPage() {
</section>
)}
{/* 绑定到作品授权预检(来源侧 handoff 预检):先获取授权,再预检绑定到作品的来源授权摘要/就绪态 */}
<section aria-label="绑定授权预检" className="mt-6 rounded-2xl border border-indigo-100 bg-indigo-50/30 p-6">
<h2 className="text-base font-bold text-slate-800"></h2>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
,( token )
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => acquire.mutate(assetId ?? '')}
disabled={acquire.isPending || acquire.isSuccess}
className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{acquire.isSuccess ? '✓ 已获取授权' : acquire.isPending ? '获取中…' : '获取授权'}
</button>
<input
type="text"
inputMode="numeric"
value={targetWorkId}
onChange={(e) => setTargetWorkId(e.target.value.replace(/[^0-9]/g, ''))}
aria-label="目标作品 ID"
placeholder="目标作品 ID"
className="w-32 rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none"
/>
<button
type="button"
onClick={() => bindPrecheck.mutate(Number(targetWorkId))}
disabled={!targetWorkId || bindPrecheck.isPending}
className="rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 px-4 py-2 text-sm font-semibold text-white transition hover:from-indigo-700 hover:to-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{bindPrecheck.isPending ? '预检中…' : '授权预检'}
</button>
</div>
{precheck && (
<dl className="mt-4 space-y-1.5 rounded-xl border border-indigo-100 bg-white p-4 text-sm">
<div className="flex gap-2">
<dt className="w-24 shrink-0 text-slate-400"></dt>
<dd className={`font-semibold ${precheck.handoffReady ? 'text-emerald-700' : 'text-amber-700'}`}>
{precheck.handoffReady ? '可绑定(就绪)' : '未就绪'}
</dd>
</div>
<div className="flex gap-2">
<dt className="w-24 shrink-0 text-slate-400"></dt>
<dd className="font-medium text-slate-700">{precheck.sourceStatus}</dd>
</div>
<div className="flex gap-2">
<dt className="w-24 shrink-0 text-slate-400"></dt>
<dd className="font-medium text-slate-700">{precheck.compatibilityResult}</dd>
</div>
<div className="flex gap-2">
<dt className="w-24 shrink-0 text-slate-400"></dt>
<dd className="font-medium text-slate-700">{precheck.actionPolicy?.bindPolicy}</dd>
</div>
</dl>
)}
{bindPrecheck.isError && (
<p className="mt-3 text-xs font-medium text-rose-600">
():{bindPrecheck.error.message}
</p>
)}
</section>
{data.tags && data.tags.length > 0 && (
<div className="mt-6 flex flex-wrap gap-2">
{data.tags.map((tag) => (