git
build / build-push (push) Failing after 40s

This commit is contained in:
wanghu
2026-09-02 10:44:33 +08:00
parent 8774b6cc44
commit 8c840aa933
246 changed files with 13876 additions and 9343 deletions
+285
View File
@@ -0,0 +1,285 @@
# 底层框架代码审查报告
> 审查对象:`YHDocPrintCore`(业务侧,YhCore.\*)+ `YHBasePlatformDotnet`(底层框架,Yh.Platform.\*)
> 审查日期:2026-09-01 | 审查范围:Startup / 中间件 / 公共类 / 框架注册与验签链路 / CAS 认证 / OpenApi 客户端
## 结论
整体迁移质量良好:DI 生命周期、配置绑定、异常链路、签名协议与 Java 侧对齐度高,测试套件 98/98 通过。
审查发现 **5 个 P1 契约缺陷**(3 个静态审查 + 2 个冒烟触发)与 **4 个 P2 健壮性问题**,均已修复。
---
## 一、P1 问题(已修复)
### 1.1 管道顺序缺陷:/oapi 应用身份(AppKey)被下游中间件清空
**位置**:`YhCore.WebApi/Startup.cs`(管道顺序)+ `Yh.Platform.Auth/UserContextMiddleware.cs`
**现象**:原管道 `AppAuth(2) → Authentication(3) → UseYhUserContext(4) → ...`。`AppAuthMiddleware` 验签通过后写入 `UserContextHolder.Set(LoginUser{AppKey})`(位置 2),而 `UserContextMiddleware` 的 `finally` 在请求结束前**无条件** `UserContextHolder.Clear()`(位置 4 更内层,其 finally 在整条链路末尾执行)。结果:`/oapi` 业务 Controller 中 `UserContextHolder.Get()` 恒为 `null`,`AppAuthMiddleware` 注释承诺的"业务可经 UserContextHolder 读取 AppKey"失效。
**修复**:调整管道顺序,`AppAuth` 移到 `UseYhUserContext` 之后——`Authentication → SsoProtection → UseYhUserContext → AppAuth → LegacyAdapter → Authorization`。`UserContextMiddleware` 的 finally 在 AppAuth 写入之后才执行,AppKey 可存活到业务层;且不再被误清空。
```
修复前: ApiAccessLog → GlobalException → AppAuth → Auth → YhUserContext → Legacy → Sso → Authorization
修复后: ApiAccessLog → GlobalException → Auth → Sso → YhUserContext → AppAuth → Legacy → Authorization
```
### 1.2 业务自定义错误码被 `ErrorCodeUtil.From` 吞成 Error=1
**位置**:`YhCore.Common/Exceptions/BizException.cs`
**现象**:`ErrorCodeUtil.From(int)` 对未知码 `_ => ErrorCode.Error`(Code=1)。业务 `throw new BizException("库存不足", 5001)` 经映射后 `Code` 变成 1,前端收到 `code:1` 而非 `code:5001`,错误码契约失效。
**修复**:未知码返回保留原始 code 的 `RawErrorCode(code, "")`(内部 record),msg 由业务消息承载。已知码(0/2/3/4/5/6/405)映射不变。
### 1.3 CAS 回跳开放重定向
**位置**:`Yh.Platform.Auth/CasAuthenticationHandler.cs`
**现象**:`HandleAuthenticateAsync` 中 `Response.Redirect(returnUrl)` 未校验来源,攻击者可诱导已登录用户访问 `https://app/api/x?returnUrl=https://evil.com`,登录后浏览器被跳转到外站(开放重定向)。
**修复**:新增 `IsLocalUrl` 校验(仅放行 `/` 开头的本地相对路径,拒绝 `//host`、`/\host`、绝对地址),Challenge 拼 service 与回调回跳两处均校验;非法 returnUrl 回退 `/`。
### 1.4 UserContextMiddleware 构造注入 Scoped 服务,服务启动即崩溃
**位置**:`Yh.Platform.Auth/UserContextMiddleware.cs`
**现象**:`IUserDetailsProvider` 注册为 **Scoped**,而中间件在**构造函数**注入它。ASP.NET Core 的 `UseMiddleware<T>` 在管道构建(启动)时用 `ActivatorUtilities` 从**根容器**激活中间件,构造含 Scoped 依赖直接抛 `InvalidOperationException: Cannot resolve scoped service 'Yh.Platform.Auth.IUserDetailsProvider' from root provider`,**WebApi 起不来**(此前迁移冒烟未重启实例,缺陷被掩盖;本轮验证 /oapi 时首次触发)。
**修复**:改为 ASP.NET Core 官方模式——构造仅保留 `RequestDelegate`(Singleton),Scoped 依赖 `IUserDetailsProvider` 移到 `InvokeAsync(HttpContext, IUserDetailsProvider)` **方法注入**(每请求由 DI 从请求作用域解析)。同步更新 `AuthContextTests` 两处调用。
### 1.5 服务端验签密钥链不读配置,合法签名恒 401
**位置**:`Yh.Platform.AppAuth/SignKeyResolver.cs`
**现象**:`GetSecretAsync` 解析链仅 `Redis(sign:app:{appKey}) → IMemoryCache → UserBase 兜底`,**不消费 `yh:app-auth` 的 AppKey/AppSecret**;且 `AppAuthOptions.TrustedApps` 对应的 `PropertyAppSecretProvider` 从未被注册(`IAppSecretProvider` 写死为 `SignKeyResolverAppSecretProvider`)。本地联调无 Redis 预置 key、无 UserBase → secret 恒为 null → **任意合法签名都 401**。此前冒烟只断言"无签名/假签名 → 401",未验证"合法签名 → 通过",契约缺陷未被发现。
**修复**:解析链头部增加「配置内置应用」层——`appKey == yh:app-auth:AppKey` 时直接使用配置的 `AppSecret`(覆盖"A 以自身密钥调 B 的 /oapi,B 用配置密钥验签"的自服务/联调场景);第三方应用密钥仍走 Redis → Cache → UserBase。配置密钥从此对服务端验签生效。
---
## 二、P2 问题(已修复)
### 2.1 MemoryReplayGuard 并发下防重放失效
**位置**:`Yh.Platform.AppAuth/ReplayGuard.cs`
**现象**:`TryGetValue` 检查后 `_seen[key] = ...` 写入,非原子。两个并发相同签名请求(同 appKey/timestamp/nonce)可同时通过检查同时放行,防重放被绕过。
**修复**:改为 `ConcurrentDictionary.TryAdd` 原子插入;未过期命中拒绝,过期条目允许覆盖。
### 2.2 SignCanonical 非法 URL 编码导致 500
**位置**:`Yh.Platform.AppAuth/SignCanonical.cs`
**现象**:`Uri.UnescapeDataString` 对未编码的 `%`(如 `?a=100%`)抛 `UriFormatException`,`AppAuthMiddleware` 验签前调用 `BuildTarget` 未捕获 → 兜底返回 `code:6` 内部错误,而非 `401`。
**修复**:`UrlDecode` 捕获 `UriFormatException` 原样返回;恶意参数验签必失败 → 正确返回 401。
### 2.3 CasAuthenticationOptions.TicketValidateTimeoutMs 配置未生效
**位置**:`Yh.Platform.Auth/CasServiceTicketValidator.cs` + `AuthServiceCollectionExtensions.cs` + `CasAuthenticationHandler.cs`
**现象**:配置项定义了(默认 5000ms)但 validator 硬编码 `5000`,改配置无效。
**修复(两阶段)**:
1. `CasServiceTicketValidator` 构造函数接收 `timeoutMs`,注册时从 `IOptions<CasAuthenticationOptions>` 传入;
2. **补漏(9/1 SSO 故障排查发现)**:`CasAuthenticationHandler` 此前绕过 DI、自建 validator 时只传 URL 不传超时 → 配置仍不生效。改为**构造注入 DI 注册的 `CasServiceTicketValidator`**,注册源升级 `IOptionsMonitor<CasAuthenticationOptions>.CurrentValue`。业务侧 `yh:sso:TicketValidateTimeoutMs` 提升到 10000(实测 CAS serviceValidate 延迟 0.5~4.5s+ 波动,5s 默认值存在偶发超时风险)。
### 2.4 AppAuthMiddleware 手拼 JSON 转义不完整
**位置**:`YhCore.WebApi/Middleware/AppAuthMiddleware.cs`
**现象**:`"{\"code\":4,\"msg\":\"" + message.Replace("\"", "\\\"") + ...` 仅转义引号,含反斜杠/换行时产出非法 JSON(当前 message 均为内部常量,风险低)。
**修复**:改用 `System.Text.Json.JsonSerializer.Serialize`。
---
## 三、观察项(未改动,建议后续处理)
| # | 位置 | 说明 | 建议 |
|---|---|---|---|
| 3.1 | `OpenApiOptions.Enabled` / `YhAuthOptions.Enabled` | 配置项定义了但注册链路未消费,`yh:open:Enabled=false` 仍会注册客户端并装配门面 | 消费方明确后接入(涉及门面装配语义,需测试覆盖) |
| 3.2 | `Startup` CORS | `AllowAnyOrigin` 宽松,注释已标注待 CAS 接入后按 `yh.sso.allow-origins` 收紧 | 上线前提供允许源清单后收紧 |
| 3.3 | 反代部署 | `BuildServiceUrl` 依赖 `request.Scheme/Host`,反代后 HTTPS 终止时 service 可能错配 | 生产配置 `AppBaseUrl` 或加 `UseForwardedHeaders` |
| 3.4 | `CasServiceTicketValidator` | 回调低频,每次 new `SocketsHttpHandler`,可接受 | 高并发可换共享 `HttpClient` |
| 3.5 | `GlobalExceptionMiddleware` | `OperationCanceledException`(客户端断开)走兜底 `code:6` | 若需区分可前置 catch |
| 3.6 | `OpenApiClientBase` | `GetRawText()` 二次反序列化,大响应内存开销 | 数据量大时改流式/直接反序列化 |
| 3.7 | `SsoProtectionMiddleware` | 直接读 `IConfiguration` 而非 `IOptions`,且保留旧节 `Yh:Sso:Enabled` 兼容 | 框架稳定后移除旧节回退逻辑 |
---
## 四、验证结果
- ✅ 框架重编(Release):**0 警告 0 错误**
- ✅ 框架测试套件:**98/98 通过**(Abstractions 8 / AppAuth 42 / Auth 11 / OpenApi 37)——9 处修复零回归
- ✅ 业务侧编译(Debug):**0 错误**(仅 net5.0 EOL 警告 NETSDK1138)
- ✅ 框架 Release net5.0 产物已拷回 `E:\YHXT\net\Library`(时间戳 2026-09-01 10:03)
- ✅ **/oapi 冒烟矩阵**(实例 51835 实启验证):
| 用例 | 结果 | 断言 |
|---|---|---|
| 无签名 `GET /oapi/hello` | 401 `{code:4,"msg":"缺少签名头…"}` | 未带票据被拒 |
| 合法签名 `GET /oapi/hello`(无 Cookie) | **404**(直达路由层) | 验签通过、**匿名直访成功** |
| 合法签名 + 排序 query | 404 | 签名口径含 query 正确 |
| 假签名 | 401 `{code:4}` | 票据非法被拒 |
| 无 Cookie `GET /api/xxx` | **302** → CAS 登录页 | SSO 保护不受影响 |
| 无 Cookie `GET /mobiapi/xxx` | **302** → CAS 登录页 | SSO 保护不受影响 |
> 备注:编译期间遇到 `MSB3021/MSB3027` 文件锁定,根因为**上次冒烟测试遗留的 YhCore.WebApi 实例进程(PID 21104,监听 51999)**仍持有 `bin` 下 DLL 句柄,`Stop-Process` 结束后编译恢复。排查路径:`netstat -ano | grep <PID>` → 确认连 Redis → taskkill。
## 五、修复文件清单
**业务侧(YHDocPrintCore)**
- `src/YhCore.WebApi/Startup.cs` — 管道顺序调整(1.1)
- `src/YhCore.Common/Exceptions/BizException.cs` — 未知码透传(1.2)
- `src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs` — JSON 序列化(2.4)
- `src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs` — 显式放行 /oapi(渠道契约,1.1 配套)
**框架侧(YHBasePlatformDotnet)**
- `src/Yh.Platform.Auth/CasAuthenticationHandler.cs` — 开放重定向修复(1.3)
- `src/Yh.Platform.Auth/UserContextMiddleware.cs` — Scoped 构造注入改方法注入(1.4)
- `tests/Yh.Platform.Auth.Tests/AuthContextTests.cs` — 同步新调用约定(1.4)
- `src/Yh.Platform.AppAuth/SignKeyResolver.cs` — 配置内置应用密钥层(1.5)
- `src/Yh.Platform.AppAuth/ReplayGuard.cs` — 并发防重放(2.1)
- `src/Yh.Platform.AppAuth/SignCanonical.cs` — 非法编码容错(2.2)
- `src/Yh.Platform.Auth/CasServiceTicketValidator.cs` — 超时配置接入(2.3)
- `src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs` — 超时配置接入(2.3)
---
## 六、追加:BizException 退役(9/1)
业务异常统一改为**直接使用框架 `DException`**(`Yh.Platform.Abstractions`),`BizException` 包装类已删除:
- `src/YhCore.Common/Exceptions/BizException.cs` **已删除**;`ErrorCodeUtil.From(int)` 独立为 `ErrorCodeUtil.cs`(int → IErrorCode 映射保留,业务抛码仍可用)
- `GlobalExceptionMiddleware`:原先逻辑完全相同的 `catch (BizException)` + `catch (DException)` 合并为单个 `catch (DException)`
- `WrapBizExceptionAttribute` 更名 `WrapDExceptionAttribute`(当前无业务标注,改名安全)
- 业务写法:`throw new DException(ErrorCodeUtil.From(5001), "库存不足")`;仅提示语用 `new DException("提示语")`(默认 Custom=5)
---
## 七、追加:SSO 单点登录失败排查(9/1)
**现象**:浏览器打开 `http://localhost:51835/signin-cas?ticket=ST-7-...` 报"单点登录失败"。
### 根因(四层)
1. **CAS 票据单次有效**:`ST-7` 来自更早会话(00:41 首个实例),已被消费或过期。curl 直调 `serviceValidate` 实测 CAS 返回 `<cas:authenticationFailure code="INVALID_TICKET">Ticket 'ST-7-...' not recognized</cas:authenticationFailure>`——**任何重复校验必然失败**,这是 CAS 协议设计(防重放),非缺陷。
2. **应用当时未运行**:51835 端口无监听进程(netstat 确认),回调 URL 无法被服务。
3. **UX 缺陷**:修复前失效票据回调 → `AuthenticateResult.Fail` → 未认证 → 路径不在保护集 → 落 MVC 裸 **404**,用户感知为"登录失败"。
4. **潜在超时风险**:实测 CAS serviceValidate 延迟 **0.5~4.5s+ 波动**,框架默认超时 5s 且此前配置未真正生效(见 2.3 补漏),CAS 慢时登录偶发失败。
### 修复(框架 2 处 + 业务 2 处,第一轮)
- 框架 `CasAuthenticationHandler`:**构造注入 DI 注册的 `CasServiceTicketValidator`**(2.3 补漏),并补回旧版有、迁移时丢失的 **`Context.User = principal`**(SignInAsync 只写 Cookie 不改当前请求身份,缺它后续中间件误判未认证)。
- 框架 `AuthServiceCollectionExtensions`:validator 注册源 `IOptions` → `IOptionsMonitor<CasAuthenticationOptions>.CurrentValue`。
- 业务 `SsoProtectionMiddleware`:CAS 回调路径(`yh:sso:signInCallbackPath`)纳入保护集——失效票据回调**自动 302 弹回 CAS 登录页重新登录**,不再裸 404。
- 业务 `appsettings.json`:`yh:sso:TicketValidateTimeoutMs: 10000`(吸收 0.5~4.5s+ 延迟波动)。
### 验证(实例 51835 实启,第一轮)
| 用例 | 修复前 | 修复后 |
|---|---|---|
| 旧票据 `GET /signin-cas?ticket=ST-7-...` | **404** | **302 → CAS 登录页**(自动重登) |
| `GET /` | 302 → CAS | 302 → CAS(不变) |
| 无 Cookie `GET /api/GetUserInfo` | 302 → CAS | 302 → CAS(不变) |
| 无签名 `GET /oapi/Test` | 401 | 401(不变) |
| `GET /swagger/index.html` | 200 | 200(不变) |
⚠️ **此轮验证只覆盖了失败路径**:真实成功路径(票据校验 → Cookie → 回跳)依赖真实 CAS 账号,无法端到端验证。随后用本地 Mock CAS 服务器补测,**立刻暴露一个更深层的框架级缺陷**(见附录八)——第一轮的"302 弹回登录页"只是防循环表象,票据校验实际从未被触发。
> **给使用者的操作指引**:CAS 票据单次有效且时效短——登录流程必须**从受保护页面(`/` 或 `/api/xxx`)发起**,不要收藏/刷新带 `ticket` 的回调 URL(刷新必失败)。失败后回到 `/` 重新走一遍即可。
## 八、追加:SSO 成功路径端到端验证——DefaultScheme 缺陷(9/1 第二轮)
**背景**:附录七修复后,用本地 Mock CAS(`Temp/mock-cas.js`,自动登录 + 单次票据语义)通过环境变量 `yh__sso__CasServerUrlPrefix` 覆盖指向 Mock,对**成功路径**做全链路验证。结果发现:票据校验请求**从未送达** CAS(Mock 日志只有 `/cas/login`,无 `/serviceValidate`),回调 2ms 即返回——校验在网络请求之前就被跳过。
### 根因(框架级,P0)
`AuthServiceCollectionExtensions.AddAuthentication` 中 **`DefaultAuthenticateScheme = CookieScheme`**。`UseAuthentication` 每请求只调用 `AuthenticateAsync(默认 scheme)` 恢复 Cookie 登录态;**CAS 票据校验(`CasAuthenticationHandler.HandleAuthenticateAsync` 的 ticket 分支)在回调请求上没有任何触发点**:
```
回调 /signin-cas?ticket=ST-… 进入
└─ UseAuthentication → 只查 Cookie(无)→ 未认证
└─ SsoProtectionMiddleware:带 ticket + 未认证 → 直接 401 防循环(附录七引入)
└─ CasAuthenticationHandler.HandleAuthenticateAsync 从未执行 → 票据从不校验 → 登录永远失败
```
即:第一轮"防循环"把**合法票据路径一并拦截**,与 `DefaultScheme` 错误叠加,导致任何 CAS 登录都不可能成功(Mock 端到端立即暴露,真实账号难以察觉)。
### 修复(框架 1 处 + 业务 1 处)
- **框架 `AuthServiceCollectionExtensions`**:`DefaultAuthenticateScheme` 改为 `YhAuthDefaults.CasScheme`。`CasAuthenticationHandler.HandleAuthenticateAsync` 第一步就是 `AuthenticateAsync(CookieScheme)` 恢复登录态,因此语义等价"先 Cookie 恢复、后 ticket 校验",每请求行为不变、仅补上票据校验触发点。
- **业务 `SsoProtectionMiddleware`**:回调路径**已认证且已 302**(handler 验票成功 `Response.Redirect`)时**短路管道**——handler 返回 `NoResult` 不短路,继续走 MVC 端点匹配会因 `/signin-cas` 无 action 返回 **404 覆盖 302**(实测现象),短路保住回跳响应。
### 验证(Mock CAS 端到端,实例 51835 实启)
| # | 场景 | 结果 |
|---|---|---|
| A | `/` → Mock 登录 → 回调验票 → Cookie → 回跳 `/` | **302 → 200**,Cookie 落盘 ✓ |
| B | 带 Cookie `GET /api/GetUserInfo` | **200**(登录态生效)✓ |
| C | 复用已消费票据 `GET /signin-cas?ticket=同前` | **401 防循环页** ✓(Mock 第二次校验返回 INVALID_TICKET) |
| D | 无 Cookie `GET /api/GetUserInfo` | 302 → CAS ✓ |
| E | 无签名 `GET /oapi/Test` | 401(SSO 不覆盖)✓ |
Mock 日志互证:`02:35:09 /cas/serviceValidate?ticket=ST-MOCK-…` 送达并成功、票据被消费;复用同一票据时 Mock 返回 `INVALID_TICKET` → 应用 401。
框架重编 0 错误、测试 **98/98 通过**、业务编译 0 错误;`Yh.Platform.Auth.dll` 已更新至 `E:\YHXT\net\Library`。
> **教训**:SSO 类链路缺陷必须覆盖**成功路径**验证——失败路径的"防循环 401/302"表象可能掩盖"校验从未发生"的框架级问题。无真实账号时用 Mock CAS 全链路补测是必要手段。
---
## 附录九:登录态 Cookie 会话改造(9/1 需求:关闭失效 / 1 分钟过期 / 32 位)
**需求**:① 关闭浏览器 Cookie 失效;② 过期时间 1 分钟;③ Cookie 长度为 32 位。
**实现(框架 `Yh.Platform.Auth` 3 处 + 业务 1 处)**:
| 文件 | 变更 |
|---|---|
| `SessionTicketDataFormat.cs`(新增) | 自定义 `ISecureDataFormat<AuthenticationTicket>`:`Protect` 把完整票据存服务端内存、返回 **32 位 key**(Guid N 格式);`Unprotect` 查表并校验 `ExpiresUtc`(过期返回 null 并清理)。key 为 128 位随机熵,不可枚举 |
| `AuthServiceCollectionExtensions.cs` | Cookie 注册:`options.TicketDataFormat = new SessionTicketDataFormat()`;`ExpireTimeSpan` 由 `yh:auth:CookieExpireMinutes` 决定(默认 1 分钟);`SlidingExpiration = false`(固定过期不滑动) |
| `CasAuthenticationHandler.cs` | `IsPersistent: true → false`(会话级 Cookie,关闭浏览器失效;旧版 `_legacy` 本为 false,系迁移回归,已修正) |
| 业务 `appsettings.json` | `CookieExpireHours: 8` → `CookieExpireMinutes: 1`(注:文件曾被外部覆盖一次,CookieName 现为用户保存的 `.yh.auth`) |
**关键技术发现**:**.NET 5 的 `CookieAuthenticationOptions.SessionStore` 并不能缩短 Cookie**——官方源码(v5.0.17 `CookieAuthenticationHandler.HandleSignInAsync`)显示即便配置了 SessionStore,cookie 值仍是 `TicketDataFormat.Protect(精简票据)` 的加密长串(`CfDJ8…`),只是把完整用户票据移到了服务端。**要实现"Cookie 32 位",必须自定义 `TicketDataFormat`(Protect 落库返回 key)**,而非依赖 SessionStore。首次尝试(SessionTicketStore + SessionStore)经最小复现程序(独立 net5.0 Web 项目直连框架 DLL)证实无效后放弃。
**验证(Mock CAS 端到端 + 真实配置冒烟)**:
| 需求 | 验证结果 |
|---|---|
| ③ Cookie 32 位 | `Set-Cookie: .yh.auth=3a3f40993606438e86d8e756b99a6f07` = **32 字符** ✓ |
| ① 关闭浏览器失效 | Set-Cookie 无 `Max-Age`/`Expires`(会话 Cookie)+ `IsPersistent=false` ✓ |
| ② 1 分钟过期 | 登录后立即带 Cookie 访问 `/` → 200;**65 秒后** → **302 弹回 CAS 登录页**(服务端票据过期)✓ |
框架双目标(net5.0/net8.0)编译 0 错误 0 警告、测试 **98/98 通过**、业务编译 0 错误;DLL 已更新至 `E:/YHXT/net/Library`。
**注意**:票据存进程内存(ConcurrentDictionary)——应用重启后所有登录态失效(用户需重新登录);多实例部署时各实例票据互不可见(如需跨实例共享可换 Redis 实现)。
### 附录九补充:退出即吊销——显式登出服务端票据立即失效(9/1 第二轮)
**背景**:附录九初版只覆盖"登录态自然过期/关浏览器"。审查发现缺口:`SignOutAsync`(业务 `GET /api/Logout`)只删 Cookie,**服务端内存票据残留到 ExpireTimeSpan 过期**(默认 1 分钟)——期间若旧 Cookie key 被恢复/复用,登录态仍有效。
**修复(框架 `Yh.Platform.Auth` 2 处)**:
| 文件 | 变更 |
|---|---|
| `SessionTicketDataFormat.cs` | 新增 `Revoke(string? key)`:删除指定票据(显式退出立即失效);新增分钟级摊还清理 `CleanupExpiredIfDue()`(Unprotect/Revoke 路径触发,Interlocked 门闩保证并发下每分钟最多一次全量扫描,防内存膨胀) |
| `AuthServiceCollectionExtensions.cs` | `SessionTicketDataFormat` 提升为 DI 单例(`services.AddSingleton`,TicketDataFormat 与事件共享同一票据表);Cookie 注册挂 `options.Events.OnSigningOut`:SignOutAsync 时从请求 Cookie 读 32 位 key → `Revoke(key)` → 服务端票据**立即删除** |
**语义闭环**:① 关闭浏览器 → 会话 Cookie 删除(服务端无法感知浏览器关闭,1 分钟过期兜底);② 显式退出(/api/Logout)→ Cookie 删除 **+** 服务端票据立即吊销,不等过期。
**验证(Mock CAS 端到端,实例 51835 实启)**:
| 步骤 | 期望 | 实测 |
|---|---|---|
| 登录流 → Cookie(32 位 key) | 200 + Cookie 落盘 | ✓ |
| 带 Cookie 访问 `/api/GetUserInfo` | 200 | ✓ |
| `GET /api/Logout`(带 Cookie) | 200 | ✓ |
| **Logout 后旧 key 手动放回访问** | **302 弹回 CAS**(服务端已吊销;旧行为下会 200) | ✓ **302** |
框架测试 **106/106 通过**(Auth 19 = 原 11 + 新增 `SessionTicketDataFormatTests` 8 项:32 位 key 往返 / 未知 key null / Revoke 立即失效 / 过期 null / 未过期正常 / Revoke 不影响其他票据等),双目标编译 0 错误 0 警告;`Yh.Platform.Auth.dll`(Release net5.0)已更新至 `E:/YHXT/net/Library`。
+86
View File
@@ -0,0 +1,86 @@
# YhCore 开发规范
> .NET 5.0 后端框架(参照 Java 版 bschclassmanage 迁移),仅框架无业务逻辑。
## 1. 解决方案结构
```
YhCore.sln
├─ src/
│ ├─ YhCore.WebApi # 启动项:Controllers / Middleware / Filters / Attributes / Auth
│ ├─ YhCore.Service # 业务服务层:Interfaces / Impl(不含业务时用 *Stub 占位)
│ ├─ YhCore.Repository # 数据层:YHDbContext / IRepository<T> / Specification / DapperChannel
│ ├─ YhCore.Entity # 数据库实体(旧 EF6 POCO 迁移而来,无导航属性)
│ ├─ YhCore.Model # DTO / VO / Boa 平台占位模型
│ ├─ YhCore.Common # 工具:ApiRoutes / UserContext / RedisUtil / PageUtil / ApiResult
│ └─ YhCore.Tasks # 调度:QuartzHostedService / Jobs
└─ lib / docker / docs / sqlscript
```
**依赖方向(单向,禁止反向引用)**:WebApi → Service → Repository → Entity;WebApi → Model ← Service;Common 不依赖任何上层。
**底层框架(Yh.Platform.\*)**:CAS 认证、用户上下文(BoaUserContext)、应用验签(AppAuthVerifier)、用户中心(OpenApi)均由底层框架提供:
- 引用 DLL:`Yh.Platform.Abstractions` / `Yh.Platform.AppAuth` / `Yh.Platform.Auth` / `Yh.Platform.OpenApi`(lib 目录,源码在 `E:\YHXT\net\YHBasePlatformDotnet`)
- `Startup` 用 `services.AddYhPlatform(Configuration)` 聚合注册(= AddYhAppAuth + AddYhAuth[含 AddYhOpenApi]),管道用 `app.UseYhUserContext()` 注入用户上下文
- 旧自研 CAS/验签/用户中心实现已退役到 `src/_legacy/`(不参与编译,仅留档备份);业务代码取当前用户用 `BoaUserContext.Get()`
**`/oapi` 开放渠道契约**:应用间调用渠道,**无需用户登录**,凭签名票据直接访问:
- 票据 = 请求头 `X-App-Key` / `X-Timestamp` / `X-Signature`(`signature = Base64(HmacSHA256(METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp, secret))`,详见框架仓库 `YHBasePlatformDotnet\BasePlatformDotnet\contract\sign-protocol.md`)
- 未带票据/票据非法 → `401 {code:4,msg:"缺少签名头…"/"签名校验失败…"}`;验签通过 → 放行到业务(无用户上下文,业务只可读 `UserContextHolder.Get().AppKey` 应用身份)
- SSO 登录保护**永不拦截** `/oapi`(SsoProtectionMiddleware 显式放行,即使 `yh:sso:urlPattern` 误配也不影响);服务端验签密钥解析链:配置内置应用(`yh:app-auth` AppKey/AppSecret)→ Redis `sign:app:{appKey}` → 本地缓存 → UserBase 兜底
**`/oapi` 冒烟验证方法**:本地联调/自测票据可用 `scripts/oapi-sign.js` 生成(对齐签名协议):
- 生成票据:`node scripts/oapi-sign.js GET "/oapi/hello?a=2&b=1"` → 输出 `appKey/ts/signature` 三头
- 直接出 curl:`node scripts/oapi-sign.js --curl POST "/oapi/xxx"` → 复制替换 `PORT` 即可
- 密钥默认取开发联调密钥(`yh:app-auth`),生产环境用 `APP_KEY` / `APP_SECRET` 环境变量覆盖,**禁止提交真实密钥到仓库**
- 断言基线:无票据 → 401 `{code:4}`;合法票据 → 直达业务(404/业务结果);假票据 → 401;`/api`、`/mobiapi` 无 Cookie → 302 CAS(SSO 保护不受影响)
## 2. 强制规范
| 约定 | 说明 |
|---|---|
| **接口路径** | 一律用 `[Route(ApiRoutes.ApiPath + "/xxx")]`,禁止硬编码 `"api/..."`;渠道前缀见 `ApiRoutes`(api/mobiapi/oapi) |
| **接口返回结构** | 与 Java 版保持一致(裸返回),**全局 `GlobalWrapResultFilter` 自动统一包装**(ApiResult{code,msg,data});需要裸返回用 `[NoWrapResult]` 显式排除;异常由 `GlobalExceptionMiddleware` 统一转失败响应 |
| **业务异常** | Service 层抛 `new DException(ErrorCodeUtil.From(code), "提示语")`(框架 `Yh.Platform.Abstractions`);仅提示语可用便捷构造 `new DException("提示语")`(默认 Custom=5);中间件自动转统一失败响应 |
| **日志** | **只允许接口访问日志**(ApiAccessLogMiddleware 自动记录)。业务代码禁止调用 `ILogger` 写日志;异常摘要由接口日志统一带出 |
| **缓存 Key** | 必须用 `RedisConstants.KeyOf(...)` 生成;过期时间用 `RedisConstants.CACHE_EXPIRE_*` |
| **分页** | 页码从 0 开始(与 Spring Pageable 对齐);数据库分页用 `IRepository<T>.PageAsync`,内存分页用 `PageUtil` |
| **动态条件** | 用 `Specification.True<T>().And(...).AndIf(cond, ...)` 拼装 |
| **操作日志** | **接口级审计日志,全局自动**:`SysOperationLogFilter`(Startup 全局注册)每次 Controller 请求后落库 `sys_operation_log`(对齐 Java `SysOperationLog`,含 URI/参数/响应 GZIP/耗时/状态码/异常),无需任何标注;级别按 HTTP 方法推导(GET=QUERY/POST=ADD/PUT=EDIT/DELETE=DELETE);`LogController` 已显式排除防递归 |
| **实体主键** | 实体无 `[Key]` 时按约定推断(Id → SerID);新增表实体建议显式标注 |
| **数据库** | 默认 SQL Server 主库;`DatabaseProvider=MySql` 切换;**缺表自动补建**(`Database:AutoCreateMissingTables`,默认 true:启动时对比 EF 模型与数据库,缺失的表按模型自动 CREATE,幂等;已存在表的结构变更仍走 sqlscript 脚本) |
| **数据库写操作** | 注意 `RepositoryBase` 的 Add/Update 需调用 `SaveChangesAsync()` 提交(UnitOfWork 模式) |
## 3. 常用命令
```bash
dotnet build # 编译
dotnet run --project src/YhCore.WebApi # 本地运行(Swagger: /swagger/index.html)
dotnet watch run --project src/YhCore.WebApi
```
## 4. 环境配置(appsettings.json 或环境变量覆盖)
| 配置 | 默认值 | 说明 |
|---|---|---|
| `DatabaseProvider` | `SqlServer` | `SqlServer` / `MySql` |
| `Database:AutoCreateMissingTables` | `true` | 启动时自动补建 EF 模型中有而数据库缺失的表(仅 SQL Server;幂等;已存在表不触碰,结构变更仍走 sqlscript) |
| `ConnectionStrings__SqlServer` | localhost/EduCloudDocPrint | 主库连接串(生产用环境变量,勿提交明文密码) |
| `ConnectionStrings__Mysql` | localhost:3306 | MySQL 备用连接串 |
| `Redis:ConnectionString` | localhost:6379,defaultDatabase=0 | Redis 连接串 |
| `yh:auth:CookieName` | `.yh.auth` | 认证 Cookie 名 |
| `yh:auth:CookieExpireMinutes` | `1` | 登录态固定过期(分钟),不滑动续期;票据存服务端内存(SessionTicketDataFormat),Cookie 只写 32 位 key;会话级 Cookie(IsPersistent=false),关闭浏览器即失效;**显式退出(/api/Logout)时服务端票据立即吊销**(OnSigningOut 事件,不等过期兜底) |
| `yh:sso:Enabled` | `true` | CAS SSO 开关(拦截 urlPattern / mobiApiPattern) |
| `yh:sso:CasServerUrlPrefix` | - | CAS 服务端地址前缀(Challenge 跳转用) |
| `yh:app-auth:Enabled` | `true` | /oapi 应用验签开关(X-App-Key / X-Timestamp / X-Signature) |
| `yh:open:AppKey` / `yh:open:AppSecret` | 100002 | OpenApi 调用方凭证 |
## 5. 待办(TODO)
- [x] CAS 认证:已由 `Yh.Platform.Auth`(CasScheme)接管,旧本地实现退役到 `_legacy/Auth`
- [x] 应用签名:`AppAuthMiddleware` 已接入 `AppAuthVerifier` + `SignCanonical` 真实验签
- [x] 用户中心:`IUserCenterService` 已替换为 `Yh.Platform.OpenApi`(`BoaUserContext.Get()` 取上下文)
- [ ] `ILogService` 真实查询实现(替换 LogServiceStub)
- [x] Quartz Job 按业务启用:`CleanupLogJob` 已实现(每日凌晨 3:30 删除 30 天前 `sys_operation_log`),`QuartzHostedService.InitJobs` 已注册 `cleanup-log-job`(cron `0 30 3 * * ?`)
- [ ] 审计日志增强(`SysOperationLogFilter` 目前按 HTTP 方法推导级别,`Module/Operation` 两列恒空,可接入业务字典映射;日志查询走 `LogController` 但 `LogServiceStub` 尚未实现真实分页查询)
- [ ] .NET 5 已 EOL:框架代码避免使用 5.0 专属 API,便于日后升 6/8