feat(mvp): 收束1.0.0线A交付闭环
Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2dde0a45ef
commit
3585219637
@ -40,7 +40,7 @@
|
||||
| [`knowledge/module-reality-baseline.md`](knowledge/module-reality-baseline.md) | ✅ | 模块**真实**现状指针(指向现状基线 spec + 对抗复盘)+ 基建复利进展 |
|
||||
| [`knowledge/tech-decisions.md`](knowledge/tech-decisions.md) | ✅ | 已确认架构决策蒸馏(指向 `架构-03-ADR` + CLAUDE.md 决策表) |
|
||||
| [`knowledge/external-deps-and-gotchas.md`](knowledge/external-deps-and-gotchas.md) | ✅ | 外部依赖(New-API/RAGFlow 地址·凭据来源)+ 集成兼容坑 + 前端/构建坑(蒸馏自已清理 memorys) |
|
||||
| [`knowledge/market-install-downstream-materialization.md`](knowledge/market-install-downstream-materialization.md) | ✅ | market 安装下游物化机制 + KB 类型 D0-fork 私有隔离(四单元闭环、进程内事件/跨 BC 读端口/kb_id 去污染;agent 物化·召回回滚·副本回收为开放项) |
|
||||
| [`knowledge/market-install-downstream-materialization.md`](knowledge/market-install-downstream-materialization.md) | ✅ | market 安装下游物化机制 + KB 类型 D0-fork 私有隔离(四单元闭环、进程内事件/跨 BC 读端口/kb_id 去污染;agent handoff 物化与 KB 召回阻断已闭环,副本回收等后置) |
|
||||
|
||||
**skills/**
|
||||
|
||||
|
||||
@ -37,7 +37,34 @@
|
||||
|
||||
## 四、跑 P1R real-PG 集成测试(IT)的配方 + 本机代理坑(2026-06-14 实证)
|
||||
|
||||
**结论先行**:P1 后端 `P1r*IT`(completed-approval / Flyway 迁移 / events-publish outbox / live-acceptance)**可在共享远端 PG 上真跑验证**。**2026-06-26 全量 50 个 `P1r*IT` 全绿(199 用例 0F/0E,1 Skipped = RagFlow GraphRAG attribution 因 `..._GRAPHRAG_ATTRIBUTION_READY=false` 而 assumeTrue 跳过)**;4 个 live-acceptance(NewApi/RagFlow/AiRuntime/KnowledgeRuntime)载外部 env + `MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 真打 New-API/RAGFlow 全绿。本轮修 2 个**测试上下文落后产品演进**的预存红:① `P1rContentPlanningCompletedApprovalIT` 的 `TARGET_VERSION=21` 停在 V21,但 `savePlanningItem→captureFieldSnapshot` 已依赖 V25 新增的 `muse_content_planning_field_snapshot` 表(commit 94a2379)→ 写入 `relation does not exist` 500;**修=推进 TARGET_VERSION 21→31 + migrationsExecuted 断言 21→31**(与 `P1rContentCoreCompletedApprovalIT` V30、`P1rAiRuntimeEndToEndLiveAcceptanceIT` V31 同向,核心断言不放宽)。② live-acceptance HTTP 代理坑(见下条)。**经验**:`*CompletedApprovalIT` 各自硬编 `TARGET_VERSION`,只迁到其所测路径需要的版本(V14~V31 混杂);产品给某 service 新增依赖表后,对应 IT 的 target 须跟进,否则该 IT(且仅该 IT)红——其他停在低版本的 IT 不受影响是因其路径不碰新表。此前 `P1rKnowledgeFlywayMigrationIT` 硬编码版本断言随 schema 增长失效(V14→V21→V23 复发两次)**已修为动态**:读本次 Flyway `MigrateResult.targetSchemaVersion/migrationsExecuted` 自适应(commit 4d46d7a)。
|
||||
**E0 分层入口(2026-06-27)**:默认 CI 只跑纯本地层,真实 PG / live 验收统一走脚本,避免把台账门禁、默认跳过或 mock 绿混成真验证。判据见 [`docs/mvp/1.0.0-真验证清单.md`](../../docs/mvp/1.0.0-真验证清单.md)。
|
||||
|
||||
```bash
|
||||
# 纯本地门禁:coverage/RealApiGate/ArchUnit/契约结构,不连 DB/外部服务
|
||||
bash muse-cloud/scripts/run-p1r-verification.sh local
|
||||
|
||||
# 真实 PG 层:非 live P1r*IT,会 flyway.clean() 指定 _test 库
|
||||
set -a && . "$HOME/.config/muse-repo/infra.env" && set +a
|
||||
P1R_FLYWAY_URL='jdbc:postgresql://100.64.0.8:5433/<专属_test库>' \
|
||||
P1R_FLYWAY_USER=root \
|
||||
bash muse-cloud/scripts/run-p1r-verification.sh real-pg
|
||||
|
||||
# 外部 live 层:New-API/RAGFlow + runtime live,必须显式 opt-in
|
||||
set -a
|
||||
. "$HOME/.config/muse-repo/infra.env"
|
||||
. "muse-cloud/scripts/dev/p1r-external-acceptance.env"
|
||||
set +a
|
||||
export MUSE_P1R_EXTERNAL_ACCEPTANCE=true
|
||||
P1R_FLYWAY_URL='jdbc:postgresql://100.64.0.8:5433/<专属_test库>' \
|
||||
P1R_FLYWAY_USER=root \
|
||||
bash muse-cloud/scripts/run-p1r-verification.sh external-live
|
||||
```
|
||||
|
||||
脚本硬闸:真实 database name 必须以 `_test` 结尾;目标 `_test` 库必须经人类 DDL 闸门预建,脚本只做存在性预检、不自动 `CREATE DATABASE`;JDBC URL 不得携带 `password/token/secret/api-key` query;密码只走 `P1R_FLYWAY_PASSWORD` 或 `MUSE_POSTGRES_PASSWORD`;输出只打印脱敏 URL。
|
||||
|
||||
**结论先行**:P1 后端 `P1r*IT`(completed-approval / Flyway 迁移 / events-publish outbox / live-acceptance)**可在共享远端 PG 上真跑验证**。**2026-06-27 RC fresh real-PG 层**:`bash muse-cloud/scripts/run-p1r-verification.sh real-pg` 真连 `_test` 库、显式排除 live 外部链路,194 用例 0F/0E/0S,`BUILD SUCCESS`;缺 URL、非 `_test`、URL 携带敏感 query、缺目标库、live 未 opt-in 均会在 Maven 前失败。**2026-06-27 RC fresh external-live 层**:`bash muse-cloud/scripts/run-p1r-verification.sh external-live` server live 8/0F/0E/0S, module live 4/0F/0E/1S,`BUILD SUCCESS`;New-API completion、AI runtime usage/quota/attribution、RAGFlow health/upload/parse/chunks/retrieval、knowledge runtime、market KB fork 物化与 after-commit 均真打通过,唯一 skip=GraphRAG attribution 未配置,不得计为 passed。本轮修 2 个**测试上下文落后产品演进**的预存红:① `P1rContentPlanningCompletedApprovalIT` 的 `TARGET_VERSION=21` 停在 V21,但 `savePlanningItem→captureFieldSnapshot` 已依赖 V25 新增的 `muse_content_planning_field_snapshot` 表(commit 94a2379)→ 写入 `relation does not exist` 500;**修=推进 TARGET_VERSION 21→31 + migrationsExecuted 断言 21→31**(与 `P1rContentCoreCompletedApprovalIT` V30、`P1rAiRuntimeEndToEndLiveAcceptanceIT` V31 同向,核心断言不放宽)。② live-acceptance HTTP 代理坑(见下条)。**经验**:`*CompletedApprovalIT` 各自硬编 `TARGET_VERSION`,只迁到其所测路径需要的版本(V14~V31 混杂);产品给某 service 新增依赖表后,对应 IT 的 target 须跟进,否则该 IT(且仅该 IT)红——其他停在低版本的 IT 不受影响是因其路径不碰新表。此前 `P1rKnowledgeFlywayMigrationIT` 硬编码版本断言随 schema 增长失效(V14→V21→V23 复发两次)**已修为动态**:读本次 Flyway `MigrateResult.targetSchemaVersion/migrationsExecuted` 自适应(commit 4d46d7a)。
|
||||
|
||||
**▲ stale jar / argLine 假红(2026-06-27 RC 实证):**手工绕过 `run-p1r-verification.sh` 直跑 `muse-server` IT 时,不要只在 Maven 顶层传 `-Dp1r.flyway.url` 或代理清理参数。surefire forked JVM 可能拿不到这些系统属性,导致 JDBC 仍走旧代理或缺 flyway 参数;应把 `p1r.flyway.url/user/locations` 和代理清理一起放入 `-DargLine`。另外,改过任一上游 module 后跑 `muse-server` IT 必须加 `-am` 或先 `mvn install`,否则 server 会链接本地仓库旧 jar,表现为“代码已改但 IT 仍跑旧行为”的 stale-jar 假红。本轮 `P1rMarketGovernanceWriteCompletedApprovalIT` 就因未 `-am` 先复现旧 recall 断言,加 `-am` 后 targeted 6/0、全量 real-PG 194/0。
|
||||
|
||||
**▲ 最大坑(排查极久):本机 env `HTTP_PROXY=HTTPS_PROXY=http://127.0.0.1:7897` → JVM 取 `socksProxyHost=127.0.0.1:7897`,把所有 Java socket 走 SOCKS;转发 HTTP 正常,但破坏 PostgreSQL 原始线协议(发完 StartupMessage 即被 EOF,认证前关闭,极像 pg_hba/密码错)。** `nc`/`curl` 直连正常,只有 JDBC/JVM 中招;且 `env -u HTTP_PROXY` 清不掉(socksProxyHost 由 JVM 层注入)。
|
||||
- 判定 PG 真可达:`printf '\x00\x00\x00\x08\x04\xd2\x16\x2f' | nc -w5 100.64.0.8 5433 | xxd` 回 `N` 即 PG 在说话(纯 nc 直连,不经代理)。
|
||||
@ -47,6 +74,8 @@
|
||||
- **修复:live-acceptance 的 argLine 须同时清 HTTP 代理**:`-DargLine='-DsocksProxyHost= -DsocksProxyPort= -Dhttp.proxyHost= -Dhttp.proxyPort= -Dhttps.proxyHost= -Dhttps.proxyPort= -Djava.net.useSystemProxies=false'`。本轮加此参后 4 个 live-acceptance(NewApi 2/2、RagFlow 2/2[1 GraphRAG skip]、AiRuntime 3/3、KnowledgeRuntime 3/3)全绿。
|
||||
- 判定外部服务真可达(绕本机代理):`curl -s --noproxy '*' http://100.64.0.8/v1/system/healthz`(RAGFlow 回 `{"status":"ok",...}`)、`curl -s --noproxy '*' -X POST http://100.64.0.8:3000/v1/chat/completions -H "Authorization: Bearer <token>" -d '{"model":"MiniMax-M2.5","messages":[{"role":"user","content":"ping"}],"max_tokens":5}'`(回 200 + completion)。两者 200 而 live IT 报 unavailable/5xx → 必是 JVM 代理坑,非外部波动。
|
||||
|
||||
**▲ New-API 管理口 live 验收补充(2026-06-27 实证,E5 member):** New-API 管理 API 与 OpenAI 兼容 completion 不是同一类调用,Account 侧 `RealNewApiAccountFacade` 会调用 `/api/user/search`、`/api/user/`、`/api/user/{id}` 创建/刷新用户与配额。管理口鉴权从 `NEW_API_SYSTEM_MANAGEMENT_TOKEN` 读取,`New-Api-User` 必须是数值管理员用户 id,两者都只走 env,不得写入仓库/日志。排查时发现即便清了 shell 代理,Java/Python 仍可能读取 macOS 系统代理;`curl --noproxy '*'` 直连成功但 Java `HttpClient` 失败时,优先检查是否显式设置 `Proxy.NO_PROXY`/`ProxySelector`。`RealNewApiAccountFacadeLiveAcceptanceIT` 必须显式 `MUSE_ACCOUNT_NEW_API_LIVE_ACCEPTANCE=true` 才计为 live passed;默认 skip 是诚实跳过,不能当绿证。
|
||||
|
||||
**跑单个 IT 的配方(已验证)**:
|
||||
```bash
|
||||
export MUSE_POSTGRES_PASSWORD=<root 密码;来自 infra.env / 用户提供,勿入库>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# knowledge:市场安装下游物化 + D0-fork 知识库隔离
|
||||
|
||||
> **类型**:事实与蓝图(knowledge) · **范围**:market 安装如何把市场资产物化成下游可用实体,以及知识库类型资产的私有内容隔离方案 · **状态**:KB 物化 D0-fork 已实现并真验收(2026-06-26),agent / 副本回收 / 召回回滚等为开放项
|
||||
> **类型**:事实与蓝图(knowledge) · **范围**:market 安装如何把市场资产物化成下游可用实体,以及知识库类型资产的私有内容隔离方案 · **状态**:KB 物化 D0-fork 已实现并真验收(2026-06-26);agent handoff 物化与 KB 召回阻断已实现并真 e2e 验收(2026-06-27);副本回收等为开放项
|
||||
> **关联红线**:[`../rules/security-and-reliability.md`](../rules/security-and-reliability.md)(私有不泄露须物理隔离、forkStatus fail-closed、kb_id 不存 assetId)、[`../rules/bc-boundaries.md`](../rules/bc-boundaries.md)(跨域经 -api 端口)
|
||||
|
||||
本文件解释一个长期被忽视的事实:**市场"安装"在很长一段时间里只是一笔账,并不真正让安装的东西能用**;以及为知识库类型资产补上这条下游链路时,如何顺带堵住一个真实的越权口子。它是 market 与 knowledge 之间这道接缝的单一归属文档,其他地方只做指针引用。
|
||||
@ -13,7 +13,7 @@ market 的安装流程原本只写两样东西——`muse_market_installation`
|
||||
|
||||
第一个是**知识库断点**。安装一个 KB 类型资产后,本地没有对应的 `muse_knowledge_base` 行,也没有可检索的 RAGFlow dataset。于是当用户把它绑到作品、发起检索时,检索链路走到第四道门 `selectActiveDatasetByKbId` 拿不到 dataset,这个来源就被静默地以 `no_dataset` 理由省略掉了——检索看起来正常返回,实际上这个知识库从未参与。更隐蔽的是,绑定时把 `binding.kb_id` 写成了市场资产 id 而不是本地知识库主键,导致这个字段从源头就被污染,后续任何按 kbId 的查询都注定落空。
|
||||
|
||||
第二个是**agent 断点**。安装 agent 类资产后,虽然授权槽位放宽了,但 AI 运行时的 `requireVisibleAgent` 仍会拒绝裸引用一个没有本地实体的 agent。本期只解决了知识库断点,agent 断点留作后续。
|
||||
第二个是**agent 断点**。安装 agent 类资产后,虽然授权槽位放宽了,但 AI 运行时的 `requireVisibleAgent` 仍会拒绝裸引用一个没有本地实体的 agent。这个断点已在 E4 补齐:market agent handoff 只把 assetId/token 交给目标 owner,AI 后端按 market asset `source_id` 解析发布者 agent,再物化为安装者本地 user agent,槽位绑定指向本地副本,运行时自然按本地可见 agent 过门。活体 e2e 已证明该槽位可创建并完成真实 AI task。
|
||||
|
||||
## 二、真正的越权:不是安装者之间,而是安装者读到发布者私有内容
|
||||
|
||||
@ -47,9 +47,10 @@ market 的安装流程原本只写两样东西——`muse_market_installation`
|
||||
|
||||
**kb_id 去污染是检索能否命中的命门**。市场 KB 绑定请求里的 sourceId 是市场资产 id,不是本地 kbId。一旦把它当主键写进 binding.kb_id,后续按 kbId 的检索查询永远落空、表现为静默 no_dataset。所以 user/global KB 沿用 sourceId 即本地主键,market KB 则在预检阶段留空、在物化后回填本地新建的 kbId。这条已上升为红线(见 security-and-reliability.md)。
|
||||
|
||||
## 五、诚实遗留(开放项,未闭环)
|
||||
**召回只发布事实,停用由目标 owner 自治执行**。market 召回资产时只拥有市场治理事实,不能直接改 knowledge/ai/content 的本地投影。E4 以后,`recallAsset` 在同事务写 market source status 记录并发布 `MarketAssetSourceStatusChangedEvent`;Knowledge 用 AFTER_COMMIT 监听该事件,按本域模型把已物化 installed_ref KB 的 projection 改成 `recalled/blocked`。检索 API 的来源状态门已能据此把该 KB 作为 omitted source 返回,不再送进 RAGFlow。
|
||||
|
||||
## 五、仍未闭环
|
||||
|
||||
- **召回回滚未触达 installed_ref**。market 召回资产时只写了源状态 blocked 的传播记录,但 knowledge 侧没有召回的消费者,已物化的 installed_ref KB 和副本在召回后不会被回收或停用。这是一个开放项,需要补 knowledge 侧对召回/下架事件的消费。
|
||||
- **安装侧物化的端到端 e2e 待补**。现有 IT 覆盖了后端链路,但前端 global-setup 的物化 e2e fixture 还没补齐。
|
||||
- **副本资源回收待做**。资产被删/版本更替后,旧的公开副本 dataset 没有清理路径。
|
||||
- **下架/撤权的细粒度补偿仍待设计**。E4 已覆盖 recall→installed_ref 停检索的合规底线;delist/revoke 的差异化策略、已运行任务补偿、生产者自助入口仍后置。
|
||||
- **metadata_filter 字段 bug 待修(已知、刻意未依赖)**。代码里发的是 `metadata_filter`,而 RAGFlow 官方契约是 `metadata_condition`,字段名不匹配被 RAGFlow 静默忽略。D0-fork 刻意不依赖运行时 metadata 过滤(改用物理隔离),所以这个 bug 目前潜伏不影响隔离;但它仍是一个待修的真实缺陷。详见 [`external-deps-and-gotchas.md`](external-deps-and-gotchas.md) 的 RAGFlow 集成坑。
|
||||
|
||||
@ -17,4 +17,4 @@
|
||||
- BC 边界 ArchUnit 门 → [`../rules/bc-boundaries.md`](../rules/bc-boundaries.md)(已验证绿)。
|
||||
- 契约先行门(Flyway 卫生 + OpenAPI 结构)→ [`../rules/contract-first.md`](../rules/contract-first.md)(已验证绿)。
|
||||
- CI 真跑测试 + JDK21 + 门禁去硬编码 → P0(见 [`../rules/verification-and-anti-false-green.md`](../rules/verification-and-anti-false-green.md))。
|
||||
- **market 安装物化断层(已部分补齐)**:基线"市场生产侧缺失"之外另有一层后端事实——安装原本只写安装记录 + license 投影、不物化下游(检索 `no_dataset` 静默省略、`binding.kb_id` 被污染存 assetId)。知识库类型已补完整链路并真验收(D0-fork:上架 fork 公开副本隔离发布者私有内容);机制、四单元闭环与开放项(agent 物化 / 召回回滚 / 副本回收)见 [`market-install-downstream-materialization.md`](market-install-downstream-materialization.md)。
|
||||
- **market 安装物化断层(核心已补齐)**:基线"市场生产侧缺失"之外另有一层后端事实——安装原本只写安装记录 + license 投影、不物化下游(检索 `no_dataset` 静默省略、`binding.kb_id` 被污染存 assetId)。知识库类型已补完整链路并真验收(D0-fork:上架 fork 公开副本隔离发布者私有内容);agent handoff 物化与 KB 召回阻断已在 E4 真 e2e 验收。机制、四单元闭环与剩余开放项(副本回收/下架撤权细补偿)见 [`market-install-downstream-materialization.md`](market-install-downstream-materialization.md)。
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
## 二、机械门禁(不是自觉)
|
||||
|
||||
- 测试:`muse-server/src/test/java/cn/iocoder/muse/server/framework/arch/BcBoundaryArchTest.java`(ArchUnit 1.3.0),含两条通用规则:`no_business_bc_may_depend_on_another_bc_dal`(禁跨域 `.dal`)+ `no_business_bc_may_depend_on_another_bc_application`(禁跨域 `.application`,2026-06-17 新增,见 §四)。
|
||||
- CI:随 `muse-cloud/.github/workflows/maven.yml`(P0 已打开测试门、JDK21)在 PR/push 阻断。
|
||||
- CI:随仓库根 `.github/workflows/maven.yml`(P0 已打开测试门、JDK21)在 PR/push 阻断。
|
||||
- 任何**新增**跨域 DAL / application 依赖 → 测试红 → 阻断合入。
|
||||
|
||||
---
|
||||
|
||||
@ -29,10 +29,12 @@
|
||||
|
||||
## 三、机械门禁(不是自觉)
|
||||
|
||||
- 测试:`muse-server/src/test/java/cn/iocoder/muse/server/framework/contract/ContractFirstGateTest.java`,随 CI([`maven.yml`](../../muse-cloud/.github/workflows/maven.yml),P0 已打开测试门 + JDK21)在 PR/push 阻断。两条:
|
||||
- 测试:`muse-server/src/test/java/cn/iocoder/muse/server/framework/contract/ContractFirstGateTest.java`,随 CI([`maven.yml`](../../.github/workflows/maven.yml),P0 已打开测试门 + JDK21)在 PR/push 阻断。四类:
|
||||
1. **Flyway 迁移卫生**:版本号唯一、从 1 连续无缺口、命名 `V<版本>__<描述>.sql`。重复/缺口/乱名 → 红。
|
||||
2. **OpenAPI 契约存在性+结构**:必备业务域(account/ai/content/events/knowledge/market/meta)各有可解析的 `openapi.yaml`,含 `openapi/info/paths` 顶层键且为 OAS3。被删/写坏 → 红。
|
||||
- 已验证:2026-06-14 JDK21 全 reactor `clean test`,`ContractFirstGateTest` Tests run: 2, Failures: 0(证据见交付报告)。
|
||||
3. **OpenAPI diff workflow 位置**:必须在仓库根 `.github/workflows/openapi-diff.yml`,不得回流到子目录。
|
||||
4. **Maven CI workflow 位置与纯层口径**:必须在仓库根 `.github/workflows/maven.yml`,不得跳测试,不得在默认 CI 接 real-PG/live 凭据。
|
||||
- 已验证:2026-06-14 JDK21 全 reactor `clean test` 曾通过;2026-06-27 E0 后该门禁扩为 4 类,以当前 CI / 本地复跑结果为准。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -46,5 +46,6 @@
|
||||
- ✅ P0-2:收口刷分回路冻结令落档。
|
||||
- ✅ ArchUnit BC 边界门已落地,见 [[rules/bc-boundaries]]。
|
||||
- ✅ `openapi-diff` 已接仓库根 workflow,并由 `ContractFirstGateTest` 静态锁住位置与关键配置;仍需远端 Gitea Actions 首个真实 PR 运行确认。进度总账仍需坚持 done=自动化绿旅程。
|
||||
- ✅ E0(2026-06-27):Maven CI 已接仓库根 `.github/workflows/maven.yml`;P1r 验证分为 `local-ci` / `real-pg` / `external-live`,一键脚本为 `muse-cloud/scripts/run-p1r-verification.sh`,判据见 `docs/mvp/1.0.0-真验证清单.md`。`P1r*RealApiGateTest` 明确为台账门禁,默认跳过的 live IT 不计绿证。
|
||||
|
||||
> 维护:本规则随机械门禁逐条落地而更新"已落地"清单;新发现的假绿形态追加进第一节并补对应门禁。
|
||||
|
||||
31
.github/workflows/maven.yml
vendored
Normal file
31
.github/workflows/maven.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# 后端纯本地层 CI。
|
||||
#
|
||||
# 说明:
|
||||
# - 本仓是 monorepo,自建 Gitea Actions 只扫描仓库根 .github/workflows,所以 Maven workflow 必须放在这里。
|
||||
# - 默认 CI 不连接 PostgreSQL/RAGFlow/New-API,也不把 P1r*IT 的真实验收伪装成普通 package 绿。
|
||||
# - P1r*IT 真 PG / external-live 分层验收见 muse-cloud/scripts/run-p1r-verification.sh。
|
||||
name: Backend Maven CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev/1.0.0 ]
|
||||
pull_request:
|
||||
branches: [ main, dev/1.0.0 ]
|
||||
|
||||
jobs:
|
||||
backend-local:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
cache: maven
|
||||
|
||||
- name: Maven package with local tests
|
||||
working-directory: muse-cloud
|
||||
run: mvn -B package --file pom.xml
|
||||
@ -1597,7 +1597,152 @@ paths:
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
/app-api/muse/agents/{agentId}:
|
||||
put:
|
||||
tags: [App Agent]
|
||||
summary: 更新用户智能体
|
||||
operationId: updateUserAgent
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: agentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description:
|
||||
type: string
|
||||
maxLength: 1000
|
||||
promptTemplate:
|
||||
type: string
|
||||
description: 新版本提示词模板;未传时沿用当前版本
|
||||
slotBindings:
|
||||
type: object
|
||||
description: 新版本槽位配置;未传时沿用当前版本
|
||||
changeNote:
|
||||
type: string
|
||||
description: 版本变更说明
|
||||
commandId:
|
||||
type: string
|
||||
description: 幂等键
|
||||
responses:
|
||||
'200':
|
||||
description: 更新成功,返回最新 Agent 摘要
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AgentSummary'
|
||||
'400':
|
||||
$ref: '../openapi-base.yaml#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
/app-api/muse/agents/{agentId}/archive:
|
||||
post:
|
||||
tags: [App Agent]
|
||||
summary: 归档用户智能体
|
||||
operationId: archiveUserAgent
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: agentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
commandId:
|
||||
type: string
|
||||
description: 幂等键
|
||||
reason:
|
||||
type: string
|
||||
description: 归档原因
|
||||
responses:
|
||||
'200':
|
||||
description: 归档成功,返回 Agent 摘要
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AgentSummary'
|
||||
'400':
|
||||
$ref: '../openapi-base.yaml#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
/app-api/muse/agents/{agentId}/versions:
|
||||
get:
|
||||
tags: [App Agent]
|
||||
summary: 用户智能体版本列表
|
||||
operationId: listUserAgentVersions
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: agentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/pageNo'
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/pageSize'
|
||||
responses:
|
||||
'200':
|
||||
description: 版本分页列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/PaginatedResult'
|
||||
- type: object
|
||||
properties:
|
||||
list:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentVersionSummary'
|
||||
'400':
|
||||
$ref: '../openapi-base.yaml#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
post:
|
||||
tags: [App Agent]
|
||||
summary: 新建智能体版本
|
||||
@ -1660,6 +1805,120 @@ paths:
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
/app-api/muse/agents/{agentId}/versions/{version}/activate:
|
||||
post:
|
||||
tags: [App Agent]
|
||||
summary: 激活用户智能体版本
|
||||
operationId: activateUserAgentVersion
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: agentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: version
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
commandId:
|
||||
type: string
|
||||
description: 幂等键
|
||||
reason:
|
||||
type: string
|
||||
description: 激活原因
|
||||
responses:
|
||||
'200':
|
||||
description: 激活成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
agentId:
|
||||
type: integer
|
||||
format: int64
|
||||
version:
|
||||
type: integer
|
||||
'400':
|
||||
$ref: '../openapi-base.yaml#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
/app-api/muse/agents/{agentId}/versions/{version}/archive:
|
||||
post:
|
||||
tags: [App Agent]
|
||||
summary: 归档用户智能体版本
|
||||
operationId: archiveUserAgentVersion
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: agentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: version
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
commandId:
|
||||
type: string
|
||||
description: 幂等键
|
||||
reason:
|
||||
type: string
|
||||
description: 归档原因
|
||||
responses:
|
||||
'200':
|
||||
description: 归档成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
agentId:
|
||||
type: integer
|
||||
format: int64
|
||||
version:
|
||||
type: integer
|
||||
'400':
|
||||
$ref: '../openapi-base.yaml#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
/app-api/muse/agents/{agentId}/test:
|
||||
post:
|
||||
tags: [App Agent]
|
||||
@ -1763,7 +2022,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [commandId, sourceAgentId, sourceAgentVersion]
|
||||
required: [commandId]
|
||||
properties:
|
||||
commandId:
|
||||
type: string
|
||||
@ -1771,16 +2030,32 @@ paths:
|
||||
sourceAgentId:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 来源智能体 ID
|
||||
description: 来源智能体 ID;同空间绑定必填,market_agent 来源由服务端按资产解析
|
||||
sourceAgentVersion:
|
||||
type: integer
|
||||
description: 来源智能体版本
|
||||
description: 来源智能体版本;同空间绑定必填,market_agent 来源由服务端按发布者当前 active 版本固化
|
||||
authorizationSnapshotId:
|
||||
type: string
|
||||
description: 授权快照 ID
|
||||
expectedSlotRevision:
|
||||
type: integer
|
||||
description: 目标槽位当前 revision
|
||||
sourceType:
|
||||
type: string
|
||||
enum: [market_agent]
|
||||
description: 来源类型;market_agent 表示跨空间 handoff
|
||||
handoffToken:
|
||||
type: string
|
||||
description: Market 一次性 handoff token,仅在 precheck 阶段提交,服务端不落明文
|
||||
sourceId:
|
||||
type: string
|
||||
description: Market 来源资产 ID;sourceType=market_agent 时必填
|
||||
sourceVersion:
|
||||
type: integer
|
||||
description: Market 来源资产版本;仅用于审计,不作为 AI agent version 信任源
|
||||
authorizationSummaryId:
|
||||
type: string
|
||||
description: Market 授权摘要 ID
|
||||
responses:
|
||||
'200':
|
||||
description: 预检通过,返回绑定凭证
|
||||
@ -1832,7 +2107,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [commandId, agentSlotPrecheckId, sourceAgentId, sourceAgentVersion, expectedSlotRevision]
|
||||
required: [commandId, agentSlotPrecheckId]
|
||||
properties:
|
||||
commandId:
|
||||
type: string
|
||||
@ -1843,16 +2118,16 @@ paths:
|
||||
sourceAgentId:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 绑定来源智能体 ID
|
||||
description: 绑定来源智能体 ID;同空间绑定用于与预检凭据比对,market_agent 来源可为空
|
||||
sourceAgentVersion:
|
||||
type: integer
|
||||
description: 绑定来源智能体版本
|
||||
description: 绑定来源智能体版本;同空间绑定用于与预检凭据比对,market_agent 来源可为空
|
||||
authorizationSnapshotId:
|
||||
type: string
|
||||
description: 授权快照 ID
|
||||
expectedSlotRevision:
|
||||
type: integer
|
||||
description: 目标槽位当前 revision
|
||||
description: 目标槽位当前 revision;首次绑定无 active 行时可为空
|
||||
responses:
|
||||
'200':
|
||||
description: 槽位绑定成功
|
||||
@ -1875,6 +2150,63 @@ paths:
|
||||
'409':
|
||||
description: 预检过期/revision 冲突/来源阻断
|
||||
$ref: '../openapi-base.yaml#/components/responses/Conflict'
|
||||
/app-api/muse/works/{workId}/agent-slots/{slotKey}/unbind:
|
||||
post:
|
||||
tags: [App Agent]
|
||||
summary: 解绑槽位
|
||||
operationId: unbindAgentSlot
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: workId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: slotKey
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: 槽位标识
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [commandId, expectedSlotRevision]
|
||||
properties:
|
||||
commandId:
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedSlotRevision:
|
||||
type: integer
|
||||
description: 目标槽位当前 revision
|
||||
responses:
|
||||
'200':
|
||||
description: 槽位解绑成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
slotRevision:
|
||||
type: integer
|
||||
description: 解绑后的新槽位 revision
|
||||
sourceStatus:
|
||||
type: string
|
||||
enum: [unbound]
|
||||
'409':
|
||||
description: revision 冲突或槽位不可解绑
|
||||
$ref: '../openapi-base.yaml#/components/responses/Conflict'
|
||||
|
||||
# ====================================================================
|
||||
# App 任务和来源状态(设计文档 4.2)
|
||||
@ -2890,6 +3222,28 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
AgentVersionSummary:
|
||||
type: object
|
||||
required: [agentId, version, status, active]
|
||||
properties:
|
||||
agentId:
|
||||
type: integer
|
||||
format: int64
|
||||
version:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum: [active, archived]
|
||||
active:
|
||||
type: boolean
|
||||
description: 是否为当前激活版本
|
||||
changeNote:
|
||||
type: string
|
||||
description: 版本变更说明
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
AgentTestRequest:
|
||||
type: object
|
||||
required: [commandId, input]
|
||||
|
||||
@ -651,6 +651,46 @@ paths:
|
||||
$ref: '../openapi-base.yaml#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
/app-api/muse/works/{workId}/blocks/{blockId}/revisions:
|
||||
get:
|
||||
tags: [Content]
|
||||
summary: 查询 Block 正文版本历史
|
||||
description: |
|
||||
返回指定 Block 已进入 Canonical 的正文 revision 快照,最新 revision 在前。
|
||||
仅包含 Content owner 内部写入的正文快照;不提供恢复写入能力,避免绕过当前 Block 的乐观锁与来源归因。
|
||||
operationId: listBlockRevisions
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: workId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: blockId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
responses:
|
||||
'200':
|
||||
description: Block 正文版本历史
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BlockRevision'
|
||||
'404':
|
||||
$ref: '../openapi-base.yaml#/components/responses/NotFound'
|
||||
/app-api/muse/works/{workId}/blocks/{blockId}/split:
|
||||
post:
|
||||
tags: [Content]
|
||||
@ -2345,6 +2385,51 @@ components:
|
||||
type: integer
|
||||
description: 调用方看到的目标内容 revision
|
||||
|
||||
BlockRevision:
|
||||
type: object
|
||||
required: [blockId, revision, content, wordCount, commandId, commandType, createdAt]
|
||||
properties:
|
||||
blockId:
|
||||
type: integer
|
||||
format: int64
|
||||
revision:
|
||||
type: integer
|
||||
description: Block revision
|
||||
content:
|
||||
type: string
|
||||
description: 该 revision 的正文快照
|
||||
wordCount:
|
||||
type: integer
|
||||
description: 该 revision 的字数
|
||||
commandId:
|
||||
type: string
|
||||
description: 触发该 revision 的命令 ID
|
||||
commandType:
|
||||
type: string
|
||||
enum: [save_block, mergeBlockSuggestion]
|
||||
description: 触发该 revision 的命令类型
|
||||
sourceType:
|
||||
type: string
|
||||
description: 来源类型
|
||||
sourceId:
|
||||
type: string
|
||||
description: 来源对象 ID
|
||||
sourceVersion:
|
||||
type: integer
|
||||
description: 来源对象版本
|
||||
sourceStatus:
|
||||
type: string
|
||||
description: 来源状态
|
||||
authorizationSnapshotId:
|
||||
type: string
|
||||
description: 授权快照 ID
|
||||
auditReason:
|
||||
type: string
|
||||
description: 审计原因
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
UpdateChapterRequest:
|
||||
type: object
|
||||
description: 更新章节请求;服务端必须校验 work owner、chapterId 从属关系、commandId 幂等和 expectedRevision。
|
||||
|
||||
@ -30,6 +30,8 @@ tags:
|
||||
description: 已安装知识库管理
|
||||
- name: Knowledge-Binding
|
||||
description: 作品知识来源绑定与解绑
|
||||
- name: Knowledge-Retrieval
|
||||
description: 作品已绑定知识来源检索
|
||||
- name: Local-Knowledge
|
||||
description: 作品局域知识库
|
||||
|
||||
@ -1809,6 +1811,115 @@ paths:
|
||||
'404':
|
||||
$ref: '../openapi-base.yaml#/components/responses/NotFound'
|
||||
|
||||
/app-api/muse/works/{workId}/knowledge-retrievals:
|
||||
post:
|
||||
tags: [Knowledge-Retrieval]
|
||||
summary: 检索作品已绑定知识来源
|
||||
description: |
|
||||
用户在 Studio 主动触发作品知识检索。后端先校验作品归属,再走 Knowledge Retrieval owner API 的用途门、
|
||||
授权快照门、来源状态门和 RAGFlow 检索。无绑定、未授权、缺 dataset、检索失败或无 chunk 均以
|
||||
status=empty + omittedReason 返回,供前端透明反馈,不静默省略。
|
||||
operationId: retrieveKnowledgeForWork
|
||||
security:
|
||||
- appBearerAuth: []
|
||||
parameters:
|
||||
- $ref: '../openapi-base.yaml#/components/parameters/XApiVersion'
|
||||
- name: workId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [commandId, question]
|
||||
properties:
|
||||
commandId:
|
||||
type: string
|
||||
description: 检索关联追踪键,用于日志关联;本接口不写业务事实
|
||||
question:
|
||||
type: string
|
||||
maxLength: 500
|
||||
description: 检索问题
|
||||
topK:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 20
|
||||
default: 5
|
||||
description: 返回片段上限
|
||||
responses:
|
||||
'200':
|
||||
description: 检索结果或透明降级原因
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '../openapi-base.yaml#/components/schemas/CommonResult'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ok, empty]
|
||||
omittedReason:
|
||||
type: string
|
||||
nullable: true
|
||||
description: empty 时的总体原因,如 no_binding/no_dataset/not_authorized/retrieval_failed/no_chunk/blank_question
|
||||
chunks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
sourceKbId:
|
||||
type: string
|
||||
datasetId:
|
||||
type: string
|
||||
documentId:
|
||||
type: string
|
||||
nullable: true
|
||||
contentSummary:
|
||||
type: string
|
||||
description: 脱敏截断后的片段摘要
|
||||
similarity:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
sourceOwner:
|
||||
type: string
|
||||
sourceObjectVersion:
|
||||
type: string
|
||||
nullable: true
|
||||
authorizationSnapshotId:
|
||||
type: string
|
||||
sourceStatus:
|
||||
type: string
|
||||
nullable: true
|
||||
allowedPurpose:
|
||||
type: string
|
||||
omittedSources:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
kbId:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
enum: [not_authorized, stale_source, token_budget, no_dataset]
|
||||
sourceStatus:
|
||||
type: string
|
||||
nullable: true
|
||||
'400':
|
||||
$ref: '../openapi-base.yaml#/components/responses/BadRequest'
|
||||
'403':
|
||||
$ref: '../openapi-base.yaml#/components/responses/Forbidden'
|
||||
|
||||
# ============================================================
|
||||
# App Knowledge Bases(设计文档 Section 4.6)
|
||||
# ============================================================
|
||||
|
||||
71
docs/mvp/1.0.0-RC发布说明.md
Normal file
71
docs/mvp/1.0.0-RC发布说明.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Muse 1.0.0 RC 发布说明
|
||||
|
||||
> 日期:2026-06-27
|
||||
> 状态:RC 候选。本文只记录发布决策需要的事实、边界、复跑命令和回滚策略;详细 Epic 证据见 [1.0.0-真验证清单](1.0.0-真验证清单.md)。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
Muse 1.0.0 线 A 已具备最小可用闭环:作品写作、AI 真生成、候选采纳/拒绝、知识库绑定/上传/检索、市场发现/安装/handoff、账户用量与配额扣减、Admin 基础治理、MetaSchema 字段预校验均有 fresh 真验证证据。
|
||||
|
||||
本次 RC 不把 skipped、mock-only、台账门禁计为 passed。GraphRAG attribution、Studio D0-fork UI 物化用例、Admin Playwright e2e、pay/report/bpm/mp 等明确不纳入 1.0.0 线 A 通过项。
|
||||
|
||||
## 2. 线 A 已闭环
|
||||
|
||||
- Content:写作、自动保存、AI suggestion 采纳归档、改后合并、Block revision 历史最小读面。
|
||||
- AI:Agent 创建/编辑/版本/归档、槽位 bind/unbind、真 New-API 生成、SSE done、候选采纳/拒绝。
|
||||
- Knowledge:自建 KB、资料上传、作品绑定、主动检索、no_dataset 可见治理提示、AI 生成消费知识上下文。
|
||||
- Market:资产浏览/详情/收藏/发布草稿/审核/申诉、agent handoff 物化、KB 召回触达已物化 installed_ref。
|
||||
- Member:AI 生成预扣/扣减/释放、usage record、attribution job、quota request worker、New-API 管理口 Real facade。
|
||||
- Admin:system 管理员状态/角色治理、AI 配置关键写命令、market restored 分支真 PG。
|
||||
- Meta:动态字段值预校验、从空 schemaKey 新建草稿、schema 生命周期真 PG。
|
||||
|
||||
## 3. Fresh 验证
|
||||
|
||||
- 后端本地层:`bash muse-cloud/scripts/run-p1r-verification.sh local`,61/0F/0E/0S。
|
||||
- 后端 real-PG:`bash muse-cloud/scripts/run-p1r-verification.sh real-pg`,194/0F/0E/0S,真实 PostgreSQL `_test` 库。
|
||||
- 后端 external-live:`bash muse-cloud/scripts/run-p1r-verification.sh external-live`,server live 8/0F/0E/0S,module live 4/0F/0E/1S。
|
||||
- 后端 package:`mvn -B package --file pom.xml`(`muse-cloud`),61 reactor modules SUCCESS。
|
||||
- Studio:`tsc -b --force` 通过;ESLint 0 errors / 1 既有 warning;Vitest 27 files / 119 tests passed;Playwright MSW-off 全量 61 tests = 60 passed / 1 skipped。
|
||||
- Admin:`@vben/web-antd typecheck` 通过;Vitest 50 files / 347 tests passed。
|
||||
|
||||
## 4. 已知边界
|
||||
|
||||
- GraphRAG attribution live IT 未配置独立 opt-in,当前 honest skipped,不计为线 A passed。
|
||||
- `market-install-kb-retrieval.spec.ts` D0-fork UI 物化用例仍 `test.fixme`,后端 D0-fork/KB handoff/召回已有真 PG 与 e2e 切片证据。
|
||||
- Admin 未跑 Playwright e2e;本轮覆盖为 typecheck + Vitest + 后端真 PG。
|
||||
- Quality eval 执行器、完整导入/导出向导、高级版本 diff、偏好/通知、安全写操作、撤权独立入口后置线 B。
|
||||
- pay/report/bpm/mp 仍不纳入 1.0.0 线 A。
|
||||
- Meta impact preview 的生产 owner usage contributor 仍要求 all-real;本轮 Meta lifecycle IT 的 `VERIFIED_ZERO` 只证明状态机和装配门。
|
||||
- 当前 Node v26.3.0 超出 Admin package engines 推荐范围,但本轮 typecheck/Vitest 已通过;发布流水建议使用 package engines 声明的 Node 版本。
|
||||
|
||||
## 5. 复跑命令
|
||||
|
||||
```bash
|
||||
cd muse-cloud
|
||||
bash scripts/run-p1r-verification.sh local
|
||||
bash scripts/run-p1r-verification.sh real-pg
|
||||
bash scripts/run-p1r-verification.sh external-live
|
||||
mvn -B package --file pom.xml
|
||||
```
|
||||
|
||||
```bash
|
||||
cd muse-studio
|
||||
./node_modules/.bin/tsc -b --force
|
||||
./node_modules/.bin/eslint .
|
||||
./node_modules/.bin/vitest run
|
||||
set -a && . "$HOME/.config/muse-repo/infra.env" && set +a
|
||||
env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u http_proxy -u https_proxy -u all_proxy ./node_modules/.bin/playwright test
|
||||
```
|
||||
|
||||
```bash
|
||||
cd muse-admin
|
||||
pnpm -F @vben/web-antd run typecheck
|
||||
pnpm test:unit
|
||||
```
|
||||
|
||||
## 6. 回滚策略
|
||||
|
||||
- 后端按 Epic/模块回滚,优先回滚最近变更;涉及 Flyway 的已应用迁移不做 destructive rollback,采用前向修复。
|
||||
- 前端回滚按 Studio/Admin 独立包回滚;Playwright global setup 夹具变更若导致环境污染,先禁用对应 e2e 并复位 fixture,再决定代码回退。
|
||||
- 外部服务异常不回滚业务代码;先按 `external-live` 层复跑并确认 New-API/RAGFlow health,再判断是否为外部波动。
|
||||
- 凭据、生产库写入、DDL 仍走人类闸门;P1r real-PG 只允许 `_test` 库。
|
||||
@ -24,9 +24,9 @@
|
||||
|
||||
| 域 | 完成度* | 最强(已真闭环) | 最痛(阻断点) | 盘点方 |
|
||||
|---|---|---|---|---|
|
||||
| market | ~80% | 前台主干真IT+e2e双证、KB物化D0-fork | agent物化未做、召回不触达已安装(合规风险) | claude深盘 |
|
||||
| content | ~65% | 写作/自动保存/AI采纳真PG+e2e | Accept不归档、工作台缺多入口、版本历史无 | codex |
|
||||
| knowledge | ~65% | KB物化、enable/disable真PG | Studio薄、Source只发不消费、no_dataset | codex |
|
||||
| market | ~86% | 前台主干真IT+e2e双证、KB物化D0-fork、agent 物化、KB 召回触达 | 生产者 UI/副本回收/下架撤权细补偿后置 | claude深盘 |
|
||||
| content | ~78% | 写作/自动保存/AI采纳真PG+MSW-off e2e;E1 已补采纳归档、改后合并入口、IndexedDB 草稿键、旧知识草稿失效、工作台入口与版本历史 | EditorPage 仍 demo 壳;完整导入/导出向导后置 | codex |
|
||||
| knowledge | ~78% | KB物化、enable/disable真PG;E3 已补 Studio 检索/绑定/上传与 Source 消费真 e2e | 图谱/发布深面仍薄;召回触达已物化 KB 归 E4 | codex |
|
||||
| ai | ~60% | 生成/SSE/采纳/槽位真e2e | Agent CRUD/版本/解绑缺、拒绝不打后端、评测断 | codex |
|
||||
| admin | ~55% | 市场治理真IT、e2e真连PG行反查 | 权限治理P0双断层、AI配置前端全占位 | claude深盘 |
|
||||
| member | ~50% | account读侧真IT扎实 | 写侧/扣减未闭环、用量无写源、pay禁用 | claude深盘 |
|
||||
@ -50,7 +50,7 @@
|
||||
- 大量 **mock-green** 单测冒充验证。
|
||||
- → "51 真 IT"含金量需**大幅打折**。
|
||||
|
||||
3. **读写不对称**:读/展示完整、写/消费/扣减空——用量无写源、配额 Guard 孤儿、Source/召回只发事件无消费者、传播 worker 未落。
|
||||
3. **读写不对称**:读/展示完整、写/消费/扣减空——用量无写源、配额 Guard 孤儿、部分 Source 传播 worker 未落(E4 已补 market KB 召回消费者)。
|
||||
|
||||
4. **设计漂移(真脱节,局部)**:横切统一 Source/Auth/Audit/Outbox Schema 未落地(用分域替代表)、content 双轨 block 结构化字段成孤儿、API 契约 header 语义与实现相反。
|
||||
|
||||
@ -73,65 +73,73 @@
|
||||
> 每个 Epic 的"✔ done"是**反假绿验证标准**——必须真测试(真 PG/真 e2e)通过、非 mock-green、非台账门禁。
|
||||
|
||||
### E0 · 消假绿地基(先行,全局守护)
|
||||
- T0.1 根 pom 引入 failsafe,P1r\*IT 纳入可执行验证(真起栈或分层标注外部依赖)。
|
||||
- T0.2 `P1r*RealApiGateTest` 台账门禁:改真跑,或显式标注"非真验证"且不计入交付绿证。
|
||||
- T0.3 live opt-in IT:跳过时显式标 SKIPPED(非 PASSED),交付清单区分"真验证/仅mock/未验"。
|
||||
- ✔ done:CI 能拦截 install/fork/治理/采纳链破坏;产出一份**去水分的"真验证"清单**作为各 Epic done 的判据基线。
|
||||
- T0.1 ✅ 默认 CI 接到仓库根 `.github/workflows/maven.yml`,只跑纯本地 Maven/JUnit 层;真实 PG/live IT 不伪装进 `mvn package` 绿证。
|
||||
- T0.2 ✅ `P1r*RealApiGateTest` 明确归类为 coverage 台账门禁,非业务真跑,不计入 1.0.0 真 done。
|
||||
- T0.3 ✅ live opt-in IT 归类为 `external-live`,未显式 `MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 时只能标 `SKIPPED`。
|
||||
- T0.4 ✅ 新增 `muse-cloud/scripts/run-p1r-verification.sh`,提供 `local` / `real-pg` / `external-live` 一键分层复跑;real-PG 层强制 `_test` 库和密码仅 env。
|
||||
- ✔ done:E1-E7 后续完成判据统一引用 [1.0.0-真验证清单](1.0.0-真验证清单.md)。默认 CI 守住纯本地门禁;real-PG/external-live 作为发布前和 Epic 收口的显式复跑门。
|
||||
|
||||
### E1 · content 创作闭环补齐
|
||||
- T1.1 Accept Suggestion 归档:AI owner 写 accepted decision,P1r IT 扩到校验 suggestion status 与 decision archive。
|
||||
- T1.2 修改后合并前端入口(finalContent 编辑提交)+ 旧知识草稿失效。
|
||||
- T1.3 工作台壳补知识/导入/导出/记录最小入口(后端多已就绪,补前端入口)。
|
||||
- T1.4 版本历史最小 API/UI + IndexedDB `workId+blockId+revision` 对账。
|
||||
- ✔ done:写作→AI→采纳(候选离开 Active)→工作台各入口可用,真 e2e 覆盖。
|
||||
- T1.1 ✅ Accept Suggestion 归档:AI owner 写 accepted decision,P1r IT 扩到校验 suggestion status 与 decision archive(2026-06-27 real-PG 切片 6/0F/0E/0S)。
|
||||
- T1.2 ✅ 修改后合并前端入口已补(finalContent 编辑提交);旧知识草稿失效已由 Knowledge owner API 接管,Content 正文变更后将关联 pending draft 标为 `conflicted/needs_recheck`(2026-06-27 单测 47/0F/0E,real-PG `P1rContentMergeSuggestionIT` 5/0F/0E/0S)。
|
||||
- T1.3 ✅ 工作台壳已补知识/导入/导出/记录最小入口:知识跳转作品知识页,导入/导出创建后端任务,记录跳转账户中心(2026-06-27 前端本地门 12/0F/0E;MSW-off Playwright accept-suggestion 2/0F/0E)。
|
||||
- T1.4 ✅ IndexedDB `workId+blockId+revision` 草稿键对账已补;版本历史最小 API/UI 已补:保存/采纳写入 `muse_content_block_revision_snapshot`,只读 `GET /blocks/{blockId}/revisions` 展示 Canonical revision 快照(2026-06-27 real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S)。
|
||||
- ✔ done:写作→AI→采纳(候选离开 Active)→工作台各入口可用;2026-06-27 MSW-off Playwright 真后端旅程 `accept-suggestion.spec.ts` 2/0F/0E,正路真 New-API 生成 suggestionId=79,采纳后 Block revision 160→161,候选归档 `accepted`。
|
||||
|
||||
### E2 · ai 智能体生命周期与闭环
|
||||
- T2.1 Agent CRUD 补齐(update/delete)。
|
||||
- T2.2 Agent 版本读/切换/归档。
|
||||
- T2.3 Slot 解绑(unbind)。
|
||||
- T2.4 候选拒绝接后端(studio reject 打后端)+ 真 e2e。
|
||||
- T2.5 候选列表真 e2e;质量评测执行器落地,或明确降级出 1.0.0 范围。
|
||||
- ✔ done:agent 全生命周期可管;拒绝/候选闭环真 e2e。
|
||||
- T2.1 ✅ Agent CRUD 补齐:用户自建 Agent 支持 update/archive,update 创建新 active version;archive 同步归档 active 槽位绑定(2026-06-27 后端单测+MSW-off Playwright 真后端)。
|
||||
- T2.2 ✅ Agent 版本读/切换/归档:Studio 版本面板接真实后端,支持 list/activate/archive 非当前版本;DB 核验 `current_version_id` 与版本 `status`(2026-06-27 `agent-create.spec.ts` 1/0F/0E)。
|
||||
- T2.3 ✅ Slot 解绑:Studio 槽位面板接 `unbind`,真后端把 revision 2→3、status→`archived`、响应 `sourceStatus=unbound`(2026-06-27 `agent-slot-bind.spec.ts` 1/0F/0E)。
|
||||
- T2.4 ✅ 候选拒绝接后端:Studio reject 调 `POST /suggestions/{id}/reject`,AI owner 写 `rejected` + decision archive(2026-06-27 `accept-suggestion.spec.ts` 新增 reject 真生成候选 suggestionId=81)。
|
||||
- T2.5 ✅ 范围收口:候选处置闭环已用真生成采纳/拒绝 e2e 覆盖;独立“候选列表页”不属于线 A 必要路径;质量评测执行器明确降级出 1.0.0 线 A,后置线 B(避免把 eval-run 入队半成品伪装成可用)。
|
||||
- ✔ done:agent 全生命周期可管;采纳/拒绝候选处置闭环真 e2e;质量评测执行器不计入线 A done。
|
||||
|
||||
### E3 · knowledge studio 与检索
|
||||
- T3.1 Studio 检索触发 + 绑定/上传主流程 e2e。
|
||||
- T3.2 `no_dataset` 用户可见错误与治理入口(非静默省略)。
|
||||
- T3.3 Source 传播至少一条真实跨模块消费验收。
|
||||
- ✔ done:studio 知识闭环真 e2e;检索可被用户触发、缺 dataset 有可见反馈。
|
||||
- T3.1 ✅ Studio 检索触发 + 绑定/上传主流程 e2e:`KnowledgeRetrievalPanel` 接真实检索端点,绑定读回、market KB handoff、资料上传均由 MSW-off Playwright 真后端覆盖。
|
||||
- T3.2 ✅ `no_dataset` 用户可见错误与治理入口(非静默省略):无 dataset 时 UI 展示可治理文案并指向“我创建的”上传/等待物化路径。
|
||||
- T3.3 ✅ Source 传播真实跨模块消费验收:AI 生成 e2e 反查 `muse_ai_generation.source_summary.contextAssembly`,确认检索 chunk、KB id、授权快照进入 AI 生成上下文。
|
||||
- ✔ done:2026-06-27,Studio 知识闭环真 e2e 通过;检索可被用户触发、缺 dataset 有可见反馈,且 AI 生成链路真实消费知识 Source。
|
||||
|
||||
### E4 · market agent 物化 + 召回触达
|
||||
- T4.0 先拍板临时-05 决策 D1(config 跨 BC 读取途径:market 反调 ai 读端口 vs ai 自有读端口)。
|
||||
- T4.1 agent 物化(临时-05 五单元):**槽位去裸引用 + 运行时门加 installed 分支必须同改**(并列双断点)。
|
||||
- T4.2 召回触达已物化 KB(合规底线):解锁召回事件结构 + knowledge 召回消费者 + `projection.status` 翻 recalled + 检索门生效,真 IT 验存量安装者召回后停检索。
|
||||
- T4.3 三类可信信息面板前端接入(后端 openapi 已就绪,纯前端)。
|
||||
- ✔ done:安装 agent 可在作品里跑 AI(不再 SCOPE_FORBIDDEN);召回的 KB 存量安装者检索被停;信息面板渲染。
|
||||
- T4.0 ✅ 决策收口:agent 物化落在 handoff 目标 owner 兑现点;market 不直写 AI/Knowledge 本地事实,目标域按 token+assetId 解析来源并物化本地实体。
|
||||
- T4.1 ✅ agent 物化:market agent handoff 不再信 URL/body 里的 `sourceAgentId/sourceAgentVersion`,AI 后端按 market asset `source_id` 解析发布者 agent,并物化为安装者本地 user agent;槽位绑定指向本地副本,运行时 `requireVisibleAgent` 自然过门。
|
||||
- T4.2 ✅ 召回触达已物化 KB(合规底线):market 召回发布来源状态事件,knowledge AFTER_COMMIT 消费,将 installed_ref KB 的 projection 翻为 `recalled/blocked`;检索来源状态门返回 omitted source,不再送入 RAGFlow。
|
||||
- T4.3 ✅ 三类可信信息面板前端接入:市场详情页展示来源引用、当前授权、治理状态,消费后端 `typeSpecificInfo/userActions/governanceStatus`。
|
||||
- ✔ done:2026-06-27 fresh 证据见 [1.0.0-真验证清单](1.0.0-真验证清单.md) E4;后端目标测试 50/0F/0E,契约/覆盖门 12/0F/0E,Studio Vitest 11/0F/0E,`tsc -b` 通过,lint 0 errors(仅既有 MSW warning),MSW-off Playwright 真后端 E4 切片 6/0F/0E。边界:副本资源回收仍后置;本轮未重跑 Studio 全量 56。
|
||||
|
||||
### E5 · member 写侧闭环
|
||||
- T5.1 用量写路径接线(AI 生成消耗写 `muse_member_usage_record`,或确认 New-API 外部喂数真链路)。
|
||||
- T5.2 配额扣减闭环 + `AccountQuotaGuard` 接入生成链路 + entitlement 写审计真 PG IT(闭 ADR 假绿)。
|
||||
- T5.3 `NewApiAccountFacade` Real 实现(New-API 已在 mini-infra 在线)。
|
||||
- T5.4 配额请求/归因 job 的 consumer(推进 queued→completed)。
|
||||
- ✔ done:用量真有数、配额真扣、绑定生产可用、entitlement 写有真 IT。
|
||||
- T5.1 ✅ 用量写路径接线:AI 生成成功后经 Account owner 写 `muse_member_usage_record`,失败终态释放预占配额。
|
||||
- T5.2 ✅ 配额扣减闭环 + `AccountQuotaGuard` 接入生成链路 + entitlement/usage/account IT fresh 复跑。
|
||||
- T5.3 ✅ `NewApiAccountFacade` Real 实现:New-API 管理口显式 live 验证通过,默认未 opt-in 时 honest skipped。
|
||||
- T5.4 ✅ 配额请求/归因 job consumer:queued→processing→completed/failed,有短事务 claim/terminal、外部调用 tx 外执行、失败 fail-closed。
|
||||
- ✔ done:2026-06-27,用量真有数、配额真扣、New-API 绑定/配额管理口生产可用、quota request 与 attribution job 可推进到终态;fresh 证据见 [1.0.0-真验证清单](1.0.0-真验证清单.md) E5。
|
||||
|
||||
### E6 · admin 权限治理 + AI 配置接线
|
||||
- T6.1 用户角色权限治理三件套后端端点 + 前端接线(封禁/授予撤销角色/权限组绑定,含自提权/自审批防护)。
|
||||
- T6.2 权限组与菜单页面权限 MVP(可复用 Yudao,但后端须校验页面/操作/数据范围)。
|
||||
- T6.3 AI 配置/质量门控前端接线(僵尸 api 函数接上 + 去硬编码假数据)——消最大假绿面、成本低。
|
||||
- T6.4 market restore 分支真 PG IT。
|
||||
- ✔ done:管理员能通过 Muse 管理端授权/配权限组;AI 配置真可写、无假数据。
|
||||
- T6.1 ✅ system 管理员用户状态/角色治理后端防护 + Muse 管理端接线:`/system/user/update-status`、`/system/permission/assign-user-role` 带当前操作者上下文,后端拒绝自禁用和自改角色。
|
||||
- T6.2 ✅ 权限组与页面权限沿用 Yudao system 角色/菜单/数据范围模型;Muse 账号页新增“管理员权限”Tab 治理 system 管理员,不伪造 account 业务账号未定义写契约。
|
||||
- T6.3 ✅ AI 配置/质量门控前端接线:Prompt 激活、质量评估启动、质量策略版本创建调用真实 admin API;最近评估只展示真实提交返回,不再展示硬编码假评分。
|
||||
- T6.4 ✅ market restore 分支真 PG IT:申诉恢复路径覆盖 preview consumed、资产重新 listed、restore governance action、命令/事件幂等。
|
||||
- ✔ done:2026-06-27,Muse 管理端可治理 system 管理员状态/角色,后端有自操作防护;AI 配置页关键写命令真接后端、去假数据;market restore 分支有真实 PG 证据。fresh 证据见 [1.0.0-真验证清单](1.0.0-真验证清单.md) E6。边界:account 业务账号封禁/权限组写契约仍不存在,本轮不伪造;Agent/Tool Grant 等未接后端的 AI 命令仍保持 preview-only。
|
||||
|
||||
### E7 · meta 字段校验 + schema
|
||||
- T7.1 后端动态字段值校验(required/min/max/regex/enum/类型/废弃字段/版本 stale 错误码)落在可信边界。
|
||||
- T7.2 打通新增 schema 根对象,或限制 admin "新建草稿"入口。
|
||||
- T7.3 meta admin API 真 PG IT(草稿→校验→预览→发布→激活→回滚)+ admin 写动作真 e2e。
|
||||
- ✔ done:动态表单值校验后端生效;schema 可新建;有真 IT/真 e2e。
|
||||
- T7.1 ✅ 后端动态字段值校验落在 Content 可信边界,经 Meta active projection 执行 required/type/enum/min/max/minLength/maxLength/pattern/regex/deprecated/unknown field 校验,并返回 schema/projection stale 布尔、当前版本和写入路由建议。
|
||||
- T7.2 ✅ admin 新建 schema 草稿入口打通:schemaKey 不存在时后端创建默认 content/work/work 根对象,状态 draft,随后保存 v1 草稿;不扩张 OpenAPI 契约伪造多 targetType 入口。
|
||||
- T7.3 ✅ meta admin API 真 PG IT:`P1rMetaAdminSchemaLifecycleCompletedApprovalIT` 走真实 `/admin-api/muse/governance/meta-schemas/**`,覆盖新 schema 草稿→校验→预览→发布→激活→v2 发布→回滚,并断言 command/audit/validation/preview/version 状态落库、schema 根 `status=active` 与 active 指针同步;`P1rContentMetaProjectionClusterCompletedApprovalIT` 走真实 `/app-api/muse/works/{workId}/dynamic-fields/validate` 验证 active projection 规则。
|
||||
- ✔ done:2026-06-27,动态字段预校验真实读取 Meta 投影并 fail-closed;admin 可从空 schemaKey 新建草稿并完成治理状态机;fresh 证据见 [1.0.0-真验证清单](1.0.0-真验证清单.md) E7。边界:Meta impact preview 在生产仍要求 5 维度 usage contributor all-real;本轮 admin lifecycle IT 用 5 个 `VERIFIED_ZERO` 测试 contributor 满足装配门,不代表外部 owner 用量建模已完成;未跑 Admin Playwright e2e。
|
||||
|
||||
### E8 · 发布候选冻结与总验
|
||||
- T8.1 ✅ 后端三层发布前总验:`local`、`real-pg`、`external-live` fresh 复跑通过;GraphRAG attribution 仍是独立 opt-in skip,不计入 1.0.0 线 A 通过项。
|
||||
- T8.2 ✅ CI/package 兜底:根 Maven package 等价门已复跑通过,61 个 reactor 模块全 SUCCESS;同轮先暴露 `MuseApiContractSupport` 少登记 8 个 OpenAPI 操作(233/241)导致契约门红,补齐后定向 `MuseApiContractSupportTest` 4/0F/0E/0S,根 `mvn -B package --file pom.xml` 通过。
|
||||
- T8.3 ✅ 前端总验:Studio `tsc -b --force` 通过,ESLint 0 errors(仅既有 MSW warning),Vitest 27 files / 119 tests passed,MSW-off Playwright 全量 61 tests = 60 passed / 1 skipped / 0 failed;Admin `@vben/web-antd typecheck` 通过,Vitest 50 files / 347 tests passed。
|
||||
- T8.4 ✅ 发布说明与已知边界:已形成 [1.0.0-RC发布说明.md](1.0.0-RC发布说明.md),列出线 A 已闭环、线 B 后置、GraphRAG/Studio D0-fork skip、Admin Playwright 未跑、复跑命令与回滚策略。
|
||||
- ✔ done:所有发布候选门禁有 fresh 证据,失败项已修或明确降级出线 A;不得用旧人工批准、台账门禁、mock-only 或 skipped 代替 RC 绿证。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证准则(反假绿——什么算"真 done")
|
||||
|
||||
1. **真测试优先**:done 以真 PG IT / 真后端 e2e 为准,不接受 mock-green、台账门禁、默认跳过的 live。
|
||||
2. **CI 守护**:E0 后,关键链路的 P1r IT 必须能在 CI(或可一键复跑的流程)拦截回归。
|
||||
2. **分层守护**:E0 后,默认 CI 守纯本地层;关键链路的 P1r IT 必须能通过 `run-p1r-verification.sh` 在 real-PG / external-live 层一键复跑并拦截回归。
|
||||
3. **闭环验证**:验"用户可达的完整链路"(前端动作→后端→DB→反查),不止单点单测。
|
||||
4. **独立复核**:agent 报"已修/通过"必附运行证据;主 agent 独立复跑关键结论,不盲信子 agent。
|
||||
5. **凭据红线**:测试/日志/产物不得泄露 password/secret/token/sk-;IT 用 `_test` 库,绝不指向 muse_slice_live。
|
||||
|
||||
179
docs/mvp/1.0.0-真验证清单.md
Normal file
179
docs/mvp/1.0.0-真验证清单.md
Normal file
@ -0,0 +1,179 @@
|
||||
# Muse 1.0.0 真验证清单
|
||||
|
||||
> 版本:v1.0(2026-06-27,E0 消假绿地基)
|
||||
> 作用:给 E1-E7 的 done 判据提供统一口径。本文只记录“什么证据算真验证、如何复跑、哪些只算台账/跳过/mock”,不记录功能进度流水。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论
|
||||
|
||||
1. **默认 CI 只证明纯本地层**:`mvn -B package --file pom.xml` 能跑单测、ArchUnit、契约结构和 coverage 台账门,但不证明真实 PostgreSQL / RAGFlow / New-API 链路。
|
||||
2. **`P1r*RealApiGateTest` 是台账门禁**:它们只读 `docs/superpowers/reports/p1r-api-coverage.json`,防止 coverage 回退;不打 HTTP、不连 DB、不调用外部服务,不得计入业务真验证。
|
||||
3. **`P1r*IT` 才是后端真验证主体**:其中 real-PG 层会 `flyway.clean()` 指定 `_test` 库;external-live 层还会真实调用 New-API/RAGFlow,必须显式 opt-in。
|
||||
4. **live 默认跳过不是绿证**:未设置 `MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 时,live IT 通过 `assumeTrue` 跳过,应在交付记录中标为 `SKIPPED`,不能标为 `PASSED`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 分层口径
|
||||
|
||||
| 层级 | 证据类型 | 命令入口 | 计入 1.0.0 真 done |
|
||||
|---|---|---|---|
|
||||
| `local-ci` | 普通单测、ArchUnit、契约结构、coverage/RealApiGate 台账门 | 根 workflow `.github/workflows/maven.yml` 或 `bash muse-cloud/scripts/run-p1r-verification.sh local` | 只能证明本地静态/单测门,不证明业务闭环 |
|
||||
| `real-pg` | 非 live 的 `P1r*IT`,MockMvc/真实 mapper/真实 PostgreSQL `_test` 库 | `bash muse-cloud/scripts/run-p1r-verification.sh real-pg` | 可计入后端真验证 |
|
||||
| `external-live` | New-API / RAGFlow / 端到端 runtime live IT | `bash muse-cloud/scripts/run-p1r-verification.sh external-live` | 可计入外部链路真验证 |
|
||||
| `frontend-e2e` | MSW-off Playwright 真后端/真 DB 旅程 | 各前端现有 Playwright 命令 | 可计入用户可达闭环 |
|
||||
| `mock-only` | Mockito/Vitest/MSW/mock 数据 | 各模块单测 | 只能证明局部逻辑,不计入上线闭环 |
|
||||
| `skipped` | `assumeTrue` 或环境缺失导致跳过 | 测试报告中 skipped | 不计入通过 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 一键复跑
|
||||
|
||||
### 纯本地层
|
||||
|
||||
```bash
|
||||
bash muse-cloud/scripts/run-p1r-verification.sh local
|
||||
```
|
||||
|
||||
该层不读取凭据、不连接 PG/Redis/RAGFlow/New-API。
|
||||
|
||||
### real-PG 层
|
||||
|
||||
```bash
|
||||
set -a && . "$HOME/.config/muse-repo/infra.env" && set +a
|
||||
P1R_FLYWAY_URL='jdbc:postgresql://100.64.0.8:5433/<专属_test库>' \
|
||||
P1R_FLYWAY_USER='root' \
|
||||
bash muse-cloud/scripts/run-p1r-verification.sh real-pg
|
||||
```
|
||||
|
||||
硬闸:
|
||||
- `P1R_FLYWAY_URL` 的真实 database name 必须以 `_test` 结尾。
|
||||
- 目标 `_test` 库必须经人类 DDL 闸门预建;脚本只做存在性预检,不自动 `CREATE DATABASE`。
|
||||
- JDBC URL 不得携带 `password` / `token` / `secret` / `api-key` 等 query。
|
||||
- 密码只走 `P1R_FLYWAY_PASSWORD` 或 `MUSE_POSTGRES_PASSWORD` 环境变量。
|
||||
- 批量 IT 必须 `-DreuseForks=false`,避免脱敏后的 `p1r.flyway.url` 污染同 JVM 后续测试。
|
||||
|
||||
### external-live 层
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. "$HOME/.config/muse-repo/infra.env"
|
||||
. "muse-cloud/scripts/dev/p1r-external-acceptance.env"
|
||||
set +a
|
||||
export MUSE_P1R_EXTERNAL_ACCEPTANCE=true
|
||||
P1R_FLYWAY_URL='jdbc:postgresql://100.64.0.8:5433/muse_p1r_live_test' \
|
||||
P1R_FLYWAY_USER='root' \
|
||||
bash muse-cloud/scripts/run-p1r-verification.sh external-live
|
||||
```
|
||||
|
||||
该层会真实调用 New-API/RAGFlow。脚本会清 JVM SOCKS/HTTP/HTTPS 代理,避免把 tailnet 外部依赖误判为 502/不可用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前基线
|
||||
|
||||
已验证事实(来自代码/配置只读核验):
|
||||
- 根 Maven CI 已接在 `.github/workflows/maven.yml`,默认只跑本地层。
|
||||
- 子目录 `muse-cloud/.github/workflows/maven.yml` 已移除,避免远端不扫描造成假绿。
|
||||
- 7 个 `P1r*RealApiGateTest` 全部只读 coverage JSON。
|
||||
- 仓内有 51 个 `P1r*IT.java`,其中 5 类 live 入口需要显式 `MUSE_P1R_EXTERNAL_ACCEPTANCE=true`。
|
||||
|
||||
E0 fresh 基线(2026-06-26 本轮实跑):
|
||||
- `bash muse-cloud/scripts/run-p1r-verification.sh local`:61 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。
|
||||
- 根 CI 等价命令 `mvn -B package --file pom.xml`(工作目录 `muse-cloud`):3060 tests / 0 failures / 0 errors / 148 skipped,`BUILD SUCCESS`。
|
||||
- `bash muse-cloud/scripts/run-p1r-verification.sh real-pg`(真实 PostgreSQL `_test` 库,live IT 显式排除):189 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`,耗时 09:24。
|
||||
- `bash muse-cloud/scripts/run-p1r-verification.sh external-live`:server live 8 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`,耗时 02:12;module live 4 tests / 0 failures / 0 errors / 1 skipped,`BUILD SUCCESS`,耗时 12.395s。1 个 skip 为 GraphRAG attribution 未配置;New-API、RAGFlow health/upload/parse/chunks/retrieval、AI runtime、knowledge runtime、market KB fork 物化主链路均 fresh 通过。
|
||||
- 脚本错误路径已验:缺 `P1R_FLYWAY_URL`、非 `_test` 库、JDBC URL 携带敏感 query、缺目标 `_test` 库、`external-live` 未 opt-in 均在 Maven 前失败。
|
||||
|
||||
live 边界:
|
||||
- GraphRAG attribution 仍因开关未启用而跳过,不能标为 passed。
|
||||
- E1-E7 若修改 live 相关链路,必须按 `external-live` 层重新提供 fresh 证据;本条只作为 E0 基线。
|
||||
|
||||
1.0.0 RC fresh 总验(2026-06-27,本轮实跑):
|
||||
- `local`:`bash muse-cloud/scripts/run-p1r-verification.sh local`,61 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。本轮同步修正 `P1r*RealApiGateTest` 台账边界到 coverage `241/241 completed`,仍只作为 coverage 门禁,不计业务真跑。
|
||||
- `local-ci/package`:根 CI 等价命令 `mvn -B package --file pom.xml`(工作目录 `muse-cloud`)首次暴露契约登记表滞后:OpenAPI 241 个操作,`MuseApiContractSupport` 只登记 233 个,缺 `update/archive/list/activate/archive agent version`、`slot unbind`、`block revisions`、`knowledge retrieval` 等 8 个入口;补齐后 `MuseApiContractSupportTest` 4 tests / 0 failures / 0 errors / 0 skipped,最终根 package 61 个 reactor 模块全 SUCCESS,`BUILD SUCCESS`。
|
||||
- `real-pg`:`bash muse-cloud/scripts/run-p1r-verification.sh real-pg`,真实 PostgreSQL `_test` 库 `muse_p1r_100_release_test`,47 份 fresh surefire 报告 / 194 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。先前同轮暴露的 1F/2E 已修:market 召回断言从旧 `TARGET_OWNER_UNAVAILABLE` 对齐为当前来源状态事件发布语义;content 两个 IT 补齐测试上下文依赖;修后 targeted 6/0,再全量 194/0。
|
||||
- `external-live`:`bash muse-cloud/scripts/run-p1r-verification.sh external-live`,真实 New-API/RAGFlow + `_test` 库,server live 8 tests / 0 failures / 0 errors / 0 skipped,module live 4 tests / 0 failures / 0 errors / 1 skipped,`BUILD SUCCESS`。真跑证据覆盖 New-API completion、AI runtime 写 usage/quota/attribution、RAGFlow health/upload/parse/chunks/retrieval、Knowledge runtime、Market KB D0-fork 物化与 after-commit;唯一 skip 为 GraphRAG attribution 独立 opt-in 未配置,不计为 passed。
|
||||
- 反假绿补记:手工直跑 server IT 时必须让 surefire forked JVM 收到 `p1r.flyway.*` 与代理清理参数;优先使用脚本。改过 module 后跑 server IT 需 `-am` 或先 `mvn install`,否则会拿旧 jar 产生 stale-jar 假红。
|
||||
- `frontend-local` Studio:`./node_modules/.bin/tsc -b --force` 通过;`./node_modules/.bin/eslint .` 0 errors / 1 warning(既有 `public/mockServiceWorker.js` unused eslint-disable);`./node_modules/.bin/vitest run` 27 files / 119 tests passed。首次 Vitest 红在 `useAgents.test.tsx` 固定断言旧 total=3,E4 后真实 fixture 增加“已安装市场智能体”,已改为按列表长度与关键项断言。
|
||||
- `frontend-local` Admin:`pnpm -F @vben/web-antd run typecheck` 通过;`pnpm test:unit` 50 files / 347 tests passed。当前 Node v26.3.0 超出 package engines 推荐范围(`^20.19.0 || ^22.18.0 || ^24.0.0`),但本轮测试通过;已补 `vitest.setup.ts` 桥接 `localStorage/sessionStorage` 到 `globalThis`,避免 Node v26 下 happy-dom 测试假红。
|
||||
- `frontend-e2e` Studio:MSW-off 全量 Playwright `./node_modules/.bin/playwright test`,61 tests = 60 passed / 1 skipped / 0 failed,耗时约 1.4m,真实 muse-server 48080 + 共享 `muse_slice_live` + New-API/RAGFlow。首次全量红 5 项:3 项账户额度不足、1 项 AI Agent 不存在、1 项知识库状态定位器 strict-mode 歧义;修复为 Playwright global setup 每轮复位 user1 `ai_calls`、隔离 `agent-slot-bind` 到 work2 并刷新作品列表可见性、恢复 work1 AI 主链槽位、收窄知识库卡片定位。跳过项为 `market-install-kb-retrieval.spec.ts` D0-fork UI 物化用例 `test.fixme`,不计为 passed。
|
||||
- `frontend-e2e` 反假绿补记:全量 Playwright 曾暴露 `KnowledgePage` 自建/已安装 KB `kbId` 重复导致 React key warning;已改用 `source/installId/kbId` 复合 key。该修复不放宽断言,只消除渲染层隐患。
|
||||
|
||||
E1 fresh 切片证据(2026-06-27,本轮实跑):
|
||||
- 功能链路:Content 采纳 AI suggestion 时同步调用 AI owner,写 `accepted` 状态、accepted decision archive、AI command 与 business audit;Studio 支持“改后合并”提交 `finalContent`;IndexedDB 草稿键改为 `workId+blockId+revision`;Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive;工作台补知识/导入/导出/记录最小入口;保存/采纳后写 `muse_content_block_revision_snapshot`,Studio 历史 Tab 只读展示 Canonical revision 快照。
|
||||
- `mock-only` 局部单测:`ContentSourceServiceTest` 25 tests / 0 failures / 0 errors,`ContentAppServiceTest` 19 tests / 0 failures / 0 errors,`MuseKnowledgeDraftInvalidationServiceTest` 3 tests / 0 failures / 0 errors,`AiSuggestionMergeProjectionFacadeTest` 7 tests / 0 failures / 0 errors,`BUILD SUCCESS`。
|
||||
- `local-ci` 契约/覆盖门:`ContractFirstGateTest` 4 tests / 0 failures / 0 errors;`P1rApiCoverageReportTest` 8 tests / 0 failures / 0 errors;coverage JSON 已把 `listBlockRevisions` 纳入 completed/testFiles。
|
||||
- `real-pg` 后端切片:`P1rContentCoreCompletedApprovalIT` 13 tests / 0 failures / 0 errors / 0 skipped,`P1rContentMergeSuggestionIT` 5 tests / 0 failures / 0 errors / 0 skipped,`P1rContentMergeGeneratedSuggestionIT` 1 test / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。运行使用真实 PostgreSQL `_test` 库,限定 `P1R_REAL_PG_TESTS='P1rContentCoreCompletedApprovalIT,P1rContentMergeSuggestionIT,P1rContentMergeGeneratedSuggestionIT'`。
|
||||
- 前端静态/局部验证:`tsc -b --force` 通过;`npm run lint` 0 errors(仅既有 `public/mockServiceWorker.js` unused eslint-disable warning);Vitest 3 files / 12 tests passed;补跑 `AIPanel.contract.test.tsx` + `sse.test.ts` 2 files / 22 tests passed,覆盖 done 无 chunk 时回源读取 suggestion detail 的真实红点。
|
||||
- `frontend-e2e` 活体验证:`accept-suggestion.spec.ts` 2 tests / 0 failures / 0 errors(MSW-off,真实 muse-server 48080 + 共享 `muse_slice_live` + 真 New-API/RAGFlow 配置)。正路证据:真生成 suggestionId=79,authz=`rpe-local-*`,Block revision 160→161,`sourceAttribution.lineage.parentSuggestionId` 指向本轮真生成候选,AI owner 归档 `accepted` 且 decision archive 非空;负路 stale `expectedBlockRevision` 返回业务冲突。
|
||||
- 边界:本轮只跑 Studio `accept-suggestion` 黄金旅程,没有重跑 studio 全量 56 e2e;`muse-studio/src/types/content.ts` 生成类型未同步,新版本 `openapi-typescript` 会带出大量历史 string/number 与 version/revision 机械漂移,本轮仅在 hook 内维护 `BlockRevision` 最小类型。
|
||||
|
||||
E2 fresh 切片证据(2026-06-27,本轮实跑):
|
||||
- 功能链路:用户自建 Agent 支持创建初始版本、编辑生成新 active version、版本列表/激活/归档非当前版本、归档 Agent;作品槽位支持真实 unbind;Studio “放弃修改”从本地清 UI 改为调用 AI owner reject 写 decision archive。
|
||||
- `local-ci` 后端切片:`MuseAgentServiceTest` + `MuseAgentSlotServiceTest` + `AppMuseAgentControllerAnnotationTest` + `AppMuseAgentSlotControllerAnnotationTest` 共 63 tests / 0 failures / 0 errors,`BUILD SUCCESS`。
|
||||
- `local-ci` 契约门:`ContractFirstGateTest` 4 tests / 0 failures / 0 errors;AI OpenAPI 已补 update/archive Agent、versions list/activate/archive、slot unbind 契约。
|
||||
- 前端静态/局部验证:`tsc -b --force` 通过;目标 ESLint(E2 e2e + global-setup)0 errors;Vitest `useAgents.test.tsx` + `AIPanel.contract.test.tsx` + `sse.test.ts` 3 files / 29 tests passed。
|
||||
- 启动修红:最新 muse-server clean package 后首次启动红在 `muse.codegen.importEnable` 缺省;已在 `muse-server/src/main/resources/application.yaml` 补 `import-enable: false`,随后 `muse-server` 重新 `BUILD SUCCESS` 并在 48080 启动成功,真 HTTP `/app-api/muse/agents` smoke 返回 `code=0`。
|
||||
- `frontend-e2e` 活体验证:`agent-create.spec.ts` + `agent-slot-bind.spec.ts` + `accept-suggestion.spec.ts` 共 5 tests / 0 failures / 0 errors(MSW-off,真实 muse-server 48080 + 共享 `muse_slice_live` + 真 New-API/RAGFlow 配置)。证据:采纳真生成 suggestionId=80、authz=`rpe-local-*`、Block revision 161→162 且 AI owner accepted decision archive 非空;拒绝真生成 suggestionId=81 后 `status=rejected` 且 decision archive 非空;Agent 生命周期 DB 核验 `current_version_id` v1/v2 切换与 v2 archived;Slot unbind DB 核验 revision 2→3、status=`archived`、响应 `sourceStatus=unbound`。
|
||||
- 边界:质量评测执行器仍未落地,明确不计入 1.0.0 线 A done,后置线 B;本轮没有重跑 studio 全量 e2e,只跑 E2 相关真后端切片。
|
||||
|
||||
E3 fresh 切片证据(2026-06-27,本轮实跑):
|
||||
- 功能链路:Studio 知识页新增主动检索面板,用户可对作品已绑定知识来源发起真实检索;无 dataset 时返回用户可见治理提示;自建知识库资料上传走真实后端并在列表读回;market KB handoff 兑现后可进入作品知识绑定链路;AI 生成链路真实消费 Knowledge Source 检索上下文。
|
||||
- `local-ci` 后端切片:`MuseKnowledgeRetrievalAppServiceTest`、`AppMuseKnowledgeRetrievalControllerTest`、`MuseKnowledgeRetrievalApiImplTest`、`AppMuseKnowledgeBindingControllerTest`、`AppMuseKnowledgeDocumentControllerTest` 共 25 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。
|
||||
- `local-ci` 契约/迁移门:`P1rKnowledgeMigrationSqlTest`、`ContractFirstGateTest`、`P1rApiCoverageReportTest` 共 21 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`;coverage JSON 当前 `241/241 completed`。V33 `allow_market_kb_precheck_without_local_kb` 已落 live,`muse_knowledge_bind_precheck.kb_id` 为 nullable,修正 market_kb 预检阶段尚未物化本地 KB 时的 schema 漂移。
|
||||
- 前端静态/局部验证:Knowledge 相关 Vitest 4 files / 19 tests passed;目标 ESLint 0 errors;`tsc -b --force` 通过。`useKnowledge` 已把真实后端返回的 numeric `documentId/kbId` 归一为前端字符串,避免上传后列表被类型守卫过滤。
|
||||
- `frontend-e2e` 活体验证一:`knowledge-retrieval.spec.ts` + `knowledge-bindings.spec.ts` + `handoff-knowledge.spec.ts` + `market-handoff-precheck.spec.ts` 共 6 tests / 0 failures / 0 errors(MSW-off,真实 muse-server 48080 + 共享 `muse_slice_live` + RAGFlow/New-API 配置)。覆盖 market KB handoff 正/负路、绑定投影读回、no_dataset 治理提示、上传真落库读回、市场授权预检。
|
||||
- `frontend-e2e` 活体验证二:`ai-generation.spec.ts` 1 test / 0 failures / 0 errors(MSW-off,真 New-API 生成 suggestionId=82)。用例反查 `muse_ai_generation.source_summary->contextAssembly`,确认 `chunkCount>0`、`retrievedKbIds`、`authorizationSnapshotIds` 非空,证明 Knowledge Source 被 AI 生成真实消费。
|
||||
- 边界:本轮只跑 E3 相关 Studio e2e 与 AI 生成 Source 消费切片,没有重跑 studio 全量 56;知识图谱/发布深面仍按后续模块进度推进;已物化 KB 的召回触达归 E4。
|
||||
|
||||
E4 fresh 切片证据(2026-06-27,本轮实跑):
|
||||
- 功能链路:market agent handoff 由 AI 后端按 market asset 解析发布者 agent,并物化为安装者本地 user agent 后绑定作品槽位;该槽位可真实创建 AI task 并完成 New-API runtime。market KB 召回由 Market 发布来源状态事件,Knowledge AFTER_COMMIT 消费后把已物化 installed_ref KB projection 置 `recalled/blocked`,作品检索返回 `omittedSources`,不再检索该 KB。市场详情页渲染来源引用、当前授权、治理状态三类可信信息。
|
||||
- `local-ci` 后端切片:`MuseAgentSlotServiceTest`、`AdminMarketGovernanceServiceTest`、`MuseKnowledgeSourceEventServiceTest`、`KnowledgeMarketSourceStatusChangedConsumerTest` 共 50 tests / 0 failures / 0 errors,`BUILD SUCCESS`。
|
||||
- `local-ci` 契约/覆盖门:`ContractFirstGateTest` 4 tests / 0 failures / 0 errors;`P1rApiCoverageReportTest` 8 tests / 0 failures / 0 errors;AI OpenAPI 已收敛 market agent precheck/bind 不再要求客户端提交 `sourceAgentId/sourceAgentVersion`。
|
||||
- 前端静态/局部验证:`npx tsc -b` 通过;`npm run lint` 0 errors(仅既有 `public/mockServiceWorker.js` unused eslint-disable warning);Vitest `useMarket.test.tsx` + `AgentHandoffLanding.test.tsx` 2 files / 11 tests passed。
|
||||
- `frontend-e2e` 活体验证一:`handoff-agent.spec.ts` + `market-asset-detail.spec.ts` + `market-kb-recall.spec.ts` 共 4 tests / 0 failures / 0 errors(MSW-off,真实 muse-server 48080 + 共享 `muse_slice_live` + New-API/RAGFlow 配置)。覆盖 agent 物化、本地槽位 runtime 完成态、伪造 token 拒绝、可信信息面板渲染、KB 召回传播到 Knowledge projection 并在检索中省略。
|
||||
- `frontend-e2e` 活体验证二:`market-governance-impact.spec.ts` + `market-handoff-precheck.spec.ts` 共 2 tests / 0 failures / 0 errors(MSW-off,真实后端)。覆盖治理影响读面与 market 来源侧授权预检回归。
|
||||
- 边界:本轮未重跑 Studio 全量 56 e2e;market KB 副本资源回收、下架/撤权更细粒度补偿仍后置线 B;测试夹具会在 Playwright global setup 或新用例 finally 中复位被召回的 handoff KB 资产,避免污染后续 e2e。
|
||||
|
||||
E5 fresh 切片证据(2026-06-27,本轮实跑):
|
||||
- 功能链路:AI 生成入口先经 Account owner 预占配额;生成成功后写 `muse_member_usage_record` 并累加 quota used,生成失败终态释放预占;用量 attribution worker 消费 queued job 写 attribution item;quota request worker 调 New-API 管理口并把 request/integration call 推进到终态。
|
||||
- `local-ci` 后端切片:`MuseAiTaskServiceTest`、`MuseAiRuntimeProjectionServiceTest`、`AccountUsageServiceTest`、`AccountQuotaServiceTest`、`AccountAttributionServiceTest`、`AccountNewApiBindingServiceTest`、`MuseAccountUsageApiImplTest`、`NewApiAccountFacadeConfigurationTest`、`RealNewApiAccountFacadeTest` 共 120 tests / 0 failures / 0 errors,`BUILD SUCCESS`。
|
||||
- `real-pg` account 切片:`P1rAccountQuotaRequestCompletedApprovalIT` 5 tests / 0 failures / 0 errors / 0 skipped,`P1rAccountAdminWritesCompletedApprovalIT` 3 tests / 0 failures / 0 errors / 0 skipped,`P1rAccountNewApiBindingClusterCompletedApprovalIT` 4 tests / 0 failures / 0 errors / 0 skipped,`P1rAccountUsageObservabilityCompletedApprovalIT` 4 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。运行库为真实 PostgreSQL `_test` 库 `muse_p1r_account_e5_test`。
|
||||
- `external-live` New-API 管理口:`RealNewApiAccountFacadeLiveAcceptanceIT` 显式 opt-in 1 test / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`;证据只输出 endpoint、`newApiUserId` 哈希前缀、`quotaApplied=100000000`、binding/quota external call 布尔值,不输出凭据。未 opt-in 的默认运行是 honest skipped,不计为 live passed。
|
||||
- `external-live` AI→Account 串联:`P1rAiRuntimeEndToEndLiveAcceptanceIT` 3 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。反查 `accountUsageRecordCount=1`、`accountQuotaUsedAmount=1`、reserve audit 1、release audit 0、usage total tokens 550;attribution processedCount 1、jobStatus completed、itemStatus completed、attributionType work。
|
||||
- `local-ci` 构建兜底:`mvn -pl muse-module-ai/muse-module-ai-server,muse-module-member/muse-module-member-server,muse-server -am -DskipTests test-compile` 通过。
|
||||
- 边界:quota request worker 仍默认 disabled,生产启用需显式配置 `muse.account.events.quota-request-worker.enabled=true`;本轮 New-API live 创建/刷新专用测试用户,未输出或落库任何凭据;未重跑 Studio/Admin 前端 e2e,E6 将继续 admin 权限与 AI 配置接线。
|
||||
|
||||
E6 fresh 切片证据(2026-06-27,本轮实跑):
|
||||
- 功能链路:Muse 管理端账号页新增 system 管理员权限 Tab,可读取 system 管理员、角色、用户角色并提交角色分配/启停用;system 后端在可信边界拒绝自禁用、自改角色。AI 配置页 Prompt 激活、质量评估启动、质量策略版本创建改为调用真实 admin API,最近评估只展示本页真实提交返回,不展示硬编码评分。Market 管理端申诉 `restored` 分支经真实 PG IT 覆盖,恢复会消费治理预览、重新上架资产并写 restore governance action。
|
||||
- `local-ci` 后端切片:`AdminUserServiceImplTest` 36 tests / 0 failures / 0 errors / 0 skipped,`PermissionServiceTest` 25 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。
|
||||
- `frontend-local` Admin 账号治理:`npx -y pnpm@10.33.0 --dir muse-admin test:unit -- --run apps/web-antd/src/views/muse/__tests__/governance-pages.test.ts` 触发根 Vitest,50 test files / 345 tests passed。
|
||||
- `frontend-local` Admin AI 配置:`npx -y pnpm@10.33.0 --dir muse-admin test:unit -- --run apps/web-antd/src/views/muse/ai/__tests__/index.test.ts` 触发根 Vitest,50 test files / 347 tests passed;`npx -y pnpm@10.33.0 --dir muse-admin --filter @vben/web-antd typecheck` 通过。
|
||||
- `real-pg` market restore 切片:`P1rMarketAdminAppealCompletedApprovalIT` 7 tests / 0 failures / 0 errors / 0 skipped,运行库为真实 PostgreSQL `_test` 库 `muse_p1r_market_admin_appeal_test`。断言 restored 分支 preview status=`consumed`、preview revision=2、asset listing/status=`listed`、governance action type=`restore`、command/event 幂等各 1 条、事件/动作快照包含 `impactPreviewId`。
|
||||
- `local-ci` 构建兜底:`mvn -pl muse-module-system/muse-module-system-server,muse-server -am -DskipTests test-compile` 通过。
|
||||
- 边界:account 业务账号 status/role/group 写契约仍不存在,账号页继续明确提示“不伪造未定义 account 写接口”;system 角色菜单/数据范围仍沿用 Yudao 既有页面和权限模型;本轮未跑 Admin Playwright e2e;AI Agent/Tool Grant 等未接后端的命令仍保持 preview-only,不计为真写。
|
||||
|
||||
E7 fresh 切片证据(2026-06-27,本轮实跑):
|
||||
- 功能链路:Content 动态字段预校验从 Meta active projection 读取字段定义/校验规则/废弃信息,执行 required、类型、enum、数值/文本范围、regex/pattern、deprecated、unknown field 校验,并返回 stale 标记、当前版本、dataRevision 和 write route。Admin MetaSchema 新建草稿在 schemaKey 不存在时创建默认 content/work/work 根对象;治理状态机走草稿→校验→预览→发布→激活→v2 发布→回滚,真实写 command/audit/validation/preview/version 状态。
|
||||
- `local-ci` 后端切片:`MetaProjectionQueryApiImplTest` 4 tests / 0 failures / 0 errors / 0 skipped,`MetaSchemaServiceTest` 50 tests / 0 failures / 0 errors / 0 skipped,`RealContentMetaFacadeTest` 8 tests / 0 failures / 0 errors / 0 skipped,`ContentMetaProjectionServiceTest` 7 tests / 0 failures / 0 errors / 0 skipped,`BUILD SUCCESS`。
|
||||
- `real-pg` Content/Meta 投影校验:`P1rContentMetaProjectionClusterCompletedApprovalIT` 4 tests / 0 failures / 0 errors / 0 skipped,运行真实 PostgreSQL `_test` 库,走真实 `/app-api/muse/works/{workId}/dynamic-fields/validate`。断言无 schema 时 fail-closed;绑定 active schema 后 regex/enum/range/deprecated/unknown field 错误命中,routeSuggestions 指向 Content planning owner 命令,全程不写 Content fact。
|
||||
- `real-pg` Admin Meta 生命周期:`P1rMetaAdminSchemaLifecycleCompletedApprovalIT` 2 tests / 0 failures / 0 errors / 0 skipped,运行真实 PostgreSQL `_test` 库,走真实 `/admin-api/muse/governance/meta-schemas/**` + method security。断言新 schema 根对象 `domain=content/scope=work/targetType=work/status=draft`,v1/v2 validation/preview 成功,发布不隐式激活,激活/回滚同步切 `active_version_id` 与 schema 根 `status=active`,6 条写命令、4 条治理审计、2 条 validation、2 条 preview 真实落库。
|
||||
- `local-ci` 构建兜底:`mvn -pl muse-module-meta/muse-module-meta-api,muse-module-meta/muse-module-meta-server,muse-module-content/muse-module-content-server,muse-server -am -DskipTests test-compile` 通过;变更范围 `git diff --check` 通过。
|
||||
- 边界:Meta production impact preview 仍要求 5 个 owner usage contributor all-real;本轮 lifecycle IT 用 5 个 `VERIFIED_ZERO` 测试 contributor 只证明 Meta admin 状态机和 preview 服务可用,不等价于完成外部 owner 用量投影建模;未跑 Admin Playwright e2e;server IT 前必须先 `mvn install` 改过的 meta/content 模块,否则会拿旧 jar 产生 stale-jar 假红。
|
||||
|
||||
---
|
||||
|
||||
## 5. E1-E7 使用规则
|
||||
|
||||
每个 Epic 收口时必须列出:
|
||||
|
||||
| 字段 | 要求 |
|
||||
|---|---|
|
||||
| 功能链路 | 用一句话描述用户或后端闭环 |
|
||||
| 验证层级 | `local-ci` / `real-pg` / `external-live` / `frontend-e2e` / `mock-only` / `skipped` |
|
||||
| 命令 | 给出可复跑命令,隐去凭据 |
|
||||
| 结果 | tests / failures / errors / skipped,不能只写“通过” |
|
||||
| 边界 | 明确哪些路径未验、哪些依赖后置 |
|
||||
|
||||
未满足上述信息的完成记录,只能标为“实现候选”或“待真验证”,不得写成 1.0.0 done。
|
||||
@ -1,8 +1,8 @@
|
||||
# Muse 进度总账(单一进度源)
|
||||
|
||||
> **本文件是项目进度的唯一 SSOT**。进度更新只进**本文件 + 各模块 `.agent`**;**不再新增“状态推进 / 收口 / completedApproval”过程文档**(过程文档 churn 是失控根因之一,见 [对抗复盘](../agent-specs/2026-06-13-目标达成对抗复盘.md))。
|
||||
> **接口覆盖门机械唯一源** = [覆盖 JSON `p1r-api-coverage.json`](../superpowers/reports/p1r-api-coverage.json)(经 `P1rApiCoverageReportTest` 校验 summary 自算 + testFiles 证据),当前 **233/233 completed / 0 needs_verification**;⚠️ 该口径 = 接口门,**≠ 端到端可用**(整体 ~76%,见 §二)。
|
||||
> **🎯 1.0.0 交付计划(可闭环严判 + 待做路线、单一执行源)** = [1.0.0-交付计划.md](1.0.0-交付计划.md)(2026-06-26 八域交付盘点)。本总账记**接口门/基建进度**;**1.0.0 可闭环完成度严判(~55-65%)、五类系统性病灶、E0–E7 待做 Epic 以交付计划为准**。盘点实证口径差:覆盖 JSON 的 completed = 接口存在 + 台账标记、**≠ 真闭环可用**——多个 `P1r*RealApiGateTest` 是台账门禁(非真跑)、整套 P1r IT 不在 CI 跑(靠人工批准记录)、前端/写侧大量"装了不能用"。
|
||||
> **接口覆盖门机械唯一源** = [覆盖 JSON `p1r-api-coverage.json`](../superpowers/reports/p1r-api-coverage.json)(经 `P1rApiCoverageReportTest` 校验 summary 自算 + testFiles 证据),当前 **241/241 completed / 0 needs_verification**;⚠️ 该口径 = 接口门,**≠ 端到端可用**(整体 ~77%,见 §二)。
|
||||
> **🎯 1.0.0 交付计划(可闭环严判 + 待做路线、单一执行源)** = [1.0.0-交付计划.md](1.0.0-交付计划.md)(2026-06-26 八域交付盘点)。本总账记**接口门/基建进度**;**1.0.0 可闭环完成度严判(~55-65%)、五类系统性病灶、E0–E7 待做 Epic 以交付计划为准**。盘点实证口径差:覆盖 JSON 的 completed = 接口存在 + 台账标记、**≠ 真闭环可用**——多个 `P1r*RealApiGateTest` 是台账门禁(非真跑)、P1r IT 已改为按 [真验证清单](1.0.0-真验证清单.md) 分层复跑、前端/写侧大量"装了不能用"。
|
||||
> 模块现状细节见 [现状基线 spec](../agent-specs/2026-06-13-项目目标与模块现状基线.md)(**2026-06-13 历史快照**,文内 147/86 为当时值,以本文件 + 覆盖 JSON 为准);人读全局见 [项目功能与进度总览](../项目功能与进度总览.md)。
|
||||
|
||||
---
|
||||
@ -11,17 +11,33 @@
|
||||
|
||||
| 砖 | 内容 | 状态 | 机械证据 / 落点 |
|
||||
|---|---|---|---|
|
||||
| P0 | CI 真跑测试(JDK21、去 `-Dmaven.test.skip`)+ 覆盖台账去硬编码 + P0 冻结令 | ✅ | `muse-cloud/.github/workflows/maven.yml`、`P1rApiCoverageReportTest`、[脊柱规则](../../.agents/rules/verification-and-anti-false-green.md) |
|
||||
| P0 | CI 真跑测试(JDK21、去 `-Dmaven.test.skip`)+ 覆盖台账去硬编码 + P0 冻结令 | ✅ | `.github/workflows/maven.yml`、`P1rApiCoverageReportTest`、[脊柱规则](../../.agents/rules/verification-and-anti-false-green.md) |
|
||||
| ① | BC 边界 ArchUnit 门——**通用覆盖全业务 BC 间方向** | ✅ **全绿**(AI/knowledge/market 三处直连他域 DAL 违例均已整改,豁免清单清空) | `BcBoundaryArchTest`(1/0F,0 Architecture Violation,`KNOWN_VIOLATION_EXEMPTIONS`=空);[bc-boundaries](../../.agents/rules/bc-boundaries.md) §三 |
|
||||
| ①b | AI grant/runtime 最小 ArchUnit 门 | ✅ 绿;完整物理包拆分仍后置 | `AiGrantRuntimeBoundaryArchTest`(1/0F):runtime/任务执行侧不得直连 `ToolGrant` 写模型 |
|
||||
| ② | 契约先行门(Flyway 迁移卫生 + OpenAPI **存在性/结构**) | ✅ 绿;语义破坏由 openapi-diff 补强——**检测逻辑已本地实证**(2026-06-17,oasdiff v1.19.1,7 域真实契约 5/5),root workflow 已接线,远端 Gitea Actions 首跑待确认 | `ContractFirstGateTest`(2/0F);[openapi-diff.yml](../../.github/workflows/openapi-diff.yml)+[verify 脚本](../../muse-cloud/scripts/verify-openapi-diff.sh);[contract-first](../../.agents/rules/contract-first.md) §四 |
|
||||
| ⑦ | loop 机械牙 + CI 接电(round-2) | ✅ 绿 | `AgentsInfraIntegrityTest`(3/0F);`maven.yml` 触发分支修为 `main` |
|
||||
| ② | 契约先行门(Flyway 迁移卫生 + OpenAPI **存在性/结构**) | ✅ 绿;语义破坏由 openapi-diff 补强——**检测逻辑已本地实证**(2026-06-17,oasdiff v1.19.1,7 域真实契约 5/5),root workflow 已接线,远端 Gitea Actions 首跑待确认 | `ContractFirstGateTest`(4 类静态门);[openapi-diff.yml](../../.github/workflows/openapi-diff.yml)+[maven.yml](../../.github/workflows/maven.yml)+[verify 脚本](../../muse-cloud/scripts/verify-openapi-diff.sh);[contract-first](../../.agents/rules/contract-first.md) §四 |
|
||||
| ⑦ | loop 机械牙 + CI 接电(round-2) | ✅ 绿 | `AgentsInfraIntegrityTest`(3/0F);`maven.yml` 位于仓库根并触发 `main` / `dev/1.0.0` |
|
||||
| ③ | knowledge 蒸馏(定位架构 / 现状基线指针 / 决策) | ✅ | [`.agents/knowledge/`](../../.agents/knowledge/) |
|
||||
| ④ | skills(黄金旅程“完成”定义 / 新增 BC 模块) | ✅ | [`.agents/skills/`](../../.agents/skills/) |
|
||||
| ⑤ | workflow(AI 开发协议元流程) | ✅ | [`.agents/workflows/ai-development-protocol.md`](../../.agents/workflows/ai-development-protocol.md) |
|
||||
| ⑥ | 进度总账(本文件)+ per-module `.agent` | ✅ | 本文件 + `muse-cloud/muse-module-*/.agent` |
|
||||
|
||||
**round-2 加固(2026-06-14,据 Opus 评审)**:CI 触发分支 master→main(此前 CI 从不运行)、BC 门通用化 + 整改 knowledge 违例、loop 装机械牙、覆盖门去魔法数、文档诚实化、openapi-diff materialize 为独立 workflow。
|
||||
**round-2 加固(2026-06-14,据 Opus 评审;2026-06-27 E0 续固)**:CI 触发分支 master→main(此前 CI 从不运行)、BC 门通用化 + 整改 knowledge 违例、loop 装机械牙、覆盖门去魔法数、文档诚实化、openapi-diff materialize 为独立 workflow。E0 将 Maven CI 移到仓库根 `.github/workflows/maven.yml`,并用 `run-p1r-verification.sh` + [真验证清单](1.0.0-真验证清单.md) 区分 local-ci / real-PG / external-live / mock-only / skipped。E0 fresh 证据:local 61/0F/0E/0S,根 CI 等价 `mvn -B package --file pom.xml` 3060/0F/0E/148S,real-PG 189/0F/0E/0S,external-live server 8/0F/0E/0S + module 4/0F/0E/1S(GraphRAG attribution 未配置跳过)。
|
||||
|
||||
**E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录最小入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business audit;AI owner 不可用时整笔 merge 回滚,避免 Canonical 已写但候选仍 pending。Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive;通知失败不回滚 Canonical 主写,Knowledge confirm 端仍按来源状态 fail-closed。Studio `CandidatePanel` 支持编辑最终正文并按 `accept_as_is`/`modify_then_merge` 提交,IndexedDB 草稿键改为 `workId+blockId+revision` 防跨作品/版本污染。`saveBlock`/`mergeBlockSuggestion` 现在写 `muse_content_block_revision_snapshot`,工作台“历史”Tab 只读展示 Canonical revision 快照;导入/导出入口创建真实任务,知识/记录入口跳转对应工作台。fresh 证据:后端局部单测 `ContentSourceServiceTest` 25/0F/0E、`ContentAppServiceTest` 19/0F/0E、`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E、`AiSuggestionMergeProjectionFacadeTest` 7/0F/0E;契约/覆盖门 `ContractFirstGateTest` 4/0F/0E + `P1rApiCoverageReportTest` 8/0F/0E;real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S;studio `tsc -b --force` 通过、lint 0 errors、Vitest 12/12,追加 `AIPanel.contract.test.tsx` + `sse.test.ts` 22/0F/0E。共享 PG 写入闸门批准后已补跑 MSW-off Playwright 真后端 `accept-suggestion.spec.ts` 2/0F/0E:正路真 New-API 生成 suggestionId=79、authz `rpe-local-*`、Block revision 160→161、AI owner accepted decision archive 非空;负路 stale revision 业务冲突。边界:`muse-studio/src/types/content.ts` 生成类型因 openapi-typescript 版本漂移未同步,hook 内暂维护 `BlockRevision` 最小类型;完整导入上传/导出下载 UI 后置。
|
||||
|
||||
**E2 ai 智能体生命周期与候选处置闭环(2026-06-27)**:已补用户自建 Agent update/archive、初始版本自动创建、版本列表/激活/归档非当前版本、作品槽位 unbind、Studio reject 调后端 AI owner 决策归档。Agent update 会创建下一 active version 并更新 `current_version_id`,archive Agent 同步归档仍 active 的槽位绑定;slot unbind 将绑定行 revision+1 且 `status=archived`,运行时回到默认能力;WorkspacePage “放弃修改”不再只清本地候选,而是真打 `POST /suggestions/{id}/reject` 写 `rejected` 与 decision archive。启动修红:最新单体启动时暴露 `muse.codegen.importEnable` 缺省,已在 `muse-server/src/main/resources/application.yaml` 补 `import-enable:false`,随后 48080 成功启动并 `GET /app-api/muse/agents` smoke 返回 `code=0`。fresh 证据:后端 AI targeted `MuseAgentServiceTest`+`MuseAgentSlotServiceTest`+Controller annotation 63/0F/0E;契约门 `ContractFirstGateTest` 4/0F/0E;studio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0E;MSW-off Playwright 真后端 `agent-create.spec.ts`+`agent-slot-bind.spec.ts`+`accept-suggestion.spec.ts` 5/0F/0E:采纳真生成 suggestionId=80、Block revision 161→162、accepted decision archive 非空;拒绝真生成 suggestionId=81、`status=rejected`、decision archive 非空;Agent 生命周期 DB 核验 v1/v2 current 切换与 v2 archived;Slot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:质量评测执行器未落地,明确降级出 1.0.0 线 A、后置线 B;本轮未跑 studio 全量 e2e。
|
||||
|
||||
**E3 knowledge studio 与检索闭环(2026-06-27)**:已补 Studio 主动检索面板、no_dataset 可见治理提示、自建知识库上传真实后端读回、market KB handoff 正负路、AI 生成消费 Knowledge Source 的反查证据。新增 V33 迁移允许 `muse_knowledge_bind_precheck.kb_id` 在 market_kb 预检阶段为空,修正“本地 installed_ref KB 尚未物化但预检先落库”的 schema 漂移;前端 `useKnowledge` 归一真实后端 numeric `documentId/kbId`,修复上传后列表被类型守卫过滤。fresh 证据:后端 knowledge targeted 25/0F/0E/0S;契约/迁移门 `P1rKnowledgeMigrationSqlTest`+`ContractFirstGateTest`+`P1rApiCoverageReportTest` 21/0F/0E/0S,coverage JSON 241/241;studio knowledge Vitest 19/0F/0E、目标 ESLint 0 errors、`tsc -b --force` 通过;MSW-off Playwright 真后端 `knowledge-retrieval.spec.ts`+`knowledge-bindings.spec.ts`+`handoff-knowledge.spec.ts`+`market-handoff-precheck.spec.ts` 6/0F/0E;`ai-generation.spec.ts` 1/0F/0E,真 New-API 生成 suggestionId=82,并反查 `source_summary.contextAssembly` 中 `chunkCount>0`、`retrievedKbIds`、`authorizationSnapshotIds` 非空。边界:知识图谱/发布深面仍后置;已物化 KB 的召回触达归 E4。
|
||||
|
||||
**E4 market agent 物化 + 召回触达(2026-06-27)**:已补 market agent handoff 物化与 KB 召回触达合规底线。Agent handoff 不再信任客户端 `sourceAgentId/sourceAgentVersion`,AI 后端按 market asset `source_id` 解析发布者 agent,物化为安装者本地 user agent,槽位绑定指向本地副本并可真实创建 AI task;测试 fixture 显式种入 Security 已批准 runtime grant,验证的是 runtime 权限门真实通过而非绕过。Market 召回资产时发布 `MarketAssetSourceStatusChangedEvent`,Knowledge AFTER_COMMIT 消费后将 installed_ref KB projection 置 `recalled/blocked`,作品检索返回 `omittedSources`,不再送入 RAGFlow。Studio 市场详情页补三类可信信息面板(来源引用/当前授权/治理状态)。fresh 证据:后端 E4 targeted `MuseAgentSlotServiceTest`+`AdminMarketGovernanceServiceTest`+`MuseKnowledgeSourceEventServiceTest`+`KnowledgeMarketSourceStatusChangedConsumerTest` 50/0F/0E;契约/覆盖门 `ContractFirstGateTest`+`P1rApiCoverageReportTest` 12/0F/0E;studio Vitest 11/0F/0E、`npx tsc -b` 通过、`npm run lint` 0 errors(仅既有 MSW warning);MSW-off Playwright 真后端 `handoff-agent.spec.ts`+`market-asset-detail.spec.ts`+`market-kb-recall.spec.ts` 4/0F/0E,`market-governance-impact.spec.ts`+`market-handoff-precheck.spec.ts` 2/0F/0E。边界:未重跑 studio 全量 56;KB 副本资源回收、下架/撤权更细补偿后置。
|
||||
|
||||
**E5 member 写侧闭环(2026-06-27)**:已补 AI→Account 用量/配额真实写链路、New-API 管理口 Real facade、quota request consumer、attribution job consumer。AI 生成入口先经 Account owner 预占配额,成功后写 `muse_member_usage_record` 并累加 quota used,失败终态释放预占;`AccountQuotaRequestWorker` 以短事务 claim/terminal、外部 New-API 调用 tx 外执行,推进 queued→completed/failed;`AccountAttributionWorker` 消费 queued attribution job,按 usage correlation 写 attribution item 并标终态。`RealNewApiAccountFacade` 显式禁用系统代理,避免 macOS 系统代理把 tailnet 管理口误判为外部 5xx;配置默认 fail-closed,worker 默认 disabled。fresh 证据:后端 E5 targeted 单测 120/0F/0E;account real-PG `P1rAccountQuotaRequestCompletedApprovalIT` 5/0F/0E/0S + `P1rAccountAdminWritesCompletedApprovalIT` 3/0F/0E/0S + `P1rAccountNewApiBindingClusterCompletedApprovalIT` 4/0F/0E/0S + `P1rAccountUsageObservabilityCompletedApprovalIT` 4/0F/0E/0S;New-API 管理口 live `RealNewApiAccountFacadeLiveAcceptanceIT` 显式 opt-in 1/0F/0E/0S,quotaApplied=100000000 且只输出 hash/布尔脱敏证据;AI runtime live `P1rAiRuntimeEndToEndLiveAcceptanceIT` 3/0F/0E/0S,反查 usage record=1、quota used=1、reserve audit=1、attribution job/item completed;AI/member/server `test-compile` 通过。边界:未跑前端 e2e;默认未 opt-in 的 New-API live 为 honest skipped,不计为 passed;E6 进入 admin 权限治理与 AI 配置接线。
|
||||
|
||||
**E6 admin 权限治理 + AI 配置接线(2026-06-27)**:已补 Muse 管理端 system 管理员权限 Tab、后端自操作防护、AI 配置真实写 API 接线、market restore 分支真 PG IT。账号页现在区分 system 管理员治理与 account 业务账号治理:system 管理员可读用户/角色/用户角色并提交角色分配和启停用,后端拒绝自禁用、自改角色;account 业务账号仍无封禁/权限组写契约,前端保持诚实提示不伪造提交。AI 配置页 Prompt 激活、质量评估启动、质量策略版本创建调用真实 admin API,最近评估不再展示硬编码假分数。fresh 证据:system targeted 单测 `AdminUserServiceImplTest` 36/0F/0E/0S + `PermissionServiceTest` 25/0F/0E/0S;muse-admin 账号治理 Vitest 50 files / 345 tests passed;AI 配置 Vitest 50 files / 347 tests passed;`@vben/web-antd typecheck` 通过;market real-PG `P1rMarketAdminAppealCompletedApprovalIT` 7/0F/0E/0S 覆盖 restored 分支 preview consumed、asset relisted、restore governance action、command/event 幂等;system+server `test-compile` 通过。边界:未跑 Admin Playwright e2e;system 角色菜单/数据范围沿用 Yudao 既有模型;AI Agent/Tool Grant 等未接后端命令仍 preview-only;E7 进入 meta 字段校验 + schema。
|
||||
|
||||
**E7 meta 字段校验 + schema(2026-06-27)**:已补 Content 动态字段值预校验、Meta 新 schema 草稿入口、admin MetaSchema 治理状态机真 PG 证据。`RealContentMetaFacade.validateDynamicFields` 不再走 default unavailable,而是读取 Meta active projection,校验 required/type/enum/min/max/minLength/maxLength/pattern/regex/deprecated/unknown field,返回 schema/projection stale、当前版本/dataRevision 与 Content planning 写入路由建议;`MetaProjectionFieldDTO` 投影 validationRules/deprecated/migrationHint;Meta admin `saveMetaSchemaDraft` 在 schemaKey 不存在时创建默认 content/work/work draft 根对象。fresh 证据:targeted 单测 `MetaProjectionQueryApiImplTest` 4/0F/0E/0S + `MetaSchemaServiceTest` 50/0F/0E/0S + `RealContentMetaFacadeTest` 8/0F/0E/0S + `ContentMetaProjectionServiceTest` 7/0F/0E/0S;real-PG `P1rContentMetaProjectionClusterCompletedApprovalIT` 4/0F/0E/0S 覆盖 no-schema fail-closed 与 active projection 规则命中且 0 Content 写;real-PG `P1rMetaAdminSchemaLifecycleCompletedApprovalIT` 2/0F/0E/0S 覆盖新 schema 草稿→校验→预览→发布→激活→v2 发布→回滚,断言 command/audit/validation/preview/version 状态落库,并验证激活/回滚同步切 `active_version_id` 与 schema 根 `status=active`,避免“version active 但运行时投影不可见”的假绿;meta/content/server `test-compile` 通过。边界:生产 Meta impact preview 仍要求 5 维度 owner usage contributor all-real;本轮 lifecycle IT 用 5 个 VERIFIED_ZERO 测试 contributor 证明 Meta admin 状态机,不代表外部 owner 用量投影建模已完成;未跑 Admin Playwright e2e。反假绿教训:改 module 后跑 server IT 必须先 `mvn install`,否则 stale jar 会把新实现误判为 fail-closed。
|
||||
|
||||
**E8 发布候选冻结与总验(2026-06-27)**:E0-E7 完成后进入 RC 总验。后端三层 fresh 复跑已过:`local` 61/0F/0E/0S;`real-pg` 使用真实 PostgreSQL `_test` 库 `muse_p1r_100_release_test`,47 份 fresh surefire 报告、194/0F/0E/0S;`external-live` 真实调用 New-API/RAGFlow,server live 8/0F/0E/0S + module live 4/0F/0E/1S,`BUILD SUCCESS`。唯一 skip 为 GraphRAG attribution 独立 opt-in 未配置,不计为线 A passed;New-API completion、AI runtime usage/quota/attribution、RAGFlow upload/parse/retrieval、Knowledge runtime、Market KB D0-fork 与 after-commit 均真跑通过。本轮 real-PG 先暴露并修正 1F/2E:market 召回断言从旧 fail-closed 语义对齐为当前来源状态事件发布语义,两个 content IT 补齐测试上下文 bean;另确认手工直跑 server IT 时必须让 surefire forked JVM 收到 `p1r.flyway.*` 和代理清理 `argLine`,改 module 后必须 `-am` 或 install 防 stale jar 假红。CI/package 兜底已补跑:首次 `mvn -B package --file pom.xml` 暴露 `MuseApiContractSupport` 缺 8 个 OpenAPI 操作登记(233/241),补齐后定向契约门 `MuseApiContractSupportTest` 4/0F/0E/0S,根 package 61 个 reactor 模块全 SUCCESS、`BUILD SUCCESS`。前端总验已过:Studio `tsc -b --force` 通过、ESLint 0 errors/1 既有 MSW warning、Vitest 27 files/119 tests passed、MSW-off Playwright 全量 61 tests=60 passed/1 skipped/0 failed;Admin `@vben/web-antd typecheck` 通过、Vitest 50 files/347 tests passed。首次 Studio 全量红暴露额度夹具缺失、work1 槽位被 agent-slot-bind 污染、知识库定位器过宽,已分别用 global-setup 额度复位/槽位隔离/卡片级定位修复;React key 重复 warning 已用 `source/installId/kbId` 复合 key 消除。RC 发布说明与已知边界已落 [1.0.0-RC发布说明](1.0.0-RC发布说明.md);T8.4 完成。边界:GraphRAG attribution skip、Studio D0-fork UI 物化 `test.fixme`、Admin Playwright 未跑、pay/report/bpm/mp 与线 B 后置项均不计为 1.0.0 passed。
|
||||
|
||||
**market 写路径整改(2026-06-14,ultracode)**:member 暴露写端口 `MuseAccountRecordProjectionApi` + DTO(member-server 实现读写自有 DAL、tenantId 由实现侧从上下文注入防伪造、事务沿用调用方),market 5 类改消费端口、移除 member.dal 依赖 → `KNOWN_VIOLATION_EXEMPTIONS` 清空、BC 门全绿。**附带修复**:round-2 重构 `ContentKnowledgeWorkOwnerFacade` 时遗留的旧测试 `KnowledgeWorkOwnerFacadeTest`(仍断言旧 WorkMapper 行为)已删除,其装配守卫/兜底两用例并入 `ContentKnowledgeWorkOwnerFacadeTest`(5/0F)——此为 round-2 一处假绿(当时构建在平台时区用例处中止、未真正跑到 knowledge),现已补正。
|
||||
|
||||
@ -39,15 +55,15 @@
|
||||
|
||||
| BC 模块 | 只读评估 | `.agent` |
|
||||
|---|---|---|
|
||||
| AI 编排 (ai) | 72% | [.agent](../../muse-cloud/muse-module-ai/.agent) |
|
||||
| AI 编排 (ai) | 80% | [.agent](../../muse-cloud/muse-module-ai/.agent) |
|
||||
| 作品/编辑器 (content) | 82% | [.agent](../../muse-cloud/muse-module-content/.agent) |
|
||||
| 知识库 (knowledge) | 72% | [.agent](../../muse-cloud/muse-module-knowledge/.agent) |
|
||||
| 知识库 (knowledge) | 78% | [.agent](../../muse-cloud/muse-module-knowledge/.agent) |
|
||||
| 市场 (market) | 82% | [.agent](../../muse-cloud/muse-module-market/.agent) |
|
||||
| 元治理/MetaSchema (meta) | 82% | [.agent](../../muse-cloud/muse-module-meta/.agent) |
|
||||
| 事件/SSE (events) | 88% | [.agent](../../muse-cloud/muse-module-events/.agent) |
|
||||
| 账户/个人中心 (account→member) | 68% | [.agent](../../muse-cloud/muse-module-member/.agent) |
|
||||
| 账户/个人中心 (account→member) | 76% | [.agent](../../muse-cloud/muse-module-member/.agent) |
|
||||
|
||||
> 整体只读评估 ≈ 76%:后端实现高、**接口覆盖门已满(233/233)但 ≠ 端到端可用**、前端用户端低、跨 BC 集成低(机械门禁本轮已补 BC + 契约门)。
|
||||
> 整体只读评估 ≈ 79%:后端实现高、**接口覆盖门已满(241/241)但 ≠ 端到端可用**、前端用户端仍有深面缺口、跨 BC 集成逐步补齐(机械门禁本轮已补 BC + 契约门)。
|
||||
|
||||
> **2026-06-14 更新 —— P1 后端"环境阻塞"已解除**:此前"86 个 needs_verification 须真实 PG、本地不可验"的判断**已被实测推翻**。接通共享远端 PG(详见 §四 时间线 + [.agents/knowledge §四](../../.agents/knowledge/external-deps-and-gotchas.md))真跑 20 个 `P1r*IT`,**19/20 类零功能失败**——completed-approval / Flyway 迁移 / events-publish outbox 等后端路径在真实 PG 上获机械绿证据(非假绿)。**仍未验证/未达**:前端 muse-studio(无 FE 运行栈);唯一红 `P1rKnowledgeFlywayMigrationIT`(陈旧硬编码 V14,待改为动态版本);live-acceptance 中需 `MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 才真跑的用例;以及覆盖台账 completed≠端到端可用(FE 断链仍在)。
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
259
docs/superpowers/reports/p1r-api-coverage.md
Normal file
259
docs/superpowers/reports/p1r-api-coverage.md
Normal file
@ -0,0 +1,259 @@
|
||||
# P1R API Coverage Report
|
||||
|
||||
- Generated at: `2026-05-25T17:06:23+00:00`
|
||||
- Total operations: `241`
|
||||
- Completed: `241`
|
||||
- Needs verification: `0`
|
||||
- Incomplete: `0`
|
||||
- Catch-all: `0`
|
||||
- Generic persistence: `0`
|
||||
- SSE placeholder: `0`
|
||||
- Missing: `0`
|
||||
|
||||
## Operations
|
||||
|
||||
| Domain | Side | Method | Path | Operation | Implementation | Completion | Target Stage |
|
||||
|--------|------|--------|------|-----------|----------------|------------|--------------|
|
||||
| account | admin | POST | `/admin-api/muse/account/call-attribution-jobs` | `adminCreateCallAttributionJob` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/call-attribution-jobs/{jobId}` | `adminGetCallAttributionJob` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/integration-calls/by-correlation/{correlationId}` | `adminGetIntegrationCallByCorrelation` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/new-api-bindings` | `adminListNewApiBindings` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/purchase-records` | `adminListPurchaseRecords` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/usage-records` | `adminListUsageRecords` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/users` | `adminListAccountUsers` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/users/{userId}/balance-snapshots` | `adminGetBalanceSnapshots` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/users/{userId}/entitlements` | `adminGetUserEntitlements` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | POST | `/admin-api/muse/account/users/{userId}/new-api-binding` | `adminCreateNewApiBinding` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | GET | `/admin-api/muse/account/users/{userId}/quota-adjustments` | `adminListQuotaAdjustments` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | POST | `/admin-api/muse/account/users/{userId}/quota-adjustments` | `adminCreateQuotaAdjustment` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | admin | POST | `/admin-api/muse/account/users/{userId}/quota-requests` | `adminCreateQuotaRequest` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/balance-snapshots` | `getAppBalanceSnapshots` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/downloads/{credentialId}` | `appDownloadExport` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/entitlements` | `getAppEntitlements` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | POST | `/app-api/muse/account/export-tasks` | `appCreateExportTask` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/export-tasks/{taskId}` | `appGetExportTask` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/integration-calls/by-correlation/{correlationId}` | `appGetIntegrationCallByCorrelation` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/licenses` | `appListLicenses` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/new-api-binding` | `getAppNewApiBinding` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | POST | `/app-api/muse/account/new-api-binding/recheck` | `appRecheckNewApiBinding` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/publish-records` | `appListPublishRecords` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/purchases` | `appListPurchases` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | POST | `/app-api/muse/account/quota-requests` | `appCreateQuotaRequest` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/quota-requests/{requestId}` | `appGetQuotaRequest` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/security-events` | `appListSecurityEvents` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/security-events/{eventId}` | `appGetSecurityEvent` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | POST | `/app-api/muse/account/security-events/{eventId}/acknowledge` | `appAcknowledgeSecurityEvent` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/account/usage` | `getAppUsage` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/me` | `getCurrentUser` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | GET | `/app-api/muse/profile` | `getProfile` | dedicated | completed | P1R-3 Account Real API |
|
||||
| account | app | PATCH | `/app-api/muse/profile` | `updateProfile` | dedicated | completed | P1R-3 Account Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/ai/agents` | `adminListAgents` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/ai/agents` | `adminCreateAgent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/ai/agents/{agentId}/versions` | `adminCreateAgentVersion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/ai/evaluation-runs` | `adminStartEvaluationRun` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/ai/evaluation-runs/{runId}` | `adminGetEvaluationRun` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/ai/prompts` | `adminListPrompts` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/ai/prompts/{promptKey}/versions` | `adminCreatePromptVersion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/ai/prompts/{promptKey}/versions/{version}/activate` | `adminActivatePromptVersion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/ai/quality-policies` | `adminListQualityPolicies` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/ai/quality-policies/{policyKey}/versions` | `adminCreateQualityPolicyVersion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/ai/tasks` | `adminListAiTasks` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/ai/tool-grants` | `adminListToolGrants` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/ai/tool-grants` | `adminCreateOrAdjustToolGrant` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/audit/api-logs` | `adminListApiAccessLogs` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/audit/api-logs/{logId}` | `adminGetApiAccessLog` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/audit/business-events` | `adminListBusinessAuditEvents` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/audit/business-events/{eventId}` | `adminGetBusinessAuditEvent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/jobs` | `adminListJobs` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/jobs/{jobId}` | `adminGetJob` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/jobs/{jobId}/cancel` | `adminCancelJob` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/jobs/{jobId}/retry` | `adminRetryJob` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/source-events` | `adminListSourceEvents` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | GET | `/admin-api/muse/source-events/{eventId}` | `adminGetSourceEvent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | admin | POST | `/admin-api/muse/source-events/{eventId}/retry` | `adminRetrySourceEvent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/agents` | `listUserAgents` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/agents` | `createUserAgent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | PUT | `/app-api/muse/agents/{agentId}` | `updateUserAgent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/agents/{agentId}/archive` | `archiveUserAgent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/agents/{agentId}/test` | `testAgent` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/agents/{agentId}/versions` | `listUserAgentVersions` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/agents/{agentId}/versions` | `createAgentVersion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/agents/{agentId}/versions/{version}/activate` | `activateUserAgentVersion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/agents/{agentId}/versions/{version}/archive` | `archiveUserAgentVersion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/ai/tasks` | `createAiTask` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/ai/tasks/{taskId}` | `getAiTask` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/ai/tasks/{taskId}/stream` | `streamAiTask` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/jobs/{jobId}` | `getUserJob` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/jobs/{jobId}/cancel` | `cancelUserJob` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/source-status/query` | `querySourceStatus` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/source-status/recheck` | `recheckSourceStatus` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/suggestions/{suggestionId}` | `getSuggestion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/suggestions/{suggestionId}/reject` | `rejectSuggestion` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/works/{workId}/agent-slots` | `listWorkAgentSlots` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/works/{workId}/agent-slots/{slotKey}/bind` | `bindAgentSlot` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/works/{workId}/agent-slots/{slotKey}/prechecks` | `precheckAgentSlot` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | POST | `/app-api/muse/works/{workId}/agent-slots/{slotKey}/unbind` | `unbindAgentSlot` | dedicated | completed | P1R-4 AI Real API |
|
||||
| ai | app | GET | `/app-api/muse/works/{workId}/suggestions` | `listWorkSuggestions` | dedicated | completed | P1R-4 AI Real API |
|
||||
| content | admin | GET | `/admin-api/muse/content/export-tasks` | `adminListExportTasks` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | admin | GET | `/admin-api/muse/content/import-tasks` | `adminListImportTasks` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | admin | GET | `/admin-api/muse/content/works` | `adminListWorks` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | admin | GET | `/admin-api/muse/content/works/{workId}` | `adminGetWork` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | admin | GET | `/admin-api/muse/content/works/{workId}/chapters` | `adminListChapters` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | admin | POST | `/admin-api/muse/content/works/{workId}/risk-actions` | `adminRiskAction` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/chapter-parse-results/{resultId}/confirm` | `confirmChapterParseResult` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/chapter-parse-results/{resultId}/reject` | `rejectChapterParseResult` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/downloads/{credentialId}` | `downloadExportPackage` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/export-tasks/{taskId}` | `getExportTask` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/import-tasks/{taskId}` | `getImportTask` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/parse-jobs/{jobId}` | `getParseJob` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/parse-jobs/{jobId}/chapters` | `listParseJobChapters` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/parse-jobs/{jobId}/chapters/batch-confirm` | `batchConfirmChapters` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/parse-jobs/{jobId}/retry` | `retryParseJob` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works` | `listWorks` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works` | `createWork` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | DELETE | `/app-api/muse/works/{workId}` | `deleteWork` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}` | `getWork` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | PUT | `/app-api/muse/works/{workId}` | `updateWork` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | DELETE | `/app-api/muse/works/{workId}/blocks/{blockId}` | `deleteBlock` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/blocks/{blockId}` | `getBlock` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | PUT | `/app-api/muse/works/{workId}/blocks/{blockId}` | `saveBlock` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/blocks/{blockId}/merge` | `mergeBlocks` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/blocks/{blockId}/revisions` | `listBlockRevisions` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/blocks/{blockId}/source-attribution` | `getBlockSourceAttribution` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/blocks/{blockId}/split` | `splitBlock` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/blocks/{blockId}/suggestion-merges` | `mergeBlockSuggestion` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/chapters` | `listChapters` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/chapters` | `createChapter` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | DELETE | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `deleteChapter` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `getChapter` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | PUT | `/app-api/muse/works/{workId}/chapters/{chapterId}` | `updateChapter` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `listBlocks` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/chapters/{chapterId}/blocks` | `createBlock` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | PUT | `/app-api/muse/works/{workId}/chapters/{chapterId}/reorder` | `reorderChapters` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/dynamic-fields/validate` | `validateDynamicFields` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/export` | `exportWork` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/export-tasks` | `createExportTask` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/import-tasks` | `createImportTask` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/meta-projections` | `listMetaProjections` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/meta-projections/{projectionKey}` | `getMetaProjection` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/parse-jobs` | `createParseJob` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/planning` | `getPlanning` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/planning/candidates` | `listPlanningCandidates` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/planning/candidates` | `createPlanningCandidate` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/planning/candidates/{candidateId}` | `getPlanningCandidate` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/planning/candidates/{candidateId}/confirm` | `confirmPlanningCandidate` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/planning/candidates/{candidateId}/discard` | `discardPlanningCandidate` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | POST | `/app-api/muse/works/{workId}/planning/style-checks` | `createStyleCheck` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | GET | `/app-api/muse/works/{workId}/planning/style-checks/{jobId}` | `getStyleCheckResult` | dedicated | completed | P1R-1 Content Real API |
|
||||
| content | app | PUT | `/app-api/muse/works/{workId}/planning/{sectionKey}` | `savePlanningItem` | dedicated | completed | P1R-1 Content Real API |
|
||||
| events | app | GET | `/app-api/muse/events` | `streamEvents` | dedicated | completed | P1R-7 End-to-End Acceptance |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/drafts` | `listKnowledgeDraftsGovernance` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/global-kbs` | `listGlobalKnowledgeBases` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs` | `createGlobalKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/global-kbs/{kbId}` | `getGlobalKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | PATCH | `/admin-api/muse/knowledge/global-kbs/{kbId}` | `updateGlobalKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/global-kbs/{kbId}/access-policies` | `listGlobalKBAccessPolicies` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/access-policies/drafts` | `saveGlobalKBAccessPolicyDraft` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/access-policies/drafts/{draftId}/publish` | `publishGlobalKBAccessPolicy` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/disable` | `disableGlobalKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/global-kbs/{kbId}/documents` | `listGlobalKBDocuments` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/documents` | `uploadGlobalKBDocument` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | DELETE | `/admin-api/muse/knowledge/global-kbs/{kbId}/documents/{documentId}` | `deleteGlobalKBDocument` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/global-kbs/{kbId}/documents/{documentId}` | `getGlobalKBDocument` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/documents/{documentId}/versions` | `createGlobalKBDocumentVersion` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/enable` | `enableGlobalKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/impact-preview` | `previewGlobalKBImpact` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/global-kbs/{kbId}/processing-tasks` | `listGlobalKBProcessingTasks` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/reindex` | `reindexGlobalKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/source-events` | `triggerGlobalKBSourceEvent` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/global-kbs/{kbId}/versions` | `listGlobalKBVersions` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | POST | `/admin-api/muse/knowledge/global-kbs/{kbId}/versions/{version}/activate` | `activateGlobalKBVersion` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/projection-tasks` | `listProjectionTasks` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | admin | GET | `/admin-api/muse/knowledge/source-bindings` | `listSourceBindings` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/entities/{entityId}` | `getEntity` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | PUT | `/app-api/muse/entities/{entityId}` | `updateEntity` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/entities/{entityId}/relations` | `listRelations` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/entities/{entityId}/relations` | `createRelation` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/installed-knowledge-bases` | `listInstalledKnowledgeBases` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | DELETE | `/app-api/muse/installed-knowledge-bases/{installId}` | `deleteInstalledKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/installed-knowledge-bases/{installId}/disable` | `disableInstalledKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/installed-knowledge-bases/{installId}/restore` | `restoreInstalledKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/knowledge-bases` | `listKnowledgeBases` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases` | `createKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | DELETE | `/app-api/muse/knowledge-bases/{kbId}` | `deleteKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/knowledge-bases/{kbId}` | `getKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | PATCH | `/app-api/muse/knowledge-bases/{kbId}` | `updateKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/disable` | `disableKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/knowledge-bases/{kbId}/documents` | `listKBDocuments` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/documents` | `uploadKBDocument` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | DELETE | `/app-api/muse/knowledge-bases/{kbId}/documents/{documentId}` | `deleteKBDocument` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/knowledge-bases/{kbId}/documents/{documentId}/versions` | `listKBDocumentVersions` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/documents/{documentId}/versions` | `createKBDocumentVersion` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/export-tasks` | `createKBExportTask` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/knowledge-bases/{kbId}/processing-tasks/{taskId}` | `getKBProcessingTask` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/publish-prechecks` | `createKBPublishReadiness` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/publish-readiness` | `createKBMarketPublishReadiness` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/publish-snapshots` | `createKBPublishSnapshot` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/reindex` | `reindexKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-bases/{kbId}/restore` | `restoreKnowledgeBase` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-drafts/{draftId}/confirm` | `confirmKnowledgeDraft` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-drafts/{draftId}/ignore` | `ignoreKnowledgeDraft` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/knowledge-drafts/{draftId}/recheck` | `recheckKnowledgeDraft` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/works/{workId}/entities` | `listEntities` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/works/{workId}/graph` | `getKnowledgeGraph` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/works/{workId}/knowledge-bindings` | `createKnowledgeBinding` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/works/{workId}/knowledge-bindings/prechecks` | `createKnowledgeBindingPrecheck` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | DELETE | `/app-api/muse/works/{workId}/knowledge-bindings/{bindingId}` | `deleteKnowledgeBinding` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/works/{workId}/knowledge-drafts` | `listKnowledgeDrafts` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | POST | `/app-api/muse/works/{workId}/knowledge-retrievals` | `retrieveKnowledgeForWork` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| knowledge | app | GET | `/app-api/muse/works/{workId}/local-knowledge` | `getLocalKnowledge` | dedicated | completed | P1R-5 Knowledge Real API |
|
||||
| market | admin | GET | `/admin-api/muse/market/appeals` | `adminListAppeals` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | GET | `/admin-api/muse/market/appeals/{appealId}` | `adminGetAppeal` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | POST | `/admin-api/muse/market/appeals/{appealId}/resolve` | `adminResolveAppeal` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | GET | `/admin-api/muse/market/assets` | `adminListMarketAssets` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | GET | `/admin-api/muse/market/assets/{assetId}` | `adminGetMarketAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | POST | `/admin-api/muse/market/assets/{assetId}/delist` | `adminDelistAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | POST | `/admin-api/muse/market/assets/{assetId}/governance-impact` | `adminPreviewGovernanceImpact` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | POST | `/admin-api/muse/market/assets/{assetId}/recall` | `adminRecallAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | GET | `/admin-api/muse/market/publish-requests` | `adminListPublishRequests` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | POST | `/admin-api/muse/market/publish-requests/{requestId}/approve` | `adminApprovePublishRequest` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | admin | POST | `/admin-api/muse/market/publish-requests/{requestId}/reject` | `adminRejectPublishRequest` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/appeals` | `submitAppeal` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/appeals/{appealId}/supplements` | `supplementAppeal` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/appeals/{appealId}/withdraw` | `withdrawAppeal` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | GET | `/app-api/muse/marketplace/assets` | `listMarketplaceAssets` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | GET | `/app-api/muse/marketplace/assets/{assetId}` | `getMarketplaceAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/bind-precheck` | `createBindPrecheck` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | DELETE | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `unfavoriteAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/favorite` | `favoriteAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | GET | `/app-api/muse/marketplace/assets/{assetId}/governance-impact` | `getGovernanceImpact` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/install` | `installMarketplaceAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/assets/{assetId}/purchase` | `purchaseAsset` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | GET | `/app-api/muse/marketplace/categories` | `listMarketplaceCategories` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/handoffs` | `createMarketplaceHandoff` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | GET | `/app-api/muse/marketplace/handoffs/{handoffToken}` | `getHandoffStatus` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/handoffs/{handoffToken}/cancel` | `cancelHandoff` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | GET | `/app-api/muse/marketplace/my-publish-records` | `listMyPublishRecords` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/publish-drafts` | `savePublishDraft` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/publish-drafts/{draftId}/checks` | `runPublishCheck` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/publish-requests` | `submitPublishRequest` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | POST | `/app-api/muse/marketplace/publish-requests/{requestId}/withdraw` | `withdrawPublishRequest` | dedicated | completed | P1R-6 Market Real API |
|
||||
| market | app | GET | `/app-api/muse/marketplace/recommendations` | `listMarketplaceRecommendations` | dedicated | completed | P1R-6 Market Real API |
|
||||
| meta | admin | GET | `/admin-api/muse/governance/function-chains` | `listFunctionChains` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/function-chains/{chainKey}/impact-preview` | `previewFunctionChainImpact` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/function-chains/{chainKey}/versions/{version}/activate` | `activateFunctionChainVersion` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | GET | `/admin-api/muse/governance/meta-schemas` | `listMetaSchemas` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | GET | `/admin-api/muse/governance/meta-schemas/{schemaKey}` | `getMetaSchema` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/drafts` | `saveMetaSchemaDraft` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/drafts/{draftVersion}/impact-preview` | `previewMetaSchemaDraftImpact` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/drafts/{draftVersion}/publish` | `publishMetaSchemaDraft` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/drafts/{draftVersion}/validate` | `validateMetaSchemaDraft` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | GET | `/admin-api/muse/governance/meta-schemas/{schemaKey}/versions/{version}` | `getMetaSchemaVersion` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/versions/{version}/activate` | `activateMetaSchemaVersion` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/versions/{version}/deprecate` | `deprecateMetaSchemaVersion` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/versions/{version}/gray-rules` | `setMetaSchemaGrayRules` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | POST | `/admin-api/muse/governance/meta-schemas/{schemaKey}/versions/{version}/rollback` | `rollbackMetaSchemaVersion` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | GET | `/admin-api/muse/governance/protection-nodes` | `listProtectionNodes` | dedicated | completed | P1R-2 Meta Real API |
|
||||
| meta | admin | GET | `/admin-api/muse/governance/protection-nodes/{nodeKey}` | `getProtectionNode` | dedicated | completed | P1R-2 Meta Real API |
|
||||
|
||||
## Incomplete Or Blocked
|
||||
@ -1,7 +1,8 @@
|
||||
# Muse 项目功能与进度总览
|
||||
|
||||
> **定位(权威边界)**:本页是**进度台账的人读封面**(给人一眼看全局)。**进度的机械唯一源 = 覆盖门 JSON [`p1r-api-coverage.json`](superpowers/reports/p1r-api-coverage.json)(由 `P1rApiCoverageReportTest` 校验);叙述权威 = 主台账 [`进度总账.md`](mvp/进度总账.md)**。本页数字为 **2026-06-20 口径快照**,如与机械源/主台账冲突,**以后者为准、不以本页为准**。
|
||||
> **两个口径别混**:① **接口覆盖门**(机械)=每个 HTTP 端点是否有"真实 PG IT + testFiles 证据";② **整体完成度**(多维评估)=含前端/跨 BC 集成。覆盖门 100% **≠** 产品可用(对抗复盘 R4:禁用单一% 当可用度)。
|
||||
> **1.0.0 交付口径已升级**:可闭环路线以 [`1.0.0-交付计划.md`](mvp/1.0.0-交付计划.md) 为准;真验证判据以 [`1.0.0-真验证清单.md`](mvp/1.0.0-真验证清单.md) 为准。
|
||||
> **三个口径别混**:① **接口覆盖门**(机械)=端点 coverage/testFiles 台账门;② **真验证层级**=local-ci / real-PG / external-live / frontend-e2e / mock-only / skipped;③ **整体完成度**(多维评估)=含前端/跨 BC 集成。覆盖门 100% **≠** 产品可用(对抗复盘 R4:禁用单一% 当可用度)。
|
||||
> **图例**:✅ 完成并验证 · 🟡 部分/进行中 · 🔴 缺失或受阻 · ⚪ 设计上暂关闭 · — 不适用
|
||||
|
||||
---
|
||||
@ -10,17 +11,17 @@
|
||||
|
||||
| 维度 | 状态 |
|
||||
|---|---|
|
||||
| **整体完成度(估)** | **~76%** |
|
||||
| **整体完成度(估)** | **~77%** |
|
||||
| 后端业务实现 | **~80%**(真实端到端逻辑,无桩) |
|
||||
| 前端用户端 muse-studio | **~40%**(AI 候选链✅;知识草稿动作/图谱CRUD/发布预检✅、市场许可详情/handoff预检✅、个人中心记录三件套✅;剩 content 正文台、市场发布写端、申诉等) |
|
||||
| 前端管理端 muse-admin | 较完整(meta 治理、市场审核、系统权限均真实) |
|
||||
| 接口覆盖门(机械口径) | **233/233 通过(100%)**——每条带 testFiles 证据 + 门禁校验;⚠️ **接口门 100% ≠ 端到端可用**(故整体仍 ~76%) |
|
||||
| 接口覆盖门(机械口径) | **241/241 通过(100%)**——每条带 testFiles 证据 + 门禁校验;⚠️ **接口门 100% ≠ 端到端可用**(故整体仍 ~77%) |
|
||||
| 跨 BC 集成 | 偏低(多处 facade 仍 Unavailable) |
|
||||
|
||||
**一句话**:Muse 是一套**真实、高质量的后端实现**,配**诚实的验证门**,但**用户端前端严重欠缺**。"AI 绝不黑箱改稿、须用户复核"的目标后端已具备(`mergeBlockSuggestion` 已证),用户可见层亦已接通(WorkspacePage 2026-06-14 调该端点、e2e 2/2 绿)。非空壳、非假完成——是待补其余前端(知识/市场/个人中心) + 跨 BC 接线的真实工程。
|
||||
|
||||
**三大风险(须优先)**
|
||||
1. ✅ **前端 AI 候选链已通**(2026-06-14 MVP#1,e2e 2/2 绿):WorkspacePage 挂 AIPanel/CandidatePanel→真 `suggestion-merges`,五环节(生成→SSE→Diff→采纳→回显)全真后端;唯"真实 AI 生成"依 New-API(scope 外)。
|
||||
1. ✅ **前端 AI 候选链已通**(2026-06-14 MVP#1,e2e 2/2 绿;2026-06-27 E1 续补):WorkspacePage 挂 AIPanel/CandidatePanel→真 `suggestion-merges`,五环节(生成→SSE→Diff→采纳→回显)全真后端;真实 AI 生成归 `external-live` 层显式复跑。
|
||||
2. 🔴 **跨 BC facade 仍 Unavailable**:meta 影响预览、account 的 New-API/FileService/Market 投影 → meta 发布链运行期受阻;account 21/33 端点返 `*_UNAVAILABLE`。
|
||||
3. 🔴 **前端用户端大面积缺口**:知识工作台缺 2/3、市场生产端 0 UI、个人中心仅 ~21% 有 UI。
|
||||
|
||||
@ -30,17 +31,17 @@
|
||||
|
||||
| 模块 | 完成度 | 后端 | 前端 studio | 前端 admin | 接口门(机械) | 关键风险/缺口 |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|---|
|
||||
| **AI 编排** (ai) | 72% | ✅ 真实 | 🟡 全 MSW | — | 41/41 | New-API 真端到端未验;grant/runtime 物理隔离仅最小 ArchUnit |
|
||||
| **作品/正文** (content) | 82% | ✅ 真实 | 🔴 候选链断 | — | 51/51 | **Accept Suggestion 前端缺**;EditorPage 仍 demo 壳 |
|
||||
| **知识库** (knowledge) | 72% | ✅ 真实 | 🟡 ~1/3 UI | — | 59/59 | 工作台缺 2/3;recheck/outbox worker ⚪默认关 |
|
||||
| **市场** (market) | 82% | ✅ 真实 | 🔴 生产端无 UI | ✅ 真实 | 32/32 | 发布/handoff/申诉**生产者 UI 缺**(草稿保存已通) |
|
||||
| **AI 编排** (ai) | 78% | ✅ 真实 | ✅ 核心生命周期/运行时真 e2e | — | 41/41 | 质量评测执行器后置;grant/runtime 物理隔离仅最小 ArchUnit |
|
||||
| **作品/正文** (content) | 89% | ✅ 真实 | ✅ 候选链/工作台入口/版本历史已接 | — | 52/52 | E1 黄金旅程 MSW-off Playwright 2/2;EditorPage 仍 demo 壳 |
|
||||
| **知识库** (knowledge) | 82% | ✅ 真实 | 🟡 检索/绑定/上传/召回阻断已接真后端 | — | 67/67 | E3/E4 真 e2e 已覆盖核心;图谱/发布深面仍薄 |
|
||||
| **市场** (market) | 86% | ✅ 真实 | 🟡 消费端+handoff 已真连,生产端缺 | ✅ 真实 | 32/32 | agent 物化/KB 召回触达已闭环;发布/申诉生产者 UI 缺 |
|
||||
| **元引擎/治理** (meta) | 82% | ✅ 真实 | 🟡 用户端引擎缺 | ✅ 真实 | 16/16 | **影响预览 Unavailable → 发布链运行期阻塞**;facade-api 契约未建 |
|
||||
| **事件/SSE** (events) | 88% | ✅ 真实 | 🟡 统一客户端未接 | — | 1/1 | `connectEventStream` 仅自测、无组件消费 |
|
||||
| **账户/会员** (member) | 68% | ✅ 真实 | 🔴 ~21% UI | 🟡 受限桩 | 33/33 | 21 端点无 UI;New-API/FileService facade Unavailable |
|
||||
| **平台底座** (gateway/system/infra…) | 80% | ✅ 真实 | ✅ 真实 | ✅ 真实 | — | 网关运行期转发未验(Nacos);pay/bpm/mp/report ⚪默认隐藏、0 覆盖 |
|
||||
| **整体** | **~76%** | **~80%** | **~30%** | 良好 | **233/233** | 前端用户端缺口 + 跨 BC 集成偏低 |
|
||||
| **整体** | **~80%** | **~82%** | **~40%** | 良好 | **241/241** | 前端用户端深面缺口 + 跨 BC 集成仍需 E5-E7 |
|
||||
|
||||
> **接口门(机械口径)**:每个 HTTP 端点须 MockMvc + 真实 PostgreSQL IT + Flyway + 覆盖 JSON 的 `testFiles` 证据锚点,且无 catch_all/generic/sse_placeholder/missing/blocked。**当前 233/233 全通过**(机械源:[覆盖 JSON](superpowers/reports/p1r-api-coverage.json),经 `P1rApiCoverageReportTest` 校验 summary 自算防手工拨 + testFiles 指向真实测试源)。
|
||||
> **接口门(机械口径)**:每个 HTTP 端点须 MockMvc + 真实 PostgreSQL IT + Flyway + 覆盖 JSON 的 `testFiles` 证据锚点,且无 catch_all/generic/sse_placeholder/missing/blocked。**当前 241/241 全通过**(机械源:[覆盖 JSON](superpowers/reports/p1r-api-coverage.json),经 `P1rApiCoverageReportTest` 校验 summary 自算防手工拨 + testFiles 指向真实测试源)。
|
||||
> ⚠️ **接口门 ≠ 完成度列**:完成度含前端/跨 BC,故同模块可"接口门 full 但完成度 72%"。⚠️ 覆盖 JSON 的 `generatedAt=2026-05-25` 未随内容刷新(手工维护痕迹),以 summary + testFiles 锚点为准,不以该时间戳为准。
|
||||
|
||||
---
|
||||
@ -71,22 +72,22 @@
|
||||
|---|:---:|:---:|---|
|
||||
| 我的作品(列表/新建/导入) | ✅ | ✅ | WorkspacePage 接真 `/works` |
|
||||
| 写作台·章节编辑 + IndexedDB 自动保存 | ✅ | 🟡 | MuseEditor 真实;EditorPage 仍 demo 壳(workId/blockId/revision 硬编码) |
|
||||
| **写作台·AI 候选生成 + 接受(原样/改后/丢弃)** | ✅ | ✅ | WorkspacePage 接通 `suggestion-merges`,e2e 2/2 绿(真实生成依 New-API,scope 外) |
|
||||
| **写作台·AI 候选生成 + 接受(原样/改后/丢弃)** | ✅ | ✅ | WorkspacePage 接通 `suggestion-merges` 与 `suggestions/{id}/reject`;E1 已补原样/改后合并、采纳归档与旧知识草稿失效,E2 已补拒绝归档,真实生成归 external-live 层复跑 |
|
||||
| 作品规划台(设定/大纲/世界/角色/文风) | ✅ | 🟡 | 后端就绪;FE 部分 |
|
||||
| 知识与一致性(草稿确认/修正/冲突检查) | ✅ | 🔴 | 后端就绪;FE 无确认/修正组件 |
|
||||
| 导入解析(上传/按章确认→知识草稿) | ✅ | 🟡 | 后端状态机完整 |
|
||||
| 导出交付(范围/格式/授权校验) | ✅ | 🔴 | 后端就绪(下载凭证校验);FE 无工作流 |
|
||||
| 记录与用量 / 作品设置 | ✅ | 🟡 | 后端就绪 |
|
||||
| 知识与一致性(草稿确认/修正/冲突检查) | ✅ | 🟡 | KnowledgeDraftPanel 已有确认/忽略/重验入口;E1 已补 Content 正文变更后旧草稿 `needs_recheck` |
|
||||
| 导入解析(上传/按章确认→知识草稿) | ✅ | 🟡 | E1 已补工作台最小导入任务入口;完整上传/解析确认向导仍后置 |
|
||||
| 导出交付(范围/格式/授权校验) | ✅ | 🟡 | E1 已补工作台 TXT 导出任务入口;下载凭证与范围选择 UI 后置 |
|
||||
| 记录与用量 / 作品设置 | ✅ | 🟡 | E1 已补工作台记录入口;账户中心记录面仍按 member 进度推进 |
|
||||
|
||||
### 3. 智能体工作台(ai) — 后端 ✅ / 前端 🟡(多为 MSW)
|
||||
### 3. 智能体工作台(ai) — 后端 ✅ / 前端 ✅(核心生命周期)
|
||||
*规范:产品-02D*
|
||||
|
||||
| 功能 | 后端 | 前端 studio | 备注 |
|
||||
|---|:---:|:---:|---|
|
||||
| 智能体列表 / 新建 | ✅ | ✅ | agent-create e2e 1/1 真实单租户落库 |
|
||||
| 配置型 / 工作流型编辑 | ✅ | 🟡 | DEV 走 MSW |
|
||||
| 智能体列表 / 新建 / 编辑 / 归档 | ✅ | ✅ | E2 `agent-create.spec.ts` 真实后端覆盖创建、编辑生成 v2、版本切换、归档版本、归档 Agent |
|
||||
| 配置型 / 工作流型编辑 | ✅ | 🟡 | 配置型核心生命周期已接真后端;工作流型深编辑仍后置 |
|
||||
| 试用与评估(沙盒) | ✅ | 🟡 | 后端就绪 |
|
||||
| 槽位兼容 / 作品槽位替换(预检) | ✅ | 🟡 | 后端就绪;FE 真替换未完 |
|
||||
| 槽位兼容 / 作品槽位替换(预检/解绑) | ✅ | ✅ | E2 `agent-slot-bind.spec.ts` 真实后端覆盖替换与解绑落库 |
|
||||
| 已安装与授权 / 发布准备 / 运行记录 | ✅ | 🟡 | 后端就绪 |
|
||||
|
||||
### 4. 知识库工作台(knowledge) — 后端 ✅ / 前端 🟡(主体已接:草稿动作/图谱CRUD/绑定/发布预检)
|
||||
@ -112,7 +113,7 @@
|
||||
| 资产获取与授权(install 仅 agent/kb) | ✅ | ✅ | work 强制只读、排除可装 |
|
||||
| 资产详情·许可条款·收藏 | ✅ | ✅ | 详情页全量许可 + 收藏(e2e market-asset-detail 1/1) |
|
||||
| 发布者控制面(草稿/提交/审核/上架) | ✅ | 🔴 | **草稿保存已通(e2e 8/8),其余生产者 UI 缺** |
|
||||
| Handoff 一次性令牌 | ✅ | 🟡 | 来源侧 bind-precheck 接通(e2e 1/1);令牌消费端 UI 仍缺(知识域消费,一侧未做) |
|
||||
| Handoff 一次性令牌 | ✅ | ✅ | knowledge/agent/content 三类消费端均已真后端 e2e;E4 补 agent 物化与 KB 召回阻断 |
|
||||
| 申诉(提交/补充/撤回/状态) | ✅ | 🔴 | 后端 IT 全绿;FE 无 |
|
||||
| 市场审核治理(管理端) | ✅ | ✅(admin) | 见 §1 |
|
||||
|
||||
@ -150,7 +151,7 @@
|
||||
|
||||
| 优先级 | 待办 | 所属 | 阻塞原因 |
|
||||
|:---:|---|---|---|
|
||||
| ✅ 完成 | **AI 候选接受链已接通**:WorkspacePage 挂 AIPanel/CandidatePanel→真 `suggestion-merges`(EditorPage 为废弃演示页,故意不接) | content/studio | 2026-06-14 MVP#1;e2e 2/2 绿;「审」字段后端已补;真实生成依 New-API(scope 外) |
|
||||
| ✅ 完成 | **AI 候选接受链已接通**:WorkspacePage 挂 AIPanel/CandidatePanel→真 `suggestion-merges`(EditorPage 为废弃演示页,故意不接) | content/studio | 2026-06-14 MVP#1;e2e 2/2 绿;E1 已补改后合并、采纳归档、旧知识草稿失效;真实生成归 external-live 层复跑 |
|
||||
| 🟡 部分 | **知识工作台**:忽略/recheck、实体关系 CRUD、发布可发布性预检均已接(2026-06-20);剩处理状态/失败恢复 UI | knowledge/studio | 仅前端未接 |
|
||||
| 🟡 部分 | **市场端**:许可详情/收藏、handoff 来源侧预检、**handoff 令牌消费端 knowledge(P1)+agent(P2)+content(P3 asset_use)三 owner token 红线闭环全覆盖(真后端 e2e;⚠️ market 资产物化维度未接、列独立后续主线)**已接;剩发布工作流写端、申诉 UI | market/studio | 仅前端未接 |
|
||||
| 🟡 部分 | **个人中心**:购买/授权/发布记录三件套已接(2026-06-20);剩安全事件确认入口/导出下载/New-API绑定/配额申请 | member/studio | 部分前端未接 + 部分 facade Unavailable |
|
||||
|
||||
@ -141,6 +141,38 @@ vi.mock('#/api/muse/account', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/system/permission', () => ({
|
||||
assignUserRole: vi.fn(),
|
||||
getUserRoleList: vi.fn().mockResolvedValue([1]),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/system/role', () => ({
|
||||
getSimpleRoleList: vi.fn().mockResolvedValue([
|
||||
{
|
||||
code: 'muse-admin',
|
||||
id: 1,
|
||||
name: 'Muse 管理员',
|
||||
status: 0,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/system/user', () => ({
|
||||
getUserPage: vi.fn().mockResolvedValue({
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
mobile: '13800000000',
|
||||
nickname: '系统管理员',
|
||||
status: 0,
|
||||
username: 'admin',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
updateUserStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/muse/audit', () => ({
|
||||
getApiAccessLogApi: vi.fn(),
|
||||
getBusinessAuditEventApi: vi.fn(),
|
||||
@ -232,10 +264,14 @@ describe('muse admin governance pages', () => {
|
||||
const text = await renderPage(() => import('../account/index.vue'));
|
||||
|
||||
expect(text).toContain('用户、角色与权限治理');
|
||||
expect(text).toContain('系统管理员账号与角色权限');
|
||||
expect(text).toContain('不使用业务账号用户 ID');
|
||||
expect(text).toContain('脱敏对账摘要');
|
||||
expect(text).toContain('不提供用户侧完整个人中心或私有正文入口');
|
||||
expect(text).toContain('二次确认、原因、复核引用和审计记录');
|
||||
expect(text).toContain('管理员验证用户');
|
||||
expect(text).toContain('系统管理员');
|
||||
expect(text).toContain('Muse 管理员');
|
||||
});
|
||||
|
||||
it('New-API 页不展示凭据且只处理治理归属', async () => {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* 管理员在此查看账号治理摘要、权益配额、用量和购买脱敏记录。
|
||||
* 账号封禁、角色和权限组变更的后端写契约尚未出现在 account openapi 中,
|
||||
* 页面仅提供二次确认、原因、复核和审计字段承载,不伪造未定义的写接口。
|
||||
* 系统管理员账号、角色和权限组属于 system BC,必须通过 system 接口独立治理。
|
||||
*/
|
||||
import type {
|
||||
AdminAccountUserSummary,
|
||||
@ -16,10 +17,13 @@ import type {
|
||||
QuotaAdjustmentLedgerEntry,
|
||||
QuotaStatus,
|
||||
} from '#/api/muse/account';
|
||||
import type { SystemRoleApi } from '#/api/system/role';
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { CommonStatusEnum } from '@vben/constants';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@ -46,6 +50,9 @@ import {
|
||||
listQuotaAdjustmentsApi,
|
||||
listUsageRecordsApi,
|
||||
} from '#/api/muse/account';
|
||||
import { assignUserRole, getUserRoleList } from '#/api/system/permission';
|
||||
import { getSimpleRoleList } from '#/api/system/role';
|
||||
import { getUserPage, updateUserStatus } from '#/api/system/user';
|
||||
|
||||
/** 账号治理动作类型。 */
|
||||
type GovernanceAction =
|
||||
@ -109,6 +116,16 @@ interface QuotaAdjustmentForm {
|
||||
commandId: string;
|
||||
}
|
||||
|
||||
/** 管理员角色分配表单。 */
|
||||
interface AdminRoleAssignmentForm {
|
||||
/** 管理员用户 ID。 */
|
||||
userId?: number;
|
||||
/** 管理员账号。 */
|
||||
username: string;
|
||||
/** 待分配角色 ID 集合。 */
|
||||
roleIds: number[];
|
||||
}
|
||||
|
||||
/** 账号状态标签颜色。 */
|
||||
const accountStatusColor: Record<AccountStatus, string> = {
|
||||
active: 'green',
|
||||
@ -145,6 +162,11 @@ function asAccountUser(record: TableRecord) {
|
||||
return record as unknown as AdminAccountUserSummary;
|
||||
}
|
||||
|
||||
/** 将 Ant Table 通用记录转回系统管理员用户类型。 */
|
||||
function asSystemUser(record: TableRecord) {
|
||||
return record as unknown as SystemUserApi.User;
|
||||
}
|
||||
|
||||
/** 账号状态颜色,兼容模板中的 unknown 记录。 */
|
||||
function getAccountStatusColor(status: unknown) {
|
||||
return accountStatusColor[status as AccountStatus] ?? 'default';
|
||||
@ -165,6 +187,16 @@ function getQuotaStatusText(status: unknown) {
|
||||
return quotaStatusText[status as QuotaStatus] ?? displayText(String(status));
|
||||
}
|
||||
|
||||
/** 系统管理员状态标签颜色。 */
|
||||
function getSystemUserStatusColor(status: unknown) {
|
||||
return status === CommonStatusEnum.ENABLE ? 'green' : 'red';
|
||||
}
|
||||
|
||||
/** 系统管理员状态中文文案。 */
|
||||
function getSystemUserStatusText(status: unknown) {
|
||||
return status === CommonStatusEnum.ENABLE ? '启用' : '禁用';
|
||||
}
|
||||
|
||||
/** 用户表格列定义。 */
|
||||
const userColumns = [
|
||||
{ dataIndex: 'userId', key: 'userId', title: '用户 ID', width: 180 },
|
||||
@ -238,11 +270,25 @@ const purchaseColumns = [
|
||||
{ dataIndex: 'createdAt', key: 'createdAt', title: '时间' },
|
||||
];
|
||||
|
||||
/** 系统管理员表格列定义。 */
|
||||
const adminUserColumns = [
|
||||
{ dataIndex: 'id', key: 'id', title: '管理员 ID', width: 120 },
|
||||
{ dataIndex: 'username', key: 'username', title: '账号', width: 160 },
|
||||
{ dataIndex: 'nickname', key: 'nickname', title: '昵称', width: 160 },
|
||||
{ dataIndex: 'mobile', key: 'mobile', title: '手机号', width: 150 },
|
||||
{ dataIndex: 'status', key: 'status', title: '状态', width: 100 },
|
||||
{ key: 'actions', title: '权限治理', width: 260 },
|
||||
];
|
||||
|
||||
const userLoading = ref(false);
|
||||
const entitlementLoading = ref(false);
|
||||
const usageLoading = ref(false);
|
||||
const purchaseLoading = ref(false);
|
||||
const quotaSubmitting = ref(false);
|
||||
const adminUserLoading = ref(false);
|
||||
const adminRoleLoading = ref(false);
|
||||
const adminRoleSubmitting = ref(false);
|
||||
const adminStatusSubmitting = ref<number>();
|
||||
|
||||
const users = ref<AdminAccountUserSummary[]>([]);
|
||||
const selectedUser = ref<AdminAccountUserSummary>();
|
||||
@ -250,6 +296,8 @@ const selectedEntitlement = ref<AdminUserEntitlementDetail>();
|
||||
const quotaLedger = ref<QuotaAdjustmentLedgerEntry[]>([]);
|
||||
const usageRecords = ref<AdminUsageRecord[]>([]);
|
||||
const purchaseRecords = ref<AdminPurchaseRecord[]>([]);
|
||||
const adminUsers = ref<SystemUserApi.User[]>([]);
|
||||
const adminRoles = ref<SystemRoleApi.Role[]>([]);
|
||||
|
||||
/** 用户列表筛选条件。 */
|
||||
const userFilters = reactive({
|
||||
@ -262,6 +310,12 @@ const recordFilters = reactive({
|
||||
userId: '',
|
||||
});
|
||||
|
||||
/** 系统管理员列表筛选条件。 */
|
||||
const adminUserFilters = reactive({
|
||||
status: undefined as number | undefined,
|
||||
username: '',
|
||||
});
|
||||
|
||||
/** 用户列表分页状态。 */
|
||||
const userPager = reactive({
|
||||
current: 1,
|
||||
@ -283,8 +337,16 @@ const purchasePager = reactive({
|
||||
total: 0,
|
||||
});
|
||||
|
||||
/** 系统管理员列表分页状态。 */
|
||||
const adminUserPager = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const governanceModalOpen = ref(false);
|
||||
const quotaModalOpen = ref(false);
|
||||
const adminRoleModalOpen = ref(false);
|
||||
|
||||
const governanceForm = reactive<GovernanceCommandForm>({
|
||||
action: 'suspend',
|
||||
@ -305,6 +367,11 @@ const quotaForm = reactive<QuotaAdjustmentForm>({
|
||||
userId: '',
|
||||
});
|
||||
|
||||
const adminRoleForm = reactive<AdminRoleAssignmentForm>({
|
||||
roleIds: [],
|
||||
username: '',
|
||||
});
|
||||
|
||||
const governanceActionTitle = computed(() => {
|
||||
const titles: Record<GovernanceAction, string> = {
|
||||
assign_group: '调整权限组',
|
||||
@ -358,6 +425,33 @@ function getQuotaSubmissionEntitlement() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 刷新系统角色精简列表,供管理员角色分配使用。 */
|
||||
async function loadAdminRoles() {
|
||||
adminRoles.value = await getSimpleRoleList();
|
||||
}
|
||||
|
||||
/** 刷新系统管理员分页列表。 */
|
||||
async function loadAdminUsers(
|
||||
page = adminUserPager.current,
|
||||
pageSize = adminUserPager.pageSize,
|
||||
) {
|
||||
adminUserLoading.value = true;
|
||||
try {
|
||||
const data = await getUserPage({
|
||||
pageNo: page,
|
||||
pageSize,
|
||||
status: adminUserFilters.status,
|
||||
username: adminUserFilters.username || undefined,
|
||||
});
|
||||
adminUsers.value = data.list;
|
||||
adminUserPager.current = page;
|
||||
adminUserPager.pageSize = pageSize;
|
||||
adminUserPager.total = data.total;
|
||||
} finally {
|
||||
adminUserLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新用户账户摘要列表。 */
|
||||
async function loadUsers(page = userPager.current, pageSize = userPager.pageSize) {
|
||||
userLoading.value = true;
|
||||
@ -454,6 +548,11 @@ function handlePurchaseTableChange(pager: TablePager) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 系统管理员表格分页变化。 */
|
||||
function handleAdminUserTableChange(pager: TablePager) {
|
||||
loadAdminUsers(pager.current ?? 1, pager.pageSize ?? adminUserPager.pageSize);
|
||||
}
|
||||
|
||||
/** 打开账号、角色或权限组治理确认弹窗。 */
|
||||
function openGovernanceModal(
|
||||
record: AdminAccountUserSummary,
|
||||
@ -485,6 +584,16 @@ function handleOpenQuotaModal(record: TableRecord) {
|
||||
openQuotaModal(asAccountUser(record));
|
||||
}
|
||||
|
||||
/** 表格事件包装:打开系统管理员角色分配弹窗。 */
|
||||
function handleOpenAdminRoleModal(record: TableRecord) {
|
||||
return openAdminRoleModal(asSystemUser(record));
|
||||
}
|
||||
|
||||
/** 表格事件包装:更新系统管理员启停状态。 */
|
||||
function handleUpdateAdminUserStatus(record: TableRecord, status: number) {
|
||||
return updateAdminUserStatus(asSystemUser(record), status);
|
||||
}
|
||||
|
||||
/** 提交账号治理确认;当前契约无写接口,因此不调用后端伪造结果。 */
|
||||
function submitGovernanceCommand() {
|
||||
if (!governanceForm.reason.trim()) {
|
||||
@ -577,6 +686,63 @@ async function submitQuotaAdjustment() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开系统管理员角色分配弹窗,并读取当前真实角色绑定。 */
|
||||
async function openAdminRoleModal(record: SystemUserApi.User) {
|
||||
if (!record.id) {
|
||||
message.error('缺少管理员用户 ID');
|
||||
return;
|
||||
}
|
||||
adminRoleForm.userId = record.id;
|
||||
adminRoleForm.username = record.username;
|
||||
adminRoleForm.roleIds = [];
|
||||
adminRoleModalOpen.value = true;
|
||||
adminRoleLoading.value = true;
|
||||
try {
|
||||
if (!adminRoles.value.length) {
|
||||
await loadAdminRoles();
|
||||
}
|
||||
const roleIds = await getUserRoleList(record.id);
|
||||
adminRoleForm.roleIds = Array.isArray(roleIds) ? roleIds : [];
|
||||
} finally {
|
||||
adminRoleLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交系统管理员角色分配,后端负责自改角色等高危动作兜底拒绝。 */
|
||||
async function submitAdminRoleAssignment() {
|
||||
if (!adminRoleForm.userId) {
|
||||
message.error('缺少管理员用户 ID');
|
||||
return;
|
||||
}
|
||||
adminRoleSubmitting.value = true;
|
||||
try {
|
||||
await assignUserRole({
|
||||
roleIds: adminRoleForm.roleIds,
|
||||
userId: adminRoleForm.userId,
|
||||
});
|
||||
message.success('管理员角色已更新');
|
||||
adminRoleModalOpen.value = false;
|
||||
} finally {
|
||||
adminRoleSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新系统管理员状态,后端负责自封禁等高危动作兜底拒绝。 */
|
||||
async function updateAdminUserStatus(record: SystemUserApi.User, status: number) {
|
||||
if (!record.id) {
|
||||
message.error('缺少管理员用户 ID');
|
||||
return;
|
||||
}
|
||||
adminStatusSubmitting.value = record.id;
|
||||
try {
|
||||
await updateUserStatus(record.id, status);
|
||||
message.success('管理员状态已更新');
|
||||
await loadAdminUsers();
|
||||
} finally {
|
||||
adminStatusSubmitting.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用选中用户过滤账户记录。 */
|
||||
function filterRecordsBySelectedUser() {
|
||||
recordFilters.userId = selectedUser.value?.userId ?? '';
|
||||
@ -588,6 +754,8 @@ onMounted(() => {
|
||||
loadUsers();
|
||||
loadUsageRecords();
|
||||
loadPurchaseRecords();
|
||||
loadAdminRoles();
|
||||
loadAdminUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -743,6 +911,97 @@ onMounted(() => {
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="admin-permissions" tab="管理员权限">
|
||||
<Card class="mb-4" title="系统管理员账号与角色权限">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
show-icon
|
||||
type="warning"
|
||||
message="这里治理 system 管理员账号和角色,不使用业务账号用户 ID;自封禁和自改角色由后端强制拒绝。"
|
||||
/>
|
||||
<Space class="mb-4" wrap>
|
||||
<Input
|
||||
v-model:value="adminUserFilters.username"
|
||||
allow-clear
|
||||
placeholder="管理员账号"
|
||||
style="width: 220px"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="adminUserFilters.status"
|
||||
allow-clear
|
||||
placeholder="管理员状态"
|
||||
style="width: 160px"
|
||||
>
|
||||
<Select.Option :value="CommonStatusEnum.ENABLE">
|
||||
启用
|
||||
</Select.Option>
|
||||
<Select.Option :value="CommonStatusEnum.DISABLE">
|
||||
禁用
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button
|
||||
type="primary"
|
||||
@click="loadAdminUsers(1, adminUserPager.pageSize)"
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button @click="loadAdminUsers()">刷新</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
row-key="id"
|
||||
:columns="adminUserColumns"
|
||||
:data-source="adminUsers"
|
||||
:loading="adminUserLoading"
|
||||
:pagination="adminUserPager"
|
||||
size="middle"
|
||||
@change="handleAdminUserTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag :color="getSystemUserStatusColor(record.status)">
|
||||
{{ getSystemUserStatusText(record.status) }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<Space wrap>
|
||||
<Button type="link" @click="handleOpenAdminRoleModal(record)">
|
||||
分配角色
|
||||
</Button>
|
||||
<Button
|
||||
v-if="record.status === CommonStatusEnum.ENABLE"
|
||||
danger
|
||||
:loading="adminStatusSubmitting === record.id"
|
||||
type="link"
|
||||
@click="
|
||||
handleUpdateAdminUserStatus(
|
||||
record,
|
||||
CommonStatusEnum.DISABLE,
|
||||
)
|
||||
"
|
||||
>
|
||||
禁用
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:loading="adminStatusSubmitting === record.id"
|
||||
type="link"
|
||||
@click="
|
||||
handleUpdateAdminUserStatus(
|
||||
record,
|
||||
CommonStatusEnum.ENABLE,
|
||||
)
|
||||
"
|
||||
>
|
||||
启用
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="records" tab="用量与购买摘要">
|
||||
<Card class="mb-4" title="脱敏对账摘要">
|
||||
<Space class="mb-4" wrap>
|
||||
@ -902,5 +1161,40 @@ onMounted(() => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
v-model:open="adminRoleModalOpen"
|
||||
:confirm-loading="adminRoleSubmitting"
|
||||
title="分配管理员角色"
|
||||
@ok="submitAdminRoleAssignment"
|
||||
>
|
||||
<Alert
|
||||
class="mb-4"
|
||||
show-icon
|
||||
type="warning"
|
||||
message="角色变更直接调用 system 权限接口;自改角色等高危动作由后端拒绝。"
|
||||
/>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="管理员账号">
|
||||
<Input v-model:value="adminRoleForm.username" disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色" required>
|
||||
<Select
|
||||
v-model:value="adminRoleForm.roleIds"
|
||||
:loading="adminRoleLoading"
|
||||
mode="multiple"
|
||||
placeholder="选择管理员角色"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="role in adminRoles"
|
||||
:key="role.id"
|
||||
:value="role.id"
|
||||
>
|
||||
{{ role.name }} / {{ role.code }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -36,9 +36,36 @@ vi.mock('ant-design-vue', () => {
|
||||
},
|
||||
});
|
||||
const Modal = defineComponent({
|
||||
emits: ['cancel', 'ok'],
|
||||
props: ['open'],
|
||||
setup(props, { slots }) {
|
||||
return () => (props.open ? h('div', { class: 'modal-stub' }, slots.default?.()) : null);
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
props.open
|
||||
? h('div', { class: 'modal-stub' }, [
|
||||
slots.default?.(),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-test': 'modal-ok',
|
||||
onClick: () => emit('ok'),
|
||||
},
|
||||
'确认',
|
||||
),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const Textarea = defineComponent({
|
||||
emits: ['update:value'],
|
||||
props: ['placeholder', 'value'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('textarea', {
|
||||
placeholder: props.placeholder,
|
||||
value: props.value,
|
||||
onInput: (event: Event) =>
|
||||
emit('update:value', (event.target as HTMLTextAreaElement).value),
|
||||
});
|
||||
},
|
||||
});
|
||||
const Tabs = renderDefaultSlot({ class: 'tabs-stub' }) as unknown as {
|
||||
@ -62,7 +89,7 @@ vi.mock('ant-design-vue', () => {
|
||||
Table: renderDefaultSlot(),
|
||||
Tabs,
|
||||
Tag: renderDefaultSlot(),
|
||||
Textarea: renderDefaultSlot(),
|
||||
Textarea,
|
||||
Tooltip: renderDefaultSlot(),
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
@ -72,6 +99,14 @@ vi.mock('ant-design-vue', () => {
|
||||
});
|
||||
|
||||
vi.mock('#/api/muse/ai', () => ({
|
||||
activatePromptVersionApi: vi.fn(async () => ({
|
||||
activeVersion: 4,
|
||||
promptKey: 'plot.planner',
|
||||
})),
|
||||
createQualityPolicyVersionApi: vi.fn(async () => ({
|
||||
policyKey: 'candidate.delivery',
|
||||
version: 3,
|
||||
})),
|
||||
getAgentPageApi: vi.fn(async () => ({
|
||||
list: [
|
||||
{
|
||||
@ -156,8 +191,14 @@ vi.mock('#/api/muse/ai', () => ({
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
})),
|
||||
startEvaluationRunApi: vi.fn(async () => ({
|
||||
jobId: 'job-100',
|
||||
runId: 'run-100',
|
||||
})),
|
||||
}));
|
||||
|
||||
const aiApi = await import('#/api/muse/ai');
|
||||
|
||||
describe('muse ai config page', () => {
|
||||
it('renders AI governance domains and blocks user private agent management', async () => {
|
||||
const { container, unmount } = await mountPage();
|
||||
@ -175,24 +216,98 @@ describe('muse ai config page', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('opens impact preview before risky quality policy operations', async () => {
|
||||
it('activates prompt version through the real admin API', async () => {
|
||||
const { container, unmount } = await mountPage();
|
||||
await flushPromises();
|
||||
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[data-test="quality-publish-preview"]',
|
||||
'[data-test="prompt-activate-command"]',
|
||||
);
|
||||
button?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(container.textContent).toContain('影响预览');
|
||||
expect(container.textContent).toContain('操作原因');
|
||||
expect(container.textContent).toContain('审计回执字段');
|
||||
fillReason(container, 'E6 Prompt 激活验证');
|
||||
container.querySelector<HTMLButtonElement>('[data-test="modal-ok"]')?.click();
|
||||
await flushPromises();
|
||||
|
||||
expect(aiApi.activatePromptVersionApi).toHaveBeenCalledWith(
|
||||
'plot.planner',
|
||||
4,
|
||||
expect.objectContaining({
|
||||
reason: 'E6 Prompt 激活验证',
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('starts quality evaluation through the real admin API and shows returned run', async () => {
|
||||
const { container, unmount } = await mountPage();
|
||||
await flushPromises();
|
||||
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-test="quality-evaluation-command"]')
|
||||
?.click();
|
||||
await nextTick();
|
||||
|
||||
fillReason(container, 'E6 质量评估验证');
|
||||
const datasetInput = Array.from(container.querySelectorAll('textarea')).find(
|
||||
(textarea) => textarea.placeholder.includes('评估集引用'),
|
||||
);
|
||||
datasetInput!.value = 'redacted-fixture-v1';
|
||||
datasetInput!.dispatchEvent(new Event('input'));
|
||||
await nextTick();
|
||||
|
||||
container.querySelector<HTMLButtonElement>('[data-test="modal-ok"]')?.click();
|
||||
await flushPromises();
|
||||
|
||||
expect(aiApi.startEvaluationRunApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
datasetReference: 'redacted-fixture-v1',
|
||||
policyKey: 'candidate.delivery',
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toContain('run-100');
|
||||
expect(container.textContent).toContain('job-100');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('creates quality policy version through the real admin API', async () => {
|
||||
const { container, unmount } = await mountPage();
|
||||
await flushPromises();
|
||||
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-test="quality-publish-command"]')
|
||||
?.click();
|
||||
await nextTick();
|
||||
|
||||
fillReason(container, 'E6 质量策略版本验证');
|
||||
container.querySelector<HTMLButtonElement>('[data-test="modal-ok"]')?.click();
|
||||
await flushPromises();
|
||||
|
||||
expect(aiApi.createQualityPolicyVersionApi).toHaveBeenCalledWith(
|
||||
'candidate.delivery',
|
||||
expect.objectContaining({
|
||||
changeNote: 'E6 质量策略版本验证',
|
||||
dimensions: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'canon_compliance' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
function fillReason(container: HTMLElement, value: string) {
|
||||
const reasonInput = Array.from(container.querySelectorAll('textarea')).find(
|
||||
(textarea) => textarea.placeholder.includes('操作原因'),
|
||||
);
|
||||
reasonInput!.value = value;
|
||||
reasonInput!.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const { default: AiConfigPage } = await import('../index.vue');
|
||||
const container = document.createElement('div');
|
||||
|
||||
@ -39,15 +39,28 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
activatePromptVersionApi,
|
||||
createQualityPolicyVersionApi,
|
||||
getAgentPageApi,
|
||||
getPromptPageApi,
|
||||
getQualityPolicyPageApi,
|
||||
getToolGrantPageApi,
|
||||
startEvaluationRunApi,
|
||||
} from '#/api/muse/ai';
|
||||
|
||||
/** 页面标签 key。 */
|
||||
type AiConfigTabKey = 'agents' | 'prompts' | 'quality' | 'tool-grants';
|
||||
|
||||
/** 高危操作命令类型。 */
|
||||
type RiskCommandType =
|
||||
| 'agent_archive_preview'
|
||||
| 'agent_release_preview'
|
||||
| 'prompt_activate'
|
||||
| 'quality_evaluate'
|
||||
| 'quality_publish'
|
||||
| 'quality_rollback_preview'
|
||||
| 'tool_grant_preview';
|
||||
|
||||
/** 高危操作预览弹窗状态。 */
|
||||
interface RiskPreviewState {
|
||||
/** 弹窗是否打开。 */
|
||||
@ -62,6 +75,18 @@ interface RiskPreviewState {
|
||||
impacts: string[];
|
||||
/** 审计回执字段。 */
|
||||
auditFields: string[];
|
||||
/** 命令类型,决定确认时是否调用真实后端写接口。 */
|
||||
commandType: RiskCommandType;
|
||||
/** 目标 Prompt Key。 */
|
||||
promptKey?: string;
|
||||
/** 目标质量策略 Key。 */
|
||||
policyKey?: string;
|
||||
/** 目标版本号。 */
|
||||
targetVersion?: number;
|
||||
/** 评估数据集引用。 */
|
||||
datasetReference: string;
|
||||
/** 评估采样数量。 */
|
||||
sampleSize?: number;
|
||||
}
|
||||
|
||||
/** 质量维度配置预设;当前列表接口只返回维度数量,页面用规格中的核心维度展示治理入口。 */
|
||||
@ -78,10 +103,11 @@ interface QualityDimensionPreset {
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 最近评估摘要;后端 evaluation-runs 详情接口落地后可由运行结果刷新。 */
|
||||
interface EvaluationSummary extends Partial<EvaluationRunVO> {
|
||||
/** 评估结果标题。 */
|
||||
title: string;
|
||||
/** 异步任务 ID。 */
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
const activeTab = ref<AiConfigTabKey>('prompts');
|
||||
@ -101,10 +127,16 @@ const loadingState = reactive({
|
||||
/** 高危操作预览弹窗统一收口发布、回滚、激活、停用和授权调整入口。 */
|
||||
const riskPreview = reactive<RiskPreviewState>({
|
||||
auditFields: [],
|
||||
commandType: 'prompt_activate',
|
||||
datasetReference: 'muse-admin-redacted-smoke',
|
||||
impacts: [],
|
||||
open: false,
|
||||
policyKey: undefined,
|
||||
promptKey: undefined,
|
||||
reason: '',
|
||||
sampleSize: 20,
|
||||
targetName: '',
|
||||
targetVersion: undefined,
|
||||
title: '',
|
||||
});
|
||||
|
||||
@ -133,22 +165,9 @@ const qualityDimensionPresets: QualityDimensionPreset[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** 最近评估摘要,当前页面先展示治理入口需要的最小决策信息。 */
|
||||
const evaluationSummaries = ref<EvaluationSummary[]>([
|
||||
{
|
||||
createdAt: '2026-05-24T00:00:00Z',
|
||||
dimensionScores: {
|
||||
canon_compliance: 0.94,
|
||||
character_voice: 0.87,
|
||||
scene_structure: 0.82,
|
||||
},
|
||||
overallScore: 0.89,
|
||||
policyKey: 'candidate.delivery',
|
||||
status: 'completed',
|
||||
title: '脱敏样本集最近评估',
|
||||
totalSamples: 120,
|
||||
},
|
||||
]);
|
||||
/** 最近评估摘要只展示本页真实提交返回,不展示硬编码评分。 */
|
||||
const evaluationSummaries = ref<EvaluationSummary[]>([]);
|
||||
const commandSubmitting = ref(false);
|
||||
|
||||
const systemAgents = computed(() =>
|
||||
agents.value.filter((agent) => agent.scope === 'system'),
|
||||
@ -229,12 +248,25 @@ async function loadQualityPolicies() {
|
||||
}
|
||||
|
||||
/** 打开高危操作预览,不在按钮点击时直接改状态。 */
|
||||
function openRiskPreview(payload: Omit<RiskPreviewState, 'open' | 'reason'>) {
|
||||
function openRiskPreview(
|
||||
payload: Omit<
|
||||
RiskPreviewState,
|
||||
'datasetReference' | 'open' | 'reason' | 'sampleSize'
|
||||
> &
|
||||
Partial<Pick<RiskPreviewState, 'datasetReference' | 'sampleSize'>>,
|
||||
) {
|
||||
riskPreview.auditFields = payload.auditFields;
|
||||
riskPreview.commandType = payload.commandType;
|
||||
riskPreview.datasetReference =
|
||||
payload.datasetReference ?? 'muse-admin-redacted-smoke';
|
||||
riskPreview.impacts = payload.impacts;
|
||||
riskPreview.open = true;
|
||||
riskPreview.policyKey = payload.policyKey;
|
||||
riskPreview.promptKey = payload.promptKey;
|
||||
riskPreview.reason = '';
|
||||
riskPreview.sampleSize = payload.sampleSize ?? 20;
|
||||
riskPreview.targetName = payload.targetName;
|
||||
riskPreview.targetVersion = payload.targetVersion;
|
||||
riskPreview.title = payload.title;
|
||||
}
|
||||
|
||||
@ -243,18 +275,102 @@ function closeRiskPreview() {
|
||||
riskPreview.open = false;
|
||||
}
|
||||
|
||||
/** 确认已完成预览;复杂审批和真实命令由后续流程承接。 */
|
||||
function handleRiskPreviewAcknowledged() {
|
||||
/** 确认高危操作;已接线的命令会调用真实后端写接口,未接线入口只保留预览。 */
|
||||
async function handleRiskPreviewAcknowledged() {
|
||||
if (!riskPreview.reason.trim()) {
|
||||
message.error('请先填写操作原因,原因会进入审计回执');
|
||||
return;
|
||||
}
|
||||
message.success('已记录高危操作预览,等待后续审批或命令流程接入');
|
||||
commandSubmitting.value = true;
|
||||
try {
|
||||
if (riskPreview.commandType === 'prompt_activate') {
|
||||
await activatePromptVersionCommand();
|
||||
return;
|
||||
}
|
||||
if (riskPreview.commandType === 'quality_evaluate') {
|
||||
await startQualityEvaluationCommand();
|
||||
return;
|
||||
}
|
||||
if (riskPreview.commandType === 'quality_publish') {
|
||||
await createQualityPolicyVersionCommand();
|
||||
return;
|
||||
}
|
||||
message.success('已完成高危操作预览,当前入口尚未接入正式写命令');
|
||||
closeRiskPreview();
|
||||
} finally {
|
||||
commandSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成前端幂等键,后端仍以 commandId 做最终幂等校验。 */
|
||||
function createCommandId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** 提交 Prompt 激活命令。 */
|
||||
async function activatePromptVersionCommand() {
|
||||
if (!riskPreview.promptKey || !riskPreview.targetVersion) {
|
||||
message.error('缺少 Prompt Key 或目标版本');
|
||||
return;
|
||||
}
|
||||
await activatePromptVersionApi(riskPreview.promptKey, riskPreview.targetVersion, {
|
||||
commandId: createCommandId('prompt-activate'),
|
||||
reason: riskPreview.reason,
|
||||
});
|
||||
message.success('Prompt 版本已激活');
|
||||
closeRiskPreview();
|
||||
await loadPrompts();
|
||||
}
|
||||
|
||||
/** 提交质量评估启动命令。 */
|
||||
async function startQualityEvaluationCommand() {
|
||||
if (!riskPreview.policyKey || !riskPreview.datasetReference.trim()) {
|
||||
message.error('请填写质量策略和评估数据集引用');
|
||||
return;
|
||||
}
|
||||
const result = await startEvaluationRunApi({
|
||||
commandId: createCommandId('quality-eval'),
|
||||
datasetReference: riskPreview.datasetReference.trim(),
|
||||
policyKey: riskPreview.policyKey,
|
||||
policyVersion: riskPreview.targetVersion,
|
||||
sampleSize: riskPreview.sampleSize,
|
||||
});
|
||||
evaluationSummaries.value.unshift({
|
||||
createdAt: new Date().toISOString(),
|
||||
jobId: result.jobId,
|
||||
policyKey: riskPreview.policyKey,
|
||||
policyVersion: riskPreview.targetVersion,
|
||||
runId: result.runId ?? '',
|
||||
status: 'queued',
|
||||
title: `${riskPreview.targetName} / ${result.runId ?? '已提交'}`,
|
||||
});
|
||||
message.success('质量评估已提交');
|
||||
closeRiskPreview();
|
||||
}
|
||||
|
||||
/** 提交质量策略新版本命令。 */
|
||||
async function createQualityPolicyVersionCommand() {
|
||||
if (!riskPreview.policyKey) {
|
||||
message.error('缺少质量策略 Key');
|
||||
return;
|
||||
}
|
||||
await createQualityPolicyVersionApi(riskPreview.policyKey, {
|
||||
changeNote: riskPreview.reason,
|
||||
commandId: createCommandId('quality-policy'),
|
||||
dimensions: qualityDimensionPresets.map((dimension) => ({
|
||||
name: dimension.key,
|
||||
threshold: dimension.threshold,
|
||||
weight: dimension.critical ? 1 : 0.6,
|
||||
})),
|
||||
});
|
||||
message.success('质量策略新版本已创建');
|
||||
closeRiskPreview();
|
||||
await loadQualityPolicies();
|
||||
}
|
||||
|
||||
/** 打开 Prompt 激活影响预览。 */
|
||||
function previewPromptActivation(prompt: PromptSummaryVO) {
|
||||
const targetVersion = prompt.latestVersion ?? prompt.activeVersion;
|
||||
openRiskPreview({
|
||||
auditFields: [
|
||||
'promptKey',
|
||||
@ -265,11 +381,14 @@ function previewPromptActivation(prompt: PromptSummaryVO) {
|
||||
'auditReceiptId',
|
||||
],
|
||||
impacts: [
|
||||
`当前激活版本 v${prompt.activeVersion} 将切换到最新版本 v${prompt.latestVersion ?? prompt.activeVersion}`,
|
||||
`当前激活版本 v${prompt.activeVersion} 将切换到目标版本 v${targetVersion}`,
|
||||
'影响后续系统智能体生成链路,不改写历史候选和已保存作品事实',
|
||||
'需要记录激活原因、版本差异摘要和回滚目标版本',
|
||||
],
|
||||
commandType: 'prompt_activate',
|
||||
promptKey: prompt.promptKey,
|
||||
targetName: prompt.name ?? prompt.promptKey,
|
||||
targetVersion,
|
||||
title: '激活 Prompt 版本',
|
||||
});
|
||||
}
|
||||
@ -285,6 +404,7 @@ function previewSystemAgentRelease(agent: AdminAgentSummaryVO) {
|
||||
'affectedChains',
|
||||
'auditReceiptId',
|
||||
],
|
||||
commandType: 'agent_release_preview',
|
||||
impacts: [
|
||||
'仅允许 system scope Agent 进入发布流程',
|
||||
'影响后续系统功能链路和默认子智能体选择,不接管用户私有 Agent',
|
||||
@ -306,6 +426,7 @@ function previewSystemAgentArchive(agent: AdminAgentSummaryVO) {
|
||||
'previewId',
|
||||
'auditReceiptId',
|
||||
],
|
||||
commandType: 'agent_archive_preview',
|
||||
impacts: [
|
||||
'停用默认系统 Agent 前必须确认受影响功能链路已有替代方案',
|
||||
'历史候选和历史运行记录不被改写',
|
||||
@ -328,6 +449,7 @@ function previewToolGrantAdjustment(grant: ToolGrantSummaryVO) {
|
||||
'reason',
|
||||
'auditReceiptId',
|
||||
],
|
||||
commandType: 'tool_grant_preview',
|
||||
impacts: [
|
||||
'Tool Grant 审批权威属于 Security facade,AI runtime 只能消费授权结果',
|
||||
`当前外发策略为 ${formatNetworkMode(grant.outboundPolicy.networkMode)} / ${formatDataPolicy(grant.outboundPolicy.dataPolicy)}`,
|
||||
@ -349,12 +471,15 @@ function previewQualityEvaluation(policy: QualityPolicySummaryVO) {
|
||||
'commandId',
|
||||
'auditReceiptId',
|
||||
],
|
||||
commandType: 'quality_evaluate',
|
||||
impacts: [
|
||||
'默认只能使用合成、公开或脱敏评估集',
|
||||
'评估可能调用模型网关,但不改变作品事实和用户正文',
|
||||
'样本不足、脱敏失败或外发未授权时应阻断正式运行',
|
||||
],
|
||||
policyKey: policy.policyKey,
|
||||
targetName: policy.name ?? policy.policyKey,
|
||||
targetVersion: policy.activeVersion,
|
||||
title: '运行质量评估',
|
||||
});
|
||||
}
|
||||
@ -370,12 +495,15 @@ function previewQualityPolicyPublish(policy: QualityPolicySummaryVO) {
|
||||
'exemptionReason',
|
||||
'auditReceiptId',
|
||||
],
|
||||
commandType: 'quality_publish',
|
||||
impacts: [
|
||||
'新策略只影响后续候选交付前评分和内部重写提示',
|
||||
'质量门控不阻止用户写作、保存、私人使用或市场发布',
|
||||
'降低关键维度阈值或基于失败评估发布时必须进入高危确认',
|
||||
],
|
||||
policyKey: policy.policyKey,
|
||||
targetName: policy.name ?? policy.policyKey,
|
||||
targetVersion: policy.latestVersion ?? policy.activeVersion,
|
||||
title: '发布质量策略',
|
||||
});
|
||||
}
|
||||
@ -390,6 +518,7 @@ function previewQualityPolicyRollback(policy: QualityPolicySummaryVO) {
|
||||
'rollbackReason',
|
||||
'auditReceiptId',
|
||||
],
|
||||
commandType: 'quality_rollback_preview',
|
||||
impacts: [
|
||||
'回滚只影响后续候选评分,不改写历史候选评分结果',
|
||||
'需要保留当前版本、目标版本和回滚原因',
|
||||
@ -564,6 +693,7 @@ function toPercent(score?: number) {
|
||||
</dl>
|
||||
<Space>
|
||||
<Button
|
||||
data-test="prompt-activate-command"
|
||||
:disabled="!hasNewerVersion(prompt.activeVersion, prompt.latestVersion)"
|
||||
type="primary"
|
||||
@click="previewPromptActivation(prompt)"
|
||||
@ -731,13 +861,18 @@ function toPercent(score?: number) {
|
||||
</div>
|
||||
</dl>
|
||||
<Space wrap>
|
||||
<Button @click="previewQualityEvaluation(policy)">运行评估</Button>
|
||||
<Button
|
||||
data-test="quality-publish-preview"
|
||||
data-test="quality-evaluation-command"
|
||||
@click="previewQualityEvaluation(policy)"
|
||||
>
|
||||
运行评估
|
||||
</Button>
|
||||
<Button
|
||||
data-test="quality-publish-command"
|
||||
type="primary"
|
||||
@click="previewQualityPolicyPublish(policy)"
|
||||
>
|
||||
发布策略
|
||||
创建策略版本
|
||||
</Button>
|
||||
<Button danger @click="previewQualityPolicyRollback(policy)">
|
||||
回滚入口
|
||||
@ -747,9 +882,10 @@ function toPercent(score?: number) {
|
||||
|
||||
<div class="evaluation-list">
|
||||
<h3 class="ai-subtitle">最近评估</h3>
|
||||
<Empty v-if="evaluationSummaries.length === 0" description="暂无本页提交的真实评估运行" />
|
||||
<article
|
||||
v-for="evaluation in evaluationSummaries"
|
||||
:key="evaluation.title"
|
||||
:key="evaluation.runId ?? evaluation.title"
|
||||
class="evaluation-item"
|
||||
>
|
||||
<div>
|
||||
@ -757,8 +893,9 @@ function toPercent(score?: number) {
|
||||
<span>{{ formatEvaluationStatus(evaluation.status) }}</span>
|
||||
</div>
|
||||
<p>
|
||||
样本 {{ evaluation.totalSamples ?? 0 }},
|
||||
总分 {{ toPercent(evaluation.overallScore) }}%,
|
||||
Run {{ evaluation.runId ?? '-' }},
|
||||
Job {{ evaluation.jobId ?? '-' }},
|
||||
数据集 {{ evaluation.datasetReference ?? riskPreview.datasetReference }},
|
||||
创建 {{ formatDateTime(evaluation.createdAt) }}
|
||||
</p>
|
||||
</article>
|
||||
@ -835,6 +972,7 @@ function toPercent(score?: number) {
|
||||
|
||||
<Modal
|
||||
v-model:open="riskPreview.open"
|
||||
:confirm-loading="commandSubmitting"
|
||||
title="高危操作确认预览"
|
||||
width="720px"
|
||||
@cancel="closeRiskPreview"
|
||||
@ -864,6 +1002,15 @@ function toPercent(score?: number) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="riskPreview.commandType === 'quality_evaluate'">
|
||||
<h3>评估数据集</h3>
|
||||
<Textarea
|
||||
v-model:value="riskPreview.datasetReference"
|
||||
:rows="2"
|
||||
placeholder="请输入合成、公开或脱敏评估集引用"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>审计回执字段</h3>
|
||||
<div class="audit-field-list">
|
||||
|
||||
@ -6,6 +6,7 @@ export default defineConfig({
|
||||
plugins: [Vue(), VueJsx()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
environmentOptions: {
|
||||
happyDOM: {
|
||||
settings: {
|
||||
|
||||
53
muse-admin/vitest.setup.ts
Normal file
53
muse-admin/vitest.setup.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/** 创建测试用内存 Storage;仅用于 Vitest 环境,不影响生产浏览器存储实现。 */
|
||||
function createMemoryStorage(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 同时挂到 window 与 globalThis,兼容直接访问 localStorage 的历史测试。 */
|
||||
function installStorage(name: 'localStorage' | 'sessionStorage') {
|
||||
const existing =
|
||||
typeof window !== 'undefined'
|
||||
? (window[name] as Storage | undefined)
|
||||
: undefined;
|
||||
const storage = existing ?? createMemoryStorage();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Node 26 默认不再提供可直接访问的 localStorage;happy-dom 在当前组合下也可能未挂载。
|
||||
// 这里统一补齐浏览器存储对象,避免全仓单测因环境缺口假红。
|
||||
installStorage('localStorage');
|
||||
installStorage('sessionStorage');
|
||||
32
muse-cloud/.github/workflows/maven.yml
vendored
32
muse-cloud/.github/workflows/maven.yml
vendored
@ -1,32 +0,0 @@
|
||||
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
|
||||
|
||||
name: Java CI with Maven
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
java: [ '21' ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK ${{ matrix.java }}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: ${{ matrix.java }}
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
# P0 止血(2026-06-13):移除 -Dmaven.test.skip=true,让 CI 真正编译并运行测试。
|
||||
# 此前跳过全部测试 = 假绿;单测走 surefire(yudao H2/内嵌 Redis 基类,无需外部 DB)。
|
||||
- name: Build with Maven
|
||||
run: mvn -B package --file pom.xml
|
||||
@ -87,11 +87,17 @@ public final class MuseApiContractSupport {
|
||||
op("ai", "app", "POST", "/app-api/muse/suggestions/{suggestionId}/reject", "rejectSuggestion", true, new String[] {"commandId"}, new String[] {}),
|
||||
op("ai", "app", "GET", "/app-api/muse/agents", "listUserAgents", false, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/agents", "createUserAgent", true, new String[] {"name"}, new String[] {}),
|
||||
op("ai", "app", "PUT", "/app-api/muse/agents/{agentId}", "updateUserAgent", true, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/agents/{agentId}/archive", "archiveUserAgent", true, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "GET", "/app-api/muse/agents/{agentId}/versions", "listUserAgentVersions", false, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/agents/{agentId}/versions", "createAgentVersion", true, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/agents/{agentId}/versions/{version}/activate", "activateUserAgentVersion", true, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/agents/{agentId}/versions/{version}/archive", "archiveUserAgentVersion", true, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/agents/{agentId}/test", "testAgent", true, new String[] {"commandId", "input"}, new String[] {}),
|
||||
op("ai", "app", "GET", "/app-api/muse/works/{workId}/agent-slots", "listWorkAgentSlots", false, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/works/{workId}/agent-slots/{slotKey}/prechecks", "precheckAgentSlot", true, new String[] {"commandId", "sourceAgentId", "sourceAgentVersion"}, new String[] {"sourceAgentVersion"}),
|
||||
op("ai", "app", "POST", "/app-api/muse/works/{workId}/agent-slots/{slotKey}/bind", "bindAgentSlot", true, new String[] {"commandId", "agentSlotPrecheckId", "sourceAgentId", "sourceAgentVersion", "expectedSlotRevision"}, new String[] {"sourceAgentVersion", "expectedSlotRevision"}),
|
||||
op("ai", "app", "POST", "/app-api/muse/works/{workId}/agent-slots/{slotKey}/unbind", "unbindAgentSlot", true, new String[] {"commandId", "expectedSlotRevision"}, new String[] {"expectedSlotRevision"}),
|
||||
op("ai", "app", "GET", "/app-api/muse/jobs/{jobId}", "getUserJob", false, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/jobs/{jobId}/cancel", "cancelUserJob", true, new String[] {}, new String[] {}),
|
||||
op("ai", "app", "POST", "/app-api/muse/source-status/query", "querySourceStatus", false, new String[] {"sourceType", "sourceId", "purpose", "targetOwner", "targetId"}, new String[] {}),
|
||||
@ -110,6 +116,7 @@ public final class MuseApiContractSupport {
|
||||
op("content", "app", "GET", "/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", "listBlocks", false, new String[] {}, new String[] {}),
|
||||
op("content", "app", "POST", "/app-api/muse/works/{workId}/chapters/{chapterId}/blocks", "createBlock", true, new String[] {"commandId", "content", "blockType", "expectedChapterRevision"}, new String[] {"expectedChapterRevision"}),
|
||||
op("content", "app", "GET", "/app-api/muse/works/{workId}/blocks/{blockId}", "getBlock", false, new String[] {}, new String[] {}),
|
||||
op("content", "app", "GET", "/app-api/muse/works/{workId}/blocks/{blockId}/revisions", "listBlockRevisions", false, new String[] {}, new String[] {}),
|
||||
op("content", "app", "PUT", "/app-api/muse/works/{workId}/blocks/{blockId}", "saveBlock", true, new String[] {"commandId", "content", "expectedRevision", "sourceSnapshot"}, new String[] {"expectedRevision"}),
|
||||
op("content", "app", "DELETE", "/app-api/muse/works/{workId}/blocks/{blockId}", "deleteBlock", true, new String[] {"commandId", "expectedRevision", "sourceSnapshot"}, new String[] {"expectedRevision"}),
|
||||
op("content", "app", "POST", "/app-api/muse/works/{workId}/blocks/{blockId}/split", "splitBlock", true, new String[] {"commandId", "splitPosition", "expectedRevision", "sourceSnapshot"}, new String[] {"expectedRevision"}),
|
||||
@ -177,6 +184,7 @@ public final class MuseApiContractSupport {
|
||||
op("knowledge", "app", "GET", "/app-api/muse/entities/{entityId}/relations", "listRelations", false, new String[] {}, new String[] {}),
|
||||
op("knowledge", "app", "POST", "/app-api/muse/entities/{entityId}/relations", "createRelation", true, new String[] {"targetEntityId", "relationType", "commandId"}, new String[] {}),
|
||||
op("knowledge", "app", "GET", "/app-api/muse/works/{workId}/graph", "getKnowledgeGraph", false, new String[] {}, new String[] {}),
|
||||
op("knowledge", "app", "POST", "/app-api/muse/works/{workId}/knowledge-retrievals", "retrieveKnowledgeForWork", true, new String[] {"commandId", "question"}, new String[] {}),
|
||||
op("knowledge", "app", "GET", "/app-api/muse/knowledge-bases", "listKnowledgeBases", false, new String[] {}, new String[] {}),
|
||||
op("knowledge", "app", "POST", "/app-api/muse/knowledge-bases", "createKnowledgeBase", true, new String[] {"name", "commandId"}, new String[] {}),
|
||||
op("knowledge", "app", "GET", "/app-api/muse/knowledge-bases/{kbId}", "getKnowledgeBase", false, new String[] {}, new String[] {}),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -92,5 +92,7 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode AI_AUDIT_EVENT_NOT_EXISTS = new ErrorCode(1_040_100_023, "AI 业务审计事件不存在");
|
||||
// 跨空间 handoff(market→agent):Market 一次性 token 核验失败(不存在/非属主/已过期/已消费/owner-action 不匹配)
|
||||
ErrorCode AI_MARKET_HANDOFF_UNAVAILABLE = new ErrorCode(1_040_100_024, "AI Market handoff 凭据不可用");
|
||||
ErrorCode AI_AGENT_VERSION_NOT_EXISTS = new ErrorCode(1_040_100_025, "AI Agent 版本不存在");
|
||||
ErrorCode AI_AGENT_VERSION_ACTIVE_REQUIRED = new ErrorCode(1_040_100_026, "AI Agent 当前版本不可归档,请先激活其他版本");
|
||||
|
||||
}
|
||||
|
||||
@ -76,6 +76,12 @@
|
||||
<artifactId>muse-module-knowledge-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<!-- Account 用量/配额写端口:AI 生成只经 member-api 写入 Account 自有事实,禁止跨域 DAL/application。 -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>muse-module-member-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件 -->
|
||||
|
||||
|
||||
@ -7,7 +7,11 @@ import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AgentVersionCreateReqV
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AgentVersionCreateRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.MuseAiPageResult;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionLifecycleReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentArchiveReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentCreateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentUpdateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentVersionCreateReqVO;
|
||||
|
||||
/**
|
||||
@ -35,6 +39,16 @@ public interface MuseAgentService {
|
||||
*/
|
||||
AdminAgentCreateRespVO createUserAgent(Long ownerUserId, UserAgentCreateReqVO request);
|
||||
|
||||
/**
|
||||
* 用户端更新本人 Agent;更新会生成下一版本并激活。
|
||||
*/
|
||||
AgentSummaryRespVO updateUserAgent(Long ownerUserId, Long agentId, UserAgentUpdateReqVO request);
|
||||
|
||||
/**
|
||||
* 用户端归档本人 Agent。
|
||||
*/
|
||||
AgentSummaryRespVO archiveUserAgent(Long ownerUserId, Long agentId, UserAgentArchiveReqVO request);
|
||||
|
||||
/**
|
||||
* 管理端为系统 Agent 创建新版本。
|
||||
*/
|
||||
@ -45,4 +59,22 @@ public interface MuseAgentService {
|
||||
*/
|
||||
AgentVersionCreateRespVO createUserAgentVersion(Long ownerUserId, Long agentId, UserAgentVersionCreateReqVO request);
|
||||
|
||||
/**
|
||||
* 用户端读取本人 Agent 版本列表。
|
||||
*/
|
||||
MuseAiPageResult<AgentVersionSummaryRespVO> listUserAgentVersions(Long ownerUserId, Long agentId,
|
||||
Integer pageNo, Integer pageSize);
|
||||
|
||||
/**
|
||||
* 用户端激活本人 Agent 的既有版本。
|
||||
*/
|
||||
AgentVersionCreateRespVO activateUserAgentVersion(Long ownerUserId, Long agentId, Integer version,
|
||||
AgentVersionLifecycleReqVO request);
|
||||
|
||||
/**
|
||||
* 用户端归档本人 Agent 的非当前版本。
|
||||
*/
|
||||
AgentVersionCreateRespVO archiveUserAgentVersion(Long ownerUserId, Long agentId, Integer version,
|
||||
AgentVersionLifecycleReqVO request);
|
||||
|
||||
}
|
||||
|
||||
@ -12,15 +12,22 @@ import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AgentVersionCreateReqV
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AgentVersionCreateRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.MuseAiPageResult;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionLifecycleReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentArchiveReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentCreateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentUpdateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentVersionCreateReqVO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentSlotBindingDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentVersionDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiCommandDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseToolGrantDO;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotBindingMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentVersionMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseToolGrantMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@ -33,9 +40,12 @@ import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_NOT_EXISTS;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SCOPE_FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_VERSION_ACTIVE_REQUIRED;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_VERSION_NOT_EXISTS;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_TOOL_GRANT_NOT_APPROVED;
|
||||
|
||||
/**
|
||||
@ -47,17 +57,24 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
private static final String OPERATION_ADMIN_CREATE = "adminCreateAgent";
|
||||
private static final String OPERATION_ADMIN_CREATE_VERSION = "adminCreateAgentVersion";
|
||||
private static final String OPERATION_USER_CREATE = "createUserAgent";
|
||||
private static final String OPERATION_USER_UPDATE = "updateUserAgent";
|
||||
private static final String OPERATION_USER_ARCHIVE = "archiveUserAgent";
|
||||
private static final String OPERATION_USER_CREATE_VERSION = "createAgentVersion";
|
||||
private static final String OPERATION_USER_ACTIVATE_VERSION = "activateAgentVersion";
|
||||
private static final String OPERATION_USER_ARCHIVE_VERSION = "archiveAgentVersion";
|
||||
private static final String TARGET_TYPE_AGENT = "agent";
|
||||
private static final String SCOPE_SYSTEM = "system";
|
||||
private static final String SCOPE_USER = "user";
|
||||
private static final String STATUS_ACTIVE = "active";
|
||||
private static final String STATUS_ARCHIVED = "archived";
|
||||
|
||||
@Resource
|
||||
private MuseAgentMapper agentMapper;
|
||||
@Resource
|
||||
private MuseAgentVersionMapper agentVersionMapper;
|
||||
@Resource
|
||||
private MuseAgentSlotBindingMapper slotBindingMapper;
|
||||
@Resource
|
||||
private MuseToolGrantMapper toolGrantMapper;
|
||||
@Resource
|
||||
private MuseAiCommandService commandService;
|
||||
@ -148,6 +165,8 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
agent.setStatus(STATUS_ACTIVE);
|
||||
agent.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
agentMapper.insert(agent);
|
||||
// 自建 Agent 必须同步生成首个 active 版本,否则列表 UI 虽可展示 v1,运行试用/槽位绑定却没有真实版本可解析。
|
||||
createInitialUserAgentVersion(agent, request, commandId);
|
||||
|
||||
AdminAgentCreateRespVO response = new AdminAgentCreateRespVO();
|
||||
response.setAgentId(agent.getId());
|
||||
@ -157,6 +176,77 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentSummaryRespVO updateUserAgent(Long ownerUserId, Long agentId, UserAgentUpdateReqVO request) {
|
||||
String requestHash = commandService.buildRequestHash(request);
|
||||
String commandId = commandId(request == null ? null : request.getCommandId(), OPERATION_USER_UPDATE,
|
||||
ownerUserId, requestHash);
|
||||
MuseAiCommandService.CommandEnvelope envelope = commandEnvelope(commandId, OPERATION_USER_UPDATE,
|
||||
ownerUserId, ownerUserId, agentId, null, requestHash);
|
||||
MuseAiCommandDO replay = commandService.reserveCommand(envelope);
|
||||
if (replay != null) {
|
||||
return JsonUtils.parseObject(replay.getResultSnapshot(), AgentSummaryRespVO.class);
|
||||
}
|
||||
|
||||
MuseAgentDO lockedAgent = requireOwnedUserAgentForUpdate(ownerUserId, agentId);
|
||||
requireActiveAgent(lockedAgent);
|
||||
Map<String, Object> currentConfig = currentUserVersionConfig(lockedAgent);
|
||||
String nextName = textOrCurrent(request == null ? null : request.getName(), lockedAgent.getName());
|
||||
String nextDescription = textOrCurrent(request == null ? null : request.getDescription(),
|
||||
lockedAgent.getDescription());
|
||||
String nextPromptTemplate = textOrCurrent(request == null ? null : request.getPromptTemplate(),
|
||||
stringValue(currentConfig.get("promptTemplate")));
|
||||
Map<String, Object> nextSlotBindings = request != null && request.getSlotBindings() != null
|
||||
? request.getSlotBindings()
|
||||
: currentSlotBindings(lockedAgent, currentConfig);
|
||||
Integer nextVersion = createUserAgentVersionRow(lockedAgent,
|
||||
nextName,
|
||||
nextDescription,
|
||||
nextPromptTemplate,
|
||||
nextSlotBindings,
|
||||
textOrCurrent(request == null ? null : request.getChangeNote(), "update"),
|
||||
commandId);
|
||||
lockedAgent.setName(nextName);
|
||||
lockedAgent.setDescription(nextDescription);
|
||||
lockedAgent.setSlotBindings(JsonUtils.toJsonString(nextSlotBindings));
|
||||
agentMapper.updateById(lockedAgent);
|
||||
|
||||
AgentSummaryRespVO response = toUserSummary(lockedAgent, ownerUserId);
|
||||
response.setActiveVersion(nextVersion);
|
||||
recordSucceeded(envelope, response);
|
||||
audit(OPERATION_USER_UPDATE, "app", ownerUserId, ownerUserId, agentId, commandId, requestHash,
|
||||
"{\"agentId\":" + agentId + ",\"nextVersion\":" + nextVersion + "}",
|
||||
JsonUtils.toJsonString(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentSummaryRespVO archiveUserAgent(Long ownerUserId, Long agentId, UserAgentArchiveReqVO request) {
|
||||
String requestHash = commandService.buildRequestHash(request);
|
||||
String commandId = commandId(request == null ? null : request.getCommandId(), OPERATION_USER_ARCHIVE,
|
||||
ownerUserId, requestHash);
|
||||
MuseAiCommandService.CommandEnvelope envelope = commandEnvelope(commandId, OPERATION_USER_ARCHIVE,
|
||||
ownerUserId, ownerUserId, agentId, null, requestHash);
|
||||
MuseAiCommandDO replay = commandService.reserveCommand(envelope);
|
||||
if (replay != null) {
|
||||
return JsonUtils.parseObject(replay.getResultSnapshot(), AgentSummaryRespVO.class);
|
||||
}
|
||||
|
||||
MuseAgentDO agent = requireOwnedUserAgentForUpdate(ownerUserId, agentId);
|
||||
agent.setStatus(STATUS_ARCHIVED);
|
||||
agentMapper.updateById(agent);
|
||||
int archivedBindings = slotBindingMapper.archiveActiveByAgentId(TenantContextHolder.getRequiredTenantId(), agentId);
|
||||
|
||||
AgentSummaryRespVO response = toUserSummary(agent, ownerUserId);
|
||||
recordSucceeded(envelope, response);
|
||||
audit(OPERATION_USER_ARCHIVE, "app", ownerUserId, ownerUserId, agentId, commandId, requestHash,
|
||||
"{\"agentId\":" + agentId + ",\"archivedBindings\":" + archivedBindings + "}",
|
||||
JsonUtils.toJsonString(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentVersionCreateRespVO createAdminAgentVersion(Long agentId, AgentVersionCreateReqVO request,
|
||||
@ -196,10 +286,86 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
if (!SCOPE_USER.equals(agent.getAgentType()) || !ownerUserId.equals(agent.getOwnerUserId())) {
|
||||
throw new ServiceException(AI_AGENT_SCOPE_FORBIDDEN);
|
||||
}
|
||||
requireActiveAgent(agent);
|
||||
return createVersion(agent, request, ownerUserId, ownerUserId, OPERATION_USER_CREATE_VERSION, "app",
|
||||
envelope, commandId, requestHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public MuseAiPageResult<AgentVersionSummaryRespVO> listUserAgentVersions(Long ownerUserId, Long agentId,
|
||||
Integer pageNo, Integer pageSize) {
|
||||
MuseAgentDO agent = requireOwnedUserAgent(ownerUserId, agentId);
|
||||
PageParam pageParam = pageParam(pageNo, pageSize);
|
||||
PageResult<MuseAgentVersionDO> page = agentVersionMapper.selectPageByAgentId(pageParam, agentId);
|
||||
return new MuseAiPageResult<>(pageParam.getPageNo(), pageParam.getPageSize(), page.getTotal(),
|
||||
page.getList().stream().map(version -> toVersionSummary(agent, version)).toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentVersionCreateRespVO activateUserAgentVersion(Long ownerUserId, Long agentId, Integer version,
|
||||
AgentVersionLifecycleReqVO request) {
|
||||
String requestHash = commandService.buildRequestHash(request);
|
||||
String commandId = commandId(request == null ? null : request.getCommandId(), OPERATION_USER_ACTIVATE_VERSION,
|
||||
ownerUserId, requestHash);
|
||||
MuseAiCommandService.CommandEnvelope envelope = commandEnvelope(commandId, OPERATION_USER_ACTIVATE_VERSION,
|
||||
ownerUserId, ownerUserId, agentId, "agent:" + agentId + ":version:" + version, requestHash);
|
||||
MuseAiCommandDO replay = commandService.reserveCommand(envelope);
|
||||
if (replay != null) {
|
||||
return JsonUtils.parseObject(replay.getResultSnapshot(), AgentVersionCreateRespVO.class);
|
||||
}
|
||||
|
||||
MuseAgentDO agent = requireOwnedUserAgentForUpdate(ownerUserId, agentId);
|
||||
requireActiveAgent(agent);
|
||||
MuseAgentVersionDO target = requireAgentVersion(agentId, version);
|
||||
target.setStatus(STATUS_ACTIVE);
|
||||
agentVersionMapper.updateById(target);
|
||||
agent.setCurrentVersionId(target.getId());
|
||||
agentMapper.updateById(agent);
|
||||
|
||||
AgentVersionCreateRespVO response = new AgentVersionCreateRespVO();
|
||||
response.setAgentId(agentId);
|
||||
response.setVersion(version);
|
||||
recordSucceeded(envelope, response);
|
||||
audit(OPERATION_USER_ACTIVATE_VERSION, "app", ownerUserId, ownerUserId, agentId, commandId, requestHash,
|
||||
"{\"agentId\":" + agentId + ",\"activeVersion\":" + version + "}",
|
||||
JsonUtils.toJsonString(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentVersionCreateRespVO archiveUserAgentVersion(Long ownerUserId, Long agentId, Integer version,
|
||||
AgentVersionLifecycleReqVO request) {
|
||||
String requestHash = commandService.buildRequestHash(request);
|
||||
String commandId = commandId(request == null ? null : request.getCommandId(), OPERATION_USER_ARCHIVE_VERSION,
|
||||
ownerUserId, requestHash);
|
||||
MuseAiCommandService.CommandEnvelope envelope = commandEnvelope(commandId, OPERATION_USER_ARCHIVE_VERSION,
|
||||
ownerUserId, ownerUserId, agentId, "agent:" + agentId + ":version:" + version, requestHash);
|
||||
MuseAiCommandDO replay = commandService.reserveCommand(envelope);
|
||||
if (replay != null) {
|
||||
return JsonUtils.parseObject(replay.getResultSnapshot(), AgentVersionCreateRespVO.class);
|
||||
}
|
||||
|
||||
MuseAgentDO agent = requireOwnedUserAgentForUpdate(ownerUserId, agentId);
|
||||
MuseAgentVersionDO target = requireAgentVersion(agentId, version);
|
||||
if (Objects.equals(agent.getCurrentVersionId(), target.getId())) {
|
||||
throw new ServiceException(AI_AGENT_VERSION_ACTIVE_REQUIRED);
|
||||
}
|
||||
target.setStatus(STATUS_ARCHIVED);
|
||||
agentVersionMapper.updateById(target);
|
||||
|
||||
AgentVersionCreateRespVO response = new AgentVersionCreateRespVO();
|
||||
response.setAgentId(agentId);
|
||||
response.setVersion(version);
|
||||
recordSucceeded(envelope, response);
|
||||
audit(OPERATION_USER_ARCHIVE_VERSION, "app", ownerUserId, ownerUserId, agentId, commandId, requestHash,
|
||||
"{\"agentId\":" + agentId + ",\"archivedVersion\":" + version + "}",
|
||||
JsonUtils.toJsonString(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
private AgentVersionCreateRespVO createVersion(MuseAgentDO agent, AgentVersionCreateReqVO request,
|
||||
Long actorUserId, Long ownerUserId, String operationId, String side,
|
||||
MuseAiCommandService.CommandEnvelope envelope, String requestHash) {
|
||||
@ -243,19 +409,8 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
if (lockedAgent == null) {
|
||||
throw new ServiceException(AI_AGENT_NOT_EXISTS);
|
||||
}
|
||||
MuseAgentVersionDO latest = agentVersionMapper.selectLatestByAgentId(agent.getId());
|
||||
int nextVersion = parseVersion(latest == null ? null : latest.getVersion()) + 1;
|
||||
MuseAgentVersionDO version = new MuseAgentVersionDO();
|
||||
version.setAgentId(agent.getId());
|
||||
version.setVersion(String.valueOf(nextVersion));
|
||||
version.setConfig(JsonUtils.toJsonString(userVersionConfig(request)));
|
||||
version.setChangeNote(request.getChangeNote());
|
||||
version.setStatus(STATUS_ACTIVE);
|
||||
version.setCommandId(commandId);
|
||||
version.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
agentVersionMapper.insert(version);
|
||||
|
||||
lockedAgent.setCurrentVersionId(version.getId());
|
||||
int nextVersion = createUserAgentVersionRow(lockedAgent, request.getName(), request.getDescription(),
|
||||
request.getPromptTemplate(), request.getSlotBindings(), request.getChangeNote(), commandId);
|
||||
agentMapper.updateById(lockedAgent);
|
||||
|
||||
AgentVersionCreateRespVO response = new AgentVersionCreateRespVO();
|
||||
@ -309,6 +464,87 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
return agent;
|
||||
}
|
||||
|
||||
private MuseAgentDO requireOwnedUserAgent(Long ownerUserId, Long agentId) {
|
||||
MuseAgentDO agent = requireAgent(agentId);
|
||||
if (!SCOPE_USER.equals(agent.getAgentType()) || !ownerUserId.equals(agent.getOwnerUserId())) {
|
||||
throw new ServiceException(AI_AGENT_SCOPE_FORBIDDEN);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private MuseAgentDO requireOwnedUserAgentForUpdate(Long ownerUserId, Long agentId) {
|
||||
requireOwnedUserAgent(ownerUserId, agentId);
|
||||
return lockAgent(agentId);
|
||||
}
|
||||
|
||||
private MuseAgentDO lockAgent(Long agentId) {
|
||||
MuseAgentDO lockedAgent = agentMapper.selectByIdForUpdate(TenantContextHolder.getRequiredTenantId(), agentId);
|
||||
if (lockedAgent == null) {
|
||||
throw new ServiceException(AI_AGENT_NOT_EXISTS);
|
||||
}
|
||||
return lockedAgent;
|
||||
}
|
||||
|
||||
private void requireActiveAgent(MuseAgentDO agent) {
|
||||
if (!STATUS_ACTIVE.equals(agent.getStatus())) {
|
||||
throw new ServiceException(AI_AGENT_SCOPE_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private MuseAgentVersionDO requireAgentVersion(Long agentId, Integer version) {
|
||||
MuseAgentVersionDO target = agentVersionMapper.selectByAgentIdAndVersion(agentId,
|
||||
version == null ? null : String.valueOf(version));
|
||||
if (target == null) {
|
||||
throw new ServiceException(AI_AGENT_VERSION_NOT_EXISTS);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private void createInitialUserAgentVersion(MuseAgentDO agent, UserAgentCreateReqVO request, String commandId) {
|
||||
MuseAgentVersionDO version = new MuseAgentVersionDO();
|
||||
version.setAgentId(agent.getId());
|
||||
version.setVersion("1");
|
||||
version.setConfig(JsonUtils.toJsonString(userVersionConfig(request.getName(), request.getDescription(),
|
||||
request.getPromptTemplate(), request.getSlotBindings())));
|
||||
version.setChangeNote("create");
|
||||
version.setStatus(STATUS_ACTIVE);
|
||||
version.setCommandId(commandId);
|
||||
version.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
agentVersionMapper.insert(version);
|
||||
agent.setCurrentVersionId(version.getId());
|
||||
agentMapper.updateById(agent);
|
||||
}
|
||||
|
||||
private int createUserAgentVersionRow(MuseAgentDO lockedAgent, String name, String description,
|
||||
String promptTemplate, Map<String, Object> slotBindings,
|
||||
String changeNote, String commandId) {
|
||||
MuseAgentVersionDO latest = agentVersionMapper.selectLatestByAgentId(lockedAgent.getId());
|
||||
int nextVersion = parseVersion(latest == null ? null : latest.getVersion()) + 1;
|
||||
MuseAgentVersionDO version = new MuseAgentVersionDO();
|
||||
version.setAgentId(lockedAgent.getId());
|
||||
version.setVersion(String.valueOf(nextVersion));
|
||||
version.setConfig(JsonUtils.toJsonString(userVersionConfig(name, description, promptTemplate, slotBindings)));
|
||||
version.setChangeNote(changeNote);
|
||||
version.setStatus(STATUS_ACTIVE);
|
||||
version.setCommandId(commandId);
|
||||
version.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
agentVersionMapper.insert(version);
|
||||
|
||||
lockedAgent.setCurrentVersionId(version.getId());
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
private AgentVersionSummaryRespVO toVersionSummary(MuseAgentDO agent, MuseAgentVersionDO version) {
|
||||
AgentVersionSummaryRespVO summary = new AgentVersionSummaryRespVO();
|
||||
summary.setAgentId(agent.getId());
|
||||
summary.setVersion(parseVersion(version.getVersion()));
|
||||
summary.setStatus(version.getStatus());
|
||||
summary.setActive(Objects.equals(agent.getCurrentVersionId(), version.getId()));
|
||||
summary.setChangeNote(version.getChangeNote());
|
||||
summary.setCreatedAt(version.getCreateTime());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private MuseAiCommandService.CommandEnvelope commandEnvelope(String commandId, String operationId, Long actorUserId,
|
||||
Long ownerUserId, Long targetId, String targetKey,
|
||||
String requestHash) {
|
||||
@ -372,14 +608,56 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
}
|
||||
|
||||
private Map<String, Object> userVersionConfig(UserAgentVersionCreateReqVO request) {
|
||||
return userVersionConfig(request.getName(), request.getDescription(), request.getPromptTemplate(),
|
||||
request.getSlotBindings());
|
||||
}
|
||||
|
||||
private Map<String, Object> currentUserVersionConfig(MuseAgentDO agent) {
|
||||
if (agent.getCurrentVersionId() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
MuseAgentVersionDO currentVersion = agentVersionMapper.selectById(agent.getCurrentVersionId());
|
||||
return parseJsonMap(currentVersion == null ? null : currentVersion.getConfig());
|
||||
}
|
||||
|
||||
private Map<String, Object> userVersionConfig(String name, String description, String promptTemplate,
|
||||
Map<String, Object> slotBindings) {
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("name", request.getName());
|
||||
config.put("description", request.getDescription());
|
||||
config.put("promptTemplate", request.getPromptTemplate());
|
||||
config.put("slotBindings", request.getSlotBindings() == null ? Map.of() : request.getSlotBindings());
|
||||
config.put("name", name);
|
||||
config.put("description", description);
|
||||
config.put("promptTemplate", promptTemplate);
|
||||
config.put("slotBindings", slotBindings == null ? Map.of() : slotBindings);
|
||||
return config;
|
||||
}
|
||||
|
||||
private Map<String, Object> currentSlotBindings(MuseAgentDO agent, Map<String, Object> currentConfig) {
|
||||
Object configSlotBindings = currentConfig.get("slotBindings");
|
||||
if (configSlotBindings instanceof Map<?, ?> map) {
|
||||
return copyStringKeyMap(map);
|
||||
}
|
||||
return parseJsonMap(agent.getSlotBindings());
|
||||
}
|
||||
|
||||
private Map<String, Object> parseJsonMap(String json) {
|
||||
Map<String, Object> parsed = JsonUtils.parseObjectQuietly(json, new TypeReference<>() {
|
||||
});
|
||||
return parsed == null ? Map.of() : copyStringKeyMap(parsed);
|
||||
}
|
||||
|
||||
private Map<String, Object> copyStringKeyMap(Map<?, ?> source) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
source.forEach((key, value) -> {
|
||||
if (key != null) {
|
||||
result.put(String.valueOf(key), value);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private void recordSucceeded(MuseAiCommandService.CommandEnvelope envelope, Object response) {
|
||||
commandService.recordSucceeded(envelope, JsonUtils.toJsonString(response));
|
||||
}
|
||||
@ -427,6 +705,10 @@ public class MuseAgentServiceImpl implements MuseAgentService {
|
||||
return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private String textOrCurrent(String value, String current) {
|
||||
return StringUtils.hasText(value) ? value.trim() : current;
|
||||
}
|
||||
|
||||
private String shortHash(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@ -5,6 +5,7 @@ import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotBindRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotUnbindReqVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -29,4 +30,9 @@ public interface MuseAgentSlotService {
|
||||
*/
|
||||
AgentSlotBindRespVO bindAgentSlot(Long ownerUserId, Long workId, String slotKey, AgentSlotBindReqVO request);
|
||||
|
||||
/**
|
||||
* 解绑作品槽位,回到系统默认能力。
|
||||
*/
|
||||
AgentSlotBindRespVO unbindAgentSlot(Long ownerUserId, Long workId, String slotKey, AgentSlotUnbindReqVO request);
|
||||
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotBindRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotUnbindReqVO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentSlotBindingDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentSlotPrecheckDO;
|
||||
@ -18,6 +19,8 @@ import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotBindingMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotPrecheckMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentVersionMapper;
|
||||
import cn.iocoder.muse.module.market.api.asset.MarketAssetSourceApi;
|
||||
import cn.iocoder.muse.module.market.api.asset.dto.MarketAssetSourceRespDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.MarketHandoffTokenApi;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffConsumeReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyReqDTO;
|
||||
@ -33,6 +36,7 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@ -53,9 +57,13 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
|
||||
private static final String OPERATION_PRECHECK = "precheckAgentSlot";
|
||||
private static final String OPERATION_BIND = "bindAgentSlot";
|
||||
private static final String OPERATION_UNBIND = "unbindAgentSlot";
|
||||
private static final String TARGET_TYPE_AGENT_SLOT = "agentSlot";
|
||||
private static final String STATUS_PENDING = "pending";
|
||||
private static final String STATUS_ACTIVE = "active";
|
||||
private static final String AGENT_TYPE_USER = "user";
|
||||
private static final String AGENT_TYPE_SYSTEM = "system";
|
||||
private static final String ASSET_TYPE_AGENT = "agent";
|
||||
// 跨空间 handoff(market→agent):来源类型标识 + precheck 落库的来源 owner 标记(bind 凭此识别 market 放宽路径)
|
||||
private static final String SOURCE_TYPE_MARKET_AGENT = "market_agent";
|
||||
private static final String SOURCE_OWNER_MARKET = "market";
|
||||
@ -81,6 +89,8 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
private MuseContentWorkOwnerFacade workOwnerFacade;
|
||||
@Resource
|
||||
private MarketHandoffTokenApi marketHandoffTokenApi;
|
||||
@Resource
|
||||
private MarketAssetSourceApi marketAssetSourceApi;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
@ -117,14 +127,21 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
// market_agent 跨空间来源:服务端核验 + 核销 Market 一次性 handoff token(token 明文仅 precheck 阶段持有);
|
||||
// verify/consume 沿用本方法 @Transactional,与 precheck 落库原子(失败一并回滚)。非 handoff 来源不走此分支、维持原校验。
|
||||
boolean marketHandoff = isMarketAgentSource(request.getSourceType());
|
||||
ResolvedMarketAgentSource marketAgentSource = null;
|
||||
if (marketHandoff) {
|
||||
validateMarketHandoff(request);
|
||||
consumeMarketHandoff(request, apiVersion, workId, response.getAgentSlotPrecheckId());
|
||||
HandoffVerifyRespDTO verify = consumeMarketHandoff(request, apiVersion, workId,
|
||||
response.getAgentSlotPrecheckId());
|
||||
marketAgentSource = resolveMarketAgentSource(request, verify);
|
||||
} else {
|
||||
requireSameSpaceSourceFields(request.getSourceAgentId(), request.getSourceAgentVersion());
|
||||
}
|
||||
// 来源可见性校验:market handoff(token 已被 Market verify)放宽 agentType 可见性、接纳 market 类型 agent;
|
||||
// 非 handoff 维持 system/user 校验。agent/version active 不论来源都校验,不放宽。
|
||||
requireVisibleActiveSourceAgent(ownerUserId, request.getSourceAgentId(), request.getSourceAgentVersion(),
|
||||
marketHandoff);
|
||||
Long sourceAgentId = marketHandoff ? marketAgentSource.publisherAgentId() : request.getSourceAgentId();
|
||||
Integer sourceAgentVersion = marketHandoff ? marketAgentSource.publisherAgentVersion() : request.getSourceAgentVersion();
|
||||
// market handoff 只信服务端从 market 资产解析出的发布者 agent/version,不信客户端 URL 里的 sourceAgentId。
|
||||
// 非 handoff 维持 system/user 可见性校验。agent/version active 不论来源都校验。
|
||||
MuseAgentVersionDO sourceVersion = requireVisibleActiveSourceAgent(ownerUserId, sourceAgentId,
|
||||
sourceAgentVersion, marketHandoff);
|
||||
|
||||
MuseAgentSlotPrecheckDO precheck = new MuseAgentSlotPrecheckDO();
|
||||
precheck.setPrecheckId(response.getAgentSlotPrecheckId());
|
||||
@ -132,13 +149,14 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
precheck.setRequestHash(requestHash);
|
||||
precheck.setWorkId(workId);
|
||||
precheck.setSlotKey(slotKey);
|
||||
precheck.setSourceAgentId(request.getSourceAgentId());
|
||||
precheck.setSourceAgentVersion(request.getSourceAgentVersion());
|
||||
precheck.setSourceAgentId(sourceAgentId);
|
||||
precheck.setSourceAgentVersion(sourceAgentVersion);
|
||||
precheck.setExpectedSlotRevision(request.getExpectedSlotRevision());
|
||||
precheck.setAuthorizationSnapshotId(request.getAuthorizationSnapshotId());
|
||||
precheck.setStatus(STATUS_PENDING);
|
||||
precheck.setExpiresAt(response.getExpiresAt());
|
||||
precheck.setResultSummary(JsonUtils.toJsonString(response));
|
||||
precheck.setResultSummary(JsonUtils.toJsonString(precheckResultSummary(response, marketAgentSource,
|
||||
sourceVersion)));
|
||||
precheck.setOwnerUserId(ownerUserId);
|
||||
precheck.setActorUserId(ownerUserId);
|
||||
// 落库 source_owner/handoff_hash:bind 阶段凭 source_owner=market 识别放宽路径;handoff_hash 仅审计(明文不入库)
|
||||
@ -176,11 +194,11 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
if (precheck.getExpiresAt() == null || precheck.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
throw conflict(AI_AGENT_SLOT_PRECHECK_EXPIRED);
|
||||
}
|
||||
requirePrecheckMatchesBindRequest(precheck, request);
|
||||
boolean marketHandoff = SOURCE_OWNER_MARKET.equals(precheck.getSourceOwner());
|
||||
requirePrecheckMatchesBindRequest(precheck, request, marketHandoff);
|
||||
rejectBlockedSource(precheck.getAuthorizationSnapshotId());
|
||||
// bind 凭 precheck.sourceOwner 识别 market 放宽路径:token 已在 precheck 阶段核销,bind 不重复核验,
|
||||
// 凭 precheckId(悲观锁 + owner/work/slot 三维校验)信任来源;非 market 维持原 agentType 校验。
|
||||
boolean marketHandoff = SOURCE_OWNER_MARKET.equals(precheck.getSourceOwner());
|
||||
requireVisibleActiveSourceAgent(ownerUserId, precheck.getSourceAgentId(), precheck.getSourceAgentVersion(),
|
||||
marketHandoff);
|
||||
|
||||
@ -192,7 +210,11 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
precheckMapper.markConsumed(TenantContextHolder.getRequiredTenantId(), request.getAgentSlotPrecheckId(),
|
||||
LocalDateTime.now());
|
||||
|
||||
Integer slotRevision = persistBinding(binding, precheck, workId, slotKey, request.getCommandId(), marketHandoff);
|
||||
BoundAgentRef boundAgentRef = marketHandoff
|
||||
? materializeInstalledMarketAgent(ownerUserId, precheck, request.getCommandId())
|
||||
: new BoundAgentRef(precheck.getSourceAgentId(), String.valueOf(precheck.getSourceAgentVersion()));
|
||||
Integer slotRevision = persistBinding(binding, precheck, boundAgentRef, workId, slotKey,
|
||||
request.getCommandId(), marketHandoff);
|
||||
|
||||
AgentSlotBindRespVO response = new AgentSlotBindRespVO();
|
||||
response.setSlotRevision(slotRevision);
|
||||
@ -203,6 +225,41 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentSlotBindRespVO unbindAgentSlot(Long ownerUserId, Long workId, String slotKey,
|
||||
AgentSlotUnbindReqVO request) {
|
||||
String requestHash = commandService.buildRequestHash(request);
|
||||
MuseAiCommandService.CommandEnvelope envelope = commandEnvelope(request.getCommandId(), OPERATION_UNBIND,
|
||||
ownerUserId, ownerUserId, workId, slotKey, requestHash);
|
||||
MuseAiCommandDO replay = commandService.reserveCommand(envelope);
|
||||
if (replay != null) {
|
||||
return JsonUtils.parseObject(replay.getResultSnapshot(), AgentSlotBindRespVO.class);
|
||||
}
|
||||
|
||||
requireWorkOwner(workId, ownerUserId);
|
||||
rejectProtectedSlot(slotKey);
|
||||
MuseAgentSlotBindingDO binding = slotBindingMapper.selectActiveByWorkAndSlotForUpdate(
|
||||
TenantContextHolder.getRequiredTenantId(), workId, slotKey);
|
||||
if (binding == null) {
|
||||
throw conflict(AI_AGENT_SLOT_REVISION_CONFLICT);
|
||||
}
|
||||
requireRevision(binding, request.getExpectedSlotRevision());
|
||||
|
||||
binding.setStatus("archived");
|
||||
binding.setRevision(binding.getRevision() + 1);
|
||||
binding.setCommandId(request.getCommandId());
|
||||
slotBindingMapper.updateById(binding);
|
||||
|
||||
AgentSlotBindRespVO response = new AgentSlotBindRespVO();
|
||||
response.setSlotRevision(binding.getRevision());
|
||||
response.setSourceStatus("unbound");
|
||||
commandService.recordSucceeded(envelope, JsonUtils.toJsonString(response));
|
||||
audit(OPERATION_UNBIND, ownerUserId, workId, slotKey, request.getCommandId(), requestHash,
|
||||
JsonUtils.toJsonString(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 非重放路径第一步由 Content owner 确认作品归属,避免跨用户 workId 进入后续业务查询。
|
||||
*/
|
||||
@ -257,12 +314,13 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
*
|
||||
* @return 写入后的槽位 revision(供响应回传)
|
||||
*/
|
||||
private Integer persistBinding(MuseAgentSlotBindingDO binding, MuseAgentSlotPrecheckDO precheck, Long workId,
|
||||
String slotKey, String commandId, boolean marketHandoff) {
|
||||
private Integer persistBinding(MuseAgentSlotBindingDO binding, MuseAgentSlotPrecheckDO precheck,
|
||||
BoundAgentRef boundAgentRef, Long workId, String slotKey, String commandId,
|
||||
boolean marketHandoff) {
|
||||
if (binding != null) {
|
||||
// 替换:已有 active 行,沿用既有乐观锁语义(revision = 原版本 + 1)
|
||||
binding.setAgentId(precheck.getSourceAgentId());
|
||||
binding.setAgentVersion(String.valueOf(precheck.getSourceAgentVersion()));
|
||||
binding.setAgentId(boundAgentRef.agentId());
|
||||
binding.setAgentVersion(boundAgentRef.agentVersion());
|
||||
// ADR-020 收口:precheck.authorizationSnapshotId 本是 String envelope(rpe-local-<uuid>),列已 V31 改 VARCHAR,
|
||||
// 直透传不再 parseLong(原 parseLong 对字符串 envelope 返 null → 授权快照静默丢失、绑定溯源链断)。
|
||||
binding.setAuthorizationSnapshotId(precheck.getAuthorizationSnapshotId());
|
||||
@ -276,8 +334,8 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
MuseAgentSlotBindingDO created = new MuseAgentSlotBindingDO();
|
||||
created.setWorkId(workId);
|
||||
created.setSlotKey(slotKey);
|
||||
created.setAgentId(precheck.getSourceAgentId());
|
||||
created.setAgentVersion(String.valueOf(precheck.getSourceAgentVersion()));
|
||||
created.setAgentId(boundAgentRef.agentId());
|
||||
created.setAgentVersion(boundAgentRef.agentVersion());
|
||||
// ADR-020 收口:同上,String envelope 直透传(列 V31 已 VARCHAR),不再经 parseLong 丢成 null。
|
||||
created.setAuthorizationSnapshotId(precheck.getAuthorizationSnapshotId());
|
||||
created.setRevision(FIRST_BINDING_REVISION);
|
||||
@ -289,8 +347,8 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
return created.getRevision();
|
||||
}
|
||||
|
||||
private void requireVisibleActiveSourceAgent(Long ownerUserId, Long sourceAgentId, Integer sourceAgentVersion,
|
||||
boolean marketHandoff) {
|
||||
private MuseAgentVersionDO requireVisibleActiveSourceAgent(Long ownerUserId, Long sourceAgentId,
|
||||
Integer sourceAgentVersion, boolean marketHandoff) {
|
||||
MuseAgentDO agent = agentMapper.selectById(sourceAgentId);
|
||||
if (agent == null) {
|
||||
throw new ServiceException(AI_AGENT_NOT_EXISTS);
|
||||
@ -302,8 +360,8 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
// 跨空间 handoff 来源(token 已被 Market verify 通过)放宽 agentType 可见性:接纳 market 类型 agent;
|
||||
// 非 handoff 路径维持原校验(system 全可见 / user 仅属主),只开"合法 handoff 来源"这一道、不普遍放开 scope。
|
||||
if (!marketHandoff
|
||||
&& !"system".equals(agent.getAgentType())
|
||||
&& (!"user".equals(agent.getAgentType()) || !ownerUserId.equals(agent.getOwnerUserId()))) {
|
||||
&& !AGENT_TYPE_SYSTEM.equals(agent.getAgentType())
|
||||
&& (!AGENT_TYPE_USER.equals(agent.getAgentType()) || !ownerUserId.equals(agent.getOwnerUserId()))) {
|
||||
throw new ServiceException(AI_AGENT_SCOPE_FORBIDDEN);
|
||||
}
|
||||
MuseAgentVersionDO version = agentVersionMapper.selectByAgentIdAndVersion(sourceAgentId,
|
||||
@ -311,6 +369,7 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
if (version == null || !STATUS_ACTIVE.equals(version.getStatus())) {
|
||||
throw new ServiceException(AI_AGENT_NOT_EXISTS);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
private void rejectProtectedSlot(String slotKey) {
|
||||
@ -340,6 +399,9 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
if (request.getHandoffToken() == null || request.getHandoffToken().isBlank()) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
if (parseLong(request.getSourceId()) == null) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -347,16 +409,157 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
* 替代"信任客户端来源 agent"——红线("不信任客户端 URL 参数")在 agent 兑现侧落地的关键一环。
|
||||
* 沿用调用方 @Transactional 事务,verify/consume 与 precheck 落库原子(失败一并回滚);token 明文仅 precheck 阶段持有。
|
||||
*/
|
||||
private void consumeMarketHandoff(AgentSlotPrecheckReqVO request, String apiVersion, Long workId, String precheckId) {
|
||||
private HandoffVerifyRespDTO consumeMarketHandoff(AgentSlotPrecheckReqVO request, String apiVersion, Long workId,
|
||||
String precheckId) {
|
||||
HandoffVerifyRespDTO verify = marketHandoffTokenApi.verify(
|
||||
new HandoffVerifyReqDTO(request.getHandoffToken(), "agent", "bind"));
|
||||
if (!verify.valid()) {
|
||||
// token 不存在/非属主/已过期/已消费/owner-action 不匹配 → 阻断兑现,不放宽、不写任何绑定事实
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
Long requestAssetId = parseLong(request.getSourceId());
|
||||
Long tokenAssetId = parseLong(verify.assetId());
|
||||
if (requestAssetId == null || !Objects.equals(requestAssetId, tokenAssetId)) {
|
||||
// token 与请求 assetId 不一致时拒绝兑现,避免拿 A 资产 token 绑定 B 资产。
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
// 核销 token(pending→completed);bindingRef 用 precheckId,目标事实引用对齐 knowledge
|
||||
marketHandoffTokenApi.consume(new HandoffConsumeReqDTO(request.getHandoffToken(), request.getCommandId(),
|
||||
apiVersion, verify.status(), workId, precheckId));
|
||||
return verify;
|
||||
}
|
||||
|
||||
private ResolvedMarketAgentSource resolveMarketAgentSource(AgentSlotPrecheckReqVO request,
|
||||
HandoffVerifyRespDTO verify) {
|
||||
Long assetId = parseLong(verify.assetId());
|
||||
MarketAssetSourceRespDTO source = marketAssetSourceApi.getAssetSource(assetId);
|
||||
if (source == null || !source.exists() || !ASSET_TYPE_AGENT.equals(source.assetType())
|
||||
|| source.sourceId() == null) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
MuseAgentDO publisherAgent = agentMapper.selectById(source.sourceId());
|
||||
if (publisherAgent == null || !STATUS_ACTIVE.equals(publisherAgent.getStatus())) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
if (!Objects.equals(publisherAgent.getOwnerUserId(), source.publisherUserId())) {
|
||||
// Market 资产的 publisherId 必须与 AI 发布者 agent 归属一致,避免伪造 asset.source_id 绑定他人 agent。
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
MuseAgentVersionDO publisherVersion = resolvePublisherAgentVersion(publisherAgent);
|
||||
return new ResolvedMarketAgentSource(assetId, source.sourceId(), parseVersion(publisherVersion.getVersion()),
|
||||
source.assetName(), source.publisherUserId());
|
||||
}
|
||||
|
||||
private MuseAgentVersionDO resolvePublisherAgentVersion(MuseAgentDO publisherAgent) {
|
||||
// market handoff 的 sourceVersion 表示市场资产版本,不能当成 AI agent 版本信任;发布者 agent 版本只从 AI 自有事实解析。
|
||||
MuseAgentVersionDO version = publisherAgent.getCurrentVersionId() == null
|
||||
? agentVersionMapper.selectLatestByAgentId(publisherAgent.getId())
|
||||
: agentVersionMapper.selectById(publisherAgent.getCurrentVersionId());
|
||||
if (version == null || !STATUS_ACTIVE.equals(version.getStatus()) || parseVersion(version.getVersion()) == null) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
private BoundAgentRef materializeInstalledMarketAgent(Long ownerUserId, MuseAgentSlotPrecheckDO precheck,
|
||||
String commandId) {
|
||||
Long marketAssetId = marketAssetIdFromPrecheck(precheck);
|
||||
if (marketAssetId == null) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
MuseAgentDO existing = agentMapper.selectInstalledMarketAgent(ownerUserId, marketAssetId);
|
||||
if (existing != null) {
|
||||
MuseAgentVersionDO latest = agentVersionMapper.selectLatestByAgentId(existing.getId());
|
||||
if (latest == null || !STATUS_ACTIVE.equals(latest.getStatus())) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
return new BoundAgentRef(existing.getId(), latest.getVersion());
|
||||
}
|
||||
MuseAgentDO publisherAgent = agentMapper.selectById(precheck.getSourceAgentId());
|
||||
MuseAgentVersionDO publisherVersion = agentVersionMapper.selectByAgentIdAndVersion(precheck.getSourceAgentId(),
|
||||
String.valueOf(precheck.getSourceAgentVersion()));
|
||||
if (publisherAgent == null || publisherVersion == null || !STATUS_ACTIVE.equals(publisherAgent.getStatus())
|
||||
|| !STATUS_ACTIVE.equals(publisherVersion.getStatus())) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
|
||||
MuseAgentDO installed = new MuseAgentDO();
|
||||
installed.setAgentKey("market:" + marketAssetId + ":u:" + ownerUserId);
|
||||
installed.setName(installedAgentName(publisherAgent, marketAssetNameFromPrecheck(precheck)));
|
||||
installed.setDescription(publisherAgent.getDescription());
|
||||
installed.setAgentType(AGENT_TYPE_USER);
|
||||
installed.setOwnerUserId(ownerUserId);
|
||||
installed.setPromptKey(publisherAgent.getPromptKey());
|
||||
installed.setStatus(STATUS_ACTIVE);
|
||||
installed.setSourceMarketAssetId(marketAssetId);
|
||||
installed.setSlotBindings(publisherAgent.getSlotBindings());
|
||||
installed.setCategory(publisherAgent.getCategory());
|
||||
installed.setTags(publisherAgent.getTags());
|
||||
installed.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
agentMapper.insert(installed);
|
||||
|
||||
MuseAgentVersionDO installedVersion = new MuseAgentVersionDO();
|
||||
installedVersion.setAgentId(installed.getId());
|
||||
installedVersion.setVersion("1");
|
||||
installedVersion.setConfig(publisherVersion.getConfig());
|
||||
installedVersion.setChangeNote("market asset " + marketAssetId + " installed from agent "
|
||||
+ precheck.getSourceAgentId() + " v" + precheck.getSourceAgentVersion());
|
||||
installedVersion.setStatus(STATUS_ACTIVE);
|
||||
installedVersion.setCommandId(commandId + ":market-agent-version");
|
||||
installedVersion.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
agentVersionMapper.insert(installedVersion);
|
||||
|
||||
installed.setCurrentVersionId(installedVersion.getId());
|
||||
agentMapper.updateById(installed);
|
||||
return new BoundAgentRef(installed.getId(), installedVersion.getVersion());
|
||||
}
|
||||
|
||||
private Map<String, Object> precheckResultSummary(AgentSlotPrecheckRespVO response,
|
||||
ResolvedMarketAgentSource marketAgentSource,
|
||||
MuseAgentVersionDO sourceVersion) {
|
||||
java.util.LinkedHashMap<String, Object> summary = new java.util.LinkedHashMap<>();
|
||||
summary.put("agentSlotPrecheckId", response.getAgentSlotPrecheckId());
|
||||
summary.put("expiresAt", response.getExpiresAt());
|
||||
if (marketAgentSource != null) {
|
||||
summary.put("marketAssetId", marketAgentSource.marketAssetId());
|
||||
summary.put("publisherAgentId", marketAgentSource.publisherAgentId());
|
||||
summary.put("publisherAgentVersion", marketAgentSource.publisherAgentVersion());
|
||||
summary.put("publisherAgentVersionId", sourceVersion == null ? null : sourceVersion.getId());
|
||||
summary.put("assetName", marketAgentSource.assetName());
|
||||
summary.put("publisherUserId", marketAgentSource.publisherUserId());
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Long marketAssetIdFromPrecheck(MuseAgentSlotPrecheckDO precheck) {
|
||||
Map<String, Object> summary = readSummary(precheck.getResultSummary());
|
||||
return longValue(summary.get("marketAssetId"));
|
||||
}
|
||||
|
||||
private String marketAssetNameFromPrecheck(MuseAgentSlotPrecheckDO precheck) {
|
||||
Object value = readSummary(precheck.getResultSummary()).get("assetName");
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private Map<String, Object> readSummary(String json) {
|
||||
Map<String, Object> parsed = JsonUtils.parseObjectQuietly(json, new com.fasterxml.jackson.core.type.TypeReference<>() {
|
||||
});
|
||||
return parsed == null ? Map.of() : parsed;
|
||||
}
|
||||
|
||||
private Long longValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
return parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private String installedAgentName(MuseAgentDO publisherAgent, String assetName) {
|
||||
String name = StringUtils.hasText(assetName) ? assetName : publisherAgent.getName();
|
||||
return StringUtils.hasText(name) ? name : "Market Agent";
|
||||
}
|
||||
|
||||
private String hashNullable(String value) {
|
||||
@ -371,15 +574,22 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePrecheckMatchesBindRequest(MuseAgentSlotPrecheckDO precheck, AgentSlotBindReqVO request) {
|
||||
if (!Objects.equals(precheck.getSourceAgentId(), request.getSourceAgentId())
|
||||
|| !Objects.equals(precheck.getSourceAgentVersion(), request.getSourceAgentVersion())
|
||||
private void requirePrecheckMatchesBindRequest(MuseAgentSlotPrecheckDO precheck, AgentSlotBindReqVO request,
|
||||
boolean marketHandoff) {
|
||||
if ((!marketHandoff && (!Objects.equals(precheck.getSourceAgentId(), request.getSourceAgentId())
|
||||
|| !Objects.equals(precheck.getSourceAgentVersion(), request.getSourceAgentVersion())))
|
||||
|| !Objects.equals(precheck.getExpectedSlotRevision(), request.getExpectedSlotRevision())
|
||||
|| !sameAuthorizationSnapshot(precheck.getAuthorizationSnapshotId(), request.getAuthorizationSnapshotId())) {
|
||||
throw conflict(AI_AGENT_SLOT_REVISION_CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSameSpaceSourceFields(Long sourceAgentId, Integer sourceAgentVersion) {
|
||||
if (sourceAgentId == null || sourceAgentVersion == null) {
|
||||
throw new ServiceException(AI_AGENT_NOT_EXISTS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* authorizationSnapshotId 在历史 DTO/DDL 间可能表现为 null、字符串或数字文本,这里统一成语义值比较。
|
||||
*/
|
||||
@ -439,4 +649,14 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private record ResolvedMarketAgentSource(Long marketAssetId,
|
||||
Long publisherAgentId,
|
||||
Integer publisherAgentVersion,
|
||||
String assetName,
|
||||
Long publisherUserId) {
|
||||
}
|
||||
|
||||
private record BoundAgentRef(Long agentId, String agentVersion) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package cn.iocoder.muse.module.ai.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.AccountUsageFacade;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiGenerationDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiJobDO;
|
||||
@ -55,6 +56,8 @@ public class MuseAiRuntimeProjectionService {
|
||||
private MuseAiEventPublishOutboxService eventPublishOutboxService;
|
||||
@Resource
|
||||
private MuseAiCandidateReviewService candidateReviewService;
|
||||
@Resource
|
||||
private AccountUsageFacade accountUsageFacade;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyRuntimeResponse(MuseAiGenerationDO task, MuseAiJobDO job,
|
||||
@ -76,6 +79,9 @@ public class MuseAiRuntimeProjectionService {
|
||||
if (succeeded) {
|
||||
suggestionId = createRuntimeSuggestion(task, job, result, qualityScores, "suggestion");
|
||||
task.setCandidateId(suggestionId);
|
||||
recordAccountUsage(task, job, result, command);
|
||||
} else if (!retryable) {
|
||||
releaseAccountQuota(task, job, failure);
|
||||
}
|
||||
task.setStatus(terminalOrRetryStatus);
|
||||
task.setRuntimeStatus(terminalOrRetryStatus);
|
||||
@ -106,6 +112,67 @@ public class MuseAiRuntimeProjectionService {
|
||||
}
|
||||
}
|
||||
|
||||
private void recordAccountUsage(MuseAiGenerationDO task, MuseAiJobDO job,
|
||||
MuseAiRuntimeClient.RuntimeResult result,
|
||||
MuseAiRuntimeClient.RuntimeCommand command) {
|
||||
// runtime 成功和 Account 用量入账必须在同一投影事务内完成;否则不能把 AI task 标成 completed。
|
||||
accountUsageFacade.recordAiGenerationUsage(AccountUsageFacade.AiGenerationUsage.builder()
|
||||
.accountUserId(task.getOwnerUserId())
|
||||
.commandId(job.getCommandId())
|
||||
.correlationId(correlationId(job, result == null ? null : result.correlationId()))
|
||||
.taskId(task.getId())
|
||||
.jobId(job.getId())
|
||||
.workId(task.getWorkId())
|
||||
.agentId(task.getAgentId())
|
||||
.agentVersionId(task.getAgentVersionId())
|
||||
.modelKey(command == null ? null : command.modelKey())
|
||||
.promptTokens(tokenUsageInt(result, "promptTokens", "prompt_tokens"))
|
||||
.completionTokens(tokenUsageInt(result, "completionTokens", "completion_tokens"))
|
||||
.totalTokens(tokenUsageInt(result, "totalTokens", "total_tokens"))
|
||||
.costCents(result == null ? null : result.costCents())
|
||||
.providerRequestId(result == null ? null : result.providerRequestId())
|
||||
.finishReason(result == null ? null : result.finishReason())
|
||||
.build());
|
||||
}
|
||||
|
||||
private void releaseAccountQuota(MuseAiGenerationDO task, MuseAiJobDO job,
|
||||
MuseAiRuntimeClient.RuntimeFailure failure) {
|
||||
// 只有不可重试失败才释放预扣额度;retryable failure 会回到 queued,额度继续占用,防止排队重试期间穿透额度。
|
||||
accountUsageFacade.releaseAiGenerationQuota(AccountUsageFacade.AiGenerationQuotaRelease.builder()
|
||||
.accountUserId(task.getOwnerUserId())
|
||||
.commandId(job.getCommandId())
|
||||
.correlationId(correlationId(job, failure == null ? null : failure.correlationId()))
|
||||
.taskId(task.getId())
|
||||
.jobId(job.getId())
|
||||
.workId(task.getWorkId())
|
||||
.failureCode(errorCode(failure))
|
||||
.failureMessage(errorMessage(failure))
|
||||
.build());
|
||||
}
|
||||
|
||||
private String correlationId(MuseAiJobDO job, String fallback) {
|
||||
return StringUtils.hasText(job.getCorrelationId()) ? job.getCorrelationId() : fallback;
|
||||
}
|
||||
|
||||
private Integer tokenUsageInt(MuseAiRuntimeClient.RuntimeResult result, String camelKey, String snakeKey) {
|
||||
if (result == null || result.tokenUsage() == null) {
|
||||
return 0;
|
||||
}
|
||||
Object value = result.tokenUsage().containsKey(camelKey)
|
||||
? result.tokenUsage().get(camelKey) : result.tokenUsage().get(snakeKey);
|
||||
if (value instanceof Number number) {
|
||||
return Math.max(0, number.intValue());
|
||||
}
|
||||
if (value == null || !StringUtils.hasText(String.valueOf(value))) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Math.max(0, Integer.parseInt(String.valueOf(value)));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 executor 放入 command.inputSummary 的 Context Assembly Snapshot 固化进生成记录 sourceSummary(专题-03 §8 质量评测可追溯输入)。 */
|
||||
private void mergeContextAssembly(MuseAiGenerationDO task, MuseAiRuntimeClient.RuntimeCommand command) {
|
||||
if (command == null || command.inputSummary() == null) {
|
||||
|
||||
@ -5,6 +5,7 @@ import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.AccountUsageFacade;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.MuseContentWorkOwnerFacade;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.SecurityRuntimePermissionFacade;
|
||||
@ -94,6 +95,8 @@ public class MuseAiTaskServiceImpl implements MuseAiTaskService {
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Resource
|
||||
private MuseAiRuntimePayloadStore runtimePayloadStore;
|
||||
@Resource
|
||||
private AccountUsageFacade accountUsageFacade;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@ -116,6 +119,7 @@ public class MuseAiTaskServiceImpl implements MuseAiTaskService {
|
||||
|
||||
String jobId = "job-" + UUID.randomUUID();
|
||||
String correlationId = "corr-" + UUID.randomUUID();
|
||||
reserveAccountQuota(ownerUserId, envelope, request, agentRuntimeRef, correlationId);
|
||||
MuseAiGenerationDO task = createTask(request, ownerUserId, envelope.getCommandId(), requestHash,
|
||||
sourceSnapshot, permissionEnvelope, agentRuntimeRef);
|
||||
task.setJobId(jobId);
|
||||
@ -140,6 +144,21 @@ public class MuseAiTaskServiceImpl implements MuseAiTaskService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private void reserveAccountQuota(Long ownerUserId, MuseAiCommandService.CommandEnvelope envelope,
|
||||
CreateAiTaskReqVO request, AgentRuntimeRef agentRuntimeRef,
|
||||
String correlationId) {
|
||||
// Account 额度预扣必须发生在 task/job 入库前;失败时整次 createAiTask 不产生半截任务事实。
|
||||
accountUsageFacade.reserveAiGenerationQuota(AccountUsageFacade.AiGenerationQuotaReservation.builder()
|
||||
.accountUserId(ownerUserId)
|
||||
.commandId(envelope.getCommandId())
|
||||
.correlationId(correlationId)
|
||||
.workId(request == null ? null : request.getWorkId())
|
||||
.agentId(agentRuntimeRef.agentId())
|
||||
.agentVersionId(agentRuntimeRef.agentVersionId())
|
||||
.modelKey(agentRuntimeRef.modelKey())
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public AiTaskDetailRespVO getAiTask(Long ownerUserId, Long taskId) {
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
package cn.iocoder.muse.module.ai.application.muse.facade;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* AI 到 Account 用量/配额的本域出站边界。
|
||||
*
|
||||
* <p>AI 生成链路只能通过本 facade 触达 Account BC;facade 实现再消费 member-api 对外端口,
|
||||
* 避免 AI 服务感知 member 内部表或应用服务。</p>
|
||||
*/
|
||||
public interface AccountUsageFacade {
|
||||
|
||||
void reserveAiGenerationQuota(AiGenerationQuotaReservation command);
|
||||
|
||||
void releaseAiGenerationQuota(AiGenerationQuotaRelease command);
|
||||
|
||||
void recordAiGenerationUsage(AiGenerationUsage command);
|
||||
|
||||
@Builder
|
||||
record AiGenerationQuotaReservation(Long accountUserId,
|
||||
String commandId,
|
||||
String correlationId,
|
||||
Long workId,
|
||||
Long agentId,
|
||||
Long agentVersionId,
|
||||
String modelKey) {
|
||||
}
|
||||
|
||||
@Builder
|
||||
record AiGenerationQuotaRelease(Long accountUserId,
|
||||
String commandId,
|
||||
String correlationId,
|
||||
Long taskId,
|
||||
Long jobId,
|
||||
Long workId,
|
||||
String failureCode,
|
||||
String failureMessage) {
|
||||
}
|
||||
|
||||
@Builder
|
||||
record AiGenerationUsage(Long accountUserId,
|
||||
String commandId,
|
||||
String correlationId,
|
||||
Long taskId,
|
||||
Long jobId,
|
||||
Long workId,
|
||||
Long agentId,
|
||||
Long agentVersionId,
|
||||
String modelKey,
|
||||
Integer promptTokens,
|
||||
Integer completionTokens,
|
||||
Integer totalTokens,
|
||||
Long costCents,
|
||||
String providerRequestId,
|
||||
String finishReason) {
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
package cn.iocoder.muse.module.ai.application.muse.facade;
|
||||
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.ai.application.muse.MuseAiAuditService;
|
||||
import cn.iocoder.muse.module.ai.application.muse.MuseAiCommandService;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiCommandDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDecisionDO;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiSuggestionDecisionMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiSuggestionMapper;
|
||||
import cn.iocoder.muse.module.content.application.facade.ContentAiSuggestionFacade;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.ContentSourceSnapshotVO;
|
||||
@ -9,10 +15,13 @@ import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI 侧实现 Content 的 {@link ContentAiSuggestionFacade} 接缝 adapter(P1 增量 2 / 缺口 A)。
|
||||
@ -48,6 +57,12 @@ public class AiSuggestionMergeProjectionFacade implements ContentAiSuggestionFac
|
||||
|
||||
@Resource
|
||||
private MuseAiSuggestionMapper suggestionMapper;
|
||||
@Resource
|
||||
private MuseAiSuggestionDecisionMapper decisionMapper;
|
||||
@Resource
|
||||
private MuseAiCommandService commandService;
|
||||
@Resource
|
||||
private MuseAiAuditService auditService;
|
||||
|
||||
@Override
|
||||
public SuggestionLookupResult getSuggestion(Long userId, Long workId, Long blockId, Long suggestionId) {
|
||||
@ -104,6 +119,97 @@ public class AiSuggestionMergeProjectionFacade implements ContentAiSuggestionFac
|
||||
return SuggestionLookupResult.available(projection);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AcceptedDecisionResult markSuggestionAccepted(Long userId, Long workId, Long blockId,
|
||||
AcceptedSuggestionDecision decision) {
|
||||
if (decision == null || decision.suggestionId() == null
|
||||
|| !StringUtils.hasText(decision.contentCommandId())) {
|
||||
return AcceptedDecisionResult.unavailable();
|
||||
}
|
||||
String aiCommandId = acceptedCommandId(decision.contentCommandId());
|
||||
Map<String, Object> requestSummary = acceptedRequestSummary(userId, workId, blockId, decision);
|
||||
String requestHash = commandService.buildRequestHash(requestSummary);
|
||||
MuseAiCommandService.CommandEnvelope envelope = MuseAiCommandService.CommandEnvelope.builder()
|
||||
.commandId(aiCommandId)
|
||||
.operationId("markSuggestionAccepted")
|
||||
.actorUserId(userId)
|
||||
.ownerUserId(userId)
|
||||
.targetType("suggestion")
|
||||
.targetId(decision.suggestionId())
|
||||
.targetKey("suggestion:" + decision.suggestionId())
|
||||
.requestHash(requestHash)
|
||||
.build();
|
||||
// 先查 replay 再看当前 suggestion 状态:同一 commandId 的成功重放应直接返回 accepted;
|
||||
// 新命令若遇到已处置 suggestion 则返回 conflict 且不预占命令,避免留下无结果的孤儿 command。
|
||||
MuseAiCommandDO replay = commandService.getReplayCommand(envelope);
|
||||
if (replay != null && StringUtils.hasText(replay.getResultSnapshot())) {
|
||||
return AcceptedDecisionResult.acceptedResult();
|
||||
}
|
||||
if (replay != null) {
|
||||
return AcceptedDecisionResult.conflict();
|
||||
}
|
||||
|
||||
MuseAiSuggestionDO suggestion = suggestionMapper.selectById(decision.suggestionId());
|
||||
if (suggestion == null
|
||||
|| !Objects.equals(suggestion.getWorkId(), workId)
|
||||
|| !Objects.equals(suggestion.getBlockId(), blockId)) {
|
||||
return AcceptedDecisionResult.unavailable();
|
||||
}
|
||||
if (!"pending".equals(normalizeStatus(suggestion.getStatus()))) {
|
||||
return AcceptedDecisionResult.conflict();
|
||||
}
|
||||
if (decision.suggestionRevision() != null
|
||||
&& !Objects.equals(decision.suggestionRevision(), suggestion.getSourceRevision())) {
|
||||
return AcceptedDecisionResult.conflict();
|
||||
}
|
||||
// Content replay 不会再次调用本方法;这里仍用 AI 自有 commandId 做幂等兜底,避免网络重试或未来异步补偿双写决策。
|
||||
replay = commandService.reserveCommand(envelope);
|
||||
if (replay != null) {
|
||||
return AcceptedDecisionResult.acceptedResult();
|
||||
}
|
||||
|
||||
MuseAiSuggestionDecisionDO decisionArchive = new MuseAiSuggestionDecisionDO();
|
||||
decisionArchive.setSuggestionId(decision.suggestionId());
|
||||
decisionArchive.setDecisionId("decision-" + UUID.randomUUID());
|
||||
decisionArchive.setDecision("accepted");
|
||||
decisionArchive.setReasonCode(limit(decision.auditReason(), 64));
|
||||
decisionArchive.setOwnerUserId(userId);
|
||||
decisionArchive.setActorUserId(userId);
|
||||
decisionArchive.setWorkId(workId);
|
||||
decisionArchive.setCommandId(aiCommandId);
|
||||
decisionArchive.setRequestHash(requestHash);
|
||||
decisionArchive.setDecisionSummary(JsonUtils.toJsonString(acceptedDecisionSummary(decision)));
|
||||
decisionArchive.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
decisionMapper.insert(decisionArchive);
|
||||
|
||||
MuseAiSuggestionDO update = new MuseAiSuggestionDO();
|
||||
update.setId(decision.suggestionId());
|
||||
update.setStatus("accepted");
|
||||
update.setDecisionArchiveId(decisionArchive.getId());
|
||||
suggestionMapper.updateById(update);
|
||||
|
||||
String responseSnapshot = JsonUtils.toJsonString(Map.of(
|
||||
"suggestionId", decision.suggestionId(),
|
||||
"status", "accepted",
|
||||
"decisionArchiveId", decisionArchive.getId()));
|
||||
commandService.recordSucceeded(envelope, responseSnapshot);
|
||||
auditService.record(MuseAiAuditService.AuditCreateReq.builder()
|
||||
.operationId("markSuggestionAccepted")
|
||||
.side("app")
|
||||
.actorUserId(userId)
|
||||
.ownerUserId(userId)
|
||||
.targetType("suggestion")
|
||||
.targetId(decision.suggestionId())
|
||||
.commandId(aiCommandId)
|
||||
.requestHash(requestHash)
|
||||
.requestSummary(JsonUtils.toJsonString(requestSummary))
|
||||
.responseSummary(responseSnapshot)
|
||||
.status("succeeded")
|
||||
.build());
|
||||
return AcceptedDecisionResult.acceptedResult();
|
||||
}
|
||||
|
||||
/** suggestion 状态规范化:只透出 Content 合同认得的三态,脏状态按最保守的 pending 处理。 */
|
||||
private String normalizeStatus(String status) {
|
||||
String normalized = StringUtils.hasText(status) ? status : "pending";
|
||||
@ -161,4 +267,45 @@ public class AiSuggestionMergeProjectionFacade implements ContentAiSuggestionFac
|
||||
return StringUtils.hasText(value) ? value : fallback;
|
||||
}
|
||||
|
||||
private String acceptedCommandId(String contentCommandId) {
|
||||
// AI command_id 上限 128;Content commandId 由调用方提供,不能直接拼前缀后入库。
|
||||
// 用命名 UUID 稳定派生内部幂等键,原 Content commandId 仍保存在 request/decision summary 中便于追踪。
|
||||
UUID stableId = UUID.nameUUIDFromBytes(contentCommandId.getBytes(StandardCharsets.UTF_8));
|
||||
return "content-accepted:" + stableId;
|
||||
}
|
||||
|
||||
private Map<String, Object> acceptedRequestSummary(Long userId, Long workId, Long blockId,
|
||||
AcceptedSuggestionDecision decision) {
|
||||
Map<String, Object> summary = new java.util.LinkedHashMap<>();
|
||||
summary.put("suggestionId", decision.suggestionId());
|
||||
summary.put("workId", workId);
|
||||
summary.put("blockId", blockId);
|
||||
summary.put("actorUserId", userId);
|
||||
summary.put("contentCommandId", decision.contentCommandId());
|
||||
summary.put("decisionType", decision.decisionType());
|
||||
summary.put("mergeMode", decision.mergeMode());
|
||||
summary.put("suggestionRevision", decision.suggestionRevision());
|
||||
summary.put("newBlockRevision", decision.newBlockRevision());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Map<String, Object> acceptedDecisionSummary(AcceptedSuggestionDecision decision) {
|
||||
Map<String, Object> summary = new java.util.LinkedHashMap<>();
|
||||
summary.put("status", "accepted");
|
||||
summary.put("contentCommandId", decision.contentCommandId());
|
||||
summary.put("decisionType", decision.decisionType());
|
||||
summary.put("mergeMode", decision.mergeMode());
|
||||
summary.put("suggestionRevision", decision.suggestionRevision());
|
||||
summary.put("newBlockRevision", decision.newBlockRevision());
|
||||
// 决策摘要只保存来源、模式与版本,不保存 finalContent 或 provider 原文。
|
||||
return summary;
|
||||
}
|
||||
|
||||
private String limit(String value, int maxLength) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package cn.iocoder.muse.module.ai.application.muse.facade;
|
||||
|
||||
import cn.iocoder.muse.module.member.api.account.MuseAccountUsageApi;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountAiGenerationQuotaReleaseReqDTO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountAiGenerationQuotaReserveReqDTO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountAiGenerationUsageRecordReqDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Account 用量 facade 的真实本地适配器。
|
||||
*/
|
||||
@Primary
|
||||
@Component
|
||||
// AI 生成会消耗用户权益;单体装配下必须真实注入 Account 写端口,缺失时启动失败,不能运行时漏计费。
|
||||
public class MemberAccountUsageFacade implements AccountUsageFacade {
|
||||
|
||||
@Resource
|
||||
private MuseAccountUsageApi accountUsageApi;
|
||||
|
||||
@Override
|
||||
public void reserveAiGenerationQuota(AiGenerationQuotaReservation command) {
|
||||
accountUsageApi.reserveAiGenerationQuota(MuseAccountAiGenerationQuotaReserveReqDTO.builder()
|
||||
.accountUserId(command.accountUserId())
|
||||
.commandId(command.commandId())
|
||||
.correlationId(command.correlationId())
|
||||
.workId(command.workId())
|
||||
.agentId(command.agentId())
|
||||
.agentVersionId(command.agentVersionId())
|
||||
.modelKey(command.modelKey())
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseAiGenerationQuota(AiGenerationQuotaRelease command) {
|
||||
accountUsageApi.releaseAiGenerationQuota(MuseAccountAiGenerationQuotaReleaseReqDTO.builder()
|
||||
.accountUserId(command.accountUserId())
|
||||
.commandId(command.commandId())
|
||||
.correlationId(command.correlationId())
|
||||
.taskId(command.taskId())
|
||||
.jobId(command.jobId())
|
||||
.workId(command.workId())
|
||||
.failureCode(command.failureCode())
|
||||
.failureMessage(command.failureMessage())
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordAiGenerationUsage(AiGenerationUsage command) {
|
||||
accountUsageApi.recordAiGenerationUsage(MuseAccountAiGenerationUsageRecordReqDTO.builder()
|
||||
.accountUserId(command.accountUserId())
|
||||
.commandId(command.commandId())
|
||||
.correlationId(command.correlationId())
|
||||
.taskId(command.taskId())
|
||||
.jobId(command.jobId())
|
||||
.workId(command.workId())
|
||||
.agentId(command.agentId())
|
||||
.agentVersionId(command.agentVersionId())
|
||||
.modelKey(command.modelKey())
|
||||
.promptTokens(command.promptTokens())
|
||||
.completionTokens(command.completionTokens())
|
||||
.totalTokens(command.totalTokens())
|
||||
.costCents(command.costCents())
|
||||
.providerRequestId(command.providerRequestId())
|
||||
.finishReason(command.finishReason())
|
||||
.build());
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package cn.iocoder.muse.module.ai.application.muse.facade;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_RESOURCE_FORBIDDEN;
|
||||
|
||||
/**
|
||||
* 默认失败关闭的 Account 用量 facade。
|
||||
*
|
||||
* <p>AI 生成属于会消耗用户权益的写操作;单模块缺失 Account 写端口时必须拒绝,不能伪造“未计费成功”。</p>
|
||||
*/
|
||||
public class UnavailableAccountUsageFacade implements AccountUsageFacade {
|
||||
|
||||
@Override
|
||||
public void reserveAiGenerationQuota(AiGenerationQuotaReservation command) {
|
||||
throw new ServiceException(AI_RESOURCE_FORBIDDEN.getCode(), "Account 用量服务不可用");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseAiGenerationQuota(AiGenerationQuotaRelease command) {
|
||||
throw new ServiceException(AI_RESOURCE_FORBIDDEN.getCode(), "Account 用量服务不可用");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordAiGenerationUsage(AiGenerationUsage command) {
|
||||
throw new ServiceException(AI_RESOURCE_FORBIDDEN.getCode(), "Account 用量服务不可用");
|
||||
}
|
||||
|
||||
}
|
||||
@ -6,7 +6,11 @@ import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AdminAgentCreateRespVO
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AgentVersionCreateRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.MuseAiPageResult;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionLifecycleReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentArchiveReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentCreateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentUpdateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentVersionCreateReqVO;
|
||||
import cn.iocoder.muse.module.ai.domain.muse.MuseAiApiVersionGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@ -17,6 +21,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
@ -61,6 +66,26 @@ public class AppMuseAgentController {
|
||||
return success(agentService.createUserAgent(getLoginUserId(), request));
|
||||
}
|
||||
|
||||
@PutMapping("/{agentId}")
|
||||
@Operation(summary = "更新用户 Agent")
|
||||
public CommonResult<AgentSummaryRespVO> updateAgent(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long agentId,
|
||||
@Valid @RequestBody UserAgentUpdateReqVO request) {
|
||||
MuseAiApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(agentService.updateUserAgent(getLoginUserId(), agentId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{agentId}/archive")
|
||||
@Operation(summary = "归档用户 Agent")
|
||||
public CommonResult<AgentSummaryRespVO> archiveAgent(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long agentId,
|
||||
@Valid @RequestBody UserAgentArchiveReqVO request) {
|
||||
MuseAiApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(agentService.archiveUserAgent(getLoginUserId(), agentId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{agentId}/versions")
|
||||
@Operation(summary = "创建用户 Agent 版本")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@ -72,4 +97,37 @@ public class AppMuseAgentController {
|
||||
return success(agentService.createUserAgentVersion(getLoginUserId(), agentId, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{agentId}/versions")
|
||||
@Operation(summary = "用户 Agent 版本列表")
|
||||
public CommonResult<MuseAiPageResult<AgentVersionSummaryRespVO>> listAgentVersions(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long agentId,
|
||||
@RequestParam(defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(defaultValue = "20") Integer pageSize) {
|
||||
MuseAiApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(agentService.listUserAgentVersions(getLoginUserId(), agentId, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@PostMapping("/{agentId}/versions/{version}/activate")
|
||||
@Operation(summary = "激活用户 Agent 版本")
|
||||
public CommonResult<AgentVersionCreateRespVO> activateAgentVersion(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long agentId,
|
||||
@PathVariable Integer version,
|
||||
@Valid @RequestBody AgentVersionLifecycleReqVO request) {
|
||||
MuseAiApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(agentService.activateUserAgentVersion(getLoginUserId(), agentId, version, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{agentId}/versions/{version}/archive")
|
||||
@Operation(summary = "归档用户 Agent 版本")
|
||||
public CommonResult<AgentVersionCreateRespVO> archiveAgentVersion(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long agentId,
|
||||
@PathVariable Integer version,
|
||||
@Valid @RequestBody AgentVersionLifecycleReqVO request) {
|
||||
MuseAiApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(agentService.archiveUserAgentVersion(getLoginUserId(), agentId, version, request));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotBindRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotUnbindReqVO;
|
||||
import cn.iocoder.muse.module.ai.domain.muse.MuseAiApiVersionGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -73,6 +74,17 @@ public class AppMuseAgentSlotController {
|
||||
return success(slotService.bindAgentSlot(getLoginUserId(), workId, slotKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{slotKey}/unbind")
|
||||
@Operation(summary = "解绑 Agent 槽位")
|
||||
public CommonResult<AgentSlotBindRespVO> unbindAgentSlot(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long workId,
|
||||
@PathVariable String slotKey,
|
||||
@Valid @RequestBody AgentSlotUnbindReqVO request) {
|
||||
MuseAiApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(slotService.unbindAgentSlot(getLoginUserId(), workId, slotKey, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* precheck/bind 的合同冲突只在本控制器返回 409,避免影响其他 AI 业务异常的全局语义。
|
||||
*/
|
||||
|
||||
@ -2,7 +2,6 @@ package cn.iocoder.muse.module.ai.controller.app.muse.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@ -18,15 +17,15 @@ public class AgentSlotBindReqVO {
|
||||
@NotBlank(message = "agentSlotPrecheckId 不能为空")
|
||||
private String agentSlotPrecheckId;
|
||||
|
||||
@NotNull(message = "sourceAgentId 不能为空")
|
||||
@Schema(description = "来源智能体 ID;同空间绑定用于与预检凭据比对,market_agent 来源可为空并以预检固化事实为准")
|
||||
private Long sourceAgentId;
|
||||
|
||||
@NotNull(message = "sourceAgentVersion 不能为空")
|
||||
@Schema(description = "来源智能体版本;同空间绑定用于与预检凭据比对,market_agent 来源可为空并以预检固化事实为准")
|
||||
private Integer sourceAgentVersion;
|
||||
|
||||
private String authorizationSnapshotId;
|
||||
|
||||
@NotNull(message = "expectedSlotRevision 不能为空")
|
||||
@Schema(description = "目标槽位当前 revision;首次绑定无 active 行时可为空")
|
||||
private Integer expectedSlotRevision;
|
||||
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ package cn.iocoder.muse.module.ai.controller.app.muse.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@ -15,10 +14,10 @@ public class AgentSlotPrecheckReqVO {
|
||||
@NotBlank(message = "commandId 不能为空")
|
||||
private String commandId;
|
||||
|
||||
@NotNull(message = "sourceAgentId 不能为空")
|
||||
@Schema(description = "来源智能体 ID;同空间绑定必填,market_agent 来源由服务端按 assetId 解析发布者智能体")
|
||||
private Long sourceAgentId;
|
||||
|
||||
@NotNull(message = "sourceAgentVersion 不能为空")
|
||||
@Schema(description = "来源智能体版本;同空间绑定必填,market_agent 来源由服务端按发布者当前 active 版本固化")
|
||||
private Integer sourceAgentVersion;
|
||||
|
||||
private String authorizationSnapshotId;
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
package cn.iocoder.muse.module.ai.controller.app.muse.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Agent 槽位解绑请求。
|
||||
*/
|
||||
@Schema(description = "Agent 槽位解绑请求")
|
||||
@Data
|
||||
public class AgentSlotUnbindReqVO {
|
||||
|
||||
@NotBlank(message = "commandId 不能为空")
|
||||
private String commandId;
|
||||
|
||||
@NotNull(message = "expectedSlotRevision 不能为空")
|
||||
private Integer expectedSlotRevision;
|
||||
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package cn.iocoder.muse.module.ai.controller.app.muse.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Agent 版本激活/归档请求。
|
||||
*/
|
||||
@Schema(description = "Agent 版本激活/归档请求")
|
||||
@Data
|
||||
public class AgentVersionLifecycleReqVO {
|
||||
|
||||
private String commandId;
|
||||
private String reason;
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package cn.iocoder.muse.module.ai.controller.app.muse.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户端 Agent 版本摘要响应。
|
||||
*/
|
||||
@Schema(description = "用户端 Agent 版本摘要响应")
|
||||
@Data
|
||||
public class AgentVersionSummaryRespVO {
|
||||
|
||||
private Long agentId;
|
||||
private Integer version;
|
||||
private String status;
|
||||
private Boolean active;
|
||||
private String changeNote;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package cn.iocoder.muse.module.ai.controller.app.muse.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户归档 Agent 请求。
|
||||
*/
|
||||
@Schema(description = "用户归档 Agent 请求")
|
||||
@Data
|
||||
public class UserAgentArchiveReqVO {
|
||||
|
||||
private String commandId;
|
||||
private String reason;
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package cn.iocoder.muse.module.ai.controller.app.muse.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户更新 Agent 请求。
|
||||
*/
|
||||
@Schema(description = "用户更新 Agent 请求")
|
||||
@Data
|
||||
public class UserAgentUpdateReqVO {
|
||||
|
||||
private String name;
|
||||
private String description;
|
||||
private String promptTemplate;
|
||||
private Map<String, Object> slotBindings;
|
||||
private String changeNote;
|
||||
private String commandId;
|
||||
|
||||
}
|
||||
@ -30,6 +30,8 @@ public class MuseAgentDO extends TenantBaseDO {
|
||||
private String promptKey;
|
||||
private String status;
|
||||
private Long currentVersionId;
|
||||
/** Market agent 安装物化后的来源资产 id;为空表示非市场安装副本。 */
|
||||
private Long sourceMarketAssetId;
|
||||
@TableField(typeHandler = JsonbStringTypeHandler.class)
|
||||
private String slotBindings;
|
||||
private String category;
|
||||
|
||||
@ -41,6 +41,16 @@ public interface MuseAgentMapper extends BaseMapperX<MuseAgentDO> {
|
||||
return selectPage(pageParam, query);
|
||||
}
|
||||
|
||||
default MuseAgentDO selectInstalledMarketAgent(Long ownerUserId, Long sourceMarketAssetId) {
|
||||
return selectOne(new LambdaQueryWrapperX<MuseAgentDO>()
|
||||
.eq(MuseAgentDO::getAgentType, "user")
|
||||
.eq(MuseAgentDO::getOwnerUserId, ownerUserId)
|
||||
.eq(MuseAgentDO::getSourceMarketAssetId, sourceMarketAssetId)
|
||||
.eq(MuseAgentDO::getStatus, "active")
|
||||
.eq(MuseAgentDO::getDeleted, false)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定 Agent 父行,串行化同一 Agent 下的版本号递增。
|
||||
*/
|
||||
|
||||
@ -6,6 +6,7 @@ import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentSlotBindingDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -47,4 +48,20 @@ public interface MuseAgentSlotBindingMapper extends BaseMapperX<MuseAgentSlotBin
|
||||
@Param("workId") Long workId,
|
||||
@Param("slotKey") String slotKey);
|
||||
|
||||
/**
|
||||
* Agent 归档时同步归档其仍处于 active 的槽位绑定,避免运行时继续解析到不可用 Agent。
|
||||
* <p>不能批量写同一个 command_id:该表有 tenant_id + command_id 唯一索引,批量解绑只记录业务审计。</p>
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE muse_agent_slot_binding
|
||||
SET status = 'archived',
|
||||
revision = revision + 1,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE tenant_id = #{tenantId}
|
||||
AND agent_id = #{agentId}
|
||||
AND status = 'active'
|
||||
AND deleted = FALSE
|
||||
""")
|
||||
int archiveActiveByAgentId(@Param("tenantId") Long tenantId, @Param("agentId") Long agentId);
|
||||
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ package cn.iocoder.muse.module.ai.dal.mysql.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentVersionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@ -25,4 +27,10 @@ public interface MuseAgentVersionMapper extends BaseMapperX<MuseAgentVersionDO>
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
default PageResult<MuseAgentVersionDO> selectPageByAgentId(PageParam pageParam, Long agentId) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<MuseAgentVersionDO>()
|
||||
.eq(MuseAgentVersionDO::getAgentId, agentId)
|
||||
.orderByDesc(MuseAgentVersionDO::getId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -13,13 +13,18 @@ import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AgentVersionCreateReqV
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.AgentVersionCreateRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.admin.muse.vo.MuseAiPageResult;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionLifecycleReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentVersionSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentArchiveReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentCreateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentUpdateReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.UserAgentVersionCreateReqVO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentVersionDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiCommandDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseToolGrantDO;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotBindingMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentVersionMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseToolGrantMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@ -59,6 +64,8 @@ class MuseAgentServiceTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private MuseAgentVersionMapper agentVersionMapper;
|
||||
@Mock
|
||||
private MuseAgentSlotBindingMapper slotBindingMapper;
|
||||
@Mock
|
||||
private MuseToolGrantMapper toolGrantMapper;
|
||||
@Mock
|
||||
private MuseAiCommandService commandService;
|
||||
@ -413,6 +420,11 @@ class MuseAgentServiceTest extends BaseMockitoUnitTest {
|
||||
inserted.setId(201L);
|
||||
return 1;
|
||||
}).when(agentMapper).insert(any(MuseAgentDO.class));
|
||||
doAnswer(invocation -> {
|
||||
MuseAgentVersionDO inserted = invocation.getArgument(0);
|
||||
inserted.setId(301L);
|
||||
return 1;
|
||||
}).when(agentVersionMapper).insert(any(MuseAgentVersionDO.class));
|
||||
|
||||
AdminAgentCreateRespVO result = agentService.createUserAgent(1001L, request);
|
||||
|
||||
@ -423,9 +435,138 @@ class MuseAgentServiceTest extends BaseMockitoUnitTest {
|
||||
"user".equals(inserted.getAgentType())
|
||||
&& Long.valueOf(1001L).equals(inserted.getOwnerUserId())
|
||||
&& inserted.getAgentKey().startsWith("user:1001:")));
|
||||
verify(agentVersionMapper).insert(argThat((MuseAgentVersionDO inserted) ->
|
||||
Long.valueOf(201L).equals(inserted.getAgentId())
|
||||
&& "1".equals(inserted.getVersion())
|
||||
&& inserted.getConfig().contains("\"promptTemplate\":\"你是助手\"")
|
||||
&& inserted.getConfig().contains("\"role\":\"writer\"")
|
||||
&& inserted.getCommandId().startsWith("auto:createUserAgent:")));
|
||||
verify(agentMapper).updateById(argThat((MuseAgentDO updated) ->
|
||||
Long.valueOf(201L).equals(updated.getId())
|
||||
&& Long.valueOf(301L).equals(updated.getCurrentVersionId())));
|
||||
assertEquals(201L, result.getAgentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_updateUserAgentCreateNextVersionAndPreserveCurrentConfig() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
UserAgentUpdateReqVO request = new UserAgentUpdateReqVO();
|
||||
request.setName("改名后的助手");
|
||||
request.setChangeNote("只改名称");
|
||||
request.setCommandId("cmd-update-agent");
|
||||
MuseAgentDO agent = agent(11L, "user:1001:mine", "我的助手", "user", 1001L, "active", 20L);
|
||||
agent.setSlotBindings("{\"model\":\"default\"}");
|
||||
MuseAgentVersionDO current = agentVersion(20L, 11L, "2", "active");
|
||||
current.setConfig("{\"name\":\"我的助手\",\"description\":\"说明\",\"promptTemplate\":\"保留提示词\",\"slotBindings\":{\"model\":\"default\"}}");
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-update");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(agentMapper.selectById(11L)).thenReturn(agent);
|
||||
when(agentMapper.selectByIdForUpdate(100L, 11L)).thenReturn(agent);
|
||||
when(agentVersionMapper.selectById(20L)).thenReturn(current);
|
||||
when(agentVersionMapper.selectLatestByAgentId(11L)).thenReturn(current);
|
||||
doAnswer(invocation -> {
|
||||
MuseAgentVersionDO inserted = invocation.getArgument(0);
|
||||
inserted.setId(21L);
|
||||
return 1;
|
||||
}).when(agentVersionMapper).insert(any(MuseAgentVersionDO.class));
|
||||
|
||||
AgentSummaryRespVO result = agentService.updateUserAgent(1001L, 11L, request);
|
||||
|
||||
assertEquals("改名后的助手", result.getName());
|
||||
assertEquals(3, result.getActiveVersion());
|
||||
verify(agentVersionMapper).insert(argThat((MuseAgentVersionDO inserted) ->
|
||||
"3".equals(inserted.getVersion())
|
||||
&& inserted.getConfig().contains("\"name\":\"改名后的助手\"")
|
||||
&& inserted.getConfig().contains("\"promptTemplate\":\"保留提示词\"")
|
||||
&& inserted.getConfig().contains("\"model\":\"default\"")));
|
||||
verify(agentMapper).updateById(argThat((MuseAgentDO updated) ->
|
||||
Long.valueOf(11L).equals(updated.getId())
|
||||
&& "改名后的助手".equals(updated.getName())
|
||||
&& Long.valueOf(21L).equals(updated.getCurrentVersionId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_archiveUserAgentAndArchiveActiveSlotBindings() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
UserAgentArchiveReqVO request = new UserAgentArchiveReqVO();
|
||||
request.setCommandId("cmd-archive-agent");
|
||||
request.setReason("不再使用");
|
||||
MuseAgentDO agent = agent(11L, "user:1001:mine", "我的助手", "user", 1001L, "active", 20L);
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-archive");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(agentMapper.selectById(11L)).thenReturn(agent);
|
||||
when(agentMapper.selectByIdForUpdate(100L, 11L)).thenReturn(agent);
|
||||
when(agentVersionMapper.selectById(20L)).thenReturn(agentVersion(20L, 11L, "2", "active"));
|
||||
when(slotBindingMapper.archiveActiveByAgentId(100L, 11L)).thenReturn(2);
|
||||
|
||||
AgentSummaryRespVO result = agentService.archiveUserAgent(1001L, 11L, request);
|
||||
|
||||
assertEquals("archived", result.getStatus());
|
||||
verify(agentMapper).updateById(argThat((MuseAgentDO updated) ->
|
||||
Long.valueOf(11L).equals(updated.getId()) && "archived".equals(updated.getStatus())));
|
||||
verify(slotBindingMapper).archiveActiveByAgentId(100L, 11L);
|
||||
verify(auditService).record(argThat(req -> req.getRequestSummary().contains("\"archivedBindings\":2")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_listUserAgentVersionsWithActiveMarker() {
|
||||
MuseAgentDO agent = agent(11L, "user:1001:mine", "我的助手", "user", 1001L, "active", 21L);
|
||||
MuseAgentVersionDO oldVersion = agentVersion(20L, 11L, "1", "archived");
|
||||
oldVersion.setChangeNote("create");
|
||||
MuseAgentVersionDO activeVersion = agentVersion(21L, 11L, "2", "active");
|
||||
activeVersion.setChangeNote("update");
|
||||
when(agentMapper.selectById(11L)).thenReturn(agent);
|
||||
when(agentVersionMapper.selectPageByAgentId(any(PageParam.class), eq(11L)))
|
||||
.thenReturn(new PageResult<>(List.of(activeVersion, oldVersion), 2L));
|
||||
|
||||
MuseAiPageResult<AgentVersionSummaryRespVO> result = agentService.listUserAgentVersions(1001L, 11L, 1, 20);
|
||||
|
||||
assertEquals(2L, result.getTotal());
|
||||
assertEquals(2, result.getList().getFirst().getVersion());
|
||||
assertEquals(Boolean.TRUE, result.getList().getFirst().getActive());
|
||||
assertEquals(Boolean.FALSE, result.getList().get(1).getActive());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_activateUserAgentVersion() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentVersionLifecycleReqVO request = lifecycleRequest("cmd-activate-version");
|
||||
MuseAgentDO agent = agent(11L, "user:1001:mine", "我的助手", "user", 1001L, "active", 20L);
|
||||
MuseAgentVersionDO target = agentVersion(21L, 11L, "2", "archived");
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-activate");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(agentMapper.selectById(11L)).thenReturn(agent);
|
||||
when(agentMapper.selectByIdForUpdate(100L, 11L)).thenReturn(agent);
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(11L, "2")).thenReturn(target);
|
||||
|
||||
AgentVersionCreateRespVO result = agentService.activateUserAgentVersion(1001L, 11L, 2, request);
|
||||
|
||||
assertEquals(2, result.getVersion());
|
||||
verify(agentVersionMapper).updateById(argThat((MuseAgentVersionDO updated) ->
|
||||
Long.valueOf(21L).equals(updated.getId()) && "active".equals(updated.getStatus())));
|
||||
verify(agentMapper).updateById(argThat((MuseAgentDO updated) ->
|
||||
Long.valueOf(21L).equals(updated.getCurrentVersionId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_archiveNonCurrentUserAgentVersion() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentVersionLifecycleReqVO request = lifecycleRequest("cmd-archive-version");
|
||||
MuseAgentDO agent = agent(11L, "user:1001:mine", "我的助手", "user", 1001L, "active", 21L);
|
||||
MuseAgentVersionDO target = agentVersion(20L, 11L, "1", "active");
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-archive-version");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(agentMapper.selectById(11L)).thenReturn(agent);
|
||||
when(agentMapper.selectByIdForUpdate(100L, 11L)).thenReturn(agent);
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(11L, "1")).thenReturn(target);
|
||||
|
||||
AgentVersionCreateRespVO result = agentService.archiveUserAgentVersion(1001L, 11L, 1, request);
|
||||
|
||||
assertEquals(1, result.getVersion());
|
||||
verify(agentVersionMapper).updateById(argThat((MuseAgentVersionDO updated) ->
|
||||
Long.valueOf(20L).equals(updated.getId()) && "archived".equals(updated.getStatus())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_throwMissingAgentWhenVersionTargetNotFound() {
|
||||
when(agentMapper.selectById(404L)).thenReturn(null);
|
||||
@ -479,6 +620,13 @@ class MuseAgentServiceTest extends BaseMockitoUnitTest {
|
||||
return request;
|
||||
}
|
||||
|
||||
private static AgentVersionLifecycleReqVO lifecycleRequest(String commandId) {
|
||||
AgentVersionLifecycleReqVO request = new AgentVersionLifecycleReqVO();
|
||||
request.setCommandId(commandId);
|
||||
request.setReason("测试生命周期动作");
|
||||
return request;
|
||||
}
|
||||
|
||||
private static MuseAgentDO agent(Long id, String key, String name, String type, Long ownerUserId,
|
||||
String status, Long currentVersionId) {
|
||||
MuseAgentDO agent = new MuseAgentDO();
|
||||
|
||||
@ -10,6 +10,7 @@ import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotBindRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckReqVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotPrecheckRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotSummaryRespVO;
|
||||
import cn.iocoder.muse.module.ai.controller.app.muse.vo.AgentSlotUnbindReqVO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentSlotBindingDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAgentSlotPrecheckDO;
|
||||
@ -19,6 +20,8 @@ import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotBindingMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotPrecheckMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentVersionMapper;
|
||||
import cn.iocoder.muse.module.market.api.asset.MarketAssetSourceApi;
|
||||
import cn.iocoder.muse.module.market.api.asset.dto.MarketAssetSourceRespDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.MarketHandoffTokenApi;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyRespDTO;
|
||||
@ -50,6 +53,7 @@ import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
|
||||
/**
|
||||
* Agent Slot 应用服务测试。
|
||||
@ -74,6 +78,8 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
private MuseContentWorkOwnerFacade workOwnerFacade;
|
||||
@Mock
|
||||
private MarketHandoffTokenApi marketHandoffTokenApi;
|
||||
@Mock
|
||||
private MarketAssetSourceApi marketAssetSourceApi;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
@ -446,28 +452,54 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_createFirstMarketBindingWithMarketBindingSource() {
|
||||
void should_createFirstMarketBindingWithMaterializedInstalledAgent() {
|
||||
// 首次绑定 + market handoff 来源(precheck.source_owner=market):insert 新行的 bindingSource 标 market,
|
||||
// 与 source_owner 同口径供后续溯源;bind 不重复核验 token(已在 precheck 核销)。
|
||||
// 与 source_owner 同口径供后续溯源;bind 不重复核验 token(已在 precheck 核销),槽位必须指向安装者本地 user agent。
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotBindReqVO request = bindRequest("cmd-bind-mkt-first", "precheck-mkt", null);
|
||||
MuseAgentSlotPrecheckDO precheck = precheck("precheck-mkt", "pending", LocalDateTime.now().plusMinutes(5));
|
||||
precheck.setExpectedSlotRevision(null);
|
||||
precheck.setSourceOwner("market");
|
||||
precheck.setResultSummary("{\"marketAssetId\":8001,\"assetName\":\"市场助手\"}");
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-bind");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(precheckMapper.selectPendingForUpdate(100L, "precheck-mkt")).thenReturn(precheck);
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "market", null, "active"));
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "user", 9001L, "active"));
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "1"))
|
||||
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
|
||||
.thenReturn(agentVersion(901L, 1001L, "1", "active", "{\"model\":\"muse\"}"));
|
||||
when(agentMapper.selectInstalledMarketAgent(10001L, 8001L)).thenReturn(null);
|
||||
doAnswer(invocation -> {
|
||||
MuseAgentDO created = invocation.getArgument(0);
|
||||
created.setId(7001L);
|
||||
return 1;
|
||||
}).when(agentMapper).insert(any(MuseAgentDO.class));
|
||||
doAnswer(invocation -> {
|
||||
MuseAgentVersionDO created = invocation.getArgument(0);
|
||||
created.setId(9701L);
|
||||
return 1;
|
||||
}).when(agentVersionMapper).insert(any(MuseAgentVersionDO.class));
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlotForUpdate(100L, 9001L, "writer")).thenReturn(null);
|
||||
|
||||
AgentSlotBindRespVO result = slotService.bindAgentSlot(10001L, 9001L, "writer", request);
|
||||
|
||||
assertEquals(1, result.getSlotRevision());
|
||||
verify(marketHandoffTokenApi, never()).verify(any()); // bind 不重复核验
|
||||
verify(agentMapper).insert(argThat((MuseAgentDO created) ->
|
||||
"user".equals(created.getAgentType())
|
||||
&& Long.valueOf(10001L).equals(created.getOwnerUserId())
|
||||
&& Long.valueOf(8001L).equals(created.getSourceMarketAssetId())
|
||||
&& "market:8001:u:10001".equals(created.getAgentKey())
|
||||
&& "市场助手".equals(created.getName())));
|
||||
verify(agentVersionMapper).insert(argThat((MuseAgentVersionDO createdVersion) ->
|
||||
"1".equals(createdVersion.getVersion())
|
||||
&& "active".equals(createdVersion.getStatus())
|
||||
&& "{\"model\":\"muse\"}".equals(createdVersion.getConfig())
|
||||
&& "cmd-bind-mkt-first:market-agent-version".equals(createdVersion.getCommandId())));
|
||||
verify(slotBindingMapper).insert(argThat((MuseAgentSlotBindingDO created) ->
|
||||
"market".equals(created.getBindingSource()) && Integer.valueOf(1).equals(created.getRevision())));
|
||||
"market".equals(created.getBindingSource())
|
||||
&& Long.valueOf(7001L).equals(created.getAgentId())
|
||||
&& "1".equals(created.getAgentVersion())
|
||||
&& Integer.valueOf(1).equals(created.getRevision())));
|
||||
verify(slotBindingMapper, never()).updateById(any(MuseAgentSlotBindingDO.class));
|
||||
}
|
||||
|
||||
@ -495,17 +527,98 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
verify(agentVersionMapper, never()).selectByAgentIdAndVersion(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_unbindActiveSlotByArchivingBinding() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotUnbindReqVO request = unbindRequest("cmd-unbind-1", 3);
|
||||
MuseAgentSlotBindingDO binding = binding(9001L, "writer", 1001L, "1", 3, "active");
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-unbind");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlotForUpdate(100L, 9001L, "writer")).thenReturn(binding);
|
||||
|
||||
AgentSlotBindRespVO result = slotService.unbindAgentSlot(10001L, 9001L, "writer", request);
|
||||
|
||||
assertEquals(4, result.getSlotRevision());
|
||||
assertEquals("unbound", result.getSourceStatus());
|
||||
verify(workOwnerFacade).requireWorkOwner(9001L, 10001L);
|
||||
verify(slotBindingMapper).updateById(argThat((MuseAgentSlotBindingDO updated) ->
|
||||
Long.valueOf(701L).equals(updated.getId())
|
||||
&& "archived".equals(updated.getStatus())
|
||||
&& Integer.valueOf(4).equals(updated.getRevision())
|
||||
&& "cmd-unbind-1".equals(updated.getCommandId())));
|
||||
verify(commandService).recordSucceeded(any(), argThat(snapshot ->
|
||||
snapshot.contains("\"slotRevision\":4") && snapshot.contains("\"sourceStatus\":\"unbound\"")));
|
||||
verify(auditService).record(argThat(req -> "unbindAgentSlot".equals(req.getOperationId())
|
||||
&& "agentSlot".equals(req.getTargetType())
|
||||
&& Long.valueOf(9001L).equals(req.getTargetId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_replayUnbindCommandWithoutOwnerOrSlotChecks() {
|
||||
AgentSlotUnbindReqVO request = unbindRequest("cmd-unbind-1", 3);
|
||||
AgentSlotBindRespVO replay = new AgentSlotBindRespVO();
|
||||
replay.setSlotRevision(4);
|
||||
replay.setSourceStatus("unbound");
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-unbind");
|
||||
when(commandService.reserveCommand(any())).thenReturn(command(JsonUtils.toJsonString(replay)));
|
||||
|
||||
AgentSlotBindRespVO result = slotService.unbindAgentSlot(10001L, 9001L, "writer", request);
|
||||
|
||||
assertEquals(4, result.getSlotRevision());
|
||||
assertEquals("unbound", result.getSourceStatus());
|
||||
verify(workOwnerFacade, never()).requireWorkOwner(any(), any());
|
||||
verify(slotBindingMapper, never()).selectActiveByWorkAndSlotForUpdate(any(), any(), any());
|
||||
verify(slotBindingMapper, never()).updateById(any(MuseAgentSlotBindingDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectUnbindWhenSlotRevisionConflict() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotUnbindReqVO request = unbindRequest("cmd-unbind-1", 2);
|
||||
MuseAgentSlotBindingDO binding = binding(9001L, "writer", 1001L, "1", 3, "active");
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-unbind");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlotForUpdate(100L, 9001L, "writer")).thenReturn(binding);
|
||||
|
||||
MuseAgentSlotConflictException exception = assertThrows(MuseAgentSlotConflictException.class,
|
||||
() -> slotService.unbindAgentSlot(10001L, 9001L, "writer", request));
|
||||
|
||||
assertEquals(AI_AGENT_SLOT_REVISION_CONFLICT.getCode(), exception.getServiceException().getCode());
|
||||
verify(slotBindingMapper, never()).updateById(any(MuseAgentSlotBindingDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectUnbindWhenProtectedSlot() {
|
||||
AgentSlotUnbindReqVO request = unbindRequest("cmd-unbind-1", 1);
|
||||
when(commandService.buildRequestHash(request)).thenReturn("hash-unbind");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.unbindAgentSlot(10001L, 9001L, "protected:core", request));
|
||||
|
||||
assertEquals(AI_AGENT_SLOT_PROTECTED.getCode(), exception.getCode());
|
||||
verify(slotBindingMapper, never()).selectActiveByWorkAndSlotForUpdate(any(), any(), any());
|
||||
verify(slotBindingMapper, never()).updateById(any(MuseAgentSlotBindingDO.class));
|
||||
}
|
||||
|
||||
// ============ P2 跨空间 handoff(market→agent)放宽红线单测 ============
|
||||
|
||||
@Test
|
||||
void should_acceptMarketAgentSourceWhenHandoffTokenVerified() {
|
||||
// 正路:market 来源 + token 经 Market verify 通过 → 放宽 agentType(market 类型本会被拒)+ consume token + 落 source_owner=market
|
||||
// 正路:market 来源 + token 经 Market verify 通过 → 由服务端按 assetId 解析发布者 agent + consume token + 落 source_owner=market
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotPrecheckReqVO request = marketPrecheckRequest("cmd-mkt-1", 3, "handoff_ok");
|
||||
request.setSourceAgentId(null);
|
||||
request.setSourceAgentVersion(null);
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer"))
|
||||
.thenReturn(binding(9001L, "writer", 1001L, "1", 3, "active"));
|
||||
// market 类型 agent:非 system/非 user → 原 agentType 校验必拒,只有放宽才接纳
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "market", null, "active"));
|
||||
MuseAgentDO publisherAgent = agent(1001L, "市场智能体", "user", 9001L, "active");
|
||||
publisherAgent.setCurrentVersionId(901L);
|
||||
when(marketAssetSourceApi.getAssetSource(8001L))
|
||||
.thenReturn(new MarketAssetSourceRespDTO(true, "agent", 1001L, 9001L,
|
||||
"市场助手资产", null, null));
|
||||
when(agentMapper.selectById(1001L)).thenReturn(publisherAgent);
|
||||
when(agentVersionMapper.selectById(901L)).thenReturn(agentVersion(901L, 1001L, "1", "active"));
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "1"))
|
||||
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-mkt");
|
||||
@ -521,6 +634,9 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
verify(marketHandoffTokenApi).consume(any());
|
||||
verify(precheckMapper).insert(argThat((MuseAgentSlotPrecheckDO p) ->
|
||||
"market".equals(p.getSourceOwner()) && p.getHandoffHash() != null
|
||||
&& Long.valueOf(1001L).equals(p.getSourceAgentId())
|
||||
&& Integer.valueOf(1).equals(p.getSourceAgentVersion())
|
||||
&& p.getResultSummary().contains("\"marketAssetId\":8001")
|
||||
&& p.getPrecheckId().equals(result.getAgentSlotPrecheckId())));
|
||||
}
|
||||
|
||||
@ -584,17 +700,24 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Test
|
||||
void should_acceptMarketAgentAtBindViaPrecheckSourceOwner() {
|
||||
// bind 阶段:precheck.source_owner=market → 放宽接纳 market 类型 agent(token 已在 precheck 核销,bind 不重复 verify、凭 precheckId 信任)
|
||||
// bind 阶段:precheck.source_owner=market → 复用已物化本地 agent(token 已在 precheck 核销,bind 不重复 verify、凭 precheckId 信任)
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotBindReqVO request = bindRequest("cmd-bind-mkt", "precheck-mkt", 3);
|
||||
request.setSourceAgentId(null);
|
||||
request.setSourceAgentVersion(null);
|
||||
MuseAgentSlotPrecheckDO precheck = precheck("precheck-mkt", "pending", LocalDateTime.now().plusMinutes(5));
|
||||
precheck.setSourceOwner("market");
|
||||
precheck.setResultSummary("{\"marketAssetId\":8001,\"assetName\":\"市场助手\"}");
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-bind");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(precheckMapper.selectPendingForUpdate(100L, "precheck-mkt")).thenReturn(precheck);
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "market", null, "active"));
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "user", 9001L, "active"));
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "1"))
|
||||
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
|
||||
when(agentMapper.selectInstalledMarketAgent(10001L, 8001L))
|
||||
.thenReturn(agent(7001L, "已安装市场助手", "user", 10001L, "active"));
|
||||
when(agentVersionMapper.selectLatestByAgentId(7001L))
|
||||
.thenReturn(agentVersion(9701L, 7001L, "1", "active"));
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlotForUpdate(100L, 9001L, "writer"))
|
||||
.thenReturn(binding(9001L, "writer", 1001L, "1", 3, "active"));
|
||||
|
||||
@ -602,7 +725,10 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
assertEquals(4, result.getSlotRevision());
|
||||
verify(marketHandoffTokenApi, never()).verify(any()); // bind 不重复核验
|
||||
verify(slotBindingMapper).updateById(any(MuseAgentSlotBindingDO.class));
|
||||
verify(slotBindingMapper).updateById(argThat((MuseAgentSlotBindingDO updated) ->
|
||||
Long.valueOf(7001L).equals(updated.getAgentId())
|
||||
&& "1".equals(updated.getAgentVersion())
|
||||
&& Integer.valueOf(4).equals(updated.getRevision())));
|
||||
}
|
||||
|
||||
private static AgentSlotPrecheckReqVO marketPrecheckRequest(String commandId, Integer expectedRevision,
|
||||
@ -650,6 +776,13 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
return request;
|
||||
}
|
||||
|
||||
private static AgentSlotUnbindReqVO unbindRequest(String commandId, Integer expectedRevision) {
|
||||
AgentSlotUnbindReqVO request = new AgentSlotUnbindReqVO();
|
||||
request.setCommandId(commandId);
|
||||
request.setExpectedSlotRevision(expectedRevision);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static MuseAgentSlotBindingDO binding(Long workId, String slotKey, Long agentId, String agentVersion,
|
||||
Integer revision, String status) {
|
||||
MuseAgentSlotBindingDO binding = new MuseAgentSlotBindingDO();
|
||||
@ -702,6 +835,13 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
return agentVersion;
|
||||
}
|
||||
|
||||
private static MuseAgentVersionDO agentVersion(Long id, Long agentId, String version, String status,
|
||||
String config) {
|
||||
MuseAgentVersionDO agentVersion = agentVersion(id, agentId, version, status);
|
||||
agentVersion.setConfig(config);
|
||||
return agentVersion;
|
||||
}
|
||||
|
||||
private static MuseAiCommandDO command(String resultSnapshot) {
|
||||
return MuseAiCommandDO.builder().resultSnapshot(resultSnapshot).build();
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package cn.iocoder.muse.module.ai.application.muse;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.AccountUsageFacade;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiGenerationDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiJobDO;
|
||||
@ -23,6 +24,7 @@ import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@ -46,6 +48,8 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
|
||||
private MuseAiEventPublishOutboxService eventPublishOutboxService;
|
||||
@Mock
|
||||
private MuseAiCandidateReviewService candidateReviewService;
|
||||
@Mock
|
||||
private AccountUsageFacade accountUsageFacade;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
@ -98,10 +102,36 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
|
||||
ArgumentCaptor<MuseAiTaskEventDO> eventCaptor = ArgumentCaptor.forClass(MuseAiTaskEventDO.class);
|
||||
verify(taskEventMapper).insert(eventCaptor.capture());
|
||||
verify(eventPublishOutboxService).createForTerminalEvent(eventCaptor.getValue());
|
||||
verify(accountUsageFacade).recordAiGenerationUsage(argThat(command ->
|
||||
Long.valueOf(1001L).equals(command.accountUserId())
|
||||
&& "cmd-task-1".equals(command.commandId())
|
||||
&& "corr-queued".equals(command.correlationId())
|
||||
&& Long.valueOf(3001L).equals(command.taskId())
|
||||
&& Long.valueOf(4001L).equals(command.jobId())
|
||||
&& Integer.valueOf(1).equals(command.promptTokens())
|
||||
&& "provider-1".equals(command.providerRequestId())));
|
||||
assertEquals("done", eventCaptor.getValue().getEventType());
|
||||
assertEquals("completed", eventCaptor.getValue().getTaskStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_releaseReservedQuota_when_runtimeFailureIsTerminal() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
MuseAiGenerationDO task = task("running");
|
||||
MuseAiJobDO job = job("running");
|
||||
when(jobMapper.selectByIdForUpdate(100L, 4001L)).thenReturn(job("running"));
|
||||
|
||||
projectionService.applyRuntimeResponse(task, job, runtimeFailure(false), command());
|
||||
|
||||
verify(accountUsageFacade).releaseAiGenerationQuota(argThat(command ->
|
||||
Long.valueOf(1001L).equals(command.accountUserId())
|
||||
&& "cmd-task-1".equals(command.commandId())
|
||||
&& "corr-queued".equals(command.correlationId())
|
||||
&& Long.valueOf(3001L).equals(command.taskId())
|
||||
&& Long.valueOf(4001L).equals(command.jobId())
|
||||
&& "AI_NEW_API_UNAVAILABLE".equals(command.failureCode())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_notCreateOutboxWhenRetryableFailureDoesNotWriteTerminalEvent() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
@ -113,6 +143,7 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
verify(taskEventMapper, never()).insert(any(MuseAiTaskEventDO.class));
|
||||
verify(eventPublishOutboxService, never()).createForTerminalEvent(any(MuseAiTaskEventDO.class));
|
||||
verify(accountUsageFacade, never()).releaseAiGenerationQuota(any());
|
||||
}
|
||||
|
||||
private static MuseAiGenerationDO task(String status) {
|
||||
|
||||
@ -4,6 +4,7 @@ import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.AccountUsageFacade;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.MuseContentWorkOwnerFacade;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient;
|
||||
import cn.iocoder.muse.module.ai.application.muse.facade.ProjectionSecurityRuntimePermissionFacade;
|
||||
@ -138,6 +139,8 @@ class MuseAiTaskServiceTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private MuseAiRuntimePolicyProvider runtimePolicyProvider;
|
||||
@Mock
|
||||
private AccountUsageFacade accountUsageFacade;
|
||||
@Mock
|
||||
private cn.iocoder.muse.module.ai.application.muse.facade.KnowledgeRetrievalFacade knowledgeRetrievalFacade;
|
||||
|
||||
@BeforeEach
|
||||
@ -164,11 +167,13 @@ class MuseAiTaskServiceTest extends BaseMockitoUnitTest {
|
||||
// 「审」字段补齐(2026-06-14)后投影新增依赖:显式接桩 + review 返回通过审,避免投影路径 NPE(预存红整改)。
|
||||
ReflectionTestUtils.setField(runtimeProjectionService, "eventPublishOutboxService", eventPublishOutboxService);
|
||||
ReflectionTestUtils.setField(runtimeProjectionService, "candidateReviewService", candidateReviewService);
|
||||
ReflectionTestUtils.setField(runtimeProjectionService, "accountUsageFacade", accountUsageFacade);
|
||||
lenient().when(candidateReviewService.review(any(), any(), any()))
|
||||
.thenReturn(new MuseAiCandidateReviewService.CandidateReview(true, "oc-x", "sc-x", List.of(), Map.of()));
|
||||
ReflectionTestUtils.setField(taskService, "runtimeJobExecutor", runtimeJobExecutor);
|
||||
ReflectionTestUtils.setField(taskService, "transactionManager", new ActiveTransactionManager());
|
||||
ReflectionTestUtils.setField(taskService, "runtimePayloadStore", runtimePayloadStore);
|
||||
ReflectionTestUtils.setField(taskService, "accountUsageFacade", accountUsageFacade);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@ -243,6 +248,14 @@ class MuseAiTaskServiceTest extends BaseMockitoUnitTest {
|
||||
assertTrue(result.getPollUrl().endsWith("/3001"));
|
||||
assertEquals("src-approved", result.getSourceSnapshotId());
|
||||
assertEquals("rpe-approved", result.getRuntimePermissionEnvelopeId());
|
||||
verify(accountUsageFacade).reserveAiGenerationQuota(argThat(command ->
|
||||
Long.valueOf(1001L).equals(command.accountUserId())
|
||||
&& "cmd-task-1".equals(command.commandId())
|
||||
&& command.correlationId() != null
|
||||
&& command.correlationId().startsWith("corr-")
|
||||
&& Long.valueOf(9001L).equals(command.workId())
|
||||
&& Long.valueOf(10L).equals(command.agentId())
|
||||
&& Long.valueOf(100L).equals(command.agentVersionId())));
|
||||
verify(generationMapper).insert(argThat((MuseAiGenerationDO inserted) ->
|
||||
"queued".equals(inserted.getStatus())
|
||||
&& "queued".equals(inserted.getRuntimeStatus())
|
||||
|
||||
@ -2,10 +2,18 @@ package cn.iocoder.muse.module.ai.application.muse.facade;
|
||||
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.ai.application.muse.MuseAiAuditService;
|
||||
import cn.iocoder.muse.module.ai.application.muse.MuseAiCommandService;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiCommandDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDO;
|
||||
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDecisionDO;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiSuggestionDecisionMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiSuggestionMapper;
|
||||
import cn.iocoder.muse.module.content.application.facade.ContentAiSuggestionFacade;
|
||||
import cn.iocoder.muse.module.content.application.facade.ContentAiSuggestionFacade.SuggestionLookupResult;
|
||||
import cn.iocoder.muse.module.content.application.facade.ContentAiSuggestionFacade.SuggestionProjection;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
@ -17,6 +25,11 @@ import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
@ -32,6 +45,17 @@ class AiSuggestionMergeProjectionFacadeTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
private MuseAiSuggestionMapper suggestionMapper;
|
||||
@Mock
|
||||
private MuseAiSuggestionDecisionMapper decisionMapper;
|
||||
@Mock
|
||||
private MuseAiCommandService commandService;
|
||||
@Mock
|
||||
private MuseAiAuditService auditService;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_returnAvailableProjection_when_suggestionCarriesFullMergeContract() {
|
||||
@ -91,6 +115,78 @@ class AiSuggestionMergeProjectionFacadeTest extends BaseMockitoUnitTest {
|
||||
assertFalse(facade.getSuggestion(1001L, 101L, 301L, null).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_markSuggestionAcceptedWithDecisionArchive() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-accept");
|
||||
when(commandService.getReplayCommand(any())).thenReturn(null);
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(suggestionMapper.selectById(501L)).thenReturn(completeSuggestion());
|
||||
doAnswer(invocation -> {
|
||||
MuseAiSuggestionDecisionDO decision = invocation.getArgument(0);
|
||||
decision.setId(9001L);
|
||||
return 1;
|
||||
}).when(decisionMapper).insert(any(MuseAiSuggestionDecisionDO.class));
|
||||
|
||||
ContentAiSuggestionFacade.AcceptedDecisionResult result = facade.markSuggestionAccepted(1001L, 101L, 301L,
|
||||
new ContentAiSuggestionFacade.AcceptedSuggestionDecision(501L, "content-cmd-1", "accept",
|
||||
"accept_as_is", 7, 8, "用户接受"));
|
||||
|
||||
assertTrue(result.accepted());
|
||||
verify(decisionMapper).insert(argThat((MuseAiSuggestionDecisionDO decision) ->
|
||||
Long.valueOf(501L).equals(decision.getSuggestionId())
|
||||
&& "accepted".equals(decision.getDecision())
|
||||
&& decision.getCommandId().startsWith("content-accepted:")
|
||||
&& "hash-accept".equals(decision.getRequestHash())
|
||||
&& Long.valueOf(100L).equals(decision.getTenantId())
|
||||
&& decision.getDecisionSummary().contains("\"newBlockRevision\":8")
|
||||
&& decision.getDecisionSummary().contains("\"contentCommandId\":\"content-cmd-1\"")));
|
||||
verify(suggestionMapper).updateById(argThat((MuseAiSuggestionDO update) ->
|
||||
Long.valueOf(501L).equals(update.getId())
|
||||
&& "accepted".equals(update.getStatus())
|
||||
&& Long.valueOf(9001L).equals(update.getDecisionArchiveId())));
|
||||
verify(commandService).recordSucceeded(any(), argThat(snapshot -> snapshot.contains("\"status\":\"accepted\"")));
|
||||
verify(auditService).record(argThat(audit -> "markSuggestionAccepted".equals(audit.getOperationId())
|
||||
&& "succeeded".equals(audit.getStatus())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_replayAcceptedDecisionWithoutDuplicateWrite() {
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-accept");
|
||||
when(commandService.getReplayCommand(any())).thenReturn(MuseAiCommandDO.builder()
|
||||
.commandId("content-accepted:replay")
|
||||
.resultSnapshot("{\"status\":\"accepted\"}")
|
||||
.build());
|
||||
|
||||
ContentAiSuggestionFacade.AcceptedDecisionResult result = facade.markSuggestionAccepted(1001L, 101L, 301L,
|
||||
new ContentAiSuggestionFacade.AcceptedSuggestionDecision(501L, "content-cmd-1", "accept",
|
||||
"accept_as_is", 7, 8, "用户接受"));
|
||||
|
||||
assertTrue(result.accepted());
|
||||
verify(suggestionMapper, never()).selectById(any());
|
||||
verify(decisionMapper, never()).insert(any(MuseAiSuggestionDecisionDO.class));
|
||||
verify(suggestionMapper, never()).updateById(any(MuseAiSuggestionDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_returnConflict_when_acceptingNonPendingSuggestion() {
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-accept");
|
||||
when(commandService.getReplayCommand(any())).thenReturn(null);
|
||||
MuseAiSuggestionDO accepted = completeSuggestion();
|
||||
accepted.setStatus("accepted");
|
||||
when(suggestionMapper.selectById(501L)).thenReturn(accepted);
|
||||
|
||||
ContentAiSuggestionFacade.AcceptedDecisionResult result = facade.markSuggestionAccepted(1001L, 101L, 301L,
|
||||
new ContentAiSuggestionFacade.AcceptedSuggestionDecision(501L, "content-cmd-1", "accept",
|
||||
"accept_as_is", 7, 8, "用户接受"));
|
||||
|
||||
assertFalse(result.accepted());
|
||||
assertEquals("conflict", result.status());
|
||||
verify(commandService, never()).reserveCommand(any());
|
||||
verify(decisionMapper, never()).insert(any(MuseAiSuggestionDecisionDO.class));
|
||||
verify(suggestionMapper, never()).updateById(any(MuseAiSuggestionDO.class));
|
||||
}
|
||||
|
||||
/** 构造一个字段齐全、满足接缝契约的可合并 suggestion(diffSummary 承载“审”结果 + 结构化来源)。 */
|
||||
private MuseAiSuggestionDO completeSuggestion() {
|
||||
MuseAiSuggestionDO suggestion = new MuseAiSuggestionDO();
|
||||
|
||||
@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ -34,8 +35,13 @@ class AppMuseAgentControllerAnnotationTest {
|
||||
assertGet("listAgents");
|
||||
assertPost("createAgent");
|
||||
assertResponseStatus("createAgent", HttpStatus.CREATED);
|
||||
assertPut("updateAgent");
|
||||
assertPost("archiveAgent");
|
||||
assertPost("createAgentVersion");
|
||||
assertResponseStatus("createAgentVersion", HttpStatus.CREATED);
|
||||
assertGet("listAgentVersions");
|
||||
assertPost("activateAgentVersion");
|
||||
assertPost("archiveAgentVersion");
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -71,6 +77,12 @@ class AppMuseAgentControllerAnnotationTest {
|
||||
assertNull(method.getAnnotation(PreAuthorize.class), "App owner guard must be implemented with getLoginUserId");
|
||||
}
|
||||
|
||||
private static void assertPut(String methodName) throws NoSuchMethodException {
|
||||
Method method = findMethod(methodName);
|
||||
assertNotNull(method.getAnnotation(PutMapping.class), methodName + " must use @PutMapping");
|
||||
assertNull(method.getAnnotation(PreAuthorize.class), "App owner guard must be implemented with getLoginUserId");
|
||||
}
|
||||
|
||||
private static void assertResponseStatus(String methodName, HttpStatus expected) throws NoSuchMethodException {
|
||||
ResponseStatus responseStatus = findMethod(methodName).getAnnotation(ResponseStatus.class);
|
||||
assertNotNull(responseStatus, methodName + " must declare @ResponseStatus");
|
||||
|
||||
@ -44,6 +44,7 @@ class AppMuseAgentSlotControllerAnnotationTest {
|
||||
assertGet("listAgentSlots");
|
||||
assertPost("precheckAgentSlot");
|
||||
assertPost("bindAgentSlot");
|
||||
assertPost("unbindAgentSlot");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -7,4 +7,4 @@
|
||||
- **out-of-scope**:知识入库(归 knowledge BC)、AI 生成(归 ai BC)。
|
||||
- **现状**:只读评估 82%;后端齐全,契约高度一致。
|
||||
- **2026-06-17 验收债**:批1 已补 work/chapter/block 生命周期 11 op 真实 PG 证据;批20-28 已补 planning candidate 5 + meta-projection 3 + style-check 2 + chapter-parse-result 决策 2 + parse-job 生命周期/创建 5 + import-task 2 + export-task 4 + 管理端任务列表 2 + mergeBlockSuggestion 1,均真实 PG 绿且证据已人工批准并翻 completed。批26 `P1rContentExportTaskCompletedApprovalIT` 补 export-task 簇 4 op(getExportTask/exportWork/createExportTask/downloadExportPackage)并修正 `ContentExportServiceImpl` public `exportWork/createExportTask` 事务边界,IT 已用 command_log 0 残留断言验证预占命令回滚。批27 扩展 `P1rContentAdminReadCompletedApprovalIT` 补 adminListImportTasks/adminListExportTasks:真实 `/admin-api` + method security + 租户行拦截器,读 `muse_content_import_task`/`muse_content_export_task` 真实行,覆盖分页/筛选/workTitle 回填/跨租户不泄露/RBAC/API version/no-write,并补齐老 harness 专属 `_test` 库自动创建。批28 扩展 `P1rContentParseJobLifecycleCompletedApprovalIT` 补 createParseJob 守卫/fail-closed/事务回滚证据,并修正 `P1rContentMergeSuggestionIT` 老 harness 专属 `_test` 库自动创建后复验 mergeBlockSuggestion 真实写 Canonical 正文链路。content/account/market needs_verification 验收债均已清零;coverage JSON completed 已按人工批准翻转,且 2026-06-19 已补 `testFiles` 证据锚点门禁。
|
||||
- **关键风险 / TODO**:Content 验收债已补齐真实 PG 证据;外部 owner(AI/Meta/FileService/解析投影)未接入的路径仍按合同 fail-closed,需要配置真实外部依赖或 owner 服务后才能从 fail-closed 升级为完整成功路径。**2026-06-25:AI suggestion 采纳已从 fail-closed 升级为真成功(ADR-020 方案 A,commit a16c596)**——此前真生成候选 `authorization_snapshot_id=null` 恒被 `mergeBlockSuggestion` 拒(1041001001),根因 authz 列 BIGINT 存不下字符串 runtime envelope `rpe-local-uuid`;V30 把 `muse_content_block_source_attribution`+`muse_ai_suggestion` authz 列 BIGINT→VARCHAR(128)、放开 `ContentSourceServiceImpl` 两道数值门(删 requireNumericAuthorizationSnapshot/parseAuthorizationSnapshotId/parseLongQuietly,字符串直落、外部 owner 非数值编号不再丢)、`BlockSourceAttributionDO.authorizationSnapshotId` 改 String。活体 merge code=0+revision 自增、IT `P1rContentMergeGeneratedSuggestion` 假绿(注入数值 9001)转真绿。详见总账 2026-06-25 / ADR-020。
|
||||
- **关键风险 / TODO**:Content 验收债已补齐真实 PG 证据;外部 owner(FileService/解析投影等)未接入的路径仍按合同 fail-closed,需要配置真实外部依赖或 owner 服务后才能从 fail-closed 升级为完整成功路径。**2026-06-25:AI suggestion 采纳已从 fail-closed 升级为真成功(ADR-020 方案 A,commit a16c596)**——此前真生成候选 `authorization_snapshot_id=null` 恒被 `mergeBlockSuggestion` 拒(1041001001),根因 authz 列 BIGINT 存不下字符串 runtime envelope `rpe-local-uuid`;V30 把 `muse_content_block_source_attribution`+`muse_ai_suggestion` authz 列 BIGINT→VARCHAR(128)、放开 `ContentSourceServiceImpl` 两道数值门(删 requireNumericAuthorizationSnapshot/parseAuthorizationSnapshotId/parseLongQuietly,字符串直落、外部 owner 非数值编号不再丢)、`BlockSourceAttributionDO.authorizationSnapshotId` 改 String。活体 merge code=0+revision 自增、IT `P1rContentMergeGeneratedSuggestion` 假绿(注入数值 9001)转真绿。详见总账 2026-06-25 / ADR-020。**2026-06-27:E1 采纳归档 + 旧知识草稿失效切片已补**——`mergeBlockSuggestion` 写 Canonical/来源归因后必须让 AI owner 归档 accepted decision;AI owner 不可用则整笔回滚,避免正文已写但 suggestion 仍 pending。Content 正文变更后经 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck`,通知失败不回滚 Canonical 主写但 confirm 端 fail-closed。P1r 真 PG `P1rContentMergeSuggestionIT` 5/0F/0E/0S 已校验 suggestion `accepted`、decision archive、AI command/audit、Knowledge draft decision 与幂等 replay;`P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S 回归生成候选采纳。**2026-06-27:E1 工作台入口 + 版本历史已补**——`saveBlock`/`mergeBlockSuggestion` 写 `muse_content_block_revision_snapshot`,`GET /app-api/muse/works/{workId}/blocks/{blockId}/revisions` 只读返回 Canonical revision 快照,Studio 工作台“历史”Tab 展示;工作台知识/导入/导出/记录最小入口已接。P1r 真 PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S 校验版本历史、owner/tenant 守卫和纯查询不写;前端 `tsc`/lint/Vitest 12/12 绿;MSW-off Playwright `accept-suggestion.spec.ts` 2/0F/0E 验证真 New-API suggestionId=79 采纳后 revision 160→161、AI owner accepted decision archive 非空。**2026-06-27:E7 Meta 动态字段校验已从 fail-closed 升级为真成功**——`RealContentMetaFacade.validateDynamicFields` 读取 Meta active projection 校验 required/type/enum/range/regex/deprecated/unknown field 并返回 routeSuggestions,`P1rContentMetaProjectionClusterCompletedApprovalIT` 4/0F/0E/0S 覆盖 no-schema fail-closed 与 active projection 成功规则命中且 0 Content 写。仍缺:完整导入上传/解析确认向导、导出下载凭证/范围选择 UI 后置。
|
||||
|
||||
@ -32,6 +32,12 @@
|
||||
<artifactId>muse-module-events-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<!-- E1 旧知识草稿失效:正文变更后通过 Knowledge 对外 API 标记待确认草稿需重验,不碰 knowledge.dal/application -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>muse-module-knowledge-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<!-- R2 对象存储:消费 infra FileApi(上传 / 按 path 字节取回),用于导出包的存取与导入文件检视 -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
|
||||
@ -82,6 +82,11 @@ public interface ContentAppService {
|
||||
*/
|
||||
BlockRespVO getBlock(Long userId, Long workId, Long blockId);
|
||||
|
||||
/**
|
||||
* 查询 Block 正文版本历史。
|
||||
*/
|
||||
List<BlockRevisionRespVO> listBlockRevisions(Long userId, Long workId, Long blockId);
|
||||
|
||||
/**
|
||||
* 保存 Block 正文。
|
||||
*/
|
||||
|
||||
@ -9,9 +9,12 @@ import cn.iocoder.muse.module.content.convert.ContentConvert;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.*;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.*;
|
||||
import cn.iocoder.muse.module.content.domain.ContentRevisionGuard;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeDraftInvalidationApi;
|
||||
import cn.iocoder.muse.module.meta.api.schema.MetaSchemaBriefDTO;
|
||||
import cn.iocoder.muse.module.meta.api.schema.MetaSchemaQueryApi;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@ -28,6 +31,7 @@ import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_WO
|
||||
* Content 用户端应用服务实现。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ContentAppServiceImpl implements ContentAppService {
|
||||
|
||||
/** 默认作品状态 */
|
||||
@ -50,7 +54,11 @@ public class ContentAppServiceImpl implements ContentAppService {
|
||||
@Resource
|
||||
private ContentEventPublishOutboxService eventPublishOutboxService;
|
||||
@Resource
|
||||
private ContentBlockRevisionService blockRevisionService;
|
||||
@Resource
|
||||
private MetaSchemaQueryApi metaSchemaQueryApi;
|
||||
@Resource
|
||||
private ObjectProvider<MuseKnowledgeDraftInvalidationApi> knowledgeDraftInvalidationApiProvider;
|
||||
|
||||
@Override
|
||||
public PageResult<WorkSummaryRespVO> listWorks(Long userId, Integer pageNo, Integer pageSize, String status) {
|
||||
@ -319,6 +327,11 @@ public class ContentAppServiceImpl implements ContentAppService {
|
||||
return ContentConvert.toBlockResp(requireOwnedBlock(userId, workId, blockId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BlockRevisionRespVO> listBlockRevisions(Long userId, Long workId, Long blockId) {
|
||||
return blockRevisionService.listBlockRevisions(userId, workId, blockId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SaveBlockRespVO saveBlock(Long userId, Long workId, Long blockId, BlockSaveReqVO reqVO) {
|
||||
@ -353,6 +366,9 @@ public class ContentAppServiceImpl implements ContentAppService {
|
||||
// 只把 source attribution 的 active 终端事实写入 Content 本域 outbox;正文、授权快照和 lineage 均不外发。
|
||||
eventPublishOutboxService.createForBlockSourceAttribution(userId, workId, blockId, block.getRevision(),
|
||||
reqVO.getCommandId(), sourceAttribution);
|
||||
blockRevisionService.recordBlockRevision(userId, workId, block, reqVO.getCommandId(), "save_block",
|
||||
sourceAttribution, reqVO.getAuditReason());
|
||||
markKnowledgeDraftsNeedRecheck(userId, workId, block, reqVO.getCommandId(), "content_block_saved");
|
||||
SaveBlockRespVO respVO = new SaveBlockRespVO(block.getRevision());
|
||||
auditService.recordSucceededWithAudit(reqVO.getCommandId(), "save_block", userId,
|
||||
"app", "block", blockId, requestHash, JsonUtils.toJsonString(respVO),
|
||||
@ -360,6 +376,23 @@ public class ContentAppServiceImpl implements ContentAppService {
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private void markKnowledgeDraftsNeedRecheck(Long userId, Long workId, BlockDO block, String commandId,
|
||||
String reason) {
|
||||
MuseKnowledgeDraftInvalidationApi api = knowledgeDraftInvalidationApiProvider.getIfAvailable();
|
||||
if (api == null || block == null || block.getId() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
api.markContentBlockDraftsNeedRecheck(new MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand(
|
||||
null, userId, workId, block.getChapterId(), block.getId(), block.getRevision(), commandId, reason));
|
||||
} catch (RuntimeException exception) {
|
||||
// 旧知识草稿失效是派生保护链:通知失败不能回滚用户正文保存;confirm 端仍会用 sourceSnapshot fail-closed。
|
||||
log.warn("Content 保存后通知 Knowledge 草稿失效失败,ownerUserId={}, workId={}, chapterId={}, blockId={}, revision={}, errorType={}",
|
||||
userId, workId, block.getChapterId(), block.getId(), block.getRevision(),
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteBlock(Long userId, Long workId, Long blockId, RevisionCommandReqVO reqVO) {
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package cn.iocoder.muse.module.content.application;
|
||||
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.BlockRevisionRespVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockSourceAttributionDO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Content Block 正文版本历史服务。
|
||||
*/
|
||||
public interface ContentBlockRevisionService {
|
||||
|
||||
/**
|
||||
* 记录一个已进入 Canonical 的 Block revision。
|
||||
*/
|
||||
void recordBlockRevision(Long ownerUserId, Long workId, BlockDO block, String commandId, String commandType,
|
||||
BlockSourceAttributionDO sourceAttribution, String auditReason);
|
||||
|
||||
/**
|
||||
* 查询当前用户拥有的 Block 正文版本历史。
|
||||
*/
|
||||
List<BlockRevisionRespVO> listBlockRevisions(Long userId, Long workId, Long blockId);
|
||||
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package cn.iocoder.muse.module.content.application;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.BlockRevisionRespVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockRevisionSnapshotDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockSourceAttributionDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.BlockMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.BlockRevisionSnapshotMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_NOT_FOUND;
|
||||
|
||||
/**
|
||||
* Content Block 正文版本历史服务实现。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ContentBlockRevisionServiceImpl implements ContentBlockRevisionService {
|
||||
|
||||
@Resource
|
||||
private WorkMapper workMapper;
|
||||
@Resource
|
||||
private BlockMapper blockMapper;
|
||||
@Resource
|
||||
private BlockRevisionSnapshotMapper blockRevisionSnapshotMapper;
|
||||
|
||||
@Override
|
||||
public void recordBlockRevision(Long ownerUserId, Long workId, BlockDO block, String commandId, String commandType,
|
||||
BlockSourceAttributionDO sourceAttribution, String auditReason) {
|
||||
if (block == null || block.getId() == null || block.getRevision() == null) {
|
||||
throw new IllegalArgumentException("Block revision 快照缺少目标 Block 或 revision");
|
||||
}
|
||||
BlockRevisionSnapshotDO snapshot = BlockRevisionSnapshotDO.builder()
|
||||
.workId(workId)
|
||||
.chapterId(block.getChapterId())
|
||||
.blockId(block.getId())
|
||||
.revision(block.getRevision())
|
||||
.contentText(block.getContentText())
|
||||
.wordCount(block.getWordCount() == null ? 0 : block.getWordCount())
|
||||
.commandId(commandId)
|
||||
.commandType(commandType)
|
||||
.sourceType(sourceAttribution == null ? null : sourceAttribution.getSourceType())
|
||||
.sourceObjectId(sourceAttribution == null ? null : sourceAttribution.getSourceObjectId())
|
||||
.sourceVersion(sourceAttribution == null ? null : sourceAttribution.getSourceVersion())
|
||||
.sourceStatus(sourceAttribution == null ? null : sourceAttribution.getSourceStatus())
|
||||
.authorizationSnapshotId(sourceAttribution == null ? null : sourceAttribution.getAuthorizationSnapshotId())
|
||||
.auditReason(auditReason)
|
||||
.build();
|
||||
blockRevisionSnapshotMapper.insert(snapshot);
|
||||
log.info("Content Block revision 快照已记录,ownerUserId={}, workId={}, blockId={}, revision={}, commandType={}",
|
||||
ownerUserId, workId, block.getId(), block.getRevision(), commandType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BlockRevisionRespVO> listBlockRevisions(Long userId, Long workId, Long blockId) {
|
||||
requireOwnedBlock(userId, workId, blockId);
|
||||
return blockRevisionSnapshotMapper.selectListByBlockId(blockId).stream()
|
||||
.map(this::toRespVO)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private BlockDO requireOwnedBlock(Long userId, Long workId, Long blockId) {
|
||||
WorkDO work = workMapper.selectById(workId);
|
||||
if (work == null) {
|
||||
throw new ServiceException(CONTENT_NOT_FOUND.getCode(), "作品不存在");
|
||||
}
|
||||
if (!Objects.equals(work.getOwnerUserId(), userId)) {
|
||||
throw new ServiceException(CONTENT_FORBIDDEN.getCode(), "无权访问该作品");
|
||||
}
|
||||
BlockDO block = blockMapper.selectById(blockId);
|
||||
if (block == null || !Objects.equals(block.getWorkId(), workId)) {
|
||||
throw new ServiceException(CONTENT_NOT_FOUND.getCode(), "Block 不存在");
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
private BlockRevisionRespVO toRespVO(BlockRevisionSnapshotDO snapshot) {
|
||||
BlockRevisionRespVO respVO = new BlockRevisionRespVO();
|
||||
respVO.setBlockId(snapshot.getBlockId());
|
||||
respVO.setRevision(snapshot.getRevision());
|
||||
respVO.setContent(snapshot.getContentText());
|
||||
respVO.setWordCount(snapshot.getWordCount());
|
||||
respVO.setCommandId(snapshot.getCommandId());
|
||||
respVO.setCommandType(snapshot.getCommandType());
|
||||
respVO.setSourceType(snapshot.getSourceType());
|
||||
respVO.setSourceId(snapshot.getSourceObjectId());
|
||||
respVO.setSourceVersion(snapshot.getSourceVersion());
|
||||
respVO.setSourceStatus(snapshot.getSourceStatus());
|
||||
respVO.setAuthorizationSnapshotId(snapshot.getAuthorizationSnapshotId());
|
||||
respVO.setAuditReason(snapshot.getAuditReason());
|
||||
respVO.setCreatedAt(snapshot.getCreateTime());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
}
|
||||
@ -15,9 +15,12 @@ import cn.iocoder.muse.module.content.dal.mysql.BlockMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.BlockSourceAttributionMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import cn.iocoder.muse.module.content.domain.ContentRevisionGuard;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeDraftInvalidationApi;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@ -32,6 +35,7 @@ import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.*;
|
||||
* Content 来源归因与 AI suggestion 合并服务实现。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ContentSourceServiceImpl implements ContentSourceService {
|
||||
|
||||
/** OpenAPI operationId:合并 AI suggestion 到 Canonical Block。 */
|
||||
@ -63,6 +67,10 @@ public class ContentSourceServiceImpl implements ContentSourceService {
|
||||
private ContentAiSuggestionFacade suggestionFacade;
|
||||
@Resource
|
||||
private ContentEventPublishOutboxService eventPublishOutboxService;
|
||||
@Resource
|
||||
private ContentBlockRevisionService blockRevisionService;
|
||||
@Resource
|
||||
private ObjectProvider<MuseKnowledgeDraftInvalidationApi> knowledgeDraftInvalidationApiProvider;
|
||||
|
||||
@Override
|
||||
public SourceAttributionRespVO getBlockSourceAttribution(Long userId, Long workId, Long blockId) {
|
||||
@ -101,11 +109,19 @@ public class ContentSourceServiceImpl implements ContentSourceService {
|
||||
BlockSourceAttributionDO sourceAttribution = buildSuggestionSourceAttribution(workId, blockId, nextRevision,
|
||||
reqVO, suggestion);
|
||||
sourceAttributionMapper.insert(sourceAttribution);
|
||||
requireAcceptedDecisionArchived(userId, workId, blockId, reqVO, suggestion, nextRevision);
|
||||
// 与 saveBlock(ContentAppServiceImpl)一致:merge 成功写入 active 来源归因后,在同一事务内写本域 outbox,
|
||||
// 由 ContentEventPublishWorker 异步投递 source_status_change 到统一 Events,驱动前端 SSE 回流。
|
||||
// 此前缺这一步 → 合并落库成功但用户收不到 SSE 通知(对抗复盘"缺口 B")。
|
||||
eventPublishOutboxService.createForBlockSourceAttribution(userId, workId, blockId, nextRevision,
|
||||
reqVO.getCommandId(), sourceAttribution);
|
||||
block.setContentText(mergedContent);
|
||||
block.setWordCount(countWords(mergedContent));
|
||||
block.setRevision(nextRevision);
|
||||
blockRevisionService.recordBlockRevision(userId, workId, block, reqVO.getCommandId(),
|
||||
OP_MERGE_BLOCK_SUGGESTION, sourceAttribution, reqVO.getAuditReason());
|
||||
markKnowledgeDraftsNeedRecheck(userId, workId, block.getChapterId(), blockId, nextRevision,
|
||||
reqVO.getCommandId(), "content_suggestion_merged");
|
||||
|
||||
MergeBlockSuggestionRespVO respVO = buildMergeSuggestionResp(blockId, reqVO, nextRevision,
|
||||
sourceAttribution, sourceAttribution.getCreateTime());
|
||||
@ -115,6 +131,37 @@ public class ContentSourceServiceImpl implements ContentSourceService {
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private void markKnowledgeDraftsNeedRecheck(Long userId, Long workId, Long chapterId, Long blockId,
|
||||
Integer blockRevision, String commandId, String reason) {
|
||||
MuseKnowledgeDraftInvalidationApi api = knowledgeDraftInvalidationApiProvider.getIfAvailable();
|
||||
if (api == null || blockId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
api.markContentBlockDraftsNeedRecheck(new MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand(
|
||||
null, userId, workId, chapterId, blockId, blockRevision, commandId, reason));
|
||||
} catch (RuntimeException exception) {
|
||||
// Knowledge 草稿是正文派生视图;通知失败不能回滚已完成的 Canonical 采纳,但 confirm 端仍会 fail-closed。
|
||||
log.warn("Content 采纳后通知 Knowledge 草稿失效失败,ownerUserId={}, workId={}, chapterId={}, blockId={}, revision={}, errorType={}",
|
||||
userId, workId, chapterId, blockId, blockRevision, exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAcceptedDecisionArchived(Long userId, Long workId, Long blockId,
|
||||
MergeBlockSuggestionReqVO reqVO,
|
||||
ContentAiSuggestionFacade.SuggestionProjection suggestion,
|
||||
Integer nextRevision) {
|
||||
ContentAiSuggestionFacade.AcceptedDecisionResult result = suggestionFacade.markSuggestionAccepted(userId,
|
||||
workId, blockId, new ContentAiSuggestionFacade.AcceptedSuggestionDecision(reqVO.getSuggestionId(),
|
||||
reqVO.getCommandId(), reqVO.getDecisionType(), reqVO.getMergeMode(), suggestion.revision(),
|
||||
nextRevision, reqVO.getAuditReason()));
|
||||
if (result == null || !result.accepted()) {
|
||||
// AI suggestion 的 accepted/decision archive 归 AI owner;若 owner 归档失败,Content 合并必须回滚,
|
||||
// 否则会出现 Canonical 已写入但候选仍 pending、可被再次处置的半闭环。
|
||||
throw new ServiceException(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode(), "AI suggestion 决策归档不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private ContentAiSuggestionFacade.SuggestionProjection requireAvailableSuggestion(Long userId, Long workId,
|
||||
Long blockId, Long suggestionId) {
|
||||
ContentAiSuggestionFacade.SuggestionLookupResult lookupResult =
|
||||
|
||||
@ -25,6 +25,23 @@ public interface ContentAiSuggestionFacade {
|
||||
return SuggestionLookupResult.unavailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知 AI owner:指定 suggestion 已被 Content 合并入 Canonical。
|
||||
*
|
||||
* <p>Content 只拥有 Canonical Block 写入权;AI suggestion 的 status 与 decision archive 仍归 AI owner。
|
||||
* 默认实现显式 unavailable,避免在 AI owner 未接入时把正文写入成功伪装成完整闭环。</p>
|
||||
*
|
||||
* @param userId 当前登录用户 ID
|
||||
* @param workId 作品 ID
|
||||
* @param blockId 目标 Block ID
|
||||
* @param decision 已采纳决策摘要;不得包含完整正文
|
||||
* @return AI owner 的归档结果
|
||||
*/
|
||||
default AcceptedDecisionResult markSuggestionAccepted(Long userId, Long workId, Long blockId,
|
||||
AcceptedSuggestionDecision decision) {
|
||||
return AcceptedDecisionResult.unavailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* suggestion 查询结果。
|
||||
*
|
||||
@ -63,6 +80,44 @@ public interface ContentAiSuggestionFacade {
|
||||
String staticCheckResultId, String licenseRestrictionSnapshot) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Content 合并成功后提交给 AI owner 的最小决策摘要。
|
||||
*
|
||||
* @param suggestionId suggestion ID
|
||||
* @param contentCommandId Content mergeBlockSuggestion 的幂等键
|
||||
* @param decisionType 用户决策类型
|
||||
* @param mergeMode 写入方式
|
||||
* @param suggestionRevision 合并前校验过的 suggestion/source revision
|
||||
* @param newBlockRevision Content Canonical Block 写入后的 revision
|
||||
* @param auditReason 用户审计原因;不得包含完整正文
|
||||
*/
|
||||
record AcceptedSuggestionDecision(Long suggestionId, String contentCommandId, String decisionType,
|
||||
String mergeMode, Integer suggestionRevision, Integer newBlockRevision,
|
||||
String auditReason) {
|
||||
}
|
||||
|
||||
/**
|
||||
* AI owner 归档结果。
|
||||
*
|
||||
* @param accepted 是否已完成 accepted 决策归档
|
||||
* @param status 失败或成功状态;用于 Content 侧映射错误语义
|
||||
*/
|
||||
record AcceptedDecisionResult(boolean accepted, String status) {
|
||||
|
||||
public static AcceptedDecisionResult acceptedResult() {
|
||||
return new AcceptedDecisionResult(true, "accepted");
|
||||
}
|
||||
|
||||
public static AcceptedDecisionResult unavailable() {
|
||||
return new AcceptedDecisionResult(false, "unavailable");
|
||||
}
|
||||
|
||||
public static AcceptedDecisionResult conflict() {
|
||||
return new AcceptedDecisionResult(false, "conflict");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
package cn.iocoder.muse.module.content.application.facade;
|
||||
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldInputVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldRouteSuggestionRespVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationErrorRespVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationFieldResultRespVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationReqVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationRespVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.MetaProjectionDetailRespVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.MetaProjectionFieldRespVO;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.MetaProjectionSummaryRespVO;
|
||||
@ -14,12 +20,20 @@ import cn.iocoder.muse.module.meta.api.projection.MetaSchemaProjectionDTO;
|
||||
import cn.iocoder.muse.module.meta.api.schema.MetaSchemaBriefDTO;
|
||||
import cn.iocoder.muse.module.meta.api.schema.MetaSchemaQueryApi;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Content 侧 Meta 投影 facade 真实实现(content-server,@Service 替换 UnavailableContentMetaFacade)。
|
||||
@ -27,12 +41,15 @@ import java.util.Objects;
|
||||
* <p>合规跨 BC:经 meta-api 端口取字段定义 + 可见性(不直连 meta DAL);value 回填读本域 planning;
|
||||
* 任一上游缺失一律 unavailable(fail-closed,绝不伪造投影)。projectionKey ≡ schemaKey。</p>
|
||||
*
|
||||
* <p>{@code validateDynamicFields} 暂用接口 default(unavailable):B5 planning 编辑器保存走
|
||||
* {@code PUT /works/{workId}/planning/{sectionKey}},由 content 写路径自身校验,无需此预校验端口;后续按需补。</p>
|
||||
* <p>{@code validateDynamicFields} 只做预校验和路由建议,不写任何 Content fact;真正保存仍必须回到
|
||||
* planning/block/knowledge/agent 等目标 owner 命令。</p>
|
||||
*/
|
||||
@Service
|
||||
public class RealContentMetaFacade implements ContentMetaFacade {
|
||||
|
||||
private static final String OWNER_CONTENT = "content";
|
||||
private static final String PLANNING_OWNER_COMMAND = "/app-api/muse/works/{workId}/planning/{sectionKey}";
|
||||
|
||||
@Resource
|
||||
private WorkMapper workMapper;
|
||||
@Resource
|
||||
@ -96,6 +113,51 @@ public class RealContentMetaFacade implements ContentMetaFacade {
|
||||
return MetaProjectionDetailResult.available(vo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DynamicFieldValidationResult validateDynamicFields(Long userId, Long workId,
|
||||
DynamicFieldValidationReqVO reqVO) {
|
||||
WorkDO work = workMapper.selectById(workId);
|
||||
if (work == null || work.getWorkSchemaId() == null || reqVO == null) {
|
||||
return DynamicFieldValidationResult.unavailable();
|
||||
}
|
||||
MetaSchemaProjectionDTO projection = metaProjectionQueryApi.getActiveSchemaProjection(work.getWorkSchemaId());
|
||||
if (projection == null) {
|
||||
return DynamicFieldValidationResult.unavailable();
|
||||
}
|
||||
PlanningSectionDO section = planningSectionMapper.selectByWorkIdAndSectionKey(workId, projection.schemaKey());
|
||||
int currentDataRevision = section == null ? 0 : section.getRevision();
|
||||
boolean schemaStale = !Objects.equals(reqVO.getSchemaVersion(), projection.schemaVersion());
|
||||
boolean projectionStale = !Objects.equals(reqVO.getProjectionVersion(), projection.projectionVersion());
|
||||
List<DynamicFieldInputVO> inputFields = reqVO.getFields() == null ? List.of() : reqVO.getFields();
|
||||
Map<String, MetaProjectionFieldDTO> fieldsByKey = projection.fields().stream()
|
||||
.collect(Collectors.toMap(MetaProjectionFieldDTO::fieldKey, field -> field, (left, right) -> left));
|
||||
Map<String, Object> inputsByKey = inputFields.stream()
|
||||
.collect(Collectors.toMap(DynamicFieldInputVO::getFieldKey, DynamicFieldInputVO::getValue,
|
||||
(left, right) -> right));
|
||||
|
||||
List<DynamicFieldValidationFieldResultRespVO> fieldResults = projection.fields().stream()
|
||||
.filter(field -> Boolean.TRUE.equals(field.userEditable()) || inputsByKey.containsKey(field.fieldKey()))
|
||||
.map(field -> validateField(field, inputsByKey.get(field.fieldKey()), inputsByKey.containsKey(field.fieldKey())))
|
||||
.toList();
|
||||
for (DynamicFieldInputVO input : inputFields) {
|
||||
if (!fieldsByKey.containsKey(input.getFieldKey())) {
|
||||
fieldResults = append(fieldResults, unknownFieldResult(input.getFieldKey()));
|
||||
}
|
||||
}
|
||||
|
||||
DynamicFieldValidationRespVO respVO = new DynamicFieldValidationRespVO();
|
||||
respVO.setSchemaStale(schemaStale);
|
||||
respVO.setProjectionStale(projectionStale);
|
||||
respVO.setCurrentSchemaVersion(projection.schemaVersion());
|
||||
respVO.setCurrentProjectionVersion(projection.projectionVersion());
|
||||
respVO.setCurrentDataRevision(currentDataRevision);
|
||||
respVO.setFieldResults(fieldResults);
|
||||
respVO.setRouteSuggestions(routeSuggestions(projection.fields()));
|
||||
respVO.setValid(!schemaStale && !projectionStale
|
||||
&& fieldResults.stream().allMatch(result -> Boolean.TRUE.equals(result.getValid())));
|
||||
return DynamicFieldValidationResult.available(respVO);
|
||||
}
|
||||
|
||||
private MetaProjectionSummaryRespVO toSummary(Long workId, MetaSchemaBriefDTO schema) {
|
||||
MetaSchemaProjectionDTO projection = metaProjectionQueryApi.getActiveSchemaProjection(schema.id());
|
||||
if (projection == null) {
|
||||
@ -123,11 +185,123 @@ public class RealContentMetaFacade implements ContentMetaFacade {
|
||||
vo.setUserEditable(f.userEditable());
|
||||
vo.setUserSearchable(f.userSearchable());
|
||||
vo.setExportable(f.exportable());
|
||||
vo.setDeprecated(f.deprecated());
|
||||
vo.setMigrationHint(f.migrationHint());
|
||||
vo.setWriteOwner(OWNER_CONTENT);
|
||||
vo.setOwnerCommand(PLANNING_OWNER_COMMAND);
|
||||
vo.setEnumValues(f.enumValues());
|
||||
vo.setValue(payload.get(f.fieldKey())); // 从 planning content 按 fieldKey 回填(无值则 null)。
|
||||
return vo;
|
||||
}
|
||||
|
||||
private DynamicFieldValidationFieldResultRespVO validateField(MetaProjectionFieldDTO field, Object value,
|
||||
boolean submitted) {
|
||||
List<DynamicFieldValidationErrorRespVO> errors = new java.util.ArrayList<>();
|
||||
if (Boolean.TRUE.equals(field.deprecated())) {
|
||||
errors.add(error("FIELD_DEPRECATED", StringUtils.hasText(field.migrationHint())
|
||||
? field.migrationHint() : "字段已废弃,请迁移到替代字段"));
|
||||
}
|
||||
if (Boolean.TRUE.equals(field.required()) && (!submitted || isBlankValue(value))) {
|
||||
errors.add(error("REQUIRED_MISSING", "必填字段不能为空"));
|
||||
}
|
||||
if (submitted && !isBlankValue(value)) {
|
||||
validateType(field, value, errors);
|
||||
validateRules(field.validationRules(), field.fieldType(), value, errors);
|
||||
}
|
||||
DynamicFieldValidationFieldResultRespVO result = new DynamicFieldValidationFieldResultRespVO();
|
||||
result.setFieldKey(field.fieldKey());
|
||||
result.setErrors(errors);
|
||||
result.setValid(errors.isEmpty());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validateType(MetaProjectionFieldDTO field, Object value,
|
||||
List<DynamicFieldValidationErrorRespVO> errors) {
|
||||
if ("text".equals(field.fieldType()) || "string".equals(field.fieldType())) {
|
||||
requireType(value instanceof String, errors);
|
||||
return;
|
||||
}
|
||||
if ("number".equals(field.fieldType())) {
|
||||
requireType(toDecimal(value) != null, errors);
|
||||
return;
|
||||
}
|
||||
if ("boolean".equals(field.fieldType())) {
|
||||
requireType(value instanceof Boolean, errors);
|
||||
return;
|
||||
}
|
||||
if ("date".equals(field.fieldType())) {
|
||||
requireType(value instanceof String && parseDate((String) value) != null, errors);
|
||||
return;
|
||||
}
|
||||
if ("enum".equals(field.fieldType())) {
|
||||
requireType(value instanceof String, errors);
|
||||
if (value instanceof String enumValue && field.enumValues() != null
|
||||
&& !field.enumValues().contains(enumValue)) {
|
||||
errors.add(error("ENUM_INVALID", "枚举值不在允许范围内"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ("relation".equals(field.fieldType())) {
|
||||
requireType(value instanceof String || value instanceof Number, errors);
|
||||
return;
|
||||
}
|
||||
if ("json".equals(field.fieldType())) {
|
||||
requireType(value instanceof Map<?, ?> || value instanceof List<?> || value instanceof String, errors);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRules(String validationRules, String fieldType, Object value,
|
||||
List<DynamicFieldValidationErrorRespVO> errors) {
|
||||
JsonNode rules = parseRules(validationRules);
|
||||
if (rules.isMissingNode() || rules.isNull()) {
|
||||
return;
|
||||
}
|
||||
BigDecimal number = toDecimal(value);
|
||||
if (number != null) {
|
||||
if (violatesDecimal(rules, "min", number, candidate -> number.compareTo(candidate) < 0)
|
||||
|| violatesDecimal(rules, "minimum", number, candidate -> number.compareTo(candidate) < 0)
|
||||
|| violatesDecimal(rules, "max", number, candidate -> number.compareTo(candidate) > 0)
|
||||
|| violatesDecimal(rules, "maximum", number, candidate -> number.compareTo(candidate) > 0)) {
|
||||
errors.add(error("VALUE_OUT_OF_RANGE", "数值超出允许范围"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (("text".equals(fieldType) || "string".equals(fieldType)) && value instanceof String text) {
|
||||
if (violatesLength(rules, "minLength", text, min -> text.length() < min)
|
||||
|| violatesLength(rules, "maxLength", text, max -> text.length() > max)) {
|
||||
errors.add(error("VALUE_OUT_OF_RANGE", "文本长度超出允许范围"));
|
||||
}
|
||||
String pattern = text(rules, "pattern");
|
||||
if (!StringUtils.hasText(pattern)) {
|
||||
pattern = text(rules, "regex");
|
||||
}
|
||||
if (StringUtils.hasText(pattern) && !matches(pattern, text)) {
|
||||
errors.add(error("VALUE_OUT_OF_RANGE", "文本格式不符合规则"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DynamicFieldValidationFieldResultRespVO unknownFieldResult(String fieldKey) {
|
||||
DynamicFieldValidationFieldResultRespVO result = new DynamicFieldValidationFieldResultRespVO();
|
||||
result.setFieldKey(fieldKey);
|
||||
result.setValid(false);
|
||||
result.setErrors(List.of(error("TYPE_MISMATCH", "字段不在当前 MetaSchema 投影中")));
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<DynamicFieldRouteSuggestionRespVO> routeSuggestions(List<MetaProjectionFieldDTO> fields) {
|
||||
return fields.stream()
|
||||
.filter(field -> Boolean.TRUE.equals(field.userEditable()))
|
||||
.map(field -> {
|
||||
DynamicFieldRouteSuggestionRespVO suggestion = new DynamicFieldRouteSuggestionRespVO();
|
||||
suggestion.setFieldKey(field.fieldKey());
|
||||
suggestion.setWriteOwner(OWNER_CONTENT);
|
||||
suggestion.setOwnerCommand(PLANNING_OWNER_COMMAND);
|
||||
return suggestion;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<String, Object> parsePayload(PlanningSectionDO section) {
|
||||
if (section == null || section.getContentPayload() == null || section.getContentPayload().isBlank()) {
|
||||
return Map.of();
|
||||
@ -136,4 +310,80 @@ public class RealContentMetaFacade implements ContentMetaFacade {
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
return parsed == null ? Map.of() : parsed;
|
||||
}
|
||||
|
||||
private JsonNode parseRules(String validationRules) {
|
||||
if (!StringUtils.hasText(validationRules)) {
|
||||
return JsonUtils.getObjectMapper().createObjectNode();
|
||||
}
|
||||
return JsonUtils.parseTree(validationRules);
|
||||
}
|
||||
|
||||
private boolean isBlankValue(Object value) {
|
||||
return value == null || (value instanceof String text && !StringUtils.hasText(text));
|
||||
}
|
||||
|
||||
private void requireType(boolean valid, List<DynamicFieldValidationErrorRespVO> errors) {
|
||||
if (!valid) {
|
||||
errors.add(error("TYPE_MISMATCH", "字段值类型不匹配"));
|
||||
}
|
||||
}
|
||||
|
||||
private DynamicFieldValidationErrorRespVO error(String code, String message) {
|
||||
DynamicFieldValidationErrorRespVO error = new DynamicFieldValidationErrorRespVO();
|
||||
error.setErrorCode(code);
|
||||
error.setMessage(message);
|
||||
return error;
|
||||
}
|
||||
|
||||
private BigDecimal toDecimal(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return new BigDecimal(number.toString());
|
||||
}
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
try {
|
||||
return new BigDecimal(text);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDate parseDate(String value) {
|
||||
try {
|
||||
return LocalDate.parse(value);
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean violatesDecimal(JsonNode rules, String fieldName, BigDecimal value, Predicate<BigDecimal> violation) {
|
||||
return rules.hasNonNull(fieldName) && rules.get(fieldName).isNumber()
|
||||
&& violation.test(rules.get(fieldName).decimalValue());
|
||||
}
|
||||
|
||||
private boolean violatesLength(JsonNode rules, String fieldName, String value, Predicate<Integer> violation) {
|
||||
return rules.hasNonNull(fieldName) && rules.get(fieldName).canConvertToInt()
|
||||
&& violation.test(rules.get(fieldName).asInt());
|
||||
}
|
||||
|
||||
private boolean matches(String pattern, String value) {
|
||||
try {
|
||||
return Pattern.compile(pattern).matcher(value).matches();
|
||||
} catch (PatternSyntaxException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String fieldName) {
|
||||
return node != null && node.hasNonNull(fieldName) ? node.get(fieldName).asText() : null;
|
||||
}
|
||||
|
||||
private List<DynamicFieldValidationFieldResultRespVO> append(
|
||||
List<DynamicFieldValidationFieldResultRespVO> existing,
|
||||
DynamicFieldValidationFieldResultRespVO item) {
|
||||
List<DynamicFieldValidationFieldResultRespVO> appended = new java.util.ArrayList<>(existing);
|
||||
appended.add(item);
|
||||
return appended;
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,6 +154,15 @@ public class AppContentController {
|
||||
return success(contentAppService.getBlock(getLoginUserId(), workId, blockId));
|
||||
}
|
||||
|
||||
@GetMapping("/works/{workId}/blocks/{blockId}/revisions")
|
||||
@Operation(summary = "查询 Block 正文版本历史")
|
||||
public CommonResult<List<BlockRevisionRespVO>> listBlockRevisions(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long workId, @PathVariable Long blockId) {
|
||||
ContentApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(contentAppService.listBlockRevisions(getLoginUserId(), workId, blockId));
|
||||
}
|
||||
|
||||
@PutMapping("/works/{workId}/blocks/{blockId}")
|
||||
@Operation(summary = "保存 Block 正文")
|
||||
public CommonResult<SaveBlockRespVO> saveBlock(@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package cn.iocoder.muse.module.content.controller.app.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Block 正文版本历史响应。
|
||||
*/
|
||||
@Schema(description = "Block 正文版本历史响应")
|
||||
@Data
|
||||
public class BlockRevisionRespVO {
|
||||
|
||||
@Schema(description = "Block ID")
|
||||
private Long blockId;
|
||||
|
||||
@Schema(description = "Block revision")
|
||||
private Integer revision;
|
||||
|
||||
@Schema(description = "该 revision 的正文快照")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "该 revision 的字数")
|
||||
private Integer wordCount;
|
||||
|
||||
@Schema(description = "触发该 revision 的命令 ID")
|
||||
private String commandId;
|
||||
|
||||
@Schema(description = "触发该 revision 的命令类型")
|
||||
private String commandType;
|
||||
|
||||
@Schema(description = "来源类型")
|
||||
private String sourceType;
|
||||
|
||||
@Schema(description = "来源对象 ID")
|
||||
private String sourceId;
|
||||
|
||||
@Schema(description = "来源对象版本")
|
||||
private Integer sourceVersion;
|
||||
|
||||
@Schema(description = "来源状态")
|
||||
private String sourceStatus;
|
||||
|
||||
@Schema(description = "授权快照 ID")
|
||||
private String authorizationSnapshotId;
|
||||
|
||||
@Schema(description = "审计原因")
|
||||
private String auditReason;
|
||||
|
||||
@Schema(description = "快照创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package cn.iocoder.muse.module.content.dal.dataobject;
|
||||
|
||||
import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* Block 正文版本历史快照 DO。
|
||||
*/
|
||||
@TableName("muse_content_block_revision_snapshot")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BlockRevisionSnapshotDO extends TenantBaseDO {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long workId;
|
||||
private Long chapterId;
|
||||
private Long blockId;
|
||||
private Integer revision;
|
||||
private String contentText;
|
||||
private Integer wordCount;
|
||||
private String commandId;
|
||||
private String commandType;
|
||||
private String sourceType;
|
||||
private String sourceObjectId;
|
||||
private Integer sourceVersion;
|
||||
private String sourceStatus;
|
||||
private String authorizationSnapshotId;
|
||||
private String auditReason;
|
||||
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package cn.iocoder.muse.module.content.dal.mysql;
|
||||
|
||||
import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.BlockRevisionSnapshotDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Block 正文版本历史快照 Mapper。
|
||||
*/
|
||||
@Mapper
|
||||
public interface BlockRevisionSnapshotMapper extends BaseMapperX<BlockRevisionSnapshotDO> {
|
||||
|
||||
/**
|
||||
* 按 Block 查询历史快照,最新 revision 在前。
|
||||
*/
|
||||
default List<BlockRevisionSnapshotDO> selectListByBlockId(Long blockId) {
|
||||
return selectList(new LambdaQueryWrapperX<BlockRevisionSnapshotDO>()
|
||||
.eq(BlockRevisionSnapshotDO::getBlockId, blockId)
|
||||
.orderByDesc(BlockRevisionSnapshotDO::getRevision));
|
||||
}
|
||||
|
||||
}
|
||||
@ -7,6 +7,7 @@ import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.*;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.*;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.*;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeDraftInvalidationApi;
|
||||
import cn.iocoder.muse.module.meta.api.schema.MetaSchemaBriefDTO;
|
||||
import cn.iocoder.muse.module.meta.api.schema.MetaSchemaQueryApi;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@ -14,6 +15,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
@ -52,7 +54,13 @@ class ContentAppServiceTest extends BaseMockitoUnitTest {
|
||||
@Mock
|
||||
private ContentEventPublishOutboxService eventPublishOutboxService;
|
||||
@Mock
|
||||
private ContentBlockRevisionService blockRevisionService;
|
||||
@Mock
|
||||
private MetaSchemaQueryApi metaSchemaQueryApi;
|
||||
@Mock
|
||||
private ObjectProvider<MuseKnowledgeDraftInvalidationApi> knowledgeDraftInvalidationApiProvider;
|
||||
@Mock
|
||||
private MuseKnowledgeDraftInvalidationApi knowledgeDraftInvalidationApi;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
@ -220,6 +228,7 @@ class ContentAppServiceTest extends BaseMockitoUnitTest {
|
||||
verify(blockMapper, never()).updateById(any(BlockDO.class));
|
||||
verify(sourceAttributionMapper, never()).insert(any(BlockSourceAttributionDO.class));
|
||||
verify(eventPublishOutboxService, never()).createForBlockSourceAttribution(any(), any(), any(), any(), any(), any());
|
||||
verify(blockRevisionService, never()).recordBlockRevision(any(), any(), any(), any(), any(), any(), any());
|
||||
verify(auditService, never()).recordSucceededWithAudit(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@ -279,6 +288,7 @@ class ContentAppServiceTest extends BaseMockitoUnitTest {
|
||||
source.setTenantId(100L);
|
||||
return 1;
|
||||
}).when(sourceAttributionMapper).insert(any(BlockSourceAttributionDO.class));
|
||||
when(knowledgeDraftInvalidationApiProvider.getIfAvailable()).thenReturn(knowledgeDraftInvalidationApi);
|
||||
|
||||
SaveBlockRespVO result = contentAppService.saveBlock(1001L, 101L, 301L, reqVO);
|
||||
|
||||
@ -291,10 +301,27 @@ class ContentAppServiceTest extends BaseMockitoUnitTest {
|
||||
verify(eventPublishOutboxService).createForBlockSourceAttribution(eq(1001L), eq(101L), eq(301L),
|
||||
eq(2), eq("cmd-save-block"), argThat((BlockSourceAttributionDO source) -> Long.valueOf(701L).equals(source.getId())
|
||||
&& "active".equals(source.getSourceStatus())));
|
||||
InOrder inOrder = inOrder(sourceAttributionMapper, eventPublishOutboxService, auditService);
|
||||
verify(blockRevisionService).recordBlockRevision(eq(1001L), eq(101L),
|
||||
argThat((BlockDO block) -> Long.valueOf(301L).equals(block.getId())
|
||||
&& Integer.valueOf(2).equals(block.getRevision())
|
||||
&& "新正文".equals(block.getContentText())),
|
||||
eq("cmd-save-block"), eq("save_block"),
|
||||
argThat((BlockSourceAttributionDO source) -> Long.valueOf(701L).equals(source.getId())),
|
||||
eq("用户保存"));
|
||||
verify(knowledgeDraftInvalidationApi).markContentBlockDraftsNeedRecheck(argThat(command ->
|
||||
command.ownerUserId().equals(1001L)
|
||||
&& command.workId().equals(101L)
|
||||
&& command.chapterId().equals(201L)
|
||||
&& command.blockId().equals(301L)
|
||||
&& command.newBlockRevision().equals(2)
|
||||
&& "cmd-save-block".equals(command.commandId())
|
||||
&& "content_block_saved".equals(command.reason())));
|
||||
InOrder inOrder = inOrder(sourceAttributionMapper, eventPublishOutboxService, blockRevisionService, auditService);
|
||||
inOrder.verify(sourceAttributionMapper).insert(any(BlockSourceAttributionDO.class));
|
||||
inOrder.verify(eventPublishOutboxService).createForBlockSourceAttribution(eq(1001L), eq(101L), eq(301L),
|
||||
eq(2), eq("cmd-save-block"), any(BlockSourceAttributionDO.class));
|
||||
inOrder.verify(blockRevisionService).recordBlockRevision(eq(1001L), eq(101L), any(BlockDO.class),
|
||||
eq("cmd-save-block"), eq("save_block"), any(BlockSourceAttributionDO.class), eq("用户保存"));
|
||||
inOrder.verify(auditService).recordSucceededWithAudit(eq("cmd-save-block"), eq("save_block"), eq(1001L),
|
||||
eq("app"), eq("block"), eq(301L), eq("hash-4"), anyString(),
|
||||
eq("save_block"), eq("block_saved"), eq("用户保存"), eq("succeeded"));
|
||||
@ -323,6 +350,41 @@ class ContentAppServiceTest extends BaseMockitoUnitTest {
|
||||
verify(blockMapper, never()).updateById(any(BlockDO.class));
|
||||
verify(sourceAttributionMapper, never()).insert(any(BlockSourceAttributionDO.class));
|
||||
verify(eventPublishOutboxService, never()).createForBlockSourceAttribution(any(), any(), any(), any(), any(), any());
|
||||
verify(blockRevisionService, never()).recordBlockRevision(any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_keepSaveBlockSucceeded_when_knowledgeDraftInvalidationFails() {
|
||||
BlockSaveReqVO reqVO = new BlockSaveReqVO();
|
||||
reqVO.setCommandId("cmd-save-block");
|
||||
reqVO.setExpectedRevision(1);
|
||||
reqVO.setContent("新正文");
|
||||
reqVO.setAuditReason("用户保存");
|
||||
reqVO.setSourceSnapshot(sourceSnapshot("user_original", "manual", 1, null));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-4");
|
||||
when(commandService.reserveCommand("cmd-save-block", "save_block", 1001L, "block", 301L, "hash-4"))
|
||||
.thenReturn(null);
|
||||
when(workMapper.selectById(101L)).thenReturn(work(101L, 1001L, 1));
|
||||
when(blockMapper.selectById(301L)).thenReturn(block(301L, 101L, 201L, 1, "旧正文"));
|
||||
doAnswer(invocation -> {
|
||||
BlockSourceAttributionDO source = invocation.getArgument(0);
|
||||
source.setId(701L);
|
||||
source.setTenantId(100L);
|
||||
return 1;
|
||||
}).when(sourceAttributionMapper).insert(any(BlockSourceAttributionDO.class));
|
||||
when(knowledgeDraftInvalidationApiProvider.getIfAvailable()).thenReturn(knowledgeDraftInvalidationApi);
|
||||
when(knowledgeDraftInvalidationApi.markContentBlockDraftsNeedRecheck(any()))
|
||||
.thenThrow(new IllegalStateException("knowledge temporarily unavailable"));
|
||||
|
||||
SaveBlockRespVO result = contentAppService.saveBlock(1001L, 101L, 301L, reqVO);
|
||||
|
||||
assertEquals(2, result.getRevision());
|
||||
verify(blockMapper).updateById(argThat((BlockDO block) -> block.getRevision() == 2));
|
||||
verify(blockRevisionService).recordBlockRevision(eq(1001L), eq(101L), any(BlockDO.class),
|
||||
eq("cmd-save-block"), eq("save_block"), any(BlockSourceAttributionDO.class), eq("用户保存"));
|
||||
verify(auditService).recordSucceededWithAudit(eq("cmd-save-block"), eq("save_block"), eq(1001L),
|
||||
eq("app"), eq("block"), eq(301L), eq("hash-4"), anyString(),
|
||||
eq("save_block"), eq("block_saved"), eq("用户保存"), eq("succeeded"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -15,9 +15,11 @@ import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.BlockMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.BlockSourceAttributionMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeDraftInvalidationApi;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -50,6 +52,12 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
private ContentAiSuggestionFacade suggestionFacade;
|
||||
@Mock
|
||||
private ContentEventPublishOutboxService eventPublishOutboxService;
|
||||
@Mock
|
||||
private ContentBlockRevisionService blockRevisionService;
|
||||
@Mock
|
||||
private ObjectProvider<MuseKnowledgeDraftInvalidationApi> knowledgeDraftInvalidationApiProvider;
|
||||
@Mock
|
||||
private MuseKnowledgeDraftInvalidationApi knowledgeDraftInvalidationApi;
|
||||
|
||||
@Test
|
||||
void should_throwForbidden_when_sourceAttributionWorkOwnerMismatch() {
|
||||
@ -275,6 +283,8 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(ContentAiSuggestionFacade.SuggestionLookupResult.available(
|
||||
suggestion(501L, "pending", 7, "AI 正文")));
|
||||
when(blockMapper.update(isNull(), any())).thenReturn(1);
|
||||
stubAcceptedArchive(reqVO);
|
||||
when(knowledgeDraftInvalidationApiProvider.getIfAvailable()).thenReturn(knowledgeDraftInvalidationApi);
|
||||
|
||||
MergeBlockSuggestionRespVO result = contentSourceService.mergeBlockSuggestion(1001L, 101L, 301L, reqVO);
|
||||
|
||||
@ -309,6 +319,8 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(ContentAiSuggestionFacade.SuggestionLookupResult.available(
|
||||
suggestion(501L, "pending", 7, "AI 正文")));
|
||||
when(blockMapper.update(isNull(), any())).thenReturn(1);
|
||||
stubAcceptedArchive(reqVO);
|
||||
when(knowledgeDraftInvalidationApiProvider.getIfAvailable()).thenReturn(knowledgeDraftInvalidationApi);
|
||||
|
||||
MergeBlockSuggestionRespVO result = contentSourceService.mergeBlockSuggestion(1001L, 101L, 301L, reqVO);
|
||||
|
||||
@ -413,6 +425,7 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
suggestionWithSourceSnapshot(501L, "pending", 7, "AI 正文",
|
||||
sourceSnapshot("ai_suggestion", "501", 7, "rpe-local-auth-88"), null)));
|
||||
when(blockMapper.update(isNull(), any())).thenReturn(1);
|
||||
stubAcceptedArchive(reqVO);
|
||||
|
||||
MergeBlockSuggestionRespVO result = contentSourceService.mergeBlockSuggestion(1001L, 101L, 301L, reqVO);
|
||||
|
||||
@ -450,6 +463,8 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(ContentAiSuggestionFacade.SuggestionLookupResult.available(
|
||||
suggestion(501L, "pending", 7, "AI 正文")));
|
||||
when(blockMapper.update(isNull(), any())).thenReturn(1);
|
||||
stubAcceptedArchive(reqVO);
|
||||
when(knowledgeDraftInvalidationApiProvider.getIfAvailable()).thenReturn(knowledgeDraftInvalidationApi);
|
||||
|
||||
MergeBlockSuggestionRespVO result = contentSourceService.mergeBlockSuggestion(1001L, 101L, 301L, reqVO);
|
||||
|
||||
@ -477,6 +492,79 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
// 缺口 B 回归:merge 成功后必须写本域 outbox,驱动 source_status_change 经 SSE 回流。
|
||||
verify(eventPublishOutboxService).createForBlockSourceAttribution(eq(1001L), eq(101L), eq(301L),
|
||||
eq(2), eq("cmd-merge-suggestion"), any(BlockSourceAttributionDO.class));
|
||||
verify(blockRevisionService).recordBlockRevision(eq(1001L), eq(101L),
|
||||
argThat((BlockDO block) -> Long.valueOf(301L).equals(block.getId())
|
||||
&& Integer.valueOf(2).equals(block.getRevision())
|
||||
&& "AI 正文".equals(block.getContentText())),
|
||||
eq("cmd-merge-suggestion"), eq(OP_MERGE_BLOCK_SUGGESTION),
|
||||
argThat((BlockSourceAttributionDO source) -> "ai_suggestion".equals(source.getSourceType())
|
||||
&& "501".equals(source.getSourceObjectId())),
|
||||
eq("用户接受"));
|
||||
verify(knowledgeDraftInvalidationApi).markContentBlockDraftsNeedRecheck(argThat(command ->
|
||||
command.ownerUserId().equals(1001L)
|
||||
&& command.workId().equals(101L)
|
||||
&& command.chapterId().equals(201L)
|
||||
&& command.blockId().equals(301L)
|
||||
&& command.newBlockRevision().equals(2)
|
||||
&& "cmd-merge-suggestion".equals(command.commandId())
|
||||
&& "content_suggestion_merged".equals(command.reason())));
|
||||
verify(suggestionFacade).markSuggestionAccepted(eq(1001L), eq(101L), eq(301L),
|
||||
argThat(decision -> Long.valueOf(501L).equals(decision.suggestionId())
|
||||
&& "cmd-merge-suggestion".equals(decision.contentCommandId())
|
||||
&& "accept".equals(decision.decisionType())
|
||||
&& "accept_as_is".equals(decision.mergeMode())
|
||||
&& Integer.valueOf(7).equals(decision.suggestionRevision())
|
||||
&& Integer.valueOf(2).equals(decision.newBlockRevision())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_throwExternalOwnerUnavailable_when_acceptedArchiveUnavailable() {
|
||||
MergeBlockSuggestionReqVO reqVO = mergeReq("cmd-merge-suggestion", 1, 501L, 7, "accept_as_is", null);
|
||||
stubMergeContext(reqVO);
|
||||
when(suggestionFacade.getSuggestion(1001L, 101L, 301L, 501L))
|
||||
.thenReturn(ContentAiSuggestionFacade.SuggestionLookupResult.available(
|
||||
suggestion(501L, "pending", 7, "AI 正文")));
|
||||
when(blockMapper.update(isNull(), any())).thenReturn(1);
|
||||
when(suggestionFacade.markSuggestionAccepted(eq(1001L), eq(101L), eq(301L), any()))
|
||||
.thenReturn(ContentAiSuggestionFacade.AcceptedDecisionResult.unavailable());
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> contentSourceService.mergeBlockSuggestion(1001L, 101L, 301L, reqVO));
|
||||
|
||||
assertEquals(CONTENT_EXTERNAL_OWNER_UNAVAILABLE.getCode(), exception.getCode());
|
||||
verify(blockMapper).update(isNull(), any());
|
||||
verify(sourceAttributionMapper).insert(any(BlockSourceAttributionDO.class));
|
||||
verify(eventPublishOutboxService, never()).createForBlockSourceAttribution(any(), any(), any(), any(), any(), any());
|
||||
verify(blockRevisionService, never()).recordBlockRevision(any(), any(), any(), any(), any(), any(), any());
|
||||
verify(auditService, never()).recordSucceededWithAudit(any(), any(), any(), any(), any(), any(),
|
||||
any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_keepMergeSucceeded_when_knowledgeDraftInvalidationFails() {
|
||||
MergeBlockSuggestionReqVO reqVO = mergeReq("cmd-merge-suggestion", 1, 501L, 7, "accept_as_is", null);
|
||||
stubMergeContext(reqVO);
|
||||
when(suggestionFacade.getSuggestion(1001L, 101L, 301L, 501L))
|
||||
.thenReturn(ContentAiSuggestionFacade.SuggestionLookupResult.available(
|
||||
suggestion(501L, "pending", 7, "AI 正文")));
|
||||
when(blockMapper.update(isNull(), any())).thenReturn(1);
|
||||
stubAcceptedArchive(reqVO);
|
||||
when(knowledgeDraftInvalidationApiProvider.getIfAvailable()).thenReturn(knowledgeDraftInvalidationApi);
|
||||
when(knowledgeDraftInvalidationApi.markContentBlockDraftsNeedRecheck(any()))
|
||||
.thenThrow(new IllegalStateException("knowledge temporarily unavailable"));
|
||||
|
||||
MergeBlockSuggestionRespVO result = contentSourceService.mergeBlockSuggestion(1001L, 101L, 301L, reqVO);
|
||||
|
||||
assertEquals(2, result.getNewRevision());
|
||||
verify(sourceAttributionMapper).insert(any(BlockSourceAttributionDO.class));
|
||||
verify(eventPublishOutboxService).createForBlockSourceAttribution(eq(1001L), eq(101L), eq(301L),
|
||||
eq(2), eq("cmd-merge-suggestion"), any(BlockSourceAttributionDO.class));
|
||||
verify(blockRevisionService).recordBlockRevision(eq(1001L), eq(101L), any(BlockDO.class),
|
||||
eq("cmd-merge-suggestion"), eq(OP_MERGE_BLOCK_SUGGESTION), any(BlockSourceAttributionDO.class),
|
||||
eq("用户接受"));
|
||||
verify(auditService).recordSucceededWithAudit(eq("cmd-merge-suggestion"), eq(OP_MERGE_BLOCK_SUGGESTION),
|
||||
eq(1001L), eq("app"), eq("block"), eq(301L), eq("hash-merge-suggestion"), anyString(),
|
||||
anyString(), eq("suggestion_merged"), eq("用户接受"), eq("succeeded"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -504,6 +592,7 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
verify(sourceAttributionMapper, never()).insert(any(BlockSourceAttributionDO.class));
|
||||
// 幂等 replay 不得重复发事件,避免 SSE 回流重复通知。
|
||||
verify(eventPublishOutboxService, never()).createForBlockSourceAttribution(any(), any(), any(), any(), any(), any());
|
||||
verify(blockRevisionService, never()).recordBlockRevision(any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
private MergeBlockSuggestionReqVO mergeReq(String commandId, Integer expectedRevision, Long suggestionId,
|
||||
@ -531,6 +620,13 @@ class ContentSourceServiceTest extends BaseMockitoUnitTest {
|
||||
when(blockMapper.selectById(301L)).thenReturn(block(301L, 101L, 201L, 1, "旧正文"));
|
||||
}
|
||||
|
||||
private void stubAcceptedArchive(MergeBlockSuggestionReqVO reqVO) {
|
||||
when(suggestionFacade.markSuggestionAccepted(eq(1001L), eq(101L), eq(301L),
|
||||
argThat(decision -> Long.valueOf(501L).equals(decision.suggestionId())
|
||||
&& reqVO.getCommandId().equals(decision.contentCommandId()))))
|
||||
.thenReturn(ContentAiSuggestionFacade.AcceptedDecisionResult.acceptedResult());
|
||||
}
|
||||
|
||||
private ContentAiSuggestionFacade.SuggestionProjection suggestion(Long suggestionId, String status,
|
||||
Integer revision, String content) {
|
||||
return suggestionWithStatus(suggestionId, status, revision, content, "active");
|
||||
|
||||
@ -47,7 +47,8 @@ class RealContentMetaFacadeTest extends BaseMockitoUnitTest {
|
||||
private MetaSchemaProjectionDTO projection(String key, String name) {
|
||||
return new MetaSchemaProjectionDTO(1L, key, name, 1, 1, List.of(
|
||||
new MetaProjectionFieldDTO("time_period", "时代背景", "enum", true,
|
||||
true, false, true, false, false, List.of("古代", "现代"))));
|
||||
true, false, true, false, false, List.of("古代", "现代"),
|
||||
null, false, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -112,4 +113,123 @@ class RealContentMetaFacadeTest extends BaseMockitoUnitTest {
|
||||
new MetaSchemaBriefDTO(5L, "setting", "故事设定", "novel_work")));
|
||||
assertFalse(facade.getMetaProjection(100L, 1L, "nonexistent").available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateDynamicFields_appliesActiveProjectionRulesAndReturnsRoutes() {
|
||||
when(workMapper.selectById(1L)).thenReturn(work(5L));
|
||||
when(metaProjectionQueryApi.getActiveSchemaProjection(5L)).thenReturn(
|
||||
new MetaSchemaProjectionDTO(5L, "setting", "故事设定", 3, 4, List.of(
|
||||
new MetaProjectionFieldDTO("title", "标题", "string", true,
|
||||
true, false, true, false, false, null,
|
||||
"{\"minLength\":2,\"maxLength\":5,\"pattern\":\"^[A-Z].*\"}", false, null),
|
||||
new MetaProjectionFieldDTO("genre", "题材", "enum", true,
|
||||
true, false, true, false, false, List.of("fantasy", "sci-fi"),
|
||||
null, false, null),
|
||||
new MetaProjectionFieldDTO("score", "分数", "number", false,
|
||||
true, false, true, false, false, null,
|
||||
"{\"min\":1,\"max\":10}", false, null),
|
||||
new MetaProjectionFieldDTO("oldField", "旧字段", "string", false,
|
||||
true, false, true, false, false, null,
|
||||
"{\"deprecated\":true,\"deprecatedInfo\":{\"migrationHint\":\"改用 title\"}}",
|
||||
true, "改用 title"))));
|
||||
PlanningSectionDO section = new PlanningSectionDO();
|
||||
section.setRevision(7);
|
||||
when(planningSectionMapper.selectByWorkIdAndSectionKey(1L, "setting")).thenReturn(section);
|
||||
|
||||
var req = validateReq(2, 9,
|
||||
input("title", "bad"),
|
||||
input("genre", "mystery"),
|
||||
input("score", 11),
|
||||
input("oldField", "legacy"),
|
||||
input("unknown", "x"));
|
||||
|
||||
var result = facade.validateDynamicFields(100L, 1L, req);
|
||||
|
||||
assertTrue(result.available());
|
||||
var validation = result.validation();
|
||||
assertFalse(validation.getValid());
|
||||
assertTrue(validation.getSchemaStale());
|
||||
assertTrue(validation.getProjectionStale());
|
||||
assertEquals(3, validation.getCurrentSchemaVersion());
|
||||
assertEquals(4, validation.getCurrentProjectionVersion());
|
||||
assertEquals(7, validation.getCurrentDataRevision());
|
||||
assertEquals(List.of("title", "genre", "score", "oldField"), validation.getRouteSuggestions().stream()
|
||||
.map(suggestion -> suggestion.getFieldKey())
|
||||
.toList());
|
||||
assertEquals(List.of("VALUE_OUT_OF_RANGE"), errorsOf(validation, "title"));
|
||||
assertEquals(List.of("ENUM_INVALID"), errorsOf(validation, "genre"));
|
||||
assertEquals(List.of("VALUE_OUT_OF_RANGE"), errorsOf(validation, "score"));
|
||||
assertEquals(List.of("FIELD_DEPRECATED"), errorsOf(validation, "oldField"));
|
||||
assertEquals(List.of("TYPE_MISMATCH"), errorsOf(validation, "unknown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateDynamicFields_passes_whenVersionsAndValuesMatch() {
|
||||
when(workMapper.selectById(1L)).thenReturn(work(5L));
|
||||
when(metaProjectionQueryApi.getActiveSchemaProjection(5L)).thenReturn(
|
||||
new MetaSchemaProjectionDTO(5L, "setting", "故事设定", 3, 4, List.of(
|
||||
new MetaProjectionFieldDTO("title", "标题", "string", true,
|
||||
true, false, true, false, false, null,
|
||||
"{\"minLength\":2,\"maxLength\":10,\"pattern\":\"^[A-Z].*\"}", false, null),
|
||||
new MetaProjectionFieldDTO("published", "是否发布", "boolean", false,
|
||||
true, false, true, false, false, null,
|
||||
null, false, null),
|
||||
new MetaProjectionFieldDTO("dueDate", "日期", "date", false,
|
||||
true, false, true, false, false, null,
|
||||
null, false, null))));
|
||||
when(planningSectionMapper.selectByWorkIdAndSectionKey(1L, "setting")).thenReturn(null);
|
||||
|
||||
var req = validateReq(3, 4,
|
||||
input("title", "Alpha"),
|
||||
input("published", true),
|
||||
input("dueDate", "2026-06-27"));
|
||||
|
||||
var result = facade.validateDynamicFields(100L, 1L, req);
|
||||
|
||||
assertTrue(result.available());
|
||||
assertTrue(result.validation().getValid());
|
||||
assertFalse(result.validation().getSchemaStale());
|
||||
assertFalse(result.validation().getProjectionStale());
|
||||
assertEquals(0, result.validation().getCurrentDataRevision());
|
||||
assertTrue(result.validation().getFieldResults().stream().allMatch(item -> item.getErrors().isEmpty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateDynamicFields_unavailable_whenWorkHasNoSchema() {
|
||||
when(workMapper.selectById(1L)).thenReturn(work(null));
|
||||
|
||||
assertFalse(facade.validateDynamicFields(100L, 1L, validateReq(1, 1, input("title", "Alpha"))).available());
|
||||
}
|
||||
|
||||
private cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationReqVO validateReq(
|
||||
int schemaVersion,
|
||||
int projectionVersion,
|
||||
cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldInputVO... fields) {
|
||||
cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationReqVO req =
|
||||
new cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationReqVO();
|
||||
req.setSchemaVersion(schemaVersion);
|
||||
req.setProjectionVersion(projectionVersion);
|
||||
req.setFields(List.of(fields));
|
||||
return req;
|
||||
}
|
||||
|
||||
private cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldInputVO input(String fieldKey, Object value) {
|
||||
cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldInputVO input =
|
||||
new cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldInputVO();
|
||||
input.setFieldKey(fieldKey);
|
||||
input.setValue(value);
|
||||
return input;
|
||||
}
|
||||
|
||||
private List<String> errorsOf(
|
||||
cn.iocoder.muse.module.content.controller.app.vo.DynamicFieldValidationRespVO validation,
|
||||
String fieldKey) {
|
||||
return validation.getFieldResults().stream()
|
||||
.filter(result -> fieldKey.equals(result.getFieldKey()))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.getErrors().stream()
|
||||
.map(error -> error.getErrorCode())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,5 +5,5 @@
|
||||
- **目标(owner 职责)**:Local KB / User KB / 全局知识处理 / Knowledge Draft / Knowledge Source Binding / 投影与索引状态。
|
||||
- **边界(不可违反)**:**进 Local KB 唯一入口=用户显式确认草稿或手动修正**;全书解析章节确认只产/推 Draft,不写正式知识;来源优先级 Local KB > 用户绑定 > 市场/全局;依赖 RAGFlow GraphRAG(不可用时 Unavailable 优雅降级);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
|
||||
- **out-of-scope**:正文写入(归 content)。
|
||||
- **现状**:只读评估 72%;后端最完整模块之一(59 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30);source binding projection 回填闭环已修(2026-06-19:绑定确认为权威回填点写读模型行、unbind 按 bindingId 作用域撤销不误伤同源跨作品投影、唯一键补 work_id 修跨作品复用误判冲突〔V24〕;`MuseKnowledgeBindingServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 11/11、整套件 214/214 绿);**读回端到端坐实 + unbind 真软删修复(2026-06-19)**:嵌入式 DB(H2)往返证 bind 写投影→`selectActiveByWorkId` 读回该来源、unbind 后读回消失、同源他作品投影存活;并修 unbind 软删真 bug——`deleted` 是 `@TableLogic`,实体 `setDeleted(true)` 被 MP 普通 update 剥离成空操作,改 `setSql("deleted = true")` 才真软删(`MuseKnowledgeSourceBindingProjectionRoundTripTest` 2/2、整套件 216/216 绿;新增 knowledge 模块首套嵌入式 DB 测试基建)。**GET bindings 读端点已补(2026-06-19)**:`AppMuseKnowledgeBindingController` 加 `GET /muse/works/{workId}/knowledge-bindings`→`listKnowledgeBindings`(requireWorkOwner 防 IDOR + selectActiveByWorkId 读回投影,id→String;happy-path + 越权 fail-closed 单测,整套件 218/218 绿);**bindings 读回后端腿(写→读→端点)已闭环,仅余 FE hook**。**FE 读回面板已加(2026-06-19)**:studio `useKnowledgeBindings` hook + `KnowledgeBindingsPanel`(镜像 DraftPanel)接入 `KnowledgePage`,契约+组件测试经 vitest 验证(全 studio 50/50、tsc 干净);live playwright e2e 需全栈 app(env 受限)未跑。bindings 写→读→端点→FE 读回展示链(除 live e2e)全通。**live e2e 已写 ready 未跑(2026-06-19)**:`muse-studio/e2e/knowledge-bindings.spec.ts`(镜像 graph spec,`playwright --list` 编译有效);活体受阻=远端 PG 宿主 100.64.0.8 离线(不在 tailnet)、无本地 PG 兜底,PG 宿主恢复后按 [.agents/knowledge §六] 起栈可跑。 **文本内容安全扫描已实现 + 摄入 scanning 关卡打通(2026-06-23,commit ec7637d)**:`KnowledgeContentScanService`(同步纯文本字符扫描——L1 非法 UTF-8/null/危险 C0-C1 控制符/Bidi 覆盖/体量硬阻断,L2 NFC+去零宽+换行规整,L3 prompt 注入软标记不阻断;特殊字符判定全用十六进制码点避免源码字面不可见字符),`KnowledgeFileFacade.materialize` 接入(passed→materialized+规整内容入 RAG、blocked→隔离带 reasonCode、扫描异常→fail-closed),替换原恒 `scanBlocked` 占位;`MaterializedFile.scanBlocked` 加 reasonCode/message(连带改 P1r live IT 构造签名)。单测 ScanService 9 + Facade 接线 3 + DocumentService 17 + P-B RetrievalApiImpl 11 全绿。**端到端真验(48080 + muse_slice_live + RAGFlow/New-API env 配齐)**:上传纯文本 document 4 → `scan_status=passed` → 处理链推进 → RAGFlow dataset(5950e9ee)+document(595cb63e)创建成功,坐实 scan 关卡 + muse→RAGFlow 集成贯通(对比 document 2 旧 jar 卡 SCAN_SERVICE_UNAVAILABLE、document 3 RAGFlow 未配 CONFIG_MISSING)。**P-A/P-B/P-C 完整 e2e 真验受阻=RAGFlow backend 故障**(2026-06-23:`/api/v1/*` 持续 502≥5min,healthz 误导性 200;document 4 卡 RAGFlow parse 无法 embedding→searchable),属外部依赖故障非代码问题,RAGFlow 恢复/重启后可直接续跑(scan jar 已部署 48080 pid 65625、binding precheck/bind + AI task 入参已探明)。
|
||||
- **关键风险 / TODO**:前端仅约 1/3 面(草稿确认/绑定/发布/图谱缺 hook);Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;recheck 默认未生效。**parse 轮询 worker 已补齐并端到端真验(2026-06-23,commit 635045c)**:`MuseKnowledgeParseStatusPollWorker`(@Scheduled + 开关 `muse.knowledge.parse-poll-worker.enabled`,跨租户捞 parsing 任务轮询 RAGFlow 文档状态→run=done 推 version processingStatus=searchable、failed 标败、processing 保持、RAGFlow 不可达/异常不误判)+ `ProcessingTaskService.markRagflowParseCompleted/markRagflowParsePolling` + `Mapper.selectParsingForPoll`;worker 接线 7 测试 + 回归 40 全绿;启用开关后 document 4 自动 parsing→searchable(isSearchable=true),摄入链 **scan→RAGFlow→worker→searchable** 全程贯通(此前"RAGFlow backend 故障"系本机 HTTP 代理误诊,RAGFlow 实始终正常,真因=muse 缺 parse 轮询 worker)。AI 检索消费 e2e(P-A/B/C)续验卡 slice DB 缺可用 agent version(agent 1 user/owner1 可见但无 active version 行、agent 44 market 对 owner1 不可见),需补 `muse_agent_version` 种子或给 work 绑可见 agent slot。
|
||||
- **现状**:只读评估 78%;后端最完整模块之一(67 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30);source binding projection 回填闭环已修(2026-06-19:绑定确认为权威回填点写读模型行、unbind 按 bindingId 作用域撤销不误伤同源跨作品投影、唯一键补 work_id 修跨作品复用误判冲突〔V24〕;`MuseKnowledgeBindingServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 11/11、整套件 214/214 绿);**读回端到端坐实 + unbind 真软删修复(2026-06-19)**:嵌入式 DB(H2)往返证 bind 写投影→`selectActiveByWorkId` 读回该来源、unbind 后读回消失、同源他作品投影存活;并修 unbind 软删真 bug——`deleted` 是 `@TableLogic`,实体 `setDeleted(true)` 被 MP 普通 update 剥离成空操作,改 `setSql("deleted = true")` 才真软删(`MuseKnowledgeSourceBindingProjectionRoundTripTest` 2/2、整套件 216/216 绿;新增 knowledge 模块首套嵌入式 DB 测试基建)。**GET bindings 读端点已补(2026-06-19)**:`AppMuseKnowledgeBindingController` 加 `GET /muse/works/{workId}/knowledge-bindings`→`listKnowledgeBindings`(requireWorkOwner 防 IDOR + selectActiveByWorkId 读回投影,id→String;happy-path + 越权 fail-closed 单测,整套件 218/218 绿);**bindings 读回后端腿(写→读→端点)已闭环,仅余 FE hook**。**FE 读回面板已加(2026-06-19)**:studio `useKnowledgeBindings` hook + `KnowledgeBindingsPanel`(镜像 DraftPanel)接入 `KnowledgePage`,契约+组件测试经 vitest 验证(全 studio 50/50、tsc 干净);live playwright e2e 需全栈 app(env 受限)未跑。bindings 写→读→端点→FE 读回展示链(除 live e2e)全通。**live e2e 已写 ready 未跑(2026-06-19)**:`muse-studio/e2e/knowledge-bindings.spec.ts`(镜像 graph spec,`playwright --list` 编译有效);活体受阻=远端 PG 宿主 100.64.0.8 离线(不在 tailnet)、无本地 PG 兜底,PG 宿主恢复后按 [.agents/knowledge §六] 起栈可跑。 **文本内容安全扫描已实现 + 摄入 scanning 关卡打通(2026-06-23,commit ec7637d)**:`KnowledgeContentScanService`(同步纯文本字符扫描——L1 非法 UTF-8/null/危险 C0-C1 控制符/Bidi 覆盖/体量硬阻断,L2 NFC+去零宽+换行规整,L3 prompt 注入软标记不阻断;特殊字符判定全用十六进制码点避免源码字面不可见字符),`KnowledgeFileFacade.materialize` 接入(passed→materialized+规整内容入 RAG、blocked→隔离带 reasonCode、扫描异常→fail-closed),替换原恒 `scanBlocked` 占位;`MaterializedFile.scanBlocked` 加 reasonCode/message(连带改 P1r live IT 构造签名)。单测 ScanService 9 + Facade 接线 3 + DocumentService 17 + P-B RetrievalApiImpl 11 全绿。**端到端真验(48080 + muse_slice_live + RAGFlow/New-API env 配齐)**:上传纯文本 document 4 → `scan_status=passed` → 处理链推进 → RAGFlow dataset(5950e9ee)+document(595cb63e)创建成功,坐实 scan 关卡 + muse→RAGFlow 集成贯通(对比 document 2 旧 jar 卡 SCAN_SERVICE_UNAVAILABLE、document 3 RAGFlow 未配 CONFIG_MISSING)。**E3 studio 与检索闭环已完成(2026-06-27)**:新增主动检索 App 端点与 `KnowledgeRetrievalPanel`,no_dataset 显式治理提示,自建 KB 上传真实后端读回,market KB handoff 正负路真 e2e;AI 生成 e2e 反查 `source_summary.contextAssembly` 证实 Knowledge Source 被真实消费。fresh 证据:knowledge targeted 25/0F/0E/0S,契约/迁移门 21/0F/0E/0S,studio Vitest 19/0F/0E、ESLint/tsc 通过,MSW-off Playwright knowledge 6/0F/0E + ai-generation 1/0F/0E。
|
||||
- **关键风险 / TODO**:前端图谱/发布深面仍薄;Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;已物化 KB 的召回触达/检索停用已在 E4 闭环。**2026-06-27:E4 market KB 召回触达已补并真验**:Knowledge 新增 `KnowledgeMarketSourceStatusChangedConsumer` 消费 Market `MarketAssetSourceStatusChangedEvent`,将本地 installed_ref KB 的 source binding projection 更新为 `recalled/blocked`,检索来源状态门返回 `omittedSources` 而不再送入 RAGFlow;MSW-off Playwright `market-kb-recall.spec.ts` 1/0F/0E,后端单测 `MuseKnowledgeSourceEventServiceTest`+`KnowledgeMarketSourceStatusChangedConsumerTest` 9/0F/0E。副本资源回收/更细补偿仍后置。**2026-06-27:E1 旧知识草稿失效 owner API 已补**:`MuseKnowledgeDraftInvalidationApi` 由 knowledge-api 定义、knowledge-server owner 实现;Content 正文变更只传 work/chapter/block/revision/commandId,不跨 BC 传正文。Knowledge 将匹配的 pending draft 更新为 `conflicted` + `source_action_policy=needs_recheck`,并写 `muse_knowledge_draft_decision(decision_type=recheck)` 作为审计归档;invalid command 跳过并 warn,Content 通知失败不回滚 Canonical 主写,confirm 端仍按 `needs_recheck` fail-closed。验证:`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E;`P1rContentMergeSuggestionIT` real-PG 5/0F/0E/0S 穿透验证采纳后旧 draft 失效与 replay 不重复。**parse 轮询 worker 已补齐并端到端真验(2026-06-23,commit 635045c)**:`MuseKnowledgeParseStatusPollWorker`(@Scheduled + 开关 `muse.knowledge.parse-poll-worker.enabled`,跨租户捞 parsing 任务轮询 RAGFlow 文档状态→run=done 推 version processingStatus=searchable、failed 标败、processing 保持、RAGFlow 不可达/异常不误判)+ `ProcessingTaskService.markRagflowParseCompleted/markRagflowParsePolling` + `Mapper.selectParsingForPoll`;worker 接线 7 测试 + 回归 40 全绿;启用开关后 document 4 自动 parsing→searchable(isSearchable=true),摄入链 **scan→RAGFlow→worker→searchable** 全程贯通(此前"RAGFlow backend 故障"系本机 HTTP 代理误诊,RAGFlow 实始终正常,真因=muse 缺 parse 轮询 worker)。
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
package cn.iocoder.muse.module.knowledge.api;
|
||||
|
||||
/**
|
||||
* Knowledge 草稿失效对外 API(BC 对外契约)。
|
||||
*
|
||||
* <p>供 Content 在 Canonical 正文变更后通知 Knowledge:旧正文抽取出的待确认草稿不能继续作为
|
||||
* pending 候选让用户确认入库。调用方只传 ID、revision 与 commandId,不传正文或草稿内容,避免跨 BC 泄露。</p>
|
||||
*/
|
||||
public interface MuseKnowledgeDraftInvalidationApi {
|
||||
|
||||
/**
|
||||
* 标记指定 Content block 关联的 pending 知识草稿需要重新校验。
|
||||
*
|
||||
* @param command 失效命令
|
||||
* @return 失效结果
|
||||
*/
|
||||
InvalidationResult markContentBlockDraftsNeedRecheck(ContentBlockInvalidationCommand command);
|
||||
|
||||
/**
|
||||
* Content block 变更通知。
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param ownerUserId 作品拥有者
|
||||
* @param workId 作品 ID
|
||||
* @param chapterId 章节 ID,可为空
|
||||
* @param blockId Block ID
|
||||
* @param newBlockRevision 变更后的 Block revision
|
||||
* @param commandId Content 写命令幂等键
|
||||
* @param reason 失效原因
|
||||
*/
|
||||
record ContentBlockInvalidationCommand(Long tenantId, Long ownerUserId, Long workId, Long chapterId, Long blockId,
|
||||
Integer newBlockRevision, String commandId, String reason) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 失效结果。
|
||||
*
|
||||
* @param affectedDrafts 被标记为 conflicted/needs_recheck 的草稿数
|
||||
* @param skippedDrafts 因非 pending、已删除或来源不匹配而跳过的草稿数
|
||||
* @param status accepted / skipped / unavailable
|
||||
*/
|
||||
record InvalidationResult(int affectedDrafts, int skippedDrafts, String status) {
|
||||
|
||||
public static InvalidationResult accepted(int affectedDrafts, int skippedDrafts) {
|
||||
return new InvalidationResult(affectedDrafts, skippedDrafts, "accepted");
|
||||
}
|
||||
|
||||
public static InvalidationResult skipped() {
|
||||
return new InvalidationResult(0, 0, "skipped");
|
||||
}
|
||||
|
||||
public boolean accepted() {
|
||||
return "accepted".equals(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package cn.iocoder.muse.module.knowledge.api;
|
||||
|
||||
import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeDraftInvalidationService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* {@link MuseKnowledgeDraftInvalidationApi} 的 Knowledge owner 实现。
|
||||
*/
|
||||
@Service
|
||||
public class MuseKnowledgeDraftInvalidationApiImpl implements MuseKnowledgeDraftInvalidationApi {
|
||||
|
||||
@Resource
|
||||
private MuseKnowledgeDraftInvalidationService invalidationService;
|
||||
|
||||
@Override
|
||||
public InvalidationResult markContentBlockDraftsNeedRecheck(ContentBlockInvalidationCommand command) {
|
||||
return invalidationService.markContentBlockDraftsNeedRecheck(command);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.muse.module.market.api.asset.event.MarketAssetSourceStatusChangedEvent;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
|
||||
/**
|
||||
* Market 来源状态变更事件监听器。
|
||||
*
|
||||
* <p>召回/下架这类治理事实由 market 发布,Knowledge 只消费与 {@code knowledge_base} 有关的事件并更新本域
|
||||
* installed_ref 投影。监听放在 AFTER_COMMIT:只有 market 治理事务提交后才阻断检索,避免读取未提交资产状态。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class KnowledgeMarketSourceStatusChangedConsumer {
|
||||
|
||||
@Resource
|
||||
private MuseKnowledgeSourceEventService sourceEventService;
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void onMarketSourceStatusChanged(MarketAssetSourceStatusChangedEvent event) {
|
||||
if (event == null || event.tenantId() == null || event.assetId() == null) {
|
||||
log.warn("[onMarketSourceStatusChanged][Market 来源状态事件缺租户/资产,忽略,event={}]", event);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
MuseKnowledgeSourceEventService.MarketSourceStatusApplyResult result =
|
||||
TenantUtils.execute(event.tenantId(),
|
||||
() -> sourceEventService.applyMarketSourceStatusChanged(event));
|
||||
log.info("[onMarketSourceStatusChanged][Market 来源状态事件已消费,assetId={}, status={}, affectedKb={}, affectedProjection={}]",
|
||||
event.assetId(), event.sourceStatus(), result.affectedInstalledKbCount(),
|
||||
result.affectedProjectionCount());
|
||||
} catch (RuntimeException ex) {
|
||||
// 目标 owner 传播失败不能反向污染已提交的 market 治理事实;日志保留 commandId/assetId 供补偿重放。
|
||||
log.error("[onMarketSourceStatusChanged][Market 来源状态事件消费异常,assetId={}, commandId={}, errorType={}]",
|
||||
event.assetId(), event.commandId(), ex.getClass().getSimpleName(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeDraftInvalidationApi;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDraftDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDraftDecisionDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeDraftDecisionMapper;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeDraftMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Knowledge 草稿来源失效服务。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MuseKnowledgeDraftInvalidationService {
|
||||
|
||||
private static final String STATUS_ACCEPTED = "accepted";
|
||||
private static final String DECISION_RECHECK = "recheck";
|
||||
private static final String NEEDS_RECHECK = "needs_recheck";
|
||||
|
||||
@Resource
|
||||
private MuseKnowledgeDraftMapper draftMapper;
|
||||
@Resource
|
||||
private MuseKnowledgeDraftDecisionMapper decisionMapper;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MuseKnowledgeDraftInvalidationApi.InvalidationResult markContentBlockDraftsNeedRecheck(
|
||||
MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand command) {
|
||||
if (command == null || command.workId() == null || command.blockId() == null
|
||||
|| command.newBlockRevision() == null || !StringUtils.hasText(command.commandId())) {
|
||||
log.warn("Knowledge draft 失效跳过非法 Content 变更通知,workId={}, blockId={}, revision={}, commandId={}",
|
||||
command == null ? null : command.workId(), command == null ? null : command.blockId(),
|
||||
command == null ? null : command.newBlockRevision(),
|
||||
command == null ? null : command.commandId());
|
||||
return MuseKnowledgeDraftInvalidationApi.InvalidationResult.skipped();
|
||||
}
|
||||
Long tenantId = command.tenantId() == null ? TenantContextHolder.getTenantId() : command.tenantId();
|
||||
List<MuseKnowledgeDraftDO> pendingDrafts =
|
||||
draftMapper.selectPendingByContentSource(command.workId(), command.chapterId(), command.blockId());
|
||||
int affected = 0;
|
||||
int skipped = 0;
|
||||
for (MuseKnowledgeDraftDO draft : pendingDrafts) {
|
||||
String previousSourceSnapshotId = draft.getSourceSnapshotId();
|
||||
String sourceSnapshotId = sourceSnapshotId(command, draft);
|
||||
int updated = draftMapper.markNeedsRecheck(draft.getId(), sourceSnapshotId);
|
||||
if (updated <= 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
draft.setSourceSnapshotId(sourceSnapshotId);
|
||||
draft.setSourceActionPolicy(NEEDS_RECHECK);
|
||||
draft.setStatus("conflicted");
|
||||
insertInvalidationDecision(tenantId, command, draft, previousSourceSnapshotId);
|
||||
affected++;
|
||||
}
|
||||
log.info("Knowledge draft 失效完成,tenantId={}, workId={}, chapterId={}, blockId={}, revision={}, affected={}, skipped={}",
|
||||
tenantId, command.workId(), command.chapterId(), command.blockId(), command.newBlockRevision(), affected, skipped);
|
||||
return MuseKnowledgeDraftInvalidationApi.InvalidationResult.accepted(affected, skipped);
|
||||
}
|
||||
|
||||
private void insertInvalidationDecision(Long tenantId,
|
||||
MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand command,
|
||||
MuseKnowledgeDraftDO draft,
|
||||
String previousSourceSnapshotId) {
|
||||
MuseKnowledgeDraftDecisionDO decision = new MuseKnowledgeDraftDecisionDO();
|
||||
decision.setDecisionId("draft-invalidated-" + draft.getId() + "-" + UUID.randomUUID());
|
||||
decision.setDraftId(draft.getId());
|
||||
decision.setCommandId(invalidationCommandId(command.commandId(), draft.getId()));
|
||||
decision.setWorkId(draft.getWorkId());
|
||||
decision.setKbId(null);
|
||||
decision.setOwnerUserId(command.ownerUserId() == null ? 0L : command.ownerUserId());
|
||||
decision.setActorUserId(command.ownerUserId() == null ? 0L : command.ownerUserId());
|
||||
decision.setExpectedDraftRevision(draft.getRevision() == null ? 1 : draft.getRevision());
|
||||
decision.setSourceSnapshotId(nullToEmpty(draft.getSourceSnapshotId()));
|
||||
decision.setAuthorizationSnapshotId(nullToEmpty(draft.getAuthorizationSnapshotId()));
|
||||
decision.setSourceHash(sourceHash(draft));
|
||||
decision.setCanonicalEventId("content-block-revision-" + command.blockId() + "-" + command.newBlockRevision());
|
||||
// V14 约束只允许 confirm/ignore/recheck;Content 变更使草稿进入重验分支。
|
||||
decision.setDecisionType(DECISION_RECHECK);
|
||||
decision.setConfirmMode(null);
|
||||
decision.setRiskAcknowledgement("{}");
|
||||
decision.setStaleCheckStatus(NEEDS_RECHECK);
|
||||
decision.setAuthorizationCheckStatus("passed");
|
||||
decision.setStatus(STATUS_ACCEPTED);
|
||||
decision.setResultSummary(JsonUtils.toJsonString(invalidationSummary(command, draft, previousSourceSnapshotId)));
|
||||
decision.setTenantId(tenantId);
|
||||
decisionMapper.insert(decision);
|
||||
}
|
||||
|
||||
private String invalidationCommandId(String contentCommandId, Long draftId) {
|
||||
return "content-draft-invalidated:" + sha256Hex(contentCommandId + "|" + draftId).substring(0, 32);
|
||||
}
|
||||
|
||||
private String sourceSnapshotId(MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand command,
|
||||
MuseKnowledgeDraftDO draft) {
|
||||
// 列长为 VARCHAR(128),这里只写新的来源快照业务标识;旧快照进入 decision.resultSummary。
|
||||
return "content:block:" + command.blockId() + ":rev:" + command.newBlockRevision();
|
||||
}
|
||||
|
||||
private Map<String, Object> invalidationSummary(
|
||||
MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand command,
|
||||
MuseKnowledgeDraftDO draft,
|
||||
String previousSourceSnapshotId) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("reason", firstPresent(command.reason(), "content_block_changed"));
|
||||
summary.put("sourceOwner", "content");
|
||||
summary.put("sourceType", "content_block");
|
||||
summary.put("workId", command.workId());
|
||||
if (command.chapterId() != null) {
|
||||
summary.put("chapterId", command.chapterId());
|
||||
}
|
||||
summary.put("blockId", command.blockId());
|
||||
summary.put("newBlockRevision", command.newBlockRevision());
|
||||
summary.put("previousSourceSnapshotId", nullToEmpty(previousSourceSnapshotId));
|
||||
summary.put("newSourceSnapshotId", nullToEmpty(draft.getSourceSnapshotId()));
|
||||
summary.put("failClosed", true);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private String sourceHash(MuseKnowledgeDraftDO draft) {
|
||||
if (!StringUtils.hasText(draft.getDraftPayload())) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> payload = JsonUtils.parseObject(draft.getDraftPayload(), Map.class);
|
||||
if (payload != null && payload.get("sourceHash") != null) {
|
||||
String sourceHash = String.valueOf(payload.get("sourceHash"));
|
||||
return sourceHash.length() == 64 ? sourceHash : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String firstPresent(String value, String fallback) {
|
||||
return StringUtils.hasText(value) ? value : fallback;
|
||||
}
|
||||
|
||||
private String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private String sha256Hex(String raw) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(digest.length * 2);
|
||||
for (byte value : digest) {
|
||||
hex.append(String.format("%02x", value));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 algorithm is required", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.OmittedSource;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalRequest;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalResult;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievedChunk;
|
||||
import cn.iocoder.muse.module.knowledge.application.muse.facade.MuseKnowledgeWorkOwnerFacade;
|
||||
import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeRetrievalVO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户端作品知识检索应用服务。
|
||||
*
|
||||
* <p>职责只做 App 入口的作品归属校验与 VO 映射;真正的授权门、来源状态门、RAGFlow 调用继续收敛在
|
||||
* {@link MuseKnowledgeRetrievalApi} owner API 内,避免 UI 为了检索绕开后端可信边界。</p>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MuseKnowledgeRetrievalAppService {
|
||||
|
||||
@Resource
|
||||
private MuseKnowledgeWorkOwnerFacade workOwnerFacade;
|
||||
@Resource
|
||||
private MuseKnowledgeRetrievalApi retrievalApi;
|
||||
|
||||
/**
|
||||
* 检索当前作品已授权绑定的知识来源。
|
||||
*
|
||||
* @param loginUserId 当前登录用户
|
||||
* @param workId 作品 ID
|
||||
* @param reqVO 检索请求
|
||||
* @return 脱敏检索结果;空结果会带 omittedReason,供 Studio 给用户可见反馈
|
||||
*/
|
||||
public AppKnowledgeRetrievalVO.RetrievalRespVO retrieveForWork(Long loginUserId, Long workId,
|
||||
AppKnowledgeRetrievalVO.RetrieveReqVO reqVO) {
|
||||
// 可信边界:先校验作品归属,再读取该作品绑定来源;否则知道 workId 就能探测他人知识来源状态。
|
||||
workOwnerFacade.requireWorkOwner(workId, loginUserId);
|
||||
RetrievalResult result = retrievalApi.retrieveForWork(new RetrievalRequest(
|
||||
TenantContextHolder.getRequiredTenantId(), loginUserId, workId, reqVO.getQuestion(),
|
||||
reqVO.getTopK(), reqVO.getCommandId()));
|
||||
AppKnowledgeRetrievalVO.RetrievalRespVO respVO = toResp(result);
|
||||
log.info("[retrieveForWork][tenantId={}, userId={}, workId={}, commandId={}, status={}, omittedReason={}, chunkCount={}, omittedSourceCount={}]",
|
||||
TenantContextHolder.getRequiredTenantId(), loginUserId, workId, reqVO.getCommandId(),
|
||||
respVO.getStatus(), respVO.getOmittedReason(), respVO.getChunks().size(),
|
||||
respVO.getOmittedSources().size());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private AppKnowledgeRetrievalVO.RetrievalRespVO toResp(RetrievalResult result) {
|
||||
AppKnowledgeRetrievalVO.RetrievalRespVO respVO = new AppKnowledgeRetrievalVO.RetrievalRespVO();
|
||||
respVO.setStatus(result.status());
|
||||
respVO.setOmittedReason(result.omittedReason());
|
||||
respVO.setChunks(result.chunks().stream().map(this::toChunkVO).toList());
|
||||
respVO.setOmittedSources(result.omittedSources().stream().map(this::toOmittedSourceVO).toList());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
private AppKnowledgeRetrievalVO.ChunkVO toChunkVO(RetrievedChunk chunk) {
|
||||
AppKnowledgeRetrievalVO.ChunkVO vo = new AppKnowledgeRetrievalVO.ChunkVO();
|
||||
vo.setSourceKbId(chunk.sourceKbId() == null ? null : String.valueOf(chunk.sourceKbId()));
|
||||
vo.setDatasetId(chunk.datasetId());
|
||||
vo.setDocumentId(chunk.documentId());
|
||||
vo.setContentSummary(chunk.contentSummary());
|
||||
vo.setSimilarity(chunk.similarity());
|
||||
vo.setSourceOwner(chunk.sourceOwner());
|
||||
vo.setSourceObjectVersion(chunk.sourceObjectVersion());
|
||||
vo.setAuthorizationSnapshotId(chunk.authorizationSnapshotId());
|
||||
vo.setSourceStatus(chunk.sourceStatus());
|
||||
vo.setAllowedPurpose(chunk.allowedPurpose());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AppKnowledgeRetrievalVO.OmittedSourceVO toOmittedSourceVO(OmittedSource omittedSource) {
|
||||
AppKnowledgeRetrievalVO.OmittedSourceVO vo = new AppKnowledgeRetrievalVO.OmittedSourceVO();
|
||||
vo.setKbId(omittedSource.kbId() == null ? null : String.valueOf(omittedSource.kbId()));
|
||||
vo.setReason(omittedSource.reason());
|
||||
vo.setSourceStatus(omittedSource.sourceStatus());
|
||||
return vo;
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,6 +5,7 @@ import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.market.api.asset.event.MarketAssetSourceStatusChangedEvent;
|
||||
import cn.iocoder.muse.module.knowledge.controller.admin.muse.vo.AdminKnowledgeTaskVO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBaseDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeCommandDO;
|
||||
@ -20,6 +21,7 @@ import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@ -38,6 +40,8 @@ public class MuseKnowledgeSourceEventService {
|
||||
|
||||
private static final Long GLOBAL_OWNER_USER_ID = 0L;
|
||||
private static final String KB_TYPE_GLOBAL = "global";
|
||||
private static final String KB_TYPE_INSTALLED_REF = "installed_ref";
|
||||
private static final String MARKET_ASSET_TYPE_KB = "knowledge_base";
|
||||
private static final int MAX_PAGE_SIZE = 200;
|
||||
private static final Set<String> SOURCE_STATUSES = Set.of(
|
||||
"active", "stale", "revoked", "recalled", "delisted", "blocked", "owner_missing", "unauthorized");
|
||||
@ -93,6 +97,44 @@ public class MuseKnowledgeSourceEventService {
|
||||
return respVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费 market 资产来源状态变更事件,把已物化 installed_ref KB 的投影阻断。
|
||||
*
|
||||
* <p>Market 只发布治理事实,不直写 Knowledge 投影。Knowledge 在本方法内查询自己的 installed_ref KB,并把对应
|
||||
* source_binding_projection.status 置为 recalled/blocked;检索服务已有 blocked status 门,会据此停止召回内容。</p>
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MarketSourceStatusApplyResult applyMarketSourceStatusChanged(MarketAssetSourceStatusChangedEvent event) {
|
||||
if (event == null || event.assetId() == null || !MARKET_ASSET_TYPE_KB.equals(event.assetType())) {
|
||||
return new MarketSourceStatusApplyResult(0, 0);
|
||||
}
|
||||
String sourceStatus = normalizeMarketSourceStatus(event.sourceStatus());
|
||||
String actionPolicy = normalizeMarketActionPolicy(event.actionPolicy());
|
||||
List<MuseKnowledgeBaseDO> installedRefs =
|
||||
knowledgeBaseMapper.selectInstalledRefsByMarketAssetId(event.assetId());
|
||||
int affectedProjectionCount = 0;
|
||||
for (MuseKnowledgeBaseDO kb : installedRefs) {
|
||||
String sourceEventId = marketSourceEventId(event, kb);
|
||||
MuseKnowledgeSourceEventDO sourceEvent = sourceEventMapper.selectBySourceEventId(sourceEventId);
|
||||
boolean inserted = false;
|
||||
if (sourceEvent == null) {
|
||||
sourceEvent = createMarketSourceStatusEvent(event, kb, sourceEventId, sourceStatus, actionPolicy);
|
||||
sourceEventMapper.insert(sourceEvent);
|
||||
inserted = true;
|
||||
}
|
||||
int affected = sourceBindingProjectionMapper.updateStatusByKbId(kb.getId(), sourceStatus,
|
||||
actionPolicy, sourceEventId, event.sourceRevision(), marketProjectionSummaryPatch(event,
|
||||
sourceEventId, sourceStatus, actionPolicy));
|
||||
affectedProjectionCount += affected;
|
||||
if (inserted) {
|
||||
MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary ownerFanoutSummary =
|
||||
eventPublishOutboxService.createForSourceEvent(sourceEvent);
|
||||
createMarketProjectionTask(sourceEvent, kb, event, affected, ownerFanoutSummary);
|
||||
}
|
||||
}
|
||||
return new MarketSourceStatusApplyResult(installedRefs.size(), affectedProjectionCount);
|
||||
}
|
||||
|
||||
public AdminKnowledgeTaskVO.PageResultVO<AdminKnowledgeTaskVO.SourceBindingSummaryRespVO> listSourceBindings(
|
||||
Integer pageNo, Integer pageSize, String sourceType, String sourceStatus, String targetOwner) {
|
||||
requireSourceBindingFilters(sourceType, sourceStatus, targetOwner);
|
||||
@ -125,6 +167,28 @@ public class MuseKnowledgeSourceEventService {
|
||||
return event;
|
||||
}
|
||||
|
||||
private MuseKnowledgeSourceEventDO createMarketSourceStatusEvent(MarketAssetSourceStatusChangedEvent event,
|
||||
MuseKnowledgeBaseDO kb,
|
||||
String sourceEventId,
|
||||
String sourceStatus,
|
||||
String actionPolicy) {
|
||||
MuseKnowledgeSourceEventDO sourceEvent = new MuseKnowledgeSourceEventDO();
|
||||
sourceEvent.setSourceEventId(sourceEventId);
|
||||
sourceEvent.setSourceOwner("market");
|
||||
sourceEvent.setSourceType("market_kb");
|
||||
sourceEvent.setSourceId(String.valueOf(event.assetId()));
|
||||
sourceEvent.setSourceRevision(event.sourceRevision());
|
||||
sourceEvent.setOwnerUserId(kb.getOwnerUserId());
|
||||
sourceEvent.setKbId(kb.getId());
|
||||
sourceEvent.setStatus(sourceStatus);
|
||||
sourceEvent.setActionPolicy(actionPolicy);
|
||||
sourceEvent.setCommandId(event.commandId());
|
||||
sourceEvent.setEventSummary(JsonUtils.toJsonString(marketSourceEventSummary(event, kb, sourceStatus,
|
||||
actionPolicy)));
|
||||
sourceEvent.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
return sourceEvent;
|
||||
}
|
||||
|
||||
private void createProjectionTask(MuseKnowledgeSourceEventDO event, MuseKnowledgeBaseDO kb,
|
||||
AdminKnowledgeTaskVO.SourceEventReqVO reqVO, int affectedTargets,
|
||||
MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary ownerFanoutSummary) {
|
||||
@ -149,6 +213,32 @@ public class MuseKnowledgeSourceEventService {
|
||||
projectionTaskMapper.insert(task);
|
||||
}
|
||||
|
||||
private void createMarketProjectionTask(MuseKnowledgeSourceEventDO event, MuseKnowledgeBaseDO kb,
|
||||
MarketAssetSourceStatusChangedEvent sourceEvent,
|
||||
int affectedTargets,
|
||||
MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary
|
||||
ownerFanoutSummary) {
|
||||
MuseKnowledgeProjectionTaskDO task = new MuseKnowledgeProjectionTaskDO();
|
||||
task.setTaskId("projection-task-" + event.getSourceEventId());
|
||||
task.setSourceEventId(event.getSourceEventId());
|
||||
task.setSourceOwner(event.getSourceOwner());
|
||||
task.setSourceType(event.getSourceType());
|
||||
task.setSourceId(event.getSourceId());
|
||||
task.setOwnerUserId(event.getOwnerUserId());
|
||||
task.setKbId(kb.getId());
|
||||
task.setStatus("completed");
|
||||
task.setAttemptNo(1);
|
||||
task.setRetryCount(0);
|
||||
task.setRetryable(false);
|
||||
task.setStartedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setResultSummary(JsonUtils.toJsonString(marketProjectionTaskSummary(event, kb, sourceEvent,
|
||||
affectedTargets,
|
||||
ownerFanoutSummary)));
|
||||
task.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
projectionTaskMapper.insert(task);
|
||||
}
|
||||
|
||||
private AdminKnowledgeTaskVO.SourceBindingSummaryRespVO toSourceBindingSummary(
|
||||
MuseKnowledgeSourceBindingProjectionDO projection) {
|
||||
Map<String, Object> summary = readJson(projection.getProjectionSummary());
|
||||
@ -208,6 +298,50 @@ public class MuseKnowledgeSourceEventService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Map<String, Object> marketSourceEventSummary(MarketAssetSourceStatusChangedEvent event,
|
||||
MuseKnowledgeBaseDO kb,
|
||||
String sourceStatus,
|
||||
String actionPolicy) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("eventType", "market_source_status_changed");
|
||||
summary.put("assetId", event.assetId());
|
||||
summary.put("assetType", event.assetType());
|
||||
summary.put("publisherKbId", event.sourceId());
|
||||
summary.put("publisherUserId", event.publisherUserId());
|
||||
summary.put("reason", event.reason());
|
||||
summary.put("sourceStatus", sourceStatus);
|
||||
summary.put("actionPolicy", actionPolicy);
|
||||
summary.put("installedRefKbId", kb.getId());
|
||||
summary.put("installedRefOwnerUserId", kb.getOwnerUserId());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Map<String, Object> marketProjectionTaskSummary(MuseKnowledgeSourceEventDO event,
|
||||
MuseKnowledgeBaseDO kb,
|
||||
MarketAssetSourceStatusChangedEvent sourceEvent,
|
||||
int affectedTargets,
|
||||
MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary
|
||||
ownerFanoutSummary) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("eventType", "market_source_status_changed");
|
||||
summary.put("assetId", sourceEvent.assetId());
|
||||
summary.put("sourceStatus", event.getStatus());
|
||||
summary.put("actionPolicy", event.getActionPolicy());
|
||||
summary.put("lastEventId", event.getSourceEventId());
|
||||
summary.put("installedRefKbId", kb.getId());
|
||||
summary.put("affectedTargets", affectedTargets);
|
||||
summary.put("affectedProjectionCount", affectedTargets);
|
||||
if (ownerFanoutSummary != null) {
|
||||
summary.put("ownerFanoutSummary", Map.of(
|
||||
"totalProjectionCount", ownerFanoutSummary.totalProjectionCount(),
|
||||
"validOwnerProjectionCount", ownerFanoutSummary.validOwnerProjectionCount(),
|
||||
"invalidOwnerProjectionCount", ownerFanoutSummary.invalidOwnerProjectionCount(),
|
||||
"distinctTargetOwnerCount", ownerFanoutSummary.distinctTargetOwnerCount(),
|
||||
"queuedOutboxCount", ownerFanoutSummary.queuedOutboxCount()));
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private String projectionSummaryPatch(AdminKnowledgeTaskVO.SourceEventReqVO reqVO,
|
||||
MuseKnowledgeSourceEventDO event) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
@ -219,6 +353,32 @@ public class MuseKnowledgeSourceEventService {
|
||||
return JsonUtils.toJsonString(summary);
|
||||
}
|
||||
|
||||
private String marketProjectionSummaryPatch(MarketAssetSourceStatusChangedEvent event, String sourceEventId,
|
||||
String sourceStatus, String actionPolicy) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("lastEventId", sourceEventId);
|
||||
summary.put("eventType", "market_source_status_changed");
|
||||
summary.put("sourceStatus", sourceStatus);
|
||||
summary.put("actionPolicy", actionPolicy);
|
||||
summary.put("marketAssetId", event.assetId());
|
||||
summary.put("reason", event.reason());
|
||||
if (event.sourceRevision() != null && !event.sourceRevision().isBlank()) {
|
||||
summary.put("sourceVersion", event.sourceRevision());
|
||||
}
|
||||
return JsonUtils.toJsonString(summary);
|
||||
}
|
||||
|
||||
private String marketSourceEventId(MarketAssetSourceStatusChangedEvent event, MuseKnowledgeBaseDO kb) {
|
||||
String command = event.commandId() == null || event.commandId().isBlank()
|
||||
? "no-command" : event.commandId().replaceAll("[^A-Za-z0-9_.:-]", "_");
|
||||
String candidate = "market-source-" + event.assetId() + "-" + kb.getId() + "-" + command;
|
||||
if (candidate.length() <= 128) {
|
||||
return candidate;
|
||||
}
|
||||
return "market-source-" + event.assetId() + "-" + kb.getId() + "-"
|
||||
+ UUID.nameUUIDFromBytes(command.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String normalizeActionPolicy(String actionPolicy, String eventType) {
|
||||
if (actionPolicy != null && !actionPolicy.isBlank()) {
|
||||
if (!ACTION_POLICIES.contains(actionPolicy)) {
|
||||
@ -235,6 +395,21 @@ public class MuseKnowledgeSourceEventService {
|
||||
return "allowed";
|
||||
}
|
||||
|
||||
private String normalizeMarketSourceStatus(String sourceStatus) {
|
||||
if ("recalled".equals(sourceStatus) || "delisted".equals(sourceStatus) || "revoked".equals(sourceStatus)) {
|
||||
return sourceStatus;
|
||||
}
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
private String normalizeMarketActionPolicy(String actionPolicy) {
|
||||
if ("blocked".equals(actionPolicy) || "needs_recheck".equals(actionPolicy)
|
||||
|| "read_only".equals(actionPolicy) || "allowed".equals(actionPolicy)) {
|
||||
return actionPolicy;
|
||||
}
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
private String statusFromEvent(String eventType, String actionPolicy) {
|
||||
if ("blocked".equals(actionPolicy) || "kb_disabled".equals(eventType)) {
|
||||
return "blocked";
|
||||
@ -356,4 +531,7 @@ public class MuseKnowledgeSourceEventService {
|
||||
return Integer.parseInt(value);
|
||||
}
|
||||
|
||||
public record MarketSourceStatusApplyResult(int affectedInstalledKbCount, int affectedProjectionCount) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
package cn.iocoder.muse.module.knowledge.controller.app.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeRetrievalAppService;
|
||||
import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeRetrievalVO;
|
||||
import cn.iocoder.muse.module.knowledge.domain.muse.MuseKnowledgeApiVersionGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static cn.iocoder.muse.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 用户 APP - Muse Knowledge Retrieval API。
|
||||
*/
|
||||
@Tag(name = "用户 APP - Muse Knowledge Retrieval")
|
||||
@RestController
|
||||
@RequestMapping("/muse/works/{workId}/knowledge-retrievals")
|
||||
@Validated
|
||||
public class AppMuseKnowledgeRetrievalController {
|
||||
|
||||
@Resource
|
||||
private MuseKnowledgeRetrievalAppService retrievalAppService;
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "检索作品已绑定知识来源")
|
||||
public CommonResult<AppKnowledgeRetrievalVO.RetrievalRespVO> retrieveKnowledge(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long workId,
|
||||
@Valid @RequestBody AppKnowledgeRetrievalVO.RetrieveReqVO reqVO) {
|
||||
MuseKnowledgeApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(retrievalAppService.retrieveForWork(SecurityFrameworkUtils.getLoginUserId(), workId, reqVO));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package cn.iocoder.muse.module.knowledge.controller.app.muse.vo;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* App Knowledge Retrieval OpenAPI DTO。
|
||||
*/
|
||||
public final class AppKnowledgeRetrievalVO {
|
||||
|
||||
private AppKnowledgeRetrievalVO() {
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RetrieveReqVO {
|
||||
@NotBlank(message = "commandId 不能为空")
|
||||
private String commandId;
|
||||
@NotBlank(message = "question 不能为空")
|
||||
@Size(max = 500, message = "question 不能超过 500 个字符")
|
||||
private String question;
|
||||
@Min(value = 1, message = "topK 最小为 1")
|
||||
@Max(value = 20, message = "topK 最大为 20")
|
||||
private Integer topK;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RetrievalRespVO {
|
||||
private String status;
|
||||
private String omittedReason;
|
||||
private List<ChunkVO> chunks;
|
||||
private List<OmittedSourceVO> omittedSources;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ChunkVO {
|
||||
private String sourceKbId;
|
||||
private String datasetId;
|
||||
private String documentId;
|
||||
private String contentSummary;
|
||||
private Double similarity;
|
||||
private String sourceOwner;
|
||||
private String sourceObjectVersion;
|
||||
private String authorizationSnapshotId;
|
||||
private String sourceStatus;
|
||||
private String allowedPurpose;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class OmittedSourceVO {
|
||||
private String kbId;
|
||||
private String reason;
|
||||
private String sourceStatus;
|
||||
}
|
||||
|
||||
}
|
||||
@ -7,6 +7,8 @@ import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBaseDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Muse 知识库 Mapper。
|
||||
*/
|
||||
@ -39,6 +41,15 @@ public interface MuseKnowledgeBaseMapper extends BaseMapperX<MuseKnowledgeBaseDO
|
||||
return selectPage(pageParam, wrapper);
|
||||
}
|
||||
|
||||
default List<MuseKnowledgeBaseDO> selectInstalledRefsByMarketAssetId(Long sourceMarketAssetId) {
|
||||
return selectList(new LambdaQueryWrapperX<MuseKnowledgeBaseDO>()
|
||||
.eq(MuseKnowledgeBaseDO::getKbType, "installed_ref")
|
||||
.eq(MuseKnowledgeBaseDO::getSourceMarketAssetId, sourceMarketAssetId)
|
||||
.eq(MuseKnowledgeBaseDO::getDeleted, false)
|
||||
.orderByAsc(MuseKnowledgeBaseDO::getOwnerUserId)
|
||||
.orderByAsc(MuseKnowledgeBaseDO::getId));
|
||||
}
|
||||
|
||||
private static void applyAuthorizationStatusFilter(LambdaQueryWrapperX<MuseKnowledgeBaseDO> wrapper,
|
||||
String authorizationStatus) {
|
||||
if (authorizationStatus == null || authorizationStatus.isBlank()) {
|
||||
|
||||
@ -5,6 +5,7 @@ import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDraftDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
@ -36,6 +37,51 @@ public interface MuseKnowledgeDraftMapper extends BaseMapperX<MuseKnowledgeDraft
|
||||
.orderByDesc(MuseKnowledgeDraftDO::getUpdateTime));
|
||||
}
|
||||
|
||||
default List<MuseKnowledgeDraftDO> selectPendingByContentSource(Long workId, Long chapterId, Long blockId) {
|
||||
if (workId == null || (chapterId == null && blockId == null)) {
|
||||
return List.of();
|
||||
}
|
||||
LambdaQueryWrapperX<MuseKnowledgeDraftDO> wrapper = new LambdaQueryWrapperX<MuseKnowledgeDraftDO>()
|
||||
.eq(MuseKnowledgeDraftDO::getWorkId, workId)
|
||||
.eq(MuseKnowledgeDraftDO::getStatus, "pending")
|
||||
.eq(MuseKnowledgeDraftDO::getDeleted, false);
|
||||
wrapper.and(source -> {
|
||||
if (blockId != null) {
|
||||
source.and(item -> item.eq(MuseKnowledgeDraftDO::getSourceType, "content_block")
|
||||
.eq(MuseKnowledgeDraftDO::getSourceId, blockId))
|
||||
.or(item -> item.eq(MuseKnowledgeDraftDO::getSourceType, "block")
|
||||
.eq(MuseKnowledgeDraftDO::getSourceId, blockId))
|
||||
// sourceRef 存在于 draft_payload JSON 中;不新增列,兼容旧抽取草稿的来源格式。
|
||||
.or(item -> item.apply("draft_payload ->> 'sourceRef' LIKE {0}", "block:" + blockId + "%"));
|
||||
}
|
||||
if (chapterId != null) {
|
||||
if (blockId == null) {
|
||||
source.and(item -> item.eq(MuseKnowledgeDraftDO::getSourceType, "chapter")
|
||||
.eq(MuseKnowledgeDraftDO::getSourceId, chapterId));
|
||||
} else {
|
||||
source.or(item -> item.eq(MuseKnowledgeDraftDO::getSourceType, "chapter")
|
||||
.eq(MuseKnowledgeDraftDO::getSourceId, chapterId));
|
||||
}
|
||||
source.or(item -> item.eq(MuseKnowledgeDraftDO::getSourceType, "extraction")
|
||||
.eq(MuseKnowledgeDraftDO::getSourceId, chapterId))
|
||||
.or(item -> item.apply("draft_payload ->> 'sourceRef' LIKE {0}", "chapter:" + chapterId + "%"));
|
||||
}
|
||||
});
|
||||
return selectList(wrapper);
|
||||
}
|
||||
|
||||
default int markNeedsRecheck(Long draftId, String sourceSnapshotId) {
|
||||
MuseKnowledgeDraftDO update = new MuseKnowledgeDraftDO();
|
||||
update.setStatus("conflicted");
|
||||
update.setSourceActionPolicy("needs_recheck");
|
||||
update.setSourceStatus("active");
|
||||
update.setSourceSnapshotId(sourceSnapshotId);
|
||||
return update(update, new LambdaUpdateWrapper<MuseKnowledgeDraftDO>()
|
||||
.eq(MuseKnowledgeDraftDO::getId, draftId)
|
||||
.eq(MuseKnowledgeDraftDO::getStatus, "pending")
|
||||
.eq(MuseKnowledgeDraftDO::getDeleted, false));
|
||||
}
|
||||
|
||||
private static LambdaQueryWrapperX<MuseKnowledgeDraftDO> baseListWrapper(String status, String entityType) {
|
||||
LambdaQueryWrapperX<MuseKnowledgeDraftDO> wrapper = new LambdaQueryWrapperX<MuseKnowledgeDraftDO>()
|
||||
.eqIfPresent(MuseKnowledgeDraftDO::getStatus, status)
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.market.api.asset.event.MarketAssetSourceStatusChangedEvent;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Market 来源状态变更事件监听器测试。
|
||||
*/
|
||||
class KnowledgeMarketSourceStatusChangedConsumerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private KnowledgeMarketSourceStatusChangedConsumer consumer;
|
||||
@Mock
|
||||
private MuseKnowledgeSourceEventService sourceEventService;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_restoreTenantAndApplyMarketSourceStatus() {
|
||||
doAnswer(invocation -> {
|
||||
assertEquals(100L, TenantContextHolder.getTenantId());
|
||||
return new MuseKnowledgeSourceEventService.MarketSourceStatusApplyResult(1, 2);
|
||||
}).when(sourceEventService).applyMarketSourceStatusChanged(any());
|
||||
|
||||
consumer.onMarketSourceStatusChanged(new MarketAssetSourceStatusChangedEvent(100L, 8001L,
|
||||
"knowledge_base", 4001L, 9001L, "recalled", "blocked", "cmd-recall", "安全召回", "2"));
|
||||
|
||||
verify(sourceEventService).applyMarketSourceStatusChanged(argThat(event ->
|
||||
Long.valueOf(8001L).equals(event.assetId())
|
||||
&& "knowledge_base".equals(event.assetType())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_ignoreMissingTenantOrAsset() {
|
||||
consumer.onMarketSourceStatusChanged(null);
|
||||
consumer.onMarketSourceStatusChanged(new MarketAssetSourceStatusChangedEvent(null, 8001L,
|
||||
"knowledge_base", 4001L, 9001L, "recalled", "blocked", "cmd-recall", "安全召回", "2"));
|
||||
consumer.onMarketSourceStatusChanged(new MarketAssetSourceStatusChangedEvent(100L, null,
|
||||
"knowledge_base", 4001L, 9001L, "recalled", "blocked", "cmd-recall", "安全召回", "2"));
|
||||
|
||||
verify(sourceEventService, never()).applyMarketSourceStatusChanged(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_isolateApplyError() {
|
||||
when(sourceEventService.applyMarketSourceStatusChanged(any())).thenThrow(new IllegalStateException("db down"));
|
||||
|
||||
consumer.onMarketSourceStatusChanged(new MarketAssetSourceStatusChangedEvent(100L, 8001L,
|
||||
"knowledge_base", 4001L, 9001L, "recalled", "blocked", "cmd-recall", "安全召回", "2"));
|
||||
|
||||
verify(sourceEventService).applyMarketSourceStatusChanged(any());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeDraftInvalidationApi;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDraftDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDraftDecisionDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeDraftDecisionMapper;
|
||||
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeDraftMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Knowledge 草稿失效服务测试。
|
||||
*/
|
||||
class MuseKnowledgeDraftInvalidationServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MuseKnowledgeDraftInvalidationService invalidationService;
|
||||
@Mock
|
||||
private MuseKnowledgeDraftMapper draftMapper;
|
||||
@Mock
|
||||
private MuseKnowledgeDraftDecisionMapper decisionMapper;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_markPendingContentDraftNeedsRecheckAndWriteDecision() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
MuseKnowledgeDraftDO draft = draft(5001L);
|
||||
when(draftMapper.selectPendingByContentSource(9001L, 8001L, 3001L)).thenReturn(List.of(draft));
|
||||
when(draftMapper.markNeedsRecheck(5001L, "content:block:3001:rev:4")).thenReturn(1);
|
||||
|
||||
MuseKnowledgeDraftInvalidationApi.InvalidationResult result =
|
||||
invalidationService.markContentBlockDraftsNeedRecheck(command());
|
||||
|
||||
assertEquals(1, result.affectedDrafts());
|
||||
assertEquals(0, result.skippedDrafts());
|
||||
assertTrue(result.accepted());
|
||||
verify(draftMapper).selectPendingByContentSource(9001L, 8001L, 3001L);
|
||||
verify(draftMapper).markNeedsRecheck(5001L, "content:block:3001:rev:4");
|
||||
verify(decisionMapper).insert(argThat((MuseKnowledgeDraftDecisionDO decision) ->
|
||||
Long.valueOf(5001L).equals(decision.getDraftId())
|
||||
&& decision.getCommandId().startsWith("content-draft-invalidated:")
|
||||
&& decision.getCommandId().length() <= 128
|
||||
&& "recheck".equals(decision.getDecisionType())
|
||||
&& "needs_recheck".equals(decision.getStaleCheckStatus())
|
||||
&& "accepted".equals(decision.getStatus())
|
||||
&& decision.getResultSummary().contains("\"reason\":\"content_suggestion_merged\"")
|
||||
&& decision.getResultSummary().contains("\"previousSourceSnapshotId\":\"src-old\"")
|
||||
&& Long.valueOf(100L).equals(decision.getTenantId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_skipInvalidCommandWithoutWriting() {
|
||||
MuseKnowledgeDraftInvalidationApi.InvalidationResult result =
|
||||
invalidationService.markContentBlockDraftsNeedRecheck(
|
||||
new MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand(
|
||||
100L, 9001L, null, 8001L, 3001L, 4, "", "content_block_saved"));
|
||||
|
||||
assertEquals(0, result.affectedDrafts());
|
||||
assertEquals("skipped", result.status());
|
||||
verify(draftMapper, never()).selectPendingByContentSource(any(), any(), any());
|
||||
verify(decisionMapper, never()).insert(any(MuseKnowledgeDraftDecisionDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_countSkippedWhenConcurrentDraftAlreadyMovedOutOfPending() {
|
||||
MuseKnowledgeDraftDO draft = draft(5001L);
|
||||
when(draftMapper.selectPendingByContentSource(9001L, 8001L, 3001L)).thenReturn(List.of(draft));
|
||||
when(draftMapper.markNeedsRecheck(5001L, "content:block:3001:rev:4")).thenReturn(0);
|
||||
|
||||
MuseKnowledgeDraftInvalidationApi.InvalidationResult result =
|
||||
invalidationService.markContentBlockDraftsNeedRecheck(command());
|
||||
|
||||
assertEquals(0, result.affectedDrafts());
|
||||
assertEquals(1, result.skippedDrafts());
|
||||
verify(decisionMapper, never()).insert(any(MuseKnowledgeDraftDecisionDO.class));
|
||||
}
|
||||
|
||||
private static MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand command() {
|
||||
return new MuseKnowledgeDraftInvalidationApi.ContentBlockInvalidationCommand(
|
||||
100L, 9001L, 9001L, 8001L, 3001L, 4, "cmd-merge-block", "content_suggestion_merged");
|
||||
}
|
||||
|
||||
private static MuseKnowledgeDraftDO draft(Long id) {
|
||||
MuseKnowledgeDraftDO draft = new MuseKnowledgeDraftDO();
|
||||
draft.setId(id);
|
||||
draft.setWorkId(9001L);
|
||||
draft.setDraftPayload(JsonUtils.toJsonString(Map.of(
|
||||
"sourceRef", "chapter:8001@3",
|
||||
"sourceHash", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")));
|
||||
draft.setSourceSnapshotId("src-old");
|
||||
draft.setAuthorizationSnapshotId("auth-old");
|
||||
draft.setSourceType("extraction");
|
||||
draft.setSourceId(8001L);
|
||||
draft.setStatus("pending");
|
||||
draft.setRevision(2);
|
||||
return draft;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
package cn.iocoder.muse.module.knowledge.application.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.OmittedSource;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalRequest;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalResult;
|
||||
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievedChunk;
|
||||
import cn.iocoder.muse.module.knowledge.application.muse.facade.MuseKnowledgeWorkOwnerFacade;
|
||||
import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeRetrievalVO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_RESOURCE_FORBIDDEN;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 用户端知识检索应用服务测试。
|
||||
*/
|
||||
class MuseKnowledgeRetrievalAppServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MuseKnowledgeRetrievalAppService retrievalAppService;
|
||||
@Mock
|
||||
private MuseKnowledgeWorkOwnerFacade workOwnerFacade;
|
||||
@Mock
|
||||
private MuseKnowledgeRetrievalApi retrievalApi;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_failClosedBeforeRetrieval_whenWorkOwnerForbidden() {
|
||||
doThrow(new ServiceException(KNOWLEDGE_RESOURCE_FORBIDDEN)).when(workOwnerFacade)
|
||||
.requireWorkOwner(9001L, 2002L);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> retrievalAppService.retrieveForWork(2002L, 9001L, req()));
|
||||
|
||||
assertEquals(KNOWLEDGE_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
|
||||
verify(retrievalApi, never()).retrieveForWork(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_mapNoDatasetAsUserVisibleOmittedReason() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
when(retrievalApi.retrieveForWork(any())).thenReturn(RetrievalResult.empty("no_dataset",
|
||||
List.of(new OmittedSource(5001L, "no_dataset", "active"))));
|
||||
|
||||
AppKnowledgeRetrievalVO.RetrievalRespVO respVO =
|
||||
retrievalAppService.retrieveForWork(2001L, 9001L, req());
|
||||
|
||||
assertEquals("empty", respVO.getStatus());
|
||||
assertEquals("no_dataset", respVO.getOmittedReason());
|
||||
assertEquals("5001", respVO.getOmittedSources().get(0).getKbId());
|
||||
assertEquals("no_dataset", respVO.getOmittedSources().get(0).getReason());
|
||||
ArgumentCaptor<RetrievalRequest> requestCaptor = ArgumentCaptor.forClass(RetrievalRequest.class);
|
||||
verify(retrievalApi).retrieveForWork(requestCaptor.capture());
|
||||
assertEquals(100L, requestCaptor.getValue().tenantId());
|
||||
assertEquals(2001L, requestCaptor.getValue().ownerUserId());
|
||||
assertEquals(9001L, requestCaptor.getValue().workId());
|
||||
assertEquals("cmd-retrieve", requestCaptor.getValue().correlationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_mapChunksWithoutLeakingRawRuntimePayload() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
when(retrievalApi.retrieveForWork(any())).thenReturn(RetrievalResult.ok(List.of(
|
||||
new RetrievedChunk(5001L, "ds-1", "doc-1", "可展示的脱敏片段", 0.88,
|
||||
"user", "3", "auth-1", "active", "search,generate")
|
||||
), List.of()));
|
||||
|
||||
AppKnowledgeRetrievalVO.RetrievalRespVO respVO =
|
||||
retrievalAppService.retrieveForWork(2001L, 9001L, req());
|
||||
|
||||
assertEquals("ok", respVO.getStatus());
|
||||
assertEquals(1, respVO.getChunks().size());
|
||||
assertEquals("5001", respVO.getChunks().get(0).getSourceKbId());
|
||||
assertEquals("可展示的脱敏片段", respVO.getChunks().get(0).getContentSummary());
|
||||
assertEquals("auth-1", respVO.getChunks().get(0).getAuthorizationSnapshotId());
|
||||
}
|
||||
|
||||
private AppKnowledgeRetrievalVO.RetrieveReqVO req() {
|
||||
AppKnowledgeRetrievalVO.RetrieveReqVO reqVO = new AppKnowledgeRetrievalVO.RetrieveReqVO();
|
||||
reqVO.setCommandId("cmd-retrieve");
|
||||
reqVO.setQuestion("检索主角设定");
|
||||
reqVO.setTopK(3);
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
}
|
||||
@ -4,6 +4,7 @@ import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.module.market.api.asset.event.MarketAssetSourceStatusChangedEvent;
|
||||
import cn.iocoder.muse.module.knowledge.controller.admin.muse.vo.AdminKnowledgeTaskVO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBaseDO;
|
||||
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeProjectionTaskDO;
|
||||
@ -137,6 +138,73 @@ class MuseKnowledgeSourceEventServiceTest extends BaseMockitoUnitTest {
|
||||
assertEquals("content", page.getList().get(1).getTargetOwner());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_applyMarketRecallToInstalledRefProjection() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
MuseKnowledgeBaseDO installedRef = installedRefKb(4101L, 2001L, 8001L);
|
||||
when(knowledgeBaseMapper.selectInstalledRefsByMarketAssetId(8001L)).thenReturn(List.of(installedRef));
|
||||
when(sourceEventMapper.selectBySourceEventId("market-source-8001-4101-cmd-recall")).thenReturn(null);
|
||||
when(sourceEventMapper.insert(any(MuseKnowledgeSourceEventDO.class))).thenReturn(1);
|
||||
when(sourceBindingProjectionMapper.updateStatusByKbId(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(2);
|
||||
when(projectionTaskMapper.insert(any(MuseKnowledgeProjectionTaskDO.class))).thenReturn(1);
|
||||
when(eventPublishOutboxService.createForSourceEvent(any(MuseKnowledgeSourceEventDO.class)))
|
||||
.thenReturn(new MuseKnowledgeEventPublishOutboxService.OwnerFanoutSummary(2L, 2L, 0L, 1L, 1));
|
||||
|
||||
MuseKnowledgeSourceEventService.MarketSourceStatusApplyResult result =
|
||||
sourceEventService.applyMarketSourceStatusChanged(new MarketAssetSourceStatusChangedEvent(100L,
|
||||
8001L, "knowledge_base", 4001L, 9001L, "recalled", "blocked",
|
||||
"cmd-recall", "安全召回", "2"));
|
||||
|
||||
assertEquals(1, result.affectedInstalledKbCount());
|
||||
assertEquals(2, result.affectedProjectionCount());
|
||||
verify(sourceEventMapper).insert(org.mockito.ArgumentMatchers.<MuseKnowledgeSourceEventDO>argThat(event ->
|
||||
"market-source-8001-4101-cmd-recall".equals(event.getSourceEventId())
|
||||
&& "market".equals(event.getSourceOwner())
|
||||
&& "market_kb".equals(event.getSourceType())
|
||||
&& "8001".equals(event.getSourceId())
|
||||
&& "2".equals(event.getSourceRevision())
|
||||
&& Long.valueOf(2001L).equals(event.getOwnerUserId())
|
||||
&& Long.valueOf(4101L).equals(event.getKbId())
|
||||
&& "recalled".equals(event.getStatus())
|
||||
&& "blocked".equals(event.getActionPolicy())
|
||||
&& event.getEventSummary().contains("\"assetId\":8001")));
|
||||
verify(sourceBindingProjectionMapper).updateStatusByKbId(org.mockito.ArgumentMatchers.eq(4101L),
|
||||
org.mockito.ArgumentMatchers.eq("recalled"), org.mockito.ArgumentMatchers.eq("blocked"),
|
||||
org.mockito.ArgumentMatchers.eq("market-source-8001-4101-cmd-recall"),
|
||||
org.mockito.ArgumentMatchers.eq("2"),
|
||||
org.mockito.ArgumentMatchers.argThat(summary -> summary.contains("\"marketAssetId\":8001")
|
||||
&& summary.contains("\"eventType\":\"market_source_status_changed\"")));
|
||||
verify(projectionTaskMapper).insert(org.mockito.ArgumentMatchers.<MuseKnowledgeProjectionTaskDO>argThat(task ->
|
||||
"projection-task-market-source-8001-4101-cmd-recall".equals(task.getTaskId())
|
||||
&& "completed".equals(task.getStatus())
|
||||
&& task.getResultSummary().contains("\"affectedProjectionCount\":2")
|
||||
&& task.getResultSummary().contains("\"queuedOutboxCount\":1")));
|
||||
verify(eventPublishOutboxService).createForSourceEvent(any(MuseKnowledgeSourceEventDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_skipExistingMarketSourceEventButKeepProjectionBlocked() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
MuseKnowledgeBaseDO installedRef = installedRefKb(4101L, 2001L, 8001L);
|
||||
when(knowledgeBaseMapper.selectInstalledRefsByMarketAssetId(8001L)).thenReturn(List.of(installedRef));
|
||||
when(sourceEventMapper.selectBySourceEventId("market-source-8001-4101-cmd-recall"))
|
||||
.thenReturn(new MuseKnowledgeSourceEventDO());
|
||||
when(sourceBindingProjectionMapper.updateStatusByKbId(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(1);
|
||||
|
||||
MuseKnowledgeSourceEventService.MarketSourceStatusApplyResult result =
|
||||
sourceEventService.applyMarketSourceStatusChanged(new MarketAssetSourceStatusChangedEvent(100L,
|
||||
8001L, "knowledge_base", 4001L, 9001L, "recalled", "blocked",
|
||||
"cmd-recall", "安全召回", "2"));
|
||||
|
||||
assertEquals(1, result.affectedInstalledKbCount());
|
||||
assertEquals(1, result.affectedProjectionCount());
|
||||
verify(sourceEventMapper, never()).insert(any(MuseKnowledgeSourceEventDO.class));
|
||||
verify(projectionTaskMapper, never()).insert(any(MuseKnowledgeProjectionTaskDO.class));
|
||||
verify(eventPublishOutboxService, never()).createForSourceEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_keepSourceBindingsTotalFromMapperWhenTargetOwnerFiltered() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
@ -183,6 +251,16 @@ class MuseKnowledgeSourceEventServiceTest extends BaseMockitoUnitTest {
|
||||
return kb;
|
||||
}
|
||||
|
||||
private static MuseKnowledgeBaseDO installedRefKb(Long id, Long ownerUserId, Long assetId) {
|
||||
MuseKnowledgeBaseDO kb = new MuseKnowledgeBaseDO();
|
||||
kb.setId(id);
|
||||
kb.setKbType("installed_ref");
|
||||
kb.setOwnerUserId(ownerUserId);
|
||||
kb.setSourceMarketAssetId(assetId);
|
||||
kb.setName("市场知识库");
|
||||
return kb;
|
||||
}
|
||||
|
||||
private static MuseKnowledgeSourceBindingProjectionDO projection(String projectionId, Long bindingId,
|
||||
String summary, LocalDateTime updatedAt) {
|
||||
MuseKnowledgeSourceBindingProjectionDO projection = new MuseKnowledgeSourceBindingProjectionDO();
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
package cn.iocoder.muse.module.knowledge.controller.app.muse;
|
||||
|
||||
import cn.iocoder.muse.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.framework.mybatis.core.muse.MuseContractPersistenceService;
|
||||
import cn.iocoder.muse.framework.security.core.LoginUser;
|
||||
import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeRetrievalAppService;
|
||||
import cn.iocoder.muse.module.knowledge.controller.app.AppMuseKnowledgeContractController;
|
||||
import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeRetrievalVO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.muse.module.knowledge.controller.KnowledgeContractFallbackTestSupport.fallbackMappings;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* App Knowledge Retrieval Controller 测试。
|
||||
*/
|
||||
class AppMuseKnowledgeRetrievalControllerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private AppMuseKnowledgeRetrievalController retrievalController;
|
||||
@InjectMocks
|
||||
private AppMuseKnowledgeContractController fallbackController;
|
||||
@Mock
|
||||
private MuseKnowledgeRetrievalAppService retrievalAppService;
|
||||
@Mock
|
||||
private MuseContractPersistenceService contractPersistenceService;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
|
||||
validator.afterPropertiesSet();
|
||||
this.mockMvc = MockMvcBuilders.standaloneSetup(retrievalController, fallbackController)
|
||||
.setControllerAdvice(new TestExceptionAdvice())
|
||||
.setValidator(validator)
|
||||
.build();
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser(2001L), new MockHttpServletRequest());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_routeRetrievalApiToDedicatedController_withoutFallback() throws Exception {
|
||||
when(retrievalAppService.retrieveForWork(eq(2001L), eq(9001L), any()))
|
||||
.thenReturn(noDatasetResp());
|
||||
|
||||
mockMvc.perform(post("/muse/works/9001/knowledge-retrievals")
|
||||
.header("X-API-Version", "1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"commandId\":\"cmd-retrieve\",\"question\":\"检索主角设定\",\"topK\":3}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("empty"))
|
||||
.andExpect(jsonPath("$.data.omittedReason").value("no_dataset"))
|
||||
.andExpect(jsonPath("$.data.omittedSources[0].reason").value("no_dataset"));
|
||||
|
||||
verify(retrievalAppService).retrieveForWork(eq(2001L), eq(9001L), any());
|
||||
verifyNoInteractions(contractPersistenceService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_removeRetrievalRouteFromFallbackMapping() {
|
||||
List<String> mappings = Arrays.asList(fallbackMappings(AppMuseKnowledgeContractController.class));
|
||||
|
||||
assertFalse(mappings.contains("/works/{workId}/knowledge-retrievals"));
|
||||
assertFalse(mappings.contains("/works/{workId}/knowledge-retrievals/**"));
|
||||
}
|
||||
|
||||
private static AppKnowledgeRetrievalVO.RetrievalRespVO noDatasetResp() {
|
||||
AppKnowledgeRetrievalVO.OmittedSourceVO omitted = new AppKnowledgeRetrievalVO.OmittedSourceVO();
|
||||
omitted.setKbId("5001");
|
||||
omitted.setReason("no_dataset");
|
||||
omitted.setSourceStatus("active");
|
||||
AppKnowledgeRetrievalVO.RetrievalRespVO vo = new AppKnowledgeRetrievalVO.RetrievalRespVO();
|
||||
vo.setStatus("empty");
|
||||
vo.setOmittedReason("no_dataset");
|
||||
vo.setChunks(List.of());
|
||||
vo.setOmittedSources(List.of(omitted));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private LoginUser loginUser(Long id) {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setId(id);
|
||||
loginUser.setUserType(UserTypeEnum.MEMBER.getValue());
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
@RestControllerAdvice
|
||||
static class TestExceptionAdvice {
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
public CommonResult<?> handleServiceException(ServiceException ex) {
|
||||
return CommonResult.error(ex.getCode(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -6,4 +6,4 @@
|
||||
- **边界(不可违反)**:**授权 ≠ 所有权转移**;市场**非**源事实 owner,目标事实回目标 owner;Work 资产仅只读且排除出 install(`INSTALLABLE_ASSET_TYPES=Set.of(agent,knowledge_base)`);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
|
||||
- **out-of-scope**:完整电商支付结算 DRM(项目非目标);模板化/参考写入/AI 上下文受 Feature Gate 默认关闭。
|
||||
- **现状**:只读评估 82%;核心不变式已被代码强制。**生产者飞轮已端到端打通并活体证(2026-06-15)**:发布草稿→检查→提交→上架状态可视化→申诉(提交/补充材料/撤回)→申诉态回显(`MarketPublish`+`market-publish.spec.ts` 8/8,见[总账](../../docs/mvp/进度总账.md) §五C)。本会话新增 3 处 app-api 契约:`PublishRecordItem.marketAssetId`(物化资产 id,解申诉 assetId 映射 gap)、`GET /marketplace/appeals`(我的申诉列表)、记录 `appealStatus` 回填(`selectLatestByAssetIdAndUser`)。**2026-06-16 补真实 PG 验收证据**:`P1rMarketPublishProducerCompletedApprovalIT` 覆盖发布生产侧 5 op,真实 PG 3/3 绿;`P1rMarketAdminAppealCompletedApprovalIT` 覆盖管理端申诉 3 op(adminListAppeals/adminGetAppeal/adminResolveAppeal),真实 PG 6/6 绿;`P1rMarketAdminPublishReviewCompletedApprovalIT` 覆盖管理端发布审核 3 op(adminListPublishRequests/adminApprovePublishRequest/adminRejectPublishRequest),真实 PG 6/6 绿;`P1rMarketProducerAppealCompletedApprovalIT` 覆盖生产者申诉写路 3 op(submitAppeal/supplementAppeal/withdrawAppeal,app 入口 + owner 隔离 + 脱敏 + 状态 CAS + 冲突回滚),真实 PG 7/7 绿(独立复跑)。**2026-06-17 续补**:`P1rMarketAdminAssetReadsCompletedApprovalIT` 覆盖管理端资产治理读 2 op(adminListMarketAssets/adminGetMarketAsset:tenant 隔离 + assetType/listingStatus(not_listed↔draft 归一)/publisherId 过滤、详情资产快照 + 授权/安装/绑定/受影响任务计数全 0(关联表空)、缺资产/跨租户→MARKET_ASSET_NOT_EXISTS、API version/RBAC(muse:market:asset:query)fail-closed、纯读 no-write),真实 PG 2/2 绿(独立复跑)。`P1rMarketGovernanceWriteCompletedApprovalIT` 覆盖管理端资产治理写 3 op(adminPreviewGovernanceImpact/adminDelistAsset/adminRecallAsset:影响预览→凭预览下架 listed→delisted 的 expectedStatus CAS→凭预览召回 listed→recalled + 目标 owner 传播 fail-closed 如实记录 recalled/recorded/TARGET_OWNER_UNAVAILABLE;命令幂等、合同枚举 @Pattern→BAD_REQUEST、缺预览/CAS 阻断/缺资产/API version/RBAC fail-closed 0 写),真实 PG 4/4 绿(独立复跑)。`P1rMarketplaceDiscoveryReadsCompletedApprovalIT` 覆盖 marketplace 发现读 3 op(listMarketplaceAssets/listMarketplaceRecommendations/getGovernanceImpact:App 可见性隔离 listed 公开/非公开仅 publisher 自见 + assetType 过滤、fallback 推荐 + 理由、治理影响读 publisher/受影响者门禁 非属主无关联→MARKET_RESOURCE_FORBIDDEN、缺资产/API version fail-closed、纯读 no-write),真实 PG 4/4 绿(独立复跑)。`P1rMarketLicenseInstallCompletedApprovalIT` 覆盖采纳链 2 op(purchaseAsset/installMarketplaceAsset:购买写授权快照+购买事实+命令并跨 BC 同步 purchase/license 两条 Account 投影、安装要求已购授权+仅 agent/kb 可安装+写 installed 记录、命令幂等、未上架/缺资产/授权类型不符/无授权/作品不可安装/API version fail-closed 0 写),真实 PG 3/3 绿(独立复跑);证据已人工批准并翻 completed。`P1rMarketHandoffClusterCompletedApprovalIT` 覆盖 handoff 簇 4 op(createBindPrecheck/createMarketplaceHandoff/getHandoffStatus/cancelHandoff:已购 active 授权快照→来源授权摘要(handoffReady)→一次性 handoff token(明文不入库、只存 sha256 hash,事件快照不含明文)→只读回显 pending→expectedStatus CAS 取消(pending→cancelled)的端到端链;bind/handoff 回放幂等、cancel 终态后再取消被取消性守卫拒绝且不双写;非法 version/缺资产/无授权(授权≠可达)/目标 owner 白名单外(LocalMarketTargetOwnerFacade 本地目标页 fail-closed)/跨属主 token 读取与取消(属主隔离 RESOURCE_FORBIDDEN)/取消 CAS 期望态不匹配(reserveCommand MANDATORY 预占随事务回滚)失败路径 0 写),真实 PG 3/3 绿(独立复跑);至此 market needs_verification 28 op 全补 dedicated 真实 PG 证据,证据已人工批准并翻 completed。
|
||||
- **关键风险 / TODO**:① **资产物化只在 admin 审核通过(`markListed`)发生**——submit 阶段 `publish_request.asset_id=draft.id` 占位,故未上架资产无 `muse_market_asset` 行,申诉(`requireAsset`)仅对已物化(曾上架)资产可达;② 申诉证据材料附件上传(`attachmentIds` 当前前端置空);③ 上架后下架/召回的生产者自助入口;④ **market 验收债已清零(2026-06-17)**:批15-19 补 14 op(资产治理读 2 + 治理写 3 + marketplace 发现读 3 + 采纳链 purchase/install 2 + handoff 簇 4)叠加本战役前 14 op(发布生产 5 + 管理端申诉 3 + 发布审核 3 + 生产者申诉 3)= market needs_verification 28 op 全有 dedicated 真实 PG 证据;**台账 completed flip 与覆盖门禁批准集合已按人工批准同步,2026-06-19 已补 `testFiles` 证据锚点门禁**。⑤ **2026-06-25:install 用户可见假绿修复(isAcquired/isInstalled enrich,commit 37e7a8d)**——`MarketAssetQueryServiceImpl` `toCard`/`userActions` 此前把 isAcquired/isInstalled/canInstall 裸硬编 false(install 后端真实已验、但读模型不回填用户真实态),致 studio 安装按钮(`assetType!=='work' && isAcquired && !isInstalled`)真后端永不渲染、批17 IT 把 false 断言为正确(假绿固化)。修=仿 `isFavorite` enrich isAcquired(active 授权)/isInstalled(installation)/isInstallable,**与 install 写端口 `MarketInstallServiceImpl` 严格同源**(`selectActiveByOwnerAndAsset` + `INSTALLABLE_ASSET_TYPES`,避免"显示可装但点了 MARKET_LICENSE_NOT_EXISTS"新假绿);批量加载避放大 isFavorite 既有 N+1;批17 IT 改三态;前端补 onError;UI e2e `market-install.spec` 连跑 2 次。活体真验 purchase→isAcquired=T→install→isInstalled=T(详总账 2026-06-25 / memory muse-market-install-isacquired-enrich)。**教训:读模型 enrich 必须与写端口校验同源,否则显示态与可操作态背离=新假绿。** 仍缺:install 后下游物化(资产变可用 agent/kb,承 ① 资产物化主线)。
|
||||
- **关键风险 / TODO**:① **资产物化只在 admin 审核通过(`markListed`)发生**——submit 阶段 `publish_request.asset_id=draft.id` 占位,故未上架资产无 `muse_market_asset` 行,申诉(`requireAsset`)仅对已物化(曾上架)资产可达;② 申诉证据材料附件上传(`attachmentIds` 当前前端置空);③ 上架后下架/召回的生产者自助入口;④ **market 验收债已清零(2026-06-17)**:批15-19 补 14 op(资产治理读 2 + 治理写 3 + marketplace 发现读 3 + 采纳链 purchase/install 2 + handoff 簇 4)叠加本战役前 14 op(发布生产 5 + 管理端申诉 3 + 发布审核 3 + 生产者申诉 3)= market needs_verification 28 op 全有 dedicated 真实 PG 证据;**台账 completed flip 与覆盖门禁批准集合已按人工批准同步,2026-06-19 已补 `testFiles` 证据锚点门禁**。⑤ **2026-06-25:install 用户可见假绿修复(isAcquired/isInstalled enrich,commit 37e7a8d)**——`MarketAssetQueryServiceImpl` `toCard`/`userActions` 此前把 isAcquired/isInstalled/canInstall 裸硬编 false(install 后端真实已验、但读模型不回填用户真实态),致 studio 安装按钮(`assetType!=='work' && isAcquired && !isInstalled`)真后端永不渲染、批17 IT 把 false 断言为正确(假绿固化)。修=仿 `isFavorite` enrich isAcquired(active 授权)/isInstalled(installation)/isInstallable,**与 install 写端口 `MarketInstallServiceImpl` 严格同源**(`selectActiveByOwnerAndAsset` + `INSTALLABLE_ASSET_TYPES`,避免"显示可装但点了 MARKET_LICENSE_NOT_EXISTS"新假绿);批量加载避放大 isFavorite 既有 N+1;批17 IT 改三态;前端补 onError;UI e2e `market-install.spec` 连跑 2 次。活体真验 purchase→isAcquired=T→install→isInstalled=T(详总账 2026-06-25 / memory muse-market-install-isacquired-enrich)。**教训:读模型 enrich 必须与写端口校验同源,否则显示态与可操作态背离=新假绿。** ⑥ **2026-06-27:E4 agent 物化 + KB 召回触达已补并真验**:Market 召回 `recallAsset` 写 `source_status_event` 后发布 `MarketAssetSourceStatusChangedEvent`,目标 owner 自治消费;agent handoff 只签 market token/assetId,不把发布者 agent id 交给客户端。Studio 真 e2e 覆盖 handoff-agent、market-kb-recall、market-asset-detail、governance-impact、handoff-precheck 6/0F/0E。⑦ **2026-06-27:E6 restore 分支真 PG IT 已补**:`P1rMarketAdminAppealCompletedApprovalIT` 扩到 7/0F/0E/0S,覆盖申诉 `restored` 分支凭治理预览恢复资产、preview consumed、asset relisted、restore governance action、command/event 幂等与快照 `impactPreviewId`。仍缺:生产者端发布/申诉 UI、KB 副本资源回收、下架/撤权更细补偿。
|
||||
|
||||
@ -7,7 +7,8 @@ import cn.iocoder.muse.module.market.api.asset.dto.MarketAssetSourceRespDTO;
|
||||
*
|
||||
* <p>背景(D0-fork / 临时-04 事实 4):安装者把 market_kb 资产绑到作品时,{@code sourceId} 是 <b>market 资产 id</b>
|
||||
* (不再是发布者 kbId)。knowledge 侧据此校验资产存在 + 类型 + 公开副本是否就绪,并拿到副本 dataset id 建本地
|
||||
* installed_ref 实体。这些数据都在 market 自有 {@code muse_market_asset}(source_id / asset_type / publisher_id)
|
||||
* installed_ref 实体。agent 安装侧也通过同一端口读取 market 资产指向的发布者 agentId,再在 AI owner 内物化为安装者
|
||||
* 本地 user agent。这些数据都在 market 自有 {@code muse_market_asset}(source_id / asset_type / publisher_id)
|
||||
* + tags JSONB(U-fork 写回的 publicForkDatasetId / forkStatus)中,knowledge 经本端口读 market 自有数据即可,
|
||||
* <b>不直读 {@code market.dal}</b>(ArchUnit {@code BcBoundaryArchTest} 机械约束),也不需要跨 owner 反查发布者 dataset。</p>
|
||||
*
|
||||
|
||||
@ -13,7 +13,7 @@ package cn.iocoder.muse.module.market.api.asset.dto;
|
||||
*
|
||||
* @param exists 资产是否存在(false 时其余字段无意义;安装侧据此 fail-closed 拒绝绑定)
|
||||
* @param assetType 资产类型({@code knowledge_base} / {@code agent} / {@code work};安装侧只放行 knowledge_base)
|
||||
* @param sourceId 来源本地主键({@code muse_market_asset.source_id};KB 资产即发布者本地 kbId)
|
||||
* @param sourceId 来源本地主键({@code muse_market_asset.source_id};KB 资产即发布者本地 kbId,agent 资产即发布者 agentId)
|
||||
* @param publisherUserId 发布者用户 id({@code muse_market_asset.publisher_id})
|
||||
* @param assetName 资产摘要名称(建本地 installed_ref kb 行的 name 取它,无独立读模型时占位发布者 KB 名)
|
||||
* @param publicForkDatasetId 公开副本 dataset id(U-fork 写回 tags 的 publicForkDatasetId;forkStatus=ready 时非空)
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package cn.iocoder.muse.module.market.api.asset.event;
|
||||
|
||||
/**
|
||||
* Market 资产来源状态变更事件。
|
||||
*
|
||||
* <p>事件落在 market-api 契约层:market 发布治理事实,目标 owner(knowledge / ai / content)按自身模型消费,不让
|
||||
* market 直连 owner 的 DAL 或 application。当前 E4 先由 knowledge 消费 {@code knowledge_base} 召回事件,把已物化
|
||||
* installed_ref KB 的投影置为 recalled/blocked,从而让检索门立即 fail-closed。</p>
|
||||
*
|
||||
* @param tenantId 租户编号,监听侧需据此恢复租户上下文
|
||||
* @param assetId market 资产 id
|
||||
* @param assetType 资产类型(knowledge_base / agent / work)
|
||||
* @param sourceId 发布者侧来源主键
|
||||
* @param publisherUserId 发布者用户 id
|
||||
* @param sourceStatus 来源状态(如 recalled / delisted)
|
||||
* @param actionPolicy 目标 owner 应用策略(如 blocked / needs_recheck)
|
||||
* @param commandId 触发治理动作的幂等命令号
|
||||
* @param reason 治理原因摘要
|
||||
* @param sourceRevision 来源版本
|
||||
*/
|
||||
public record MarketAssetSourceStatusChangedEvent(
|
||||
Long tenantId,
|
||||
Long assetId,
|
||||
String assetType,
|
||||
Long sourceId,
|
||||
Long publisherUserId,
|
||||
String sourceStatus,
|
||||
String actionPolicy,
|
||||
String commandId,
|
||||
String reason,
|
||||
String sourceRevision
|
||||
) {
|
||||
}
|
||||
@ -24,9 +24,11 @@ import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketHandoffEventMapper
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketInstallationMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketSourceStatusEventMapper;
|
||||
import cn.iocoder.muse.module.market.domain.muse.MarketApiVersionGuard;
|
||||
import cn.iocoder.muse.module.market.api.asset.event.MarketAssetSourceStatusChangedEvent;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@ -92,6 +94,7 @@ public class AdminMarketGovernanceServiceImpl implements AdminMarketGovernanceSe
|
||||
private final MuseMarketSourceStatusEventMapper sourceStatusEventMapper;
|
||||
private final MarketCommandService commandService;
|
||||
private final MarketEventPublishOutboxService eventPublishOutboxService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public PageResult<AdminMarketAssetSummary> listMarketAssets(Long adminUserId, String apiVersion, Integer pageNo,
|
||||
@ -254,14 +257,15 @@ public class AdminMarketGovernanceServiceImpl implements AdminMarketGovernanceSe
|
||||
|
||||
GovernanceActionResult result = applyGovernanceAction(tenantId, adminUserId, asset, preview,
|
||||
command.commandId(), requestHash, "recall", STATUS_RECALLED, command.expectedStatus(),
|
||||
POLICY_NEEDS_RECHECK, List.of(POLICY_NEEDS_RECHECK, "owner_propagation_unavailable", command.scope()),
|
||||
POLICY_NEEDS_RECHECK, List.of(POLICY_NEEDS_RECHECK, "owner_event_published", command.scope()),
|
||||
command.reason(), command.scope(), linkedMap(
|
||||
"scope", command.scope(),
|
||||
"basis", command.basis(),
|
||||
"exportPreservation", Boolean.TRUE.equals(command.exportPreservation())));
|
||||
writeSourceStatusBlocked(tenantId, adminUserId, asset, command, requestHash);
|
||||
writeSourceStatusEventPublished(tenantId, adminUserId, asset, command, requestHash);
|
||||
publishSourceStatusChanged(tenantId, asset, command);
|
||||
commandService.recordCompleted(envelope, JsonUtils.toJsonString(result));
|
||||
log.info("[recallAsset][管理端 Market 召回完成且目标 owner 传播记录为 blocked,tenantId={}, adminUserId={}, assetId={}, commandId={}, previewId={}]",
|
||||
log.info("[recallAsset][管理端 Market 召回完成并已发布目标 owner 来源状态事件,tenantId={}, adminUserId={}, assetId={}, commandId={}, previewId={}]",
|
||||
tenantId, adminUserId, assetId, command.commandId(), command.impactPreviewId());
|
||||
return result;
|
||||
}
|
||||
@ -358,9 +362,9 @@ public class AdminMarketGovernanceServiceImpl implements AdminMarketGovernanceSe
|
||||
toContractActionPolicy(actionPolicy, recheckReasons), actionId);
|
||||
}
|
||||
|
||||
private void writeSourceStatusBlocked(Long tenantId, Long adminUserId, MuseMarketAssetDO asset,
|
||||
RecallAssetCommand command, String requestHash) {
|
||||
List<String> recheckReasons = List.of(POLICY_NEEDS_RECHECK, "owner_propagation_unavailable", command.scope());
|
||||
private void writeSourceStatusEventPublished(Long tenantId, Long adminUserId, MuseMarketAssetDO asset,
|
||||
RecallAssetCommand command, String requestHash) {
|
||||
List<String> recheckReasons = List.of(POLICY_NEEDS_RECHECK, "owner_event_published", command.scope());
|
||||
MuseMarketSourceStatusEventDO event = new MuseMarketSourceStatusEventDO();
|
||||
event.setSourceEventId("source-event-" + UUID.randomUUID());
|
||||
event.setSourceOwner(sourceOwner(asset));
|
||||
@ -378,21 +382,19 @@ public class AdminMarketGovernanceServiceImpl implements AdminMarketGovernanceSe
|
||||
event.setStatus("recorded");
|
||||
event.setRevision(1);
|
||||
event.setEventSummary(JsonUtils.toJsonString(linkedMap(
|
||||
"propagationStatus", STATUS_BLOCKED,
|
||||
"reason", "target_owner_unavailable",
|
||||
"propagationStatus", "event_published",
|
||||
"reason", "target_owner_event_published",
|
||||
"actorUserId", adminUserId,
|
||||
"basis", command.basis(),
|
||||
"exportPreservation", Boolean.TRUE.equals(command.exportPreservation()))));
|
||||
event.setErrorCode("TARGET_OWNER_UNAVAILABLE");
|
||||
event.setErrorMessage("Market Task 9 未接入目标 owner 传播;已记录 blocked 并要求重验");
|
||||
event.setTenantId(tenantId);
|
||||
sourceStatusEventMapper.insert(event);
|
||||
MuseMarketGovernanceImpactDO impact = newImpact(asset, "source_status_propagation",
|
||||
"source-status-" + asset.getId(), command.commandId() + ":source-status-impact", requestHash);
|
||||
impact.setStatus(STATUS_BLOCKED);
|
||||
impact.setStatus("event_published");
|
||||
impact.setImpactSnapshot(JsonUtils.toJsonString(linkedMap(
|
||||
"source", "market_action",
|
||||
"reason", "target_owner_unavailable",
|
||||
"reason", "target_owner_event_published",
|
||||
"sourceStatus", "recalled",
|
||||
"actionPolicy", POLICY_NEEDS_RECHECK,
|
||||
"recheckReasons", recheckReasons)));
|
||||
@ -403,6 +405,18 @@ public class AdminMarketGovernanceServiceImpl implements AdminMarketGovernanceSe
|
||||
governanceImpactMapper.insert(impact);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布给目标 owner 的来源状态变更事件。
|
||||
*
|
||||
* <p>market 只拥有市场治理事实,不直写 knowledge/ai/content 的本地投影;目标 owner 消费本事件后用自己的模型关闭
|
||||
* 检索、生成或绑定入口。事件在当前事务内发布,由监听侧 AFTER_COMMIT 消费,避免召回事实未提交就被 owner 读取。</p>
|
||||
*/
|
||||
private void publishSourceStatusChanged(Long tenantId, MuseMarketAssetDO asset, RecallAssetCommand command) {
|
||||
eventPublisher.publishEvent(new MarketAssetSourceStatusChangedEvent(tenantId, asset.getId(),
|
||||
asset.getAssetType(), asset.getSourceId(), asset.getPublisherId(), "recalled", STATUS_BLOCKED,
|
||||
command.commandId(), command.reason(), sourceRevision(asset)));
|
||||
}
|
||||
|
||||
private void writePreviewImpacts(Long tenantId, MuseMarketAssetDO asset, String previewId, String requestHash,
|
||||
PreviewGovernanceImpactCommand command) {
|
||||
for (String impactType : impactTypes()) {
|
||||
|
||||
@ -24,10 +24,12 @@ import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketGovernancePreviewM
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketHandoffEventMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketInstallationMapper;
|
||||
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketSourceStatusEventMapper;
|
||||
import cn.iocoder.muse.module.market.api.asset.event.MarketAssetSourceStatusChangedEvent;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@ -76,6 +78,8 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
|
||||
private MarketCommandService commandService;
|
||||
@Mock
|
||||
private MarketEventPublishOutboxService eventPublishOutboxService;
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
@ -255,7 +259,7 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_recallAssetWithSourceStatusAndBlockedOwnerPropagationWithoutNeedsRecheckStatus() {
|
||||
void should_recallAssetWithSourceStatusEventPublishedWithoutNeedsRecheckStatus() {
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
MuseMarketAssetDO asset = asset(1001L, "agent", "listed");
|
||||
when(assetMapper.selectAdminById(100L, 1001L)).thenReturn(asset);
|
||||
@ -281,19 +285,31 @@ class AdminMarketGovernanceServiceTest extends BaseMockitoUnitTest {
|
||||
assertEquals("stop_new_install", result.actionPolicy().installPolicy());
|
||||
assertEquals("stop_new_bind", result.actionPolicy().bindPolicy());
|
||||
assertEquals("stop_generation", result.actionPolicy().generationPolicy());
|
||||
assertEquals(List.of("needs_recheck", "owner_propagation_unavailable", "full_recall"),
|
||||
assertEquals(List.of("needs_recheck", "owner_event_published", "full_recall"),
|
||||
result.actionPolicy().recheckReasons());
|
||||
verify(sourceStatusEventMapper).insert(org.mockito.ArgumentMatchers.<MuseMarketSourceStatusEventDO>argThat(event ->
|
||||
"recalled".equals(event.getSourceStatus())
|
||||
&& "needs_recheck".equals(event.getActionPolicy())
|
||||
&& event.getRecheckReasons().contains("owner_propagation_unavailable")
|
||||
&& event.getRecheckReasons().contains("owner_event_published")
|
||||
&& event.getRecheckReasons().contains("needs_recheck")
|
||||
&& !event.getSourceStatus().contains("needs_recheck")
|
||||
&& event.getEventSummary().contains("\"propagationStatus\":\"blocked\"")));
|
||||
&& event.getEventSummary().contains("\"propagationStatus\":\"event_published\"")
|
||||
&& event.getErrorCode() == null));
|
||||
verify(governanceImpactMapper).insert(org.mockito.ArgumentMatchers.<MuseMarketGovernanceImpactDO>argThat(impact ->
|
||||
"source_status_propagation".equals(impact.getImpactType())
|
||||
&& "blocked".equals(impact.getStatus())
|
||||
&& impact.getImpactSnapshot().contains("\"reason\":\"target_owner_unavailable\"")));
|
||||
&& "event_published".equals(impact.getStatus())
|
||||
&& impact.getImpactSnapshot().contains("\"reason\":\"target_owner_event_published\"")));
|
||||
verify(eventPublisher).publishEvent(org.mockito.ArgumentMatchers.<MarketAssetSourceStatusChangedEvent>argThat(event ->
|
||||
Long.valueOf(100L).equals(event.tenantId())
|
||||
&& Long.valueOf(1001L).equals(event.assetId())
|
||||
&& "agent".equals(event.assetType())
|
||||
&& Long.valueOf(6001L).equals(event.sourceId())
|
||||
&& Long.valueOf(9001L).equals(event.publisherUserId())
|
||||
&& "recalled".equals(event.sourceStatus())
|
||||
&& "blocked".equals(event.actionPolicy())
|
||||
&& "cmd-recall".equals(event.commandId())
|
||||
&& "安全召回".equals(event.reason())
|
||||
&& "2".equals(event.sourceRevision())));
|
||||
verify(eventPublishOutboxService).createForGovernanceAction(
|
||||
org.mockito.ArgumentMatchers.<MuseMarketGovernanceActionDO>argThat(action ->
|
||||
"recall".equals(action.getActionType())
|
||||
|
||||
@ -6,4 +6,5 @@
|
||||
- **边界(不可违反)**:是用户可见权益的聚合**读模型**与跳转入口,**不是消费账本事实源,不能反写**作品/知识/智能体/市场事实;高危审计 append-only + WORM/哈希链;Entitlement/Quota/Usage/Security/Binding 唯一写入 authority;守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
|
||||
- **out-of-scope**:他域事实写入。
|
||||
- **现状**:只读评估 68%。**2026-06-15 活体订正**:单体内 `MarketAccountProjectionFacade` 由 market 的 `MarketAccountProjectionProvider` 真实提供,purchases/licenses/publish-records 三端可读投影,不是 `*_UNAVAILABLE` 缺口。**2026-06-16 补真实 PG 验收证据**:`P1rAccountMarketRecordsCompletedApprovalIT` 覆盖 appListPurchases/appListLicenses/appListPublishRecords,真实 PG 3/3 绿;`P1rAccountQuotaRequestCompletedApprovalIT` 覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest(命令幂等+commandId 冲突回滚+owner/tenant 隔离+RBAC+类型白名单),真实 PG 5/5 绿(独立复跑);`P1rAccountUsageObservabilityCompletedApprovalIT` 覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation(用量按 owner 聚合 token/pending/anomaly/byModel+跨用户隔离+period 白名单、外部调用 app owner 守卫/admin 无 owner 限制+RBAC、三 op 纯读 no-write、缺用户/not-found/API version/RBAC fail-closed),真实 PG 4/4 绿(独立复跑);`P1rAccountNewApiBindingClusterCompletedApprovalIT` 覆盖 getAppNewApiBinding/adminListNewApiBindings/appRecheckNewApiBinding(绑定摘要 owner 读 + active→bound 归一、admin 列表以账户用户为分页主表 + EXISTS/NOT EXISTS 状态筛选 + RBAC、两读 no-write、recheck 单体 New-API 不可用正确 fail-closed:命令预占回滚但失败调用 REQUIRES_NEW 独立落库),真实 PG 4/4 绿(独立复跑);`P1rAccountExportDownloadClusterCompletedApprovalIT` 覆盖 appCreateExportTask/appGetExportTask/appDownloadExport(FileService 未接入时非敏感导出落 queued 任务+命令+审计、不伪造 completed/凭证、敏感类型缺 step-up fail-closed、命令幂等;get owner 读 + 跨 owner not found + no-write;download 凭证门禁 + FileService 不可用正确 fail-closed:@Transactional 回滚一次性消费、不写审计),真实 PG 4/4 绿(独立复跑);`P1rAccountAdminReadsCompletedApprovalIT` 覆盖 adminListUsageRecords/adminGetCallAttributionJob(用量以账户用户为分页主表库内聚合 token/pending/failed/attributionStatus、调用归因 job 按 jobId 读 job+items 归一状态+分桶计数、RBAC+API version fail-closed、纯读 no-write),真实 PG 3/3 绿(独立复跑);`P1rAccountAdminWritesCompletedApprovalIT` 覆盖 adminCreateNewApiBinding/adminCreateCallAttributionJob(binding 单体 New-API 不可用正确 fail-closed:命令回滚+失败调用 REQUIRES_NEW 独立落库+binding 不写;attribution job 基于已存在 correlationId 调用建 queued job+命令幂等+callIds 白名单/expectedCallRevision/缺调用记录/RBAC/API version fail-closed),真实 PG 3/3 绿(独立复跑);`P1rAccountSecurityEventAckCompletedApprovalIT` 覆盖 appAcknowledgeSecurityEvent(owner 校验事件归属 + 追加表写处理轨迹 + 基础事件 acknowledged 置真 + 命令幂等 + 审计;非法 action/跨 owner NOT_FOUND/非数字 eventId/缺用户/API version fail-closed 且 0 写),真实 PG 2/2 绿(独立复跑);`P1rAccountAdminPurchaseRecordsCompletedApprovalIT` 覆盖 adminListPurchaseRecords(跨模块 base-package=cn.iocoder.muse.module + 单体内 market 的 `MarketAccountProjectionProvider` 真实判定 purchase 投影可用,读端只读 Account 自有投影表 muse_account_record_projection;无 userId→跨用户全量、userId 过滤→owner 限定 + source_status→admin status 归一 + 资产信息从 title/snapshot 回填、非法 status/API version/RBAC(muse:account:query) fail-closed、纯读 no-write,Account 投影写端口 stub 越界反假绿),真实 PG 2/2 绿(独立复跑);证据已人工批准并翻 completed。
|
||||
- **E5 member 写侧闭环(2026-06-27)**:AI 生成入口通过 `MuseAccountUsageApi` 预占配额,成功后写 usage record 并累加 quota used,失败终态释放预占;`AccountQuotaRequestWorker` 消费 queued quota request,短事务 claim/terminal、New-API 管理口调用在事务外执行,成功写 completed、失败写 failed + integration unavailable;`AccountAttributionWorker` 消费 queued attribution job,按 usage correlation 写 attribution item 并终态化。`RealNewApiAccountFacade` 已接 `/api/user/search`、`/api/user/`、`/api/user/{id}` 管理口,默认配置 fail-closed,显式 live 验证通过。fresh 证据:targeted 单测 120/0F/0E;account real-PG 16/0F/0E/0S;New-API 管理口 live 1/0F/0E/0S;AI runtime live 3/0F/0E/0S 反查 usage/quota/attribution 全落库。边界:workers 默认 disabled,生产启用需显式配置。
|
||||
- **关键风险 / TODO**:account needs_verification 验收债**已清零**:末位 adminListPurchaseRecords 经 `P1rAccountAdminPurchaseRecordsCompletedApprovalIT`(跨模块 base-package=cn.iocoder.muse.module + import MarketAccountProjectionProvider)真实 PG 2/2 绿补证;批3(市场记录读)+ 批7-14 account needs_verification op 全部补真实 PG 证据、证据已人工批准并翻 completed。截至 2026-06-17,content/market/account 三域 needs_verification 验收债均已补齐真实 PG 证据,coverage JSON completed flip 与覆盖门禁批准集合已按人工批准同步,2026-06-19 已补 `testFiles` 证据锚点门禁。前端仍有深页未全覆盖真实后端。
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package cn.iocoder.muse.module.member.api.account;
|
||||
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountAiGenerationQuotaReleaseReqDTO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountAiGenerationQuotaReserveReqDTO;
|
||||
import cn.iocoder.muse.module.member.api.account.dto.MuseAccountAiGenerationUsageRecordReqDTO;
|
||||
|
||||
/**
|
||||
* Account AI 用量与额度对外写端口。
|
||||
*
|
||||
* <p>AI 域只能通过本端口写入 Account 自有用量/配额事实,禁止直接依赖 member-server 的 DAL 或应用服务。
|
||||
* 本端口是模块化单体内的本地 Bean 契约,不做 Feign 暴露。</p>
|
||||
*/
|
||||
public interface MuseAccountUsageApi {
|
||||
|
||||
/**
|
||||
* 为一次 AI 生成预留 1 次 {@code ai_calls} 额度。
|
||||
*
|
||||
* <p>异步生成必须在入队前预扣额度,否则多次并发排队会穿透剩余额度;实现侧必须按 commandId 幂等。</p>
|
||||
*/
|
||||
void reserveAiGenerationQuota(MuseAccountAiGenerationQuotaReserveReqDTO request);
|
||||
|
||||
/**
|
||||
* 释放一次已预留但最终失败的 AI 生成额度。
|
||||
*
|
||||
* <p>仅用于 runtime 进入不可重试失败终态时补偿释放;实现侧必须按 commandId 幂等。</p>
|
||||
*/
|
||||
void releaseAiGenerationQuota(MuseAccountAiGenerationQuotaReleaseReqDTO request);
|
||||
|
||||
/**
|
||||
* 记录一次成功 AI 生成的 token 用量。
|
||||
*
|
||||
* <p>额度在 {@link #reserveAiGenerationQuota(MuseAccountAiGenerationQuotaReserveReqDTO)} 已预扣;
|
||||
* 本方法只写用量事实,不再次扣减额度。</p>
|
||||
*/
|
||||
void recordAiGenerationUsage(MuseAccountAiGenerationUsageRecordReqDTO request);
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package cn.iocoder.muse.module.member.api.account.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* AI 生成额度释放请求。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MuseAccountAiGenerationQuotaReleaseReqDTO {
|
||||
|
||||
private Long accountUserId;
|
||||
private String commandId;
|
||||
private String correlationId;
|
||||
private Long taskId;
|
||||
private Long jobId;
|
||||
private Long workId;
|
||||
private String failureCode;
|
||||
private String failureMessage;
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user