feat(studio): 补齐深面入口

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-28 08:26:47 -07:00
parent 19ff616463
commit 5acb2503b5
23 changed files with 984 additions and 57 deletions

View File

@ -51,7 +51,7 @@ Muse 1.0.0 线 A 已具备最小可用闭环:作品写作、AI 真生成、候
- 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。
- 完整导入向导已在 RC 后补到上传扫描→AI owner 解析→章节确认→Knowledge Draft 链路,并继续补齐 New-API LLM 全书深解析、docx/epub 抽取、`_test` real-PG V34+ 回归和浏览器 MSW-off 全链 e2e。偏好/通知、真实改密/2FA/session service 会话吊销仍后置线 B。Quality eval 本地确定性执行器、版本历史只读 diff、导出范围/凭证下载 UI 与真实 FileService 下载 e2e、账户安全事件处理动作、市场治理/撤权申诉独立入口已在 RC 后补;真实评估集管理/LLM judge、版本恢复/高级回滚仍后置。
- 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 版本。

View File

@ -24,13 +24,13 @@
| 域 | 完成度* | 最强(已真闭环) | 最痛(阻断点) | 盘点方 |
|---|---|---|---|---|
| market | ~86% | 前台主干真IT+e2e双证、KB物化D0-fork、agent 物化、KB 召回触达 | 生产者 UI/副本回收/下架撤权细补偿后置 | claude深盘 |
| content | ~78% | 写作/自动保存/AI采纳真PG+MSW-off e2e;E1 已补采纳归档、改后合并入口、IndexedDB 草稿键、旧知识草稿失效、工作台入口与版本历史 | EditorPage 仍 demo 壳;完整导入/导出向导后置 | codex |
| market | ~86% | 前台主干真IT+e2e双证、KB物化D0-fork、agent 物化、KB 召回触达,RC 后补市场治理/撤权申诉独立入口 | 生产者 UI/副本回收/下架撤权细补偿后置 | claude深盘 |
| content | ~88% | 写作/自动保存/AI采纳真PG+MSW-off e2e;E1 已补采纳归档、改后合并入口、IndexedDB 草稿键、旧知识草稿失效、工作台入口、版本历史只读 diff、导出范围/凭证下载 UI;RC 后完整导入向导已补 New-API LLM 全书解析、docx/epub 抽取、real-PG V34+ 与浏览器 MSW-off 全链证据;导出下载已补真实 FileService 浏览器 e2e | EditorPage 仍 demo 壳;版本恢复/高级回滚后置 | codex |
| knowledge | ~78% | KB物化、enable/disable真PG;E3 已补 Studio 检索/绑定/上传与 Source 消费真 e2e | 图谱/发布深面仍薄;召回触达已物化 KB 归 E4 | codex |
| ai | ~60% | 生成/SSE/采纳/槽位真e2e | Agent CRUD/版本/解绑缺、拒绝不打后端、评测断 | codex |
| admin | ~55% | 市场治理真IT、e2e真连PG行反查 | 权限治理P0双断层、AI配置前端全占位 | claude深盘 |
| member | ~50% | account读侧真IT扎实 | 写侧/扣减未闭环、用量无写源、pay禁用 | claude深盘 |
| meta | ~45% | planning垂直切片真e2e | 字段值校验未落、新增schema不通、API无真IT | codex |
| ai | ~80% | 生成/SSE/采纳/拒绝/Agent 生命周期/槽位 bind-unbind 真 e2e,RC 后补 Quality eval 本地执行器 | 真实评估集管理/LLM judge、grant/runtime 深层隔离后置 | codex |
| admin | ~75% | 市场治理真IT、system 管理员治理与 AI 配置写链路已接 | Admin Playwright 未跑;部分 AI 命令仍 preview-only | claude深盘 |
| member | ~70% | account 读侧真IT、AI 用量/配额写侧闭环、RC 后补安全事件处理动作入口 | pay 禁用;真实改密/2FA/session service、偏好通知后置 | claude深盘 |
| meta | ~82% | planning 垂直切片、字段值校验、新增 schema 草稿与生命周期真 PG | usage contributor 全真实建模与可视化 schema 编辑器后置 | codex |
| 横切 | ~40% | infra文件真字节往返 | 统一Schema漂移、report/bpm/mp禁用、传播worker未落 | codex |
*完成度为相对判断(供排序与定基准),非精确度量。
@ -63,7 +63,7 @@
**核心闭环(必须真可上线)**:注册登录 → 建作品 → 写作/自动保存 → AI 生成 → 采纳(归档)→ 知识库(建/绑/检索)→ 市场(发现/安装/用)。
**1.0.0 做**:见第 5 节 E0–E7。
**1.0.0 不做(后置线 B)**:pay/自助支付(用 admin 人工开通会员)、report 报表大屏、bpm 工作流、mp 公众号、安全写操作(改密/2FA/会话)、撤权独立入口、高级版本 diff、统一横切 Schema 重构、Source 全自治传播、可视化 schema 编辑器、偏好与通知页。
**1.0.0 不做(后置线 B)**:pay/自助支付(用 admin 人工开通会员)、report 报表大屏、bpm 工作流、mp 公众号、真实安全账号能力(改密/2FA/session service 会话吊销;安全事件处理动作已在 RC 后补)、管理员侧下架/撤权细补偿、版本恢复/高级回滚(只读 diff 已在 RC 后补)、统一横切 Schema 重构、Source 全自治传播、可视化 schema 编辑器、偏好与通知页。
---
@ -82,8 +82,8 @@
### E1 · content 创作闭环补齐
- T1.1 ✅ Accept Suggestion 归档:AI owner 写 accepted decision,P1r IT 扩到校验 suggestion status 与 decision archive(2026-06-27 real-PG 切片 6/0F/0E/0S)。
- T1.2 ✅ 修改后合并前端入口已补(finalContent 编辑提交);旧知识草稿失效已由 Knowledge owner API 接管,Content 正文变更后将关联 pending draft 标为 `conflicted/needs_recheck`(2026-06-27 单测 47/0F/0E,real-PG `P1rContentMergeSuggestionIT` 5/0F/0E/0S)。
- T1.3 ✅ 工作台壳已补知识/导入/导出/记录最小入口:知识跳转作品知识页,导入/导出创建后端任务,记录跳转账户中心(2026-06-27 前端本地门 12/0F/0E;MSW-off Playwright accept-suggestion 2/0F/0E)。
- T1.4 ✅ IndexedDB `workId+blockId+revision` 草稿键对账已补;版本历史最小 API/UI 已补:保存/采纳写入 `muse_content_block_revision_snapshot`,只读 `GET /blocks/{blockId}/revisions` 展示 Canonical revision 快照(2026-06-27 real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S)。
- T1.3 ✅ 工作台壳已补知识/导入/导出/记录入口:知识跳转作品知识页,导入可初始化本地正文,导出支持范围/格式选择、任务查询与下载凭证消费,记录跳转账户中心(2026-06-27 前端本地门 11/0F/0E;MSW-off Playwright accept-suggestion 2/0F/0E;2026-06-28 真后端 `export-download.spec.ts` 1/0F/0E)。
- T1.4 ✅ IndexedDB `workId+blockId+revision` 草稿键对账已补;版本历史最小 API/UI 已补:保存/采纳写入 `muse_content_block_revision_snapshot`,只读 `GET /blocks/{blockId}/revisions` 展示 Canonical revision 快照,RC 后追加前端只读版本 diff 对比(2026-06-27 real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S;Studio 目标 Vitest `BlockRevisionPanel.test.tsx` 2/0F/0E)。
- ✔ 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 智能体生命周期与闭环
@ -91,8 +91,8 @@
- T2.2 ✅ Agent 版本读/切换/归档:Studio 版本面板接真实后端,支持 list/activate/archive 非当前版本;DB 核验 `current_version_id` 与版本 `status`(2026-06-27 `agent-create.spec.ts` 1/0F/0E)。
- T2.3 ✅ Slot 解绑:Studio 槽位面板接 `unbind`,真后端把 revision 2→3、status→`archived`、响应 `sourceStatus=unbound`(2026-06-27 `agent-slot-bind.spec.ts` 1/0F/0E)。
- T2.4 ✅ 候选拒绝接后端:Studio reject 调 `POST /suggestions/{id}/reject`,AI owner 写 `rejected` + decision archive(2026-06-27 `accept-suggestion.spec.ts` 新增 reject 真生成候选 suggestionId=81)。
- T2.5 ✅ 范围收口:候选处置闭环已用真生成采纳/拒绝 e2e 覆盖;独立“候选列表页”不属于线 A 必要路径;质量评测执行器明确降级出 1.0.0 线 A,后置线 B(避免把 eval-run 入队半成品伪装成可用)。
- ✔ done:agent 全生命周期可管;采纳/拒绝候选处置闭环真 e2e;质量评测执行器不计入线 A done。
- T2.5 ✅ 范围收口:候选处置闭环已用真生成采纳/拒绝 e2e 覆盖;独立“候选列表页”不属于线 A 必要路径;RC 后已补质量评测本地确定性执行器,能把 evaluation queued job 推进到 completed/failed 并写 sample result/聚合摘要;真实评估集管理与 LLM judge 后置。
- ✔ done:agent 全生命周期可管;采纳/拒绝候选处置闭环真 e2e;质量评测已具备最小执行闭环,深度 judge 不计入线 A done。
### E3 · knowledge studio 与检索
- T3.1 ✅ Studio 检索触发 + 绑定/上传主流程 e2e:`KnowledgeRetrievalPanel` 接真实检索端点,绑定读回、market KB handoff、资料上传均由 MSW-off Playwright 真后端覆盖。

View File

