test(admin): harden Muse admin governance checks

This commit is contained in:
zizi 2026-05-26 03:59:48 +08:00
parent e068e0efff
commit e3e330317f
26 changed files with 2292 additions and 310 deletions

View File

@ -281,8 +281,7 @@ paths:
required: [commandId]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键,前端生成 UUID
licenseType:
type: string
@ -333,8 +332,7 @@ paths:
required: [commandId]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键,前端生成 UUID
versionId:
type: integer
@ -401,8 +399,7 @@ paths:
format: int64
description: 目标作品 ID
commandId:
type: integer
format: int64
type: string
description: 幂等键
responses:
'200':
@ -443,8 +440,7 @@ paths:
required: [assetId, targetOwner, targetAction, returnUrl, commandId]
properties:
assetId:
type: integer
format: int64
type: string
description: 来源资产 ID
targetOwner:
type: string
@ -467,8 +463,7 @@ paths:
format: uri
description: 完成后返回市场的 URL
commandId:
type: integer
format: int64
type: string
description: 幂等键
responses:
'200':
@ -547,8 +542,7 @@ paths:
required: [commandId, expectedStatus, reason]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
@ -669,8 +663,7 @@ paths:
format: int64
description: 发布检查结果 ID(消费检查快照)
commandId:
type: integer
format: int64
type: string
description: 幂等键
responses:
'200':
@ -705,8 +698,7 @@ paths:
in: path
required: true
schema:
type: integer
format: int64
type: string
requestBody:
required: true
content:
@ -716,8 +708,7 @@ paths:
required: [commandId, expectedStatus, reason]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
@ -841,8 +832,7 @@ paths:
required: [assetId, appealType, reason, commandId]
properties:
assetId:
type: integer
format: int64
type: string
description: 申诉对象资产 ID
appealType:
type: string
@ -858,8 +848,7 @@ paths:
type: string
description: 证据材料标识列表
commandId:
type: integer
format: int64
type: string
description: 幂等键
responses:
'200':
@ -894,8 +883,7 @@ paths:
in: path
required: true
schema:
type: integer
format: int64
type: string
requestBody:
required: true
content:
@ -917,8 +905,7 @@ paths:
type: boolean
description: 是否已确认隐私处理
commandId:
type: integer
format: int64
type: string
description: 幂等键
responses:
'200':
@ -948,8 +935,7 @@ paths:
in: path
required: true
schema:
type: integer
format: int64
type: string
requestBody:
required: true
content:
@ -962,8 +948,7 @@ paths:
type: string
description: 撤回原因
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
@ -1106,12 +1091,11 @@ paths:
required: [commandId, actionType]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
actionType:
type: string
enum: [delist, recall, revoke_license, stop_generation]
enum: [delist, recall, review_rejection, revoke_license, stop_generation]
description: 拟执行的治理动作类型
scope:
type: string
@ -1156,16 +1140,14 @@ paths:
required: [commandId, expectedStatus, impactPreviewId, reason, scope]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
enum: [listed]
description: 期望当前上架状态,避免盲下架
impactPreviewId:
type: integer
format: int64
type: string
description: 关联的治理影响预览 ID
reason:
type: string
@ -1212,16 +1194,14 @@ paths:
required: [commandId, expectedStatus, impactPreviewId, reason, basis, scope]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
enum: [listed, delisted]
description: 期望当前治理状态,避免盲召回
impactPreviewId:
type: integer
format: int64
type: string
description: 关联的治理影响预览 ID
reason:
type: string
@ -1292,8 +1272,7 @@ paths:
in: query
description: 发布者 ID
schema:
type: integer
format: int64
type: string
responses:
'200':
description: 发布申请分页列表
@ -1334,8 +1313,7 @@ paths:
in: path
required: true
schema:
type: integer
format: int64
type: string
requestBody:
required: true
content:
@ -1345,16 +1323,14 @@ paths:
required: [commandId, expectedStatus, validationResultId, note]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
enum: [pending, reviewing, needs_supplement]
description: 期望当前审核状态,避免盲通过
validationResultId:
type: integer
format: int64
type: string
description: 审核校验结果引用 ID
note:
type: string
@ -1395,8 +1371,7 @@ paths:
in: path
required: true
schema:
type: integer
format: int64
type: string
requestBody:
required: true
content:
@ -1406,16 +1381,14 @@ paths:
required: [commandId, expectedStatus, validationResultId, reason]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
enum: [pending, reviewing, needs_supplement]
description: 期望当前审核状态,避免盲驳回
validationResultId:
type: integer
format: int64
type: string
description: 审核校验结果引用 ID
reason:
type: string
@ -1482,14 +1455,12 @@ paths:
in: query
description: 申诉人 ID
schema:
type: integer
format: int64
type: string
- name: assetId
in: query
description: 关联资产 ID
schema:
type: integer
format: int64
type: string
responses:
'200':
description: 申诉分页列表
@ -1530,8 +1501,7 @@ paths:
in: path
required: true
schema:
type: integer
format: int64
type: string
responses:
'200':
description: 申诉详情
@ -1567,8 +1537,7 @@ paths:
in: path
required: true
schema:
type: integer
format: int64
type: string
requestBody:
required: true
content:
@ -1578,8 +1547,7 @@ paths:
required: [commandId, expectedStatus, impactPreviewId, resolution, reason]
properties:
commandId:
type: integer
format: int64
type: string
description: 幂等键
expectedStatus:
type: string
@ -1599,8 +1567,7 @@ paths:
type: string
description: 处理证据标签
impactPreviewId:
type: integer
format: int64
type: string
description: 恢复时的关联影响预览 ID
requireSupplement:
type: boolean
@ -1647,8 +1614,7 @@ components:
in: path
required: true
schema:
type: integer
format: int64
type: string
description: 市场资产 ID
schemas:
@ -1662,8 +1628,7 @@ components:
required: [assetId, name, assetType, publisherName, version, listingStatus]
properties:
assetId:
type: integer
format: int64
type: string
name:
type: string
maxLength: 200
@ -1806,8 +1771,7 @@ components:
required: [assetId, name, assetType, publisherName, version, listingStatus, licenseInfo]
properties:
assetId:
type: integer
format: int64
type: string
name:
type: string
maxLength: 200
@ -1817,8 +1781,7 @@ components:
publisherName:
type: string
publisherId:
type: integer
format: int64
type: string
version:
type: string
versionId:
@ -2074,8 +2037,7 @@ components:
format: int64
description: 授权记录 ID
assetId:
type: integer
format: int64
type: string
status:
type: string
enum: [active, expired, revoked]
@ -2100,8 +2062,7 @@ components:
format: int64
description: 安装记录 ID
assetId:
type: integer
format: int64
type: string
versionId:
type: integer
format: int64
@ -2135,8 +2096,7 @@ components:
format: int64
description: 来源授权快照 ID
assetId:
type: integer
format: int64
type: string
sourceVersion:
type: integer
description: 来源资产版本
@ -2207,8 +2167,7 @@ components:
targetAction:
type: string
assetId:
type: integer
format: int64
type: string
targetWorkId:
type: integer
format: int64
@ -2354,8 +2313,7 @@ components:
required: [requestId, status]
properties:
requestId:
type: integer
format: int64
type: string
description: 发布申请 ID
status:
type: string
@ -2368,8 +2326,7 @@ components:
required: [assetId, assetName, assetType, status]
properties:
assetId:
type: integer
format: int64
type: string
assetName:
type: string
assetType:
@ -2400,8 +2357,7 @@ components:
description: 治理影响范围
properties:
assetId:
type: integer
format: int64
type: string
governanceResult:
$ref: '#/components/schemas/GovernanceResultSummary'
affectedAuthorizations:
@ -2547,8 +2503,7 @@ components:
required: [appealId, status, processingDeadline]
properties:
appealId:
type: integer
format: int64
type: string
status:
type: string
enum: [pending, reviewing]
@ -2569,16 +2524,14 @@ components:
required: [assetId, name, assetType, publisherName, listingStatus, reviewStatus]
properties:
assetId:
type: integer
format: int64
type: string
name:
type: string
assetType:
type: string
enum: [work, agent, knowledge_base]
publisherId:
type: integer
format: int64
type: string
publisherName:
type: string
version:
@ -2610,16 +2563,14 @@ components:
required: [assetId, name, assetType, listingStatus, reviewStatus]
properties:
assetId:
type: integer
format: int64
type: string
name:
type: string
assetType:
type: string
enum: [work, agent, knowledge_base]
publisherId:
type: integer
format: int64
type: string
publisherName:
type: string
version:
@ -2665,8 +2616,7 @@ components:
reason:
type: string
operatorId:
type: integer
format: int64
type: string
operatedAt:
type: string
format: date-time
@ -2683,12 +2633,11 @@ components:
required: [previewId, actionType, scope, affectedCounts]
properties:
previewId:
type: integer
format: int64
type: string
description: 预览 ID(关联到后续下架/召回操作)
actionType:
type: string
enum: [delist, recall, revoke_license, stop_generation]
enum: [delist, recall, review_rejection, revoke_license, stop_generation]
scope:
type: string
affectedCounts:
@ -2732,19 +2681,16 @@ components:
required: [requestId, assetId, assetName, assetType, status]
properties:
requestId:
type: integer
format: int64
type: string
assetId:
type: integer
format: int64
type: string
assetName:
type: string
assetType:
type: string
enum: [work, agent, knowledge_base]
publisherId:
type: integer
format: int64
type: string
publisherName:
type: string
version:
@ -2775,8 +2721,7 @@ components:
required: [appealId, appealType, status, assetId]
properties:
appealId:
type: integer
format: int64
type: string
appealType:
type: string
enum: [review_rejection, delist, recall, license_revocation, usage_impact]
@ -2784,21 +2729,18 @@ components:
type: string
enum: [pending, reviewing, supplementing, maintained, restored, closed]
appellantId:
type: integer
format: int64
type: string
appellantName:
type: string
assetId:
type: integer
format: int64
type: string
assetName:
type: string
submittedAt:
type: string
format: date-time
processorId:
type: integer
format: int64
type: string
AdminAppealDetail:
type: object
@ -2806,8 +2748,7 @@ components:
required: [appealId, appealType, status, assetId, reason]
properties:
appealId:
type: integer
format: int64
type: string
appealType:
type: string
enum: [review_rejection, delist, recall, license_revocation, usage_impact]
@ -2815,13 +2756,11 @@ components:
type: string
enum: [pending, reviewing, supplementing, maintained, restored, closed]
appellantId:
type: integer
format: int64
type: string
appellantName:
type: string
assetId:
type: integer
format: int64
type: string
assetName:
type: string
assetVersion:
@ -2856,8 +2795,7 @@ components:
action:
type: string
processorId:
type: integer
format: int64
type: string
processedAt:
type: string
format: date-time

View File

@ -964,6 +964,9 @@ components:
ValidationResult:
type: object
properties:
validationResultId:
type: string
description: 校验结果引用 ID;发布、激活等治理命令必须引用同一草稿或目标版本的校验结果。
valid:
type: boolean
description: 是否校验通过
@ -1499,6 +1502,9 @@ components:
properties:
impactPreviewId:
type: string
validationResultId:
type: string
description: 功能链路激活前校验结果引用 ID;必须与 impactPreviewId、chainKey、targetVersion 同源。
chainKey:
type: string
targetVersion:

View File

@ -63,9 +63,9 @@
## 关键工程实践与避坑
### 1. `muse-admin/` 被根仓库 `.gitignore` 忽略
### 1. `muse-admin/` 已整仓纳管,但仍要排除构建产物
根 `.gitignore` 中标注 `muse-admin/` 是独立子仓库,但当前 `muse-admin` 目录没有独立 `.git`,`git -C muse-admin rev-parse --show-toplevel` 返回根仓库。
当前 `muse-admin` 目录已经作为本仓工作区的一部分被统一纳管,不再按“独立子仓库”理解;但它仍然需要继续排除 `dist/`、`dist.zip`、`node_modules/`、`test-results/` 这类构建和验证产物,避免误入提交。
因此本次按 P3 计划要求,用 `git add -f` 精确加入 P3 相关源码和测试文件:
@ -194,6 +194,30 @@ pnpm --dir muse-admin --filter @vben/web-antd run build
- 全量 `test:unit`:48 个测试文件、336 个测试通过;仍有既有 `use-sortable.test.ts` nested `vi.mock` 警告。
- `web-antd` build 退出码 0,并已清理生成的 `test-results/`、`dist/` 和 `dist.zip`,不纳入提交。
子代理评审修复后最终复核:
```bash
pnpm --dir muse-admin --filter @vben/web-antd run typecheck
pnpm --dir muse-admin test:unit
pnpm --dir muse-admin --filter @vben/web-antd run build
pnpm --dir muse-admin exec vitest run --dom apps/web-antd/src/api/muse/__tests__/client.spec.ts apps/web-antd/src/api/muse/account/__tests__/index.spec.ts apps/web-antd/src/api/muse/ai/__tests__/jobs.spec.ts apps/web-antd/src/api/muse/audit/__tests__/index.spec.ts apps/web-antd/src/api/muse/governance/__tests__/governance-api.test.ts apps/web-antd/src/api/muse/jobs/__tests__/index.spec.ts apps/web-antd/src/api/muse/knowledge/__tests__/index.test.ts apps/web-antd/src/api/muse/market/__tests__/index.test.ts apps/web-antd/src/api/muse/newapi/__tests__/index.spec.ts apps/web-antd/src/router/routes/__tests__/muse.test.ts apps/web-antd/src/views/muse/__tests__/governance-pages.test.ts apps/web-antd/src/views/muse/ai/__tests__/index.test.ts apps/web-antd/src/views/muse/governance/meta-schema/__tests__/detail.test.ts apps/web-antd/src/views/muse/knowledge/__tests__/index.test.ts apps/web-antd/src/views/muse/market/__tests__/index.test.ts
pnpm --dir muse-admin exec playwright test -c playwright.config.ts apps/web-antd/e2e/muse-admin-governance.spec.ts
pnpm --dir muse-admin exec oxlint apps/web-antd/e2e/muse-admin-governance.spec.ts apps/web-antd/src/api/muse apps/web-antd/src/views/muse apps/web-antd/src/router/routes/__tests__/muse.test.ts apps/web-antd/src/router/routes/modules/muse.ts
git diff --check -- muse-admin docs/api-contracts docs/memorys .gitignore
```
结果:
- 子代理 review 发现的 Critical 已修复:MetaSchema save/validate/preview 旧响应不能回写新 schema 或新 draft;FunctionChain 预览票据绑定 `chainKey + targetVersion`;Market 预览票据绑定 asset/appeal/action/scope/generation。
- Market 不再把 `stop_new_acquire` 影响预览票据复用到 `full_delist` 提交;`review_rejection` 已同步到 OpenAPI 和前端 action mapping。
- E2E 增强为 9 个 Chromium 用例通过,覆盖 retry 只发 `commandId`、cancel/source retry 保留 `reason`、跨资产 preview 不可复用、同资产切换治理范围/动作/申诉后必须重新生成 preview、错误动作重新预览不能提交召回、核心页面真实路由和运行时 console/pageerror 门禁。
- P3 定向 Vitest:7 个测试文件、14 个测试通过;market API 已覆盖 `assetId/requestId/appealId` 路径参数编码。
- 全量 `test:unit`:50 个测试文件、345 个测试通过;仍有既有 `use-sortable.test.ts` nested `vi.mock` 警告。
- `web-antd` typecheck 退出码 0。
- `web-antd` build 退出码 0,并已清理生成的 `test-results/`、`apps/web-antd/dist/` 和 `apps/web-antd/dist.zip`,不纳入提交。
- P3 定向 oxlint:37 个文件,0 warning、0 error。
- `git diff --check` 通过。
## 提交与 Hook 说明
本次 P3 相关提交:
@ -213,6 +237,7 @@ pnpm --dir muse-admin --filter @vben/web-antd run build
- 修复 E2E 暴露的 Ant Design Vue 组件运行时解析问题,Muse 页面改为显式导入实际使用的 Ant 组件。
- 审查修复后,E2E 对未处理的 Muse 管理端 API 不再返回空分页兜底,避免错误 URL 或漏 mock 仍然绿;同时修正 New-API 真实运行时双 `/admin-api` 前缀问题。
- 再次审查后,删除 Account/Jobs/Audit 的重复 route 注册,避免 specific route 抢先匹配导致 unified mock 失效;同步修正 E2E mock 字段契约漂移。
- 子代理评审后继续修复治理命令串票风险:MetaSchema 草稿票据加 save/validate/preview 请求序号;FunctionChain 和 Market 影响预览返回时比对发起时上下文;Market OpenAPI 关键治理 ID/引用统一为 string;E2E 开启 console/pageerror/未 mock API 失败门禁。
提交前发现暂存区已有其他会话 staged 的 `P1-execution-prompt.md` 和 `P3-execution-prompt.md`,已仅执行 `git restore --staged`,没有改动文件内容。

