diff --git a/docs/agent-specs/2026-06-13-项目目标与模块现状基线.md b/docs/agent-specs/2026-06-13-项目目标与模块现状基线.md index 24773ae8..2018e24c 100644 --- a/docs/agent-specs/2026-06-13-项目目标与模块现状基线.md +++ b/docs/agent-specs/2026-06-13-项目目标与模块现状基线.md @@ -153,7 +153,7 @@ fork yudao-cloud:保留 `system/infra/framework/gateway/member/pay/bpm/report/mp | **元治理/MetaSchema** (muse-module-meta) | ✅ | ❌ | ✅ | ⚠ | ✅ | ✅ | **82%** | 未实跑/可编译 | 高度一致 | high | **impact-preview facade 仅 Unavailable→写链路运行期端到端阻断**;facade-api 缺失,跨 BC 投影消费契约未建立 | | **事件/SSE** (muse-module-events) | ✅ | ✅ | ✅ | ⚠ | ✅ | ✅ | **88%** | 未实跑/可编译 | 后端一致/前端漂移 | high | 前端 connectAIStream 契约漂移(被 mock 掩盖,真实联调会失败);统一事件客户端为孤儿无 UI 消费 | | **账户/个人中心** (muse-module-member) | ✅ | ❌ | ✅ | ⚠ | ✅ | ✅ | **68%** | 未实跑/可编译 | 端点齐/约2/3运行期降级 | high | 跨 BC facade 全 Unavailable→21/33 端面运行期 `*_UNAVAILABLE`;前端仅约 21% 面且跑 MSW | -| **平台底座** (gateway/system/infra/pay/bpm/mp/report + 两前端接线) | ✅ | ✅ | ❌(设计如此) | ✅ | ❌(沿用 yudao) | ✅ | **80%** | 未实跑/可编译 | 无契约(符合设计) | high | **gateway 路由未接任何 Muse BC + 保留死路由**;pay/bpm/mp/report 显式停用 0 运行覆盖;底座 schema 未 Flyway 化 | +| **平台底座** (gateway/system/infra/pay/bpm/mp/report + 两前端接线) | ✅ | ✅ | ❌(设计如此) | ✅ | ❌(沿用 yudao) | ✅ | **80%** | 未实跑/可编译 | 无契约(符合设计) | high | gateway 配置层已补 Muse BC 路由并清理停用模块死路由(2026-06-19),但运行态转发未首跑;pay/bpm/mp/report 显式停用 0 运行覆盖;底座 schema 未 Flyway 化 | > 说明:`api ❌` 表示该 BC 的 `-api` 子模块仅 package-info / ErrorCode(meta、account 无对外 facade-api DTO,knowledge 的 api 仅 ErrorCode,market 的 api 刻意薄属 yudao 风格,均非"缺失业务"而是"对外 RPC 契约未建立或无需");`contract ❌` 平台底座属设计上不为 yudao 基座单独定契约。 @@ -377,16 +377,15 @@ fork yudao-cloud:保留 `system/infra/framework/gateway/member/pay/bpm/report/mp ### 5.8 平台底座(gateway/system/infra/pay/bpm/mp/report + 两前端接线)— 完成度 80% -**实现摘要:** 平台底座 = 对 yudao-cloud 的干净 fork,8 个核查模块全部真实完整(非空壳):system 32 admin controller/36 Impl/39 测试;infra 13/13/21;pay 19/12(PayOrderServiceImpl 610 行 18 方法);bpm 11/12/18;mp 12/9;report 4 Impl。gateway 17 java(CorsFilter/TokenAuthenticationFilter/GrayLoadBalancer/AccessLogFilter/GlobalExceptionHandler 等真实组件)。`muse-server` 是聚合启动入口,**实际装配 system/infra/content/meta/events/knowledge/market/member/ai;pay/bpm/mp/report 在 pom 中显式注释停用**(故意"默认隐藏",符合后端-02)。包名已整体 fork `cn.iocoder.yudao`→`cn.iocoder.muse` 无残留。前端两仓均真实接线:muse-studio 212 处 `/app-api` 引用 + 真实 client.ts/sse.ts/indexed-db.ts/typed openapi.ts;muse-admin web-antd 真实 `src/api/muse/{ai,knowledge,market,account,governance,audit,newapi,jobs}` 调 `/admin-api/muse/**` 且各带 `__tests__`。 +**实现摘要:** 平台底座 = 对 yudao-cloud 的干净 fork,8 个核查模块全部真实完整(非空壳):system 32 admin controller/36 Impl/39 测试;infra 13/13/21;pay 19/12(PayOrderServiceImpl 610 行 18 方法);bpm 11/12/18;mp 12/9;report 4 Impl。gateway 17 java(CorsFilter/TokenAuthenticationFilter/GrayLoadBalancer/AccessLogFilter/GlobalExceptionHandler 等真实组件)。`muse-server` 是聚合启动入口,**实际装配 system/infra/content/meta/events/knowledge/market/member/ai;pay/bpm/mp/report 在 pom 中显式注释停用**(故意"默认隐藏",符合后端-02)。2026-06-19 已补 gateway 配置层接线:`/admin-api/muse/**`、`/app-api/muse/**` 指向 `grayLb://muse-server`,并删除停用模块死路由;`GatewayRouteConfigurationTest` 作为回潮门禁。包名已整体 fork `cn.iocoder.yudao`→`cn.iocoder.muse` 无残留。前端两仓均真实接线:muse-studio 212 处 `/app-api` 引用 + 真实 client.ts/sse.ts/indexed-db.ts/typed openapi.ts;muse-admin web-antd 真实 `src/api/muse/{ai,knowledge,market,account,governance,audit,newapi,jobs}` 调 `/admin-api/muse/**` 且各带 `__tests__`。 **桩与缺口:** -- **gateway/application.yaml 未为任何 Muse BC 配置路由;Muse 业务 API 当前不经网关,走 muse-server 单体——网关对 Muse 业务实际未接线**(设计写应承接 /admin-api + /app-api 路由)。 -- **gateway 保留 bpm/pay/mp/report 死路由**(/admin-api/bpm 等),而这些模块已从装配注释停用,路由指向不存在的服务。 +- **gateway 运行态转发未首跑**:`application.yaml` 已有 Muse 业务路由,但 `muse-server` 默认禁用 Nacos 注册,且本地默认端口与 gateway 同为 48080;当前 P1R-7 单体验收仍走 `muse-server` 直服。 - pay/bpm/mp/report 代码完整但被显式注释,**0 运行覆盖**(设计内"默认隐藏",但意味当前无运行/测试保活,有上游漂移腐化风险)。 - **平台底座 8 模块无 Muse 自有 Flyway 迁移**(sql/muse V1-V21 全是 BC schema),其 schema 仍依赖 yudao 遗留静态 SQL dump,未纳入 Flyway 受控迁移。 - mp 模块测试 0、report 仅 2(继承 yudao 现状)。`MockPayClient` 含 `UnsupportedOperationException`(yudao 原生 mock 设计,非 Muse 业务桩)。 -**设计 vs 现实差异:** 高度吻合(pay/bpm/report/mp"保留默认隐藏不删依赖"逐条对应),但 **1 处真实缺口=gateway 路由未对齐 Muse**:既保留已停用模块死路由,又完全没有 content/ai/knowledge/market/meta/events 真正 Muse BC 路由。设计写"承接路由",实际未承接 BC。 +**设计 vs 现实差异:** 高度吻合(pay/bpm/report/mp"保留默认隐藏不删依赖"逐条对应);gateway 已在配置层承接 Muse API 路由,但运行态仍未做网关转发首跑。 **"完成"声称 vs 现实:** **平台底座根本没有"完成"声称**,它是被继承的 yudao 基座;海量 P1R 文档与 24244 行 P1r* 测试全部针对 BC,与底座无关。对 BC 层这套机制经核查真实且反 churn:`P1rApiCoverageReportTest` 把 catch_all/generic_persistence/sse_placeholder/missing 列为不可标 completed 并断言 completed 恰为 147/233(其余 86 needs_verification,incomplete/blocked/placeholder 均 0);`P1rContentCoreCompletedApprovalIT` 真跑 Flyway(断言 21 迁移)+ 真实 PG 种子 + MockMvc 打真实控制器,断言分页/owner/租户隔离(跨租户 404 vs 跨 owner 403)/读路径不写 outbox;重 IT 用 `assumeTrue` 门控无 DB 时跳过而非伪绿。 @@ -394,7 +393,8 @@ fork yudao-cloud:保留 `system/infra/framework/gateway/member/pay/bpm/report/mp - `muse-server/muse-module-server/pom.xml`(显式注释停用 report/bpm/pay/mp,启用其余) - `muse-cloud/pom.xml:1-20`(groupId=cn.iocoder.cloud,modules 含全部) - `muse-module-pay/.../PayOrderServiceImpl.java:1-610`(真实 18 方法非空壳) -- `muse-gateway/src/main/resources/application.yaml:34-`(routes 仅 system/infra/member/bpm/report/pay/mp;**无 content/ai/knowledge/market/meta/events**;bpm/report/pay/mp 死路由) +- `muse-gateway/src/main/resources/application.yaml`(routes 含 `/admin-api/muse/**`、`/app-api/muse/** → grayLb://muse-server`;停用模块死路由已清理) +- `muse-gateway/src/test/java/cn/iocoder/muse/gateway/GatewayRouteConfigurationTest.java`(配置回潮门禁) - `docs/api-contracts/openapi-base.yaml`(paths:{} 为空,仅共享组件) - `docs/superpowers/reports/p1r-api-coverage.json`(totalOperations=233 completed=147 needsVerification=86,catchAll/genericPersistence/ssePlaceholder/missing/blocked=0;domain 无平台底座) - `muse-server/.../P1rApiCoverageReportTest.java:42-48,287`(NON_REAL 状态不可标 completed;assertEquals(147)) diff --git a/docs/mvp/进度总账.md b/docs/mvp/进度总账.md index 0bcf6c4f..2b3f1e77 100644 --- a/docs/mvp/进度总账.md +++ b/docs/mvp/进度总账.md @@ -55,7 +55,7 @@ ## 三、最大共性风险(跨模块,来自基线) 1. **前端 muse-studio 严重滞后**:AI 候选闭环断链、知识库/市场生产侧 UI 缺失、个人中心约 21% 面跑 MSW——“先审后入”在用户可见层当前不可用。 2. **跨 BC facade 多为 `Unavailable` 占位** → 运行期 `*_UNAVAILABLE`。 -3. **gateway 路由未接任何 Muse BC + 死路由**:若误以网关为真实入口部署,Muse BC 全部 404。 +3. **gateway 运行态转发未首跑**:配置层已接 `/admin-api/muse/**`、`/app-api/muse/**` 到 `muse-server` 并清理停用模块死路由;但 `muse-server` 默认禁用 Nacos 注册,网关真实转发仍需部署/注册发现首跑验证。 --- @@ -162,11 +162,12 @@ - 🔧 个人中心:**✅ profile/用量/权益 + 我的购买/我的授权 + 安全事件(读+确认写路)(rendered-UI 活体证,2026-06-15)**——PersonalCenter 新增「我的购买/我的授权」渲染 market→account 投影真实记录(`useAccountPurchases`/`useAccountLicenses`→`/account/{purchases,licenses}`;`live-read.spec.ts` 第 5 例 chromium MSW-off 绿,断言真实资产「活体市场资产·测试」渲染——证后端 account 读端在单体可用即被 UI 消费,见 §五A 活体订正);**新增「安全事件」区**渲染 `GET /account/security-events` 真实摘要(`useAccountSecurityEvents`→`AccountPageResult`;eventType/severity 徽标 + 已确认/待确认状态;`live-read.spec.ts` 第 6 例 chromium MSW-off 绿,断言种子事件「活体安全事件·异地登录提醒」渲染)。**确认写路**:未确认事件展示「确认」按钮→`useAcknowledgeSecurityEvent`(每次发新 commandId,后端按 commandId 幂等)真打 `POST /account/security-events/{id}/acknowledge`(action=acknowledged)→列表失活重取翻「已确认」、按钮消失(构成 acked→无动作 UI 不变量;`account-security-ack.spec.ts` chromium MSW-off 绿,种子事件 B「待确认演练」)。后端**正负路 curl 实证**(反假绿):正路 acknowledge 真实事件→`code:0`+列表 acknowledged 翻 true;负路 acknowledge 不存在 eventId→`404 安全事件不存在`、非法 action→`400 参数校验`,均 **fail-closed 非许可桩**。写入 **DB 双证**:`muse_member_security_event.acknowledged` 翻 true + `muse_account_security_event_ack` 追加 1 行(action=acknowledged、commandId 为前端 UUID=权威处理历史,非覆盖)。种子幂等可重置(/tmp/SeedSec.java:事件A读锚点幂等插入 + 事件B写锚点每轮重置为未确认)。本轮实跑:account-security-ack **1/1** + live-read **6/6** + vitest 47/47 + tsc 干净。**仍 ⬜**:exports / downloads / new-api-binding 深页(后端均为**正确 fail-closed 外部依赖**:对象存储 / New-API 未配置,非接线缺口,见 §五A)。 - 收口口径:每切片"关 MSW→真连活体单体→playwright e2e 绿"才算完成(反假绿)。 -### D. gateway 接线(B4,**判定:单体部署范围外**) -- ⏸ 单体 muse-server 直服 48080 `/app-api`,**网关不在单体部署关键路径**。`muse-gateway` 现 0 条 Muse BC 路由 + 4 条死路由(bpm/pay/report/mp)。收口动作:要么清理死路由 + 加 `/app-api/muse/**→muse-server` 单体路由,要么文档明确"网关仅微服务拆分期启用"。不阻 P1R-7 单体验收。 +### D. gateway 接线(B4,**配置层已收口;运行态后置**) +- ✅ **配置层收口(2026-06-19)**:`muse-gateway` 已新增 `/admin-api/muse/**`、`/app-api/muse/** → grayLb://muse-server`,删除当前聚合入口未启用的 bpm/pay/report/mp 死路由,Knife4j 聚合入口改为 `muse-server`。新增 `GatewayRouteConfigurationTest` 机械门禁,断言 Muse 路由存在、停用模块路由不回潮、Knife4j 不展示停用服务;本轮 `mvn -pl muse-gateway test -Dtest=GatewayRouteConfigurationTest` **3/3 绿**。 +- ⏸ **运行态仍后置**:单体 P1R-7 仍以 `muse-server` 直服 48080 `/app-api` 为验收关键路径;`muse-server` 默认禁用 Nacos 注册,且 gateway 与 server 本地默认端口同为 48080,所以本轮不声称网关真实转发已完成。后续若启用网关部署,需补注册发现/端口方案并做 curl/e2e 首跑。 ### E. ADR 级 ArchUnit grant/runtime 包隔离(B5,**判定:项目显式后置二期**) - ⏸ BC 边界门 `BcBoundaryArchTest` 已存在且强制(豁免空)。grant/runtime 包级隔离被项目显式后置二期,严格说不在 P1 范围;当前类命名 + 运行时 Guard 软约束。收口:补 `ArchRule` 强约束 runtime 不得 import grant,或文档确认后置。 ### 收口顺序(价值/可验证性优先) -1. ✅ 红测试修复(本轮) 2. ⬜ MetaImpact 真实化(解 meta 发布链,多模块) 3. ⬜ account 投影/归因(解 21 端) 4. ⬜ 验收债按 BC 补真实 PG IT 5. ⬜ 前端逐切片 + playwright 活体 e2e 6. ⏸ gateway/ArchUnit 判定收尾。 +1. ✅ 红测试修复(本轮) 2. ⬜ MetaImpact 真实化(解 meta 发布链,多模块) 3. ⬜ account 投影/归因(解 21 端) 4. ⬜ 验收债按 BC 补真实 PG IT 5. ⬜ 前端逐切片 + playwright 活体 e2e 6. ✅ gateway 配置层收口 / ⏸ ArchUnit grant-runtime 判定收尾。 diff --git a/muse-cloud/muse-gateway/.agent b/muse-cloud/muse-gateway/.agent new file mode 100644 index 00000000..1288fb42 --- /dev/null +++ b/muse-cloud/muse-gateway/.agent @@ -0,0 +1,9 @@ +# .agent — muse-gateway(统一入口/路由) + +> 模块跨会话记忆。进度权威见 [总账](../../docs/mvp/进度总账.md)。本文件只记目标/边界/out-of-scope/现状指针/TODO,过时即更。 + +- **目标(owner 职责)**:统一入口、认证透传、灰度负载、访问日志、Knife4j 聚合与 `/admin-api/**`、`/app-api/**` 路由承接。 +- **边界**:gateway 只做入口转发与横切过滤,不承载 Muse 业务事实、不写 BC 数据、不替代 `muse-server` 聚合容器。 +- **out-of-scope**:Content/Knowledge/AI/Market/Meta/Account 业务规则、单体内 facade 装配、外部服务可用性。 +- **现状**:2026-06-19 已完成配置层收口:`/admin-api/muse/**` 与 `/app-api/muse/**` 路由到 `muse-server`,并删除当前聚合入口未启用的 bpm/pay/report/mp 死路由;`GatewayRouteConfigurationTest` 作为回潮门禁。 +- **关键风险 / TODO**:配置门禁不等于运行态联调完成。`muse-server` 当前默认禁用 Nacos 注册,网关真实转发仍需微服务部署/注册发现或单独端口方案首跑验证。 diff --git a/muse-cloud/muse-gateway/pom.xml b/muse-cloud/muse-gateway/pom.xml index 2b2b3505..91c90f2a 100644 --- a/muse-cloud/muse-gateway/pom.xml +++ b/muse-cloud/muse-gateway/pom.xml @@ -75,6 +75,11 @@ com.github.ben-manes.caffeine caffeine + + cn.iocoder.cloud + muse-spring-boot-starter-test + test + diff --git a/muse-cloud/muse-gateway/src/main/resources/application.yaml b/muse-cloud/muse-gateway/src/main/resources/application.yaml index ecccb585..1f24c3a4 100644 --- a/muse-cloud/muse-gateway/src/main/resources/application.yaml +++ b/muse-cloud/muse-gateway/src/main/resources/application.yaml @@ -79,44 +79,19 @@ spring: - Path=/app-api/member/** filters: - RewritePath=/app-api/member/v3/api-docs, /v3/api-docs - ## bpm-server 服务 - - id: bpm-admin-api # 路由的编号 - uri: grayLb://bpm-server + ## muse-server 聚合服务:承接 Muse 业务 API。pay/bpm/report/mp 当前在聚合入口默认停用,不在网关保留死路由。 + - id: muse-admin-api # 路由的编号 + uri: grayLb://muse-server predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - - Path=/admin-api/bpm/** + - Path=/admin-api/muse/** filters: - - RewritePath=/admin-api/bpm/v3/api-docs, /v3/api-docs - ## report-server 服务 - - id: report-admin-api # 路由的编号 - uri: grayLb://report-server + - RewritePath=/admin-api/muse/v3/api-docs, /v3/api-docs + - id: muse-app-api # 路由的编号 + uri: grayLb://muse-server predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - - Path=/admin-api/report/** + - Path=/app-api/muse/** filters: - - RewritePath=/admin-api/report/v3/api-docs, /v3/api-docs - - id: report-jimu # 路由的编号(积木报表) - uri: grayLb://report-server - predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - - Path=/jmreport/**, /drag/**, /jimubi/** - ## pay-server 服务 - - id: pay-admin-api # 路由的编号 - uri: grayLb://pay-server - predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - - Path=/admin-api/pay/** - filters: - - RewritePath=/admin-api/pay/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs - - id: pay-app-api # 路由的编号 - uri: grayLb://pay-server - predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - - Path=/app-api/pay/** - filters: - - RewritePath=/app-api/pay/v3/api-docs, /v3/api-docs - ## mp-server 服务 - - id: mp-admin-api # 路由的编号 - uri: grayLb://mp-server - predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - - Path=/admin-api/mp/** - filters: - - RewritePath=/admin-api/mp/v3/api-docs, /v3/api-docs + - RewritePath=/app-api/muse/v3/api-docs, /v3/api-docs x-forwarded: prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀 default-filters: @@ -143,21 +118,12 @@ knife4j: - name: member-server service-name: member-server url: /admin-api/member/v3/api-docs - - name: bpm-server - service-name: bpm-server - url: /admin-api/bpm/v3/api-docs - - name: pay-server - service-name: pay-server - url: /admin-api/pay/v3/api-docs - - name: mp-server - service-name: mp-server - url: /admin-api/mp/v3/api-docs - - name: ai-server - service-name: ai-server - url: /admin-api/ai/v3/api-docs + - name: muse-server + service-name: muse-server + url: /admin-api/muse/v3/api-docs --- #################### 芋道相关配置 #################### muse: info: - version: 1.0.0 \ No newline at end of file + version: 1.0.0 diff --git a/muse-cloud/muse-gateway/src/test/java/cn/iocoder/muse/gateway/GatewayRouteConfigurationTest.java b/muse-cloud/muse-gateway/src/test/java/cn/iocoder/muse/gateway/GatewayRouteConfigurationTest.java new file mode 100644 index 00000000..45da4342 --- /dev/null +++ b/muse-cloud/muse-gateway/src/test/java/cn/iocoder/muse/gateway/GatewayRouteConfigurationTest.java @@ -0,0 +1,175 @@ +package cn.iocoder.muse.gateway; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.StreamSupport; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Gateway 路由配置门禁。 + * + *

