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:
parent
7a16d19211
commit
9c84c05e62
50
muse-studio/e2e/market-handoff-precheck.spec.ts
Normal file
50
muse-studio/e2e/market-handoff-precheck.spec.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 市场资产「绑定到作品授权预检」e2e(来源侧 handoff 预检,真实后端,MSW off)。
|
||||
*
|
||||
* 前置:vite VITE_API_MOCK=false、真实 muse-server 48080、muse_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 });
|
||||
});
|
||||
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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) => (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user