@ -23,9 +23,9 @@
**round-2 加固(2026-06-14,据 Opus 评审;2026-06-27 E0 续固)**:CI 触发分支 master→main(此前 CI 从不运行)、BC 门通用化 + 整改 knowledge 违例、loop 装机械牙、覆盖门去魔法数、文档诚实化、openapi-diff materialize 为独立 workflow。E0 将 Maven CI 移到仓库根 `.github/workflows/maven.yml`,并用 `run-p1r-verification.sh` + [真验证清单](1.0.0-真验证清单.md) 区分 local-ci / real-PG / external-live / mock-only / skipped。E0 fresh 证据:local 61/0F/0E/0S,根 CI 等价 `mvn -B package --file pom.xml` 3060/0F/0E/148S,real-PG 189/0F/0E/0S,external-live server 8/0F/0E/0S + module 4/0F/0E/1S(GraphRAG attribution 未配置跳过)。
**E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录最小入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business audit;AI owner 不可用时整笔 merge 回滚,避免 Canonical 已写但候选仍 pending。Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive;通知失败不回滚 Canonical 主写,Knowledge confirm 端仍按来源状态 fail-closed。Studio `CandidatePanel` 支持编辑最终正文并按 `accept_as_is`/`modify_then_merge` 提交,IndexedDB 草稿键改为 `workId+blockId+revision` 防跨作品/版本污染。`saveBlock`/`mergeBlockSuggestion` 现在写 `muse_content_block_revision_snapshot`,工作台“历史”Tab 只读展示 Canonical revision 快照;导入/导出入口创建真实任务,知识/记录入口跳转对应工作台。fresh 证据:后端局部单测 `ContentSourceServiceTest` 25/0F/0E、`ContentAppServiceTest` 19/0F/0E、`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E、`AiSuggestionMergeProjectionFacadeTest` 7/0F/0E;契约/覆盖门 `ContractFirstGateTest` 4/0F/0E + `P1rApiCoverageReportTest` 8/0F/0E;real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S;studio `tsc -b --force` 通过、lint 0 errors、Vitest 12/12,追加 `AIPanel.contract.test.tsx` + `sse.test.ts` 22/0F/0E。共享 PG 写入闸门批准后已补跑 MSW-off Playwright 真后端 `accept-suggestion.spec.ts` 2/0F/0E:正路真 New-API 生成 suggestionId=79、authz `rpe-local-*`、Block revision 160→161、AI owner accepted decision archive 非空;负路 stale revision 业务冲突。边界:`muse-studio/src/types/content.ts` 生成类型因 openapi-typescript 版本漂移未同步,hook 内暂维护 `BlockRevision` 最小类型;完整导入上传/导出下载 UI 后置。
**E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business audit;AI owner 不可用时整笔 merge 回滚,避免 Canonical 已写但候选仍 pending。Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive;通知失败不回滚 Canonical 主写,Knowledge confirm 端仍按来源状态 fail-closed。Studio `CandidatePanel` 支持编辑最终正文并按 `accept_as_is`/`modify_then_merge` 提交,IndexedDB 草稿键改为 `workId+blockId+revision` 防跨作品/版本污染。`saveBlock`/`mergeBlockSuggestion` 现在写 `muse_content_block_revision_snapshot`,工作台“历史”Tab 只读展示 Canonical revision 快照;导入可创建真实任务,导出支持范围/格式选择、任务查询与下载凭证消费,知识/记录入口跳转对应工作台。fresh 证据:后端局部单测 `ContentSourceServiceTest` 25/0F/0E、`ContentAppServiceTest` 19/0F/0E、`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E、`AiSuggestionMergeProjectionFacadeTest` 7/0F/0E;契约/覆盖门 `ContractFirstGateTest` 4/0F/0E + `P1rApiCoverageReportTest` 8/0F/0E;real-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S;studio `tsc -b --force` 通过、lint 0 errors、Vitest 12/12,追加 `AIPanel.contract.test.tsx` + `sse.test.ts` 22/0F/0E;导出 UI 追加 `useWorks.test.tsx`+`ExportWorkModal.test.tsx` 11/0F/0E、目标 ESLint 0 errors。共享 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` 最小类型;完整导入向导已在 RC 后补,见下方记录;真后端导出下载 e2e 已在 2026-06-28 补跑通过,FileApi 异常仍按后端合同 fail-closed。
**E2 ai 智能体生命周期与候选处置闭环(2026-06-27)**:已补用户自建 Agent update/archive、初始版本自动创建、版本列表/激活/归档非当前版本、作品槽位 unbind、Studio reject 调后端 AI owner 决策归档。Agent update 会创建下一 active version 并更新 `current_version_id`,archive Agent 同步归档仍 active 的槽位绑定;slot unbind 将绑定行 revision+1 且 `status=archived`,运行时回到默认能力;WorkspacePage “放弃修改”不再只清本地候选,而是真打 `POST /suggestions/{id}/reject` 写 `rejected` 与 decision archive。启动修红:最新单体启动时暴露 `muse.codegen.importEnable` 缺省,已在 `muse-server/src/main/resources/application.yaml` 补 `import-enable:false`,随后 48080 成功启动并 `GET /app-api/muse/agents` smoke 返回 `code=0`。fresh 证据:后端 AI targeted `MuseAgentServiceTest`+`MuseAgentSlotServiceTest`+Controller annotation 63/0F/0E;契约门 `ContractFirstGateTest` 4/0F/0E;studio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0E;MSW-off Playwright 真后端 `agent-create.spec.ts`+`agent-slot-bind.spec.ts`+`accept-suggestion.spec.ts` 5/0F/0E:采纳真生成 suggestionId=80、Block revision 161→162、accepted decision archive 非空;拒绝真生成 suggestionId=81、`status=rejected`、decision archive 非空;Agent 生命周期 DB 核验 v1/v2 current 切换与 v2 archived;Slot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:质量评测执行器未落地,明确降级出 1.0.0 线 A、后置线 B;本轮未跑 studio 全量 e2e。
**E2 ai 智能体生命周期与候选处置闭环(2026-06-27)**:已补用户自建 Agent update/archive、初始版本自动创建、版本列表/激活/归档非当前版本、作品槽位 unbind、Studio reject 调后端 AI owner 决策归档。Agent update 会创建下一 active version 并更新 `current_version_id`,archive Agent 同步归档仍 active 的槽位绑定;slot unbind 将绑定行 revision+1 且 `status=archived`,运行时回到默认能力;WorkspacePage “放弃修改”不再只清本地候选,而是真打 `POST /suggestions/{id}/reject` 写 `rejected` 与 decision archive。启动修红:最新单体启动时暴露 `muse.codegen.importEnable` 缺省,已在 `muse-server/src/main/resources/application.yaml` 补 `import-enable:false`,随后 48080 成功启动并 `GET /app-api/muse/agents` smoke 返回 `code=0`。fresh 证据:后端 AI targeted `MuseAgentServiceTest`+`MuseAgentSlotServiceTest`+Controller annotation 63/0F/0E;契约门 `ContractFirstGateTest` 4/0F/0E;studio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0E;MSW-off Playwright 真后端 `agent-create.spec.ts`+`agent-slot-bind.spec.ts`+`accept-suggestion.spec.ts` 5/0F/0E:采纳真生成 suggestionId=80、Block revision 161→162、accepted decision archive 非空;拒绝真生成 suggestionId=81、`status=rejected`、decision archive 非空;Agent 生命周期 DB 核验 v1/v2 current 切换与 v2 archived;Slot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:本轮未跑 studio 全量 e2e。
**E3 knowledge studio 与检索闭环(2026-06-27)**:已补 Studio 主动检索面板、no_dataset 可见治理提示、自建知识库上传真实后端读回、market KB handoff 正负路、AI 生成消费 Knowledge Source 的反查证据。新增 V33 迁移允许 `muse_knowledge_bind_precheck.kb_id` 在 market_kb 预检阶段为空,修正“本地 installed_ref KB 尚未物化但预检先落库”的 schema 漂移;前端 `useKnowledge` 归一真实后端 numeric `documentId/kbId`,修复上传后列表被类型守卫过滤。fresh 证据:后端 knowledge targeted 25/0F/0E/0S;契约/迁移门 `P1rKnowledgeMigrationSqlTest`+`ContractFirstGateTest`+`P1rApiCoverageReportTest` 21/0F/0E/0S,coverage JSON 当前 242/242;studio knowledge Vitest 19/0F/0E、目标 ESLint 0 errors、`tsc -b --force` 通过;MSW-off Playwright 真后端 `knowledge-retrieval.spec.ts`+`knowledge-bindings.spec.ts`+`handoff-knowledge.spec.ts`+`market-handoff-precheck.spec.ts` 6/0F/0E;`ai-generation.spec.ts` 1/0F/0E,真 New-API 生成 suggestionId=82,并反查 `source_summary.contextAssembly` 中 `chunkCount>0`、`retrievedKbIds`、`authorizationSnapshotIds` 非空。边界:知识图谱/发布深面仍后置;已物化 KB 的召回触达归 E4。
@ -41,7 +41,17 @@
**RC 基线记录(2026-06-27 11:05 PDT)**:`v1.0.0-rc.1` 指向 `3585219 feat(mvp): 收束1.0.0线A交付闭环`,分支 `dev/1.0.0` 已推送到 `origin/dev/1.0.0`。**mini-desktop 部署已完成(2026-06-27 11:28 PDT)**:实际入口 `root@100.64.0.7`,部署目录 `/opt/muse-rc1`,端口后端 `48081` / Studio `4175` / Admin `4176`,保留既有 `48080/4173/4174` 其它项目进程。远端补齐 OpenJDK 21 与本机 loopback Redis;后端业务 smoke `GET /app-api/muse/agents|works|knowledge-bases` 均 `code=0`,Studio/Admin HTML 均 HTTP 200,经 `4175/app-api/muse/works` 与 `4176/admin-api/muse/ai/agents` 反代均 `code=0`。部署细节与边界见 [1.0.0-RC发布说明 §4](1.0.0-RC发布说明.md#4-mini-desktop-部署)。
**RC 后补功能:本地文本导入初始化正文(2026-06-27)**:已补 `POST /app-api/muse/works/{workId}/import-tasks` 的 inline text 模式,支持 Studio 选择 `.txt/.md` 或粘贴文本,后端按 Markdown/CN/EN 章节标题保守拆章,并在同一事务内创建 `muse_content_import_task`、`muse_content_chapter`、`muse_content_block`、Block 来源归因、Content Events outbox、`muse_content_block_revision_snapshot`,同时更新 work `import_status/chapter_count/word_count/revision`。外部上传模式仍要求 `uploadUrl` 并继续走 FileService 扫描 fail-closed;AI 全书解析 Shadow/章节确认向导仍后置,不把解析结果直接写 Canonical。fresh 证据:后端 `ContentImportParseServiceTest`+`AppContentImportParseControllerTest` 22/0F/0E,契约/覆盖门 `ContractFirstGateTest`+`P1rApiCoverageReportTest` 12/0F/0E,真实 PG `_test` 库 `P1rContentImportTaskCompletedApprovalIT` 4/0F/0E/0S(已确认需在 surefire forked JVM 显式清 `socksProxyHost/socksProxyPort` 并透传 `p1r.flyway.*`,此前 EOF 为 forked JVM 代理残留导致的假红);Studio `useWorks.test.tsx` 9/0F/0E、`tsc -b --force` 通过、目标 ESLint 0 errors。边界:外部上传扫描成功链路、AI 全书解析 Shadow/章节确认向导、导出下载凭证/范围选择 UI 后置。
**RC 后补功能:本地文本导入初始化正文(2026-06-27)**:已补 `POST /app-api/muse/works/{workId}/import-tasks` 的 inline text 模式,支持 Studio 选择 `.txt/.md` 或粘贴文本,后端按 Markdown/CN/EN 章节标题保守拆章,并在同一事务内创建 `muse_content_import_task`、`muse_content_chapter`、`muse_content_block`、Block 来源归因、Content Events outbox、`muse_content_block_revision_snapshot`,同时更新 work `import_status/chapter_count/word_count/revision`。fresh 证据:后端 `ContentImportParseServiceTest`+`AppContentImportParseControllerTest` 22/0F/0E,契约/覆盖门 `ContractFirstGateTest`+`P1rApiCoverageReportTest` 12/0F/0E,真实 PG `_test` 库 `P1rContentImportTaskCompletedApprovalIT` 4/0F/0E/0S(已确认需在 surefire forked JVM 显式清 `socksProxyHost/socksProxyPort` 并透传 `p1r.flyway.*`,此前 EOF 为 forked JVM 代理残留导致的假红);Studio `useWorks.test.tsx` 9/0F/0E、`tsc -b --force` 通过、目标 ESLint 0 errors。完整上传/解析/确认向导见下一条。
**RC 后补功能:完整导入向导后端 owner/parse 链路(2026-06-28)**:已补 Studio 完整导入向导与后端 owner 实现。Infra 新增 `POST /app-api/infra/file/upload-returning-path` 返回稳定存储 path;Content 外部上传任务只接受 infra 稳定 path 并通过 FileApi 读真字节扫描,非 passed fail-closed,不再接受任意外链 URL;AI 新增 import parse owner API 与 `muse_ai_parse_job` / `muse_ai_chapter_parse_result`,创建 parse job 后读取上传文件,支持 `.txt/.md/.markdown/.docx/.epub`,默认按 Markdown/CN/EN 标题确定性拆章;当 `parseConfig.mode=full_book` 或 `strategy=llm_full_book` 时调用 New-API 全书解析,401/403 fail-closed,408/429/5xx/连接超时可重试并带退避,provider 原文和凭据不落日志/证据。Knowledge 新增 draft creation owner API,章节确认只创建 pending Knowledge Draft,不直写 Local KB Canonical。Studio `ImportTextModal` 升级为上传扫描 → AI 全书解析 → 章节确认 → Knowledge Draft 的向导,同时保留快速文本导入,并支持失败可重试解析任务。fresh 证据:后端 targeted `RealContentFileFacadeTest`+`ContentImportParseServiceTest`+`MuseAiImportParseServiceTest`+`NewApiMuseAiImportLlmParserTest`+`MuseKnowledgeDraftCreationApiImplTest`+`AppFileControllerTest` 通过;AI New-API parser live `P1rImportLlmNewApiLiveAcceptanceIT` 显式 opt-in 1/0F/0E/0S;真实 PostgreSQL `_test` 库 `P1rContentImportWizardCompletedApprovalIT` 已 clean+migrate 到 V34+,docx 确定性链路默认跑 2 tests / 0F / 0E / 1S,New-API live LLM 完整链路显式 opt-in 1/0F/0E/0S;Studio targeted Vitest `useWorks.test.tsx`+`ExportWorkModal.test.tsx` 12/0F/0E、`tsc -b --force` 通过;追加浏览器 MSW-off Playwright 真后端 `import-wizard.spec.ts` 1/0F/0E(真实 48080 + 共享 `muse_slice_live` + New-API),DB 反查 parse job completed、2 条 confirmed Shadow、2 条 pending Knowledge Draft、Canonical entity 计数不变。边界:Knowledge Draft 进入 Local KB Canonical 仍需用户后续显式审核确认;FileApi/对象存储异常按合同 fail-closed,不伪造解析成功。
**RC 后补功能:作品导出范围选择与凭证下载 UI(2026-06-28)**:Studio 工作台导出从单按钮升级为导出弹窗,支持 TXT/DOCX/EPUB、全部/指定章节、元数据/规划开关;`useCreateExportTask` 按现有后端契约提交 `includeChapters/includeMetadata/includePlanning`,`useExportTask` 轮询未完成任务,`api.download` 统一携带 `X-API-Version`/tenant/Bearer 并解析 `application/octet-stream` 与 JSON 业务错误,完成态凭 `downloadCredentialId` 取回导出包。MSW 补 `GET /export-tasks/{taskId}` 与 `/downloads/{credentialId}` 二进制响应,防止单测只验证“任务创建”假绿。fresh 证据:Studio `./node_modules/.bin/tsc -b --force` 通过,目标 Vitest `useWorks.test.tsx`+`ExportWorkModal.test.tsx` 12/0F/0E,目标 ESLint 0 errors;追加浏览器 MSW-off Playwright 真后端 `export-download.spec.ts` 1/0F/0E,真实 48080 + 共享 `muse_slice_live` + 本地 FileService master,DB 反查导出任务 completed、`package_ref/credential_id` 非空,浏览器 `download` 事件读取到的 TXT 文件包含真实作品标题和正文片段。修复测试口径问题:前端代码已 `response.blob()` 消费下载响应后,测试不再二次读取同一 `Response.text()`,改为验证用户实际下载文件;清理 SQL 不再误限定 `infra_file.tenant_id=1`。边界:后端 FileApi/对象存储不可用时仍按合同 fail-closed,不伪造下载包。
**RC 后补功能:Block 版本历史只读 diff(2026-06-27)**:Studio 工作台历史面板从单版本快照列表升级为任意两版只读对比,默认展示最新版本与前一版本,支持选择基准/目标版本,按段落/句子拆分后用 LCS 计算新增、删除、不变行数,并保留原有快照列表与详情。该能力只消费既有 `GET /app-api/muse/works/{workId}/blocks/{blockId}/revisions`,不新增恢复/回滚写入口,避免绕过 Content Canonical 写入边界。fresh 证据:目标 Vitest `BlockRevisionPanel.test.tsx` 2/0F/0E;`BlockRevisionPanel.test.tsx`+`useWorks.test.tsx` 11/0F/0E;Studio `tsc -b --force` 通过;目标 ESLint 0 errors。边界:版本恢复、高级回滚、跨章节 diff 仍后置。
**RC 后补功能:Quality eval 最小执行器(2026-06-27)**:AI 质量评估不再停在 `evaluation` job 入队。新增 `MuseQualityEvaluationWorker` + `MuseQualityEvaluationExecutor`,按数据库 `FOR UPDATE SKIP LOCKED` 原子领取 queued evaluation job,默认启用;执行器只接受合成/脱敏 datasetReference(`dataset://`/`synthetic://`/`redacted://`/含 redacted/smoke),用质量策略维度做确定性本地评分,写入 `muse_ai_evaluation_sample_result` 样本结果、聚合 `metricSummary`,并把 `muse_ai_evaluation_run` / `muse_ai_job` 推进到 `completed` 或 `failed`。审计只记录 dataset hash/安全分类和聚合摘要,不记录私有正文、原始 dataset 引用、Prompt/Response 或 provider body;不调用外部 LLM。fresh 证据:AI target tests `MuseQualityEvaluationWorkerTest`+`MuseAiJobMapperTest`+`MuseQualityPolicyServiceTest` 18/0F/0E;`git diff --check` 通过。边界:真实评估集管理、外发校验凭证、LLM judge、评估结果对比/发布建议仍后置。
**RC 后补功能:账户安全事件处理与市场治理申诉独立入口(2026-06-27)**:Studio 个人中心安全事件不再只有单一“确认”,改为消费既有 `POST /app-api/muse/account/security-events/{eventId}/acknowledge` 契约内四类 action:`acknowledged/password_changed/session_revoked/false_positive`,写入时带 `commandId` 幂等键与用户 note,成功后展示后端 `riskSummary/nextSteps` 并刷新安全事件列表。Market 新增独立 `/market/appeals` 治理与申诉入口,复用真实 `GET /marketplace/appeals`、`POST /marketplace/appeals/{appealId}/supplements`、`POST /marketplace/appeals/{appealId}/withdraw`,侧边栏与市场页均可直达;该页只处理发布者/使用者面对下架、召回、撤权等治理结果后的申诉/补充/撤回,不暴露管理员执行撤权/召回/下架命令。fresh 证据:Studio target Vitest `BlockRevisionPanel.test.tsx`+`useAccount.test.tsx`+`useMarketPublish.test.tsx`+`MarketAppealCenter.test.tsx` 4 files / 11 tests passed;Studio `tsc -b --force` 通过;目标 ESLint 0 errors;`git diff --check` 通过。边界:偏好/通知页仍无 app 写契约,真实改密/2FA/session service 会话吊销仍后置;撤权执行仍在 admin 治理侧,普通用户侧只做治理结果申诉。
**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),现已补正。

