feat(mvp): 收束1.0.0线A交付闭环
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:
lili 2026-06-27 10:52:10 -07:00
parent 2dde0a45ef
commit 3585219637
217 changed files with 16013 additions and 668 deletions

View File

@ -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/**

View File

@ -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 / 用户提供,勿入库>

View File

@ -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 集成坑。

View File

@ -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)。

View File

@ -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 依赖 → 测试红 → 阻断合入。
---

View File

@ -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 / 本地复跑结果为准。
---

View File

@ -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
View 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

View File

@ -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 来源资产 IDsourceType=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]

View File

@ -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。

View File

@ -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
# ============================================================

View 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 历史最小读面。
- AIAgent 创建/编辑/版本/归档、槽位 bind/unbind、真 New-API 生成、SSE done、候选采纳/拒绝。
- Knowledge自建 KB、资料上传、作品绑定、主动检索、no_dataset 可见治理提示、AI 生成消费知识上下文。
- Market资产浏览/详情/收藏/发布草稿/审核/申诉、agent handoff 物化、KB 召回触达已物化 installed_ref。
- MemberAI 生成预扣/扣减/释放、usage record、attribution job、quota request worker、New-API 管理口 Real facade。
- Adminsystem 管理员状态/角色治理、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/0Smodule 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 既有 warningVitest 27 files / 119 tests passedPlaywright 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` 库。

View File

@ -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 e2eE1 已补采纳归档、改后合并入口、IndexedDB 草稿键、旧知识草稿失效、工作台入口与版本历史 | EditorPage 仍 demo 壳;完整导入/导出向导后置 | codex |
| knowledge | ~78% | KB物化、enable/disable真PGE3 已补 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 引入 failsafeP1r\*IT 纳入可执行验证(真起栈或分层标注外部依赖)。
- T0.2 `P1r*RealApiGateTest` 台账门禁:改真跑,或显式标注"非真验证"且不计入交付绿证。
- T0.3 live opt-in IT跳过时显式标 SKIPPED非 PASSED交付清单区分"真验证/仅mock/未验"。
- ✔ doneCI 能拦截 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。
- ✔ doneE1-E7 后续完成判据统一引用 [1.0.0-真验证清单](1.0.0-真验证清单.md)。默认 CI 守住纯本地门禁real-PG/external-live 作为发布前和 Epic 收口的显式复跑门。
### E1 · content 创作闭环补齐
- T1.1 Accept Suggestion 归档AI owner 写 accepted decisionP1r 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 decisionP1r IT 扩到校验 suggestion status 与 decision archive2026-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/0Ereal-PG `P1rContentMergeSuggestionIT` 5/0F/0E/0S
- T1.3 ✅ 工作台壳已补知识/导入/导出/记录最小入口:知识跳转作品知识页,导入/导出创建后端任务记录跳转账户中心2026-06-27 前端本地门 12/0F/0EMSW-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 范围
- ✔ doneagent 全生命周期可管;拒绝/候选闭环真 e2e。
- T2.1 ✅ Agent CRUD 补齐:用户自建 Agent 支持 update/archiveupdate 创建新 active versionarchive 同步归档 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 archive2026-06-27 `accept-suggestion.spec.ts` 新增 reject 真生成候选 suggestionId=81
- T2.5 ✅ 范围收口:候选处置闭环已用真生成采纳/拒绝 e2e 覆盖;独立“候选列表页”不属于线 A 必要路径;质量评测执行器明确降级出 1.0.0 线 A后置线 B避免把 eval-run 入队半成品伪装成可用)
- ✔ doneagent 全生命周期可管;采纳/拒绝候选处置闭环真 e2e质量评测执行器不计入线 A done。
### E3 · knowledge studio 与检索
- T3.1 Studio 检索触发 + 绑定/上传主流程 e2e。
- T3.2 `no_dataset` 用户可见错误与治理入口(非静默省略)。
- T3.3 Source 传播至少一条真实跨模块消费验收。
- ✔ donestudio 知识闭环真 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 生成上下文
- ✔ done2026-06-27Studio 知识闭环真 e2e 通过;检索可被用户触发、缺 dataset 有可见反馈,且 AI 生成链路真实消费知识 Source
### E4 · market agent 物化 + 召回触达
- T4.0 先拍板临时-05 决策 D1config 跨 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`
- ✔ done2026-06-27 fresh 证据见 [1.0.0-真验证清单](1.0.0-真验证清单.md) E4后端目标测试 50/0F/0E契约/覆盖门 12/0F/0EStudio Vitest 11/0F/0E`tsc -b` 通过lint 0 errors仅既有 MSW warningMSW-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 consumerqueued→processing→completed/failed有短事务 claim/terminal、外部调用 tx 外执行、失败 fail-closed
- ✔ done2026-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、命令/事件幂等
- ✔ done2026-06-27Muse 管理端可治理 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 规则。
- ✔ done2026-06-27动态字段预校验真实读取 Meta 投影并 fail-closedadmin 可从空 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 warningVitest 27 files / 119 tests passedMSW-off Playwright 全量 61 tests = 60 passed / 1 skipped / 0 failedAdmin `@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。

View File

@ -0,0 +1,179 @@
# Muse 1.0.0 真验证清单
> 版本v1.02026-06-27E0 消假绿地基)
> 作用:给 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:12module 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 skippedmodule 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=3E4 后真实 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` StudioMSW-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 auditStudio 支持“改后合并”提交 `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 errorscoverage 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 warningVitest 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 errorsMSW-off真实 muse-server 48080 + 共享 `muse_slice_live` + 真 New-API/RAGFlow 配置)。正路证据:真生成 suggestionId=79authz=`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作品槽位支持真实 unbindStudio “放弃修改”从本地清 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 errorsAI OpenAPI 已补 update/archive Agent、versions list/activate/archive、slot unbind 契约。
- 前端静态/局部验证:`tsc -b --force` 通过;目标 ESLintE2 e2e + global-setup0 errorsVitest `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 errorsMSW-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 archivedSlot 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 errorsMSW-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 errorsMSW-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 errorsAI OpenAPI 已收敛 market agent precheck/bind 不再要求客户端提交 `sourceAgentId/sourceAgentVersion`
- 前端静态/局部验证:`npx tsc -b` 通过;`npm run lint` 0 errors仅既有 `public/mockServiceWorker.js` unused eslint-disable warningVitest `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 errorsMSW-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 errorsMSW-off真实后端。覆盖治理影响读面与 market 来源侧授权预检回归。
- 边界:本轮未重跑 Studio 全量 56 e2emarket 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 itemquota 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 550attribution 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 前端 e2eE6 将继续 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` 触发根 Vitest50 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` 触发根 Vitest50 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 e2eAI 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 e2eserver 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。

View File

@ -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%)、五类系统性病灶、E0E7 待做 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%)、五类系统性病灶、E0E7 待做 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 auditAI 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/0Ereal-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0Sstudio `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/0Estudio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0EMSW-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 archivedSlot 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/0Scoverage JSON 241/241studio 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/0Estudio 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 全量 56KB 副本资源回收、下架/撤权更细补偿后置。
**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-closedworker 默认 disabled。fresh 证据:后端 E5 targeted 单测 120/0F/0Eaccount real-PG `P1rAccountQuotaRequestCompletedApprovalIT` 5/0F/0E/0S + `P1rAccountAdminWritesCompletedApprovalIT` 3/0F/0E/0S + `P1rAccountNewApiBindingClusterCompletedApprovalIT` 4/0F/0E/0S + `P1rAccountUsageObservabilityCompletedApprovalIT` 4/0F/0E/0SNew-API 管理口 live `RealNewApiAccountFacadeLiveAcceptanceIT` 显式 opt-in 1/0F/0E/0SquotaApplied=100000000 且只输出 hash/布尔脱敏证据AI runtime live `P1rAiRuntimeEndToEndLiveAcceptanceIT` 3/0F/0E/0S反查 usage record=1、quota used=1、reserve audit=1、attribution job/item completedAI/member/server `test-compile` 通过。边界:未跑前端 e2e默认未 opt-in 的 New-API live 为 honest skipped不计为 passedE6 进入 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/0Smuse-admin 账号治理 Vitest 50 files / 345 tests passedAI 配置 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 e2esystem 角色菜单/数据范围沿用 Yudao 既有模型AI Agent/Tool Grant 等未接后端命令仍 preview-onlyE7 进入 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/migrationHintMeta 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/0Sreal-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/RAGFlowserver live 8/0F/0E/0S + module live 4/0F/0E/1S`BUILD SUCCESS`。唯一 skip 为 GraphRAG attribution 独立 opt-in 未配置,不计为线 A passedNew-API completion、AI runtime usage/quota/attribution、RAGFlow upload/parse/retrieval、Knowledge runtime、Market KB D0-fork 与 after-commit 均真跑通过。本轮 real-PG 先暴露并修正 1F/2Emarket 召回断言从旧 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 failedAdmin `@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

View 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

View File

@ -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 三类消费端均已真后端 e2eE4 补 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 |

View File

@ -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 () => {

View File

@ -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>

View File

@ -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');

View File

@ -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 facadeAI 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">

View File

@ -6,6 +6,7 @@ export default defineConfig({
plugins: [Vue(), VueJsx()],
test: {
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts'],
environmentOptions: {
happyDOM: {
settings: {

View 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 默认不再提供可直接访问的 localStoragehappy-dom 在当前组合下也可能未挂载。
// 这里统一补齐浏览器存储对象,避免全仓单测因环境缺口假红。
installStorage('localStorage');
installStorage('sessionStorage');

View File

@ -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

View File

@ -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

View File

@ -92,5 +92,7 @@ public interface ErrorCodeConstants {
ErrorCode AI_AUDIT_EVENT_NOT_EXISTS = new ErrorCode(1_040_100_023, "AI 业务审计事件不存在");
// 跨空间 handoff(marketagent):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 当前版本不可归档,请先激活其他版本");
}

View File

@ -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>
<!-- 业务组件 -->

View File

@ -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);
}

View File

@ -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");

View File

@ -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);
}

View File

@ -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(marketagent):来源类型标识 + 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(pendingcompleted);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) {
}
}

View File

@ -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) {

View File

@ -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) {

View File

@ -0,0 +1,58 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import lombok.Builder;
/**
* AI Account 用量/配额的本域出站边界
*
* <p>AI 生成链路只能通过本 facade 触达 Account BCfacade 实现再消费 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) {
}
}

View File

@ -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 上限 128Content 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);
}
}

View File

@ -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());
}
}

View File

@ -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 用量服务不可用");
}
}

View File

@ -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));
}
}

View File

@ -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 业务异常的全局语义
*/

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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 下的版本号递增
*/