View File

@ -18,4 +18,4 @@ VITE_INJECT_APP_LOADING=true
# 默认登录用户名
VITE_APP_DEFAULT_USERNAME=admin
# 默认登录密码
VITE_APP_DEFAULT_PASSWORD=admin123
VITE_APP_DEFAULT_PASSWORD=

View File

@ -1,9 +1,9 @@
VITE_BASE=/
# 请求路径
VITE_BASE_URL=http://127.0.0.1:48080
# 接口地址
VITE_GLOB_API_URL=http://127.0.0.1:48080/admin-api
# 请求路径,生产环境由部署站点决定,保持空值避免拼接出 //admin-api
VITE_BASE_URL=
# 接口地址,沿用相对路径占位,由反向代理或同源部署接管
VITE_GLOB_API_URL=/admin-api
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
VITE_UPLOAD_TYPE=server
@ -23,4 +23,4 @@ VITE_INJECT_APP_LOADING=true
VITE_ARCHIVER=true
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=true
VITE_APP_CAPTCHA_ENABLE=true

View File

@ -15,6 +15,13 @@ const pageResult = (list: unknown[]) => ({
});
const unhandledMuseAdminApiPathsByPage = new WeakMap<Page, Set<string>>();
const museAdminPayloadsByPage = new WeakMap<Page, Record<string, unknown>[]>();
const runtimeIssuesByPage = new WeakMap<Page, string[]>();
const isIgnoredRuntimeIssue = (message: string) =>
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i.test(
message,
);
const expectNoUnhandledMuseAdminApis = (page: Page) => {
const unhandledPaths = [
@ -26,6 +33,23 @@ const expectNoUnhandledMuseAdminApis = (page: Page) => {
).toEqual([]);
};
const expectNoRuntimeIssues = (page: Page) => {
const runtimeIssues = (runtimeIssuesByPage.get(page) ?? []).filter(
(message) => !isIgnoredRuntimeIssue(message),
);
expect(
runtimeIssues,
`Browser runtime issues:\n${runtimeIssues.join('\n')}`,
).toEqual([]);
};
const recordRuntimeIssue = (page: Page, message: string) => {
if (!isIgnoredRuntimeIssue(message)) {
runtimeIssuesByPage.get(page)?.push(message);
}
};
const routeCommonResult = async (
page: Page,
patterns: string[],
@ -44,10 +68,20 @@ const routeCommonResult = async (
const routeMuseAdminApis = async (page: Page) => {
const unhandledMuseAdminApiPaths = new Set<string>();
unhandledMuseAdminApiPathsByPage.set(page, unhandledMuseAdminApiPaths);
museAdminPayloadsByPage.set(page, []);
await page.route('**/admin-api/muse/**', (route) => {
await page.route('**/admin-api/muse/**', async (route) => {
const { pathname } = new URL(route.request().url());
const path = pathname.replace('/admin-api/muse', '');
const method = route.request().method();
if (method !== 'GET') {
museAdminPayloadsByPage.get(page)?.push({
method,
path,
payload: route.request().postDataJSON(),
});
}
if (path === '/governance/meta-schemas') {
return route.fulfill({
@ -71,6 +105,28 @@ const routeMuseAdminApis = async (page: Page) => {
});
}
if (method === 'GET' && path === '/governance/meta-schemas/story.draft') {
return route.fulfill({
contentType: 'application/json',
json: commonResult({
activeVersion: 1,
description: 'E2E 元结构详情',
displayName: '故事草稿结构',
fields: [
{
fieldKey: 'title',
fieldType: 'string',
label: '标题',
required: true,
scope: 'work',
},
],
schemaKey: 'story.draft',
status: 'active',
}),
});
}
if (path === '/governance/function-chains') {
return route.fulfill({
contentType: 'application/json',
@ -282,8 +338,22 @@ const routeMuseAdminApis = async (page: Page) => {
pageResult([
{
assetId: 'asset-e2e',
assetType: 'agent',
bindCount: 1,
installCount: 1,
name: '市场资产',
listingStatus: 'listed',
publisherName: '发布者',
reviewStatus: 'approved',
},
{
assetId: 'asset-two',
assetType: 'agent',
bindCount: 0,
installCount: 0,
name: '另一市场资产',
listingStatus: 'listed',
publisherName: '发布者',
reviewStatus: 'approved',
},
]),
@ -291,6 +361,38 @@ const routeMuseAdminApis = async (page: Page) => {
});
}
if (
method === 'POST' &&
path.startsWith('/market/assets/') &&
path.endsWith('/governance-impact')
) {
const [, , , assetId] = path.split('/');
const payload = route.request().postDataJSON() as {
actionType?: string;
scope?: string;
};
return route.fulfill({
contentType: 'application/json',
json: commonResult({
actionType: payload.actionType,
affectedCounts: {},
previewId: `preview-${assetId}-${payload.actionType}-${payload.scope}`,
scope: payload.scope,
}),
});
}
if (
method === 'POST' &&
path.startsWith('/market/assets/') &&
path.endsWith('/delist')
) {
return route.fulfill({
contentType: 'application/json',
json: commonResult(null),
});
}
if (path === '/market/appeals') {
return route.fulfill({
contentType: 'application/json',
@ -306,6 +408,16 @@ const routeMuseAdminApis = async (page: Page) => {
reason: '申诉材料',
status: 'pending',
},
{
appealId: 'appeal-two',
assetId: 'asset-two',
appealType: 'delist',
appellantName: '另一申诉人',
assetName: '另一市场资产',
processorId: 'admin-e2e',
reason: '另一申诉材料',
status: 'pending',
},
]),
),
});
@ -401,6 +513,17 @@ const routeMuseAdminApis = async (page: Page) => {
{
createdAt: '2026-05-25T00:00:00Z',
createdBy: 'system',
actionPolicy: 'allowed',
jobId: 1002,
owner: 'market',
retryCount: 0,
status: 'queued',
type: 'asset-recall',
},
{
createdAt: '2026-05-25T00:00:00Z',
createdBy: 'system',
actionPolicy: 'allowed',
jobId: 1001,
owner: 'market',
retryCount: 1,
@ -431,6 +554,43 @@ const routeMuseAdminApis = async (page: Page) => {
});
}
if (method === 'GET' && path === '/jobs/1001') {
return route.fulfill({
contentType: 'application/json',
json: commonResult({
createdAt: '2026-05-25T00:00:00Z',
createdBy: 'system',
errorMessage: '来源状态需重验',
jobId: 1001,
owner: 'market',
retryable: true,
status: 'failed',
type: 'source-projection',
}),
});
}
if (method === 'POST' && path === '/jobs/1001/retry') {
return route.fulfill({
contentType: 'application/json',
json: commonResult({ jobId: 1001, status: 'queued' }),
});
}
if (method === 'POST' && path === '/jobs/1002/cancel') {
return route.fulfill({
contentType: 'application/json',
json: commonResult({ jobId: 1002, status: 'cancelled' }),
});
}
if (method === 'POST' && path === '/source-events/2001/retry') {
return route.fulfill({
contentType: 'application/json',
json: commonResult({ jobId: 1003, status: 'queued' }),
});
}
if (path === '/audit/api-logs') {
return route.fulfill({
contentType: 'application/json',
@ -486,6 +646,25 @@ const routeMuseAdminApis = async (page: Page) => {
};
test.beforeEach(async ({ page }) => {
runtimeIssuesByPage.set(page, []);
page.on('console', (message) => {
const text = message.text();
if (message.type() === 'error') {
recordRuntimeIssue(page, `console.error: ${text}`);
}
if (
message.type() === 'warning' &&
/Failed to resolve component/i.test(text)
) {
recordRuntimeIssue(page, `console.warning: ${text}`);
}
});
page.on('pageerror', (error) => {
recordRuntimeIssue(page, `pageerror: ${error.message}`);
});
await page.addInitScript((accessStoreKey) => {
localStorage.setItem(
accessStoreKey,
@ -521,6 +700,16 @@ test.beforeEach(async ({ page }) => {
sort: 10,
visible: true,
},
{
component: 'muse/governance/meta-schema/detail',
componentName: 'MuseMetaSchemaDetail',
id: 111,
name: '元结构编辑',
parentId: 100,
path: 'governance/meta-schemas/:schemaKey',
sort: 11,
visible: false,
},
{
component: 'muse/governance/function-orch/index',
componentName: 'MuseFunctionOrchestration',
@ -657,6 +846,11 @@ test.beforeEach(async ({ page }) => {
await routeMuseAdminApis(page);
});
test.afterEach(async ({ page }) => {
expectNoUnhandledMuseAdminApis(page);
expectNoRuntimeIssues(page);
});
test('管理员可进入账号、任务和审计治理页面并看到边界文案', async ({
page,
}) => {
@ -689,10 +883,292 @@ test('管理员可进入账号、任务和审计治理页面并看到边界文
expectNoUnhandledMuseAdminApis(page);
});
test('任务重试只发送当前 OpenAPI 支持的 commandId', async ({ page }) => {
await page.goto('/muse/jobs');
await page
.getByRole('row', { name: /1001/ })
.getByRole('button', { name: '重试' })
.click();
await page.getByLabel('owner 和当前操作者权限已重验').check();
await page.getByLabel('来源状态、市场状态和版本已重验').check();
await page.getByLabel('授权快照和运行权限包已重验').check();
await page
.getByLabel('幂等键、retry group 和旧上下文复用风险已重验')
.check();
await page
.locator('.ant-modal textarea')
.fill('人工复核留痕,不发送到 retry 接口');
await page.locator('.ant-modal-footer .ant-btn-primary').click();
const retryPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) => item.method === 'POST' && item.path === '/jobs/1001/retry',
)?.payload;
expect(retryPayload).toEqual({
commandId: expect.stringMatching(/^job-retry-/),
});
expectNoUnhandledMuseAdminApis(page);
});
test('任务取消和来源传播重试提交 commandId 与 reason', async ({ page }) => {
await page.goto('/muse/jobs');
await page
.getByRole('row', { name: /1002/ })
.getByRole('button', { name: '取消' })
.click();
const jobActionDialog = page.getByRole('dialog', { name: '任务治理操作' });
await jobActionDialog.locator('textarea').fill('上游召回,停止排队任务');
await jobActionDialog.locator('.ant-modal-footer .ant-btn-primary').click();
await page.getByRole('tab', { name: '来源事件' }).click();
await page
.getByRole('row', { name: /2001/ })
.getByRole('button', { name: '重试传播' })
.click();
const sourceRetryDialog = page.getByRole('dialog', { name: '重试来源传播' });
await sourceRetryDialog.locator('textarea').fill('来源状态已复核,重新传播');
await sourceRetryDialog.locator('.ant-modal-footer .ant-btn-primary').click();
const payloads = museAdminPayloadsByPage.get(page) ?? [];
const cancelPayload = payloads.find(
(item) => item.method === 'POST' && item.path === '/jobs/1002/cancel',
)?.payload;
const sourceRetryPayload = payloads.find(
(item) =>
item.method === 'POST' && item.path === '/source-events/2001/retry',
)?.payload;
expect(cancelPayload).toEqual({
commandId: expect.stringMatching(/^job-cancel-/),
reason: '上游召回,停止排队任务',
});
expect(sourceRetryPayload).toEqual({
commandId: expect.stringMatching(/^source-retry-/),
reason: '来源状态已复核,重新传播',
});
expectNoUnhandledMuseAdminApis(page);
});
test('市场下架不能复用其他资产的影响预览票据', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.first()
.click();
await page.locator('.ant-modal-footer .ant-btn-primary').click();
await expect
.poll(() =>
museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' &&
item.path === '/market/assets/asset-e2e/governance-impact',
)?.payload,
)
.toEqual(
expect.objectContaining({
actionType: 'delist',
commandId: expect.stringMatching(/^market-impact-preview-/),
scope: 'stop_new_acquire',
}),
);
await page.locator('.ant-modal-footer .ant-btn-default').click();
await page
.getByRole('row', { name: /另一市场资产/ })
.getByRole('button')
.nth(1)
.click();
await expect(page.getByPlaceholder('先执行影响预览后自动带入')).toHaveValue('');
await page
.locator('.ant-modal textarea')
.fill('尝试复用上一资产影响预览');
await page.locator('.ant-modal-footer .ant-btn-primary').click();
const delistPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-two/delist',
)?.payload;
expect(delistPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test('市场下架切换范围后必须重新生成影响预览', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.nth(1)
.click();
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(
'preview-asset-e2e-delist-stop_new_acquire',
);
await dialog.locator('.ant-select-selector').click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('停止新绑定')
.click();
await expect(previewInput).toHaveValue('');
await dialog.locator('textarea').fill('切换范围后不能复用旧影响预览');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
const delistPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-e2e/delist',
)?.payload;
expect(delistPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test('市场召回切换动作后必须重新生成影响预览', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.nth(2)
.click();
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(
'preview-asset-e2e-recall-full_recall',
);
await dialog.locator('.ant-select-selector').first().click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('下架', { exact: true })
.click();
await expect(previewInput).toHaveValue('');
await dialog.getByPlaceholder('法律、合规或安全依据').fill('合规召回依据');
await dialog.locator('textarea').fill('切换动作后不能复用旧影响预览');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
const recallPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-e2e/recall',
)?.payload;
expect(recallPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test('市场召回切到下架并重新生成预览后不能提交召回', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '资产治理' }).click();
await page
.getByRole('row', { name: /^市场资产/ })
.getByRole('button')
.nth(2)
.click();
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
await dialog.locator('.ant-select-selector').first().click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('下架', { exact: true })
.click();
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(previewInput).toHaveValue(
'preview-asset-e2e-delist-full_recall',
);
await dialog.getByPlaceholder('法律、合规或安全依据').fill('合规召回依据');
await dialog.locator('textarea').fill('尝试用下架预览提交召回');
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
const recallPayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' && item.path === '/market/assets/asset-e2e/recall',
)?.payload;
expect(recallPayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test('市场申诉切换 appealId 后必须重新生成影响预览', async ({ page }) => {
await page.goto('/muse/market');
await page.getByRole('tab', { name: '申诉处理' }).click();
await page
.getByRole('row', { name: /^市场资产\s+delist\s+申诉人/ })
.getByRole('button', { name: /处\s*理/ })
.click();
const firstDialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
const firstPreviewInput =
firstDialog.getByPlaceholder('先执行影响预览后自动带入');
await firstDialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
await expect(firstPreviewInput).toHaveValue(
'preview-asset-e2e-delist-stop_new_acquire',
);
await firstDialog.locator('.ant-modal-footer .ant-btn-default').click();
await page
.getByRole('row', { name: /^另一市场资产\s+delist\s+另一申诉人/ })
.getByRole('button', { name: /处\s*理/ })
.click();
const secondDialog = page.getByRole('dialog', {
name: '市场治理:另一市场资产',
});
await expect(
secondDialog.getByPlaceholder('先执行影响预览后自动带入'),
).toHaveValue('');
await secondDialog.locator('textarea').fill('切换申诉后不能复用旧影响预览');
await secondDialog.locator('.ant-modal-footer .ant-btn-primary').click();
const resolvePayload = museAdminPayloadsByPage
.get(page)
?.find(
(item) =>
item.method === 'POST' &&
item.path === '/market/appeals/appeal-two/resolve',
)?.payload;
expect(resolvePayload).toBeUndefined();
expectNoUnhandledMuseAdminApis(page);
});
test('管理员可进入核心治理配置页面并看到系统边界', async ({ page }) => {
await page.goto('/muse/governance/meta-schemas');
await expect(page.getByText('元结构定义').first()).toBeVisible();
await expect(page.getByText('story.draft')).toBeVisible();
await page.getByRole('button', { name: /查看详情/ }).click();
await expect(page).toHaveURL(/\/muse\/governance\/meta-schemas\/story\.draft$/);
await expect(page.getByText('草稿 JSON 编辑')).toBeVisible();
await page.goto('/muse/governance/meta-schemas');
await page.getByRole('button', { name: /新建草稿/ }).click();
await expect(page).toHaveURL(/\/muse\/governance\/meta-schemas\/__create__\?mode=create$/);
await expect(page.getByPlaceholder('例如 work.story')).toBeVisible();
await page.goto('/muse/governance/function-orchestration');
await expect(

View File

@ -39,6 +39,10 @@ describe('muse admin api client', () => {
await museAdminApi.post('/jobs/1/retry', body);
await museAdminApi.patch('/knowledge/global-kbs/kb-1', body);
await museAdminApi.delete('/knowledge/global-kbs/kb-1/documents/doc-1');
await museAdminApi.deleteBody(
'/knowledge/global-kbs/kb-1/documents/doc-1',
body,
);
expect(requestClientMock.post).toHaveBeenCalledWith(
'/muse/jobs/1/retry',
@ -60,5 +64,13 @@ describe('muse admin api client', () => {
params: undefined,
},
);
expect(requestClientMock.request).toHaveBeenCalledWith(
'/muse/knowledge/global-kbs/kb-1/documents/doc-1',
{
data: body,
headers: { 'X-API-Version': '1' },
method: 'DELETE',
},
);
});
});

View File

@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
cancelJobApi,
retryJobApi,
retrySourceEventApi,
} from '../../jobs';
const { museAdminApiMock } = vi.hoisted(() => ({
museAdminApiMock: {
post: vi.fn(),
},
}));
vi.mock('#/api/muse/client', () => ({
museAdminApi: museAdminApiMock,
}));
describe('muse ai jobs api command contract', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('重试任务只提交 commandId,原因归因由重试前检查链路负责', async () => {
await retryJobApi('job/1', {
commandId: 'cmd-retry',
reason: '不应发送到任务重试接口',
});
expect(museAdminApiMock.post).toHaveBeenCalledWith('/jobs/job%2F1/retry', {
commandId: 'cmd-retry',
});
expect(museAdminApiMock.post).toHaveBeenCalledTimes(1);
});
it('取消任务和来源事件重试必须保留 reason 审计合同', async () => {
await cancelJobApi('job/1', {
commandId: 'cmd-cancel',
reason: '管理员取消长时间运行任务',
});
await retrySourceEventApi('event/1', {
commandId: 'cmd-source-retry',
reason: '来源状态传播补偿',
});
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
1,
'/jobs/job%2F1/cancel',
{
commandId: 'cmd-cancel',
reason: '管理员取消长时间运行任务',
},
);
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
2,
'/source-events/event%2F1/retry',
{
commandId: 'cmd-source-retry',
reason: '来源状态传播补偿',
},
);
});
});

View File

@ -60,6 +60,14 @@ export const museAdminApi = {
headers: MUSE_API_HEADERS,
params,
}),
/** DELETE JSON body 接口,仅用于 OpenAPI 明确要求 requestBody 的治理命令。 */
deleteBody: <T>(url: string, data?: MuseRequestBody) =>
requestClient.request<T>(toMuseUrl(url), {
data,
headers: MUSE_API_HEADERS,
method: 'DELETE',
}),
};
export type { MuseQueryParams, MuseRequestBody };

View File

@ -173,6 +173,8 @@ export interface SlotChangeDetail {
export interface FunctionChainImpactPreview {
/** 影响预览记录 ID。 */
impactPreviewId?: string;
/** 激活前校验结果引用 ID,必须与当前影响预览同源。 */
validationResultId?: string;
/** 功能链路业务标识。 */
chainKey?: string;
/** 目标版本号。 */

View File

@ -369,6 +369,8 @@ export interface CompatibilityResult {
/** 校验结果。 */
export interface ValidationResult {
/** 校验结果引用 ID,发布和激活命令必须引用同一草稿的校验结果。 */
validationResultId?: string;
/** 是否校验通过。 */
valid?: boolean;
/** 校验错误列表。 */

View File

@ -38,19 +38,19 @@ describe('muse jobs api', () => {
expect(museAdminApiMock.get).toHaveBeenNthCalledWith(2, '/jobs/job%2F1');
});
it('提交任务重试和取消命令', async () => {
it('提交任务重试和取消命令,并按合同只让 retry 外发 commandId', async () => {
await retryJobApi('42', { commandId: 'cmd-retry', reason: '重验通过' });
await cancelJobApi('42', { commandId: 'cmd-cancel', reason: '来源召回' });
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
1,
'/jobs/42/retry',
expect.objectContaining({ commandId: 'cmd-retry' }),
{ commandId: 'cmd-retry' },
);
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
2,
'/jobs/42/cancel',
expect.objectContaining({ commandId: 'cmd-cancel' }),
{ commandId: 'cmd-cancel', reason: '来源召回' },
);
});
@ -77,7 +77,7 @@ describe('muse jobs api', () => {
);
expect(museAdminApiMock.post).toHaveBeenCalledWith(
'/source-events/event%2F1/retry',
expect.objectContaining({ commandId: 'cmd-event' }),
{ commandId: 'cmd-event', reason: '补偿传播' },
);
});
});

View File

@ -184,7 +184,7 @@ export function getJobApi(jobId: MuseJobId) {
export function retryJobApi(jobId: MuseJobId, data?: JobCommandRequest) {
return museAdminApi.post<JobCommandResult>(
`/jobs/${encodePath(jobId)}/retry`,
data,
data ? { commandId: data.commandId } : undefined,
);
}

View File

@ -1,25 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import {
activateGlobalKBVersionApi,
createGlobalKBDocumentVersionApi,
deleteGlobalKBDocumentApi,
disableGlobalKnowledgeBaseApi,
getGlobalKBDocumentApi,
getGlobalKnowledgeBaseApi,
getGlobalKnowledgeBaseListApi,
getGlobalKBVersionsApi,
getGlobalKBSourceBindingsApi,
previewGlobalKBImpactApi,
publishGlobalKBAccessPolicyApi,
reindexGlobalKnowledgeBaseApi,
retryGlobalKBSourceEventApi,
saveGlobalKBAccessPolicyDraftApi,
uploadGlobalKBDocumentApi,
} from '../index';
const clientMocks = vi.hoisted(() => ({
delete: vi.fn(),
deleteBody: vi.fn(),
get: vi.fn(),
post: vi.fn(),
request: vi.fn(),
}));
vi.mock('#/api/muse/client', () => ({
museAdminApi: {
delete: clientMocks.delete,
deleteBody: clientMocks.deleteBody,
get: clientMocks.get,
post: clientMocks.post,
request: clientMocks.request,
},
}));
@ -84,4 +97,70 @@ describe('muse knowledge admin api', () => {
sourceType: 'global_kb',
});
});
it('对知识库和资料路径 ID 做编码,避免斜杠破坏 OpenAPI 路径段', async () => {
await getGlobalKnowledgeBaseApi('kb/a b');
await getGlobalKBDocumentApi('kb/a b', 'doc/1');
await getGlobalKBVersionsApi('kb/a b');
await activateGlobalKBVersionApi('kb/a b', 3, {
commandId: 'cmd-activate',
reason: '版本确认',
});
await publishGlobalKBAccessPolicyApi('kb/a b', 'draft/1', {
commandId: 'cmd-policy-publish',
reason: '权限策略发布',
});
expect(clientMocks.get).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2Fa%20b',
);
expect(clientMocks.get).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2Fa%20b/documents/doc%2F1',
);
expect(clientMocks.get).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2Fa%20b/versions',
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2Fa%20b/versions/3/activate',
expect.objectContaining({ commandId: 'cmd-activate' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2Fa%20b/access-policies/drafts/draft%2F1/publish',
expect.objectContaining({ commandId: 'cmd-policy-publish' }),
);
});
it('按 OpenAPI 使用 DELETE JSON body 停用全局知识库资料', async () => {
await deleteGlobalKBDocumentApi('kb/1', 'doc/1', {
commandId: 'cmd-delete',
reason: '资料违规',
});
expect(clientMocks.deleteBody).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2F1/documents/doc%2F1',
{ commandId: 'cmd-delete', reason: '资料违规' },
);
expect(clientMocks.delete).not.toHaveBeenCalled();
});
it('保留上传和资料版本创建的请求体,不把 multipart 命令错误转成查询参数', async () => {
const uploadForm = new FormData();
uploadForm.set('commandId', 'cmd-upload');
uploadForm.set('sourceDescription', '初始化资料');
const versionForm = new FormData();
versionForm.set('commandId', 'cmd-version');
await uploadGlobalKBDocumentApi('kb/1', uploadForm);
await createGlobalKBDocumentVersionApi('kb/1', 'doc/1', versionForm);
expect(clientMocks.post).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2F1/documents',
uploadForm,
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/knowledge/global-kbs/kb%2F1/documents/doc%2F1/versions',
versionForm,
);
});
});

View File

@ -9,6 +9,24 @@ function toMuseQueryParams(params: object): MuseQueryParams {
return params as MuseQueryParams;
}
/** 编码路径参数,避免 kbId/documentId/draftId 中的斜杠破坏路由段。 */
function encodePathParam(value: string | number) {
return encodeURIComponent(String(value));
}
/** 全局知识库基础路径。 */
function globalKnowledgeBasePath(kbId: string | number) {
return `/knowledge/global-kbs/${encodePathParam(kbId)}`;
}
/** 全局知识库资料路径。 */
function globalKBDocumentPath(
kbId: string | number,
documentId: string | number,
) {
return `${globalKnowledgeBasePath(kbId)}/documents/${encodePathParam(documentId)}`;
}
/** 全局知识库启停和处理状态。 */
export type GlobalKnowledgeBaseStatus =
| 'active'
@ -673,7 +691,7 @@ export function createGlobalKnowledgeBaseApi(
/** 查询全局知识库详情。 */
export function getGlobalKnowledgeBaseApi(kbId: string) {
return museAdminApi.get<GlobalKnowledgeBaseDetailVO>(
`/knowledge/global-kbs/${kbId}`,
globalKnowledgeBasePath(kbId),
);
}
@ -683,7 +701,7 @@ export function updateGlobalKnowledgeBaseApi(
data: UpdateGlobalKnowledgeBaseDTO,
) {
return museAdminApi.patch<UpdateGlobalKnowledgeBaseResultVO>(
`/knowledge/global-kbs/${kbId}`,
globalKnowledgeBasePath(kbId),
data,
);
}
@ -694,7 +712,7 @@ export function getGlobalKBDocumentsApi(
params: GlobalKBDocumentQuery,
) {
return museAdminApi.get<PageResult<GlobalKBDocumentSummaryVO>>(
`/knowledge/global-kbs/${kbId}/documents`,
`${globalKnowledgeBasePath(kbId)}/documents`,
toMuseQueryParams(params),
);
}
@ -705,7 +723,7 @@ export function uploadGlobalKBDocumentApi(
data: FormData | UploadGlobalKBDocumentDTO,
) {
return museAdminApi.post<UploadGlobalKBDocumentResultVO>(
`/knowledge/global-kbs/${kbId}/documents`,
`${globalKnowledgeBasePath(kbId)}/documents`,
data,
);
}
@ -713,7 +731,7 @@ export function uploadGlobalKBDocumentApi(
/** 查询全局知识库资料详情。 */
export function getGlobalKBDocumentApi(kbId: string, documentId: string) {
return museAdminApi.get<GlobalKBDocumentDetailVO>(
`/knowledge/global-kbs/${kbId}/documents/${documentId}`,
globalKBDocumentPath(kbId, documentId),
);
}
@ -723,9 +741,13 @@ export function deleteGlobalKBDocumentApi(
documentId: string,
data: DeleteGlobalKBDocumentDTO,
) {
return museAdminApi.delete<DeleteGlobalKBDocumentResultVO>(
`/knowledge/global-kbs/${kbId}/documents/${documentId}`,
toMuseQueryParams(data),
/**
* OpenAPI 对这个 DELETE 明确要求 JSON requestBody。
* 必须继续走 museAdminApi,确保 URL 保持 `/admin-api/muse/**` 边界。
*/
return museAdminApi.deleteBody<DeleteGlobalKBDocumentResultVO>(
globalKBDocumentPath(kbId, documentId),
data,
);
}
@ -736,7 +758,7 @@ export function createGlobalKBDocumentVersionApi(
data: FormData | CreateGlobalKBDocumentVersionDTO,
) {
return museAdminApi.post<CreateGlobalKBDocumentVersionResultVO>(
`/knowledge/global-kbs/${kbId}/documents/${documentId}/versions`,
`${globalKBDocumentPath(kbId, documentId)}/versions`,
data,
);
}
@ -744,7 +766,7 @@ export function createGlobalKBDocumentVersionApi(
/** 查询全局知识库版本列表。 */
export function getGlobalKBVersionsApi(kbId: string) {
return museAdminApi.get<GlobalKBVersionListVO>(
`/knowledge/global-kbs/${kbId}/versions`,
`${globalKnowledgeBasePath(kbId)}/versions`,
);
}
@ -755,7 +777,7 @@ export function activateGlobalKBVersionApi(
data: ActivateGlobalKBVersionDTO,
) {
return museAdminApi.post<ActivateGlobalKBVersionResultVO>(
`/knowledge/global-kbs/${kbId}/versions/${version}/activate`,
`${globalKnowledgeBasePath(kbId)}/versions/${encodePathParam(version)}/activate`,
data,
);
}
@ -763,7 +785,7 @@ export function activateGlobalKBVersionApi(
/** 查询全局知识库授权策略列表。 */
export function getGlobalKBAccessPoliciesApi(kbId: string) {
return museAdminApi.get<GlobalKBAccessPolicyListVO>(
`/knowledge/global-kbs/${kbId}/access-policies`,
`${globalKnowledgeBasePath(kbId)}/access-policies`,
);
}
@ -773,7 +795,7 @@ export function saveGlobalKBAccessPolicyDraftApi(
data: SaveGlobalKBAccessPolicyDraftDTO,
) {
return museAdminApi.post<SaveGlobalKBAccessPolicyDraftResultVO>(
`/knowledge/global-kbs/${kbId}/access-policies/drafts`,
`${globalKnowledgeBasePath(kbId)}/access-policies/drafts`,
data,
);
}
@ -785,7 +807,7 @@ export function publishGlobalKBAccessPolicyApi(
data: PublishGlobalKBAccessPolicyDTO,
) {
return museAdminApi.post<PublishGlobalKBAccessPolicyResultVO>(
`/knowledge/global-kbs/${kbId}/access-policies/drafts/${draftId}/publish`,
`${globalKnowledgeBasePath(kbId)}/access-policies/drafts/${encodePathParam(draftId)}/publish`,
data,
);
}
@ -796,7 +818,7 @@ export function enableGlobalKnowledgeBaseApi(
data: GlobalKBEnableDisableDTO,
) {
return museAdminApi.post<EnableGlobalKnowledgeBaseResultVO>(
`/knowledge/global-kbs/${kbId}/enable`,
`${globalKnowledgeBasePath(kbId)}/enable`,
data,
);
}
@ -807,7 +829,7 @@ export function disableGlobalKnowledgeBaseApi(
data: GlobalKBEnableDisableDTO,
) {
return museAdminApi.post<DisableGlobalKnowledgeBaseResultVO>(
`/knowledge/global-kbs/${kbId}/disable`,
`${globalKnowledgeBasePath(kbId)}/disable`,
data,
);
}
@ -818,7 +840,7 @@ export function previewGlobalKBImpactApi(
data: PreviewGlobalKBImpactDTO,
) {
return museAdminApi.post<GlobalKBImpactPreviewVO>(
`/knowledge/global-kbs/${kbId}/impact-preview`,
`${globalKnowledgeBasePath(kbId)}/impact-preview`,
data,
);
}
@ -829,7 +851,7 @@ export function reindexGlobalKnowledgeBaseApi(
data: ReindexGlobalKnowledgeBaseDTO,
) {
return museAdminApi.post<GlobalKBJobResultVO>(
`/knowledge/global-kbs/${kbId}/reindex`,
`${globalKnowledgeBasePath(kbId)}/reindex`,
data,
);
}
@ -840,7 +862,7 @@ export function getGlobalKBProcessingTasksApi(
params: GlobalKBProcessingTaskQuery,
) {
return museAdminApi.get<PageResult<GlobalKBProcessingTaskSummaryVO>>(
`/knowledge/global-kbs/${kbId}/processing-tasks`,
`${globalKnowledgeBasePath(kbId)}/processing-tasks`,
toMuseQueryParams(params),
);
}
@ -851,7 +873,7 @@ export function retryGlobalKBSourceEventApi(
data: TriggerGlobalKBSourceEventDTO,
) {
return museAdminApi.post<TriggerGlobalKBSourceEventResultVO>(
`/knowledge/global-kbs/${kbId}/source-events`,
`${globalKnowledgeBasePath(kbId)}/source-events`,
data,
);
}

View File

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
approveMarketPublishRequestApi,
@ -27,6 +27,10 @@ vi.mock('#/api/muse/client', () => ({
}));
describe('muse market admin api', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('按 OpenAPI 契约调用市场审核、治理和申诉端点', async () => {
await getMarketAssetListApi({ assetType: 'knowledge_base', pageNo: 1 });
await getMarketAssetApi('asset-1');
@ -35,12 +39,17 @@ describe('muse market admin api', () => {
commandId: 'cmd-preview',
scope: 'full_recall',
});
await previewMarketGovernanceImpactApi('asset-1', {
actionType: 'review_rejection',
commandId: 'cmd-review-rejection-preview',
scope: 'stop_new_acquire',
});
await delistMarketAssetApi('asset-1', {
commandId: 'cmd-delist',
expectedStatus: 'listed',
impactPreviewId: 'preview-1',
reason: '权利声明失效',
scope: 'full_delist',
scope: 'stop_new_acquire',
});
await recallMarketAssetApi('asset-1', {
basis: '合规召回',
@ -82,9 +91,13 @@ describe('muse market admin api', () => {
'/market/assets/asset-1/governance-impact',
expect.objectContaining({ actionType: 'recall' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/assets/asset-1/governance-impact',
expect.objectContaining({ actionType: 'review_rejection' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/assets/asset-1/delist',
expect.objectContaining({ scope: 'full_delist' }),
expect.objectContaining({ scope: 'stop_new_acquire' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/assets/asset-1/recall',
@ -110,4 +123,79 @@ describe('muse market admin api', () => {
expect.objectContaining({ resolution: 'maintained' }),
);
});
it('编码市场 API 路径中的字符串 ID', async () => {
await getMarketAssetApi('asset/with/slash');
await previewMarketGovernanceImpactApi('asset/with/slash', {
actionType: 'recall',
commandId: 'cmd-preview-slash',
scope: 'full_recall',
});
await delistMarketAssetApi('asset/with/slash', {
commandId: 'cmd-delist-slash',
expectedStatus: 'listed',
impactPreviewId: 'preview/slash',
reason: '路径编码测试',
scope: 'stop_new_acquire',
});
await recallMarketAssetApi('asset/with/slash', {
basis: '合规召回',
commandId: 'cmd-recall-slash',
expectedStatus: 'listed',
impactPreviewId: 'preview/slash',
reason: '路径编码测试',
scope: 'full_recall',
});
await approveMarketPublishRequestApi('request/with/slash', {
commandId: 'cmd-approve-slash',
expectedStatus: 'pending',
note: '材料完整',
validationResultId: 'validation/slash',
});
await rejectMarketPublishRequestApi('request/with/slash', {
commandId: 'cmd-reject-slash',
expectedStatus: 'reviewing',
reason: '路径编码测试',
validationResultId: 'validation/slash',
});
await getMarketAppealApi('appeal/with/slash');
await resolveMarketAppealApi('appeal/with/slash', {
commandId: 'cmd-resolve-slash',
expectedStatus: 'reviewing',
impactPreviewId: 'preview/slash',
reason: '路径编码测试',
resolution: 'maintained',
});
expect(clientMocks.get).toHaveBeenCalledWith(
'/market/assets/asset%2Fwith%2Fslash',
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/assets/asset%2Fwith%2Fslash/governance-impact',
expect.objectContaining({ commandId: 'cmd-preview-slash' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/assets/asset%2Fwith%2Fslash/delist',
expect.objectContaining({ commandId: 'cmd-delist-slash' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/assets/asset%2Fwith%2Fslash/recall',
expect.objectContaining({ commandId: 'cmd-recall-slash' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/publish-requests/request%2Fwith%2Fslash/approve',
expect.objectContaining({ commandId: 'cmd-approve-slash' }),
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/publish-requests/request%2Fwith%2Fslash/reject',
expect.objectContaining({ commandId: 'cmd-reject-slash' }),
);
expect(clientMocks.get).toHaveBeenCalledWith(
'/market/appeals/appeal%2Fwith%2Fslash',
);
expect(clientMocks.post).toHaveBeenCalledWith(
'/market/appeals/appeal%2Fwith%2Fslash/resolve',
expect.objectContaining({ commandId: 'cmd-resolve-slash' }),
);
});
});

View File

@ -9,6 +9,11 @@ function toMuseQueryParams(params: object): MuseQueryParams {
return params as MuseQueryParams;
}
/** 编码路径参数,避免业务 ID 中的斜杠被当成额外 URL 分段。 */
function encodePathParam(value: string) {
return encodeURIComponent(value);
}
/** 市场资产类型。 */
export type MarketAssetType = 'agent' | 'knowledge_base' | 'work';
@ -49,6 +54,7 @@ export type MarketActionPolicyFilter =
export type MarketGovernanceActionType =
| 'delist'
| 'recall'
| 'review_rejection'
| 'revoke_license'
| 'stop_generation';
@ -453,7 +459,7 @@ export function getMarketAssetListApi(params: MarketAssetQuery) {
/** 查询市场资产治理详情。 */
export function getMarketAssetApi(assetId: string) {
return museAdminApi.get<AdminMarketAssetDetailVO>(
`/market/assets/${assetId}`,
`/market/assets/${encodePathParam(assetId)}`,
);
}
@ -463,7 +469,7 @@ export function previewMarketGovernanceImpactApi(
data: PreviewMarketGovernanceImpactDTO,
) {
return museAdminApi.post<AdminGovernanceImpactPreviewVO>(
`/market/assets/${assetId}/governance-impact`,
`/market/assets/${encodePathParam(assetId)}/governance-impact`,
data,
);
}
@ -473,7 +479,10 @@ export function delistMarketAssetApi(
assetId: string,
data: DelistMarketAssetDTO,
) {
return museAdminApi.post<void>(`/market/assets/${assetId}/delist`, data);
return museAdminApi.post<void>(
`/market/assets/${encodePathParam(assetId)}/delist`,
data,
);
}
/** 召回市场资产。 */
@ -481,7 +490,10 @@ export function recallMarketAssetApi(
assetId: string,
data: RecallMarketAssetDTO,
) {
return museAdminApi.post<void>(`/market/assets/${assetId}/recall`, data);
return museAdminApi.post<void>(
`/market/assets/${encodePathParam(assetId)}/recall`,
data,
);
}
/** 查询发布申请审核队列。 */
@ -500,7 +512,7 @@ export function approveMarketPublishRequestApi(
data: ApproveMarketPublishRequestDTO,
) {
return museAdminApi.post<void>(
`/market/publish-requests/${requestId}/approve`,
`/market/publish-requests/${encodePathParam(requestId)}/approve`,
data,
);
}
@ -511,7 +523,7 @@ export function rejectMarketPublishRequestApi(
data: RejectMarketPublishRequestDTO,
) {
return museAdminApi.post<void>(
`/market/publish-requests/${requestId}/reject`,
`/market/publish-requests/${encodePathParam(requestId)}/reject`,
data,
);
}
@ -526,7 +538,9 @@ export function getMarketAppealListApi(params: MarketAppealQuery) {
/** 查询市场申诉详情。 */
export function getMarketAppealApi(appealId: string) {
return museAdminApi.get<AdminAppealDetailVO>(`/market/appeals/${appealId}`);
return museAdminApi.get<AdminAppealDetailVO>(
`/market/appeals/${encodePathParam(appealId)}`,
);
}
/** 处理市场申诉。 */
@ -535,7 +549,7 @@ export function resolveMarketAppealApi(
data: ResolveMarketAppealDTO,
) {
return museAdminApi.post<void>(
`/market/appeals/${appealId}/resolve`,
`/market/appeals/${encodePathParam(appealId)}/resolve`,
data,
);
}

View File

@ -1,25 +1,47 @@
import { createMemoryHistory, createRouter } from 'vue-router';
import { describe, expect, it } from 'vitest';
import routes from '../modules/muse';
describe('muse admin routes', () => {
it('注册所有 P3 管理端功能域,并统一挂载在 /muse 下', () => {
const root = routes[0]!;
const childPaths = root.children?.map((route) => route.path) ?? [];
const createMuseRouter = () =>
createRouter({
history: createMemoryHistory(),
routes,
});
expect(root.path).toBe('/muse');
expect(childPaths).toEqual([
'governance/meta-schemas',
'governance/meta-schemas/:schemaKey',
'governance/function-orchestration',
'ai',
'knowledge',
'market',
'account',
'new-api',
'jobs',
'audit',
]);
expect(root.children?.every((route) => route.component)).toBe(true);
describe('muse admin routes', () => {
it('通过路由解析暴露所有 P3 管理端功能域', () => {
const memoryRouter = createMuseRouter();
const routeContracts = [
['MuseMetaSchemaList', '/muse/governance/meta-schemas'],
['MuseFunctionOrchestration', '/muse/governance/function-orchestration'],
['MuseAiConfig', '/muse/ai'],
['MuseGlobalKnowledge', '/muse/knowledge'],
['MuseMarketGovernance', '/muse/market'],
['MuseAccountGovernance', '/muse/account'],
['MuseNewApiUsage', '/muse/new-api'],
['MuseTaskOps', '/muse/jobs'],
['MuseAuditLog', '/muse/audit'],
] as const;
for (const [name, path] of routeContracts) {
const resolved = memoryRouter.resolve({ name });
expect(resolved.fullPath).toBe(path);
expect(resolved.name).toBe(name);
expect(resolved.matched.at(0)?.path).toBe('/muse');
}
});
it('MetaSchema 详情页使用真实 REST 风格 path 和路由参数', () => {
const memoryRouter = createMuseRouter();
const resolved = memoryRouter.resolve({
name: 'MuseMetaSchemaDetail',
params: { schemaKey: 'work/story' },
});
expect(resolved.name).toBe('MuseMetaSchemaDetail');
expect(resolved.fullPath).toBe('/muse/governance/meta-schemas/work%2Fstory');
expect(resolved.meta.activePath).toBe('/muse/governance/meta-schemas');
});
});

View File

@ -12,6 +12,7 @@ import type {
AdminUsageRecord,
AdminUserEntitlementDetail,
AccountStatus,
Entitlement,
QuotaAdjustmentLedgerEntry,
QuotaStatus,
} from '#/api/muse/account';
@ -326,6 +327,37 @@ function displayText(value?: number | string) {
return value === undefined || value === '' ? '-' : String(value);
}
/** 当前配额弹窗对应用户的权益快照,禁止跨用户复用 selectedEntitlement。 */
const quotaUserEntitlements = computed(() => {
if (selectedEntitlement.value?.userId !== quotaForm.userId) {
return [];
}
return selectedEntitlement.value.entitlements;
});
/** 用当前用户的权益快照填充调整前审计快照。 */
function applyQuotaSnapshot(entitlement?: Entitlement) {
quotaForm.beforeLimit = entitlement?.limit;
quotaForm.beforeRemaining = entitlement?.remaining;
quotaForm.beforeUsed = entitlement?.used;
quotaForm.resourceType = entitlement?.resourceType ?? '';
}
/** 切换资源类型时同步切换 beforeSnapshot,避免资源类型和快照错配。 */
function handleQuotaResourceTypeChange(resourceType: string) {
const entitlement = quotaUserEntitlements.value.find(
(item) => item.resourceType === resourceType,
);
applyQuotaSnapshot(entitlement);
}
/** 提交前再次从当前用户快照取值,防止表单残留被当作可信审计快照。 */
function getQuotaSubmissionEntitlement() {
return quotaUserEntitlements.value.find(
(item) => item.resourceType === quotaForm.resourceType,
);
}
/** 刷新用户账户摘要列表。 */
async function loadUsers(page = userPager.current, pageSize = userPager.pageSize) {
userLoading.value = true;
@ -475,16 +507,29 @@ function submitGovernanceCommand() {
/** 打开配额调整弹窗,并用当前权益快照预填审计字段。 */
function openQuotaModal(record: AdminAccountUserSummary) {
const entitlement = selectedEntitlement.value?.entitlements?.[0];
const isCurrentUserSnapshot =
selectedUser.value?.userId === record.userId &&
selectedEntitlement.value?.userId === record.userId;
if (!isCurrentUserSnapshot) {
message.error('请先点击“权益”加载当前用户的权益快照,再调整配额');
return;
}
const entitlementDetail = selectedEntitlement.value;
if (!entitlementDetail) {
message.error('请先加载当前用户的权益快照');
return;
}
const entitlement = entitlementDetail.entitlements[0];
if (!entitlement) {
message.error('当前用户没有可用于配额调整的权益快照');
return;
}
quotaForm.approvalRef = '';
quotaForm.beforeLimit = entitlement?.limit;
quotaForm.beforeRemaining = entitlement?.remaining;
quotaForm.beforeUsed = entitlement?.used;
quotaForm.commandId = createCommandId('quota');
quotaForm.delta = undefined;
quotaForm.reason = '';
quotaForm.resourceType = entitlement?.resourceType ?? '';
quotaForm.userId = record.userId;
applyQuotaSnapshot(entitlement);
quotaModalOpen.value = true;
}
@ -498,18 +543,23 @@ async function submitQuotaAdjustment() {
message.error('请填写非 0 调整量');
return;
}
const entitlement = getQuotaSubmissionEntitlement();
if (!entitlement) {
message.error('当前资源类型没有匹配的用户权益快照,请重新加载权益后再提交');
return;
}
quotaSubmitting.value = true;
try {
await createQuotaAdjustmentApi(quotaForm.userId, {
adjustments: [
{
beforeSnapshot: {
limit: quotaForm.beforeLimit,
remaining: quotaForm.beforeRemaining,
used: quotaForm.beforeUsed,
limit: entitlement.limit,
remaining: entitlement.remaining,
used: entitlement.used,
},
delta: quotaForm.delta,
resourceType: quotaForm.resourceType,
resourceType: entitlement.resourceType,
},
],
approvalRef: quotaForm.approvalRef || undefined,
@ -808,7 +858,19 @@ onMounted(() => {
<Input v-model:value="quotaForm.userId" disabled />
</Form.Item>
<Form.Item label="资源类型" required>
<Input v-model:value="quotaForm.resourceType" />
<Select
v-model:value="quotaForm.resourceType"
placeholder="选择当前用户权益资源"
@change="(value) => handleQuotaResourceTypeChange(String(value))"
>
<Select.Option
v-for="entitlement in quotaUserEntitlements"
:key="entitlement.resourceType"
:value="entitlement.resourceType"
>
{{ entitlement.resourceType }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="调整量" required>
<InputNumber v-model:value="quotaForm.delta" class="w-full" />

View File

@ -49,6 +49,17 @@ import {
defineOptions({ name: 'MuseGovernanceFunctionOrchestration' });
interface FunctionChainPreviewTicketCompat {
/** 后端兼容返回的功能链路校验结果票据。 */
validationResultId?: string;
/** 后端兼容返回的功能链路校验票据别名。 */
validationTicketId?: string;
/** 后端兼容返回的功能链路校验结果别名。 */
validationId?: string;
/** 后端兼容返回的票据通用别名。 */
ticketId?: string;
}
/** 功能链路状态展示配置。 */
const STATUS_META_MAP: Record<FunctionChainStatus, { color: string; label: string }> = {
active: { color: 'green', label: '已启用' },
@ -103,8 +114,19 @@ const protectionNodeLoading = ref(false);
const previewDrawerOpen = ref(false);
const activateDrawerOpen = ref(false);
const previewLoading = ref(false);
const previewRequestSeq = ref(0);
const activating = ref(false);
const impactPreview = ref<FunctionChainImpactPreview>();
const impactPreviewBinding = ref<{
/** 影响预览所属链路。 */
chainKey: string;
/** 影响预览所属目标版本。 */
targetVersion: number;
/** 当前可提交的影响预览票据。 */
impactPreviewId: string;
/** 当前可提交的校验结果票据。 */
validationResultId: string;
}>();
/** 影响预览请求表单。 */
const previewForm = reactive({
@ -121,8 +143,29 @@ const activateForm = reactive({
validationResultId: '',
});
/** 当前影响预览是否仍绑定当前链路和目标版本。 */
const hasCurrentImpactPreviewBinding = computed(() => {
return (
!!selectedChain.value &&
!!activateForm.targetVersion &&
!!impactPreviewBinding.value &&
impactPreviewBinding.value.chainKey === selectedChain.value.chainKey &&
impactPreviewBinding.value.targetVersion === activateForm.targetVersion &&
impactPreviewBinding.value.impactPreviewId === activateForm.impactPreviewId &&
!!activateForm.impactPreviewId
);
});
/** 当前激活票据是否完整绑定当前链路和目标版本。 */
const hasCurrentActivationTicketBinding = computed(() => {
return hasCurrentImpactPreviewBinding.value && !!activateForm.validationResultId;
});
/** 是否存在保护节点边界违规,存在时前端也阻断激活入口。 */
const hasProtectionBoundaryViolation = computed(() => {
if (!hasCurrentImpactPreviewBinding.value) {
return false;
}
return (impactPreview.value?.slotChanges ?? []).some(
(item) => item.changeType === 'protection_boundary_violation',
);
@ -133,6 +176,19 @@ function createCommandId(action: string) {
return `muse-admin-function-chain-${action}-${Date.now()}`;
}
/** 兼容后端影响预览返回的校验票据,不改 OpenAPI 合同,只在页面内窄类型读取。 */
function readValidationResultId(
result: FunctionChainImpactPreview & FunctionChainPreviewTicketCompat,
) {
return (
result.validationResultId ??
result.validationTicketId ??
result.validationId ??
result.ticketId ??
''
);
}
/** 推导功能链路状态。 */
function resolveChainStatus(row: FunctionChainSummary): FunctionChainStatus {
return row.status ?? 'active';
@ -140,11 +196,28 @@ function resolveChainStatus(row: FunctionChainSummary): FunctionChainStatus {
/** 选中链路并加载保护节点注册表。 */
async function handleSelectChain(row: FunctionChainSummary) {
const previousChainKey = selectedChain.value?.chainKey;
const currentBinding =
impactPreviewBinding.value?.chainKey === row.chainKey
? impactPreviewBinding.value
: undefined;
selectedChain.value = row;
previewForm.expectedCurrentVersion = row.activeVersion;
previewForm.targetVersion = row.activeVersion;
previewForm.targetVersion = currentBinding?.targetVersion ?? row.activeVersion;
activateForm.expectedActiveVersion = row.activeVersion;
activateForm.targetVersion = row.activeVersion;
activateForm.targetVersion = currentBinding?.targetVersion ?? row.activeVersion;
if (!currentBinding) {
impactPreview.value = undefined;
impactPreviewBinding.value = undefined;
activateForm.impactPreviewId = '';
activateForm.validationResultId = '';
} else {
activateForm.impactPreviewId = currentBinding.impactPreviewId;
activateForm.validationResultId = currentBinding.validationResultId;
}
if (previousChainKey !== row.chainKey) {
activateForm.reason = '';
}
protectionNodeLoading.value = true;
try {
const result = await listProtectionNodesApi({
@ -167,7 +240,7 @@ function openPreviewDrawer(row: FunctionChainSummary) {
/** 打开激活确认抽屉。 */
function openActivateDrawer(row: FunctionChainSummary) {
handleSelectChain(row);
activateForm.impactPreviewId = impactPreview.value?.impactPreviewId ?? '';
activateForm.impactPreviewId = impactPreviewBinding.value?.impactPreviewId ?? '';
activateDrawerOpen.value = true;
}
@ -177,29 +250,78 @@ async function handlePreviewImpact() {
message.warning('请先选择功能链路');
return;
}
const requestedSeq = previewRequestSeq.value + 1;
previewRequestSeq.value = requestedSeq;
const requestedChainKey = selectedChain.value.chainKey;
const requestedTargetVersion = previewForm.targetVersion ?? selectedChain.value.activeVersion;
const requestedExpectedCurrentVersion = previewForm.expectedCurrentVersion;
if (
impactPreviewBinding.value &&
(impactPreviewBinding.value.chainKey !== requestedChainKey ||
impactPreviewBinding.value.targetVersion !== requestedTargetVersion)
) {
impactPreview.value = undefined;
impactPreviewBinding.value = undefined;
activateForm.impactPreviewId = '';
}
previewLoading.value = true;
try {
impactPreview.value = await previewFunctionChainImpactApi(
selectedChain.value.chainKey,
const result = (await previewFunctionChainImpactApi(
requestedChainKey,
{
commandId: createCommandId('impact-preview'),
expectedCurrentVersion: previewForm.expectedCurrentVersion,
targetVersion: previewForm.targetVersion,
expectedCurrentVersion: requestedExpectedCurrentVersion,
targetVersion: requestedTargetVersion,
},
);
activateForm.impactPreviewId = impactPreview.value.impactPreviewId ?? '';
)) as FunctionChainImpactPreview & FunctionChainPreviewTicketCompat;
if (
previewRequestSeq.value !== requestedSeq ||
selectedChain.value?.chainKey !== requestedChainKey ||
(previewForm.targetVersion ?? selectedChain.value?.activeVersion) !==
requestedTargetVersion
) {
return;
}
impactPreview.value = result;
impactPreviewBinding.value = {
chainKey: requestedChainKey,
impactPreviewId: result.impactPreviewId ?? '',
targetVersion: requestedTargetVersion,
validationResultId: readValidationResultId(result),
};
activateForm.targetVersion = requestedTargetVersion;
activateForm.impactPreviewId = impactPreviewBinding.value.impactPreviewId;
activateForm.validationResultId = impactPreviewBinding.value.validationResultId;
message.success('影响预览已生成');
} finally {
previewLoading.value = false;
if (previewRequestSeq.value === requestedSeq) {
previewLoading.value = false;
}
}
}
/** 激活前校验预览票据和当前链路、版本一致。 */
function ensureCurrentImpactPreviewBinding() {
if (!hasCurrentImpactPreviewBinding.value) {
message.warning('请先为当前链路和目标版本重新生成影响预览');
return false;
}
if (!activateForm.validationResultId) {
message.warning('请先完成当前链路校验,确保当前预览返回校验结果 ID');
return false;
}
return true;
}
/** 执行功能链路激活。 */
async function handleActivateChain() {
if (!selectedChain.value || !activateForm.targetVersion) {
message.warning('请先选择要激活的功能链路版本');
return;
}
if (!ensureCurrentImpactPreviewBinding()) {
return;
}
if (hasProtectionBoundaryViolation.value) {
message.error('影响预览存在保护节点边界违规,不能激活');
return;
@ -481,10 +603,17 @@ function handleRefresh() {
/>
</a-form-item>
<a-form-item label="validationResultId">
<a-input v-model:value="activateForm.validationResultId" />
<a-input v-model:value="activateForm.validationResultId" readonly />
</a-form-item>
<a-form-item label="impactPreviewId">
<a-input v-model:value="activateForm.impactPreviewId" />
<a-input v-model:value="activateForm.impactPreviewId" readonly />
<div class="mt-1 text-xs text-muted-foreground">
{{
hasCurrentActivationTicketBinding
? '当前链路预览和校验票据已绑定'
: '请先为当前链路和目标版本生成影响预览并取得校验票据'
}}
</div>
</a-form-item>
<a-form-item label="expectedActiveVersion">
<a-input-number

View File

@ -0,0 +1,281 @@
import { createApp, defineComponent, h, nextTick, reactive } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const routeState = reactive({
params: { schemaKey: 'work.story' },
query: {} as Record<string, string>,
});
function makeStub(name: string) {
return defineComponent({
name,
props: [
'dataSource',
'label',
'loading',
'message',
'open',
'placeholder',
'status',
'subTitle',
'title',
'value',
],
setup(props, { slots }) {
return () =>
h('section', { 'data-stub': name }, [
props.title,
props.label,
props.message,
props.placeholder,
props.status,
props.subTitle,
props.value,
props.open ? 'open' : '',
Array.isArray(props.dataSource)
? JSON.stringify(props.dataSource)
: '',
slots.default?.(),
slots.footer?.(),
slots.bodyCell?.({
column: { dataIndex: 'required' },
record: {},
}),
slots.renderItem?.({
item: { code: 'stub', message: 'stub message' },
}),
]);
},
});
}
vi.mock('vue-router', () => ({
useRoute: () => routeState,
}));
vi.mock('@vben/common-ui', () => ({
Page: makeStub('Page'),
}));
vi.mock('ant-design-vue', () => ({
Alert: makeStub('Alert'),
Button: defineComponent({
emits: ['click'],
props: ['disabled', 'loading'],
setup(props, { slots, emit }) {
return () =>
h(
'button',
{
disabled: props.disabled,
onClick: () => emit('click'),
},
slots.default?.(),
);
},
}),
Card: makeStub('Card'),
Col: makeStub('Col'),
Descriptions: makeStub('Descriptions'),
DescriptionsItem: makeStub('DescriptionsItem'),
Drawer: makeStub('Drawer'),
Form: makeStub('Form'),
FormItem: makeStub('FormItem'),
Input: makeStub('Input'),
InputNumber: makeStub('InputNumber'),
List: makeStub('List'),
ListItem: makeStub('ListItem'),
RadioButton: makeStub('RadioButton'),
RadioGroup: makeStub('RadioGroup'),
Result: makeStub('Result'),
Row: makeStub('Row'),
Select: makeStub('Select'),
Space: makeStub('Space'),
Table: makeStub('Table'),
Tag: makeStub('Tag'),
Textarea: makeStub('Textarea'),
message: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
Modal: {
confirm: vi.fn(),
},
}));
const getMetaSchemaApi = vi.fn();
const saveMetaSchemaDraftApi = vi.fn();
const validateMetaSchemaDraftApi = vi.fn();
const previewMetaSchemaDraftImpactApi = vi.fn();
vi.mock('#/api/muse/governance/meta-schema', () => ({
activateMetaSchemaVersionApi: vi.fn(),
getMetaSchemaApi,
getMetaSchemaVersionApi: vi.fn(),
previewMetaSchemaDraftImpactApi,
publishMetaSchemaDraftApi: vi.fn(),
saveMetaSchemaDraftApi,
setMetaSchemaGrayRulesApi: vi.fn(),
validateMetaSchemaDraftApi,
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function makeDetail(schemaKey = 'work.story') {
return {
activeVersion: 1,
description: '当前结构',
displayName: '作品结构',
fields: [
{
displayName: '标题',
fieldKey: 'title',
fieldType: 'string',
},
],
schemaKey,
scope: 'work',
};
}
describe('muse meta schema detail page', () => {
beforeEach(() => {
routeState.params.schemaKey = 'work.story';
routeState.query = {};
getMetaSchemaApi.mockReset();
saveMetaSchemaDraftApi.mockReset();
validateMetaSchemaDraftApi.mockReset();
previewMetaSchemaDraftImpactApi.mockReset();
});
it('丢弃旧保存响应,避免草稿版本回滚', async () => {
const firstSaveRequest = deferred<{ draftVersion: number }>();
getMetaSchemaApi.mockResolvedValue(makeDetail());
saveMetaSchemaDraftApi
.mockReturnValueOnce(firstSaveRequest.promise)
.mockResolvedValueOnce({ draftVersion: 2 });
validateMetaSchemaDraftApi.mockResolvedValue({
valid: true,
validationResultId: 'validation-current',
});
const { root, unmount } = await mountPage();
await flushPromises();
const buttons = () =>
[...root.querySelectorAll<HTMLElement>('button,a-button')].map((button) => ({
element: button,
text: button.textContent ?? '',
}));
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
await flushPromises();
await nextTick();
firstSaveRequest.resolve({ draftVersion: 1 });
await flushPromises();
await nextTick();
buttons().find((button) => button.text.includes('校验草稿'))?.element.click();
await flushPromises();
expect(validateMetaSchemaDraftApi).toHaveBeenCalledWith('work.story', 2);
unmount();
});
it('丢弃草稿切换前返回的校验和影响预览票据', async () => {
const validateRequest = deferred<{
valid: boolean;
validationResultId: string;
}>();
const previewRequest = deferred<{
draftVersion: number;
impactPreviewId: string;
schemaKey: string;
}>();
const oldValidationResult = {
valid: true,
validationResultId: 'validation-old',
};
const oldImpactPreview = {
draftVersion: 1,
impactPreviewId: 'impact-old',
schemaKey: 'work.story',
};
getMetaSchemaApi.mockResolvedValue(makeDetail());
saveMetaSchemaDraftApi
.mockResolvedValueOnce({ draftVersion: 1 })
.mockResolvedValueOnce({ draftVersion: 2 });
validateMetaSchemaDraftApi.mockReturnValue(validateRequest.promise);
previewMetaSchemaDraftImpactApi.mockReturnValue(previewRequest.promise);
const { root, unmount } = await mountPage();
await flushPromises();
const buttons = () =>
[...root.querySelectorAll<HTMLElement>('button,a-button')].map((button) => ({
element: button,
text: button.textContent ?? '',
}));
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
await flushPromises();
await nextTick();
buttons().find((button) => button.text.includes('校验草稿'))?.element.click();
buttons().find((button) => button.text.includes('影响预览'))?.element.click();
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
await flushPromises();
await nextTick();
validateRequest.resolve(oldValidationResult);
previewRequest.resolve(oldImpactPreview);
await flushPromises();
await nextTick();
buttons().find((button) => button.text.includes('发布确认'))?.element.click();
await nextTick();
buttons().find((button) => button.text.includes('灰度规则 / 激活'))?.element.click();
await nextTick();
expect(root.textContent).not.toContain('validation-old');
expect(root.textContent).not.toContain('impact-old');
unmount();
});
});
async function mountPage() {
const { default: DetailPage } = await import('../detail.vue');
const root = document.createElement('div');
document.body.append(root);
const app = createApp(DetailPage);
app.mount(root);
await flushPromises();
await nextTick();
return {
root,
unmount: () => {
app.unmount();
root.remove();
},
};
}

View File

@ -4,7 +4,6 @@
* 提供 active 字段展示、草稿 JSON 编辑、校验、影响预览、发布确认和灰度规则配置。
*/
import type { TableColumnsType } from 'ant-design-vue';
import type { LocationQueryValue } from 'vue-router';
import type {
GrayRuleStrategy,
MetaField,
@ -17,12 +16,37 @@ import type {
ValidationResult,
} from '#/api/muse/governance/meta-schema';
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Page } from '@vben/common-ui';
import { message, Modal } from 'ant-design-vue';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Drawer as ADrawer,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
List as AList,
ListItem as AListItem,
RadioButton as ARadioButton,
RadioGroup as ARadioGroup,
Result as AResult,
Row as ARow,
Select as ASelect,
Space as ASpace,
Table as ATable,
Tag as ATag,
Textarea as ATextarea,
message,
Modal,
} from 'ant-design-vue';
import {
activateMetaSchemaVersionApi,
@ -80,6 +104,30 @@ interface VersionHistoryRow {
note: string;
}
interface DraftTicketBinding {
/** 当前票据所属 schemaKey,防止详情页切换后跨 schema 复用。 */
schemaKey: string;
/** 当前票据所属草稿版本。 */
draftVersion?: number;
/** 当前票据发布出的版本;激活只能引用刚发布的版本。 */
publishedVersion?: number;
/** 当前草稿校验结果票据。 */
validationResultId: string;
/** 当前草稿影响预览票据。 */
impactPreviewId: string;
}
interface ValidationResultTicketCompat {
/** 后端兼容返回的校验结果票据。 */
validationResultId?: string;
/** 后端兼容返回的校验票据别名。 */
validationTicketId?: string;
/** 后端兼容返回的校验结果别名。 */
validationId?: string;
/** 后端兼容返回的票据通用别名。 */
ticketId?: string;
}
/** 版本历史表格列。 */
const versionColumns: TableColumnsType<VersionHistoryRow> = [
{ dataIndex: 'version', minWidth: 100, title: '版本' },
@ -105,6 +153,25 @@ const impactPreview = ref<MetaSchemaImpactPreview>();
const publishResult = ref<MetaSchemaPublishResponse>();
const draftVersion = ref<number>();
const draftFieldsJson = ref('[]');
const draftTicketBinding = reactive<DraftTicketBinding>({
draftVersion: undefined,
impactPreviewId: '',
publishedVersion: undefined,
schemaKey: '',
validationResultId: '',
});
/** 详情加载序号,用于避免快速切换路由时旧请求覆盖新页面状态。 */
let detailLoadSeq = 0;
/** 草稿保存请求序号,用于避免旧保存响应回写新草稿版本。 */
let saveDraftSeq = 0;
/** 草稿校验请求序号,用于避免旧请求回写新草稿票据。 */
let validateDraftSeq = 0;
/** 影响预览请求序号,用于避免旧请求回写新草稿票据。 */
let previewImpactSeq = 0;
/** 基础表单状态,schemaKey 在新建草稿时由管理员显式输入。 */
const baseForm = reactive({
@ -136,14 +203,34 @@ const grayForm = reactive({
version: undefined as number | undefined,
});
/** 获取 query 中第一个字符串值。 */
function firstQueryValue(value: LocationQueryValue | LocationQueryValue[] | undefined) {
/** 当前草稿绑定的票据信息,仅允许由校验和预览回填。 */
const currentDraftTicketSnapshot = computed(() => ({
impactPreviewId: draftTicketBinding.impactPreviewId,
validationResultId: draftTicketBinding.validationResultId,
}));
/** 获取路由参数或 query 中第一个字符串值。 */
function firstRouteString(value: unknown) {
if (Array.isArray(value)) {
return value[0] ?? '';
return typeof value[0] === 'string' ? value[0] : '';
}
return value ?? '';
return typeof value === 'string' ? value : '';
}
/** 当前是否处于创建模式。 */
const isCreateMode = computed(() => firstRouteString(route.query.mode) === 'create');
/**
* 当前路由对应的 schemaKey。
* 创建模式只允许从 query.schemaKey 预填,避免把路由占位参数误当成真实 schemaKey。
*/
const routeSchemaKey = computed(() => {
if (isCreateMode.value) {
return firstRouteString(route.query.schemaKey);
}
return firstRouteString(route.query.schemaKey) || firstRouteString(route.params.schemaKey);
});
/** 生成命令幂等键。 */
function createCommandId(action: string) {
return `muse-admin-meta-schema-${action}-${Date.now()}`;
@ -171,6 +258,17 @@ function toStringArray(value: unknown) {
: undefined;
}
/** 兼容后端校验结果返回的票据字段,不改 OpenAPI 合同,只做窄类型读取。 */
function readValidationResultId(result: ValidationResult & ValidationResultTicketCompat) {
return (
result.validationResultId ??
result.validationTicketId ??
result.validationId ??
result.ticketId ??
''
);
}
/** 从对象中解析布尔值,非布尔值不提交。 */
function toOptionalBoolean(value: unknown) {
return typeof value === 'boolean' ? value : undefined;
@ -253,8 +351,32 @@ function splitWhitelist(text: string) {
.filter(Boolean);
}
/** 当前可用 schemaKey。 */
const currentSchemaKey = computed(() => baseForm.schemaKey.trim());
/** 重置发布表单,避免 route/schema 切换后旧发布理由和票据残留。 */
function resetPublishForm() {
publishForm.expectedVersion = undefined;
publishForm.impactPreviewId = '';
publishForm.reason = '';
publishForm.validationResultId = '';
}
/** 重置激活与灰度表单,避免旧策略、百分比和白名单跨 schema 泄漏。 */
function resetGrayForm() {
grayForm.activateMode = 'full';
grayForm.commandReason = '';
grayForm.expectedActiveVersion = undefined;
grayForm.impactPreviewId = '';
grayForm.percentage = 0;
grayForm.strategy = 'percentage';
grayForm.tenantWhitelistText = '';
grayForm.userWhitelistText = '';
grayForm.validationResultId = '';
grayForm.version = undefined;
}
/** 当前可用 schemaKey。创建模式只接受管理员当前输入,不吃路由占位参数。 */
const currentSchemaKey = computed(() =>
isCreateMode.value ? baseForm.schemaKey.trim() : routeSchemaKey.value || baseForm.schemaKey.trim(),
);
/** active 字段列表。 */
const activeFields = computed(() => detail.value?.fields ?? []);
@ -284,6 +406,87 @@ const versionRows = computed<VersionHistoryRow[]>(() => {
return rows;
});
/** 是否已经拿到当前草稿的发布/激活票据。 */
const hasCurrentDraftTicketBinding = computed(() => {
return (
draftTicketBinding.schemaKey === currentSchemaKey.value &&
draftVersion.value !== undefined &&
draftTicketBinding.draftVersion === draftVersion.value &&
Boolean(draftTicketBinding.validationResultId) &&
Boolean(draftTicketBinding.impactPreviewId)
);
});
/** 清掉旧草稿相关的票据,防止旧票据误发到新草稿。 */
function clearDraftTicketBinding() {
draftTicketBinding.schemaKey = '';
draftTicketBinding.draftVersion = undefined;
draftTicketBinding.publishedVersion = undefined;
draftTicketBinding.validationResultId = '';
draftTicketBinding.impactPreviewId = '';
impactPreview.value = undefined;
}
/** 当前草稿重新保存后,旧票据全部失效,只保留新的草稿版本号。 */
function resetCurrentDraftCommandTickets(nextDraftVersion?: number) {
draftTicketBinding.schemaKey = currentSchemaKey.value;
draftTicketBinding.draftVersion = nextDraftVersion;
draftTicketBinding.publishedVersion = undefined;
draftTicketBinding.validationResultId = '';
draftTicketBinding.impactPreviewId = '';
syncCommandTicketForms();
impactPreview.value = undefined;
}
/** 将当前草稿票据回填到发布和激活表单。 */
function syncCommandTicketForms() {
publishForm.validationResultId = draftTicketBinding.validationResultId;
publishForm.impactPreviewId = draftTicketBinding.impactPreviewId;
grayForm.validationResultId = draftTicketBinding.validationResultId;
grayForm.impactPreviewId = draftTicketBinding.impactPreviewId;
}
/** 切换到新路由时,清空上一页的工作区状态。 */
function resetWorkspaceState() {
saveDraftSeq += 1;
validateDraftSeq += 1;
previewImpactSeq += 1;
loading.value = false;
saving.value = false;
validating.value = false;
previewing.value = false;
publishing.value = false;
activating.value = false;
graySaving.value = false;
publishDrawerOpen.value = false;
grayDrawerOpen.value = false;
detail.value = undefined;
selectedVersion.value = undefined;
validationResult.value = undefined;
impactPreview.value = undefined;
publishResult.value = undefined;
draftVersion.value = undefined;
draftFieldsJson.value = '[]';
baseForm.description = '';
baseForm.displayName = '';
baseForm.expectedVersion = undefined;
baseForm.schemaKey = '';
resetPublishForm();
resetGrayForm();
clearDraftTicketBinding();
}
/** 选择当前草稿绑定是否有效,不有效时提示管理员先校验并生成预览。 */
function ensureCurrentDraftTicketBinding(actionLabel: string) {
if (!hasCurrentDraftTicketBinding.value) {
message.warning(`请先完成当前草稿的校验和影响预览,再执行${actionLabel}`);
return false;
}
/** 当前草稿已有票据时,再次执行前把表单回填到同一组票据,避免人工改串。 */
syncCommandTicketForms();
return true;
}
/** 同步详情数据到编辑表单。 */
function syncDetailToForm(data: MetaSchemaDetail) {
baseForm.schemaKey = data.schemaKey;
@ -297,18 +500,31 @@ function syncDetailToForm(data: MetaSchemaDetail) {
/** 加载 MetaSchema 详情。 */
async function loadDetail() {
const schemaKey = firstQueryValue(route.query.schemaKey);
const requestSeq = ++detailLoadSeq;
resetWorkspaceState();
if (isCreateMode.value) {
const seedSchemaKey = firstRouteString(route.query.schemaKey);
if (seedSchemaKey) {
baseForm.schemaKey = seedSchemaKey;
}
return;
}
const schemaKey = routeSchemaKey.value;
if (!schemaKey) {
draftFieldsJson.value = '[]';
return;
}
loading.value = true;
try {
const data = await getMetaSchemaApi(schemaKey);
if (requestSeq !== detailLoadSeq) {
return;
}
detail.value = data;
syncDetailToForm(data);
} finally {
loading.value = false;
if (requestSeq === detailLoadSeq) {
loading.value = false;
}
}
}
@ -336,6 +552,8 @@ async function handleSaveDraft() {
message.warning('请先填写 schemaKey');
return;
}
const requestSeq = ++saveDraftSeq;
const requestSchemaKey = currentSchemaKey.value;
let fields: MetaField[];
try {
fields = parseDraftFieldsJson();
@ -345,18 +563,33 @@ async function handleSaveDraft() {
}
saving.value = true;
try {
const response = await saveMetaSchemaDraftApi(currentSchemaKey.value, {
const response = await saveMetaSchemaDraftApi(requestSchemaKey, {
commandId: createCommandId('save-draft'),
description: baseForm.description,
displayName: baseForm.displayName,
expectedVersion: baseForm.expectedVersion,
fields,
});
if (
requestSeq !== saveDraftSeq ||
requestSchemaKey !== currentSchemaKey.value
) {
return;
}
draftVersion.value = response.draftVersion;
validationResult.value = response.validationSummary;
resetCurrentDraftCommandTickets(response.draftVersion);
if (response.validationSummary) {
draftTicketBinding.validationResultId = readValidationResultId(
response.validationSummary as ValidationResult & ValidationResultTicketCompat,
);
syncCommandTicketForms();
}
message.success('草稿已保存');
} finally {
saving.value = false;
if (requestSeq === saveDraftSeq) {
saving.value = false;
}
}
}
@ -366,15 +599,32 @@ async function handleValidateDraft() {
message.warning('请先保存草稿获取 draftVersion');
return;
}
const requestSeq = ++validateDraftSeq;
const requestSchemaKey = currentSchemaKey.value;
const requestDraftVersion = draftVersion.value;
validating.value = true;
try {
validationResult.value = await validateMetaSchemaDraftApi(
currentSchemaKey.value,
draftVersion.value,
);
const result = (await validateMetaSchemaDraftApi(
requestSchemaKey,
requestDraftVersion,
)) as ValidationResult & ValidationResultTicketCompat;
if (
requestSeq !== validateDraftSeq ||
requestSchemaKey !== currentSchemaKey.value ||
requestDraftVersion !== draftVersion.value
) {
return;
}
validationResult.value = result;
draftTicketBinding.schemaKey = requestSchemaKey;
draftTicketBinding.draftVersion = requestDraftVersion;
draftTicketBinding.validationResultId = readValidationResultId(result);
syncCommandTicketForms();
message.success(validationResult.value.valid ? '校验通过' : '校验完成,请查看问题');
} finally {
validating.value = false;
if (requestSeq === validateDraftSeq) {
validating.value = false;
}
}
}
@ -384,24 +634,41 @@ async function handlePreviewImpact() {
message.warning('请先保存草稿获取 draftVersion');
return;
}
const requestSeq = ++previewImpactSeq;
const requestSchemaKey = currentSchemaKey.value;
const requestDraftVersion = draftVersion.value;
previewing.value = true;
try {
impactPreview.value = await previewMetaSchemaDraftImpactApi(
currentSchemaKey.value,
draftVersion.value,
const result = await previewMetaSchemaDraftImpactApi(
requestSchemaKey,
requestDraftVersion,
);
publishForm.impactPreviewId = impactPreview.value.impactPreviewId ?? '';
grayForm.impactPreviewId = impactPreview.value.impactPreviewId ?? '';
if (
requestSeq !== previewImpactSeq ||
requestSchemaKey !== currentSchemaKey.value ||
requestDraftVersion !== draftVersion.value
) {
return;
}
impactPreview.value = result;
draftTicketBinding.schemaKey = requestSchemaKey;
draftTicketBinding.draftVersion = requestDraftVersion;
draftTicketBinding.impactPreviewId = result.impactPreviewId ?? '';
syncCommandTicketForms();
message.success('影响预览已生成');
} finally {
previewing.value = false;
if (requestSeq === previewImpactSeq) {
previewing.value = false;
}
}
}
/** 打开发版确认抽屉。 */
function openPublishDrawer() {
if (!ensureCurrentDraftTicketBinding('发布')) {
return;
}
publishForm.expectedVersion = baseForm.expectedVersion;
publishForm.impactPreviewId = impactPreview.value?.impactPreviewId ?? '';
publishDrawerOpen.value = true;
}
@ -411,6 +678,9 @@ async function handlePublishDraft() {
message.warning('请先保存草稿获取 draftVersion');
return;
}
if (!ensureCurrentDraftTicketBinding('发布')) {
return;
}
if (!publishForm.reason || !publishForm.validationResultId || !publishForm.impactPreviewId) {
message.warning('请填写发布理由、校验结果 ID 和影响预览 ID');
return;
@ -429,6 +699,7 @@ async function handlePublishDraft() {
validationResultId: publishForm.validationResultId,
},
);
draftTicketBinding.publishedVersion = publishResult.value.publishedVersion;
publishDrawerOpen.value = false;
message.success('元结构版本已发布,仍需单独激活或灰度');
} finally {
@ -438,10 +709,15 @@ async function handlePublishDraft() {
/** 打开激活和灰度规则抽屉。 */
function openGrayDrawer() {
grayForm.version =
publishResult.value?.publishedVersion ?? detail.value?.grayVersion ?? detail.value?.activeVersion;
if (!ensureCurrentDraftTicketBinding('激活')) {
return;
}
if (publishResult.value?.publishedVersion === undefined) {
message.warning('请先发布当前草稿,再激活刚发布的版本');
return;
}
grayForm.version = publishResult.value.publishedVersion;
grayForm.expectedActiveVersion = detail.value?.activeVersion ?? baseForm.expectedVersion;
grayForm.impactPreviewId = impactPreview.value?.impactPreviewId ?? '';
grayDrawerOpen.value = true;
}
@ -467,6 +743,17 @@ async function handleActivateVersion() {
message.warning('请先选择要激活的版本');
return;
}
if (!ensureCurrentDraftTicketBinding('激活')) {
return;
}
if (
publishResult.value?.publishedVersion === undefined ||
grayForm.version !== publishResult.value.publishedVersion ||
draftTicketBinding.publishedVersion !== publishResult.value.publishedVersion
) {
message.warning('请先发布当前草稿,并只激活该草稿刚发布出的版本');
return;
}
if (!grayForm.commandReason || !grayForm.validationResultId || !grayForm.impactPreviewId) {
message.warning('请填写激活理由、校验结果 ID 和影响预览 ID');
return;
@ -524,7 +811,13 @@ function confirmPublishDraft() {
});
}
onMounted(loadDetail);
watch(
() => [route.query.mode, route.query.schemaKey, route.params.schemaKey],
async () => {
await loadDetail();
},
{ immediate: true },
);
</script>
<template>
@ -729,10 +1022,14 @@ onMounted(loadDetail);
/>
</a-form-item>
<a-form-item label="validationResultId">
<a-input v-model:value="publishForm.validationResultId" />
<a-input v-model:value="publishForm.validationResultId" readonly />
</a-form-item>
<a-form-item label="impactPreviewId">
<a-input v-model:value="publishForm.impactPreviewId" />
<a-input v-model:value="publishForm.impactPreviewId" readonly />
<div class="mt-1 text-xs text-muted-foreground">
当前绑定:{{ currentDraftTicketSnapshot.validationResultId || '-' }} /
{{ currentDraftTicketSnapshot.impactPreviewId || '-' }}
</div>
</a-form-item>
<a-form-item label="expectedVersion">
<a-input-number
@ -793,10 +1090,14 @@ onMounted(loadDetail);
/>
</a-form-item>
<a-form-item label="validationResultId">
<a-input v-model:value="grayForm.validationResultId" />
<a-input v-model:value="grayForm.validationResultId" readonly />
</a-form-item>
<a-form-item label="impactPreviewId">
<a-input v-model:value="grayForm.impactPreviewId" />
<a-input v-model:value="grayForm.impactPreviewId" readonly />
<div class="mt-1 text-xs text-muted-foreground">
当前绑定:{{ currentDraftTicketSnapshot.validationResultId || '-' }} /
{{ currentDraftTicketSnapshot.impactPreviewId || '-' }}
</div>
</a-form-item>
<a-form-item label="expectedActiveVersion">
<a-input-number

View File

@ -126,15 +126,14 @@ function formatImpactSummary(row: MetaSchemaSummary) {
/** 进入详情页。 */
function handleViewDetail(row: MetaSchemaSummary) {
router.push({
path: '/muse/governance/meta-schema/detail',
query: { schemaKey: row.schemaKey },
path: `/muse/governance/meta-schemas/${encodeURIComponent(row.schemaKey)}`,
});
}
/** 新建草稿入口,详情页负责填写 schemaKey 和字段 JSON。 */
function handleCreateDraft() {
router.push({
path: '/muse/governance/meta-schema/detail',
path: '/muse/governance/meta-schemas/__create__',
query: { mode: 'create' },
});
}

View File

@ -9,6 +9,7 @@ import type {
JobDetail,
JobStatus,
JobSummary,
SourceActionPolicy,
SourceEventDetail,
SourceEventSummary,
SourceStatus,
@ -116,6 +117,13 @@ const sourceStatusColor: Record<SourceStatus, string> = {
unauthorized: 'red',
};
const ACTION_POLICY_TEXT: Record<SourceActionPolicy, string> = {
allowed: '允许',
blocked: '阻断',
needs_recheck: '需重验',
read_only: '只读',
};
/** 将 Ant Table 通用记录转回任务摘要。 */
function asJobSummary(record: TableRecord) {
return record as unknown as JobSummary;
@ -141,9 +149,42 @@ function getSourceStatusColor(status: unknown) {
return sourceStatusColor[status as SourceStatus] ?? 'default';
}
/** 判断任务是否已完成,用于禁用会污染事实的操作。 */
function isCompletedStatus(status: unknown) {
return status === 'completed';
/** 列表合同只有 status;是否 retryable 必须点击时拉详情确认。 */
function canOpenRetryJob(record: Pick<JobSummary, 'status'>) {
return record.status === 'failed';
}
/** 取消只适用于还未进入终态的排队或运行任务。 */
function canCancelJob(record: Pick<JobSummary, 'status'>) {
return record.status === 'queued' || record.status === 'running';
}
/** 独立重验当前没有后端写接口,只在非终态任务上开放 UI 留痕。 */
function canRecheckJob(record: Pick<JobSummary, 'status'>) {
return record.status === 'failed' || record.status === 'running';
}
/** 来源事件传播重试必须由 actionPolicy 明确要求重验。 */
function canRetrySourceEvent(record: Pick<SourceEventSummary, 'actionPolicy'>) {
return record.actionPolicy === 'needs_recheck';
}
function findCurrentJob(jobId: number | string) {
return jobs.value.find((item) => String(item.jobId) === String(jobId));
}
function getActionPolicyText(actionPolicy?: SourceActionPolicy) {
return actionPolicy ? ACTION_POLICY_TEXT[actionPolicy] : '-';
}
/** 重试前拉取详情合同中的 retryable,列表摘要不承担重试授权判断。 */
async function ensureRetryableJob(jobId: number | string) {
const detail = await getJobApi(jobId);
if (detail.status !== 'failed' || detail.retryable !== true) {
message.error('当前任务详情不允许重试');
return false;
}
return true;
}
const jobColumns = [
@ -303,7 +344,22 @@ function handleSourceTableChange(pager: TablePager) {
}
/** 打开任务操作弹窗。 */
function openJobAction(record: JobSummary, action: JobAction) {
async function openJobAction(record: JobSummary, action: JobAction) {
if (action === 'retry' && !canOpenRetryJob(record)) {
message.error('当前任务状态不允许重试');
return;
}
if (action === 'cancel' && !canCancelJob(record)) {
message.error('当前任务状态不允许取消');
return;
}
if (action === 'recheck' && !canRecheckJob(record)) {
message.error('当前任务状态不需要重验');
return;
}
if (action === 'retry' && !(await ensureRetryableJob(record.jobId))) {
return;
}
jobActionForm.action = action;
jobActionForm.authorizationChecked = false;
jobActionForm.commandId = createCommandId(`job-${action}`);
@ -322,7 +378,7 @@ function handleOpenJobDetail(record: TableRecord) {
/** 表格事件包装:打开任务操作弹窗。 */
function handleOpenJobAction(record: TableRecord, action: JobAction) {
openJobAction(asJobSummary(record), action);
void openJobAction(asJobSummary(record), action);
}
/** 表格事件包装:打开来源事件详情。 */
@ -337,6 +393,21 @@ function handleOpenSourceRetry(record: TableRecord) {
/** 提交任务重验、重试或取消。 */
async function submitJobAction() {
const currentJob = findCurrentJob(jobActionForm.jobId);
if (
jobActionForm.action === 'retry' &&
(!currentJob || !canOpenRetryJob(currentJob))
) {
message.error('当前任务状态不允许重试');
return;
}
if (
jobActionForm.action === 'cancel' &&
(!currentJob || !canCancelJob(currentJob))
) {
message.error('当前任务状态不允许取消');
return;
}
if (!jobActionForm.reason.trim()) {
message.error('请填写操作原因');
return;
@ -361,9 +432,12 @@ async function submitJobAction() {
commandSubmitting.value = true;
try {
if (jobActionForm.action === 'retry') {
if (!(await ensureRetryableJob(jobActionForm.jobId))) {
return;
}
// retry OpenAPI 当前只接收 commandId;reason 仅作为 UI 人工复核留痕,不外发。
await retryJobApi(jobActionForm.jobId, {
commandId: jobActionForm.commandId,
reason: jobActionForm.reason,
});
message.success('任务重试命令已提交');
} else {
@ -382,6 +456,10 @@ async function submitJobAction() {
/** 打开来源传播重试弹窗。 */
function openSourceRetry(record: SourceEventSummary) {
if (!canRetrySourceEvent(record)) {
message.error('当前来源事件动作策略不允许重试传播');
return;
}
sourceRetryForm.commandId = createCommandId('source-retry');
sourceRetryForm.eventId = record.eventId;
sourceRetryForm.reason = '';
@ -471,13 +549,14 @@ onMounted(() => {
详情
</Button>
<Button
:disabled="!canRecheckJob(asJobSummary(record))"
type="link"
@click="handleOpenJobAction(record, 'recheck')"
>
重验
</Button>
<Button
:disabled="isCompletedStatus(record.status)"
:disabled="!canOpenRetryJob(asJobSummary(record))"
type="link"
@click="handleOpenJobAction(record, 'retry')"
>
@ -485,7 +564,7 @@ onMounted(() => {
</Button>
<Button
danger
:disabled="isCompletedStatus(record.status)"
:disabled="!canCancelJob(asJobSummary(record))"
type="link"
@click="handleOpenJobAction(record, 'cancel')"
>
@ -550,7 +629,13 @@ onMounted(() => {
<Button type="link" @click="handleOpenSourceDetail(record)">
详情
</Button>
<Button type="link" @click="handleOpenSourceRetry(record)">
<Button
:disabled="
!canRetrySourceEvent(asSourceEventSummary(record))
"
type="link"
@click="handleOpenSourceRetry(record)"
>
重试传播
</Button>
</Space>
@ -622,7 +707,7 @@ onMounted(() => {
{{ selectedSourceEvent.sourceId }}
</Descriptions.Item>
<Descriptions.Item label="动作策略">
{{ selectedSourceEvent.actionPolicy }}
{{ getActionPolicyText(selectedSourceEvent.actionPolicy) }}
</Descriptions.Item>
<Descriptions.Item label="传播状态">
{{ displayText(selectedSourceEvent.propagationStatus) }}

View File

@ -126,6 +126,7 @@ describe('muse market admin view', () => {
expect(root.textContent).toContain('审核队列');
expect(root.textContent).toContain('世界观知识库');
expect(root.textContent).toContain('不修改用户私有副本');
expect(root.textContent).not.toContain('完全下架');
app.unmount();
root.remove();

View File

@ -111,29 +111,75 @@ const appealColumns = [
];
/** 当前治理动作目标,避免把状态字段散落在多个 ref 中。 */
type ExpectedAppealStatus = Extract<
MarketAppealStatus,
'pending' | 'reviewing' | 'supplementing'
>;
type ExpectedListingStatus = Extract<MarketListingStatus, 'delisted' | 'listed'>;
type ExpectedReviewStatus = Extract<
MarketReviewStatus,
'needs_supplement' | 'pending' | 'reviewing'
>;
type DelistSubmissionScope = Exclude<
MarketGovernanceScope,
'full_delist' | 'full_recall'
>;
type PreviewSubmissionScope = Exclude<
MarketGovernanceScope,
'full_delist'
>;
type ScopeOption = {
/** 范围展示名称。 */
label: string;
/** 影响预览 OpenAPI 当前允许的治理范围。 */
value: PreviewSubmissionScope;
};
const DELIST_SCOPE_OPTIONS: ScopeOption[] = [
{ label: '停止新获取', value: 'stop_new_acquire' },
{ label: '停止新安装', value: 'stop_new_install' },
{ label: '停止新绑定', value: 'stop_new_bind' },
{ label: '停止生成使用', value: 'stop_generation' },
];
const RECALL_SCOPE_OPTIONS: ScopeOption[] = [
{ label: '停止生成使用', value: 'stop_generation' },
{ label: '完全召回', value: 'full_recall' },
];
interface MarketActionTarget {
/** 申诉 ID。 */
appealId?: string;
/** 资产 ID。 */
assetId?: string;
/** 期望申诉状态。 */
expectedAppealStatus?: Extract<
MarketAppealStatus,
'pending' | 'reviewing' | 'supplementing'
>;
expectedAppealStatus?: ExpectedAppealStatus;
/** 期望上架状态。 */
expectedListingStatus?: Extract<MarketListingStatus, 'delisted' | 'listed'>;
expectedListingStatus?: ExpectedListingStatus;
/** 期望审核状态。 */
expectedReviewStatus?: Extract<
MarketReviewStatus,
'needs_supplement' | 'pending' | 'reviewing'
>;
expectedReviewStatus?: ExpectedReviewStatus;
/** 发布申请 ID。 */
requestId?: string;
/** 展示名称。 */
title?: string;
}
/** 影响预览票据绑定信息,本地校验可避免跨资产或跨申诉串用票据。 */
interface ImpactPreviewBinding {
/** 申诉 ID,只有申诉处理场景需要绑定。 */
appealId?: string;
/** 资产 ID。 */
assetId: string;
/** 生成该票据时的动作弹窗上下文代次。 */
generation: number;
/** 预览动作。 */
actionType: MarketGovernanceActionType;
/** 预览 ID。 */
previewId: string;
/** 预览范围。 */
scope: PreviewSubmissionScope;
}
/** 市场页面加载态。 */
const loading = ref(false);
/** 发布审核队列。 */
@ -144,8 +190,16 @@ const marketAssets = ref<AdminMarketAssetSummaryVO[]>([]);
const appeals = ref<AdminAppealSummaryVO[]>([]);
/** 最近一次治理影响预览。 */
const impactPreview = ref<AdminGovernanceImpactPreviewVO>();
/** 最近一次治理影响预览的绑定元数据。 */
const impactPreviewBinding = ref<ImpactPreviewBinding>();
/** 动作弹窗是否打开。 */
const actionModalOpen = ref(false);
/** 当前影响预览请求加载态。 */
const previewing = ref(false);
/** 影响预览请求序号,只允许最新请求回写 loading 状态。 */
const previewRequestSeq = ref(0);
/** 动作弹窗上下文代次,打开、切换动作或切换范围都会让旧预览响应失效。 */
const actionGeneration = ref(0);
/** 当前动作。 */
const currentAction = ref<
'approve' | 'delist' | 'preview' | 'recall' | 'reject' | 'resolve'
@ -184,11 +238,23 @@ const summary = computed(() => {
return { blockedAssets, listedAssets, openAppeals, pendingReview };
});
/** 当前动作可选范围按最终命令合同收口,避免预览范围和提交范围漂移。 */
const scopeOptions = computed(() => {
return currentAction.value === 'recall' || actionForm.actionType === 'recall'
? RECALL_SCOPE_OPTIONS
: DELIST_SCOPE_OPTIONS;
});
/** 生成幂等命令 ID。 */
function createCommandId(prefix: string) {
return `${prefix}-${Date.now()}`;
}
/** 推进动作上下文代次,用于丢弃旧弹窗或旧动作的异步预览响应。 */
function advanceActionGeneration() {
actionGeneration.value += 1;
}
/** 把逗号分隔的证据输入转成后端需要的标签数组。 */
function splitEvidenceTags(value: string) {
return value
@ -197,6 +263,166 @@ function splitEvidenceTags(value: string) {
.filter(Boolean);
}
/** 清空当前动作的预览票据绑定,防止改动作或改范围后继续复用旧票据。 */
function clearActionPreviewBinding() {
actionForm.impactPreviewId = '';
impactPreviewBinding.value = undefined;
}
/** 按当前动作类型归一化范围选项,确保最终提交枚举和预览枚举一致。 */
function normalizeActionScope() {
if (!scopeOptions.value.some((option) => option.value === actionForm.scope)) {
actionForm.scope = scopeOptions.value[0]?.value ?? 'stop_new_acquire';
}
}
/** 切换治理动作后,旧预览票据立即失效。 */
function handleActionTypeChange(value: MarketGovernanceActionType) {
advanceActionGeneration();
actionForm.actionType = value;
normalizeActionScope();
clearActionPreviewBinding();
}
/** 切换治理范围后,旧预览票据立即失效。 */
function handleScopeChange(value: MarketGovernanceScope) {
advanceActionGeneration();
actionForm.scope = value;
clearActionPreviewBinding();
}
/** 根据申诉类型推导预览动作,申诉处理也必须生成与申诉绑定的影响预览。 */
function resolveAppealPreviewAction(row: AdminAppealSummaryVO) {
if (row.appealType === 'recall') {
return 'recall';
}
if (row.appealType === 'license_revocation') {
return 'revoke_license';
}
if (row.appealType === 'usage_impact') {
return 'stop_generation';
}
if (row.appealType === 'review_rejection') {
return 'review_rejection';
}
return 'delist';
}
/** 审核动作只允许提交非终态发布申请,避免 expectedStatus 合同错配。 */
function getExpectedReviewStatus(status: MarketReviewStatus) {
if (
status === 'needs_supplement' ||
status === 'pending' ||
status === 'reviewing'
) {
return status;
}
return undefined;
}
/** 下架只能针对已上架资产;其他终态或非上架态不构造非法 expectedStatus。 */
function getExpectedDelistStatus(status: MarketListingStatus) {
return status === 'listed' ? status : undefined;
}
/** 召回只允许从已上架或已下架状态进入,其他状态交给服务端前先在前端拦截。 */
function getExpectedRecallStatus(status: MarketListingStatus) {
if (status === 'delisted' || status === 'listed') {
return status;
}
return undefined;
}
/** 申诉处理只允许开放状态,终态申诉不能提交旧 expectedStatus。 */
function getExpectedAppealStatus(status: MarketAppealStatus) {
if (
status === 'pending' ||
status === 'reviewing' ||
status === 'supplementing'
) {
return status;
}
return undefined;
}
function canReviewRequest(row: AdminPublishRequestSummaryVO) {
return Boolean(getExpectedReviewStatus(row.status));
}
function canDelistAsset(row: AdminMarketAssetSummaryVO) {
return Boolean(getExpectedDelistStatus(row.listingStatus));
}
function canRecallAsset(row: AdminMarketAssetSummaryVO) {
return Boolean(getExpectedRecallStatus(row.listingStatus));
}
function canResolveAppeal(row: AdminAppealSummaryVO) {
return Boolean(getExpectedAppealStatus(row.status));
}
/** 预览接口不支持 full_delist,不能用其他范围票据替代完全下架票据。 */
function toPreviewScope(scope: MarketGovernanceScope): PreviewSubmissionScope | undefined {
return scope === 'full_delist' ? undefined : scope;
}
/** 下架提交只允许使用已经按相同范围完成影响预览的安全下架范围。 */
function toDelistScope(scope: MarketGovernanceScope): DelistSubmissionScope | undefined {
if (scope === 'full_delist' || scope === 'full_recall') {
return undefined;
}
return scope;
}
/** 资产直达治理动作不能借用其他影响动作,申诉处理仍按申诉类型单独映射。 */
function getLockedAssetActionType() {
if (currentAction.value === 'delist') {
return 'delist';
}
if (currentAction.value === 'recall') {
return 'recall';
}
return undefined;
}
/** 影响预览必须和当前资产、动作、范围以及申诉上下文一致。 */
function validatePreviewBinding() {
const binding = impactPreviewBinding.value;
if (!binding || binding.previewId !== actionForm.impactPreviewId) {
return false;
}
if (binding.generation !== actionGeneration.value || !actionModalOpen.value) {
return false;
}
if (binding.assetId !== actionTarget.value.assetId) {
return false;
}
if (currentAction.value === 'resolve') {
const previewScope = toPreviewScope(actionForm.scope);
return (
!!previewScope &&
binding.appealId === actionTarget.value.appealId &&
binding.actionType === actionForm.actionType &&
binding.scope === previewScope
);
}
if (
currentAction.value === 'delist' ||
currentAction.value === 'recall'
) {
const lockedActionType = getLockedAssetActionType();
const previewScope = toPreviewScope(actionForm.scope);
return (
!!previewScope &&
!!lockedActionType &&
actionForm.actionType === lockedActionType &&
binding.actionType === lockedActionType &&
binding.scope === previewScope
);
}
return true;
}
/** 加载审核队列。 */
async function loadReviewQueue() {
const result = await getMarketPublishRequestListApi({
@ -242,13 +468,20 @@ function openReviewAction(
action: 'approve' | 'reject',
row: AdminPublishRequestSummaryVO,
) {
const expectedReviewStatus = getExpectedReviewStatus(row.status);
if (!expectedReviewStatus) {
message.error('当前发布申请状态不能执行审核动作');
return;
}
advanceActionGeneration();
currentAction.value = action;
actionTarget.value = {
assetId: row.assetId,
expectedReviewStatus: row.status as MarketActionTarget['expectedReviewStatus'],
expectedReviewStatus,
requestId: row.requestId,
title: row.assetName,
};
clearActionPreviewBinding();
actionForm.evidenceTags = '';
actionForm.note = '';
actionForm.reason = '';
@ -261,15 +494,24 @@ function openAssetAction(
action: 'delist' | 'preview' | 'recall',
row: AdminMarketAssetSummaryVO,
) {
const expectedListingStatus =
action === 'delist'
? getExpectedDelistStatus(row.listingStatus)
: getExpectedRecallStatus(row.listingStatus);
if (action !== 'preview' && !expectedListingStatus) {
message.error('当前资产状态不能执行该市场治理动作');
return;
}
advanceActionGeneration();
currentAction.value = action;
actionTarget.value = {
assetId: row.assetId,
expectedListingStatus: row.listingStatus as MarketActionTarget['expectedListingStatus'],
expectedListingStatus,
title: row.name,
};
clearActionPreviewBinding();
actionForm.actionType = action === 'recall' ? 'recall' : 'delist';
actionForm.basis = '';
actionForm.impactPreviewId = impactPreview.value?.previewId ?? '';
actionForm.reason = '';
actionForm.scope = action === 'recall' ? 'full_recall' : 'stop_new_acquire';
actionModalOpen.value = true;
@ -277,17 +519,26 @@ function openAssetAction(
/** 打开申诉处理动作。 */
function openAppealAction(row: AdminAppealSummaryVO) {
const expectedAppealStatus = getExpectedAppealStatus(row.status);
if (!expectedAppealStatus) {
message.error('当前申诉状态不能继续处理');
return;
}
advanceActionGeneration();
currentAction.value = 'resolve';
actionTarget.value = {
appealId: row.appealId,
assetId: row.assetId,
expectedAppealStatus: row.status as MarketActionTarget['expectedAppealStatus'],
expectedAppealStatus,
title: row.assetName,
};
clearActionPreviewBinding();
actionForm.actionType = resolveAppealPreviewAction(row);
actionForm.basis = '';
actionForm.evidenceTags = '';
actionForm.impactPreviewId = impactPreview.value?.previewId ?? '';
actionForm.reason = '';
actionForm.resolution = 'maintained';
actionForm.scope = actionForm.actionType === 'recall' ? 'full_recall' : 'stop_new_acquire';
actionModalOpen.value = true;
}
@ -296,18 +547,58 @@ async function handlePreviewImpact() {
if (!actionTarget.value.assetId) {
return;
}
const previewScope =
actionForm.scope === 'full_delist' ? 'stop_new_acquire' : actionForm.scope;
impactPreview.value = await previewMarketGovernanceImpactApi(
actionTarget.value.assetId,
{
actionType: actionForm.actionType,
commandId: createCommandId('market-impact-preview'),
scope: previewScope as Exclude<MarketGovernanceScope, 'full_delist'>,
},
);
actionForm.impactPreviewId = impactPreview.value.previewId;
message.success('市场治理影响预览已生成');
normalizeActionScope();
const requestedSeq = previewRequestSeq.value + 1;
previewRequestSeq.value = requestedSeq;
const requestedGeneration = actionGeneration.value;
const requestedCurrentAction = currentAction.value;
const requestedAssetId = actionTarget.value.assetId;
const requestedAppealId = actionTarget.value.appealId;
const requestedActionType = actionForm.actionType;
const previewScope = toPreviewScope(actionForm.scope);
if (!previewScope) {
clearActionPreviewBinding();
message.error('当前治理范围暂不支持影响预览,请重新选择范围');
return;
}
previewing.value = true;
try {
const result = await previewMarketGovernanceImpactApi(
requestedAssetId,
{
actionType: requestedActionType,
commandId: createCommandId('market-impact-preview'),
scope: previewScope,
},
);
if (
previewRequestSeq.value !== requestedSeq ||
actionGeneration.value !== requestedGeneration ||
currentAction.value !== requestedCurrentAction ||
!actionModalOpen.value ||
actionTarget.value.assetId !== requestedAssetId ||
actionTarget.value.appealId !== requestedAppealId ||
actionForm.actionType !== requestedActionType ||
toPreviewScope(actionForm.scope) !== previewScope
) {
return;
}
impactPreview.value = result;
impactPreviewBinding.value = {
appealId: requestedAppealId,
assetId: requestedAssetId,
generation: requestedGeneration,
actionType: requestedActionType,
previewId: result.previewId,
scope: previewScope,
};
actionForm.impactPreviewId = result.previewId;
message.success('市场治理影响预览已生成');
} finally {
if (previewRequestSeq.value === requestedSeq) {
previewing.value = false;
}
}
}
/** 提交审核、治理或申诉动作。 */
@ -326,11 +617,20 @@ async function handleSubmitAction() {
message.error('审核通过需要校验结果引用');
return;
}
if (!actionTarget.value.expectedReviewStatus) {
message.error('当前发布申请状态不能审核通过');
return;
}
if (!actionForm.note.trim()) {
message.error('审核通过必须填写可审计原因');
return;
}
await approveMarketPublishRequestApi(actionTarget.value.requestId, {
commandId: createCommandId('market-review-approve'),
evidenceTags: splitEvidenceTags(actionForm.evidenceTags),
expectedStatus: actionTarget.value.expectedReviewStatus ?? 'pending',
note: actionForm.note || '审核材料完整',
expectedStatus: actionTarget.value.expectedReviewStatus,
// OpenAPI 当前只支持 note 字段,前端用 note 承载审核通过原因。
note: actionForm.note,
validationResultId: actionForm.validationResultId,
});
message.success('发布申请已审核通过');
@ -341,10 +641,14 @@ async function handleSubmitAction() {
message.error('审核驳回需要校验结果引用');
return;
}
if (!actionTarget.value.expectedReviewStatus) {
message.error('当前发布申请状态不能审核驳回');
return;
}
await rejectMarketPublishRequestApi(actionTarget.value.requestId, {
commandId: createCommandId('market-review-reject'),
evidenceTags: splitEvidenceTags(actionForm.evidenceTags),
expectedStatus: actionTarget.value.expectedReviewStatus ?? 'pending',
expectedStatus: actionTarget.value.expectedReviewStatus,
reason: actionForm.reason,
validationResultId: actionForm.validationResultId,
});
@ -356,13 +660,33 @@ async function handleSubmitAction() {
message.error('下架前必须先生成影响预览');
return;
}
if (!actionTarget.value.expectedListingStatus) {
message.error('当前资产状态不能下架');
return;
}
if (actionTarget.value.expectedListingStatus !== 'listed') {
message.error('下架只能针对已上架资产');
return;
}
if (actionForm.actionType !== 'delist') {
message.error('下架提交只能使用下架影响预览,请重新生成');
return;
}
if (!validatePreviewBinding()) {
message.error('影响预览与当前资产、动作或范围不匹配,请重新生成');
return;
}
const delistScope = toDelistScope(actionForm.scope);
if (!delistScope) {
message.error('当前下架范围暂不支持提交,请重新选择范围并生成影响预览');
return;
}
await delistMarketAssetApi(actionTarget.value.assetId, {
commandId: createCommandId('market-asset-delist'),
expectedStatus: 'listed',
expectedStatus: actionTarget.value.expectedListingStatus,
impactPreviewId: actionForm.impactPreviewId,
reason: actionForm.reason,
scope:
actionForm.scope === 'full_recall' ? 'full_delist' : actionForm.scope,
scope: delistScope,
});
message.success('市场资产下架命令已提交');
}
@ -376,10 +700,22 @@ async function handleSubmitAction() {
message.error('召回需要影响预览和合规依据');
return;
}
if (!actionTarget.value.expectedListingStatus) {
message.error('当前资产状态不能召回');
return;
}
if (actionForm.actionType !== 'recall') {
message.error('召回提交只能使用召回影响预览,请重新生成');
return;
}
if (!validatePreviewBinding()) {
message.error('影响预览与当前资产、动作或范围不匹配,请重新生成');
return;
}
await recallMarketAssetApi(actionTarget.value.assetId, {
basis: actionForm.basis,
commandId: createCommandId('market-asset-recall'),
expectedStatus: actionTarget.value.expectedListingStatus ?? 'listed',
expectedStatus: actionTarget.value.expectedListingStatus,
impactPreviewId: actionForm.impactPreviewId,
reason: actionForm.reason,
scope:
@ -395,10 +731,18 @@ async function handleSubmitAction() {
message.error('申诉处理需要影响预览引用');
return;
}
if (!actionTarget.value.expectedAppealStatus) {
message.error('当前申诉状态不能处理');
return;
}
if (!validatePreviewBinding()) {
message.error('影响预览与当前申诉、资产、动作或范围不匹配,请重新生成');
return;
}
await resolveMarketAppealApi(actionTarget.value.appealId, {
commandId: createCommandId('market-appeal-resolve'),
evidenceTags: splitEvidenceTags(actionForm.evidenceTags),
expectedStatus: actionTarget.value.expectedAppealStatus ?? 'reviewing',
expectedStatus: actionTarget.value.expectedAppealStatus,
impactPreviewId: actionForm.impactPreviewId,
reason: actionForm.reason,
resolution: actionForm.resolution,
@ -494,6 +838,9 @@ onMounted(loadMarketDashboard);
<template v-else-if="column.dataIndex === 'actions'">
<a-space>
<a-button
:disabled="
!canReviewRequest(record as AdminPublishRequestSummaryVO)
"
size="small"
@click="
openReviewAction(
@ -506,6 +853,9 @@ onMounted(loadMarketDashboard);
</a-button>
<a-button
danger
:disabled="
!canReviewRequest(record as AdminPublishRequestSummaryVO)
"
size="small"
@click="
openReviewAction(
@ -555,6 +905,9 @@ onMounted(loadMarketDashboard);
<template v-else-if="column.dataIndex === 'actions'">
<a-space>
<a-button
:disabled="
!canDelistAsset(record as AdminMarketAssetSummaryVO)
"
size="small"
@click="
openAssetAction(
@ -578,6 +931,9 @@ onMounted(loadMarketDashboard);
</a-button>
<a-button
danger
:disabled="
!canRecallAsset(record as AdminMarketAssetSummaryVO)
"
size="small"
@click="
openAssetAction(
@ -616,6 +972,7 @@ onMounted(loadMarketDashboard);
</template>
<template v-else-if="column.dataIndex === 'actions'">
<a-button
:disabled="!canResolveAppeal(record as AdminAppealSummaryVO)"
size="small"
@click="openAppealAction(record as AdminAppealSummaryVO)"
>
@ -667,9 +1024,15 @@ onMounted(loadMarketDashboard);
v-if="currentAction === 'preview' || currentAction === 'recall'"
label="影响动作"
>
<a-select v-model:value="actionForm.actionType">
<a-select
v-model:value="actionForm.actionType"
@change="(value) => handleActionTypeChange(String(value) as MarketGovernanceActionType)"
>
<a-select-option value="delist">下架</a-select-option>
<a-select-option value="recall">召回</a-select-option>
<a-select-option value="review_rejection">
审核申诉恢复
</a-select-option>
<a-select-option value="stop_generation">
停止生成
</a-select-option>
@ -684,19 +1047,17 @@ onMounted(loadMarketDashboard);
"
label="治理范围"
>
<a-select v-model:value="actionForm.scope">
<a-select-option value="stop_new_acquire">
停止新获取
<a-select
v-model:value="actionForm.scope"
@change="(value) => handleScopeChange(String(value) as MarketGovernanceScope)"
>
<a-select-option
v-for="option in scopeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
<a-select-option value="stop_new_install">
停止新安装
</a-select-option>
<a-select-option value="stop_new_bind">停止新绑定</a-select-option>
<a-select-option value="stop_generation">
停止生成使用
</a-select-option>
<a-select-option value="full_delist">完全下架</a-select-option>
<a-select-option value="full_recall">完全召回</a-select-option>
</a-select>
</a-form-item>
<a-form-item
@ -712,7 +1073,7 @@ onMounted(loadMarketDashboard);
<a-textarea
v-model:value="actionForm.note"
:rows="3"
placeholder="说明审核材料完整性和通过理由"
placeholder="OpenAPI 当前用 note 承载审核通过原因"
/>
</a-form-item>
<a-form-item
@ -723,10 +1084,16 @@ onMounted(loadMarketDashboard);
"
label="影响预览 ID"
>
<a-input
v-model:value="actionForm.impactPreviewId"
placeholder="先执行影响预览后自动带入"
/>
<a-space direction="vertical" class="w-full">
<a-input
v-model:value="actionForm.impactPreviewId"
placeholder="先执行影响预览后自动带入"
readonly
/>
<a-button :loading="previewing" @click="handlePreviewImpact">
生成当前对象影响预览
</a-button>
</a-space>
</a-form-item>
<a-form-item v-if="currentAction === 'recall'" label="召回依据">
<a-input