View File

@ -31,13 +31,13 @@
| 模块 | 完成度 | 后端 | 前端 studio | 前端 admin | 接口门(机械) | 关键风险/缺口 |
|---|:---:|:---:|:---:|:---:|:---:|---|
| **AI 编排** (ai) | 78% | ✅ 真实 | ✅ 核心生命周期/运行时真 e2e | — | 48/48 | 质量评测执行器后置;grant/runtime 物理隔离仅最小 ArchUnit |
| **作品/正文** (content) | 89% | ✅ 真实 | ✅ 候选链/工作台入口/版本历史已接 | — | 52/52 | E1 黄金旅程 MSW-off Playwright 2/2;EditorPage 仍 demo 壳 |
| **AI 编排** (ai) | 80% | ✅ 真实 | ✅ 核心生命周期/运行时真 e2e | — | 48/48 | Quality eval 本地执行器已补;LLM judge 与 grant/runtime 物理隔离仍后置 |
| **作品/正文** (content) | 89% | ✅ 真实 | ✅ 候选链/工作台入口/版本历史只读 diff 已接 | — | 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 缺 |
| **市场** (market) | 87% | ✅ 真实 | 🟡 消费端+handoff+治理申诉入口已真连,生产端仍薄 | ✅ 真实 | 32/32 | agent 物化/KB 召回触达已闭环;发布生产者深面仍缺 |
| **元引擎/治理** (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 |
| **账户/会员** (member) | 70% | ✅ 真实 | 🟡 安全事件处理/用量权益已接,深面仍缺 | 🟡 受限桩 | 33/33 | 偏好通知/真实改密/2FA/session service 仍缺;New-API/FileService facade Unavailable |
| **平台底座** (gateway/system/infra…) | 80% | ✅ 真实 | ✅ 真实 | ✅ 真实 | — | 网关运行期转发未验(Nacos);pay/bpm/mp/report ⚪默认隐藏、0 覆盖 |
| **整体** | **~80%** | **~82%** | **~40%** | 良好 | **242/242** | 前端用户端深面缺口 + 跨 BC 集成仍需 E5-E7 |
@ -59,7 +59,7 @@
| 影响预览(迁移影响范围) | 🔴 | 🟡 | facade 恒 Unavailable;admin 读侧显示 0 affected |
| 系统级智能体与功能编排 | ✅ | 🟡 | 后端链路完整;部分 owner 计数硬编码 0 |
| 全局知识库授权 | ✅ | 🟡 | 后端就绪 |
| 质量门控与效果观测 | 🟡 | 🟡 | 评估作业已起,LLM-judge 深度待明 |
| 质量门控与效果观测 | 🟡 | 🟡 | 评估作业已可本地执行并产出样本/聚合结果;LLM-judge 深度待明 |
| 市场审核与治理(审核/下架/召回/申诉) | ✅ | ✅ | index.vue 1166 行生产级;IT 全绿 |
| 用户/角色/权限/数据范围 | ✅ | ✅ | 继承 yudao,真实 |
| New-API 网关用户与用量 | 🟡 | 🟡 | 桩待真 New-API provider |
@ -75,8 +75,9 @@
| **写作台·AI 候选生成 + 接受(原样/改后/丢弃)** | ✅ | ✅ | WorkspacePage 接通 `suggestion-merges` 与 `suggestions/{id}/reject`;E1 已补原样/改后合并、采纳归档与旧知识草稿失效,E2 已补拒绝归档,真实生成归 external-live 层复跑 |
| 作品规划台(设定/大纲/世界/角色/文风) | ✅ | 🟡 | 后端就绪;FE 部分 |
| 知识与一致性(草稿确认/修正/冲突检查) | ✅ | 🟡 | KnowledgeDraftPanel 已有确认/忽略/重验入口;E1 已补 Content 正文变更后旧草稿 `needs_recheck` |
| 导入解析(上传/按章确认→知识草稿) | ✅ | 🟡 | E1 已补工作台最小导入任务入口;完整上传/解析确认向导仍后置 |
| 导出交付(范围/格式/授权校验) | ✅ | 🟡 | E1 已补工作台 TXT 导出任务入口;下载凭证与范围选择 UI 后置 |
| 导入解析(上传/按章确认→知识草稿) | ✅ | ✅ | 完整上传扫描→AI owner 解析→章节确认→Knowledge Draft 向导已补;支持 txt/md、docx/epub 抽取与 New-API LLM 全书解析,浏览器 MSW-off 全链已真验 |
| 导出交付(范围/格式/授权校验) | ✅ | ✅ | 工作台已补格式/范围选择、任务查询与下载凭证消费;真后端 FileService 下载 e2e 已通过 |
| 版本历史与对比 | ✅ | ✅ | 只读历史快照 + 任意两版 diff 已接;恢复/回滚仍后置 |
| 记录与用量 / 作品设置 | ✅ | 🟡 | E1 已补工作台记录入口;账户中心记录面仍按 member 进度推进 |
### 3. 智能体工作台(ai) — 后端 ✅ / 前端 ✅(核心生命周期)
@ -114,7 +115,7 @@
| 资产详情·许可条款·收藏 | ✅ | ✅ | 详情页全量许可 + 收藏(e2e market-asset-detail 1/1) |
| 发布者控制面(草稿/提交/审核/上架) | ✅ | 🔴 | **草稿保存已通(e2e 8/8),其余生产者 UI 缺** |
| Handoff 一次性令牌 | ✅ | ✅ | knowledge/agent/content 三类消费端均已真后端 e2e;E4 补 agent 物化与 KB 召回阻断 |
| 申诉(提交/补充/撤回/状态) | ✅ | 🔴 | 后端 IT 全绿;FE 无 |
| 申诉(提交/补充/撤回/状态) | ✅ | ✅ | 独立 `/market/appeals` 治理与申诉入口已接;管理员执行撤权/召回仍在 admin 侧 |
| 市场审核治理(管理端) | ✅ | ✅(admin) | 见 §1 |
### 6. 个人中心(account/member) — 后端 ✅ / 前端 🔴(~21%)
@ -123,7 +124,7 @@
| 功能 | 后端 | 前端 studio | 备注 |
|---|:---:|:---:|---|
| 账户总览 | 🟡 | 🟡 | 部分诚实占位(emailVerified/mfaEnabled 硬编 false) |
| 个人资料 / 安全登录 / 偏好通知 | 🟡 | 🔴 | session 吊销未接 session service |
| 个人资料 / 安全登录 / 偏好通知 | 🟡 | 🟡 | 安全事件处理动作已接;偏好通知、真实改密/2FA/session service 会话吊销仍缺 |
| Token 与用量总览 | ✅ | ✅ | `/account/usage` 真接 |
| 权益与配额 | ✅ | ✅ | `/account/entitlements` 真接 |
| 授权/购买/安装绑定/发布记录 | ✅ | 🟡 | 购买/授权/发布记录三件套已接(PersonalCenter 真接,e2e account-publish-records 1/1);安装绑定等其余记录仍缺 |

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(FileService/解析投影等)未接入的路径仍按合同 fail-closed,需要配置真实外部依赖或 owner 服务后才能从 fail-closed 升级为完整成功路径。**2026-06-25:AI suggestion 采纳已从 fail-closed 升级为真成功(ADR-020 方案 A,commit a16c596)**——此前真生成候选 `authorization_snapshot_id=null` 恒被 `mergeBlockSuggestion` 拒(1041001001),根因 authz 列 BIGINT 存不下字符串 runtime envelope `rpe-local-uuid`;V30 把 `muse_content_block_source_attribution`+`muse_ai_suggestion` authz 列 BIGINT→VARCHAR(128)、放开 `ContentSourceServiceImpl` 两道数值门(删 requireNumericAuthorizationSnapshot/parseAuthorizationSnapshotId/parseLongQuietly,字符串直落、外部 owner 非数值编号不再丢)、`BlockSourceAttributionDO.authorizationSnapshotId` 改 String。活体 merge code=0+revision 自增、IT `P1rContentMergeGeneratedSuggestion` 假绿(注入数值 9001)转真绿。详见总账 2026-06-25 / ADR-020。**2026-06-27:E1 采纳归档 + 旧知识草稿失效切片已补**——`mergeBlockSuggestion` 写 Canonical/来源归因后必须让 AI owner 归档 accepted decision;AI owner 不可用则整笔回滚,避免正文已写但 suggestion 仍 pending。Content 正文变更后经 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck`,通知失败不回滚 Canonical 主写但 confirm 端 fail-closed。P1r 真 PG `P1rContentMergeSuggestionIT` 5/0F/0E/0S 已校验 suggestion `accepted`、decision archive、AI command/audit、Knowledge draft decision 与幂等 replay;`P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S 回归生成候选采纳。**2026-06-27:E1 工作台入口 + 版本历史已补**——`saveBlock`/`mergeBlockSuggestion` 写 `muse_content_block_revision_snapshot`,`GET /app-api/muse/works/{workId}/blocks/{blockId}/revisions` 只读返回 Canonical revision 快照,Studio 工作台“历史”Tab 展示;工作台知识/导入/导出/记录最小入口已接。P1r 真 PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S 校验版本历史、owner/tenant 守卫和纯查询不写;前端 `tsc`/lint/Vitest 12/12 绿;MSW-off Playwright `accept-suggestion.spec.ts` 2/0F/0E 验证真 New-API suggestionId=79 采纳后 revision 160→161、AI owner accepted decision archive 非空。**2026-06-27:E7 Meta 动态字段校验已从 fail-closed 升级为真成功**——`RealContentMetaFacade.validateDynamicFields` 读取 Meta active projection 校验 required/type/enum/range/regex/deprecated/unknown field 并返回 routeSuggestions,`P1rContentMetaProjectionClusterCompletedApprovalIT` 4/0F/0E/0S 覆盖 no-schema fail-closed 与 active projection 成功规则命中且 0 Content 写。**2026-06-27:RC 后本地文本导入初始化正文已补**——`CreateImportTaskReqVO.contentText` 支持 txt/markdown inline 模式,后端保守拆章并同事务写 import task/chapter/block/source attribution/outbox/block revision snapshot/work 统计;Studio 导入弹窗可选 `.txt/.md` 或粘贴正文。验证:Content import/controller 单测 22/0F/0E、契约/覆盖门 12/0F/0E、Studio hook 9/0F/0E、tsc/目标 lint 绿;真实 PG `_test` 库 `P1rContentImportTaskCompletedApprovalIT` 4/0F/0E/0S 已绿(需在 surefire forked JVM 清 `socksProxyHost/socksProxyPort` 并透传 `p1r.flyway.*`,此前 JDBC EOF 是代理残留假红)。仍缺:外部上传扫描成功链路、AI 全书解析/章节确认向导、导出下载凭证/范围选择 UI 后置。
- **关键风险 / TODO**:Content 验收债已补齐真实 PG 证据;外部 owner(FileService/解析投影等)未接入的路径仍按合同 fail-closed,需要配置真实外部依赖或 owner 服务后才能从 fail-closed 升级为完整成功路径。**2026-06-25:AI suggestion 采纳已从 fail-closed 升级为真成功(ADR-020 方案 A,commit a16c596)**——此前真生成候选 `authorization_snapshot_id=null` 恒被 `mergeBlockSuggestion` 拒(1041001001),根因 authz 列 BIGINT 存不下字符串 runtime envelope `rpe-local-uuid`;V30 把 `muse_content_block_source_attribution`+`muse_ai_suggestion` authz 列 BIGINT→VARCHAR(128)、放开 `ContentSourceServiceImpl` 两道数值门(删 requireNumericAuthorizationSnapshot/parseAuthorizationSnapshotId/parseLongQuietly,字符串直落、外部 owner 非数值编号不再丢)、`BlockSourceAttributionDO.authorizationSnapshotId` 改 String。活体 merge code=0+revision 自增、IT `P1rContentMergeGeneratedSuggestion` 假绿(注入数值 9001)转真绿。详见总账 2026-06-25 / ADR-020。**2026-06-27:E1 采纳归档 + 旧知识草稿失效切片已补**——`mergeBlockSuggestion` 写 Canonical/来源归因后必须让 AI owner 归档 accepted decision;AI owner 不可用则整笔回滚,避免正文已写但 suggestion 仍 pending。Content 正文变更后经 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck`,通知失败不回滚 Canonical 主写但 confirm 端 fail-closed。P1r 真 PG `P1rContentMergeSuggestionIT` 5/0F/0E/0S 已校验 suggestion `accepted`、decision archive、AI command/audit、Knowledge draft decision 与幂等 replay;`P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S 回归生成候选采纳。**2026-06-27:E1 工作台入口 + 版本历史已补**——`saveBlock`/`mergeBlockSuggestion` 写 `muse_content_block_revision_snapshot`,`GET /app-api/muse/works/{workId}/blocks/{blockId}/revisions` 只读返回 Canonical revision 快照,Studio 工作台“历史”Tab 展示;工作台知识/导入/导出/记录最小入口已接。P1r 真 PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S 校验版本历史、owner/tenant 守卫和纯查询不写;前端 `tsc`/lint/Vitest 12/12 绿;MSW-off Playwright `accept-suggestion.spec.ts` 2/0F/0E 验证真 New-API suggestionId=79 采纳后 revision 160→161、AI owner accepted decision archive 非空。**2026-06-27:E7 Meta 动态字段校验已从 fail-closed 升级为真成功**——`RealContentMetaFacade.validateDynamicFields` 读取 Meta active projection 校验 required/type/enum/range/regex/deprecated/unknown field 并返回 routeSuggestions,`P1rContentMetaProjectionClusterCompletedApprovalIT` 4/0F/0E/0S 覆盖 no-schema fail-closed 与 active projection 成功规则命中且 0 Content 写。**2026-06-27:RC 后本地文本导入初始化正文已补**——`CreateImportTaskReqVO.contentText` 支持 txt/markdown inline 模式,后端保守拆章并同事务写 import task/chapter/block/source attribution/outbox/block revision snapshot/work 统计;Studio 导入弹窗可选 `.txt/.md` 或粘贴正文。验证:Content import/controller 单测 22/0F/0E、契约/覆盖门 12/0F/0E、Studio hook 9/0F/0E、tsc/目标 lint 绿;真实 PG `_test` 库 `P1rContentImportTaskCompletedApprovalIT` 4/0F/0E/0S 已绿(需在 surefire forked JVM 清 `socksProxyHost/socksProxyPort` 并透传 `p1r.flyway.*`,此前 JDBC EOF 是代理残留假红)。**2026-06-27:RC 后作品导出范围选择与凭证下载 UI 已补**——Studio 导出弹窗支持 TXT/DOCX/EPUB、全部/指定章节、元数据/规划开关;hook 接 `createExportTask/getExportTask/downloads/{credentialId}`,`api.download` 解析 octet-stream 与 JSON 业务错误。验证:Studio `tsc -b --force`、目标 Vitest 12/0F/0E、目标 ESLint 0 errors;2026-06-28 已补 `export-download.spec.ts` MSW-off 真后端 FileService 下载 e2e 1/0F/0E,FileApi 不可用仍按后端合同 fail-closed。**2026-06-27:RC 后 Block 版本历史只读 diff 已补**——Studio 历史面板支持任意两版基准/目标选择,按段落/句子做 LCS diff 并展示新增/删除/不变统计;只读消费版本历史 API,不新增恢复/回滚写入口。验证:目标 Vitest 2/0F/0E、`tsc`、目标 ESLint 绿。**2026-06-28:RC 后完整导入向导已补**——Infra 上传返回稳定 path,Content 外部导入任务只接受 infra path 并用 FileApi 真字节扫描;AI owner 持久化 parse job 与章节 Shadow;Knowledge owner 在章节确认时只产 pending Draft,不写 Local KB Canonical;Studio 向导串起上传扫描→解析→章节确认。验证见总账 2026-06-28,含浏览器 MSW-off 导入全链 e2e。2026-06-28 已补 `export-download.spec.ts` MSW-off 真后端 FileService 下载 e2e 1/0F/0E。仍缺:版本恢复/高级回滚。

View File

@ -6,4 +6,4 @@
- **边界(不可违反)**:**进 Local KB 唯一入口=用户显式确认草稿或手动修正**;全书解析章节确认只产/推 Draft,不写正式知识;来源优先级 Local KB > 用户绑定 > 市场/全局;依赖 RAGFlow GraphRAG(不可用时 Unavailable 优雅降级);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
- **out-of-scope**:正文写入(归 content)。
- **现状**:只读评估 78%;后端最完整模块之一(67 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30);source binding projection 回填闭环已修(2026-06-19:绑定确认为权威回填点写读模型行、unbind 按 bindingId 作用域撤销不误伤同源跨作品投影、唯一键补 work_id 修跨作品复用误判冲突〔V24〕;`MuseKnowledgeBindingServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 11/11、整套件 214/214 绿);**读回端到端坐实 + unbind 真软删修复(2026-06-19)**:嵌入式 DB(H2)往返证 bind 写投影→`selectActiveByWorkId` 读回该来源、unbind 后读回消失、同源他作品投影存活;并修 unbind 软删真 bug——`deleted` 是 `@TableLogic`,实体 `setDeleted(true)` 被 MP 普通 update 剥离成空操作,改 `setSql("deleted = true")` 才真软删(`MuseKnowledgeSourceBindingProjectionRoundTripTest` 2/2、整套件 216/216 绿;新增 knowledge 模块首套嵌入式 DB 测试基建)。**GET bindings 读端点已补(2026-06-19)**:`AppMuseKnowledgeBindingController` 加 `GET /muse/works/{workId}/knowledge-bindings`→`listKnowledgeBindings`(requireWorkOwner 防 IDOR + selectActiveByWorkId 读回投影,id→String;happy-path + 越权 fail-closed 单测,整套件 218/218 绿);**bindings 读回后端腿(写→读→端点)已闭环,仅余 FE hook**。**FE 读回面板已加(2026-06-19)**:studio `useKnowledgeBindings` hook + `KnowledgeBindingsPanel`(镜像 DraftPanel)接入 `KnowledgePage`,契约+组件测试经 vitest 验证(全 studio 50/50、tsc 干净);live playwright e2e 需全栈 app(env 受限)未跑。bindings 写→读→端点→FE 读回展示链(除 live e2e)全通。**live e2e 已写 ready 未跑(2026-06-19)**:`muse-studio/e2e/knowledge-bindings.spec.ts`(镜像 graph spec,`playwright --list` 编译有效);活体受阻=远端 PG 宿主 100.64.0.8 离线(不在 tailnet)、无本地 PG 兜底,PG 宿主恢复后按 [.agents/knowledge §六] 起栈可跑。 **文本内容安全扫描已实现 + 摄入 scanning 关卡打通(2026-06-23,commit ec7637d)**:`KnowledgeContentScanService`(同步纯文本字符扫描——L1 非法 UTF-8/null/危险 C0-C1 控制符/Bidi 覆盖/体量硬阻断,L2 NFC+去零宽+换行规整,L3 prompt 注入软标记不阻断;特殊字符判定全用十六进制码点避免源码字面不可见字符),`KnowledgeFileFacade.materialize` 接入(passed→materialized+规整内容入 RAG、blocked→隔离带 reasonCode、扫描异常→fail-closed),替换原恒 `scanBlocked` 占位;`MaterializedFile.scanBlocked` 加 reasonCode/message(连带改 P1r live IT 构造签名)。单测 ScanService 9 + Facade 接线 3 + DocumentService 17 + P-B RetrievalApiImpl 11 全绿。**端到端真验(48080 + muse_slice_live + RAGFlow/New-API env 配齐)**:上传纯文本 document 4 → `scan_status=passed` → 处理链推进 → RAGFlow dataset(5950e9ee)+document(595cb63e)创建成功,坐实 scan 关卡 + muse→RAGFlow 集成贯通(对比 document 2 旧 jar 卡 SCAN_SERVICE_UNAVAILABLE、document 3 RAGFlow 未配 CONFIG_MISSING)。**E3 studio 与检索闭环已完成(2026-06-27)**:新增主动检索 App 端点与 `KnowledgeRetrievalPanel`,no_dataset 显式治理提示,自建 KB 上传真实后端读回,market KB handoff 正负路真 e2e;AI 生成 e2e 反查 `source_summary.contextAssembly` 证实 Knowledge Source 被真实消费。fresh 证据:knowledge targeted 25/0F/0E/0S,契约/迁移门 21/0F/0E/0S,studio Vitest 19/0F/0E、ESLint/tsc 通过,MSW-off Playwright knowledge 6/0F/0E + ai-generation 1/0F/0E。
- **关键风险 / TODO**:前端图谱/发布深面仍薄;Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;已物化 KB 的召回触达/检索停用已在 E4 闭环。**2026-06-27:E4 market KB 召回触达已补并真验**:Knowledge 新增 `KnowledgeMarketSourceStatusChangedConsumer` 消费 Market `MarketAssetSourceStatusChangedEvent`,将本地 installed_ref KB 的 source binding projection 更新为 `recalled/blocked`,检索来源状态门返回 `omittedSources` 而不再送入 RAGFlow;MSW-off Playwright `market-kb-recall.spec.ts` 1/0F/0E,后端单测 `MuseKnowledgeSourceEventServiceTest`+`KnowledgeMarketSourceStatusChangedConsumerTest` 9/0F/0E。副本资源回收/更细补偿仍后置。**2026-06-27:E1 旧知识草稿失效 owner API 已补**:`MuseKnowledgeDraftInvalidationApi` 由 knowledge-api 定义、knowledge-server owner 实现;Content 正文变更只传 work/chapter/block/revision/commandId,不跨 BC 传正文。Knowledge 将匹配的 pending draft 更新为 `conflicted` + `source_action_policy=needs_recheck`,并写 `muse_knowledge_draft_decision(decision_type=recheck)` 作为审计归档;invalid command 跳过并 warn,Content 通知失败不回滚 Canonical 主写,confirm 端仍按 `needs_recheck` fail-closed。验证:`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E;`P1rContentMergeSuggestionIT` real-PG 5/0F/0E/0S 穿透验证采纳后旧 draft 失效与 replay 不重复。**parse 轮询 worker 已补齐并端到端真验(2026-06-23,commit 635045c)**:`MuseKnowledgeParseStatusPollWorker`(@Scheduled + 开关 `muse.knowledge.parse-poll-worker.enabled`,跨租户捞 parsing 任务轮询 RAGFlow 文档状态→run=done 推 version processingStatus=searchable、failed 标败、processing 保持、RAGFlow 不可达/异常不误判)+ `ProcessingTaskService.markRagflowParseCompleted/markRagflowParsePolling` + `Mapper.selectParsingForPoll`;worker 接线 7 测试 + 回归 40 全绿;启用开关后 document 4 自动 parsing→searchable(isSearchable=true),摄入链 **scan→RAGFlow→worker→searchable** 全程贯通(此前"RAGFlow backend 故障"系本机 HTTP 代理误诊,RAGFlow 实始终正常,真因=muse 缺 parse 轮询 worker)。
- **关键风险 / TODO**:前端图谱/发布深面仍薄;Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;已物化 KB 的召回触达/检索停用已在 E4 闭环。**2026-06-27:E4 market KB 召回触达已补并真验**:Knowledge 新增 `KnowledgeMarketSourceStatusChangedConsumer` 消费 Market `MarketAssetSourceStatusChangedEvent`,将本地 installed_ref KB 的 source binding projection 更新为 `recalled/blocked`,检索来源状态门返回 `omittedSources` 而不再送入 RAGFlow;MSW-off Playwright `market-kb-recall.spec.ts` 1/0F/0E,后端单测 `MuseKnowledgeSourceEventServiceTest`+`KnowledgeMarketSourceStatusChangedConsumerTest` 9/0F/0E。副本资源回收/更细补偿仍后置。**2026-06-27:E1 旧知识草稿失效 owner API 已补**:`MuseKnowledgeDraftInvalidationApi` 由 knowledge-api 定义、knowledge-server owner 实现;Content 正文变更只传 work/chapter/block/revision/commandId,不跨 BC 传正文。Knowledge 将匹配的 pending draft 更新为 `conflicted` + `source_action_policy=needs_recheck`,并写 `muse_knowledge_draft_decision(decision_type=recheck)` 作为审计归档;invalid command 跳过并 warn,Content 通知失败不回滚 Canonical 主写,confirm 端仍按 `needs_recheck` fail-closed。验证:`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E;`P1rContentMergeSuggestionIT` real-PG 5/0F/0E/0S 穿透验证采纳后旧 draft 失效与 replay 不重复。**2026-06-28:导入章节确认 Draft owner API 已补**:`MuseKnowledgeDraftCreationApi` 由 knowledge-api 定义、knowledge-server owner 实现;Content/AI 只传章节 Shadow 结果与 sourceRef,Knowledge 负责创建 pending draft 与幂等 command,不写 Local KB Canonical,仍需用户后续显式确认草稿才进入正式知识。验证:`MuseKnowledgeDraftCreationApiImplTest` 与完整导入链路 targeted 后端测试通过。**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

@ -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 必须与写端口校验同源,否则显示态与可操作态背离=新假绿。** ⑥ **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 副本资源回收、下架/撤权更细补偿。
- **关键风险 / 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`。⑧ **2026-06-27 RC 后补治理/撤权申诉独立入口**:Studio 新增 `/market/appeals`,侧边栏“治理”和市场页可直达,消费 `GET /marketplace/appeals`、补充材料、撤回申诉真实 hook;该入口只处理发布者/使用者面对下架/召回/撤权结果后的申诉,不暴露管理员执行撤权/召回命令。验证:`useMarketPublish.test.tsx` 2/0F/0E、`MarketAppealCenter.test.tsx` 2/0F/0E、组合目标 Vitest 20/0F/0E、`tsc`/目标 ESLint 通过。仍缺:更完整生产者发布深面、申诉附件上传、KB 副本资源回收、下架/撤权更细补偿。

View File

@ -7,4 +7,5 @@
- **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,生产启用需显式配置。
- **2026-06-27 RC 后补安全事件处理入口**:Studio 个人中心安全事件从单一“确认”扩为契约内 `acknowledged/password_changed/session_revoked/false_positive` 四类处理动作,写 `POST /account/security-events/{eventId}/acknowledge` 时带 commandId 和 note,成功后展示后端 `riskSummary/nextSteps` 并刷新列表。验证:Studio `useAccount.test.tsx` 5/0F/0E,组合目标 Vitest 20/0F/0E,`tsc -b --force` 与目标 ESLint 通过。边界:这不是完整改密/2FA/session service,会话吊销真实联动仍后置;偏好通知仍无 app 写契约,不伪造。
- **关键风险 / 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

@ -1,6 +1,16 @@
import { http, HttpResponse } from 'msw';
import type { AccountProfile, AppEntitlementDetail, AppUsageSummary, ProfileUpdateRequest } from '@/types/openapi';
interface MockSecurityEvent {
eventId: string;
eventType: string;
severity: string;
occurredAt: string;
description: string;
acknowledged: boolean;
acknowledgedAction?: string;
}
let mockProfile: AccountProfile = {
userId: 'u-20260524',
nickname: '青瑟',
@ -81,6 +91,27 @@ const mockUsage: AppUsageSummary = {
],
};
const INITIAL_SECURITY_EVENTS: MockSecurityEvent[] = [
{
eventId: 'sec-1',
eventType: 'login_anomaly',
severity: 'warning',
occurredAt: '2026-06-27T08:30:00Z',
description: '检测到一次异地登录风险,请确认是否本人操作。',
acknowledged: false,
},
{
eventId: 'sec-2',
eventType: 'sensitive_export',
severity: 'critical',
occurredAt: '2026-06-27T09:00:00Z',
description: '导出凭证触发敏感来源校验。',
acknowledged: false,
},
];
let mockSecurityEvents: MockSecurityEvent[] = structuredClone(INITIAL_SECURITY_EVENTS);
/** 重置个人中心 Mock 状态,便于后续测试隔离。 */
export function resetMockDb(): void {
mockProfile = {
@ -89,6 +120,7 @@ export function resetMockDb(): void {
publicPenName: '青瑟写作实验室',
version: 3,
};
mockSecurityEvents = structuredClone(INITIAL_SECURITY_EVENTS);
}
export const accountHandlers = [
@ -136,4 +168,56 @@ export const accountHandlers = [
data: { bindingStatus: 'unbound', boundAt: null, lastSyncAt: null, canRecheck: false },
});
}),
// GET /app-api/muse/account/security-events — 当前账户安全事件摘要,敏感信息已在后端脱敏。
http.get('/app-api/muse/account/security-events', () => {
return HttpResponse.json({
code: 0,
msg: 'success',
data: {
total: mockSecurityEvents.length,
pageNo: 1,
pageSize: 50,
list: mockSecurityEvents.map((event) => ({
eventId: event.eventId,
eventType: event.eventType,
severity: event.severity,
occurredAt: event.occurredAt,
description: event.description,
acknowledged: event.acknowledged,
})),
},
});
}),
// POST /app-api/muse/account/security-events/:eventId/acknowledge — 安全事件处理动作,契约内四种 action 都走同一写链路。
http.post('/app-api/muse/account/security-events/:eventId/acknowledge', async ({ params, request }) => {
const eventId = String(params.eventId);
const body = (await request.json()) as { action?: string; note?: string };
const event = mockSecurityEvents.find((item) => item.eventId === eventId);
if (!event) {
return HttpResponse.json({ code: 40440, msg: '安全事件不存在', data: null }, { status: 404 });
}
event.acknowledged = true;
event.acknowledgedAction = body.action ?? 'acknowledged';
return HttpResponse.json({
code: 0,
msg: 'success',
data: {
eventId,
acknowledged: true,
action: event.acknowledgedAction,
acknowledgedAt: new Date().toISOString(),
riskSummary:
event.acknowledgedAction === 'session_revoked'
? '已记录会话撤销处理;真实会话服务不可用时由后端返回后续建议。'
: '已记录安全事件处理动作。',
nextSteps:
event.acknowledgedAction === 'password_changed'
? ['重新登录高风险设备', '观察后续异常事件']
: ['继续观察账户安全状态'],
},
});
}),
];