View File

@ -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);
}

View File

@ -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));
}
}

View File

@ -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();

View File

@ -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(marketagent)放宽红线单测 ============
@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();
}

View File

@ -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) {

View File

@ -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())

View File

@ -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();

View File

@ -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");

View File

@ -44,6 +44,7 @@ class AppMuseAgentSlotControllerAnnotationTest {
assertGet("listAgentSlots");
assertPost("precheckAgentSlot");
assertPost("bindAgentSlot");
assertPost("unbindAgentSlot");
}
@Test

View File

@ -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 decisionAI 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 后置。

View File

@ -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>

View File

@ -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 正文
*/

View File

@ -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) {

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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 =

View File

@ -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");
}
}
}
/**

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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));
}
}

View File

@ -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

View File

@ -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");

View File

@ -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();
}
}

View File

@ -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 正负路真 e2eAI 生成 e2e 反查 `source_summary.contextAssembly` 证实 Knowledge Source 被真实消费。fresh 证据:knowledge targeted 25/0F/0E/0S契约/迁移门 21/0F/0E/0Sstudio 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` 而不再送入 RAGFlowMSW-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 跳过并 warnContent 通知失败不回滚 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)。

View File

@ -0,0 +1,56 @@
package cn.iocoder.muse.module.knowledge.api;
/**
* Knowledge 草稿失效对外 API(BC 对外契约)
*
* <p> Content Canonical 正文变更后通知 Knowledge:旧正文抽取出的待确认草稿不能继续作为
* pending 候选让用户确认入库调用方只传 IDrevision 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);
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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/recheckContent 变更使草稿进入重验分支
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);
}
}
}

View File

@ -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;
}
}

View File

@ -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) {
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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()) {

View File

@ -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)

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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());
}
}
}

View File

@ -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 副本资源回收、下架/撤权更细补偿

View File

@ -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>
* 不再是发布者 kbIdknowledge 侧据此校验资产存在 + 类型 + 公开副本是否就绪并拿到副本 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 JSONBU-fork 写回的 publicForkDatasetId / forkStatusknowledge 经本端口读 market 自有数据即可
* <b>不直读 {@code market.dal}</b>ArchUnit {@code BcBoundaryArchTest} 机械约束也不需要跨 owner 反查发布者 dataset</p>
*

View File

@ -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 资产即发布者本地 kbIdagent 资产即发布者 agentId
* @param publisherUserId 发布者用户 id{@code muse_market_asset.publisher_id}
* @param assetName 资产摘要名称建本地 installed_ref kb 行的 name 取它无独立读模型时占位发布者 KB
* @param publicForkDatasetId 公开副本 dataset idU-fork 写回 tags publicForkDatasetIdforkStatus=ready 时非空

View File

@ -0,0 +1,33 @@
package cn.iocoder.muse.module.market.api.asset.event;
/**
* Market 资产来源状态变更事件
*
* <p>事件落在 market-api 契约层market 发布治理事实目标 ownerknowledge / 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
) {
}

View File

@ -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 传播记录为 blockedtenantId={}, 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()) {

View File

@ -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())

View File

@ -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` 证据锚点门禁。前端仍有深页未全覆盖真实后端。

View File

@ -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);
}

View File

@ -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