本测试只校验静态路由事实: Muse 业务 API 不能再被网关漏掉,当前聚合入口未启用的 pay/bpm/report/mp + * 也不能继续保留死路由。它不声明运行态注册发现已经可用,运行态转发仍由部署环境的服务注册负责。

+ */ +class GatewayRouteConfigurationTest { + + private static final Set DISABLED_SERVICE_NAMES = Set.of( + "bpm-server", "pay-server", "report-server", "mp-server"); + + private static final Set DISABLED_PATH_FRAGMENTS = Set.of( + "/admin-api/bpm", "/app-api/bpm", + "/admin-api/pay", "/app-api/pay", + "/admin-api/report", "/app-api/report", + "/admin-api/mp", "/app-api/mp", + "/jmreport", "/drag", "/jimubi"); + + @Test + void should_route_muse_business_api_to_muse_server() throws IOException { + List routes = gatewayRoutes(); + + RouteView adminRoute = requireRoute(routes, "muse-admin-api"); + assertEquals("grayLb://muse-server", adminRoute.uri()); + assertTrue(adminRoute.predicates().contains("Path=/admin-api/muse/**"), + "网关必须承接 Muse 管理端业务 API: " + adminRoute.predicates()); + assertTrue(adminRoute.filters().contains("RewritePath=/admin-api/muse/v3/api-docs, /v3/api-docs"), + "网关必须保留 Muse 管理端 OpenAPI 聚合重写: " + adminRoute.filters()); + + RouteView appRoute = requireRoute(routes, "muse-app-api"); + assertEquals("grayLb://muse-server", appRoute.uri()); + assertTrue(appRoute.predicates().contains("Path=/app-api/muse/**"), + "网关必须承接 Muse 用户端业务 API: " + appRoute.predicates()); + assertTrue(appRoute.filters().contains("RewritePath=/app-api/muse/v3/api-docs, /v3/api-docs"), + "网关必须保留 Muse 用户端 OpenAPI 聚合重写: " + appRoute.filters()); + } + + @Test + void should_not_keep_routes_for_disabled_optional_modules() throws IOException { + List routes = gatewayRoutes(); + + for (RouteView route : routes) { + assertFalse(DISABLED_SERVICE_NAMES.stream().anyMatch(route.uri()::contains), + "网关不得继续指向聚合入口未启用的服务: " + route); + String joinedPredicates = String.join("\n", route.predicates()); + assertFalse(DISABLED_PATH_FRAGMENTS.stream().anyMatch(joinedPredicates::contains), + "网关不得继续保留停用模块的死路由路径: " + route); + } + } + + @Test + void should_publish_only_enabled_services_in_knife4j_gateway_index() throws IOException { + List> routes = knife4jRoutes(); + Set serviceNames = routes.stream() + .map(route -> Objects.toString(route.get("service-name"), "")) + .collect(Collectors.toSet()); + + assertTrue(serviceNames.contains("muse-server"), "Knife4j 聚合入口必须包含 muse-server"); + for (String disabledServiceName : DISABLED_SERVICE_NAMES) { + assertFalse(serviceNames.contains(disabledServiceName), + "Knife4j 聚合入口不得继续展示停用模块: " + disabledServiceName); + } + } + + private RouteView requireRoute(List routes, String id) { + return routes.stream() + .filter(route -> id.equals(route.id())) + .findFirst() + .orElseThrow(() -> new AssertionError("缺少 Gateway 路由: " + id)); + } + + @SuppressWarnings("unchecked") + private List gatewayRoutes() throws IOException { + Map root = readApplicationYaml(); + Map webflux = nestedMap(root, + "spring", "cloud", "gateway", "server", "webflux"); + Object rawRoutes = webflux.get("routes"); + assertTrue(rawRoutes instanceof Collection, "Gateway routes 必须是 YAML 列表"); + return ((Collection) rawRoutes).stream() + .map(route -> routeView((Map) route)) + .toList(); + } + + @SuppressWarnings("unchecked") + private List> knife4jRoutes() throws IOException { + Map root = readApplicationYaml(); + Map gateway = nestedMap(root, "knife4j", "gateway"); + Object rawRoutes = gateway.get("routes"); + assertTrue(rawRoutes instanceof Collection, "Knife4j gateway.routes 必须是 YAML 列表"); + return ((Collection>) rawRoutes).stream().toList(); + } + + private RouteView routeView(Map route) { + return new RouteView( + Objects.toString(route.get("id"), ""), + Objects.toString(route.get("uri"), ""), + stringList(route.get("predicates")), + stringList(route.get("filters"))); + } + + @SuppressWarnings("unchecked") + private List stringList(Object value) { + if (value == null) { + return List.of(); + } + assertTrue(value instanceof Collection, "YAML 节点必须是列表: " + value); + return ((Collection) value).stream() + .map(Objects::toString) + .toList(); + } + + @SuppressWarnings("unchecked") + private Map nestedMap(Map root, String... keys) { + Map current = root; + for (String key : keys) { + Object value = current.get(key); + assertTrue(value instanceof Map, "YAML 缺少映射节点: " + String.join(".", keys)); + current = (Map) value; + } + return current; + } + + @SuppressWarnings("unchecked") + private Map readApplicationYaml() throws IOException { + Path applicationYaml = findRepoRoot() + .resolve("muse-cloud/muse-gateway/src/main/resources/application.yaml"); + assertTrue(Files.exists(applicationYaml), "Gateway application.yaml 必须存在: " + applicationYaml); + try (InputStream inputStream = Files.newInputStream(applicationYaml)) { + Object loaded = StreamSupport.stream(new Yaml().loadAll(inputStream).spliterator(), false) + .filter(LinkedHashMap.class::isInstance) + .findFirst() + .orElseThrow(() -> new AssertionError("Gateway application.yaml 必须至少包含一个 YAML 映射文档")); + return (Map) loaded; + } + } + + /** + * 从 surefire 工作目录逐级上溯到仓库根,避免模块内/根目录执行时相对路径漂移。 + */ + private Path findRepoRoot() { + Path current = Path.of("").toAbsolutePath(); + for (Path candidate = current; candidate != null; candidate = candidate.getParent()) { + if (Files.exists(candidate.resolve("muse-cloud/muse-gateway/src/main/resources/application.yaml"))) { + return candidate; + } + } + return current; + } + + private record RouteView(String id, String uri, List predicates, List filters) { + } + +}