View File

@ -3,6 +3,18 @@ import type { MarketAssetCard, MarketAssetDetail } from '@/types/openapi';
type AssetType = MarketAssetCard['assetType'];
interface MockAppeal {
appealId: string;
assetId: string;
appealType: string;
status: string;
reason: string | null;
resolution: string | null;
submittedAt: number | null;
canSupplement: boolean;
canWithdraw: boolean;
}
const INITIAL_ASSETS: MarketAssetDetail[] = [
{
assetId: '20000000-0000-4000-8000-000000000001',
@ -108,11 +120,27 @@ const INITIAL_ASSETS: MarketAssetDetail[] = [
},
];
const INITIAL_APPEALS: MockAppeal[] = [
{
appealId: 'appeal-revoke-1',
assetId: '20000000-0000-4000-8000-000000000002',
appealType: 'license_revocation',
status: 'supplementing',
reason: '授权撤权影响正在使用的智能体,请复核许可范围。',
resolution: null,
submittedAt: Date.parse('2026-06-27T10:00:00Z'),
canSupplement: true,
canWithdraw: true,
},
];
let mockAssets = structuredClone(INITIAL_ASSETS);
let mockAppeals = structuredClone(INITIAL_APPEALS);
/** 重置市场 Mock 数据,保证后续测试互不污染。 */
export function resetMockDb(): void {
mockAssets = structuredClone(INITIAL_ASSETS);
mockAppeals = structuredClone(INITIAL_APPEALS);
}
function toAssetCard(asset: MarketAssetDetail): MarketAssetCard {
@ -316,4 +344,91 @@ export const marketHandlers = [
},
});
}),
// GET /app-api/muse/marketplace/my-publish-records — 发布者资产记录,含可申诉治理态。
http.get('/app-api/muse/marketplace/my-publish-records', () => {
return HttpResponse.json({
code: 0,
msg: 'success',
data: {
total: 1,
list: [
{
assetId: 'source-agent-1',
marketAssetId: '20000000-0000-4000-8000-000000000002',
assetName: '悬疑伏笔回收智能体',
assetType: 'agent',
version: '2.0.1',
status: 'recalled',
nextAction: 'appeal',
governanceStatus: 'recalled',
appealStatus: null,
exposureRange: 'account_install',
occurredAt: '2026-06-27T10:00:00Z',
},
],
},
});
}),
// GET /app-api/muse/marketplace/appeals — 我的治理申诉列表。
http.get('/app-api/muse/marketplace/appeals', () => {
return HttpResponse.json({ code: 0, msg: 'success', data: mockAppeals });
}),
// POST /app-api/muse/marketplace/appeals/:appealId/withdraw — 带 expectedStatus 乐观锁撤回申诉。
http.post('/app-api/muse/marketplace/appeals/:appealId/withdraw', async ({ params, request }) => {
const body = (await request.json()) as { expectedStatus?: string };
const appeal = mockAppeals.find((item) => item.appealId === params.appealId);
if (!appeal) {
return HttpResponse.json({ code: 40441, msg: '申诉不存在', data: null }, { status: 404 });
}
if (body.expectedStatus && body.expectedStatus !== appeal.status) {
return HttpResponse.json({ code: 40941, msg: '申诉状态已变化,请刷新后重试', data: null }, { status: 409 });
}
appeal.status = 'closed';
appeal.canSupplement = false;
appeal.canWithdraw = false;
appeal.resolution = '用户主动撤回';
return HttpResponse.json({ code: 0, msg: 'success', data: true });
}),
// POST /app-api/muse/marketplace/appeals/:appealId/supplements — 补充申诉材料。
http.post('/app-api/muse/marketplace/appeals/:appealId/supplements', async ({ params }) => {
const appeal = mockAppeals.find((item) => item.appealId === params.appealId);
if (!appeal) {
return HttpResponse.json({ code: 40441, msg: '申诉不存在', data: null }, { status: 404 });
}
appeal.status = 'reviewing';
appeal.canSupplement = false;
appeal.canWithdraw = true;
return HttpResponse.json({ code: 0, msg: 'success', data: true });
}),
// POST /app-api/muse/marketplace/appeals — 对下架/召回/撤权等治理结果提交申诉。通用创建路由必须放在具体子路由之后。
http.post('/app-api/muse/marketplace/appeals', async ({ request }) => {
const body = (await request.json()) as { assetId?: string; appealType?: string; reason?: string };
const appeal: MockAppeal = {
appealId: `appeal-${crypto.randomUUID().slice(0, 8)}`,
assetId: body.assetId ?? 'unknown',
appealType: body.appealType ?? 'usage_impact',
status: 'pending',
reason: body.reason ?? null,
resolution: null,
submittedAt: Date.now(),
canSupplement: false,
canWithdraw: true,
};
mockAppeals = [appeal, ...mockAppeals];
return HttpResponse.json({
code: 0,
msg: 'success',
data: {
appealId: appeal.appealId,
status: appeal.status,
processingDeadline: '2026-07-01T00:00:00Z',
supplementEntryAvailable: appeal.canSupplement,
},
});
}),
];

View File

@ -8,6 +8,7 @@ import AgentPage from '@/pages/AgentPage';
import MarketPage from '@/pages/MarketPage';
import MarketAssetDetailPage from '@/pages/MarketAssetDetailPage';
import MarketPublishPage from '@/pages/MarketPublishPage';
import MarketAppealsPage from '@/pages/MarketAppealsPage';
import AccountPage from '@/pages/AccountPage';
import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
@ -29,6 +30,7 @@ export const router = createBrowserRouter([
{ path: 'market', element: <MarketPage /> },
{ path: 'market/assets/:assetId', element: <MarketAssetDetailPage /> },
{ path: 'market/publish', element: <MarketPublishPage /> },
{ path: 'market/appeals', element: <MarketAppealsPage /> },
{ path: 'account', element: <AccountPage /> },
{ path: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> },
],

View File

@ -15,6 +15,7 @@ const navItems: NavItem[] = [
{ to: '/knowledge', label: '知识库', icon: '📚' },
{ to: '/agents', label: '智能体', icon: '🤖' },
{ to: '/market', label: '市场', icon: '🏪' },
{ to: '/market/appeals', label: '治理', icon: '⚖️' },
{ to: '/account', label: '个人', icon: '👤' },
];

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import {
type SecurityEventAction,
useAccountEntitlements,
useAccountLicenses,
useAccountNewApiBinding,
@ -20,6 +21,16 @@ function severityBadgeClass(severity: string): string {
return 'bg-slate-100 text-slate-500';
}
const securityActionLabels: Record<SecurityEventAction, string> = {
acknowledged: '知晓',
password_changed: '已改密',
session_revoked: '撤销会话',
false_positive: '误报',
};
/** 安全事件处理动作按钮,严格限于后端契约允许的 action 枚举。 */
const securityActions: SecurityEventAction[] = ['acknowledged', 'password_changed', 'session_revoked', 'false_positive'];
/** New-API 绑定状态 → 中文文案。 */
const newApiBindingLabels: Record<string, string> = {
bound: '已绑定',
@ -48,6 +59,7 @@ export default function PersonalCenter() {
const updateProfile = useUpdateAccountProfile();
const [nickname, setNickname] = useState('');
const [publicPenName, setPublicPenName] = useState('');
const [securityActionResult, setSecurityActionResult] = useState<string | null>(null);
const activeNickname = nickname || profile?.nickname || '';
const activePenName = publicPenName || profile?.publicPenName || '';
@ -62,6 +74,23 @@ export default function PersonalCenter() {
});
};
const handleSecurityAction = (eventId: string, action: SecurityEventAction) => {
setSecurityActionResult(null);
acknowledgeEvent.mutate(
{
eventId,
action,
note: securityActionLabels[action],
},
{
onSuccess: (result) => {
const nextSteps = result.nextSteps?.length ? ` 后续:${result.nextSteps.join('、')}` : '';
setSecurityActionResult(`${securityActionLabels[action]}已记录。${result.riskSummary ?? ''}${nextSteps}`);
},
}
);
};
return (
<div className="min-h-screen bg-slate-50/60 px-6 py-8">
<div className="mx-auto max-w-7xl">
@ -213,6 +242,16 @@ export default function PersonalCenter() {
<section className="mt-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-800">安全事件</h2>
{securityActionResult && (
<p className="mt-3 rounded-lg bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-700">
{securityActionResult}
</p>
)}
{acknowledgeEvent.isError && (
<p className="mt-3 rounded-lg bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700">
处理失败:{acknowledgeEvent.error.message}
</p>
)}
{(securityEvents?.list?.length ?? 0) === 0 ? (
<p className="mt-3 text-sm text-slate-400">暂无安全事件</p>
) : (
@ -233,16 +272,30 @@ export default function PersonalCenter() {
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{!evt.acknowledged && (
{!evt.acknowledged && securityActions.map((action) => (
<button
key={`${evt.eventId}-${action}`}
type="button"
onClick={() => acknowledgeEvent.mutate(evt.eventId)}
disabled={acknowledgeEvent.isPending && acknowledgeEvent.variables === evt.eventId}
className="rounded-full bg-slate-900 px-3 py-1 text-[11px] font-semibold text-white transition hover:bg-indigo-700 disabled:bg-slate-300"
onClick={() => handleSecurityAction(evt.eventId, action)}
disabled={
acknowledgeEvent.isPending &&
typeof acknowledgeEvent.variables !== 'string' &&
acknowledgeEvent.variables?.eventId === evt.eventId
}
className={`rounded-full px-3 py-1 text-[11px] font-semibold transition disabled:bg-slate-300 disabled:text-white ${
action === 'session_revoked'
? 'bg-rose-600 text-white hover:bg-rose-700'
: 'bg-slate-900 text-white hover:bg-indigo-700'
}`}
>
{acknowledgeEvent.isPending && acknowledgeEvent.variables === evt.eventId ? '确认中...' : '确认'}
{acknowledgeEvent.isPending &&
typeof acknowledgeEvent.variables !== 'string' &&
acknowledgeEvent.variables?.eventId === evt.eventId &&
acknowledgeEvent.variables.action === action
? '处理中...'
: securityActionLabels[action]}
</button>
)}
))}
<span
className={`rounded-full px-2.5 py-1 text-[11px] font-bold ${
evt.acknowledged ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-700'

View File

@ -10,7 +10,9 @@ import {
useAccountNewApiBinding,
useAccountProfile,
useAccountPublishRecords,
useAccountSecurityEvents,
useAccountUsage,
useAcknowledgeSecurityEvent,
useUpdateAccountProfile,
} from './useAccount';
@ -102,4 +104,23 @@ describe('useAccount API Hooks 单元测试', () => {
// 契约:命中账户发布记录端点(账户台账视角,异于 market my-publish-records)。
expect(urls[0]).toContain('/app-api/muse/account/publish-records');
});
it('处理安全事件应支持撤销会话动作并刷新列表', async () => {
const { result: eventsHook } = renderHook(() => useAccountSecurityEvents(), { wrapper });
await waitFor(() => expect(eventsHook.current.isSuccess).toBe(true));
expect(eventsHook.current.data?.list[0]?.acknowledged).toBe(false);
const { result: acknowledgeHook } = renderHook(() => useAcknowledgeSecurityEvent(), { wrapper });
acknowledgeHook.current.mutate({
eventId: 'sec-1',
action: 'session_revoked',
note: '用户确认撤销相关会话',
});
await waitFor(() => expect(acknowledgeHook.current.isSuccess).toBe(true));
expect(acknowledgeHook.current.data?.action).toBe('session_revoked');
expect(acknowledgeHook.current.data?.riskSummary).toContain('会话撤销');
await waitFor(() => expect(eventsHook.current.data?.list[0]?.acknowledged).toBe(true));
});
});

View File

@ -142,8 +142,8 @@ export interface AccountSecurityEvent {
eventType: string;
/** info | warning | critical(后端兜底枚举,未知收敛为 warning)。 */
severity: string;
/** LocalDateTime 序列化为 epoch 毫秒。 */
occurredAt?: number;
/** 后端 OpenAPI 声明为 date-time 字符串,前端只做展示透传,不参与安全判断。 */
occurredAt?: string;
description?: string;
acknowledged?: boolean;
}
@ -163,23 +163,43 @@ export interface SecurityEventAckResult {
eventId: string;
acknowledged: boolean;
action: string;
acknowledgedAt?: number;
acknowledgedAt?: string;
riskSummary?: string;
nextSteps?: string[];
}
/** 安全事件处理动作,严格对齐 OpenAPI 的 SecurityEventAcknowledgeRequest.action 枚举。 */
export type SecurityEventAction = 'acknowledged' | 'password_changed' | 'session_revoked' | 'false_positive';
/** 安全事件处理请求:eventId 来自服务端列表,action/note 由用户显式选择。 */
export interface SecurityEventAckRequest {
eventId: string;
action?: SecurityEventAction;
note?: string;
}
/**
* 确认安全事件(写路,action=acknowledged“已知晓”)。
* 处理安全事件(写路,默认 action=acknowledged)。
* 每次发新 commandId(后端按 commandId 幂等去重),成功后失活列表查询使状态翻为「已确认」。
*/
export function useAcknowledgeSecurityEvent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (eventId: string) =>
api.post<SecurityEventAckResult>(`/account/security-events/${eventId}/acknowledge`, {
mutationFn: (request: string | SecurityEventAckRequest) => {
const normalized: SecurityEventAckRequest =
typeof request === 'string' ? { eventId: request, action: 'acknowledged' } : request;
const action = normalized.action ?? 'acknowledged';
const trimmedNote = normalized.note?.trim();
const body: { commandId: string; action: SecurityEventAction; note?: string } = {
commandId: crypto.randomUUID(),
action: 'acknowledged',
}),
action,
};
if (trimmedNote) {
body.note = trimmedNote;
}
return api.post<SecurityEventAckResult>(`/account/security-events/${normalized.eventId}/acknowledge`, body);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accountSecurityEvents'] });
},

View File

@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { api } from '@/api/client';
import { resetMockDb } from '@/api/mocks/handlers/content';
import BlockRevisionPanel from './BlockRevisionPanel';
import React from 'react';
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const docJson = (text: string) =>
JSON.stringify({
type: 'doc',
content: text.split('\n').map((line) => ({
type: 'paragraph',
content: [{ type: 'text', text: line }],
})),
});
async function saveRevision(commandId: string, expectedRevision: number, text: string) {
await api.put('/works/1/blocks/block-1-1-main', {
commandId,
expectedRevision,
content: docJson(text),
sourceSnapshot: {
sourceType: 'user_original',
sourceId: 'block-1-1-main',
contentRevision: expectedRevision,
},
auditReason: 'vitest revision diff',
});
}
describe('BlockRevisionPanel 组件', () => {
beforeEach(() => resetMockDb());
it('应在两个版本之间展示新增和删除 diff', async () => {
await saveRevision('vitest-revision-diff-1', 1, '第一行保留\n第二行删除');
await saveRevision('vitest-revision-diff-2', 2, '第一行保留\n第三行新增');
renderWithClient(<BlockRevisionPanel workId="1" blockId="block-1-1-main" />);
expect(await screen.findByText('版本对比')).toBeInTheDocument();
expect(screen.getByText('新增 1')).toBeInTheDocument();
expect(screen.getByText('删除 1')).toBeInTheDocument();
expect(screen.getByText('不变 1')).toBeInTheDocument();
const diffRegion = screen.getByRole('region', { name: '版本对比结果' });
expect(within(diffRegion).getByText('第二行删除')).toBeInTheDocument();
expect(within(diffRegion).getByText('第三行新增')).toBeInTheDocument();
});
it('切换基准版本后应重新计算 diff 摘要', async () => {
await saveRevision('vitest-revision-diff-a', 1, '旧版本');
await saveRevision('vitest-revision-diff-b', 2, '中间版本');
await saveRevision('vitest-revision-diff-c', 3, '最新版本');
renderWithClient(<BlockRevisionPanel workId="1" blockId="block-1-1-main" />);
expect(await screen.findByText('版本对比')).toBeInTheDocument();
const diffRegion = screen.getByRole('region', { name: '版本对比结果' });
expect(within(diffRegion).getByText('中间版本')).toBeInTheDocument();
expect(within(diffRegion).getByText('最新版本')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('基准版本'), { target: { value: '2' } });
await waitFor(() => expect(within(diffRegion).getByText('旧版本')).toBeInTheDocument());
expect(within(diffRegion).getByText('最新版本')).toBeInTheDocument();
expect(within(diffRegion).queryByText('中间版本')).not.toBeInTheDocument();
});
});

View File

@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useBlockRevisions } from '../hooks/useWorks';
import type { BlockRevision } from '../hooks/useWorks';
interface BlockRevisionPanelProps {
workId: string;
@ -8,25 +9,123 @@ interface BlockRevisionPanelProps {
function previewText(content: string): string {
try {
const parsed = JSON.parse(content) as { content?: Array<{ content?: Array<{ text?: string }> }> };
const parsed = JSON.parse(content) as { content?: unknown[] };
const text = parsed.content
?.flatMap((node) => node.content ?? [])
.map((node) => node.text ?? '')
.join('');
?.map((node) => collectText(node).trim())
.filter(Boolean)
.join('\n');
return text && text.trim().length > 0 ? text : content;
} catch {
return content;
}
}
/** 递归提取 Tiptap 节点文本;段落分隔由 previewText 的顶层遍历负责保留。 */
function collectText(node: unknown): string {
if (typeof node !== 'object' || node === null) return '';
const current = node as { content?: unknown[]; text?: string };
const ownText = typeof current.text === 'string' ? current.text : '';
const childText = Array.isArray(current.content) ? current.content.map(collectText).join('') : '';
return `${ownText}${childText}`;
}
interface DiffLine {
key: string;
type: 'added' | 'removed' | 'unchanged';
text: string;
}
/**
* 把正文拆成对比单元:优先按自然换行,单行长文本再按中英文句末标点切分。
* 这里仅做只读展示,不参与任何 Canonical 写入或版本恢复。
*/
function splitDiffUnits(content: string): string[] {
const text = previewText(content).replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
if (!text) return [];
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
if (lines.length > 1) return lines;
return text
.split(/(?<=[。!?;!?;])\s*/)
.map((line) => line.trim())
.filter(Boolean);
}
/**
* 使用 LCS 生成稳定的逐行 diff,避免简单字符串包含导致的误判。
* 版本历史正文通常很短,O(n*m) 足够且更容易审计。
*/
function buildDiffLines(baseContent: string, targetContent: string): DiffLine[] {
const base = splitDiffUnits(baseContent);
const target = splitDiffUnits(targetContent);
const dp = Array.from({ length: base.length + 1 }, () => Array<number>(target.length + 1).fill(0));
for (let i = base.length - 1; i >= 0; i -= 1) {
for (let j = target.length - 1; j >= 0; j -= 1) {
dp[i]![j] = base[i] === target[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
}
}
const lines: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < base.length && j < target.length) {
if (base[i] === target[j]) {
lines.push({ key: `u-${i}-${j}`, type: 'unchanged', text: base[i]! });
i += 1;
j += 1;
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
lines.push({ key: `r-${i}-${j}`, type: 'removed', text: base[i]! });
i += 1;
} else {
lines.push({ key: `a-${i}-${j}`, type: 'added', text: target[j]! });
j += 1;
}
}
while (i < base.length) {
lines.push({ key: `r-${i}-${j}`, type: 'removed', text: base[i]! });
i += 1;
}
while (j < target.length) {
lines.push({ key: `a-${i}-${j}`, type: 'added', text: target[j]! });
j += 1;
}
return lines;
}
function revisionLabel(revision: BlockRevision): string {
return `revision ${revision.revision}`;
}
/** Block 正文版本历史面板;只读展示,不提供恢复写入。 */
export default function BlockRevisionPanel({ workId, blockId }: BlockRevisionPanelProps) {
const { data = [], isLoading, isError } = useBlockRevisions(workId, blockId);
const [selectedRevision, setSelectedRevision] = useState<number | null>(null);
const [baseRevision, setBaseRevision] = useState<number | null>(null);
const [targetRevision, setTargetRevision] = useState<number | null>(null);
const selected = useMemo(
() => data.find((revision) => revision.revision === selectedRevision) ?? data[0],
[data, selectedRevision]
);
const target = useMemo(
() => data.find((revision) => revision.revision === targetRevision) ?? data[0],
[data, targetRevision]
);
const base = useMemo(
() => data.find((revision) => revision.revision === baseRevision) ?? data[1] ?? data[0],
[data, baseRevision]
);
const diffLines = useMemo(
() => (base && target ? buildDiffLines(base.content, target.content) : []),
[base, target]
);
const diffSummary = useMemo(
() => ({
added: diffLines.filter((line) => line.type === 'added').length,
removed: diffLines.filter((line) => line.type === 'removed').length,
unchanged: diffLines.filter((line) => line.type === 'unchanged').length,
}),
[diffLines]
);
if (!blockId) {
return <div className="p-6 text-sm text-slate-400">请在左侧大纲选择章节,查看正文版本历史。</div>;
@ -47,6 +146,79 @@ export default function BlockRevisionPanel({ workId, blockId }: BlockRevisionPan
<h3 className="text-sm font-bold text-slate-800">版本历史</h3>
<p className="mt-1 text-[11px] text-slate-400">仅展示已写入 Canonical 的正文快照</p>
</div>
{base && target && (
<div className="border-b border-slate-100 bg-white px-4 py-3">
<div className="grid grid-cols-2 gap-2">
<label className="text-[11px] font-semibold text-slate-500">
基准版本
<select
aria-label="基准版本"
value={base.revision}
onChange={(event) => setBaseRevision(Number(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-semibold text-slate-700 outline-none focus:border-slate-400"
>
{data.map((revision) => (
<option key={`base-${revision.revision}`} value={revision.revision}>
{revisionLabel(revision)}
</option>
))}
</select>
</label>
<label className="text-[11px] font-semibold text-slate-500">
目标版本
<select
aria-label="目标版本"
value={target.revision}
onChange={(event) => setTargetRevision(Number(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-semibold text-slate-700 outline-none focus:border-slate-400"
>
{data.map((revision) => (
<option key={`target-${revision.revision}`} value={revision.revision}>
{revisionLabel(revision)}
</option>
))}
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap gap-1.5 text-[11px] font-bold">
<span className="rounded-md bg-emerald-50 px-2 py-1 text-emerald-700">新增 {diffSummary.added}</span>
<span className="rounded-md bg-rose-50 px-2 py-1 text-rose-700">删除 {diffSummary.removed}</span>
<span className="rounded-md bg-slate-100 px-2 py-1 text-slate-600">不变 {diffSummary.unchanged}</span>
</div>
<div
role="region"
aria-label="版本对比结果"
className="mt-3 max-h-44 overflow-y-auto rounded-md border border-slate-100 bg-slate-50"
>
<div className="border-b border-slate-100 bg-white px-3 py-2 text-[11px] font-bold text-slate-600">
版本对比
</div>
{diffLines.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400">两个版本正文一致。</div>
) : (
<div className="divide-y divide-slate-100">
{diffLines.map((line) => (
<div
key={line.key}
className={`flex gap-2 px-3 py-2 text-[11px] leading-5 ${
line.type === 'added'
? 'bg-emerald-50 text-emerald-800'
: line.type === 'removed'
? 'bg-rose-50 text-rose-800'
: 'bg-white text-slate-500'
}`}
>
<span className="w-4 shrink-0 font-black">
{line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '}
</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words">{line.text}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
{data.map((revision) => (

View File

@ -0,0 +1,53 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it } from 'vitest';
import { resetMockDb } from '@/api/mocks/handlers/market';
import MarketAppealCenter from './MarketAppealCenter';
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
let testQueryClient = createTestQueryClient();
function renderPage() {
return render(
<QueryClientProvider client={testQueryClient}>
<MemoryRouter>
<MarketAppealCenter />
</MemoryRouter>
</QueryClientProvider>
);
}
describe('MarketAppealCenter', () => {
beforeEach(() => {
resetMockDb();
testQueryClient = createTestQueryClient();
});
it('应展示撤权申诉并允许补充材料', async () => {
renderPage();
expect(await screen.findByText('撤权申诉')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '补充材料' }));
fireEvent.change(screen.getByPlaceholderText(/补充说明/), { target: { value: '补充新的授权证明' } });
fireEvent.click(screen.getByRole('button', { name: '提交补充' }));
await waitFor(() => expect(screen.getByText(/材料已补充/)).toBeInTheDocument());
});
it('应允许从独立入口撤回申诉', async () => {
renderPage();
expect(await screen.findByText('撤权申诉')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '撤回' }));
await waitFor(() => expect(screen.getByText(/已撤回/)).toBeInTheDocument());
});
});

View File

@ -0,0 +1,222 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
useMyAppeals,
useSupplementAppeal,
useWithdrawAppeal,
type MyAppealItem,
} from '../hooks/useMarketPublish';
/** 申诉状态 → 中文徽标(对齐后端 MarketAppealServiceImpl 申诉状态机)。 */
const APPEAL_STATUS_META: Record<string, { label: string; className: string }> = {
pending: { label: '待处理', className: 'bg-amber-50 text-amber-700' },
reviewing: { label: '审核中', className: 'bg-blue-50 text-blue-700' },
supplementing: { label: '待补充材料', className: 'bg-amber-50 text-amber-700' },
maintained: { label: '维持原判', className: 'bg-slate-100 text-slate-500' },
restored: { label: '已恢复', className: 'bg-emerald-50 text-emerald-700' },
partially_restored: { label: '部分恢复', className: 'bg-emerald-50 text-emerald-700' },
closed: { label: '已关闭', className: 'bg-slate-100 text-slate-500' },
};
function appealStatusMeta(status: string): { label: string; className: string } {
return APPEAL_STATUS_META[status] ?? { label: status, className: 'bg-slate-100 text-slate-500' };
}
function formatSubmittedAt(value: number | null): string {
if (!value) return '—';
return new Date(value).toLocaleString();
}
function appealTypeLabel(type: string): string {
if (type === 'license_revocation') return '撤权申诉';
if (type === 'recall') return '召回申诉';
if (type === 'delist') return '下架申诉';
if (type === 'review_rejection') return '审核驳回申诉';
if (type === 'usage_impact') return '使用影响申诉';
return type;
}
interface AppealRowProps {
appeal: MyAppealItem;
onWithdraw: (appealId: string, expectedStatus: string) => void;
onSupplement: (appealId: string, description: string) => void;
withdrawPending: boolean;
supplementPending: boolean;
}
/** 单条申诉处理行:补充/撤回都通过后端命令接口执行,前端不直接改状态。 */
function AppealRow({
appeal,
onWithdraw,
onSupplement,
withdrawPending,
supplementPending,
}: AppealRowProps) {
const [supplementOpen, setSupplementOpen] = useState(false);
const [description, setDescription] = useState('已按要求补充材料与证据,请复核。');
const status = appealStatusMeta(appeal.status);
return (
<li className="px-5 py-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold text-slate-500">
{appealTypeLabel(appeal.appealType)}
</span>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-bold ${status.className}`}>
{status.label}
</span>
<span className="text-xs text-slate-400">提交时间:{formatSubmittedAt(appeal.submittedAt)}</span>
</div>
<div className="mt-2 text-sm font-semibold text-slate-800">{appeal.reason || '未填写申诉理由'}</div>
{appeal.resolution && <div className="mt-1 text-xs text-slate-500">处理结论:{appeal.resolution}</div>}
<div className="mt-1 text-xs text-slate-400">资产 ID:{appeal.assetId}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{appeal.canSupplement && (
<button
type="button"
onClick={() => setSupplementOpen((open) => !open)}
className="rounded-full bg-amber-500 px-3 py-1 text-[11px] font-semibold text-white transition hover:bg-amber-600"
>
补充材料
</button>
)}
{appeal.canWithdraw && (
<button
type="button"
onClick={() => onWithdraw(appeal.appealId, appeal.status)}
disabled={withdrawPending}
className="rounded-full bg-white px-3 py-1 text-[11px] font-semibold text-slate-600 ring-1 ring-slate-200 transition hover:ring-slate-300 disabled:text-slate-300"
>
{withdrawPending ? '撤回中...' : '撤回'}
</button>
)}
</div>
</div>
{supplementOpen && (
<div className="mt-4 rounded-xl border border-amber-100 bg-amber-50/40 p-4">
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
placeholder="补充说明(材料 / 证据,提交即确认隐私授权)"
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-amber-400"
/>
<div className="mt-3 flex gap-3">
<button
type="button"
onClick={() => onSupplement(appeal.appealId, description)}
disabled={supplementPending || description.trim().length === 0}
className="rounded-xl bg-amber-500 px-4 py-2 text-sm font-bold text-white transition hover:bg-amber-600 disabled:bg-slate-300"
>
{supplementPending ? '提交中...' : '提交补充'}
</button>
<button
type="button"
onClick={() => setSupplementOpen(false)}
className="rounded-xl bg-white px-4 py-2 text-sm font-bold text-slate-600 ring-1 ring-slate-200 transition hover:ring-slate-300"
>
取消
</button>
</div>
</div>
)}
</li>
);
}
/**
* 市场治理与申诉中心。
*
* 边界:这里处理发布者/使用者面对下架、召回、撤权等治理结果后的申诉、补充、撤回;
* 不暴露管理员下架/召回/撤权命令,避免普通用户越权执行治理动作。
*/
export default function MarketAppealCenter() {
const appeals = useMyAppeals();
const withdrawAppeal = useWithdrawAppeal();
const supplementAppeal = useSupplementAppeal();
const [message, setMessage] = useState<string | null>(null);
const handleWithdraw = async (appealId: string, expectedStatus: string) => {
setMessage(null);
try {
await withdrawAppeal.mutateAsync({ appealId, expectedStatus, reason: '用户从治理申诉中心撤回申诉' });
setMessage(`申诉 #${appealId} 已撤回`);
} catch (error) {
setMessage(`撤回失败:${error instanceof Error ? error.message : String(error)}`);
}
};
const handleSupplement = async (appealId: string, description: string) => {
if (description.trim().length === 0) return;
setMessage(null);
try {
await supplementAppeal.mutateAsync({ appealId, description: description.trim() });
setMessage(`申诉 #${appealId} 材料已补充`);
} catch (error) {
setMessage(`补充失败:${error instanceof Error ? error.message : String(error)}`);
}
};
const list = appeals.data ?? [];
return (
<div className="min-h-screen bg-slate-50/60 px-6 py-8">
<div className="mx-auto max-w-5xl">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-extrabold tracking-tight text-slate-950">治理与申诉</h1>
<p className="mt-2 text-sm text-slate-500">
处理下架、召回、撤权和审核驳回产生的申诉事项。
</p>
</div>
<div className="flex gap-3">
<Link to="/market/publish" className="text-sm font-semibold text-indigo-600 hover:text-indigo-700">
发布记录
</Link>
<Link to="/market" className="text-sm font-semibold text-slate-500 hover:text-slate-800">
返回市场
</Link>
</div>
</div>
{message && (
<p className="mt-6 rounded-lg bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-700">{message}</p>
)}
{(withdrawAppeal.isError || supplementAppeal.isError) && (
<p className="mt-6 rounded-lg bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700">
操作失败:{withdrawAppeal.error?.message ?? supplementAppeal.error?.message}
</p>
)}
<section className="mt-6 rounded-2xl border border-slate-100 bg-white shadow-sm">
{appeals.isLoading ? (
<p className="p-6 text-sm text-slate-400">加载中...</p>
) : appeals.isError ? (
<p className="p-6 text-sm font-semibold text-rose-600">申诉记录加载失败:{appeals.error.message}</p>
) : list.length === 0 ? (
<p className="p-6 text-sm text-slate-400">暂无申诉记录</p>
) : (
<ul className="divide-y divide-slate-100">
{list.map((appeal) => (
<AppealRow
key={appeal.appealId}
appeal={appeal}
onWithdraw={handleWithdraw}
onSupplement={handleSupplement}
withdrawPending={withdrawAppeal.isPending && withdrawAppeal.variables?.appealId === appeal.appealId}
supplementPending={
supplementAppeal.isPending && supplementAppeal.variables?.appealId === appeal.appealId
}
/>
))}
</ul>
)}
</section>
</div>
</div>
);
}

View File

@ -57,12 +57,14 @@ export default function MarketBrowse() {
<div>
<h1 className="text-3xl font-extrabold tracking-tight text-slate-950">创作市场</h1>
<p className="mt-2 text-sm text-slate-500">发现作品参考、智能体和知识库资产。</p>
<Link
to="/market/publish"
className="mt-2 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-700"
>
<div className="mt-2 flex flex-wrap gap-3">
<Link to="/market/publish" className="text-sm font-semibold text-indigo-600 hover:text-indigo-700">
我要发布资产 →
</Link>
<Link to="/market/appeals" className="text-sm font-semibold text-rose-600 hover:text-rose-700">
治理与申诉 →
</Link>
</div>
</div>
<input
value={keyword}

View File

@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { resetMockDb } from '@/api/mocks/handlers/market';
import { useMyAppeals, useSupplementAppeal, useWithdrawAppeal } from './useMarketPublish';
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
let testQueryClient = createTestQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
describe('useMarketPublish 申诉 Hook', () => {
beforeEach(() => {
resetMockDb();
testQueryClient = createTestQueryClient();
});
it('应读取撤权申诉并支持补充材料后刷新状态', async () => {
const { result: appealsHook } = renderHook(() => useMyAppeals(), { wrapper });
await waitFor(() => expect(appealsHook.current.isSuccess).toBe(true));
expect(appealsHook.current.data?.[0]?.appealType).toBe('license_revocation');
expect(appealsHook.current.data?.[0]?.canSupplement).toBe(true);
const { result: supplementHook } = renderHook(() => useSupplementAppeal(), { wrapper });
supplementHook.current.mutate({ appealId: 'appeal-revoke-1', description: '补充授权证据' });
await waitFor(() => expect(supplementHook.current.isSuccess).toBe(true));
await waitFor(() => expect(appealsHook.current.data?.[0]?.status).toBe('reviewing'));
expect(appealsHook.current.data?.[0]?.canSupplement).toBe(false);
});
it('撤回申诉应携带 expectedStatus 并刷新为 closed', async () => {
const { result: appealsHook } = renderHook(() => useMyAppeals(), { wrapper });
await waitFor(() => expect(appealsHook.current.isSuccess).toBe(true));
const { result: withdrawHook } = renderHook(() => useWithdrawAppeal(), { wrapper });
withdrawHook.current.mutate({
appealId: 'appeal-revoke-1',
expectedStatus: 'supplementing',
reason: '用户主动撤回',
});
await waitFor(() => expect(withdrawHook.current.isSuccess).toBe(true));
const refreshed = await appealsHook.current.refetch();
expect(refreshed.data?.[0]?.status).toBe('closed');
expect(refreshed.data?.[0]?.canWithdraw).toBe(false);
});
});

View File

@ -173,15 +173,33 @@ export function useMyAppeals() {
/** 撤回申诉:POST /marketplace/appeals/{appealId}/withdraw(带 expectedStatus 乐观锁,转 closed)。 */
export function useWithdrawAppeal() {
const queryClient = useQueryClient();
return useMutation<boolean, Error, { appealId: string; expectedStatus: string; reason: string }>({
return useMutation<
boolean,
Error,
{ appealId: string; expectedStatus: string; reason: string },
{ previous?: MyAppealItem[] }
>({
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['myAppeals'] });
const previous = queryClient.getQueryData<MyAppealItem[]>(['myAppeals']);
// 撤回是明确的用户写动作,先在本地列表收敛到 closed,失败时按 previous 回滚。
queryClient.setQueryData<MyAppealItem[]>(['myAppeals'], (old) =>
old?.map((item) =>
item.appealId === variables.appealId
? { ...item, status: 'closed', canSupplement: false, canWithdraw: false }
: item
)
);
return previous ? { previous } : {};
},
mutationFn: (req) =>
api.post<boolean>(`/marketplace/appeals/${req.appealId}/withdraw`, {
commandId: crypto.randomUUID(),
expectedStatus: req.expectedStatus,
reason: req.reason,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['myAppeals'] });
onError: (_error, _variables, context) => {
queryClient.setQueryData(['myAppeals'], context?.previous);
},
});
}
@ -189,7 +207,20 @@ export function useWithdrawAppeal() {
/** 补充申诉材料:POST /marketplace/appeals/{appealId}/supplements(需 description + privacyConfirmed,仅 supplementing 态)。 */
export function useSupplementAppeal() {
const queryClient = useQueryClient();
return useMutation<boolean, Error, { appealId: string; description: string }>({
return useMutation<boolean, Error, { appealId: string; description: string }, { previous?: MyAppealItem[] }>({
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['myAppeals'] });
const previous = queryClient.getQueryData<MyAppealItem[]>(['myAppeals']);
// 补充材料提交后立即切到 reviewing,避免用户重复提交;失败则回滚。
queryClient.setQueryData<MyAppealItem[]>(['myAppeals'], (old) =>
old?.map((item) =>
item.appealId === variables.appealId
? { ...item, status: 'reviewing', canSupplement: false }
: item
)
);
return previous ? { previous } : {};
},
mutationFn: (req) =>
api.post<boolean>(`/marketplace/appeals/${req.appealId}/supplements`, {
commandId: crypto.randomUUID(),
@ -197,8 +228,8 @@ export function useSupplementAppeal() {
attachmentIds: [],
privacyConfirmed: true,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['myAppeals'] });
onError: (_error, _variables, context) => {
queryClient.setQueryData(['myAppeals'], context?.previous);
},
});
}

View File

@ -0,0 +1,6 @@
import MarketAppealCenter from '@/features/market/components/MarketAppealCenter';
/** 市场治理结果与申诉独立入口(/market/appeals)。 */
export default function MarketAppealsPage() {
return <MarketAppealCenter />;
}