commit 8774b6cc448ec7bceef4b41c835a2df5c0bba3e4 Author: wanghu Date: Wed Sep 2 09:38:45 2026 +0800 bag diff --git a/BasePlatformDotnet/.gitea/workflows/ci.yml b/BasePlatformDotnet/.gitea/workflows/ci.yml new file mode 100644 index 0000000..00b8211 --- /dev/null +++ b/BasePlatformDotnet/.gitea/workflows/ci.yml @@ -0,0 +1,52 @@ +name: ci +on: + push: + branches: [main, master] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore yh-platform-net.sln + + - name: Build + run: dotnet build yh-platform-net.sln --no-restore -c Release + + - name: Test + run: dotnet test yh-platform-net.sln --no-build -c Release --logger "console;verbosity=normal" + + - name: Pack + run: dotnet pack yh-platform-net.sln --no-build -c Release -o artifacts + + publish: + # 仅主分支 push 时发版到 Nexus 私服(PR 不触发) + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + needs: build-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Pack + run: dotnet pack yh-platform-net.sln -c Release -o artifacts + + - name: Push to Nexus + run: > + dotnet nuget push "artifacts/*.nupkg" + --source ${{ secrets.NEXUS_URL }} + --username ${{ secrets.NEXUS_USER }} + --password "${{ secrets.NEXUS_PASS }}" + --api-key nexus --skip-duplicate diff --git a/BasePlatformDotnet/.gitignore b/BasePlatformDotnet/.gitignore new file mode 100644 index 0000000..b276142 --- /dev/null +++ b/BasePlatformDotnet/.gitignore @@ -0,0 +1,39 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +artifacts/ + +# Visual Studio cache/options +.vs/ +.idea/ +*.DotSettings.user + +# NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ +*.nuget.props +*.nuget.targets + +# Rider +*.sln.iml + +# OS +.DS_Store +Thumbs.db diff --git a/BasePlatformDotnet/.workbuddy/memory/2026-09-01.md b/BasePlatformDotnet/.workbuddy/memory/2026-09-01.md new file mode 100644 index 0000000..992eb02 --- /dev/null +++ b/BasePlatformDotnet/.workbuddy/memory/2026-09-01.md @@ -0,0 +1,149 @@ +# 2026-09-01 + +## 修复:CS0121 Deserialize 重载二义性(Yh.Platform.AppAuth) + +- 文件:`src/Yh.Platform.AppAuth/SignKeyResolver.cs` 第 49~52 行。 +- 现象:`JsonSerializer.Deserialize(json!, JsonOpts)` 报 CS0121,在 + `Deserialize(ReadOnlySpan, JsonSerializerOptions?)` 与 + `Deserialize(string, JsonSerializerOptions?)` 之间二义。 +- 根因:`json` 是 StackExchange.Redis 的 `RedisValue`,它同时定义了到 `string` 和 `byte[]` 的隐式转换运算符, + `byte[]` 又可隐式转 `ReadOnlySpan`,两条用户定义转换路径都可行 → 编译器无法取舍。 +- 修复:先显式取 string,`var json = (string?)await redis.StringGetAsync(...)`,再用 + `!string.IsNullOrEmpty(json)` 判断(原 `json.HasValue` 语义等价,RedisValue.Null 转 string 得 null)。 +- 验证:`dotnet build yh-platform-net.sln` 全解决方案 0 警告 0 错误。 + +### 通用经验 +凡是把 `RedisValue`(或其他带多个隐式转换运算符的类型)直接传给重载方法,都可能触发 CS0121。 +统一约定:从 Redis 取出的值先显式转换成目标 CLR 类型(`(string?)` / `(byte[]?)` / `(long?)`),再传给下游 API。 + +## 新增项目:Yh.SchClass.OpenApi(类库,net5.0;net8.0) + +- 路径:`src/Yh.SchClass.OpenApi/`,目录下原有空文件夹(sln 未引用),本次补齐。 +- 新增文件: + - `Yh.SchClass.OpenApi.csproj`:继承 `Directory.Build.props` 默认 `TargetFrameworks=net5.0;net8.0`,仅设 `PackageId`/`Description`/`Summary`,无额外 PackageReference(最小起步)。 + - `SchClassOpenApiEntry.cs`:根命名空间 `Yh.SchClass.OpenApi` 的占位静态类 `SchClassOpenApi`,避免空库 NETSDK1082 警告。 +- sln 注册:GUID `{2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}`,已加入 Project 声明、ProjectConfigurationPlatforms(6 行 Debug/Release × AnyCPU/x64/x86)与 NestedProjects(归入 samples 方案文件夹 `{5D20AA90...}`)。 +- 验证:`dotnet build yh-platform-net.sln` 全方案 0 警告 0 错误,新建项目双目标均通过。 +- 约定:新增 src 类库时默认沿用中央包管理(Directory.Packages.props),csproj 内不写版本号;项目需手动在 sln 注册到 `samples` 文件夹下。 + +## 新功能:Yh.SchClass.OpenApi 实现 Java OA 端点客户端(69 个方法) + +- 需求来源:`E:\YHXT\java\bschclassmanage\...\controller\oapi\oa` 下 12 个 Java 控制器,逐一映射为 .NET 客户端方法。 +- Java 侧关键事实: + - 前缀 `Config.OAPI = "/oapi"`(config/Config.java:16);控制器各自追加 `/DbClass/`、`/VSchClass/`、`/Schoolen/`、`/UserInfo/`、`/SchInfo/`、`/OATea/`、`/OAStu/`、`/TeachInfo/`、`/SubInfo/`、`/Common/FamCom/`。 + - 端点返回**裸 JSON**(对象/数组/字符串数组),无统一信封。 + - 用 HTTP 404 / 204 表达"查无数据",HTTP 400 表达参数缺失 —— 客户端需把 404/204 归一为 null/空列表,不能当异常抛。 + - 参数名大小写混用且敏感:`SchSerID` / `schserid` / `Schooling` / `schId` / `Group` / `ClassID` / `SchIDArr` / `userid` 等,必须逐字照搬。 + - `SchGroupController` 是空壳(0 端点),不生成客户端;`OaCompatSupport` 是行映射工具类,非端点。 +- 设计决策: + - **零项目依赖**:自建 `SchClassClientBase`,不引用 `Yh.Platform.OpenApi`(避免拖入 AppAuth 依赖链),可独立打包。 + - `Map` 兼容行用 `Dictionary`,通过 `global using SchClassRow = ...` 别名收敛(net5.0 可用,不用 net6+ 的 JsonNode)。 + - VO 用 `[JsonPropertyName]` 锚定 **Java 字段原名**(如 `IDCard`、`SchSerID`),配合 `JsonSerializerDefaults.Web` 的大小写不敏感匹配,可同时兼容 Jackson 从 getter 推导出的 `idcard`、`schSerID` 等变体。 + - POST 的 `@RequestBody String` 端点按 `text/plain` 提交原始逗号分隔串(非 JSON 包裹),否则 Spring 侧会带到引号。 + - **信封识别加严**:只有同时出现数字 `code` 且(有 `data` 或有字符串 `msg`)才判为信封。否则学制等级行 `{Id,Name,OwnerId,Levell,code,sx}` 中的 `code=gradeId` 会被误判成错误响应而抛异常。 +- 交付文件(`src/Yh.SchClass.OpenApi/`): + - 基础设施:`GlobalUsings.cs`、`SchClassClientBase.cs`、`SchClassOpenApiOptions.cs`、`SchClassOpenApiException.cs` + - DTO:`SchInfoVo.cs`、`UserInfoVo.cs`、`TeaInfoVo.cs` + - 客户端 10 个:DbClass(1) / VClass(17) / Schooling(5) / UserInfo(2) / SchInfo(7) / TeaInfo(4) / StuInfo(19) / TeachInfo(8) / SubInfo(4) / FamilyCompat(2) + - 门面:`SchClassOpenApi.cs`(含 `SchClassOpenApiFactory` 轻量工厂) + - 端点数与方法数 1:1 对齐(合计 69),已用 grep 双向核对。 +- 踩坑:`Unwrap` 里先把 `out var codeEl` 的 `&&` 链赋值给 bool 变量再使用,编译器不做相关性分析 → CS0165「使用了未赋值的局部变量」。改为直接 `if` 分支判定即可。 +- 验证:`dotnet build yh-platform-net.sln` 全方案 **0 警告 0 错误**,net5.0 与 net8.0 双目标均通过。 + +## 新增项目:Yh.SchClass.OpenApi.Tests(xunit,net8.0)+ 联调调试项 + +- 路径:`tests/Yh.SchClass.OpenApi.Tests/`,GUID `{E70492B2-109A-4E6F-AF91-443AC2956E3A}`,sln 三处已注册(Project / ProjectConfigurationPlatforms / NestedProjects 归入 `samples` 方案文件夹 `{5D20AA90...}`)。 +- 结构对齐 `Yh.Platform.OpenApi.Tests`:`net8.0` + `IsPackable=false` + xunit 三件套(版本号走 CPM,csproj 内不写版本)。 +- 交付文件:`GlobalUsings.cs`、`FakeHandler.cs`(同构于平台测试的捕获/回放 handler,额外记录 Content-Type)、 + `SchClassTestHost.cs`(共享 Options、`Verify`/`VerifyBody` 契约断言)、 + `SchClassOpenApiOptionsTests.cs`、`SchClassResponseParsingTests.cs`(裸数据/信封/404/204/400/500/非法JSON/学制行 code 不误判)、 + `SchClassVClassClientTests.cs`(17 端点)、`SchClassStuInfoClientTests.cs`(19 端点)、 + `SchClassSchInfoClientTests.cs`(7)、`SchClassTeaInfoClientTests.cs`(4)、`SchClassUserInfoClientTests.cs`(2)、 + `SchClassOtherClientsTests.cs`(Schooling/SubInfo/TeachInfo/DbClass/FamCom 共 20)、 + `SchClassOpenApiFacadeTests.cs`(门面装配与工厂)。 +- **调试项**:`SchClassLiveDebug.cs` + `SchClassLiveDebugTests.cs`。设 `YH_SCHCLASS_DEBUG_HOST`(可选 `_PATH`/`_SCH`/`_CLASS`/`_TEA`)后对真实后端发请求并逐端点打印条数与异常;未设则自动空跑通过,CI 安全。一键入口是 `联调_一键跑通全部端点`(20 个端点汇总表)。用法示例见文件头注释。 +- 顺带修复:`src/Yh.SchClass.OpenApi/Model/*.cs` 的 namespace 仍是 `Yh.SchClass.OpenApi`,而 3 个 client 写的是 `using Yh.SchClass.OpenApi.Model;` → 6 个 CS0234。已把三个 VO 的 namespace 改为 `Yh.SchClass.OpenApi.Model`(与 `Model/` 目录一致)。 +- 测试踩坑:`TeaInfoVo.UserID` 是**用户中心 ID**,与查询参数 `uid`(教师 teaId)不是同一个值,写断言时易混。 +- 验证:`dotnet test yh-platform-net.sln` 全绿 —— Abstractions 8 / SchClass 63 / OpenApi 37 / Auth 19 / AppAuth 42,共 169。 + +## 联调地址改为真实环境 + 首次真实联调通过 + +- **教务开放接口真实地址**:`http://base.yunhaixingtong.com/bschclass` + - 拆解:`BaseHost = http://base.yunhaixingtong.com`,`BasePath = /bschclass`(Java 工程的 context-path)。 + - 完整示例:`http://base.yunhaixingtong.com/bschclass/oapi/DbClass/GetSchteaList?Group=3` +- 改动: + - `SchClassLiveDebug.cs`:新增内置默认 `DefaultHost`/`DefaultPath`,**默认即联调环境,开箱即用**; + 新增 `Enabled` 开关(默认开)与 `YH_SCHCLASS_DEBUG_OFF=1` 逃生开关(CI 用,置 1 则全部空跑通过); + 新增 `GroupVar`(`YH_SCHCLASS_DEBUG_GROUP`,缺省 3)。 + - `SchClassLiveDebugTests.cs`:缺省 `classId`/`teaId` 由捏造值(1001/77)改为**从真实数据推导** —— + classId 取 `VClass.GetVClassList` 首条的 `ClassID`,teaId 取 `DbClass.GetSchteaList` 首条的 `UserTeaSerID`。 + 这一改让 `GetClaStulist` 0→28 条、`TeachInfo` 两个端点 0→2/4 条,证明此前是参数不对而非接口空。 + - `SchClassOpenApiOptions.cs`:XML 注释示例改为真实地址。 +- **真实联调结果(20/20 通过)**:SchoolInfo 5 / VSchClass 29·65 / UserInfo 51 / OAStu 89·64·28 / + SubInfo 4·18 / DbClass 19 / TeachInfo 2·4。 +- 仍为 0 条的端点(**非客户端问题,已核实**): + - `OATea.GetSchTeainfo` / `GetArrTeainfo` —— 后端返回 `[null,null,...]`(19 个 null), + `ResolveListAsync` 会跳过数组中的 null 元素 → 0 条。属后端 VO 转换失败的数据问题。 + - `FamCom.*` 两个 —— Java 源码里就是 `return new ArrayList<>()`,恒空。 +- 修复:`SchClassOpenApiFacadeTests.工厂_按配置创建HttpClient` 断言里的 path 被误写成 + `/base.yunhaixingtong.com`(应为 `/bschclass`),之前因 host 也是该串而偶然通过 → 改为 `/bschclass`。 +- 修复:`SchClassLiveDebugTests.cs:124` CS8604(nullable 警告)—— `teaId` 推导后类型仍是 `int?`, + 改用 `teaId.Value.ToString()`。 +- 踩坑:`curl -o /tmp/xxx` 在 Git Bash 下 exit 23 写文件失败(0B),改用工作区相对路径输出。 +- 验证:`dotnet build yh-platform-net.sln` 0 警告 0 错误;`dotnet test` 169 全通过。 + +## 配置文件读取:yh:open 共享节 + yh:schclass:open 独立节 + +- 真实配置结构(`tests/Yh.SchClass.OpenApi.Tests/appsettings.json` 的 `yh:open` 节,UserBase 与教务共享): + ```json + "open": { "BaseHost": "http://base.yunhaixingtong.com", + "BasePath": "/userbase", "SchClassPath": "/bschclass", + "AppKey": "100002", "AppSecret": "..." } + ``` + 关键:共享节里的 `BasePath` 是 **UserBase** 的路径,教务走 `SchClassPath`。 +- src 改动(`Yh.SchClass.OpenApi` 不再零依赖,引入 M.E.Options.ConfigurationExtensions + M.E.Http): + - `SchClassOpenApiOptions`:新增 `SharedSectionName="yh:open"`、`SchClassPathKey`、`HttpClientName` + 常量;新增 `FillFromSharedSection(Func)` —— 只回填未显式配置项 + (BaseHost ← yh:open:BaseHost;**BasePath ← yh:open:SchClassPath**(绝不回退到 yh:open:BasePath); + AppKey/AppSecret 同名回填),最后 NormalizeBasePath。 + - 新增 `SchClassOpenApiServiceCollectionExtensions.AddSchClassOpenApi(services, IConfiguration)`: + Bind 独立节 `yh:schclass:open` + PostConfigure 用共享节回填 → AddHttpClient(BaseAddress+超时+SocketsHttpHandler 连接超时)→ 注册门面 Singleton。 + - `SchClassOpenApi` 新增只读属性 `HttpBaseAddress`(底层 HttpClient.BaseAddress,测试/诊断用)。 +- 测试改动: + - `SchClassOpenApiConfigTests.cs`(6 个):SchClassPath 映射、`" /bschclass"` 空格/尾斜杠归一化、 + 未配 SchClassPath 时 BasePath 保持 null(**不会误用 /userbase**)、独立节优先+共享节回填、 + DI 解析门面+BaseAddress、独立节整节生效。 + - `SchClassLiveDebug`:地址来源优先级 = 环境变量 → appsettings.json(yh:schclass:open → yh:open:SchClassPath)→ 内置默认。 + 测试项目 csproj 加 `Microsoft.Extensions.Configuration(.Json)` 包 + appsettings.json CopyToOutputDirectory。 +- CPM 新增:`Microsoft.Extensions.Configuration(.Json)` 5.0.0/8.0.0。 +- 踩坑:测试项目显式引用 `Microsoft.Extensions.DependencyInjection` 8.0.0 与 M.E.Http 8.0.1 的传递依赖 + (要求 ≥8.0.1)冲突报 NU1605 包降级 → 删掉显式引用走传递依赖即可。 +- 验证:`dotnet build` 0 警告 0 错误;`dotnet test` 175 全通过(SchClass 63→69); + 联调一键跑通 20/20,联调目标确认取自 appsettings.json。 + +## 新增调试入口:Yh.SchClass.DebugTool(控制台) + +- 动机:联调此前只能 `dotnet test --filter` 跑,需要独立可执行入口。 +- 路径:`samples/Yh.SchClass.DebugTool/`(net8.0 控制台,IsPackable=false), + GUID `{0B1C9571-DCC3-436A-9DF6-02AB380E6CAA}`,sln 三处已注册(归入 `samples` 方案文件夹)。 +- `Program.cs`:读 appsettings.json(yh:schclass:open → yh:open 的 BaseHost+SchClassPath), + 命令行参数可覆盖:`--host --path --sch --class --tea --group --only=名称子串 --list`(列出 20 个端点名,零网络请求)。 + 缺省 ID 从真实数据推导(学校列表首条 → sch;班级首条 ClassID;教师首条 UserTeaSerID)。 + 输出逐端点 OK/FAIL + 条数,末尾汇总,失败退出码非 0(可接入脚本)。 +- 用法:`dotnet run --project samples/Yh.SchClass.DebugTool [-- --参数]`。 +- 踩坑:`SchClassOpenApi` 未实现 IDisposable,`using var` 报 CS1674,去掉 using 即可。 +- 验证:全方案 0 警告 0 错误;测试 175 全通过;实跑真实环境 20/20 通过。 + +## 测试工程添加 Main 运行调试入口(Yh.SchClass.OpenApi.Tests) + +- 需求:在 IDE 里直接 F5 / `dotnet run` 测试工程即可联调,不必经 vstest。 +- 改动: + - 测试 csproj:`Exe` + `false` + (关掉 Test SDK 自动生成的空 Main,避免 CS0017;`dotnet test` 不受影响,69 个用例照常发现)。 + - `DebugMain.cs`:`DebugMain.Main(string[])` 调试入口,支持 `--only=名称子串`、`--off`; + 调 `SchClassLiveDebug.RunAllAsync`,失败退出码非 0。 + - 重构:把"一键跑通 20 个端点"的执行逻辑从 `SchClassLiveDebugTests.联调_一键跑通全部端点` + 抽成 `SchClassLiveDebug.RunAllAsync(Action log, string? only = null)`,测试与 Main 共用。 +- 验证:`dotnet run --project tests/Yh.SchClass.OpenApi.Tests -- --only=VSchClass` → 3/3 通过; + `dotnet test` → 69 全通过;全方案 0 警告 0 错误。 + diff --git a/BasePlatformDotnet/Directory.Build.props b/BasePlatformDotnet/Directory.Build.props new file mode 100644 index 0000000..0fbb85b --- /dev/null +++ b/BasePlatformDotnet/Directory.Build.props @@ -0,0 +1,23 @@ + + + + net5.0;net8.0 + latest + enable + disable + true + false + true + + 1.0.0 + $(YhPlatformNetVersion) + yunhai-platform + yunhai + yh-platform-net + https://git.yunhaixingtong.com/platform/yh-platform-net + https://git.yunhaixingtong.com/platform/yh-platform-net + MIT + true + + diff --git a/BasePlatformDotnet/Directory.Packages.props b/BasePlatformDotnet/Directory.Packages.props new file mode 100644 index 0000000..dac3e2d --- /dev/null +++ b/BasePlatformDotnet/Directory.Packages.props @@ -0,0 +1,38 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/README.md b/BasePlatformDotnet/README.md new file mode 100644 index 0000000..cc7b574 --- /dev/null +++ b/BasePlatformDotnet/README.md @@ -0,0 +1,105 @@ +# yh-platform-net + +yunhai 基础平台的 .NET SDK(对齐 Java `yh-platform`),让 .NET 应用以与 Java 完全一致的方式接入平台核心能力:用户信息、`/oapi` 开放接口、应用签名鉴权(yh-app-auth)、CAS 单点登录(yh-auth)。 + +## 工程结构 + +``` +BasePlatformDotnet/ +├── src/ +│ ├── Yh.Platform.Abstractions/ # 模型与契约:BoaUser/BoaGroup/BoaRoleInfo、ApiEnvelope、DException、IErrorCode +│ ├── Yh.Platform.AppAuth/ # 应用签名:SignUtil/SignCanonical/AppSignHandler/SignKeyResolver/ReplayGuard +│ ├── Yh.Platform.OpenApi/ # /oapi 客户端:用户/组织/应用三域 + 双形态门面(静态 YschBaseOpenApi / 实例 BaseOpenApi) +│ └── Yh.Platform.Auth/ # CAS SSO:CasServiceTicketValidator/CasAuthenticationHandler、BoaUserContext、UserContextHolder +├── tests/ # xunit:Abstractions/AppAuth/OpenApi/Auth 四个测试工程 +├── samples/Yh.Platform.Sample.Web/ # ASP.NET Core 集成示例 +├── contract/ # 跨语言契约工件 +│ ├── sign-protocol.md # 签名协议权威定义(Java/.NET 共同遵守) +│ ├── openapi-oapi.yaml # UserBase /oapi OpenAPI 规范(38 个端点) +│ └── fixtures/ # 信封样例与 13 组签名测试向量(tools/gen_sign_vectors.py 独立生成) +└── tools/gen_sign_vectors.py # 签名向量生成器(独立 Python 实现做双向对拍) +``` + +## 多目标框架 + +`net5.0; net8.0`:net5.0 供存量 GrowthHealth 等老项目使用,net8.0 供新项目。依赖版本由 `Directory.Packages.props`(CPM,等价 Java BOM)统一管理,net5.0 走条件版本兜底。 + +## 快速开始 + +### 1. 注册服务 + +```csharp +builder.Services.AddYhOpenApi(builder.Configuration); // yh:open 配置节 +builder.Services.AddYhAuth(builder.Configuration); // yh:sso 配置节(CAS) +``` + +```json +{ + "yh": { + "open": { + "BaseHost": "http://base.bestsch.com", + "BasePath": "/userbase", + "AppKey": "your-app-key", + "AppSecret": "your-app-secret" + }, + "sso": { + "CasServerUrlPrefix": "https://sso.example.com/cas" + } + } +} +``` + +### 2. 调用开放接口 + +```csharp +// 静态门面(高频读) +var user = await YschBaseOpenApi.GetUserInfoAsync(loginName); +var tree = await YschBaseOpenApi.GetGroupTreeAsync(); + +// 实例客户端(低频写、需精细控制) +var role = await userClient.SwitchIdentityAsync(userId, roleId, schoolId); +``` + +响应解析为"信封感知":成功返回裸数据;业务失败(HTTP 200 + `{code≠0, msg, data}`)抛 `DException`,`Code`/`Data` 原样携带服务端内容。 + +### 3. 应用签名(服务端验签 / 客户端签名) + +```csharp +builder.Services.AddYhAppAuth(builder.Configuration); // SignKeyResolver/ReplayGuard/AppAuthVerifier + 客户端 AppSignHandler +``` + +校验签名三件套(`X-App-Key` / `X-Timestamp` / `X-Signature`): + +```csharp +var ok = await verifier.VerifyAsync(appKey, timestamp, signature, "GET", "/oapi/user/byId?id=1", nonce); +``` + +签名规则见 [contract/sign-protocol.md](contract/sign-protocol.md): +- 数据级:`HMAC_SHA256(secret, data + ts)` 小写 hex +- 请求级:`HMAC_SHA256(secret, METHOD\npath?sortedQuery\nts)` Base64 + +密钥解析链:Redis → MemoryCache → UserBase 兜底。 + +### 4. 用户上下文 + +```csharp +var boaUser = BoaUserContext.Get(); // 当前请求 BoaUser(AsyncLocal) +var login = UserContextHolder.Get(); // 当前请求 LoginUser +``` + +## 测试 + +```bash +dotnet test yh-platform-net.sln +``` + +覆盖:模型双序列化对拍、签名向量对拍(13 组独立 Python 生成向量)、URL 规范化、信封解析(成功/业务失败/HTTP 错误)、CAS XML 提取、用户上下文生命周期。 + +## 与 Java 侧对应关系 + +| .NET | Java | +|---|---| +| Yh.Platform.Abstractions | yh-platform-utils(模型) | +| Yh.Platform.AppAuth | yh-app-auth-spring-boot-starter | +| Yh.Platform.OpenApi | yh-openapi-client | +| Yh.Platform.Auth | yh-auth-core + yh-auth-spring-boot-starter | diff --git a/BasePlatformDotnet/contract/fixtures/envelope-error.json b/BasePlatformDotnet/contract/fixtures/envelope-error.json new file mode 100644 index 0000000..cf04d4c --- /dev/null +++ b/BasePlatformDotnet/contract/fixtures/envelope-error.json @@ -0,0 +1,24 @@ +{ + "desc": "业务失败信封样例(与 Java 端对拍):HTTP 200 + code≠0,data 原样携带;客户端必须抛 DException(code,msg,data)", + "cases": [ + { + "name": "code=4 未登录/登录过期", + "httpStatus": 200, + "envelope": { "code": 4, "msg": "登录已经过期,请重新登录", "data": null } + }, + { + "name": "code=5 业务校验失败(组下有子组/用户,拒绝删除)", + "httpStatus": 200, + "envelope": { + "code": 5, + "msg": "组下存在子组或用户,禁止删除", + "data": { "groupId": 9, "childGroups": 2, "users": 35 } + } + }, + { + "name": "code=5 身份切换:非白名单 appKey", + "httpStatus": 200, + "envelope": { "code": 5, "msg": "当前应用无权切换身份", "data": null } + } + ] +} diff --git a/BasePlatformDotnet/contract/fixtures/envelope-success.json b/BasePlatformDotnet/contract/fixtures/envelope-success.json new file mode 100644 index 0000000..28b922b --- /dev/null +++ b/BasePlatformDotnet/contract/fixtures/envelope-success.json @@ -0,0 +1,41 @@ +{ + "desc": "成功信封样例(与 Java 端对拍):BoaUser 裸数据,含 currentRole 多角色嵌套;亦兼容 {code:0,data:{}} 包裹形态", + "bareData": { + "id": 1001, + "account": "zhangsan", + "name": "张三", + "idcard": "510101199001011234", + "phone": "13800138000", + "email": "zhangsan@example.com", + "gender": 1, + "profilePicture": "https://cdn.example.com/avatar/1001.png", + "type": 1, + "status": 1, + "dept": { "id": 11, "name": "教务处", "parentId": 2, "type": 3, "sort": 1 }, + "school": { "id": 2, "name": "云海第一中学", "parentId": 1, "type": 2, "sort": 1 }, + "bureau": { "id": 1, "name": "云海市教育局", "parentId": null, "type": 1, "sort": 0 }, + "groups": [ + { "id": 1, "name": "云海市教育局", "type": 1 }, + { "id": 2, "name": "云海第一中学", "type": 2 }, + { "id": 11, "name": "教务处", "type": 3 } + ], + "roleIds": [3, 5], + "currentRole": { + "roleId": 3, + "roleName": "教师", + "schId": 2, + "schName": "云海第一中学", + "childId": 21, + "childName": "高一(3)班" + }, + "schools": [ + { "id": 2, "name": "云海第一中学", "type": 2 }, + { "id": 4, "name": "云海第二中学", "type": 2 } + ] + }, + "wrappedData": { + "code": 0, + "msg": "", + "data": { "id": 1001, "account": "zhangsan", "name": "张三" } + } +} diff --git a/BasePlatformDotnet/contract/fixtures/sign-vectors.json b/BasePlatformDotnet/contract/fixtures/sign-vectors.json new file mode 100644 index 0000000..cea7379 --- /dev/null +++ b/BasePlatformDotnet/contract/fixtures/sign-vectors.json @@ -0,0 +1,124 @@ +{ + "algorithm": "HmacSHA256", + "dataLevel": { + "canonical": "data + '|' + ts", + "output": "lowercase hex" + }, + "requestLevel": { + "canonical": "METHOD + '\\n' + canonicalTarget + '\\n' + ts", + "output": "base64", + "note": "canonicalTarget 为剥离部署 basePath(如 /userbase,与 UserBase context-path 一致)后的服务端 canonical 口径(/oapi/**);客户端实现须在签名前完成剥离(对齐 Java AppSignInterceptor.stripBasePath / AppAuthFilter 剥 contextPath)" + }, + "minSecretLength": 16, + "cases": [ + { + "name": "数据级-常规", + "ts": "1700000000000", + "secret": "0123456789abcdef", + "data": "hello", + "expectedDataSignHex": "9d79a45345dca5d12c0348d8c5cfd9be1a1ef9733b3f0c617f1eed5319b4a398" + }, + { + "name": "数据级-空串", + "ts": "1700000000000", + "secret": "0123456789abcdef", + "data": "", + "expectedDataSignHex": "022915adc73b73229e09bc6f1c5a5f51e6c323f48ba581c0e4af49ba5684ff20" + }, + { + "name": "数据级-含分隔符", + "ts": "1700000000000", + "secret": "0123456789abcdef", + "data": "a|b", + "expectedDataSignHex": "f988e0bcbc2c3ece3a5391c22255a3f1ed1d213b1afb1e0d8969a3b2384aaa8c" + }, + { + "name": "数据级-中文UTF8", + "ts": "1700000000000", + "secret": "app-secret-100476-yunhai", + "data": "云海学通", + "expectedDataSignHex": "e113d7086b2d4c0146477033cd386114d102b3acd0c12ce5c89d6833c7186d25" + }, + { + "name": "数据级-JSON体", + "ts": "1699999999999", + "secret": "app-secret-100476-yunhai", + "data": "{\"id\":1,\"name\":\"zhang\"}", + "expectedDataSignHex": "b405a624e084c6ebedb85f56931d3809917b87eba750057b72665ebf3d382c5e" + }, + { + "name": "数据级-超长数据", + "ts": "1700000000000", + "secret": "app-secret-100476-yunhai", + "data": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "expectedDataSignHex": "be7812a22c1c7b9b6c0cd37382f4a074ca8c81d2e6d718053c3792df8c9ea84c" + }, + { + "name": "数据级-超长密钥", + "ts": "1700000000010", + "secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "data": "user/login", + "expectedDataSignHex": "1b48440b7a7cb89502d9b2b2977bd7c2720eeef11ee1e8178167f170fb8a3d01" + }, + { + "name": "请求级-GET无参", + "ts": "1700000000000", + "secret": "app-secret-100476-yunhai", + "method": "GET", + "canonicalTarget": "/oapi/user/byId", + "fullUrl": "http://base.bestsch.com/userbase/oapi/user/byId", + "basePath": "/userbase", + "expectedRequestSignBase64": "R4YDIRnThxqk40dWXsWPtPYpE6AnpyzGDzTlsRgB9L0=" + }, + { + "name": "请求级-GET带query", + "ts": "1700000000000", + "secret": "app-secret-100476-yunhai", + "method": "GET", + "canonicalTarget": "/oapi/user/byId?id=123", + "fullUrl": "http://base.bestsch.com/userbase/oapi/user/byId?id=123", + "basePath": "/userbase", + "expectedRequestSignBase64": "0xkI3XWSbomr+iddwXr4FS0lvMnzamJVpaAUg/35CoM=" + }, + { + "name": "请求级-GET多参数", + "ts": "1700000000000", + "secret": "0123456789abcdef", + "method": "GET", + "canonicalTarget": "/oapi/group/byId?groupId=9&withSub=true", + "fullUrl": "http://base.bestsch.com/userbase/oapi/group/byId?withSub=true&groupId=9", + "basePath": "/userbase", + "expectedRequestSignBase64": "O4f8XxmwxFapdzeuJ382PHCzau5kx6iEfmfJyXxlL3I=" + }, + { + "name": "请求级-POST", + "ts": "1700000000000", + "secret": "app-secret-100476-yunhai", + "method": "POST", + "canonicalTarget": "/oapi/identity/switch", + "fullUrl": "http://base.bestsch.com/userbase/oapi/identity/switch", + "basePath": "/userbase", + "expectedRequestSignBase64": "AW1LU585v3JLPvHj5jm6XTPtshFg6E+FtEkIG1p3bm4=" + }, + { + "name": "请求级-中文query", + "ts": "1700000000001", + "secret": "app-secret-100476-yunhai", + "method": "GET", + "canonicalTarget": "/oapi/user/byLoginName?loginName=张三", + "fullUrl": "http://base.bestsch.com/userbase/oapi/user/byLoginName?loginName=%E5%BC%A0%E4%B8%89", + "basePath": "/userbase", + "expectedRequestSignBase64": "cTBpNkIqJLAOgwh/MasWUjIZKk8v6ZKDXjxSISCidjQ=" + }, + { + "name": "请求级-小写method(与GET不同)", + "method": "get", + "canonicalTarget": "/oapi/user/byId", + "fullUrl": "http://base.bestsch.com/userbase/oapi/user/byId", + "basePath": "/userbase", + "ts": "1700000000000", + "secret": "0123456789abcdef", + "expectedRequestSignBase64": "g6mmXqcSNiTMHQKQUACLG4YwIP4CDq7eNbjNXtH/HWM=" + } + ] +} \ No newline at end of file diff --git a/BasePlatformDotnet/contract/openapi-oapi.exported.json b/BasePlatformDotnet/contract/openapi-oapi.exported.json new file mode 100644 index 0000000..82230d5 --- /dev/null +++ b/BasePlatformDotnet/contract/openapi-oapi.exported.json @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"用户中心接口文档","description":"UserBase 用户中心 OpenAPI 文档(springdoc-openapi)","version":"0.0.1"},"servers":[{"url":"http://localhost:8060","description":"Generated server url"}],"paths":{"/oapi/user/transferToSchool":{"post":{"tags":["user-controller"],"operationId":"transferToSchool","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserTransferSchoolRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/password":{"post":{"tags":["user-controller"],"operationId":"password","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/moveToGroup":{"post":{"tags":["user-controller"],"operationId":"moveToGroup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserMoveRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/groupState":{"get":{"tags":["user-controller"],"operationId":"groupState_1","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"groupId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schoolId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"integer","format":"int32"}}}}}},"post":{"tags":["user-controller"],"operationId":"groupState","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserGroupStateRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/edit/{id}":{"post":{"tags":["user-controller"],"operationId":"edit","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/deleteUsers":{"post":{"tags":["user-controller"],"operationId":"deleteUsers","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SysUserGroupDeleteItem"}}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/delete/{id}/{groupId}/{schoolId}":{"post":{"tags":["user-controller"],"operationId":"delete","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"groupId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schoolId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK"}}}},"/oapi/user/changeStatus":{"post":{"tags":["user-controller"],"operationId":"changeStatus","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserChangeStatusRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/batchChangeStatus":{"post":{"tags":["user-controller"],"operationId":"batchChangeStatus","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchChangeStatusRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/batchAdd":{"post":{"tags":["user-controller"],"operationId":"batchAdd","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SysUserRequest"}}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaUser"}}}}}}}},"/oapi/user/add":{"post":{"tags":["user-controller"],"operationId":"add","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/identity/switch":{"post":{"tags":["identity-controller"],"operationId":"switchIdentity","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdentitySwitchOapiRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}},"/oapi/group/move/{schoolId}":{"post":{"tags":["group-controller"],"operationId":"move","parameters":[{"name":"schoolId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupMoveRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/group/edit/{id}":{"post":{"tags":["group-controller"],"operationId":"edit_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/group/delete/{id}/{schoolId}":{"post":{"tags":["group-controller"],"operationId":"delete_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schoolId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK"}}}},"/oapi/group/batchAdd":{"post":{"tags":["group-controller"],"operationId":"batchAdd_1","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupBatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}}}}}},"/oapi/group/add":{"post":{"tags":["group-controller"],"operationId":"add_1","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/user/roles":{"get":{"tags":["user-controller"],"operationId":"roles","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}}},"/oapi/user/modules":{"get":{"tags":["user-controller"],"operationId":"modules","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schId","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaModule"}}}}}}}},"/oapi/user/byPhone":{"get":{"tags":["user-controller"],"operationId":"byPhone","parameters":[{"name":"phone","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/byLoginName":{"get":{"tags":["user-controller"],"operationId":"byLoginName","parameters":[{"name":"loginName","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/byId":{"get":{"tags":["user-controller"],"operationId":"byId","parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/identity/current":{"get":{"tags":["identity-controller"],"operationId":"current","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}},"/oapi/identity/available":{"get":{"tags":["identity-controller"],"operationId":"available","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}}},"/oapi/group/tree":{"get":{"tags":["group-controller"],"operationId":"tree","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/group/list":{"get":{"tags":["group-controller"],"operationId":"list","parameters":[{"name":"ids","in":"query","required":true,"schema":{"type":"string"}},{"name":"withSub","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}}}}}},"/oapi/group/byId":{"get":{"tags":["group-controller"],"operationId":"byId_1","parameters":[{"name":"groupId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"withSub","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/app/{appId}":{"get":{"tags":["app-controller"],"operationId":"byId_2","parameters":[{"name":"appId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApplicationInfoVo"}}}}}}},"/oapi/app/list":{"get":{"tags":["app-controller"],"operationId":"list_1","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"clientType","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaAppType"}}}}}}}}},"components":{"schemas":{"SysUserTransferSchoolRequest":{"required":["fromSchoolId","groupId","schoolId","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"fromSchoolId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"}}},"BoaGroup":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"parentId":{"type":"integer","format":"int32"},"type":{"type":"integer","format":"int32"},"sort":{"type":"integer","format":"int32"},"children":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}},"BoaRoleInfo":{"type":"object","properties":{"roleId":{"type":"integer","format":"int32"},"roleName":{"type":"string"},"schId":{"type":"integer","format":"int32"},"schName":{"type":"string"},"childId":{"type":"integer","format":"int32"},"childName":{"type":"string"}}},"BoaUser":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"account":{"type":"string"},"name":{"type":"string"},"idcard":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"gender":{"type":"integer","format":"int32"},"profilePicture":{"type":"string"},"type":{"type":"integer","format":"int32"},"status":{"type":"integer","format":"int32"},"dept":{"$ref":"#/components/schemas/BoaGroup"},"school":{"$ref":"#/components/schemas/BoaGroup"},"bureau":{"$ref":"#/components/schemas/BoaGroup"},"groups":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}},"roleIds":{"type":"array","items":{"type":"integer","format":"int32"}},"currentRole":{"$ref":"#/components/schemas/BoaRoleInfo"},"schools":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}},"SysUserPasswordRequest":{"required":["newPassword","oldPassword","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"oldPassword":{"type":"string"},"newPassword":{"type":"string"}}},"SysUserMoveRequest":{"required":["groupId","schoolId","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"}}},"SysUserGroupStateRequest":{"required":["groupId","schoolId","state","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"},"state":{"type":"integer","format":"int32"}}},"SysUserRequest":{"required":["account","groupId","id","password"],"type":"object","properties":{"id":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"account":{"type":"string"},"password":{"type":"string"},"confirmPassword":{"type":"string"},"realName":{"type":"string"},"phone":{"type":"string"},"birthday":{"type":"string","format":"date"},"idcard":{"type":"string"},"email":{"type":"string"},"gender":{"type":"integer","format":"int32"},"profilePicture":{"type":"string"},"schoolId":{"type":"integer","format":"int32"},"roleIds":{"type":"array","items":{"type":"integer","format":"int32"}},"disabled":{"type":"integer","format":"int32"},"expired":{"type":"boolean"}}},"SysUserGroupDeleteItem":{"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"}}},"SysUserChangeStatusRequest":{"required":["disabled","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"disabled":{"type":"boolean"}}},"BatchChangeStatusRequest":{"required":["disabled","userIds"],"type":"object","properties":{"userIds":{"type":"array","items":{"type":"integer","format":"int32"}},"disabled":{"type":"boolean"}}},"IdentitySwitchOapiRequest":{"required":["roleId","schoolId","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"roleId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"}}},"SysGroupMoveRequest":{"required":["moveId","pid"],"type":"object","properties":{"moveId":{"type":"integer","format":"int32"},"pid":{"type":"integer","format":"int32"}}},"SysGroupRequest":{"required":["name","parentId","type"],"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"parentId":{"type":"integer","format":"int32"},"type":{"type":"integer","format":"int32"},"sort":{"type":"integer","format":"int32"}}},"BatchItem":{"required":["name"],"type":"object","properties":{"name":{"type":"string"}}},"SysGroupBatchRequest":{"required":["items","pid","type"],"type":"object","properties":{"pid":{"type":"integer","format":"int32"},"type":{"type":"integer","format":"int32"},"items":{"type":"array","items":{"$ref":"#/components/schemas/BatchItem"}}}},"BoaModule":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"parentId":{"type":"integer","format":"int32"},"customId":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"level":{"type":"integer","format":"int32"},"sortNo":{"type":"integer","format":"int32"},"icon":{"type":"string"}}},"ApplicationInfoVo":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"categoryId":{"type":"integer","format":"int32"},"sortNo":{"type":"integer","format":"int32"},"desc":{"type":"string"},"appKey":{"type":"integer","format":"int32"},"appSecret":{"type":"string"},"clients":{"type":"array","items":{"type":"integer","format":"int32"}},"pcUrl":{"type":"string"},"pcIcon":{"type":"string"},"appUrl":{"type":"string"},"appIcon":{"type":"string"},"officialAccountUrl":{"type":"string"},"dingtalkUrl":{"type":"string"}}},"BoaApp":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"icon":{"type":"string"},"version":{"type":"string"},"type":{"type":"integer","format":"int32"},"webAddr":{"type":"string"},"adminAddress":{"type":"string"},"apiRoot":{"type":"string"},"appKey":{"type":"integer","format":"int32"},"orderNumber":{"type":"integer","format":"int32"},"createUser":{"type":"integer","format":"int32"},"createTime":{"type":"string","format":"date-time"},"mobiIcon":{"type":"string"},"clients":{"type":"integer","format":"int32"},"integMode":{"type":"integer","format":"int32"},"builtInTag":{"type":"integer","format":"int32"},"webPagePkgAddr":{"type":"string"},"webAddress":{"type":"string"},"wxgzhAddress":{"type":"string"},"dingAddress":{"type":"string"},"isPublic":{"type":"boolean"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/BoaModule"}}}},"BoaAppType":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"apps":{"type":"array","items":{"$ref":"#/components/schemas/BoaApp"}}}}}}} \ No newline at end of file diff --git a/BasePlatformDotnet/contract/openapi-oapi.yaml b/BasePlatformDotnet/contract/openapi-oapi.yaml new file mode 100644 index 0000000..2bc8022 --- /dev/null +++ b/BasePlatformDotnet/contract/openapi-oapi.yaml @@ -0,0 +1,580 @@ +openapi: 3.0.3 +info: + title: UserBase /oapi 开放接口(契约工件) + description: | + UserBase 对子应用暴露的开放接口。权威源为 UserBase controller.oapi(springdoc 导出 + 人工补错误信封 oneOf)。 + 成功响应为裸数据对象/数组;业务/校验失败为 HTTP 200 + {code:4/5, msg, data}。 + 除 /oapi/internal/** 外所有请求须带 X-App-Key / X-Timestamp / X-Signature(见 sign-protocol.md)。 + version: 1.0.0 +servers: + - url: '{baseHost}/userbase' + variables: + baseHost: + default: http://userbase.example.com +security: + - appSign: [] +paths: + /oapi/user/byId: + get: + tags: [user] + summary: 按用户主键查询(裸数据 BoaUser) + parameters: + - { name: id, in: query, required: true, schema: { type: integer } } + responses: + '200': { $ref: '#/components/responses/BoaUserOrError' } + /oapi/user/byLoginName: + get: + tags: [user] + summary: 按登录账号查询用户明细 + parameters: + - { name: loginName, in: query, required: true, schema: { type: string } } + responses: + '200': { $ref: '#/components/responses/BoaUserOrError' } + /oapi/user/byPhone: + get: + tags: [user] + summary: 按手机号查询用户明细 + parameters: + - { name: phone, in: query, required: true, schema: { type: string } } + responses: + '200': { $ref: '#/components/responses/BoaUserOrError' } + /oapi/user/modules: + get: + tags: [user] + summary: 按用户查询模块列表 + parameters: + - { name: userId, in: query, required: true, schema: { type: integer } } + - { name: schId, in: query, required: false, schema: { type: integer } } + responses: + '200': { $ref: '#/components/responses/BoaModuleListOrError' } + /oapi/user/add: + post: + tags: [user] + summary: 新增用户 + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserRequest' } } } } + responses: + '200': { $ref: '#/components/responses/BoaUserOrError' } + /oapi/user/edit/{id}: + post: + tags: [user] + summary: 编辑用户(路径 id 权威) + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserRequest' } } } } + responses: + '200': { $ref: '#/components/responses/BoaUserOrError' } + /oapi/user/delete/{id}/{groupId}/{schoolId}: + post: + tags: [user] + summary: 删除用户(按组解绑) + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + - { name: groupId, in: path, required: true, schema: { type: integer } } + - { name: schoolId, in: path, required: true, schema: { type: integer } } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/user/deleteUsers: + post: + tags: [user] + summary: 批量删除用户-组关系 + requestBody: + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/SysUserGroupDeleteItem' } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/user/batchAdd: + post: + tags: [user] + summary: 批量新增用户(整批回滚) + requestBody: + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/SysUserRequest' } + responses: + '200': + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/BoaUser' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + /oapi/user/moveToGroup: + post: + tags: [user] + summary: 移动用户到指定组(同校内,覆盖式替换主组) + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserMoveRequest' } } } } + responses: + '200': { $ref: '#/components/responses/BoaUserOrError' } + /oapi/user/roles: + get: + tags: [user] + summary: 按用户查询角色身份列表 + parameters: + - { name: userId, in: query, required: true, schema: { type: integer } } + responses: + '200': + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/BoaRoleInfo' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + /oapi/identity/current: + get: + tags: [identity] + summary: 查询用户当前激活身份(未切换时兜底 is_main 主身份) + parameters: + - { name: userId, in: query, required: true, schema: { type: integer } } + responses: + '200': { $ref: '#/components/responses/BoaRoleInfoOrError' } + /oapi/identity/available: + get: + tags: [identity] + summary: 查询用户可切换的身份列表(多角色迁移配套) + parameters: + - { name: userId, in: query, required: true, schema: { type: integer } } + responses: + '200': + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/BoaRoleInfo' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + /oapi/identity/switch: + post: + tags: [identity] + summary: 切换身份(调用方须在 yh.identity.switch-allowed-app-keys 白名单内) + requestBody: + content: + application/json: + schema: + type: object + properties: + userId: { type: integer } + roleId: { type: integer } + schoolId: { type: integer } + responses: + '200': { $ref: '#/components/responses/BoaRoleInfoOrError' } + /oapi/user/password: + post: + tags: [user] + summary: 修改用户密码 + requestBody: + content: + application/json: + schema: + type: object + properties: + userId: { type: integer } + oldPassword: { type: string } + newPassword: { type: string } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/user/groupState: + get: + tags: [user] + summary: 查询用户在指定组上的状态(0=正常 1=转出) + parameters: + - { name: userId, in: query, required: true, schema: { type: integer } } + - { name: groupId, in: query, required: true, schema: { type: integer } } + - { name: schoolId, in: query, required: true, schema: { type: integer } } + responses: + '200': { $ref: '#/components/responses/IntOrError' } + post: + tags: [user] + summary: 设置用户在指定组上的状态(转出/恢复) + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserGroupStateRequest' } } } } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/user/transferToSchool: + post: + tags: [user] + summary: 跨校转入 + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserTransferSchoolRequest' } } } } + responses: + '200': { $ref: '#/components/responses/BoaUserOrError' } + /oapi/user/changeStatus: + post: + tags: [user] + summary: 设置单个用户状态 + requestBody: + content: + application/json: + schema: + type: object + properties: + userId: { type: integer } + disabled: { type: boolean } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/user/batchChangeStatus: + post: + tags: [user] + summary: 批量设置用户状态(任一无效整体拒绝) + requestBody: + content: + application/json: + schema: + type: object + properties: + userIds: + type: array + items: { type: integer } + disabled: { type: boolean } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/group/tree: + get: + tags: [group] + summary: 平台组树(根=教育云中心,children 递归) + responses: + '200': + content: + application/json: + schema: + oneOf: + - { $ref: '#/components/schemas/BoaGroup' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + /oapi/group/byId: + get: + tags: [group] + summary: 按组ID查询单个组 + parameters: + - { name: groupId, in: query, required: true, schema: { type: integer } } + - { name: withSub, in: query, required: false, schema: { type: boolean } } + responses: + '200': { $ref: '#/components/responses/BoaGroupOrError' } + /oapi/group/list: + get: + tags: [group] + summary: 按多个组ID批量查询 + parameters: + - { name: ids, in: query, required: true, schema: { type: string } } + - { name: withSub, in: query, required: false, schema: { type: boolean } } + responses: + '200': + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/BoaGroup' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + /oapi/group/add: + post: + tags: [group] + summary: 新增组 + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupRequest' } } } } + responses: + '200': { $ref: '#/components/responses/BoaGroupOrError' } + /oapi/group/edit/{id}: + post: + tags: [group] + summary: 编辑组(路径 id 权威,body 不含 id) + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupRequest' } } } } + responses: + '200': { $ref: '#/components/responses/BoaGroupOrError' } + /oapi/group/delete/{id}/{schoolId}: + post: + tags: [group] + summary: 删除组(组下存在子组或用户时服务端拒绝 code=5) + parameters: + - { name: id, in: path, required: true, schema: { type: integer } } + - { name: schoolId, in: path, required: true, schema: { type: integer } } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/group/batchAdd: + post: + tags: [group] + summary: 批量新增子组 + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupBatchRequest' } } } } + responses: + '200': + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/BoaGroup' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + /oapi/group/move/{schoolId}: + post: + tags: [group] + summary: 移动组(学校限定) + parameters: + - { name: schoolId, in: path, required: true, schema: { type: integer } } + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupMoveRequest' } } } } + responses: + '200': { $ref: '#/components/responses/VoidOrError' } + /oapi/app/{appId}: + get: + tags: [app] + summary: 按应用ID查询应用信息 + parameters: + - { name: appId, in: path, required: true, schema: { type: integer } } + responses: + '200': { $ref: '#/components/responses/ObjectOrError' } + /oapi/app/list: + get: + tags: [app] + summary: 按用户+客户端类型查询有权访问的应用列表(按应用类型分组) + parameters: + - { name: userId, in: query, required: true, schema: { type: integer } } + - { name: clientType, in: query, required: false, schema: { type: integer, description: 2 的幂位掩码 } } + responses: + '200': + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/BoaAppType' } + - { $ref: '#/components/schemas/ErrorEnvelope' } +components: + securitySchemes: + appSign: + type: apiKey + in: header + name: X-App-Key + description: 另需 X-Timestamp / X-Signature,见 sign-protocol.md + responses: + BoaUserOrError: + content: + application/json: + schema: + oneOf: + - { $ref: '#/components/schemas/BoaUser' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + BoaRoleInfoOrError: + content: + application/json: + schema: + oneOf: + - { $ref: '#/components/schemas/BoaRoleInfo' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + BoaGroupOrError: + content: + application/json: + schema: + oneOf: + - { $ref: '#/components/schemas/BoaGroup' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + BoaModuleListOrError: + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/BoaModule' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + IntOrError: + content: + application/json: + schema: + oneOf: + - { type: integer } + - { $ref: '#/components/schemas/ErrorEnvelope' } + ObjectOrError: + content: + application/json: + schema: + oneOf: + - { type: object } + - { $ref: '#/components/schemas/ErrorEnvelope' } + VoidOrError: + content: + application/json: + schema: + oneOf: + - { type: 'null' } + - { $ref: '#/components/schemas/ErrorEnvelope' } + schemas: + BoaRoleInfo: + type: object + properties: + roleId: { type: integer, nullable: true } + roleName: { type: string, nullable: true } + schId: { type: integer, nullable: true } + schName: { type: string, nullable: true } + childId: { type: integer, nullable: true } + childName: { type: string, nullable: true } + BoaGroup: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + parentId: { type: integer, nullable: true } + type: { type: integer, nullable: true } + sort: { type: integer, nullable: true } + children: + type: array + nullable: true + items: { $ref: '#/components/schemas/BoaGroup' } + BoaModule: + type: object + properties: + id: { type: integer, nullable: true } + parentId: { type: integer, nullable: true } + customId: { type: string, nullable: true } + name: { type: string, nullable: true } + path: { type: string, nullable: true } + level: { type: integer, nullable: true } + sortNo: { type: integer, nullable: true } + icon: { type: string, nullable: true } + BoaApp: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + icon: { type: string, nullable: true } + version: { type: string, nullable: true } + type: { type: integer, nullable: true } + webAddr: { type: string, nullable: true } + adminAddress: { type: string, nullable: true } + apiRoot: { type: string, nullable: true } + appKey: { type: integer, nullable: true } + orderNumber: { type: integer, nullable: true } + createUser: { type: integer, nullable: true } + createTime: { type: string, format: date-time, nullable: true } + mobiIcon: { type: string, nullable: true } + clients: { type: integer, nullable: true } + integMode: { type: integer, nullable: true } + builtInTag: { type: integer, nullable: true } + webPagePkgAddr: { type: string, nullable: true } + webAddress: { type: string, nullable: true } + wxgzhAddress: { type: string, nullable: true } + dingAddress: { type: string, nullable: true } + isPublic: { type: boolean, nullable: true } + modules: + type: array + nullable: true + items: { $ref: '#/components/schemas/BoaModule' } + BoaAppType: + type: object + properties: + id: { type: integer, nullable: true } + name: { type: string, nullable: true } + apps: + type: array + nullable: true + items: { $ref: '#/components/schemas/BoaApp' } + BoaUser: + type: object + properties: + id: { type: integer, nullable: true } + account: { type: string, nullable: true } + name: { type: string, nullable: true } + idcard: { type: string, nullable: true } + phone: { type: string, nullable: true } + email: { type: string, nullable: true } + gender: { type: integer, nullable: true } + profilePicture: { type: string, nullable: true } + type: { type: integer, nullable: true } + status: { type: integer, nullable: true } + dept: { $ref: '#/components/schemas/BoaGroup' } + school: { $ref: '#/components/schemas/BoaGroup' } + bureau: { $ref: '#/components/schemas/BoaGroup' } + groups: + type: array + nullable: true + items: { $ref: '#/components/schemas/BoaGroup' } + roleIds: + type: array + nullable: true + items: { type: integer } + currentRole: { $ref: '#/components/schemas/BoaRoleInfo' } + schools: + type: array + nullable: true + items: { $ref: '#/components/schemas/BoaGroup' } + SysUserRequest: + type: object + properties: + id: { type: integer, nullable: true } + groupId: { type: integer, nullable: true } + account: { type: string, nullable: true } + password: { type: string, nullable: true } + confirmPassword: { type: string, nullable: true } + realName: { type: string, nullable: true } + phone: { type: string, nullable: true } + idcard: { type: string, nullable: true } + email: { type: string, nullable: true } + gender: { type: integer, nullable: true } + birthday: { type: string, nullable: true } + profilePicture: { type: string, nullable: true } + schoolId: { type: integer, nullable: true } + roleIds: + type: array + nullable: true + items: { type: integer } + disabled: { type: integer, nullable: true } + expired: { type: boolean, nullable: true } + SysUserGroupDeleteItem: + type: object + properties: + userId: { type: integer, nullable: true } + groupId: { type: integer, nullable: true } + schoolId: { type: integer, nullable: true } + SysUserMoveRequest: + type: object + properties: + userId: { type: integer, nullable: true } + groupId: { type: integer, nullable: true } + schoolId: { type: integer, nullable: true } + SysUserGroupStateRequest: + type: object + properties: + userId: { type: integer, nullable: true } + groupId: { type: integer, nullable: true } + schoolId: { type: integer, nullable: true } + state: { type: integer, nullable: true } + SysUserTransferSchoolRequest: + type: object + properties: + userId: { type: integer, nullable: true } + fromSchoolId: { type: integer, nullable: true } + schoolId: { type: integer, nullable: true } + groupId: { type: integer, nullable: true } + SysGroupRequest: + type: object + properties: + name: { type: string, nullable: true } + parentId: { type: integer, nullable: true } + type: { type: integer, nullable: true } + sort: { type: integer, nullable: true } + SysGroupBatchRequest: + type: object + properties: + pid: { type: integer, nullable: true } + type: { type: integer, nullable: true } + items: + type: array + nullable: true + items: + type: object + properties: + name: { type: string, nullable: true } + SysGroupMoveRequest: + type: object + properties: + moveId: { type: integer, nullable: true } + pid: { type: integer, nullable: true } + ErrorEnvelope: + type: object + description: 业务/校验失败信封(HTTP 200) + required: [code, msg] + properties: + code: { type: integer, description: 4=未登录/登录过期 5=业务校验失败 } + msg: { type: string } + data: {} diff --git a/BasePlatformDotnet/contract/sign-protocol.md b/BasePlatformDotnet/contract/sign-protocol.md new file mode 100644 index 0000000..41099c1 --- /dev/null +++ b/BasePlatformDotnet/contract/sign-protocol.md @@ -0,0 +1,71 @@ +# yh-platform 签名协议规范(v1.0) + +> 权威源:Java `yh-utils-core` `utils.crypto.SignUtil` + `yh-app-auth-spring-boot-starter` `SignCanonical`。 +> 本文档为双端(Java / C#)共同遵守的契约工件;任何变更须先改本文,再同步两侧实现。 + +## 1. 算法 + +统一使用 **HmacSHA256**,密钥与签名串均为 UTF-8 字节。 + +## 2. 双签名规则(长期并存,见方案 9.5 裁决 #5 收敛计划) + +### 2.1 数据级签名(业务内容签名) + +``` +source = data + "|" + timestamp +output = HmacSHA256(source, secret) 的小写十六进制 +timestamp = 毫秒级时间戳字符串 +``` + +- 验签忽略大小写比对(`equalsIgnoreCase`)。 +- `verifyWithExpire`:先比对签名,再校验 `|now - ts| <= expireMs`(默认 5 分钟,双向偏差均拒绝)。 + +### 2.2 请求级签名(HTTP 请求签名,SDK 出站默认) + +``` +canonicalTarget = path + ("?" + sortedQuery # query 为空则不带 +source = METHOD + "\n" + canonicalTarget + "\n" + timestamp +output = HmacSHA256(source, secret) 的 Base64 +``` + +- `METHOD`:大写(GET/POST),**大小写敏感**精确比对(这是既有行为,是否收敛见方案 9.5 裁决 #3)。 +- `path`:**只取路径**,不含 host/port/context-path(保证服务发现/反代前后端天然对齐)。 +- **basePath 剥离(客户端与服务端共同口径)**:客户端签名前从 path 剥离部署 basePath(与 UserBase 部署 context-path 一致,如 `/userbase`,对齐 Java `AppSignInterceptor.stripBasePath`);服务端验签前剥 `request.getContextPath()`(对齐 Java `AppAuthFilter`)。剥离后 canonical 口径统一为 `/oapi/**`。剥离规则:path 不以 basePath 开头时原样保留;剥离后为空或丢失前导斜杠时补 `/`(`/userbase` → `/`)。 +- `sortedQuery`:query 按 `k=v` 整串字典序排序后以 `&` 连接;k、v 先 URL 解码再参与排序;多值参数的值也排序。 +- 验签为**精确比对**(Base64 区分大小写)。 +- `verifyRequestWithExpire` 语义同数据级。 +- 测试向量中的 `canonicalTarget` 即**剥离后**的服务端口径(含 `fullUrl`/`basePath` 字段可复现剥离前 URL)。 + +### 2.3 密钥约束 + +- secret 最小长度 **16** 位(`MIN_SECRET_LENGTH`),低于此值签名方法抛参数异常。 + +## 3. HTTP 头契约(逐字一致) + +| 头名 | 值 | +|---|---| +| `X-App-Key` | 调用方在 UserBase 注册的 appKey | +| `X-Timestamp` | 毫秒级时间戳字符串 | +| `X-Signature` | 请求级签名(Base64) | + +## 4. 防重放 + +- 时间窗口:`|now - X-Timestamp| > expireMs`(默认 **300000ms**)即拒绝。 +- nonce(可选扩展):同一 `(appKey, timestamp, nonce)` 在窗口内只允许提交一次; + Redis 键 `sign:replay:{appKey}:{timestamp}:{nonce}`,SETNX + TTL=窗口。 + +## 5. 验签密钥解析链(服务端) + +``` +1. Redis key = sign:app:{appKey} → 值为 SignAppKeyPayload JSON {appKey, secret, enabled} +2. 进程内缓存(Caffeine / IMemoryCache,TTL 5min) +3. 兜底 GET {userbase}/oapi/internal/sign-key/{appKey} → 信封 data 为 SignAppKeyPayload +``` + +- 仅当 `enabled=true` 且 `secret` 非空才可用;未知 appKey 直接拒绝。 +- 任一层异常静默降级到下一层(Redis 宕机不阻断业务)。 + +## 6. 测试向量 + +`fixtures/sign-vectors.json`:≥10 组向量(含空 body、超长参数、中文 UTF-8、query 排序、密钥长度边界、毫秒时间戳边界)。 +**基线由 Java `SignUtil` 实际运行产出**;CI 中 Java 侧重算 fixture 防漂移,C# 侧逐字节对拍。 diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs new file mode 100644 index 0000000..2790324 --- /dev/null +++ b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Yh.Platform.Abstractions; +using Yh.Platform.Auth; +using Yh.Platform.OpenApi; + +var builder = WebApplication.CreateBuilder(args); + +// 一次带齐:/oapi 客户端 + 应用签名 + Cookie/CAS 认证 + 用户上下文 +builder.Services.AddYhPlatform(builder.Configuration); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); +app.UseYhUserContext(); + +// 平台验收载体:CAS 登录 → BoaUser 上下文注入 +app.MapGet("/", () => Results.Text( + "yh-platform-net sample。\n" + + "GET /me —— CAS 登录后返回 BoaUser(含 currentRole)\n" + + "GET /user/{id} —— 签名调 UserBase /oapi/user/byId\n" + + "GET /roles/{id} —— 签名调 /oapi/user/roles")); + +app.MapGet("/me", (HttpContext ctx) => +{ + var user = BoaUserContext.Get(); + if (user == null) + { + // 未登录 → challenge CAS + return Results.Challenge(new AuthenticationProperties { RedirectUri = "/me" }, new[] { YhAuthDefaults.CasScheme }); + } + return Results.Ok(new + { + user.Id, + user.Account, + user.Name, + School = user.School?.Name, + CurrentRole = user.CurrentRole, + Roles = user.RoleIds, + }); +}); + +// 签名调 /oapi:byId / byLoginName / byPhone 三入口 +app.MapGet("/user/{id:int}", async (int id, IOpenApiUserClient client) => Results.Ok(await client.GetUserInfoAsync(id))); + +app.MapGet("/roles/{id:int}", async (int id, IOpenApiUserClient client) => Results.Ok(await client.GetUserRoleListAsync(id))); + +app.MapGet("/user/by-login-name/{loginName}", async (string loginName, IOpenApiUserClient client) => Results.Ok(await client.GetUserInfoAsync(loginName))); + +// 静态门面等价性演示 +app.MapGet("/facade/{id:int}", async (int id) => Results.Ok(await YschBaseOpenApi.GetUserInfoAsync(id))); + +app.Run(); diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Properties/launchSettings.json b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Properties/launchSettings.json new file mode 100644 index 0000000..a3c2df3 --- /dev/null +++ b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Yh.Platform.Sample.Web": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Yh.Platform.Sample.Web.csproj b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Yh.Platform.Sample.Web.csproj new file mode 100644 index 0000000..7985afd --- /dev/null +++ b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Yh.Platform.Sample.Web.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + false + + + + + + + diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/appsettings.json b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/appsettings.json new file mode 100644 index 0000000..a7bd6da --- /dev/null +++ b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/appsettings.json @@ -0,0 +1,39 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "yh": { + "open": { + "enabled": true, + "base-host": "http://userbase.example.com", + "base-path": "/userbase", + "app-key": "100476", + "app-secret": "请替换为在UserBase注册的appSecret", + "connectTimeoutMs": 1500, + "readTimeoutMs": 5000 + }, + "app-auth": { + "enabled": true, + "expireMs": 300000, + "app-key": "100476", + "app-secret": "请替换为在UserBase注册的appSecret", + "userBaseSignKeyFallbackEnabled": false, + "userBaseBaseUrl": "http://userbase.example.com/userbase", + "redisConnectionString": "" + }, + "auth": { + "enabled": true, + "CookieExpireMinutes": 30, + "cookieName": ".yh.auth" + }, + "sso": { + "casServerUrlPrefix": "https://sso.example.com/cas", + "appBaseUrl": "https://your-app.example.com", + "signInCallbackPath": "/signin-cas" + } + } +} diff --git a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Program.cs b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Program.cs new file mode 100644 index 0000000..73a02a2 --- /dev/null +++ b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Program.cs @@ -0,0 +1,169 @@ +// SchClass OpenAPI 调试入口:独立控制台,不开测试即可联调教务后端。 +// +// 用法: +// dotnet run --project samples/Yh.SchClass.DebugTool # 跑全部 20 个端点 +// dotnet run --project samples/Yh.SchClass.DebugTool -- --list # 只列出端点名 +// dotnet run --project samples/Yh.SchClass.DebugTool -- --only=OAStu +// dotnet run --project samples/Yh.SchClass.DebugTool -- --host=http://192.168.1.20:8080 --path=/bschclass +// 可覆盖项:--host --path --sch --class --tea --group --only +// +// 地址优先级:命令行参数 → 环境变量 YH_SCHCLASS_DEBUG_* → appsettings.json(yh:schclass:open → yh:open)→ 内置默认。 + +using System.Text; +using Microsoft.Extensions.Configuration; +using Yh.SchClass.OpenApi; + +Console.OutputEncoding = Encoding.UTF8; + +const string DefaultHost = "http://base.yunhaixingtong.com"; +const string DefaultPath = "/bschclass"; + +string? host = null, path = null, sch = null, classId = null, teaRaw = null, groupRaw = null, only = null; +bool listOnly = false; + +// 环境变量兜底(与测试工程的 SchClassLiveDebug 同名同义) +string? Env(string n) { var v = Environment.GetEnvironmentVariable(n); return string.IsNullOrWhiteSpace(v) ? null : v.Trim(); } +host = Env("YH_SCHCLASS_DEBUG_HOST"); +path = Env("YH_SCHCLASS_DEBUG_PATH"); +sch = Env("YH_SCHCLASS_DEBUG_SCH"); +classId = Env("YH_SCHCLASS_DEBUG_CLASS"); +teaRaw = Env("YH_SCHCLASS_DEBUG_TEA"); +groupRaw = Env("YH_SCHCLASS_DEBUG_GROUP"); + +// 命令行参数优先 +foreach (var arg in args) +{ + if (!arg.StartsWith("--")) { continue; } + var kv = arg[2..].Split('=', 2); + var key = kv[0].Trim().ToLowerInvariant(); + var val = kv.Length > 1 ? kv[1].Trim() : null; + switch (key) + { + case "host": host = val; break; + case "path": path = val; break; + case "sch": sch = val; break; + case "class": classId = val; break; + case "tea": teaRaw = val; break; + case "group": groupRaw = val; break; + case "only": only = val; break; + case "list": listOnly = true; break; + } +} + +var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true) + .Build(); +host ??= config["yh:schclass:open:BaseHost"] ?? config["yh:open:BaseHost"] ?? DefaultHost; +path ??= config["yh:schclass:open:BasePath"] ?? config["yh:open:SchClassPath"] ?? DefaultPath; + +var options = new SchClassOpenApiOptions { BaseHost = host, BasePath = string.IsNullOrWhiteSpace(path) ? null : path }; +var api = SchClassOpenApiFactory.Create(options); + +// ---- 构造调试用例(缺省 ID 一律从真实数据推导,避免捏造 ID 造成假 0 条) ---- +var group = int.TryParse(groupRaw, out var g) ? g : 3; +var teaId = int.TryParse(teaRaw, out var t) ? t : 0; + +Console.WriteLine($"目标:{options.ResolveBaseUrl()}"); + +// --list:只打印端点名,不做任何网络请求 +if (listOnly) +{ + foreach (var name in new[] + { + "DbClass.GetSchteaList", "VSchClass.GetVClassList", "VSchClass.GetSchArrVClassListAll", + "VSchClass.GetSchArrVClassListPost", "Schoolen.GetSchLenLevellist", "Schoolen.GetSchLenlist", + "UserInfo.GetUserList", "SchInfo.AllList", "SchInfo.GetSchlist", "OATea.GetSchTeainfo", + "OATea.GetArrTeainfo", "OAStu.GetAllStulist", "OAStu.GetSchStulist", "OAStu.GetClaStulist", + "TeachInfo.GetSchTeachlist", "TeachInfo.GetSchClaTeachlist", "SubInfo.GetSchTeachSublist", + "SubInfo.GetSchSublist", "FamCom.GetStuUserFamIDList", "FamCom.GetAllStuFamMem", + }) + { + Console.WriteLine(name); + } + return 0; +} + +var schResolved = sch; +var cases = new List<(string Name, Func> Call)>(); + +// 先探测学校列表,推导缺省参数 +try +{ + var schools = await api.SchInfo.AllListAsync(); + Console.WriteLine($"学校列表:{schools.Count} 所(前 3:{string.Join(" | ", schools.Take(3).Select(s => $"{s.SchName}[{s.SchSerId}]"))})"); + schResolved ??= schools.FirstOrDefault()?.SchSerId ?? "S001"; +} +catch (Exception e) +{ + Console.WriteLine($"!! 学校列表探测失败:{e.GetType().Name}: {e.Message}"); + Console.WriteLine("后端不可达时后续端点大概率全部失败,仍继续逐个执行以便核对。"); + schResolved ??= "S001"; +} + +if (!listOnly) +{ + var sch2 = schResolved; + string cid = classId ?? ""; + if (cid.Length == 0) + { + var cls = (await api.VClass.GetVClassListAsync(sch2)).FirstOrDefault(); + cid = cls != null && cls.TryGetValue("ClassID", out var c) ? c.ToString() ?? "1001" : "1001"; + } + if (teaId == 0) + { + var tea = (await api.DbClass.GetSchteaListAsync(group)).FirstOrDefault(); + teaId = tea != null && tea.TryGetValue("UserTeaSerID", out var d) && int.TryParse(d.ToString(), out var p) ? p : 77; + } + + Console.WriteLine($"参数:sch={sch2} classId={cid} teaId={teaId} group={group}"); + + cases.Add(("DbClass.GetSchteaList", async () => (await api.DbClass.GetSchteaListAsync(group)).Count)); + cases.Add(("VSchClass.GetVClassList", async () => (await api.VClass.GetVClassListAsync(sch2)).Count)); + cases.Add(("VSchClass.GetSchArrVClassListAll", async () => (await api.VClass.GetSchArrVClassListAllAsync()).Count)); + cases.Add(("VSchClass.GetSchArrVClassListPost", async () => (await api.VClass.GetSchArrVClassListPostAsync(sch2)).Count)); + cases.Add(("Schoolen.GetSchLenLevellist", async () => (await api.Schooling.GetSchLenLevelListAsync(sch2)).Count)); + cases.Add(("Schoolen.GetSchLenlist", async () => (await api.Schooling.GetSchLenListAsync(sch2)).Count)); + cases.Add(("UserInfo.GetUserList", async () => (await api.UserInfo.GetUserListAsync(sch2)).Count)); + cases.Add(("SchInfo.AllList", async () => (await api.SchInfo.AllListAsync()).Count)); + cases.Add(("SchInfo.GetSchlist", async () => (await api.SchInfo.GetSchListAsync(null)).Count)); + cases.Add(("OATea.GetSchTeainfo", async () => (await api.TeaInfo.GetSchTeaInfoAsync(sch2, null)).Count)); + cases.Add(("OATea.GetArrTeainfo", async () => (await api.TeaInfo.GetArrTeaInfoAsync(teaId.ToString())).Count)); + cases.Add(("OAStu.GetAllStulist", async () => (await api.StuInfo.GetAllStuListAsync()).Count)); + cases.Add(("OAStu.GetSchStulist", async () => (await api.StuInfo.GetSchStuListAsync(sch2, null)).Count)); + cases.Add(("OAStu.GetClaStulist", async () => (await api.StuInfo.GetClaStuListAsync(cid, null)).Count)); + cases.Add(("TeachInfo.GetSchTeachlist", async () => (await api.TeachInfo.GetSchTeachListAsync(sch2, teaId)).Count)); + cases.Add(("TeachInfo.GetSchClaTeachlist", async () => (await api.TeachInfo.GetSchClaTeachListAsync(cid)).Count)); + cases.Add(("SubInfo.GetSchTeachSublist", async () => (await api.SubInfo.GetSchTeachSubListAsync(sch2)).Count)); + cases.Add(("SubInfo.GetSchSublist", async () => (await api.SubInfo.GetSchSubListAsync(sch2, null, null)).Count)); + cases.Add(("FamCom.GetStuUserFamIDList", async () => (await api.FamilyCompat.GetStuUserFamIdListAsync("1,2")).Count)); + cases.Add(("FamCom.GetAllStuFamMem", async () => (await api.FamilyCompat.GetAllStuFamMemAsync("1,2")).Count)); +} + +var failures = new List(); +if (only != null) +{ + cases = cases.Where(c => c.Name.Contains(only, StringComparison.OrdinalIgnoreCase)).ToList(); + if (cases.Count == 0) + { + Console.WriteLine($"--only={only} 未匹配任何端点(用 --list 查看全部名称)"); + return 2; + } +} + +foreach (var (name, call) in cases) +{ + try + { + var count = await call(); + Console.WriteLine($" OK {name} -> {count} 条"); + } + catch (Exception e) + { + failures.Add(name); + Console.WriteLine($" FAIL {name} -> {e.GetType().Name}: {e.Message}"); + } +} + +Console.WriteLine($"汇总:{cases.Count - failures.Count}/{cases.Count} 通过"); +return failures.Count == 0 ? 0 : 1; diff --git a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Yh.SchClass.DebugTool.csproj b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Yh.SchClass.DebugTool.csproj new file mode 100644 index 0000000..1c536e5 --- /dev/null +++ b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Yh.SchClass.DebugTool.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + enable + enable + false + Yh.SchClass.DebugTool + + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/appsettings.json b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/appsettings.json new file mode 100644 index 0000000..4a6f4bd --- /dev/null +++ b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/appsettings.json @@ -0,0 +1,12 @@ +{ + "yh": { + "open": { + "Enabled": true, + "BaseHost": "http://base.yunhaixingtong.com", + "BasePath": "/userbase", + "SchClassPath": "/bschclass", + "AppKey": "100002", + "AppSecret": "2314a3b158b24a838b2b9b69fd3db118" + } + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ApiEnvelope.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ApiEnvelope.cs new file mode 100644 index 0000000..7520729 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ApiEnvelope.cs @@ -0,0 +1,25 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 通用接口响应包装,对应大多数后端接口返回的 code/msg/data 结构。 +/// 纯数据载体,双序列化器兼容(Newtonsoft 大小写不敏感 / System.Text.Json camelCase)。 +/// +public class ApiEnvelope +{ + /// 业务状态码,0 表示成功 + public int Code { get; set; } + + /// 提示信息 + public string? Msg { get; set; } + + /// 业务数据 + public T? Data { get; set; } + + /// 是否成功响应(Code == 0) + public bool IsSuccess => Code == 0; +} + +/// 非泛型信封(data 为通用对象/数组),用于信封感知解析前的判别。 +public class ApiEnvelope : ApiEnvelope +{ +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaApp.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaApp.cs new file mode 100644 index 0000000..8d7ab00 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaApp.cs @@ -0,0 +1,74 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 应用富信息载体。替代旧 bsch BoaApp。 +/// 不含 appSecret(避免 oapi 泄露密钥);字段命名保留旧 bsch 风格。 +/// +public class BoaApp +{ + /// 应用ID + public int? Id { get; set; } + + /// 应用名称 + public string? Name { get; set; } + + /// 图标URL + public string? Icon { get; set; } + + /// 版本号 + public string? Version { get; set; } + + /// 应用类型ID + public int? Type { get; set; } + + /// PC端访问地址 + public string? WebAddr { get; set; } + + /// 管理端地址 + public string? AdminAddress { get; set; } + + /// API根路径 + public string? ApiRoot { get; set; } + + /// appKey(不含 appSecret,避免泄露密钥) + public int? AppKey { get; set; } + + /// 排序号 + public int? OrderNumber { get; set; } + + /// 创建人ID + public int? CreateUser { get; set; } + + /// 创建时间 + public DateTime? CreateTime { get; set; } + + /// 移动端图标URL + public string? MobiIcon { get; set; } + + /// 支持的客户端类型(位掩码叠加值,对齐 UserBase ApplicationInfo.clients) + public int? Clients { get; set; } + + /// 集成方式 + public int? IntegMode { get; set; } + + /// 内置标记:1=内置 0=非内置 + public int? BuiltInTag { get; set; } = 0; + + /// 网页包地址 + public string? WebPagePkgAddr { get; set; } + + /// 网页访问地址 + public string? WebAddress { get; set; } + + /// 微信公众号地址 + public string? WxgzhAddress { get; set; } + + /// 钉钉应用地址 + public string? DingAddress { get; set; } + + /// 是否公开应用 + public bool? IsPublic { get; set; } + + /// 模块列表 + public List? Modules { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaAppType.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaAppType.cs new file mode 100644 index 0000000..67aa9b5 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaAppType.cs @@ -0,0 +1,16 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 应用类型 + 该类型下挂的应用列表。平台级富信息载体,替代旧 bsch BoaAppType。 +/// +public class BoaAppType +{ + /// 应用类型ID + public int? Id { get; set; } + + /// 应用类型名称 + public string? Name { get; set; } + + /// 该类型下的应用列表 + public List? Apps { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaGroup.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaGroup.cs new file mode 100644 index 0000000..1476ac0 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaGroup.cs @@ -0,0 +1,32 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 组织机构单元(部门 / 学校 / 教育局等)。对应旧 bsch 的 com.bestsch.openapi.BoaGroup。 +/// +public class BoaGroup +{ + public int? Id { get; set; } + + public string? Name { get; set; } + + public int? ParentId { get; set; } + + /// 节点类型:1=单位/教育局 2=学校 3=部门 等,由业务自定义 + public int? Type { get; set; } + + /// 排序号(同层级升序;可空) + public int? Sort { get; set; } + + /// 子节点列表;仅树接口展开时填充,未展开时为 null + public List? Children { get; set; } + + public BoaGroup() + { + } + + public BoaGroup(int? id, string? name) + { + Id = id; + Name = name; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaModule.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaModule.cs new file mode 100644 index 0000000..2074f0c --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaModule.cs @@ -0,0 +1,21 @@ +namespace Yh.Platform.Abstractions; + +/// 功能模块载体。与 Java utils.model.BoaModule 一比一。 +public class BoaModule +{ + public int? Id { get; set; } + + public int? ParentId { get; set; } + + public string? CustomId { get; set; } + + public string? Name { get; set; } + + public string? Path { get; set; } + + public int? Level { get; set; } + + public int? SortNo { get; set; } + + public string? Icon { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaRoleInfo.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaRoleInfo.cs new file mode 100644 index 0000000..8ea0286 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaRoleInfo.cs @@ -0,0 +1,26 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 用户角色身份信息载体。与 Java utils.model.BoaRoleInfo 一比一: +/// 用户在某校某子节点的激活角色快照,由 UserBase oapi 端组装。 +/// +public class BoaRoleInfo +{ + /// 角色ID + public int? RoleId { get; set; } + + /// 角色名称 + public string? RoleName { get; set; } + + /// 学校ID(组ID) + public int? SchId { get; set; } + + /// 学校名称 + public string? SchName { get; set; } + + /// 子节点ID(班级/学段等) + public int? ChildId { get; set; } + + /// 子节点名称 + public string? ChildName { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaUser.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaUser.cs new file mode 100644 index 0000000..fa209ba --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaUser.cs @@ -0,0 +1,79 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 已登录用户的富信息载体(含部门/学校/角色)。与 Java utils.model.BoaUser 字段一比一, +/// 替代旧 bsch 的 com.bestsch.openapi.BoaUser。 +/// 纯 POCO 零注解:Newtonsoft(大小写不敏感)与 System.Text.Json(camelCase)双兼容。 +/// +public class BoaUser +{ + /// 用户主键 + public int? Id { get; set; } + + /// 登录账号 + public string? Account { get; set; } + + /// 姓名 + public string? Name { get; set; } + + public string? Idcard { get; set; } + + public string? Phone { get; set; } + + public string? Email { get; set; } + + public int? Gender { get; set; } + + public string? ProfilePicture { get; set; } + + /// 用户类型:1=教师 2=家长 3=学生 等,由业务自定义 + public int? Type { get; set; } + + /// 状态:1=正常 0=禁用 + public int? Status { get; set; } + + /// 所属部门 + public BoaGroup? Dept { get; set; } + + /// + /// 当前激活身份的学校(来自 sys_user_current_role.school_id,数值即学校组 sys_group.id)。 + /// 由 UserBase 侧填充:优先取用户当前切换的学校,缺省兜底为主身份所在学校; + /// 区域组/学段组/根直属用户(无学校归属)时为 null。 + /// 与 平行:本字段表示"当前",Schools 表示"全部可选"。 + /// + public BoaGroup? School { get; set; } + + /// 所属教育局/主管单位 + public BoaGroup? Bureau { get; set; } + + /// 多级组织路径(含父级) + public List? Groups { get; set; } + + /// 角色 id 列表 + public List? RoleIds { get; set; } + + /// + /// 当前激活身份(角色 × 学校)。来自 sys_user_current_role(未切换时兜底 sys_user_role_rel.is_main=1 主身份)。 + /// 由 UserBase 侧填充;用户无任何身份时为 null。子应用做"当前身份/当前学校"判断时优先读本字段。 + /// + public BoaRoleInfo? CurrentRole { get; set; } + + /// + /// 用户所有可选学校列表(来自 sys_user_role_rel 按 user_id 去重的 school_id,不含 school_id=0)。 + /// 每个元素即一所学校(type=SCHOOL_GROUP 的 sys_group),本列表即"切换身份"页的可选学校集合。 + /// 区域组/学段组/根直属用户(无学校归属)此列表为空。 + /// 与 平行:School=当前激活学校,Schools=全部可选学校。 + /// + public List? Schools { get; set; } + + public BoaUser() + { + } + + public BoaUser(int? id, string? account, string? name) + { + Id = id; + Account = account; + Name = name; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ClientEnum.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ClientEnum.cs new file mode 100644 index 0000000..5a3be6e --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ClientEnum.cs @@ -0,0 +1,16 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 客户端来源(对齐 Java ClientEnum)。key 为 2 的幂,支持位运算叠加。 +/// +[Flags] +public enum ClientEnum +{ + PcBrowser = 1, + MobileApp = 2, + SystemModule = 4, + WechatOfficial = 8, + WorkWechat = 16, + DingTalk = 32, + StudentDevice = 64, +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/DException.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/DException.cs new file mode 100644 index 0000000..96688ed --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/DException.cs @@ -0,0 +1,91 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 平台业务异常(对齐 Java utils.exception.DException)。 +/// 携带 code/EnvelopeData/msg/rawException/httpError;/oapi 业务失败(HTTP 200 + code≠0 信封)由 OpenApi 包抛出本异常。 +/// 注意:信封 data 属性命名为 而非 Data,避免遮蔽 (IDictionary 契约)。 +/// +public class DException : Exception +{ + public IErrorCode? Code { get; } + + /// 信封 data 原样携带(通常为 System.Text.Json.JsonElement);对应 Java 侧 data 字段。 + public object? EnvelopeData { get; } + + public Exception? RawException { get; } + + public object? HttpError { get; } + + public DException(IErrorCode? code) + : base(ResolveMessage(code, null)) + { + Code = code; + } + + /// 便捷构造:默认 code = Custom(5),message 为纯 msg。 + public DException(string message) + : this(ErrorCode.Custom, message) + { + } + + public DException(IErrorCode? code, string? message) + : base(ResolveMessage(code, message)) + { + Code = code; + } + + public DException(IErrorCode? code, object? data) + : base(ResolveMessage(code, null)) + { + Code = code; + EnvelopeData = data; + } + + public DException(IErrorCode? code, Exception? rawException) + : base(ResolveMessage(code, null), rawException) + { + Code = code; + RawException = rawException; + } + + public DException(IErrorCode? code, object? data, Exception? rawException) + : base(ResolveMessage(code, null), rawException) + { + Code = code; + EnvelopeData = data; + RawException = rawException; + } + + public DException(IErrorCode? code, string? message, object? data) + : base(ResolveMessage(code, message)) + { + Code = code; + EnvelopeData = data; + } + + public DException(IErrorCode? code, string? message, object? data, Exception? rawException) + : base(ResolveMessage(code, message), rawException) + { + Code = code; + EnvelopeData = data; + RawException = rawException; + } + + public DException(IErrorCode? code, string? message, object? data, Exception? rawException, object? httpError) + : base(ResolveMessage(code, message), rawException) + { + Code = code; + EnvelopeData = data; + RawException = rawException; + HttpError = httpError; + } + + private static string? ResolveMessage(IErrorCode? code, string? message) + { + if (message != null) + { + return message; + } + return code?.Msg; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ErrorCode.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ErrorCode.cs new file mode 100644 index 0000000..afc1052 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ErrorCode.cs @@ -0,0 +1,18 @@ +namespace Yh.Platform.Abstractions; + +/// +/// 平台已知错误码(对齐 Java ErrorCode 枚举的常量值与默认文案)。 +/// +public static class ErrorCode +{ + public static readonly IErrorCode Success = new SimpleErrorCode(0, ""); + public static readonly IErrorCode Error = new SimpleErrorCode(1, "未知错误"); + public static readonly IErrorCode ParamError = new SimpleErrorCode(2, "错误的请求参数"); + public static readonly IErrorCode Forbidden = new SimpleErrorCode(3, "非法操作,没有权限"); + public static readonly IErrorCode Unauthorized = new SimpleErrorCode(4, "登录已经过期,请重新登录"); + public static readonly IErrorCode Custom = new SimpleErrorCode(5, ""); + public static readonly IErrorCode InternalServerError = new SimpleErrorCode(6, "内部服务错误"); + public static readonly IErrorCode HttpMethodNotAllowed = new SimpleErrorCode(405, "HTTP方法不支持"); + + private sealed record SimpleErrorCode(int Code, string Msg) : IErrorCode; +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/GlobalUsings.cs new file mode 100644 index 0000000..8868ea6 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/GlobalUsings.cs @@ -0,0 +1,3 @@ +// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛) +global using System; +global using System.Collections.Generic; diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/IErrorCode.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/IErrorCode.cs new file mode 100644 index 0000000..b8f1e6d --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/IErrorCode.cs @@ -0,0 +1,11 @@ +namespace Yh.Platform.Abstractions; + +/// 错误码抽象(对齐 Java IErrorCode)。 +public interface IErrorCode +{ + /// 业务状态码 + int Code { get; } + + /// 默认提示信息 + string Msg { get; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/Yh.Platform.Abstractions.csproj b/BasePlatformDotnet/src/Yh.Platform.Abstractions/Yh.Platform.Abstractions.csproj new file mode 100644 index 0000000..1b52845 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Abstractions/Yh.Platform.Abstractions.csproj @@ -0,0 +1,9 @@ + + + + Yh.Platform.Abstractions + yunhai 平台模型与信封(对应 Java yh-utils-core):BoaUser/BoaRoleInfo 等纯 POCO、ApiEnvelope、DException、错误码抽象 + 平台模型与信封契约,双序列化器(Newtonsoft/System.Text.Json)兼容 + + + diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthOptions.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthOptions.cs new file mode 100644 index 0000000..b1e5b4a --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthOptions.cs @@ -0,0 +1,36 @@ +namespace Yh.Platform.AppAuth; + +/// +/// 应用签名鉴权配置(对齐 Java yh.app-auth.* 配置节)。 +/// +public class AppAuthOptions +{ + public const string SectionName = "yh:app-auth"; + + /// 是否启用(默认 true) + public bool Enabled { get; set; } = true; + + /// 请求有效期(毫秒),默认 5 分钟 + public long ExpireMs { get; set; } = SignUtil.DefaultExpireMs; + + /// 本应用的 appKey(客户端出站签名用) + public string? AppKey { get; set; } + + /// 本应用的 appSecret(客户端出站签名用) + public string? AppSecret { get; set; } + + /// 服务端受信应用表(appKey → appSecret),用于固定密钥验签场景 + public Dictionary TrustedApps { get; set; } = new(StringComparer.Ordinal); + + /// Redis 连接串;为空时密钥解析链跳过 Redis 层、防重放退化为进程内实现 + public string? RedisConnectionString { get; set; } + + /// 验签密钥在 Redis 中的键前缀(键空间与 Java 约定一致:sign:app:{appKey}) + public string SignKeyRedisKeyPrefix { get; set; } = "sign:app:"; + + /// 是否启用 UserBase 密钥兜底(GET {userbase}/oapi/internal/sign-key/{appKey}) + public bool UserBaseSignKeyFallbackEnabled { get; set; } + + /// UserBase 基址(兜底取密钥用),如 http://userbase 或 http://host/userbase + public string? UserBaseBaseUrl { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthServiceCollectionExtensions.cs new file mode 100644 index 0000000..60f644d --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthServiceCollectionExtensions.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Yh.Platform.AppAuth; + +/// 配置型密钥来源(对齐 Java PropertyAppSecretProvider):从 TrustedApps 映射取密钥。 +public sealed class PropertyAppSecretProvider : IAppSecretProvider +{ + private readonly AppAuthOptions _options; + + public PropertyAppSecretProvider(AppAuthOptions options) + { + _options = options; + } + + public Task GetSecretAsync(string appKey, CancellationToken ct = default) + => Task.FromResult(_options.TrustedApps.TryGetValue(appKey, out var secret) ? secret : null); +} + +/// Redis 密钥来源(对齐 Java RedisAppSecretProvider):委托 三层解析链。 +public sealed class SignKeyResolverAppSecretProvider : IAppSecretProvider +{ + private readonly SignKeyResolver _resolver; + + public SignKeyResolverAppSecretProvider(SignKeyResolver resolver) + { + _resolver = resolver; + } + + public Task GetSecretAsync(string appKey, CancellationToken ct = default) + => _resolver.GetSecretAsync(appKey, ct); +} + +/// 服务端验签器:请求级签名 + 过期 + 防重放一次性校验入口。 +public class AppAuthVerifier +{ + private readonly IAppSecretProvider _secretProvider; + private readonly IReplayGuard _replayGuard; + private readonly AppAuthOptions _options; + + public AppAuthVerifier(IAppSecretProvider secretProvider, IReplayGuard replayGuard, IOptions options) + { + _secretProvider = secretProvider; + _replayGuard = replayGuard; + _options = options.Value; + } + + /// + /// 校验签名三件套。 + /// + /// X-App-Key + /// X-Timestamp + /// X-Signature + /// HTTP 方法 + /// canonical target(path[?sortedQuery],见 ) + /// 可选防重放 nonce;提供时参与防重放判定 + /// 取消令牌 + /// 校验通过返回 true;签名错/过期/重放返回 false + public async Task VerifyAsync(string appKey, string timestamp, string signature, + string method, string canonicalTarget, string? nonce = null, CancellationToken ct = default) + { + var secret = await _secretProvider.GetSecretAsync(appKey, ct); + if (secret == null) + { + return false; // 未知 app 直接拒绝 + } + if (!SignUtil.VerifyRequestWithExpire(method, canonicalTarget, timestamp, secret, signature, _options.ExpireMs)) + { + return false; + } + if (nonce == null) + { + return true; + } + return await _replayGuard.TryBeginAsync(appKey, timestamp, nonce, ct); + } +} + +public static class AppAuthServiceCollectionExtensions +{ + /// + /// 注册应用签名鉴权(客户端 AppSignHandler + 服务端 SignKeyResolver/ReplayGuard/AppAuthVerifier)。 + /// + public static IServiceCollection AddYhAppAuth(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(AppAuthOptions.SectionName)) + .PostConfigure(o => + { + if (o.ExpireMs <= 0) + { + o.ExpireMs = SignUtil.DefaultExpireMs; + } + }); + services.AddMemoryCache(); + + // Redis(可选):连接串为空时整条 Redis 链路自动降级 + services.AddSingleton>(sp => + { + var options = sp.GetRequiredService>().Value; + if (string.IsNullOrEmpty(options.RedisConnectionString)) + { + return () => null; + } + var multiplexer = ConnectionMultiplexer.Connect(options.RedisConnectionString); + return () => multiplexer.GetDatabase(); + }); + + // 密钥兜底:仅当配置开启时直调 UserBase internal 端点 + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + if (!options.UserBaseSignKeyFallbackEnabled || string.IsNullOrEmpty(options.UserBaseBaseUrl)) + { + return NoOpSignKeyFallback.Instance; + } + var factory = sp.GetRequiredService(); + return new UserBaseSignKeyFallback(factory.CreateClient("yh-appauth-signkey"), options.UserBaseBaseUrl); + }); + + services.AddSingleton(); + services.AddSingleton(sp => new SignKeyResolverAppSecretProvider(sp.GetRequiredService())); + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return string.IsNullOrEmpty(options.RedisConnectionString) + ? new MemoryReplayGuard(options.ExpireMs) + : new RedisReplayGuard(sp.GetRequiredService>(), options.ExpireMs); + }); + services.AddSingleton(); + return services; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppSignHandler.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppSignHandler.cs new file mode 100644 index 0000000..c202e64 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppSignHandler.cs @@ -0,0 +1,77 @@ +using System.Net.Http; + +namespace Yh.Platform.AppAuth; + +/// +/// 客户端出站签名 (对齐 Java AppSignInterceptor): +/// 为请求自动附加 X-App-Key / X-Timestamp / X-Signature 三头; +/// 签名为请求级(canonical target 只取 path + 排序 query,Base64 输出)。 +/// 签名前剥离 (对齐 Java stripBasePath):canonical 口径与服务端验签统一为 /oapi/**, +/// basePath 须与 UserBase 部署 context-path 一致(如 /userbase)。 +/// +public class AppSignHandler : DelegatingHandler +{ + public const string HeaderAppKey = "X-App-Key"; + public const string HeaderTimestamp = "X-Timestamp"; + public const string HeaderSignature = "X-Signature"; + + private readonly string _appKey; + private readonly string _appSecret; + private readonly string? _basePath; + + public AppSignHandler(string appKey, string appSecret) + : this(appKey, appSecret, null, null) + { + } + + /// + /// 构造签名处理器。innerHandler 为 null 时 InnerHandler 保持 null(DelegatingHandler 默认), + /// 由 HttpClientFactory 注入 primary handler(AddHttpMessageHandler 场景要求 InnerHandler 初始为 null); + /// 独立使用时显式传入内层 handler(见 BaseOpenApi / 单元测试)。 + /// + public AppSignHandler(string appKey, string appSecret, string? basePath, HttpMessageHandler? innerHandler = null) + : base() + { + _appKey = appKey ?? throw new ArgumentNullException(nameof(appKey)); + _appSecret = appSecret ?? throw new ArgumentNullException(nameof(appSecret)); + _basePath = NormalizeBasePath(basePath); + if (innerHandler != null) + { + InnerHandler = innerHandler; + } + } + + /// 归一化 basePath:trim、去尾斜杠;null/空白返回 null(挂根路径,不剥离)。 + internal static string? NormalizeBasePath(string? basePath) + { + if (string.IsNullOrWhiteSpace(basePath)) + { + return null; + } + var trimmed = basePath.Trim().TrimEnd('/'); + if (trimmed.Length == 0) + { + return null; + } + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri is { IsAbsoluteUri: true } uri) + { + // 时钟源统一走 SignUtil.NowMillis(与 SignUtil 共用同一可注入时钟,测试可固定 ts 与基线逐字节对拍) + var ts = SignUtil.NowMillis().ToString(); + var target = SignCanonical.BuildTarget(uri, _basePath); + var sign = SignUtil.SignRequest(request.Method.Method, target, ts, _appSecret); + var headers = request.Headers; + headers.Remove(HeaderAppKey); + headers.Remove(HeaderTimestamp); + headers.Remove(HeaderSignature); + headers.TryAddWithoutValidation(HeaderAppKey, _appKey); + headers.TryAddWithoutValidation(HeaderTimestamp, ts); + headers.TryAddWithoutValidation(HeaderSignature, sign); + } + return base.SendAsync(request, cancellationToken); + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/GlobalUsings.cs new file mode 100644 index 0000000..dd34d99 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/GlobalUsings.cs @@ -0,0 +1,8 @@ +// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛) +global using System; +global using System.Collections.Concurrent; +global using System.Collections.Generic; +global using System.Linq; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/ReplayGuard.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/ReplayGuard.cs new file mode 100644 index 0000000..fdbb05a --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/ReplayGuard.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; +using StackExchange.Redis; + +namespace Yh.Platform.AppAuth; + +/// +/// 防重放守卫:同一 (appKey, timestamp, nonce) 只允许提交一次,有效期同请求窗口(默认 5 分钟)。 +/// +public interface IReplayGuard +{ + /// true = 首次提交放行;false = 重复提交拒绝。 + Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default); +} + +/// 进程内防重放(单实例部署或 Redis 未配置时使用)。 +public sealed class MemoryReplayGuard : IReplayGuard +{ + private readonly ConcurrentDictionary _seen = new(StringComparer.Ordinal); + private readonly long _windowMs; + private DateTimeOffset _lastSweep = DateTimeOffset.MinValue; + private readonly object _sweepLock = new(); + + public MemoryReplayGuard(long windowMs = SignUtil.DefaultExpireMs) + { + _windowMs = windowMs; + } + + public Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default) + { + Sweep(); + var key = appKey + ":" + timestamp + ":" + nonce; + var now = DateTimeOffset.UtcNow; + var expiry = now.AddMilliseconds(_windowMs); + // 原子插入:首次出现即放行;已存在且未过期 → 拒绝;已过期 → 允许覆盖重放窗口 + if (!_seen.TryAdd(key, expiry)) + { + if (_seen.TryGetValue(key, out var existing) && existing > now) + { + return Task.FromResult(false); + } + _seen[key] = expiry; + } + return Task.FromResult(true); + } + + private void Sweep() + { + var now = DateTimeOffset.UtcNow; + lock (_sweepLock) + { + if (now - _lastSweep < TimeSpan.FromSeconds(30)) + { + return; + } + _lastSweep = now; + } + foreach (var kv in _seen) + { + if (kv.Value <= now) + { + _seen.TryRemove(kv.Key, out _); + } + } + } +} + +/// +/// Redis 防重放:SETNX 键 sign:replay:{appKey}:{timestamp}:{nonce},TTL = 请求窗口。 +/// +public sealed class RedisReplayGuard : IReplayGuard +{ + private readonly Func _redisDatabaseFactory; + private readonly long _windowMs; + + public RedisReplayGuard(Func redisDatabaseFactory, long windowMs = SignUtil.DefaultExpireMs) + { + _redisDatabaseFactory = redisDatabaseFactory; + _windowMs = windowMs; + } + + public async Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default) + { + var redis = _redisDatabaseFactory(); + if (redis == null) + { + return true; // Redis 未配置时不拦截 + } + var key = "sign:replay:" + appKey + ":" + timestamp + ":" + nonce; + return await redis.StringSetAsync(key, "1", TimeSpan.FromMilliseconds(_windowMs), When.NotExists); + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignAppKeyPayload.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignAppKeyPayload.cs new file mode 100644 index 0000000..050d36e --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignAppKeyPayload.cs @@ -0,0 +1,13 @@ +namespace Yh.Platform.AppAuth; + +/// +/// 应用密钥载荷(对齐 Java SignAppKeyPayload,与 UserBase SignKeyInternalController 返回契约一致)。 +/// +public class SignAppKeyPayload +{ + public string? AppKey { get; set; } + + public string? Secret { get; set; } + + public bool Enabled { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignCanonical.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignCanonical.cs new file mode 100644 index 0000000..ff91ab3 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignCanonical.cs @@ -0,0 +1,125 @@ +using System.Net; + +namespace Yh.Platform.AppAuth; + +/// +/// 请求签名规范化工具(调用方签名与服务端验签共用)。 +/// canonical 的 target 部分只取 path(不含 host/port/context-path)+ 排序后的 query: +/// METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp。 +/// 只编码 path 可让服务发现/负载均衡解析前后两端 host 不一致时仍天然对齐。 +/// +public static class SignCanonical +{ + /// 拼接 target:path +(有 query 时 ?sortedQuery)。 + public static string BuildTarget(string path, string sortedQuery) + => string.IsNullOrEmpty(sortedQuery) ? path : path + "?" + sortedQuery; + + /// 从 构建 canonical target(AbsolutePath + 排序 query),不剥离 basePath。 + public static string BuildTarget(Uri uri) + => BuildTarget(uri, null); + + /// + /// 从 构建 canonical target,并在签名前剥离部署 basePath + /// (对齐 Java AppSignInterceptor.stripBasePath:客户端配置 base-path 与部署 context-path 一致时, + /// 签名口径与服务端 AppAuthFilter 剥 request.getContextPath() 后严格对齐,canonical 统一为 /oapi/**)。 + /// + public static string BuildTarget(Uri uri, string? basePath) + { + var rawQuery = uri.Query; + if (rawQuery.StartsWith('?')) + { + rawQuery = rawQuery.Substring(1); + } + var path = string.IsNullOrEmpty(basePath) ? uri.AbsolutePath : StripBasePath(uri.AbsolutePath, basePath); + return BuildTarget(path, SortRawQuery(rawQuery)); + } + + /// + /// 从请求路径剥离部署基础路径(对齐 Java AppSignInterceptor.stripBasePath,含尾斜杠归一化边界): + /// path 不以 basePath 开头时原样返回;剥离后为空或丢失前导斜杠时补 "/"。 + /// + public static string StripBasePath(string path, string? basePath) + { + if (string.IsNullOrEmpty(basePath)) + { + return path; + } + var bp = basePath.EndsWith('/') ? basePath : basePath + "/"; + var p = path.EndsWith('/') ? path : path + "/"; + if (!p.StartsWith(bp, StringComparison.Ordinal)) + { + return path; + } + var stripped = path.Substring(basePath.Length); + if (stripped.Length == 0 || stripped[0] != '/') + { + return "/" + stripped; + } + return stripped; + } + + /// + /// 从原始 query 串(a=1&b=2,可能已编码)解码并整体排序,输出 k=v&k2=v2。 + /// 与 Java sortRawQuery 一致:先拆 k/v、各自 URL 解码、按 "k=v" 整串排序后拼接。 + /// + public static string SortRawQuery(string? rawQuery) + { + if (string.IsNullOrEmpty(rawQuery)) + { + return ""; + } + var pairs = new List(); + foreach (var part in rawQuery.Split('&')) + { + if (part.Length == 0) + { + continue; + } + var idx = part.IndexOf('='); + var k = idx < 0 ? part : part.Substring(0, idx); + var v = idx < 0 ? "" : part.Substring(idx + 1); + pairs.Add(UrlDecode(k) + "=" + UrlDecode(v)); + } + pairs.Sort(StringComparer.Ordinal); + return string.Join("&", pairs); + } + + /// + /// 从多值参数表排序,输出 k=v&k2=v2(键排序、值也排序,保证顺序无关)。 + /// 对齐 Java sortMultiValue。 + /// + public static string SortMultiValue(IEnumerable>> multiValueParams) + { + if (multiValueParams == null) + { + return ""; + } + var pairs = new List(); + foreach (var (key, values) in multiValueParams) + { + var vals = (values ?? Enumerable.Empty()) + .Select(v => v ?? "") + .OrderBy(v => v, StringComparer.Ordinal) + .ToList(); + foreach (var v in vals) + { + pairs.Add(key + "=" + v); + } + } + pairs.Sort(StringComparer.Ordinal); + return string.Join("&", pairs); + } + + private static string UrlDecode(string s) + { + try + { + return Uri.UnescapeDataString(s.Replace("+", " ")); + } + catch (UriFormatException) + { + // 非法百分号编码(如 ?a=100%):原样参与排序即可,服务端不崩溃(验签必失败 → 401,而非 500) + return s; + } + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyFallback.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyFallback.cs new file mode 100644 index 0000000..0f2b50e --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyFallback.cs @@ -0,0 +1,67 @@ +namespace Yh.Platform.AppAuth; + +/// 服务端验签密钥来源(对齐 Java AppSecretProvider):未知应用返回 null。 +public interface IAppSecretProvider +{ + Task GetSecretAsync(string appKey, CancellationToken ct = default); +} + +/// 签名密钥兜底来源(对齐 Java SignKeyFallback)。 +public interface ISignKeyFallback +{ + Task ResolveAsync(string appKey, CancellationToken ct = default); +} + +/// 空实现兜底。 +public sealed class NoOpSignKeyFallback : ISignKeyFallback +{ + public static readonly NoOpSignKeyFallback Instance = new(); + + public Task ResolveAsync(string appKey, CancellationToken ct = default) => Task.FromResult(null); +} + +/// +/// UserBase 密钥兜底:直调 GET {baseUrl}/oapi/internal/sign-key/{appKey}, +/// 解析 ;仅当 enabled 且 secret 非空时返回;404/异常/超时均返回 null。 +/// +public sealed class UserBaseSignKeyFallback : ISignKeyFallback +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + + public UserBaseSignKeyFallback(HttpClient httpClient, string baseUrl) + { + _httpClient = httpClient; + _baseUrl = (baseUrl ?? "").TrimEnd('/'); + } + + public async Task ResolveAsync(string appKey, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(_baseUrl) || string.IsNullOrEmpty(appKey)) + { + return null; + } + try + { + using var resp = await _httpClient.GetAsync(_baseUrl + "/oapi/internal/sign-key/" + Uri.EscapeDataString(appKey), ct); + if (!resp.IsSuccessStatusCode) + { + return null; + } + var json = await resp.Content.ReadAsStringAsync(ct); + var envelope = System.Text.Json.JsonSerializer.Deserialize>(json, JsonOpts); + var payload = envelope?.Data; + if (payload == null || !payload.Enabled || string.IsNullOrEmpty(payload.Secret)) + { + return null; + } + return payload.Secret; + } + catch + { + return null; + } + } + + private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new(System.Text.Json.JsonSerializerDefaults.Web); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyResolver.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyResolver.cs new file mode 100644 index 0000000..36cc3d3 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyResolver.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Yh.Platform.AppAuth; + +/// +/// 验签密钥解析链(对齐 Java SignKeyResolver): +/// 配置内置应用(yh:app-auth AppKey/AppSecret)→ Redis(sign:app:{appKey})→ IMemoryCache → ISignKeyFallback(UserBase /oapi/internal/sign-key/{appKey}); +/// 未知 app 返回 null(调用方直接拒绝)。 +/// +public class SignKeyResolver +{ + private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new(System.Text.Json.JsonSerializerDefaults.Web); + + private readonly AppAuthOptions _options; + private readonly IMemoryCache _memoryCache; + private readonly ISignKeyFallback _fallback; + private readonly Func? _redisDatabaseFactory; + private IDatabase? _redis; + + public SignKeyResolver(IOptions options, IMemoryCache memoryCache, ISignKeyFallback? fallback = null) + : this(options.Value, memoryCache, fallback ?? NoOpSignKeyFallback.Instance, null) + { + } + + public SignKeyResolver(AppAuthOptions options, IMemoryCache memoryCache, ISignKeyFallback fallback, Func? redisDatabaseFactory) + { + _options = options; + _memoryCache = memoryCache; + _fallback = fallback; + _redisDatabaseFactory = redisDatabaseFactory; + } + + /// 解析调用方 appKey 对应的 appSecret;未知/不可用返回 null。 + public async Task GetSecretAsync(string appKey, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(appKey)) + { + return null; + } + + // 0. 配置内置应用(自服务/联调):调用方 appKey 与本应用 yh:app-auth 配置一致时, + // 直接用配置的 AppSecret 验签。覆盖"系统 A 以自身 AppKey/AppSecret 调系统 B 的 /oapi": + // B 验签时 appKey==B 配置 AppKey → 用 B 配置 AppSecret 校验,无需 Redis/UserBase 预置密钥。 + if (string.Equals(appKey, _options.AppKey, StringComparison.Ordinal)) + { + return string.IsNullOrEmpty(_options.AppSecret) ? null : _options.AppSecret; + } + + // 1. Redis 主路径(宕机/未配置时静默降级) + var redis = TryGetRedis(); + if (redis != null) + { + try + { + // RedisValue 同时可隐式转换为 string / byte[],直接传给 Deserialize 会触发重载二义性,故先显式取 string + var json = (string?)await redis.StringGetAsync(RedisKey(appKey)); + if (!string.IsNullOrEmpty(json)) + { + var payload = System.Text.Json.JsonSerializer.Deserialize(json, JsonOpts); + var secret = Normalize(payload); + if (secret != null) + { + CacheLocal(appKey, secret); + return secret; + } + } + } + catch (RedisConnectionException) + { + // 降级到本地缓存/fallback + } + catch (System.Text.Json.JsonException) + { + // 脏数据视同未命中 + } + } + + // 2. 本地缓存 + if (_memoryCache.TryGetValue(CacheKey(appKey), out string? cached) && cached != null) + { + return cached; + } + + // 3. 兜底(UserBase),成功后回填本地缓存 + var fallbackSecret = await _fallback.ResolveAsync(appKey, ct); + if (fallbackSecret != null) + { + CacheLocal(appKey, fallbackSecret); + } + return fallbackSecret; + } + + private string RedisKey(string appKey) => _options.SignKeyRedisKeyPrefix + appKey; + + private static string CacheKey(string appKey) => "yh:signkey:local:" + appKey; + + private void CacheLocal(string appKey, string secret) + => _memoryCache.Set(CacheKey(appKey), secret, TimeSpan.FromMinutes(5)); + + private static string? Normalize(SignAppKeyPayload? payload) + => payload == null || !payload.Enabled || string.IsNullOrEmpty(payload.Secret) ? null : payload.Secret; + + private IDatabase? TryGetRedis() + { + if (_redisDatabaseFactory == null) + { + return null; + } + if (_redis != null) + { + return _redis; + } + try + { + _redis = _redisDatabaseFactory(); + } + catch + { + _redis = null; + } + return _redis; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignUtil.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignUtil.cs new file mode 100644 index 0000000..f42e36c --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignUtil.cs @@ -0,0 +1,154 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Yh.Platform.AppAuth; + +/// +/// HmacSHA256 接口签名工具类(对齐 Java utils.crypto.SignUtil,输出格式逐字节一致)。 +/// 双签名规则: +/// +/// 数据级:source = data + "|" + timeStamp,HmacSHA256 输出小写十六进制; +/// 请求级:source = METHOD + "\n" + url + "\n" + timeStamp,HmacSHA256 输出 Base64。 +/// +/// +public static class SignUtil +{ + private const string AlgHmacSha256 = "HmacSHA256"; + private const string SignSplitChar = "|"; + + /// 签名密钥最低长度 + public const int MinSecretLength = 16; + + /// 默认请求有效时长:5 分钟(毫秒) + public const long DefaultExpireMs = 5 * 60 * 1000; + + // ====================== 数据级签名 ====================== + + /// 生成数据级签名(data|ts → HmacSHA256 小写十六进制)。 + public static string Sign(string data, string timeStamp, string secret) + { + CheckParam(data, timeStamp, secret); + return HmacSha256Hex(data + SignSplitChar + timeStamp, secret); + } + + /// 数据级验签(不校验时间戳,忽略大小写比对)。 + public static bool Verify(string data, string timeStamp, string secret, string? clientSign) + { + if (string.IsNullOrWhiteSpace(clientSign)) + { + return false; + } + var serverSign = Sign(data, timeStamp, secret); + return string.Equals(serverSign, clientSign, StringComparison.OrdinalIgnoreCase); + } + + /// 数据级验签 + 时间戳过期校验(默认 5 分钟)。 + public static bool VerifyWithExpire(string data, string timeStamp, string secret, string? clientSign) + => VerifyWithExpire(data, timeStamp, secret, clientSign, DefaultExpireMs); + + public static bool VerifyWithExpire(string data, string timeStamp, string secret, string? clientSign, long expireMs) + { + if (!Verify(data, timeStamp, secret, clientSign)) + { + return false; + } + return !IsExpired(timeStamp, expireMs); + } + + // ====================== 请求级签名(统一方案) ====================== + + /// + /// 对一次 HTTP 请求签名,输出 Base64。 + /// canonical = METHOD + "\n" + url + "\n" + timestamp;url 为 canonical target(path[?sortedQuery],见 )。 + /// + public static string SignRequest(string method, string url, string? timeStamp, string secret) + { + if (string.IsNullOrWhiteSpace(method) || string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("method/url 不能为空"); + } + CheckSecret(secret); + if (string.IsNullOrWhiteSpace(timeStamp)) + { + timeStamp = NowMillis().ToString(); + } + return HmacSha256Base64(method + "\n" + url + "\n" + timeStamp, secret); + } + + /// 请求级验签(不校验时间戳,精确比对)。 + public static bool VerifyRequest(string method, string url, string timeStamp, string secret, string? clientSign) + { + if (string.IsNullOrWhiteSpace(clientSign)) + { + return false; + } + var serverSign = SignRequest(method, url, timeStamp, secret); + return string.Equals(serverSign, clientSign, StringComparison.Ordinal); + } + + /// 请求级验签 + 时间戳过期校验(默认 5 分钟)。 + public static bool VerifyRequestWithExpire(string method, string url, string timeStamp, string secret, string? clientSign) + => VerifyRequestWithExpire(method, url, timeStamp, secret, clientSign, DefaultExpireMs); + + public static bool VerifyRequestWithExpire(string method, string url, string timeStamp, string secret, string? clientSign, long expireMs) + { + if (!VerifyRequest(method, url, timeStamp, secret, clientSign)) + { + return false; + } + return !IsExpired(timeStamp, expireMs); + } + + // ====================== 内部工具 ====================== + + /// 当前毫秒时间戳(供测试注入替代)。 + public static Func NowMillis { get; set; } = static () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static bool IsExpired(string timeStamp, long expireMs) + { + if (!long.TryParse(timeStamp, out var clientTs)) + { + return true; + } + return Math.Abs(NowMillis() - clientTs) > expireMs; + } + + private static void CheckParam(string data, string timeStamp, string secret) + { + if (data == null) + { + throw new ArgumentException("业务数据data不能为null"); + } + if (string.IsNullOrWhiteSpace(timeStamp)) + { + throw new ArgumentException("时间戳timeStamp不能为空"); + } + CheckSecret(secret); + } + + private static void CheckSecret(string secret) + { + if (string.IsNullOrWhiteSpace(secret)) + { + throw new ArgumentException("签名密钥secret不能为空"); + } + if (secret.Length < MinSecretLength) + { + throw new ArgumentException("签名密钥长度不能小于" + MinSecretLength + "位"); + } + } + + private static string HmacSha256Hex(string source, string secret) + { + using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(source)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string HmacSha256Base64(string source, string secret) + { + using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(source)); + return Convert.ToBase64String(hash); + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/Yh.Platform.AppAuth.csproj b/BasePlatformDotnet/src/Yh.Platform.AppAuth/Yh.Platform.AppAuth.csproj new file mode 100644 index 0000000..6980626 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.AppAuth/Yh.Platform.AppAuth.csproj @@ -0,0 +1,20 @@ + + + + Yh.Platform.AppAuth + yunhai 平台应用签名与防重放(对应 Java yh-app-auth-starter):HmacSHA256 SignUtil、AppSignHandler、SignKeyResolver、ReplayGuard + HMAC 应用签名(X-App-Key/X-Timestamp/X-Signature)与验签密钥三层解析链 + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs new file mode 100644 index 0000000..a94bdaa --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Yh.Platform.AppAuth; +using Yh.Platform.OpenApi; + +namespace Yh.Platform.Auth; + +/// 认证装配扩展(对齐 Java YhAuthAutoConfiguration 与聚合 starter)。 +public static class AuthServiceCollectionExtensions +{ + /// + /// 注册 Cookie + CAS 认证与用户上下文依赖(含 AddYhOpenApi)。 + /// 配置节:yh:auth(通用)与 yh:sso(CAS 服务端)。 + /// + public static IServiceCollection AddYhAuth(this IServiceCollection services, IConfiguration configuration) + { + services.AddYhOpenApi(configuration); + + // 会话票据表单例:Cookie TicketDataFormat 与 OnSigningOut 退出吊销必须共享同一实例(同一票据表) + var sessionTicketFormat = new SessionTicketDataFormat(); + services.AddSingleton(sessionTicketFormat); + + services.AddOptions().Bind(configuration.GetSection(YhAuthOptions.SectionName)); + // 注:AddScheme 的 configure 才是 handler 实际读取的命名 options(AuthenticationHandler.Options); + // 必须在此绑定 yh:sso,否则 handler 拿到的 CasAuthenticationOptions 为空(Challenge 时 CasServerUrlPrefix 缺失)。 + services.AddOptions().Bind(configuration.GetSection("yh:sso")); + + services.AddAuthentication(options => + { + // DefaultScheme 必须为 CasScheme(而非 CookieScheme): + // UseAuthentication 每请求调用 AuthenticateAsync(默认 scheme),若默认是 CookieScheme, + // CAS 票据校验(CasAuthenticationHandler.HandleAuthenticateAsync 的 ticket 分支)永远没有触发点—— + // 回调带 ticket 时认证不校验、SsoProtectionMiddleware 见未认证直接 401,CAS 登录永远失败。 + // CasAuthenticationHandler.HandleAuthenticateAsync 第一步就是 Context.AuthenticateAsync(CookieScheme) + // 恢复 Cookie 登录态,因此 CasScheme 作为默认认证 scheme 语义等价"先 Cookie 恢复、后 ticket 校验"。 + options.DefaultScheme = YhAuthDefaults.CasScheme; + options.DefaultChallengeScheme = YhAuthDefaults.CasScheme; + }) + .AddCookie(YhAuthDefaults.CookieScheme, options => + { + var auth = configuration.GetSection(YhAuthOptions.SectionName).Get() ?? new YhAuthOptions(); + options.Cookie.Name = auth.CookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax; + // 会话级 Cookie + 服务端票据固定过期: + // - TicketDataFormat(SessionTicketDataFormat):票据存服务端,Cookie 只写 32 位 key + // (.NET 5 SessionStore 模式下 cookie 值仍是 Protect 的加密长串,无法满足 32 位,故自定义 DataFormat; + // 实例与 DI 单例同一对象——TicketDataFormat 与下方 OnSigningOut 事件必须操作同一票据表) + // - IsPersistent=false(CasAuthenticationHandler):会话 Cookie,关闭浏览器即失效 + // - ExpireTimeSpan=1 分钟且 SlidingExpiration=false:服务端票据固定 1 分钟过期,不滑动续期 + options.TicketDataFormat = sessionTicketFormat; + options.ExpireTimeSpan = TimeSpan.FromMinutes(auth.CookieExpireMinutes > 0 ? auth.CookieExpireMinutes : 1); + options.SlidingExpiration = false; + options.LoginPath = "/signin-cas"; + options.ReturnUrlParameter = "returnUrl"; + // 退出即吊销:SignOutAsync(如业务 GET /api/Logout)清除 Cookie 时, + // 从请求 Cookie 取 32 位 key 立即删除服务端票据——否则票据残留到 ExpireTimeSpan 过期才失效。 + // ("关闭浏览器"本身服务端无法感知,依赖会话 Cookie 删除 + 1 分钟过期兜底;显式退出则双删。) + options.Events = new CookieAuthenticationEvents + { + OnSigningOut = context => + { + if (context.HttpContext.Request.Cookies.TryGetValue(auth.CookieName, out var rawKey)) + { + sessionTicketFormat.Revoke(rawKey); + } + return Task.CompletedTask; + }, + }; + }) + .AddScheme(YhAuthDefaults.CasScheme, + o => configuration.GetSection("yh:sso").Bind(o)); + + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService>().CurrentValue; + return new CasServiceTicketValidator(opts.CasServerUrlPrefix, opts.TicketValidateTimeoutMs); + }); + services.AddScoped(); + + return services; + } + + /// + /// 聚合入口(等价 Java yh-spring-boot-starter):一次带齐 OpenApi + AppAuth + Auth。 + /// + public static IServiceCollection AddYhPlatform(this IServiceCollection services, IConfiguration configuration) + => services + .AddYhAppAuth(configuration) + .AddYhAuth(configuration); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/BoaUserContext.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/BoaUserContext.cs new file mode 100644 index 0000000..f2cdfc7 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/BoaUserContext.cs @@ -0,0 +1,56 @@ +using Yh.Platform.Abstractions; + +namespace Yh.Platform.Auth; + +/// 登录用户载体(对齐 Java LoginUser)。 +public class LoginUser +{ + public string? UserId { get; set; } + + public string? LoginName { get; set; } + + public string? DisplayName { get; set; } + + /// 应用身份标识。普通用户登录态为 null;由应用签名鉴权写入调用方 appKey。 + public string? AppKey { get; set; } + + public LoginUser() + { + } + + public LoginUser(string? userId) + { + UserId = userId; + } +} + +/// +/// 当前请求的 持有器。 +/// 对齐 Java BoaUserContext(ThreadLocal)语义;C# 侧用 , +/// 请求内任何层可取,请求结束由 清空(不串请求)。 +/// +public static class BoaUserContext +{ + private static readonly AsyncLocal Current = new(); + + public static void Set(BoaUser? boaUser) => Current.Value = boaUser; + + public static BoaUser? Get() => Current.Value; + + public static void Clear() => Current.Value = null; +} + +/// +/// 当前请求的 持有器(对齐 Java UserContextHolder): +/// 认证中间件写入、业务读取、请求结束清理。 +/// +public static class UserContextHolder +{ + private static readonly AsyncLocal Current = new(); + + public static void Set(LoginUser? user) => Current.Value = user; + + public static LoginUser? Get() => Current.Value; + + public static void Clear() => Current.Value = null; +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/CasAuthenticationHandler.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/CasAuthenticationHandler.cs new file mode 100644 index 0000000..7e0daad --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/CasAuthenticationHandler.cs @@ -0,0 +1,140 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Yh.Platform.Auth; + +/// +/// 自研 CAS 远程认证 handler(替换 AspNetCore.Security.CAS 本地 dll): +/// +/// Challenge:302 到 CAS 登录页(service 指向本应用 ); +/// Authenticate:回调带 ticket 时调 /serviceValidate 校验,成功后写入 Cookie 登录态并回跳原地址。 +/// +/// +public class CasAuthenticationHandler : AuthenticationHandler +{ + private readonly CasServiceTicketValidator _validator; + +#if NET8_0_OR_GREATER + public CasAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + CasServiceTicketValidator validator) + : base(options, logger, encoder) + { + _validator = validator; + } +#else + public CasAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + CasServiceTicketValidator validator) + : base(options, logger, encoder, clock) + { + _validator = validator; + } +#endif + + private string BuildServiceUrl(string? returnUrl) + { + var request = Context.Request; + var root = Options.AppBaseUrl?.TrimEnd('/'); + if (string.IsNullOrEmpty(root)) + { + root = request.Scheme + "://" + request.Host.Value; + } + var callback = root + Options.SignInCallbackPath; + // 只允许本地相对路径回跳,杜绝开放重定向(returnUrl 不得为外站绝对地址 / 协议相对地址) + if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl)) + { + return callback; + } + return callback + "?returnUrl=" + Uri.EscapeDataString(returnUrl); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + var casBase = Options.CasServerUrlPrefix; + if (string.IsNullOrWhiteSpace(casBase)) + { + Response.StatusCode = 500; + return Response.WriteAsync("未配置 CAS 服务端地址(yh:sso:cas-server-url-prefix)"); + } + properties.Items.TryGetValue("returnUrl", out var returnUrl); + var service = BuildServiceUrl(returnUrl ?? properties.RedirectUri); + var loginUrl = (casBase!.EndsWith('/') ? casBase : casBase + "/") + "login?service=" + UrlEncoder.Encode(service); + Response.Redirect(loginUrl); + return Task.CompletedTask; + } + + protected override async Task HandleAuthenticateAsync() + { + // 1. 已有 Cookie 登录态直接放行 + var cookieResult = await Context.AuthenticateAsync(YhAuthDefaults.CookieScheme); + if (cookieResult.Succeeded && cookieResult.Principal != null) + { + return AuthenticateResult.Success(cookieResult.Ticket!); + } + + // 2. CAS 回调:校验 ticket + var ticketValue = Context.Request.Query["ticket"].ToString(); + if (string.IsNullOrEmpty(ticketValue)) + { + return AuthenticateResult.NoResult(); + } + + // service 必须与登录时完全一致:剥掉本回调的 ticket 参数后重建 + var service = BuildServiceUrl(Context.Request.Query["returnUrl"].ToString()); + string? loginName; + try + { + loginName = await _validator.ValidateAsync(ticketValue, service, Context.RequestAborted); + } + catch (AuthException e) + { + return AuthenticateResult.Fail(e); + } + if (string.IsNullOrEmpty(loginName)) + { + return AuthenticateResult.Fail("CAS ticket 校验失败或已失效"); + } + + var claims = new List + { + new(ClaimTypes.Name, loginName!), + new(ClaimTypes.NameIdentifier, loginName!), + new("loginName", loginName!), + }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + + // 写入 Cookie 登录态(会话级:IsPersistent=false,关闭浏览器即失效;票据过期由 SessionStore + ExpireTimeSpan 控制), + // 然后回跳去掉 ticket 的原地址(仅限本地路径,防开放重定向) + var props = new AuthenticationProperties { IsPersistent = false }; + var returnUrl = Context.Request.Query["returnUrl"].ToString(); + await Context.SignInAsync(YhAuthDefaults.CookieScheme, principal, props); + // 同步当前请求上下文身份:SignInAsync 只写 Cookie 不改 context.User, + // 后续中间件(SsoProtectionMiddleware)需据此识别“刚登录成功”,避免被误判未认证再次 Challenge + Context.User = principal; + var redirect = IsLocalUrl(returnUrl) ? returnUrl : "/"; + Response.Redirect(redirect); + return AuthenticateResult.NoResult(); + } + + /// 仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。 + private static bool IsLocalUrl(string? url) + { + if (string.IsNullOrEmpty(url) || url[0] != '/') + { + return false; + } + return !url.StartsWith("//", StringComparison.Ordinal) + && !url.StartsWith("/\\", StringComparison.Ordinal); + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/CasServiceTicketValidator.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/CasServiceTicketValidator.cs new file mode 100644 index 0000000..83719ef --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/CasServiceTicketValidator.cs @@ -0,0 +1,74 @@ +namespace Yh.Platform.Auth; + +/// +/// 轻量 CAS service ticket 校验器(对齐 Java CasServiceTicketValidator,不依赖 CAS 客户端库)。 +/// 调用 CAS 的 /serviceValidate 端点,从响应 XML 中提取 <cas:user>。 +/// +public class CasServiceTicketValidator +{ + private readonly string? _casServerUrl; + private readonly int _timeoutMs; + + public CasServiceTicketValidator(string? casServerUrl, int timeoutMs = 5000) + { + _casServerUrl = casServerUrl; + _timeoutMs = timeoutMs > 0 ? timeoutMs : 5000; + } + + /// 校验 ticket,成功返回登录账号,失败/无效返回 null。 + public async Task ValidateAsync(string ticket, string service, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(_casServerUrl)) + { + throw new AuthException("未配置 CasServerUrlPrefix(yh:sso:cas-server-url-prefix),无法进行 ticket 校验"); + } + var base_ = _casServerUrl!.EndsWith('/') ? _casServerUrl : _casServerUrl + "/"; + var endpoint = base_ + "serviceValidate" + + "?ticket=" + Uri.EscapeDataString(ticket ?? "") + + "&service=" + Uri.EscapeDataString(service ?? ""); + try + { + using var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromMilliseconds(_timeoutMs) }; + using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(_timeoutMs) }; + using var resp = await http.GetAsync(endpoint, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode || body == null) + { + return null; + } + return ExtractUser(body); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException) + { + throw new AuthException("CAS serviceValidate 请求失败: " + e.Message, e); + } + } + + /// + /// 从 CAS 响应 XML 提取用户名(公开以便对拍测试)。 + /// 成功响应形如 <cas:serviceResponse><cas:authenticationSuccess><cas:user>NAME</cas:user>... + /// + public static string? ExtractUser(string? xml) + { + if (string.IsNullOrEmpty(xml)) + { + return null; + } + var start = xml.IndexOf("", StringComparison.Ordinal); + if (start < 0) + { + start = xml.IndexOf("', start); + var end = xml.IndexOf("", tagEnd, StringComparison.Ordinal); + if (tagEnd < 0 || end < 0) + { + return null; + } + return xml.Substring(tagEnd + 1, end - tagEnd - 1).Trim(); + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/GlobalUsings.cs new file mode 100644 index 0000000..71d80c5 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/GlobalUsings.cs @@ -0,0 +1,7 @@ +// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛) +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/SessionTicketDataFormat.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/SessionTicketDataFormat.cs new file mode 100644 index 0000000..c31733a --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/SessionTicketDataFormat.cs @@ -0,0 +1,105 @@ +using System.Collections.Concurrent; + +using Microsoft.AspNetCore.Authentication; + +namespace Yh.Platform.Auth; + +/// +/// 会话票据数据格式(自定义 ): +/// 登录票据(AuthenticationTicket)保存在服务端内存,Cookie 只写 32 位 key(Guid N 格式), +/// 实现「Cookie 短值 + 服务端可控过期」。 +/// +/// 为什么不用框架 SessionStore:.NET 5 的 CookieAuthenticationHandler 即便配置了 SessionStore, +/// cookie 值仍是 TicketDataFormat.Protect(精简票据) 的加密长串(CfDJ8…),无法满足"Cookie 32 位"。 +/// 直接自定义 TicketDataFormat:Protect 落库返回 32 位 key,Unprotect 查表并校验过期。 +/// +/// +/// 语义: +/// +/// 关闭浏览器失效:AuthenticationProperties.IsPersistent=false(会话 Cookie,无持久化头); +/// 固定过期:服务端票据按 Properties.ExpiresUtc(由 ExpireTimeSpan 决定)过期,Unprotect 过期返回 null → 未认证; +/// 显式退出立即失效: 删除指定 key(由 Cookie 注册的 OnSigningOut 事件触发,退出不等过期); +/// 安全:key 为 128 位随机熵(Guid),不可枚举猜测;查表失败/过期即无效。 +/// +/// +/// +public class SessionTicketDataFormat : ISecureDataFormat +{ + private readonly ConcurrentDictionary _tickets = new(StringComparer.Ordinal); + private long _lastCleanupUtc; + + public string Protect(AuthenticationTicket data) => Protect(data, null); + + public string Protect(AuthenticationTicket data, string? purpose) + { + var key = Guid.NewGuid().ToString("N"); // 32 个十六进制字符 + _tickets[key] = data; + return key; + } + +#if NET8_0_OR_GREATER + public AuthenticationTicket Unprotect(string? protectedText) => Unprotect(protectedText, null); +#else + public AuthenticationTicket Unprotect(string protectedText) => Unprotect(protectedText, null); +#endif + +#if NET8_0_OR_GREATER + public AuthenticationTicket Unprotect(string? protectedText, string? purpose) +#else + public AuthenticationTicket Unprotect(string protectedText, string? purpose) +#endif + { + if (protectedText is null || !_tickets.TryGetValue(protectedText, out var ticket)) + { + CleanupExpiredIfDue(); + return null!; + } + + // 服务端票据过期:视为无效并清理(配合 SlidingExpiration=false 实现固定 1 分钟过期) + if (ticket.Properties.ExpiresUtc.HasValue && ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow) + { + _tickets.TryRemove(protectedText, out _); + return null!; + } + + return ticket; + } + + /// + /// 吊销指定票据:显式退出(SignOut,如 /api/Logout)时服务端票据立即失效,无需等待过期兜底。 + /// 由 Cookie 注册处的 OnSigningOut 事件从请求 Cookie 读取 32 位 key 后调用。 + /// + public void Revoke(string? key) + { + if (!string.IsNullOrEmpty(key)) + { + _tickets.TryRemove(key, out _); + } + CleanupExpiredIfDue(); + } + + /// + /// 分钟级摊还清理过期票据,防内存膨胀:Unprotect/Revoke 路径触发,最多每分钟一次全量扫描, + /// 并发下仅一个线程进入(Interlocked.CompareExchange 门闩)。 + /// + private void CleanupExpiredIfDue() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var last = Volatile.Read(ref _lastCleanupUtc); + if (now - last < 60) + { + return; + } + if (Interlocked.CompareExchange(ref _lastCleanupUtc, now, last) != last) + { + return; + } + foreach (var kvp in _tickets) + { + if (kvp.Value.Properties.ExpiresUtc is { } exp && exp < DateTimeOffset.UtcNow) + { + _tickets.TryRemove(kvp.Key, out _); + } + } + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/UserContextMiddleware.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/UserContextMiddleware.cs new file mode 100644 index 0000000..1f575e6 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/UserContextMiddleware.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Yh.Platform.OpenApi; + +namespace Yh.Platform.Auth; + +/// +/// 用户上下文中间件:认证成功后按登录名换取 并写入 ; +/// 请求结束(含异常)保证清空,防止上下文跨请求串扰。 +/// 注意: 为 Scoped 依赖,必须走 InvokeAsync 方法注入(每请求解析); +/// 若放构造函数会被 UseMiddleware 启动时从根容器激活,抛出 +/// "Cannot resolve scoped service ... from root provider"(ASP.NET Core 中间件约束)。 +/// +public class UserContextMiddleware +{ + private readonly RequestDelegate _next; + + public UserContextMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IUserDetailsProvider userDetailsProvider) + { + try + { + if (context.User.Identity?.IsAuthenticated == true) + { + var loginName = context.User.FindFirst("loginName")?.Value + ?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value; + var user = await userDetailsProvider.GetByLoginNameAsync(loginName ?? "", context.RequestAborted).ConfigureAwait(false); + if (user != null) + { + BoaUserContext.Set(user); + UserContextHolder.Set(new LoginUser(user.Id?.ToString()) + { + LoginName = loginName, + DisplayName = user.Name, + }); + } + } + await _next(context).ConfigureAwait(false); + } + finally + { + BoaUserContext.Clear(); + UserContextHolder.Clear(); + } + } +} + +/// 用户上下文中间件注册扩展。 +public static class UserContextMiddlewareExtensions +{ + public static IApplicationBuilder UseYhUserContext(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/UserDetailsProvider.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/UserDetailsProvider.cs new file mode 100644 index 0000000..17cd442 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/UserDetailsProvider.cs @@ -0,0 +1,52 @@ +using Yh.Platform.Abstractions; +using Yh.Platform.OpenApi; + +namespace Yh.Platform.Auth; + +/// 认证链路异常(对齐 Java AuthException)。 +public class AuthException : Exception +{ + public AuthException(string message) : base(message) + { + } + + public AuthException(string message, Exception? innerException) : base(message, innerException) + { + } +} + +/// +/// 用户明细 SPI(对齐 Java UserDetailsProvider):认证链路解析出登录名后,由本接口换取 。 +/// 默认实现 走 IOpenApiUserClient;业务侧可替换为本地查询。 +/// +public interface IUserDetailsProvider +{ + Task GetByLoginNameAsync(string loginName, CancellationToken ct = default); +} + +/// 默认实现:调 UserBase /oapi/user/byLoginName。 +public class OpenApiUserDetailsProvider : IUserDetailsProvider +{ + private readonly IOpenApiUserClient _userClient; + + public OpenApiUserDetailsProvider(IOpenApiUserClient userClient) + { + _userClient = userClient; + } + + public async Task GetByLoginNameAsync(string loginName, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(loginName)) + { + return null; + } + try + { + return await _userClient.GetUserByLoginNameAsync(loginName, ct).ConfigureAwait(false); + } + catch (DException) + { + return null; // 用户不存在(code=4/5 信封)视同未找到,交由认证链路按未认证处理 + } + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/Yh.Platform.Auth.csproj b/BasePlatformDotnet/src/Yh.Platform.Auth/Yh.Platform.Auth.csproj new file mode 100644 index 0000000..1c97900 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/Yh.Platform.Auth.csproj @@ -0,0 +1,18 @@ + + + + Yh.Platform.Auth + yunhai 平台认证与用户上下文(对应 Java yh-auth-starter):CAS SSO 认证 handler、BoaUserContext、IUserDetailsProvider、AddYhAuth/AddYhPlatform 聚合入口 + CAS 单点登录 + BoaUser 请求上下文(AsyncLocal),替换 AspNetCore.Security.CAS 本地 dll + + + + + + + + + + + + diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/YhAuthOptions.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/YhAuthOptions.cs new file mode 100644 index 0000000..72ff8bb --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.Auth/YhAuthOptions.cs @@ -0,0 +1,50 @@ +using System.Net; + +using Microsoft.AspNetCore.Authentication; + +namespace Yh.Platform.Auth; + +/// +/// 认证方案名常量。 +/// +public static class YhAuthDefaults +{ + /// Cookie 认证方案(登录态载体,替代 GrowthHealth 既有 Cookie scheme) + public const string CookieScheme = "YhCookie"; + + /// CAS 认证方案(challenge 时重定向 CAS 登录页) + public const string CasScheme = "YhCas"; +} + +/// +/// CAS 认证配置(对齐 GrowthHealth AddCasSso() 与 Java yh.sso.*)。 +/// +public class CasAuthenticationOptions : AuthenticationSchemeOptions +{ + /// CAS 服务端地址前缀,如 https://sso.example.com/cas + public string? CasServerUrlPrefix { get; set; } + + /// 本应用根地址(用于拼 service 回调地址),为空时从当前请求推断 + public string? AppBaseUrl { get; set; } + + /// ticket 回调路径 + public string SignInCallbackPath { get; set; } = "/signin-cas"; + + /// ticket 校验超时(毫秒),对齐 Java 轻量校验器 5 秒 + public int TicketValidateTimeoutMs { get; set; } = 5000; +} + +/// 通用鉴权配置(对齐 Java yh.auth.*)。 +public class YhAuthOptions +{ + public const string SectionName = "yh:auth"; + + /// 是否启用(默认 true) + public bool Enabled { get; set; } = true; + + /// 登录态有效期(分钟),默认 30 分钟(固定过期,不滑动续期) + public int CookieExpireMinutes { get; set; } = 30; + + /// Cookie 名 + public string CookieName { get; set; } = ".yh.auth"; +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/BaseOpenApi.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/BaseOpenApi.cs new file mode 100644 index 0000000..bb35d92 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/BaseOpenApi.cs @@ -0,0 +1,55 @@ +using Yh.Platform.AppAuth; +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// +/// 实例形态门面:复刻旧 BschOpenApi 的 new BaseOpenApi(host, appKey, appSecret) 构造习惯, +/// GrowthHealth 等消费方迁移时改 using 即可。行为与静态门面 一致。 +/// +public class BaseOpenApi +{ + public IOpenApiUserClient User { get; } + + public IOpenApiGroupClient Group { get; } + + public IOpenApiAppClient App { get; } + + /// 旧 SDK 同构构造:host + appKey + appSecret(basePath 默认 /userbase)。 + public BaseOpenApi(string host, string appKey, string appSecret, string? basePath = "/userbase") + : this(new OpenApiOptions + { + BaseHost = host, + BasePath = basePath, + AppKey = appKey, + AppSecret = appSecret, + }) + { + } + + public BaseOpenApi(OpenApiOptions options, HttpMessageHandler? handler = null) + { + var signHandler = new AppSignHandler(options.AppKey ?? "", options.AppSecret ?? "", options.BasePath, handler); + var httpClient = new HttpClient(signHandler) + { + Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000), + }; + User = new OpenApiUserClient(httpClient, options); + Group = new OpenApiGroupClient(httpClient, options); + App = new OpenApiAppClient(httpClient, options); + } + + // ============================ 旧 BaseOpenApi 高频方法直通 ============================ + + /// 按登录账号查询用户明细(旧 SDK 直通方法)。 + public Task GetUserInfoByLoginNameAsync(string loginName, CancellationToken ct = default) + => User.GetUserInfoAsync(loginName, ct); + + /// 按用户主键查询用户信息(旧 SDK 直通方法)。 + public Task GetUserInfoByIdAsync(int userId, CancellationToken ct = default) + => User.GetUserInfoAsync(userId, ct); + + /// 按用户查询角色身份列表(旧 SDK 直通方法)。 + public Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) + => User.GetUserRoleListAsync(userId, ct); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/GlobalUsings.cs new file mode 100644 index 0000000..850e671 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/GlobalUsings.cs @@ -0,0 +1,8 @@ +// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛) +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Net.Http; +global using System.Text.Json; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiGroupClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiGroupClient.cs new file mode 100644 index 0000000..69ab878 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiGroupClient.cs @@ -0,0 +1,45 @@ +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// +/// 组织/组域开放接口客户端契约(对齐 UserBase /oapi/group/**,镜像 Java OpenApiGroupClient)。 +/// +public interface IOpenApiGroupClient +{ + /// 获取整个平台的组树结构(GET /oapi/group/tree) + Task GetGroupTreeAsync(CancellationToken ct = default); + + /// 按组ID查询单个组;withSub=true 时递归返回整棵子树(GET /oapi/group/byId) + Task GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default); + + /// 按多个组ID(逗号/空白分隔)批量查询(GET /oapi/group/list) + Task> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default); + + /// 新增组(POST /oapi/group/add) + Task AddGroupAsync(SysGroupRequest request, CancellationToken ct = default); + + /// 编辑组(路径 id 权威;body 不含 id):POST /oapi/group/edit/{id} + Task EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default); + + /// 删除组(组下存在子组或用户时服务端拒绝):POST /oapi/group/delete/{id}/{schoolId} + Task DeleteGroupAsync(int id, int schoolId, CancellationToken ct = default); + + /// 批量新增子组(POST /oapi/group/batchAdd) + Task> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default); + + /// 移动组(学校限定):POST /oapi/group/move/{schoolId},body 仅含 moveId/pid + Task MoveGroupAsync(int schoolId, SysGroupMoveRequest request, CancellationToken ct = default); +} + +/// +/// 应用域开放接口客户端契约(对齐 UserBase /oapi/app/**,镜像 Java OpenApiAppClient)。 +/// +public interface IOpenApiAppClient +{ + /// 按应用ID查询应用信息(GET /oapi/app/{appId};无 byId 端点时返回 404 错误信封) + Task GetAppByIdAsync(string appId, CancellationToken ct = default); + + /// 按用户 + 客户端类型查询有权访问的应用列表(按应用类型分组):GET /oapi/app/list + Task> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiUserClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiUserClient.cs new file mode 100644 index 0000000..3e1fa0b --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiUserClient.cs @@ -0,0 +1,76 @@ +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// +/// 用户域开放接口客户端契约(对齐 UserBase /oapi/user/**,镜像 Java OpenApiUserClient)。 +/// 基址取自 。 +/// +public interface IOpenApiUserClient +{ + /// 按用户主键查询(GET /oapi/user/byId?id=) + Task GetUserByIdAsync(string userId, CancellationToken ct = default); + + /// 按登录账号查询用户明细(GET /oapi/user/byLoginName?loginName=) + Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default); + + /// 按手机号查询用户明细(GET /oapi/user/byPhone?phone=) + Task GetUserByPhoneAsync(string phone, CancellationToken ct = default); + + /// 按用户主键(int)查询用户信息(新接口,与旧 byId 并存) + Task GetUserInfoAsync(int userId, CancellationToken ct = default); + + /// 按登录账号查询用户信息(新接口,与旧 byLoginName 并存) + Task GetUserInfoAsync(string loginName, CancellationToken ct = default); + + /// 按用户查询模块列表(GET /oapi/user/modules?userId=&schId=) + Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default); + + /// 新增用户(POST /oapi/user/add) + Task AddUserAsync(SysUserRequest request, CancellationToken ct = default); + + /// 编辑用户(POST /oapi/user/edit/{id};路径 id 权威,自动写入 request.Id) + Task EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default); + + /// 删除用户(按组解绑):POST /oapi/user/delete/{id}/{groupId}/{schoolId} + Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default); + + /// 批量删除用户-组关系(POST /oapi/user/deleteUsers,请求体为数组) + Task DeleteUsersAsync(IReadOnlyList items, CancellationToken ct = default); + + /// 批量新增用户,整批回滚(POST /oapi/user/batchAdd,请求体为裸数组) + Task> BatchAddUsersAsync(IReadOnlyList requests, CancellationToken ct = default); + + /// 移动用户到指定组(同校内,覆盖式替换主组):POST /oapi/user/moveToGroup + Task MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default); + + /// 按用户查询角色身份列表(GET /oapi/user/roles?userId=) + Task> GetUserRoleListAsync(int userId, CancellationToken ct = default); + + /// 查询用户当前激活身份(未切换时兜底 is_main 主身份):GET /oapi/identity/current?userId= + Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default); + + /// 查询用户可切换的身份列表(多角色迁移配套):GET /oapi/identity/available?userId= + Task> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default); + + /// 切换身份(调用方须在 UserBase 侧白名单内):POST /oapi/identity/switch + Task SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default); + + /// 修改用户密码(先校验旧密码):POST /oapi/user/password + Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default); + + /// 设置用户在指定组上的状态(转出/恢复):POST /oapi/user/groupState + Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default); + + /// 查询用户在指定组上的状态:0=正常,1=转出(GET /oapi/user/groupState) + Task GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default); + + /// 跨校转入:POST /oapi/user/transferToSchool + Task TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default); + + /// 设置单个用户状态(启用/禁用):POST /oapi/user/changeStatus + Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default); + + /// 批量设置用户状态(任一 userId 无效则整体拒绝):POST /oapi/user/batchChangeStatus + Task BatchChangeUserStatusAsync(IReadOnlyList userIds, bool disabled, CancellationToken ct = default); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiClientBase.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiClientBase.cs new file mode 100644 index 0000000..ad089f6 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiClientBase.cs @@ -0,0 +1,176 @@ +using System.Text; +using System.Text.Json; +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// +/// 开放接口客户端基类(对齐 Java AbstractOpenApiClient): +/// 统一持有 HttpClient / 配置,沉淀请求构造与"信封感知"的响应解析单点。 +/// 响应解析约定(UserBase oapi):成功为裸数据对象/数组;业务/校验失败为 {code:4/5, msg, data:null} 但 HTTP 仍为 200。 +/// +public abstract class OpenApiClientBase +{ + /// 平台统一开放接口前缀(与 UserBase Config.OPEN_API_PATH 严格一致) + protected const string Prefix = "/oapi"; + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + protected HttpClient HttpClient { get; } + + protected OpenApiOptions Options { get; } + + protected OpenApiClientBase(HttpClient httpClient, OpenApiOptions options) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + // ====================== 请求构造 ====================== + + protected static Dictionary Params(params (string key, object? value)[] kv) + { + var m = new Dictionary(StringComparer.Ordinal); + if (kv == null) + { + return m; + } + foreach (var (key, value) in kv) + { + if (key != null && value != null) + { + m[key] = value.ToString(); + } + } + return m; + } + + private static string BuildQuery(Dictionary? query) + { + if (query == null || query.Count == 0) + { + return ""; + } + var sb = new StringBuilder(); + foreach (var kv in query) + { + if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) + { + continue; + } + sb.Append(sb.Length == 0 ? '?' : '&') + .Append(Uri.EscapeDataString(kv.Key)) + .Append('=') + .Append(Uri.EscapeDataString(kv.Value)); + } + return sb.ToString(); + } + + // ====================== 信封感知解析 ====================== + + /// 发送请求并解析:返回信封感知后的裸数据节点(可能为 null)。 + protected async Task SendAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) + { + var url = Options.ResolveBaseUrl() + path + BuildQuery(query); + using var request = new HttpRequestMessage(method, url); + if (body != null) + { + request.Content = new StringContent(JsonSerializer.Serialize(body, JsonOpts), Encoding.UTF8, "application/json"); + } + using var response = await HttpClient.SendAsync(request, ct).ConfigureAwait(false); + var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new OpenApiException("OpenAPI HTTP " + (int)response.StatusCode + ": " + text, (int)response.StatusCode, text); + } + try + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "null" : text); + return Unwrap(doc.RootElement); + } + catch (JsonException e) + { + throw new OpenApiException("OpenAPI 响应解析失败: " + e.Message, (int)response.StatusCode, text); + } + } + + /// 裸数据反序列化为 ;含 code 且非 0 时抛 。 + protected async Task ResolveAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) + { + var data = await SendAsync(method, path, query, body, ct).ConfigureAwait(false); + if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return default; + } + try + { + return JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts); + } + catch (JsonException e) + { + throw new OpenApiException("OpenAPI 反序列化失败: " + typeof(T).Name, e); + } + } + + /// 裸数组/单对象反序列化为列表;含 code 且非 0 时抛 。 + protected async Task> ResolveListAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) + { + var data = await SendAsync(method, path, query, body, ct).ConfigureAwait(false); + var result = new List(); + if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return result; + } + try + { + if (data.Value.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.Value.EnumerateArray()) + { + if (item.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined)) + { + result.Add(JsonSerializer.Deserialize(item.GetRawText(), JsonOpts)!); + } + } + } + else + { + result.Add(JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts)!); + } + } + catch (JsonException e) + { + throw new OpenApiException("OpenAPI 列表反序列化失败: " + typeof(T).Name, e); + } + return result; + } + + /// 信封感知 void 解析:含 code 且非 0 时抛 ,否则忽略 data。 + protected async Task ResolveVoidAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) + { + await SendAsync(method, path, query, body, ct).ConfigureAwait(false); + } + + /// + /// 信封感知:对象含 code 字段且非 0 → 抛 (原样携带服务端 code/msg/data); + /// code==0 → 返回 data 节点(可能为 null);非信封 → 原样返回。 + /// + private static JsonElement? Unwrap(JsonElement root) + { + if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("code", out var codeEl) && codeEl.ValueKind == JsonValueKind.Number && codeEl.TryGetInt32(out var code)) + { + if (code != 0) + { + var msg = root.TryGetProperty("msg", out var msgEl) && msgEl.ValueKind == JsonValueKind.String ? msgEl.GetString() : null; + msg = string.IsNullOrEmpty(msg) ? "OpenAPI 业务错误" : msg; + JsonElement? data = root.TryGetProperty("data", out var dataEl) && dataEl.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) ? dataEl.Clone() : null; + object? dataObj = data?.ValueKind == JsonValueKind.String || data?.ValueKind == JsonValueKind.Number || data?.ValueKind == JsonValueKind.True || data?.ValueKind == JsonValueKind.False + ? data.Value.ToString() + : data; + throw new DException(new OpenApiErrorCode(code, msg!), msg, dataObj); + } + return root.TryGetProperty("data", out var dataOk) && dataOk.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) ? dataOk.Clone() : null; + } + return root.Clone(); + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiErrorCode.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiErrorCode.cs new file mode 100644 index 0000000..b4333de --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiErrorCode.cs @@ -0,0 +1,20 @@ +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// +/// 业务错误码载体(对齐 Java OpenApiErrorCode):原样保存服务端返回的 code 与 msg, +/// 由业务失败信封解析后携带给 的调用方。 +/// +public class OpenApiErrorCode : IErrorCode +{ + public int Code { get; } + + public string Msg { get; } + + public OpenApiErrorCode(int code, string msg) + { + Code = code; + Msg = msg; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiException.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiException.cs new file mode 100644 index 0000000..c2edb31 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiException.cs @@ -0,0 +1,31 @@ +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// +/// HTTP 层异常(对齐 Java OpenApiException):非 2xx 响应或反序列化失败时抛出,保留 HTTP 状态码与响应体。 +/// 业务失败(HTTP 200 + code≠0 信封)抛 。 +/// +public class OpenApiException : Exception +{ + public int? HttpStatus { get; } + + public string? ResponseBody { get; } + + public OpenApiException(string message) + : base(message) + { + } + + public OpenApiException(string message, Exception? rawException) + : base(message, rawException) + { + } + + public OpenApiException(string message, int? httpStatus, string? responseBody) + : base(message) + { + HttpStatus = httpStatus; + ResponseBody = responseBody; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiGroupClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiGroupClient.cs new file mode 100644 index 0000000..3fc0d41 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiGroupClient.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// 的 HTTP 实现(镜像 Java DefaultOpenApiGroupClient)。 +public class OpenApiGroupClient : OpenApiClientBase, IOpenApiGroupClient +{ + public OpenApiGroupClient(HttpClient httpClient, OpenApiOptions options) + : base(httpClient, options) + { + } + + public Task GetGroupTreeAsync(CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/group/tree", ct: ct); + + public Task GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/group/byId", + Params(("groupId", groupId), ("withSub", withSub)), ct: ct); + + public Task> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Prefix + "/group/list", + Params(("ids", ids), ("withSub", withSub)), ct: ct); + + public Task AddGroupAsync(SysGroupRequest request, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Post, Prefix + "/group/add", body: request, ct: ct); + + public Task EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Post, Prefix + "/group/edit/" + id, body: request, ct: ct); + + public Task DeleteGroupAsync(int id, int schoolId, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/group/delete/" + id + "/" + schoolId, ct: ct); + + public Task> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Post, Prefix + "/group/batchAdd", body: request, ct: ct); + + public Task MoveGroupAsync(int schoolId, SysGroupMoveRequest request, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/group/move/" + schoolId, body: request, ct: ct); +} + +/// 的 HTTP 实现(镜像 Java DefaultOpenApiAppClient)。 +public class OpenApiAppClient : OpenApiClientBase, IOpenApiAppClient +{ + public OpenApiAppClient(HttpClient httpClient, OpenApiOptions options) + : base(httpClient, options) + { + } + + public Task GetAppByIdAsync(string appId, CancellationToken ct = default) + => SendAsync(HttpMethod.Get, Prefix + "/app/" + Uri.EscapeDataString(appId), ct: ct); + + public Task> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Prefix + "/app/list", + Params(("userId", userId), ("clientType", (int)clientType)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiOptions.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiOptions.cs new file mode 100644 index 0000000..5b54710 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiOptions.cs @@ -0,0 +1,61 @@ +namespace Yh.Platform.OpenApi; + +/// +/// 基础平台开放接口(UserBase)客户端配置,配置节 yh:open(对齐 Java yh.open)。 +/// +public class OpenApiOptions +{ + public const string SectionName = "yh:open"; + + /// 是否启用(默认 true) + public bool Enabled { get; set; } = true; + + /// UserBase 基础地址,例如 http://base.bestsch.com + public string? BaseHost { get; set; } + + /// UserBase 基础路径(默认 /userbase;代理改挂其他路径时配置兜底) + public string? BasePath { get; set; } = "/userbase"; + + /// 连接超时(毫秒),对齐 Java 默认 1500ms + public long ConnectTimeoutMs { get; set; } = 1500; + + /// 读超时(毫秒),对齐 Java 默认 5000ms + public long ReadTimeoutMs { get; set; } = 5000; + + /// 写超时(毫秒),对齐 Java 默认 5000ms + public long WriteTimeoutMs { get; set; } = 5000; + + /// 调用方在 UserBase 注册的应用标识(用于签名) + public string? AppKey { get; set; } + + /// 调用方在 UserBase 注册的密钥(用于签名) + public string? AppSecret { get; set; } + + /// 拼接 baseHost + basePath 为统一开放接口基址。 + public string ResolveBaseUrl() + { + var baseHost = BaseHost?.Trim() ?? ""; + if (baseHost.EndsWith('/')) + { + baseHost = baseHost.TrimEnd('/'); + } + return baseHost + (NormalizeBasePath(BasePath) ?? ""); + } + + /// + /// 归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null(挂载在根路径)。 + /// + public static string? NormalizeBasePath(string? path) + { + if (path == null) + { + return null; + } + var trimmed = path.Trim().TrimEnd('/'); + if (trimmed.Length == 0) + { + return null; + } + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiServiceCollectionExtensions.cs new file mode 100644 index 0000000..10accb5 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiServiceCollectionExtensions.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Yh.Platform.AppAuth; + +namespace Yh.Platform.OpenApi; + +/// 静态门面自动装配宿主服务(等价 Java starter 启动时 Set*Client 注入)。 +internal sealed class YschFacadeWiring : IHostedService +{ + private readonly IServiceProvider _provider; + + public YschFacadeWiring(IServiceProvider provider) + { + _provider = provider; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + YschBaseOpenApi.SetUserClient(_provider.GetRequiredService()); + YschBaseOpenApi.SetGroupClient(_provider.GetRequiredService()); + YschBaseOpenApi.SetAppClient(_provider.GetRequiredService()); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +public static class OpenApiServiceCollectionExtensions +{ + public const string HttpClientName = "yh-openapi"; + + /// + /// 注册 /oapi 客户端(对齐 Java OpenApiAutoConfiguration): + /// IHttpClientFactory + typed client + 签名 DelegatingHandler,并在宿主启动时装配静态门面。 + /// + public static IServiceCollection AddYhOpenApi(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(OpenApiOptions.SectionName)) + .PostConfigure(o => o.BasePath = OpenApiOptions.NormalizeBasePath(o.BasePath)); + + services.AddHttpClient(HttpClientName) + .ConfigureHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000); + }) + .ConfigurePrimaryHttpMessageHandler(sp => + { + var options = sp.GetRequiredService>().Value; + return new SocketsHttpHandler + { + ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500), + }; + }) + .AddHttpMessageHandler(sp => + { + var options = sp.GetRequiredService>().Value; + return new AppSignHandler(options.AppKey ?? "", options.AppSecret ?? "", options.BasePath); + }); + + services.AddSingleton(sp => + { + var factory = sp.GetRequiredService(); + return new OpenApiUserClient(factory.CreateClient(HttpClientName), sp.GetRequiredService>().Value); + }); + services.AddSingleton(sp => + { + var factory = sp.GetRequiredService(); + return new OpenApiGroupClient(factory.CreateClient(HttpClientName), sp.GetRequiredService>().Value); + }); + services.AddSingleton(sp => + { + var factory = sp.GetRequiredService(); + return new OpenApiAppClient(factory.CreateClient(HttpClientName), sp.GetRequiredService>().Value); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddHostedService(); + return services; + } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiUserClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiUserClient.cs new file mode 100644 index 0000000..e067867 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiUserClient.cs @@ -0,0 +1,87 @@ +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// 的 HTTP 实现(镜像 Java DefaultOpenApiUserClient)。 +public class OpenApiUserClient : OpenApiClientBase, IOpenApiUserClient +{ + public OpenApiUserClient(HttpClient httpClient, OpenApiOptions options) + : base(httpClient, options) + { + } + + public Task GetUserByIdAsync(string userId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct); + + public Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/user/byLoginName", Params(("loginName", loginName)), ct: ct); + + public Task GetUserByPhoneAsync(string phone, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/user/byPhone", Params(("phone", phone)), ct: ct); + + public Task GetUserInfoAsync(int userId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct); + + public Task GetUserInfoAsync(string loginName, CancellationToken ct = default) + => GetUserByLoginNameAsync(loginName, ct); + + public Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Prefix + "/user/modules", + Params(("userId", userId), ("schId", schoolId)), ct: ct); + + public Task AddUserAsync(SysUserRequest request, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Post, Prefix + "/user/add", body: request, ct: ct); + + public Task EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default) + { + request.Id = id; + return ResolveAsync(HttpMethod.Post, Prefix + "/user/edit/" + id, body: request, ct: ct); + } + + public Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/delete/" + id + "/" + groupId + "/" + schoolId, ct: ct); + + public Task DeleteUsersAsync(IReadOnlyList items, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/deleteUsers", body: items, ct: ct); + + public Task> BatchAddUsersAsync(IReadOnlyList requests, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Post, Prefix + "/user/batchAdd", body: requests, ct: ct); + + public Task MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Post, Prefix + "/user/moveToGroup", body: request, ct: ct); + + public Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Prefix + "/user/roles", Params(("userId", userId)), ct: ct); + + public Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/identity/current", Params(("userId", userId)), ct: ct); + + public Task> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Prefix + "/identity/available", Params(("userId", userId)), ct: ct); + + public Task SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Post, Prefix + "/identity/switch", + body: new Dictionary { ["userId"] = userId, ["roleId"] = roleId, ["schoolId"] = schoolId }, ct: ct); + + public Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/password", + body: new Dictionary { ["userId"] = userId, ["oldPassword"] = oldPassword, ["newPassword"] = newPassword }, ct: ct); + + public Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/groupState", body: request, ct: ct); + + public Task GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Prefix + "/user/groupState", + Params(("userId", userId), ("groupId", groupId), ("schoolId", schoolId)), ct: ct); + + public Task TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Post, Prefix + "/user/transferToSchool", body: request, ct: ct); + + public Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/changeStatus", + body: new Dictionary { ["userId"] = userId, ["disabled"] = disabled }, ct: ct); + + public Task BatchChangeUserStatusAsync(IReadOnlyList userIds, bool disabled, CancellationToken ct = default) + => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/batchChangeStatus", + body: new Dictionary { ["userIds"] = userIds, ["disabled"] = disabled }, ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysGroupRequests.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysGroupRequests.cs new file mode 100644 index 0000000..b5d858e --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysGroupRequests.cs @@ -0,0 +1,45 @@ +namespace Yh.Platform.OpenApi; + +/// 新增/编辑组请求(对齐 UserBase SysGroupRequest)。编辑以路径 id 为准;Id 字段对齐服务端契约键(快照对拍 2026-08-28)。 +public class SysGroupRequest +{ + /// 组 ID(新增忽略;编辑时以路径 id 为权威,此字段仅保持契约键完整) + public int? Id { get; set; } + + public string? Name { get; set; } + + public int? ParentId { get; set; } + + public int? Type { get; set; } + + public int? Sort { get; set; } +} + +/// 批量新增子组请求:在 Pid 下生成一批同类型子组。 +public class SysGroupBatchRequest +{ + /// 父组 ID(挂载点,必须存在) + public int? Pid { get; set; } + + /// 应用于本次生成的所有子组的类型 + public int? Type { get; set; } + + /// 子组列表,每项仅需 Name + public List? Items { get; set; } + + /// 单个子组项,仅需 Name + public class BatchItem + { + public string? Name { get; set; } + } +} + +/// 移动组请求:MoveId 移动到 Pid 下,两者都必须落在 schoolId 子树内;schoolId 走路径参数。 +public class SysGroupMoveRequest +{ + /// 待移动节点ID + public int? MoveId { get; set; } + + /// 目标父节点ID(移动后的挂载点) + public int? Pid { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysUserRequests.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysUserRequests.cs new file mode 100644 index 0000000..0f8d128 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysUserRequests.cs @@ -0,0 +1,111 @@ +namespace Yh.Platform.OpenApi; + +/// 新增/编辑用户请求(对齐 UserBase SysUserRequest)。编辑时 Id 必填且与路径 id 一致。 +public class SysUserRequest +{ + /// 用户 ID(编辑必填,新增忽略) + public int? Id { get; set; } + + /// 初始归属组 ID;新增必填,编辑不校验 + public int? GroupId { get; set; } + + /// 登录账号(唯一) + public string? Account { get; set; } + + /// 登录密码(明文,服务端 BCrypt 加密存储) + public string? Password { get; set; } + + /// 确认密码,需与 Password 一致 + public string? ConfirmPassword { get; set; } + + /// 姓名 + public string? RealName { get; set; } + + /// 手机号 + public string? Phone { get; set; } + + /// 身份证号 + public string? Idcard { get; set; } + + /// 邮箱 + public string? Email { get; set; } + + /// 性别 + public int? Gender { get; set; } + + /// 生日(YYYY-MM-DD) + public string? Birthday { get; set; } + + /// 头像 URL + public string? ProfilePicture { get; set; } + + /// 归属学校组 ID(服务端 SysUserRequest.roleIds/schoolId 契约;与 GroupId 二选一维度,见 UserBase 文档) + public int? SchoolId { get; set; } + + /// 角色 ID 列表(契约键为 roleIds,对齐 UserBase 导出快照 2026-08-28 对拍) + public List? RoleIds { get; set; } + + /// 禁用状态(0=正常,1=禁用;不传默认启用) + public int? Disabled { get; set; } + + /// 是否过期;不传默认 false + public bool? Expired { get; set; } +} + +/// 批量删除用户-组关系单项(对齐 UserBase SysUserGroupDeleteItem)。 +public class SysUserGroupDeleteItem +{ + /// 待解绑用户 ID + public int? UserId { get; set; } + + /// 待解绑的组 ID(必须属于 schoolId 子树,且不能等于 schoolId) + public int? GroupId { get; set; } + + /// 学校组 ID:限定该条解绑的学校范围 + public int? SchoolId { get; set; } +} + +/// 移动用户到指定组(同校内,覆盖式替换主组)请求。 +public class SysUserMoveRequest +{ + /// 待移动用户 ID + public int? UserId { get; set; } + + /// 目标组 ID(移动后的主归属组) + public int? GroupId { get; set; } + + /// 学校组 ID:限定移动范围,仅允许同一学校内移动 + public int? SchoolId { get; set; } +} + +/// 设置用户组状态请求:state=1 转出、0=恢复正常;groupId 必须属于 schoolId 子树。 +public class SysUserGroupStateRequest +{ + /// 用户 ID + public int? UserId { get; set; } + + /// 组 ID(用户所在的具体组) + public int? GroupId { get; set; } + + /// 学校组 ID:限定操作范围 + public int? SchoolId { get; set; } + + /// 目标状态:0=恢复正常,1=转出 + public int? State { get; set; } +} + +/// 跨校转入请求:前置为源学校子树内存在至少一条「转出」状态组关系。 +public class SysUserTransferSchoolRequest +{ + /// 待迁移用户 ID + public int? UserId { get; set; } + + /// 原学校组 ID(源学校) + public int? FromSchoolId { get; set; } + + /// 目标学校组 ID + public int? SchoolId { get; set; } + + /// 目标组 ID(迁移后的主归属组) + public int? GroupId { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/Yh.Platform.OpenApi.csproj b/BasePlatformDotnet/src/Yh.Platform.OpenApi/Yh.Platform.OpenApi.csproj new file mode 100644 index 0000000..737e943 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/Yh.Platform.OpenApi.csproj @@ -0,0 +1,20 @@ + + + + Yh.Platform.OpenApi + yunhai 平台 /oapi 开放接口客户端(对应 Java yh-openapi-client):用户/组/应用三域客户端、信封感知解析、静态门面 YschBaseOpenApi 与实例门面 BaseOpenApi + UserBase /oapi 同构客户端 SDK,签名请求自动附加三头 + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/YschBaseOpenApi.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/YschBaseOpenApi.cs new file mode 100644 index 0000000..72c973f --- /dev/null +++ b/BasePlatformDotnet/src/Yh.Platform.OpenApi/YschBaseOpenApi.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Yh.Platform.Abstractions; + +namespace Yh.Platform.OpenApi; + +/// +/// 基础平台开放接口统一门面(静态入口,对齐 Java YschBaseOpenApi)。 +/// 下游业务只依赖本类;内部按域聚合三客户端,由 +/// 在宿主启动时自动注入(或手动 Set*Client)。 +/// 门面准入原则:仅保留高频读接口;低频写操作请注入 / 。 +/// +public static class YschBaseOpenApi +{ + private static volatile IOpenApiUserClient? _userClient; + private static volatile IOpenApiGroupClient? _groupClient; + private static volatile IOpenApiAppClient? _appClient; + private static readonly object Lock = new(); + + public static void SetUserClient(IOpenApiUserClient? client) { lock (Lock) { _userClient = client; } } + + public static void SetGroupClient(IOpenApiGroupClient? client) { lock (Lock) { _groupClient = client; } } + + public static void SetAppClient(IOpenApiAppClient? client) { lock (Lock) { _appClient = client; } } + + // ============================ 用户域 ============================ + + public static Task GetUserInfoAsync(int userId, CancellationToken ct = default) => User().GetUserInfoAsync(userId, ct); + + public static Task GetUserInfoAsync(string loginName, CancellationToken ct = default) => User().GetUserInfoAsync(loginName, ct); + + public static Task GetUserByIdAsync(string userId, CancellationToken ct = default) => User().GetUserByIdAsync(userId, ct); + + public static Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) => User().GetUserByLoginNameAsync(loginName, ct); + + public static Task GetUserByPhoneAsync(string phone, CancellationToken ct = default) => User().GetUserByPhoneAsync(phone, ct); + + public static Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) => User().GetUserModuleAsync(userId, schoolId, ct); + + public static Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) => User().GetUserRoleListAsync(userId, ct); + + /// 查询用户当前激活身份(未切换时兜底 is_main 主身份;无任何身份返回 null)。 + public static Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default) => User().GetCurrentIdentityAsync(userId, ct); + + // ============================ 组织/组域 ============================ + + public static Task GetGroupTreeAsync(CancellationToken ct = default) => Group().GetGroupTreeAsync(ct); + + public static Task GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupByIdAsync(groupId, withSub, ct); + + public static Task> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupListAsync(ids, withSub, ct); + + // ============================ 应用域 ============================ + + public static Task GetAppByIdAsync(string appId, CancellationToken ct = default) => App().GetAppByIdAsync(appId, ct); + + public static Task> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default) => App().GetUserAppAsync(userId, clientType, ct); + + private static IOpenApiUserClient User() + => _userClient ?? throw new InvalidOperationException( + "YschBaseOpenApi 尚未初始化(UserClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetUserClient(...)。"); + + private static IOpenApiGroupClient Group() + => _groupClient ?? throw new InvalidOperationException( + "YschBaseOpenApi 尚未初始化(GroupClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetGroupClient(...)。"); + + private static IOpenApiAppClient App() + => _appClient ?? throw new InvalidOperationException( + "YschBaseOpenApi 尚未初始化(AppClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetAppClient(...)。"); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/GlobalUsings.cs new file mode 100644 index 0000000..6d8f050 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/GlobalUsings.cs @@ -0,0 +1,12 @@ +// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛) +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Net.Http; +global using System.Text; +global using System.Text.Json; +global using System.Threading; +global using System.Threading.Tasks; + +// Java 侧 Map 兼容行的 .NET 承载类型(net5.0 可用,不依赖 net6+ 的 JsonNode) +global using SchClassRow = System.Collections.Generic.Dictionary; diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/SchInfoVo.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/SchInfoVo.cs new file mode 100644 index 0000000..566a1a1 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/SchInfoVo.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Yh.SchClass.OpenApi.Model; + +/// +/// 学校视图对象(镜像 Java com.yunhai.bschclassmanage.model.vo.oa.SchInfoVO)。 +/// Java 侧以 @JsonProperty 显式指定键名,故此处按原样锚定。 +/// +public class SchInfoVo +{ + /// 学校外部序列号 + [JsonPropertyName("SchSerId")] + public string? SchSerId { get; set; } + + /// 用户中心学校 ID + [JsonPropertyName("UserSchSerId")] + public int? UserSchSerId { get; set; } + + /// 学校名称 + [JsonPropertyName("SchName")] + public string? SchName { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/TeaInfoVo.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/TeaInfoVo.cs new file mode 100644 index 0000000..cacb830 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/TeaInfoVo.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Serialization; + +namespace Yh.SchClass.OpenApi.Model; + +/// +/// 教师信息视图对象(镜像 Java com.yunhai.bschclassmanage.model.oa.TeaInfoVO)。 +/// +/// Java 侧为 Lombok @Data,序列化键名由 Jackson 从 getter 推导;本客户端大小写不敏感反序列化, +/// 故按 Java 字段原名锚定即可双向兼容。 +/// +/// +public class TeaInfoVo +{ + /// 学校外部序列号 + [JsonPropertyName("SchSerID")] public string? SchSerId { get; set; } + + /// 政治面貌 + [JsonPropertyName("Teapolitical")] public string? TeaPolitical { get; set; } + + /// 参加工作日期 + [JsonPropertyName("TeaWorkStart")] public string? TeaWorkStart { get; set; } + + /// 登录账户 + [JsonPropertyName("Account")] public string? Account { get; set; } + + /// 学校序列号(多校场景,当前与 SchSerID 相同) + [JsonPropertyName("SchSerIDs")] public string? SchSerIds { get; set; } + + /// 角色 + [JsonPropertyName("role")] public int? Role { get; set; } + + /// 教育局 + [JsonPropertyName("JiaoYuJu")] public string? JiaoYuJu { get; set; } + + /// 性别(文本) + [JsonPropertyName("TeaSex")] public string? TeaSex { get; set; } + + /// 是否中层以上干部 + [JsonPropertyName("IsLeadingMiddle")] public int? IsLeadingMiddle { get; set; } + + /// 籍贯 + [JsonPropertyName("TeaOriginPlace")] public string? TeaOriginPlace { get; set; } + + /// 头像 + [JsonPropertyName("Photo")] public string? Photo { get; set; } + + /// 身份证号 + [JsonPropertyName("TeaIDCard")] public string? TeaIdCard { get; set; } + + /// 姓名 + [JsonPropertyName("Name")] public string? Name { get; set; } + + /// 分组 ID + [JsonPropertyName("GroupID")] public int? GroupId { get; set; } + + /// 学校名称 + [JsonPropertyName("SchName")] public string? SchName { get; set; } + + /// 从教日期 + [JsonPropertyName("TeaTeachStart")] public string? TeaTeachStart { get; set; } + + /// 教师序列号(teaId 的字符串形式) + [JsonPropertyName("TeaSerID")] public string? TeaSerId { get; set; } + + /// 用户 ID + [JsonPropertyName("UserID")] public int? UserId { get; set; } + + /// 民族 + [JsonPropertyName("TeaNation")] public string? TeaNation { get; set; } + + /// 电话 + [JsonPropertyName("Tel")] public string? Tel { get; set; } + + /// 工作类型 + [JsonPropertyName("TeaWorkType")] public string? TeaWorkType { get; set; } + + /// 教师编号 + [JsonPropertyName("TeaNo")] public int? TeaNo { get; set; } + + /// 出生日期 + [JsonPropertyName("TeaBirth")] public string? TeaBirth { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/UserInfoVo.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/UserInfoVo.cs new file mode 100644 index 0000000..067aa76 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/UserInfoVo.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace Yh.SchClass.OpenApi.Model; + +/// +/// 用户信息视图对象(镜像 Java com.yunhai.bschclassmanage.model.oa.UserInfoVO)。 +/// +/// Java 侧为 Lombok @Data,序列化键名由 Jackson 从 getter 推导(如 getSchSerID() → schSerID); +/// 本客户端使用大小写不敏感反序列化(),故此处按 Java 字段原名锚定即可双向兼容。 +/// +/// +public class UserInfoVo +{ + /// 学校外部序列号 + [JsonPropertyName("SchSerID")] public string? SchSerId { get; set; } + + /// 编号(教师为 sort) + [JsonPropertyName("No")] public int? No { get; set; } + + /// 身份证号 + [JsonPropertyName("IDCard")] public string? IdCard { get; set; } + + /// 用户 ID(教师为 teaId,学生为 stuId) + [JsonPropertyName("UserID")] public int? UserId { get; set; } + + /// 性别 + [JsonPropertyName("sex")] public int? Sex { get; set; } + + /// 用户类型(教师 1 / 学生按 UserRole) + [JsonPropertyName("usertype")] public int? UserType { get; set; } + + /// 序列号(Java 侧恒为 null) + [JsonPropertyName("SerID")] public string? SerId { get; set; } + + /// 头像(Java 侧恒为 null) + [JsonPropertyName("Photo")] public string? Photo { get; set; } + + /// 电话(学生为 null) + [JsonPropertyName("Tel")] public string? Tel { get; set; } + + /// 登录账户 + [JsonPropertyName("Uids")] public string? Uids { get; set; } + + /// 分组 ID + [JsonPropertyName("GroupID")] public int? GroupId { get; set; } + + /// 姓名 + [JsonPropertyName("Name")] public string? Name { get; set; } +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassClientBase.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassClientBase.cs new file mode 100644 index 0000000..571e124 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassClientBase.cs @@ -0,0 +1,217 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务(SchClass / bschclassmanage)开放接口客户端基类。 +/// +/// 与 Yh.Platform.OpenApi.OpenApiClientBase 同构,但刻意保持零项目依赖,便于单独打包引用。 +/// +/// 响应约定(Java 侧 OA 兼容端点):成功时直接返回裸数据(对象 / 数组 / 字符串数组),无统一信封; +/// 部分端点以 HTTP 404 / 204 表示"无数据",以 HTTP 400 表示参数缺失。因此本基类默认把 404 与 204 +/// 归一为 null(列表则为空列表),仅对 400 及其它非 2xx 抛 。 +/// +/// +public abstract class SchClassClientBase +{ + /// 教务开放接口统一前缀(与 Java Config.OAPI 严格一致) + protected const string Prefix = "/oapi"; + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + protected HttpClient HttpClient { get; } + + protected SchClassOpenApiOptions Options { get; } + + protected SchClassClientBase(HttpClient httpClient, SchClassOpenApiOptions options) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + // ====================== 请求构造 ====================== + + /// 构造查询参数;值为 null 的参数会被跳过(对应 Java 侧 required=false)。 + protected static Dictionary Params(params (string key, object? value)[] kv) + { + var map = new Dictionary(StringComparer.Ordinal); + if (kv == null) + { + return map; + } + foreach (var (key, value) in kv) + { + if (key != null && value != null) + { + map[key] = value.ToString(); + } + } + return map; + } + + private static string BuildQuery(Dictionary? query) + { + if (query == null || query.Count == 0) + { + return ""; + } + var sb = new StringBuilder(); + foreach (var kv in query) + { + if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) + { + continue; + } + sb.Append(sb.Length == 0 ? '?' : '&') + .Append(Uri.EscapeDataString(kv.Key)) + .Append('=') + .Append(Uri.EscapeDataString(kv.Value)); + } + return sb.ToString(); + } + + // ====================== 发送与解析 ====================== + + /// + /// 发送请求并返回裸数据节点(可能为 null)。 + /// 为 时按 text/plain 原样提交(对应 Java @RequestBody String), + /// 其余类型按 application/json 序列化后提交。 + /// + /// 为 true 时把 HTTP 404 / 204 归一为 null(OA 端点惯用的"无数据"语义)。 + protected async Task SendAsync(HttpMethod method, string path, + Dictionary? query = null, object? body = null, + bool notFoundAsNull = true, CancellationToken ct = default) + { + var url = Options.ResolveBaseUrl() + path + BuildQuery(query); + using var request = new HttpRequestMessage(method, url); + if (body != null) + { + request.Content = body is string raw + ? new StringContent(raw, Encoding.UTF8, "text/plain") + : new StringContent(JsonSerializer.Serialize(body, JsonOpts), Encoding.UTF8, "application/json"); + } + + using var response = await HttpClient.SendAsync(request, ct).ConfigureAwait(false); + var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var status = (int)response.StatusCode; + + // OA 兼容端点惯用 404 / 204 表达"查无数据",不是调用失败 + if ((status == 404 && notFoundAsNull) || status == 204 || status == 205) + { + return null; + } + if (!response.IsSuccessStatusCode) + { + throw new SchClassOpenApiException("SchClass OpenAPI HTTP " + status + ": " + text, status, text); + } + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(text); + return Unwrap(doc.RootElement); + } + catch (JsonException e) + { + throw new SchClassOpenApiException("SchClass OpenAPI 响应解析失败: " + e.Message, status, text); + } + } + + /// 裸数据反序列化为 。 + protected async Task ResolveAsync(HttpMethod method, string path, + Dictionary? query = null, object? body = null, + bool notFoundAsNull = true, CancellationToken ct = default) + { + var data = await SendAsync(method, path, query, body, notFoundAsNull, ct).ConfigureAwait(false); + if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return default; + } + try + { + return JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts); + } + catch (JsonException) + { + throw new SchClassOpenApiException("SchClass OpenAPI 反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText()); + } + } + + /// 裸数组 / 单对象反序列化为列表;无数据时返回空列表(不会为 null)。 + protected async Task> ResolveListAsync(HttpMethod method, string path, + Dictionary? query = null, object? body = null, + bool notFoundAsNull = true, CancellationToken ct = default) + { + var data = await SendAsync(method, path, query, body, notFoundAsNull, ct).ConfigureAwait(false); + var result = new List(); + if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return result; + } + try + { + if (data.Value.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.Value.EnumerateArray()) + { + if (item.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined)) + { + result.Add(JsonSerializer.Deserialize(item.GetRawText(), JsonOpts)!); + } + } + } + else + { + result.Add(JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts)!); + } + } + catch (JsonException) + { + throw new SchClassOpenApiException("SchClass OpenAPI 列表反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText()); + } + return result; + } + + /// + /// 弱类型兼容行(Java Map<String,Object>)列表;无数据时返回空列表。 + /// 每行键名与 Java 侧 LinkedHashMap 的 put 顺序完全一致。 + /// + protected Task> ResolveRowsAsync(HttpMethod method, string path, + Dictionary? query = null, object? body = null, + bool notFoundAsNull = true, CancellationToken ct = default) + => ResolveListAsync(method, path, query, body, notFoundAsNull, ct); + + /// + /// 信封感知:仅当同时出现数字 code 与(data 或字符串 msg)时才认定为信封, + /// 避免把业务行里恰好叫 code 的字段(如学制等级的 code=gradeId)误判成错误响应。 + /// + private static JsonElement? Unwrap(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("code", out var codeEl) + || codeEl.ValueKind != JsonValueKind.Number) + { + return root.Clone(); + } + + var hasData = root.TryGetProperty("data", out var dataEl); + var hasMsg = root.TryGetProperty("msg", out var msgEl) && msgEl.ValueKind == JsonValueKind.String; + if (!hasData && !hasMsg) + { + // 业务行里恰好含 code 字段(如学制等级的 code=gradeId),不是错误信封 + return root.Clone(); + } + + codeEl.TryGetInt32(out var code); + if (code != 0) + { + var msg = hasMsg ? msgEl.GetString() : null; + throw new SchClassOpenApiException( + string.IsNullOrEmpty(msg) ? "SchClass OpenAPI 业务错误" : msg!, 0, root.GetRawText()); + } + return hasData && dataEl.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) + ? dataEl.Clone() + : null; + } +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassDbClassClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassDbClassClient.cs new file mode 100644 index 0000000..a6fa833 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassDbClassClient.cs @@ -0,0 +1,23 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 DbClass 接口客户端(镜像 Java DbClassController,路径前缀 /oapi/DbClass/)。 +/// +public class SchClassDbClassClient : SchClassClientBase +{ + private const string Root = Prefix + "/DbClass/"; + + public SchClassDbClassClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// + /// 查询学校教师名单(GET /oapi/DbClass/GetSchteaList)。 + /// 每位教师的每个有效分组归属返回一行(同一教师多分组会重复出现)。 + /// 行字段:SchSerID / TeaTel / TeaName / UserTeaSerID / TeaIDCard / TeaNo / GroupID。 + /// + /// 学校 ID(Java 参数名 Group),必须大于 0;<=0 时服务端返回 400。 + public Task> GetSchteaListAsync(int group, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchteaList", Params(("Group", group)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassFamilyCompatClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassFamilyCompatClient.cs new file mode 100644 index 0000000..5338398 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassFamilyCompatClient.cs @@ -0,0 +1,29 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务家长兼容接口客户端(镜像 Java FamilyCompatController,路径前缀 /oapi/Common/FamCom/)。 +/// 两个端点当前服务端均恒返回空数据,保留调用以兼容既有消费方。 +/// +public class SchClassFamilyCompatClient : SchClassClientBase +{ + private const string Root = Prefix + "/Common/FamCom/"; + + public SchClassFamilyCompatClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// + /// 查询学生家长用户 ID 列表,POST /oapi/Common/FamCom/GetStuUserFamIDList; + /// 请求体为逗号分隔的学生 ID(Java 侧 @RequestBody String,按 text/plain 提交)。 + /// + public Task> GetStuUserFamIdListAsync(string? stuIds, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Post, Root + "GetStuUserFamIDList", body: stuIds, ct: ct); + + /// + /// 查询学生家庭成员,POST /oapi/Common/FamCom/GetAllStuFamMem; + /// 请求体为逗号分隔的学生 ID(text/plain)。 + /// + public Task> GetAllStuFamMemAsync(string? stuIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Post, Root + "GetAllStuFamMem", body: stuIds, ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApi.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApi.cs new file mode 100644 index 0000000..f66e598 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApi.cs @@ -0,0 +1,100 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务(SchClass)开放接口聚合门面:一个入口访问全部 OA 兼容端点。 +/// +/// 用法: +/// +/// var api = SchClassOpenApiFactory.Create(new SchClassOpenApiOptions { BaseHost = "http://bschclassmanage" }); +/// var schools = await api.SchInfo.AllListAsync(); +/// +/// +/// +public sealed class SchClassOpenApi +{ + public SchClassOpenApi(HttpClient httpClient, SchClassOpenApiOptions options) + { + if (httpClient == null) throw new ArgumentNullException(nameof(httpClient)); + if (options == null) throw new ArgumentNullException(nameof(options)); + + Options = options; + HttpClient = httpClient; + + DbClass = new SchClassDbClassClient(httpClient, options); + VClass = new SchClassVClassClient(httpClient, options); + Schooling = new SchClassSchoolingClient(httpClient, options); + UserInfo = new SchClassUserInfoClient(httpClient, options); + SchInfo = new SchClassSchInfoClient(httpClient, options); + TeaInfo = new SchClassTeaInfoClient(httpClient, options); + StuInfo = new SchClassStuInfoClient(httpClient, options); + TeachInfo = new SchClassTeachInfoClient(httpClient, options); + SubInfo = new SchClassSubInfoClient(httpClient, options); + FamilyCompat = new SchClassFamilyCompatClient(httpClient, options); + } + + /// 当前生效的配置。 + public SchClassOpenApiOptions Options { get; } + + /// 底层 HttpClient 的 BaseAddress(配置为空时为 null)。 + public Uri? HttpBaseAddress => HttpClient.BaseAddress; + + private HttpClient HttpClient { get; } + + /// DbClass:学校教师名单(/oapi/DbClass/)。 + public SchClassDbClassClient DbClass { get; } + + /// VSchClass:班级(/oapi/VSchClass/)。 + public SchClassVClassClient VClass { get; } + + /// Schoolen:学制与年级(/oapi/Schoolen/)。 + public SchClassSchoolingClient Schooling { get; } + + /// UserInfo:师生统一用户(/oapi/UserInfo/)。 + public SchClassUserInfoClient UserInfo { get; } + + /// SchInfo:学校(/oapi/SchInfo/)。 + public SchClassSchInfoClient SchInfo { get; } + + /// OATea:教师(/oapi/OATea/)。 + public SchClassTeaInfoClient TeaInfo { get; } + + /// OAStu:学生(/oapi/OAStu/)。 + public SchClassStuInfoClient StuInfo { get; } + + /// TeachInfo:任课(/oapi/TeachInfo/)。 + public SchClassTeachInfoClient TeachInfo { get; } + + /// SubInfo:科目(/oapi/SubInfo/)。 + public SchClassSubInfoClient SubInfo { get; } + + /// FamCom:家长兼容(/oapi/Common/FamCom/)。 + public SchClassFamilyCompatClient FamilyCompat { get; } +} + +/// 的轻量工厂(不依赖 DI 容器)。 +public static class SchClassOpenApiFactory +{ + /// + /// 按配置创建 :预设 BaseAddress 与读超时。 + /// 生产环境建议改用 IHttpClientFactory 注入实例,本方法仅用于快速接入与测试。 + /// + public static HttpClient CreateHttpClient(SchClassOpenApiOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + + var client = new HttpClient + { + Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs <= 0 ? 5000 : options.ReadTimeoutMs) + }; + var baseUrl = options.ResolveBaseUrl(); + if (!string.IsNullOrEmpty(baseUrl)) + { + client.BaseAddress = new Uri(baseUrl, UriKind.Absolute); + } + return client; + } + + /// 按配置创建一个完整的门面实例(内部自建 HttpClient,调用方负责复用而非频繁创建)。 + public static SchClassOpenApi Create(SchClassOpenApiOptions options) + => new SchClassOpenApi(CreateHttpClient(options), options); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiException.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiException.cs new file mode 100644 index 0000000..6757bbd --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiException.cs @@ -0,0 +1,18 @@ +namespace Yh.SchClass.OpenApi; + +/// 教务开放接口调用异常(HTTP 非 2xx 或响应解析失败)。 +public class SchClassOpenApiException : Exception +{ + /// HTTP 状态码;非 HTTP 层错误时为 0。 + public int StatusCode { get; } + + /// 原始响应文本(若有)。 + public string? ResponseBody { get; } + + public SchClassOpenApiException(string message, int statusCode = 0, string? responseBody = null) + : base(message) + { + StatusCode = statusCode; + ResponseBody = responseBody; + } +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiOptions.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiOptions.cs new file mode 100644 index 0000000..afa3cf0 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiOptions.cs @@ -0,0 +1,107 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 云海教务(SchClass / bschclassmanage)开放接口客户端配置,配置节 yh:schclass:open。 +/// 与 UserBase 的 OpenApiOptions 同构,但独立配置节,便于分别指向不同后端。 +/// +public class SchClassOpenApiOptions +{ + public const string SectionName = "yh:schclass:open"; + + /// + /// 共享开放接口配置节(与 UserBase 的 OpenApiOptions 同节): + /// 其中 BaseHost 与 SchClassPath 可作为教务客户端的缺省来源。 + /// + /// "yh": { + /// "open": { + /// "BaseHost": "http://base.yunhaixingtong.com", + /// "BasePath": "/userbase", ← UserBase 用 + /// "SchClassPath": "/bschclass" ← 教务(SchClass)用 + /// } + /// } + /// + /// + public const string SharedSectionName = "yh:open"; + + /// 共享节里教务路径的键名。 + public const string SchClassPathKey = "SchClassPath"; + + /// HTTP 客户端逻辑名(IHttpClientFactory)。 + public const string HttpClientName = "yh-schclass-openapi"; + + /// 是否启用(默认 true) + public bool Enabled { get; set; } = true; + + /// 教务开放接口基础地址,例如 http://base.yunhaixingtong.com + public string? BaseHost { get; set; } + + /// 基础路径(默认 null,挂载在根路径);联调环境为 /bschclass + public string? BasePath { get; set; } + + /// 连接超时(毫秒),默认 1500 + public long ConnectTimeoutMs { get; set; } = 1500; + + /// 读超时(毫秒),默认 5000 + public long ReadTimeoutMs { get; set; } = 5000; + + /// 写超时(毫秒),默认 5000 + public long WriteTimeoutMs { get; set; } = 5000; + + /// 调用方在教务平台注册的应用标识(用于签名,可选) + public string? AppKey { get; set; } + + /// 调用方在教务平台注册的密钥(用于签名,可选) + public string? AppSecret { get; set; } + + /// 拼接 baseHost + basePath 为统一开放接口基址。 + public string ResolveBaseUrl() + { + var baseHost = BaseHost?.Trim() ?? ""; + if (baseHost.EndsWith('/')) + { + baseHost = baseHost.TrimEnd('/'); + } + return baseHost + (NormalizeBasePath(BasePath) ?? ""); + } + + /// 归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null。 + public static string? NormalizeBasePath(string? path) + { + if (path == null) + { + return null; + } + var trimmed = path.Trim().TrimEnd('/'); + if (trimmed.Length == 0) + { + return null; + } + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } + + /// + /// 从共享节(,即 yh:open)按键读取,回填本选项中**未显式配置**的项: + /// BaseHost ← yh:open:BaseHost;BasePath ← yh:open:SchClassPath(注意不是 BasePath —— 那是 UserBase 的路径)。 + /// 传相对键名(如 "BaseHost"),返回 null/空白视为未配置。 + /// + public void FillFromSharedSection(Func readShared) + { + if (string.IsNullOrWhiteSpace(BaseHost)) + { + BaseHost = readShared("BaseHost"); + } + if (string.IsNullOrWhiteSpace(BasePath)) + { + BasePath = readShared(SchClassPathKey); + } + if (string.IsNullOrWhiteSpace(AppKey)) + { + AppKey = readShared("AppKey"); + } + if (string.IsNullOrWhiteSpace(AppSecret)) + { + AppSecret = readShared("AppSecret"); + } + BasePath = NormalizeBasePath(BasePath); + } +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiServiceCollectionExtensions.cs new file mode 100644 index 0000000..f87766e --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiServiceCollectionExtensions.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Yh.SchClass.OpenApi; + +/// +/// DI 注册扩展:从配置读取地址并装配全部教务客户端。 +/// +/// 配置来源(优先级从高到低): +/// 1. 独立节 yh:schclass:open(整节绑定,适合教务单独部署、地址与 UserBase 无关的场景); +/// 2. 共享节 yh:open 回填:BaseHost ← yh:open:BaseHost、 +/// BasePath ← yh:open:SchClassPath(注意共享节的 BasePath=/userbase 是 UserBase 的,不会被教务采用)、 +/// AppKey/AppSecret 同名回填。 +/// +/// +public static class SchClassOpenApiServiceCollectionExtensions +{ + /// + /// 注册教务 /oapi 全部客户端:IHttpClientFactory + typed client + 聚合门面 (Singleton)。 + /// + /// builder.Services.AddSchClassOpenApi(builder.Configuration); + /// // 注入使用: + /// public class X(SchClassOpenApi sch) { ... sch.SchInfo.AllListAsync() ... } + /// + /// + public static IServiceCollection AddSchClassOpenApi(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(SchClassOpenApiOptions.SectionName)) + .PostConfigure(o => + { + var shared = configuration.GetSection(SchClassOpenApiOptions.SharedSectionName); + o.FillFromSharedSection(key => shared[key]); + }); + + services.AddHttpClient(SchClassOpenApiOptions.HttpClientName) + .ConfigureHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000); + var baseUrl = options.ResolveBaseUrl(); + if (!string.IsNullOrEmpty(baseUrl)) + { + client.BaseAddress = new Uri(baseUrl, UriKind.Absolute); + } + }) + .ConfigurePrimaryHttpMessageHandler(sp => + { + var options = sp.GetRequiredService>().Value; + return new SocketsHttpHandler + { + ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500), + }; + }); + + services.AddSingleton(sp => + { + var factory = sp.GetRequiredService(); + return new SchClassOpenApi(factory.CreateClient(SchClassOpenApiOptions.HttpClientName), + sp.GetRequiredService>().Value); + }); + return services; + } +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchInfoClient.cs new file mode 100644 index 0000000..3df7758 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchInfoClient.cs @@ -0,0 +1,54 @@ +using Yh.SchClass.OpenApi.Model; + +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 SchInfo(学校)接口客户端(镜像 Java SchInfoController,路径前缀 /oapi/SchInfo/)。 +/// +public class SchClassSchInfoClient : SchClassClientBase +{ + private const string Root = Prefix + "/SchInfo/"; + + public SchClassSchInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// 按多个学校 ID 查询学校列表,GET /oapi/SchInfo/GetSchlist。 + /// 逗号分隔的学校 ID 列表(Java 参数名 SchIDArr);为 null 时等价于查询全部。 + public Task> GetSchListAsync(string? schIdArr, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetSchlist", + Params(("SchIDArr", schIdArr)), ct: ct); + + /// 查询全部学校列表,GET /oapi/SchInfo/AllList。 + public Task> AllListAsync(CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "AllList", ct: ct); + + /// 按学校外部序列号查询单个学校,GET /oapi/SchInfo/GetSchFirst;未找到返回 null。 + /// 学校外部序列号(Java 参数名 SchID)。 + public Task GetSchFirstAsync(string? schId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Root + "GetSchFirst", Params(("SchID", schId)), ct: ct); + + /// 按用户中心学校 ID 查询单个学校,GET /oapi/SchInfo/GetUserSchFirst;未找到返回 null。 + /// 用户中心学校 ID(Java 参数名 UserSchID)。 + public Task GetUserSchFirstAsync(int? userSchId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Root + "GetUserSchFirst", + Params(("UserSchID", userSchId)), ct: ct); + + /// 按多个用户中心学校 ID 查询学校列表,GET /oapi/SchInfo/GetUserSchlist。 + /// 逗号分隔的用户中心学校 ID 列表(Java 参数名 UserSchIds)。 + public Task> GetUserSchListAsync(string? userSchIds, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetUserSchlist", + Params(("UserSchIds", userSchIds)), ct: ct); + + /// 按用户中心组 ID 查询学校列表,GET /oapi/SchInfo/GetSchoolByGroupId;0 或空时返回空列表。 + /// 用户中心组 ID(Java 参数名 groupId)。 + public Task> GetSchoolByGroupIdAsync(int? groupId, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetSchoolByGroupId", + Params(("groupId", groupId)), ct: ct); + + /// 按学制 ID 查询学校,GET /oapi/SchInfo/GetSchoolBySchoolenId;当前服务端恒返回空列表。 + public Task> GetSchoolBySchoolenIdAsync(int? groupId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchoolBySchoolenId", + Params(("groupId", groupId)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchoolingClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchoolingClient.cs new file mode 100644 index 0000000..560cfc7 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchoolingClient.cs @@ -0,0 +1,45 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 Schoolen(学制 / 年级)接口客户端(镜像 Java SchoolingController,路径前缀 /oapi/Schoolen/)。 +/// +/// 年级行字段:Id / Name / OwnerId / Levell / code / sx; +/// 学制行字段:Id / Name / FormerName / Type。 +/// +/// +public class SchClassSchoolingClient : SchClassClientBase +{ + private const string Root = Prefix + "/Schoolen/"; + + public SchClassSchoolingClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// 按学校查询年级(学制层级)列表,GET /oapi/Schoolen/GetSchLenLevellist。 + /// 学校外部序列号(Java 参数名 SchSerID)。 + public Task> GetSchLenLevelListAsync(string? schSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenLevellist", Params(("SchSerID", schSerId)), ct: ct); + + /// 按学制 ID 查询年级列表,GET /oapi/Schoolen/GetLevelIdlist。 + /// 学制 ID(Java 参数名 Schooling);0 或空时服务端返回空列表。 + public Task> GetLevelIdListAsync(int? schooling, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetLevelIdlist", Params(("Schooling", schooling)), ct: ct); + + /// 按年级 ID 查询单个年级,GET /oapi/Schoolen/GetSchLenLevelFirst;未找到返回 null。 + /// 年级 ID(Java 参数名 GradeId,字符串形式)。 + public Task GetSchLenLevelFirstAsync(string? gradeId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Root + "GetSchLenLevelFirst", + Params(("GradeId", gradeId)), ct: ct); + + /// 按学校查询学制列表,GET /oapi/Schoolen/GetSchLenlist。 + public Task> GetSchLenListAsync(string? schSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenlist", Params(("SchSerID", schSerId)), ct: ct); + + /// + /// 查询班级科目,GET /oapi/Schoolen/GetAllClassSubject;当前服务端恒返回空列表。 + /// + public Task> GetAllClassSubjectAsync(int? schId, string? schserid, string? startTime, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetAllClassSubject", + Params(("schId", schId), ("schserid", schserid), ("startTime", startTime)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassStuInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassStuInfoClient.cs new file mode 100644 index 0000000..35aa99f --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassStuInfoClient.cs @@ -0,0 +1,119 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 OAStu(学生)接口客户端(镜像 Java StuInfoController,路径前缀 /oapi/OAStu/)。 +/// +/// 学生行字段(OaCompatSupport.student): +/// Account / UserStuSerID / StuName / StuIDCard / StuSex / StuStatus / ClassID / ClassStuID / +/// GroupID / StuPhoto / CardID / StuCurrentTel / invitecode / SchSerID / Graduated / isjiedu; +/// 走班(Elective)行在此基础上追加 electiveId / schoolId 且 StuSex 为数值。 +/// +/// +/// 历史学生行字段:stuId / stuName / classId / className / grade / name / school_system_id / system_grade。 +/// +/// +public class SchClassStuInfoClient : SchClassClientBase +{ + private const string Root = Prefix + "/OAStu/"; + + public SchClassStuInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// 查询全部学生,GET /oapi/OAStu/GetAllStulist。 + public Task> GetAllStuListAsync(CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetAllStulist", ct: ct); + + /// 查询全部走班学生,GET /oapi/OAStu/GetEleAllStulist。 + public Task> GetEleAllStuListAsync(CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleAllStulist", ct: ct); + + /// 按学校查询学生,GET /oapi/OAStu/GetSchStulist。 + /// 学校外部序列号(Java 参数名 schserid,全小写)。 + /// 学校分组 ID(Java 参数名 schgroupid)。 + public Task> GetSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchStulist", + Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct); + + /// 按学校查询走班学生,GET /oapi/OAStu/GetEleSchStulist。 + public Task> GetEleSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchStulist", + Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct); + + /// 按班级查询学生,GET /oapi/OAStu/GetClaStulist。 + /// 班级 ID(Java 参数名 ClassID)。 + /// 班级分组 ID(Java 参数名 clagroupid)。 + public Task> GetClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetClaStulist", + Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct); + + /// 按班级查询走班学生,GET /oapi/OAStu/GetEleClaStulist。 + public Task> GetEleClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaStulist", + Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct); + + /// 按多个班级查询学生,GET /oapi/OAStu/GetClaArrStulist。 + /// 逗号分隔的班级 ID 列表(Java 参数名 ClassIDs)。 + /// 逗号分隔的班级分组 ID 列表(Java 参数名 clagroupids)。 + public Task> GetClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetClaArrStulist", + Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct); + + /// 按多个班级查询走班学生,GET /oapi/OAStu/GetEleClaArrStulist。 + public Task> GetEleClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaArrStulist", + Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct); + + /// 按多个学生 ID 查询学生,GET /oapi/OAStu/GetStuIdslist。 + /// 逗号分隔的学生 ID 列表(Java 参数名 stuids,全小写)。 + public Task> GetStuIdsListAsync(string? stuids, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetStuIdslist", Params(("stuids", stuids)), ct: ct); + + /// 按多个学生 ID 查询走班学生,GET /oapi/OAStu/GetEleStuIdslist。 + public Task> GetEleStuIdsListAsync(string? stuids, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleStuIdslist", Params(("stuids", stuids)), ct: ct); + + /// 查询学生家长用户 ID 列表,GET /oapi/OAStu/GetStuUserFamIDList;当前服务端恒返回空列表。 + public Task> GetStuUserFamIdListAsync(string? stuids, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetStuUserFamIDList", + Params(("stuids", stuids)), ct: ct); + + /// 按邀请码查询学生,GET /oapi/OAStu/GetFamStulist。 + /// 逗号分隔的邀请码列表(Java 参数名 invitecodes)。 + public Task> GetFamStuListAsync(string? invitecodes, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetFamStulist", + Params(("invitecodes", invitecodes)), ct: ct); + + /// 查询学生家庭成员,GET /oapi/OAStu/GetStuFamMem;当前服务端恒返回空列表。 + public Task> GetStuFamMemAsync(string? schSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetStuFamMem", Params(("SchSerID", schSerId)), ct: ct); + + /// 按多个学校序列号查询学生,GET /oapi/OAStu/GetSchBySchIds。 + /// 逗号分隔的学校序列号列表(Java 参数名 schserids,全小写)。 + public Task> GetSchBySchIdsAsync(string? schserids, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchBySchIds", Params(("schserids", schserids)), ct: ct); + + /// 按多个学校序列号查询走班学生,GET /oapi/OAStu/GetEleSchIdsStulist。 + public Task> GetEleSchIdsStuListAsync(string? schserids, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchIdsStulist", Params(("schserids", schserids)), ct: ct); + + /// 按多个学校序列号查询走班学生,GET /oapi/OAStu/GetEleSchBySchIds。 + public Task> GetEleSchBySchIdsAsync(string? schserids, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchBySchIds", Params(("schserids", schserids)), ct: ct); + + /// 按选课 ID 查询学生,GET /oapi/OAStu/GetStudentsByElectiveId;当前服务端恒返回空列表。 + public Task> GetStudentsByElectiveIdAsync(int? electiveIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetStudentsByElectiveId", + Params(("electiveIds", electiveIds)), ct: ct); + + /// 按学校 + 年度查询历史学生,GET /oapi/OAStu/getHistoryStudentsBySchoolId。 + public Task> GetHistoryStudentsBySchoolIdAsync(int schoolId, int year, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryStudentsBySchoolId", + Params(("schoolId", schoolId), ("year", year)), ct: ct); + + /// 按学校 + 年度查询历史班级,GET /oapi/OAStu/getHistoryClass;当前服务端恒返回空列表。 + public Task> GetHistoryClassAsync(int schoolId, int year, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryClass", + Params(("schoolId", schoolId), ("year", year)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSubInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSubInfoClient.cs new file mode 100644 index 0000000..dc5761a --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSubInfoClient.cs @@ -0,0 +1,41 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 SubInfo(科目)接口客户端(镜像 Java SchSubController,路径前缀 /oapi/SubInfo/)。 +/// 科目行字段:SchSerID / SubID / SubName / No。 +/// +public class SchClassSubInfoClient : SchClassClientBase +{ + private const string Root = Prefix + "/SubInfo/"; + + public SchClassSubInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// 按学校 + 学制查询科目列表,GET /oapi/SubInfo/GetSchSublist。 + /// 学校外部序列号(Java 参数名 SchID)。 + /// 学制 ID(Java 参数名 schooling,全小写)。 + /// 年级 ID(Java 参数名 gradeId;当前服务端未使用该参数)。 + public Task> GetSchSubListAsync(string? schId, int? schooling, int? gradeId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchSublist", + Params(("SchID", schId), ("schooling", schooling), ("gradeId", gradeId)), ct: ct); + + /// 按多个学校查询科目列表,GET /oapi/SubInfo/GetSchIdsSublist。 + /// 逗号分隔的学校序列号列表(Java 参数名 SchIds)。 + public Task> GetSchIdsSubListAsync(string? schIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchIdsSublist", Params(("SchIds", schIds)), ct: ct); + + /// 按教师查询其任教的科目编码列表(去重字符串),GET /oapi/SubInfo/GetSchTeaSublist。 + /// 教师 ID(Java 参数名 UserTeaSerID,字符串形式)。 + public Task> GetSchTeaSubListAsync(string? userTeaSerId, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeaSublist", + Params(("UserTeaSerID", userTeaSerId)), ct: ct); + + /// + /// 按学校查询该校全部任课科目编码(小写去重字符串),GET /oapi/SubInfo/GetSchTeachSublist。 + /// + public Task> GetSchTeachSubListAsync(string? schId, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeachSublist", + Params(("SchID", schId)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeaInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeaInfoClient.cs new file mode 100644 index 0000000..0e26c0f --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeaInfoClient.cs @@ -0,0 +1,49 @@ +using Yh.SchClass.OpenApi.Model; + +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 OATea(教师)接口客户端(镜像 Java TeainfoController,路径前缀 /oapi/OATea/)。 +/// +public class SchClassTeaInfoClient : SchClassClientBase +{ + private const string Root = Prefix + "/OATea/"; + + public SchClassTeaInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// + /// 按教师 ID 查询教师(GET /oapi/OATea/GetTeainfo)。 + /// 教师不存在或无分组归属时服务端返回 404,本方法返回 null。 + /// + /// 教师 ID(Java 参数名 uid,全小写)。 + public Task GetTeaInfoAsync(int uid, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Root + "GetTeainfo", Params(("uid", uid)), ct: ct); + + /// + /// 按多个教师 ID 查询教师列表(POST /oapi/OATea/GetArrTeainfo)。 + /// 请求体为逗号分隔的教师 ID(Java 侧 @RequestBody String,按 text/plain 提交); + /// 参数缺失时服务端返回 400,查询为空时返回 204(本方法返回空列表)。 + /// + public Task> GetArrTeaInfoAsync(string userIds, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Post, Root + "GetArrTeainfo", body: userIds, ct: ct); + + /// + /// 按学校查询教师列表(GET /oapi/OATea/GetSchTeainfo)。 + /// 与 至少提供一个,否则服务端返回 400。 + /// + /// 学校外部序列号(Java 参数名 SchSerID)。 + /// 学校内部 ID(Java 参数名 schId,优先使用)。 + public Task> GetSchTeaInfoAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeainfo", + Params(("SchSerID", schSerId), ("schId", schId)), ct: ct); + + /// + /// 按学校查询教师列表(GET /oapi/OATea/GetSchTeaList),与 等价。 + /// + public Task> GetSchTeaListAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeaList", + Params(("SchSerID", schSerId), ("schId", schId)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeachInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeachInfoClient.cs new file mode 100644 index 0000000..8b885e2 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeachInfoClient.cs @@ -0,0 +1,60 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 TeachInfo(任课)接口客户端(镜像 Java TeaChesController,路径前缀 /oapi/TeachInfo/)。 +/// +/// 任课行字段(OaCompatSupport.teaching): +/// SerID / SchSerID / UserTeaSerID / SubID / ClassID / UserClassID / CreationTime / static。 +/// +/// +public class SchClassTeachInfoClient : SchClassClientBase +{ + private const string Root = Prefix + "/TeachInfo/"; + + public SchClassTeachInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// 按学校 + 教师查询任课,GET /oapi/TeachInfo/GetSchTeachlist。 + /// 学校外部序列号(Java 参数名 SchID)。 + /// 教师 ID(Java 参数名 UserTeaSerID)。 + public Task> GetSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchTeachlist", + Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct); + + /// 按学校 + 教师查询走班任课,GET /oapi/TeachInfo/GetEleSchTeachlist。 + public Task> GetEleSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchTeachlist", + Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct); + + /// 按多个学校查询任课,GET /oapi/TeachInfo/GetSchArrTeachlist。 + /// 逗号分隔的学校序列号列表(Java 参数名 SchIDArr)。 + public Task> GetSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrTeachlist", Params(("SchIDArr", schIdArr)), ct: ct); + + /// 按多个学校查询走班任课,GET /oapi/TeachInfo/GetEleSchArrTeachList。 + public Task> GetEleSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrTeachList", Params(("SchIDArr", schIdArr)), ct: ct); + + /// 按班级查询任课,GET /oapi/TeachInfo/GetSchClaTeachlist。 + /// 班级 ID(Java 参数名 ClassID);非法或为空时返回空列表。 + public Task> GetSchClaTeachListAsync(string? classId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchClaTeachlist", Params(("ClassID", classId)), ct: ct); + + /// 按班级查询走班任课,GET /oapi/TeachInfo/GetEleSchClaTeachlist。 + public Task> GetEleSchClaTeachListAsync(string? classId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchClaTeachlist", Params(("ClassID", classId)), ct: ct); + + /// 按学校 + 年级查询选课班级,GET /oapi/TeachInfo/GetElectiveClassByGrade;当前服务端恒返回空列表。 + public Task> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade", + Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct); + + /// + /// 按多个教师查询选课班级,POST /oapi/TeachInfo/GetElectiveClassByTeachers; + /// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。 + /// + public Task> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassUserInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassUserInfoClient.cs new file mode 100644 index 0000000..ba9a2bd --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassUserInfoClient.cs @@ -0,0 +1,34 @@ +using Yh.SchClass.OpenApi.Model; + +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 UserInfo(师生统一用户)接口客户端(镜像 Java UserInfoController,路径前缀 /oapi/UserInfo/)。 +/// +public class SchClassUserInfoClient : SchClassClientBase +{ + private const string Root = Prefix + "/UserInfo/"; + + public SchClassUserInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// + /// 查询学校全部教师 + 学生(GET /oapi/UserInfo/GetUserList)。 + /// 参数缺失或学校不存在时服务端返回 400;查询为空时返回 204(本方法返回空列表)。 + /// + /// 学校外部序列号(Java 参数名 SchSerID)。 + public Task> GetUserListAsync(string schSerId, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetUserList", + Params(("SchSerID", schSerId)), ct: ct); + + /// + /// 按用户 ID 查询用户信息(GET /oapi/UserInfo/getUserIdList)。 + /// 教师 / 学生均未命中时服务端返回 404,本方法返回空列表。 + /// + /// 用户 ID(Java 参数名 userid,全小写)。 + public Task> GetUserIdListAsync(int userid, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "getUserIdList", + Params(("userid", userid)), ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassVClassClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassVClassClient.cs new file mode 100644 index 0000000..ed44eaa --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassVClassClient.cs @@ -0,0 +1,107 @@ +namespace Yh.SchClass.OpenApi; + +/// +/// 教务 VSchClass(班级)接口客户端(镜像 Java VClassController,路径前缀 /oapi/VSchClass/)。 +/// +/// 班级行字段(OaCompatSupport.classInfo): +/// Id / Name / OwnerId / Levell / sx / SchSerID / ClassID / UserClassID / Schooling / MajorCode / +/// ClassCode / ClassMotto / ClassName / StartDate / FirstTeaUserID / SecondTeaUserID / UpgradeYear / +/// CurrentIndex / CreationTime / GradtionTime / GreduatedYear / Graduated。 +/// +/// +public class SchClassVClassClient : SchClassClientBase +{ + private const string Root = Prefix + "/VSchClass/"; + + public SchClassVClassClient(HttpClient httpClient, SchClassOpenApiOptions options) + : base(httpClient, options) + { + } + + /// 按学校查询班级(排除已毕业),GET /oapi/VSchClass/GetVClassList。 + /// 学校外部序列号(Java 参数名 SchSerID)。 + public Task> GetVClassListAsync(string? schSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassList", Params(("SchSerID", schSerId)), ct: ct); + + /// 按学校查询班级(含已毕业),GET /oapi/VSchClass/GetEleVClassList。 + public Task> GetEleVClassListAsync(string? schSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassList", Params(("SchSerID", schSerId)), ct: ct); + + /// 按多个学校查询班级(排除已毕业),GET /oapi/VSchClass/GetSchArrVClassList。 + /// 逗号分隔的学校序列号列表(Java 参数名 SchIDArr)。 + public Task> GetSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct); + + /// 按多个学校查询班级(含已毕业),GET /oapi/VSchClass/GetEleSchArrVClassList。 + public Task> GetEleSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct); + + /// + /// 按时间查询学校班级,GET /oapi/VSchClass/GetFunTimeSchClass。 + /// 注意:Java 实现忽略 ,实际按学校过滤。 + /// + public Task> GetFunTimeSchClassAsync(string? time, string? schSerId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetFunTimeSchClass", + Params(("time", time), ("SchSerID", schSerId)), ct: ct); + + /// 按班级 ID 查询单个班级,GET /oapi/VSchClass/GetVClassFirst;未找到返回 null。 + /// 班级 ID(Java 参数名 ClassID)。 + public Task GetVClassFirstAsync(string? classId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Root + "GetVClassFirst", Params(("ClassID", classId)), ct: ct); + + /// 按班级 ID 查询单个班级(含已毕业),GET /oapi/VSchClass/GetEleVClassFirst。 + public Task GetEleVClassFirstAsync(string? classId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Root + "GetEleVClassFirst", Params(("ClassID", classId)), ct: ct); + + /// 按多个班级 ID 查询班级,GET /oapi/VSchClass/GetVClassIdsList。 + /// 逗号分隔的班级 ID 列表(Java 参数名 ClassIds)。 + public Task> GetVClassIdsListAsync(string? classIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassIdsList", Params(("ClassIds", classIds)), ct: ct); + + /// 按多个班级 ID 查询班级(含已毕业),GET /oapi/VSchClass/GetEleVClassIdsList。 + public Task> GetEleVClassIdsListAsync(string? classIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassIdsList", Params(("ClassIds", classIds)), ct: ct); + + /// 按学校 + 学制 + 年级查询班级,GET /oapi/VSchClass/GetGradeClassList。 + public Task> GetGradeClassListAsync(string? schSerId, int? schooling, int? gradeId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetGradeClassList", + Params(("SchSerID", schSerId), ("Schooling", schooling), ("GradeId", gradeId)), ct: ct); + + /// 查询教师任课的班级 ID 列表(去重后的字符串),GET /oapi/VSchClass/GetTeachClassArr。 + /// 教师 ID(Java 参数名 UserTeaSerID)。 + public Task> GetTeachClassArrAsync(int? userTeaSerId, CancellationToken ct = default) + => ResolveListAsync(HttpMethod.Get, Root + "GetTeachClassArr", + Params(("UserTeaSerID", userTeaSerId)), ct: ct); + + /// 查询全部班级(排除已毕业),GET /oapi/VSchClass/GetSchArrVClassListAll。 + public Task> GetSchArrVClassListAllAsync(CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassListAll", ct: ct); + + /// 查询全部班级(含已毕业),GET /oapi/VSchClass/GetEleSchArrVClassListAll。 + public Task> GetEleSchArrVClassListAllAsync(CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassListAll", ct: ct); + + /// + /// 以 POST 方式按多个学校查询班级,POST /oapi/VSchClass/GetSchArrVClassListPost。 + /// 请求体为逗号分隔的学校序列号(Java 侧 @RequestBody String,按 text/plain 提交)。 + /// + public Task> GetSchArrVClassListPostAsync(string? body, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Post, Root + "GetSchArrVClassListPost", body: body, ct: ct); + + /// 按选课 ID 查询班级,GET /oapi/VSchClass/GetElectiveClassById;当前服务端恒返回 null。 + public Task GetElectiveClassByIdAsync(int? electiveId, CancellationToken ct = default) + => ResolveAsync(HttpMethod.Get, Root + "GetElectiveClassById", + Params(("electiveId", electiveId)), ct: ct); + + /// 按学校 + 年级查询选课班级,GET /oapi/VSchClass/GetElectiveClassByGrade;当前服务端恒返回空列表。 + public Task> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade", + Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct); + + /// + /// 按多个教师查询选课班级,POST /oapi/VSchClass/GetElectiveClassByTeachers; + /// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。 + /// + public Task> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default) + => ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct); +} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Yh.SchClass.OpenApi.csproj b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Yh.SchClass.OpenApi.csproj new file mode 100644 index 0000000..f4d8ab8 --- /dev/null +++ b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Yh.SchClass.OpenApi.csproj @@ -0,0 +1,14 @@ + + + + Yh.SchClass.OpenApi + 云海教务(SchClass)开放接口客户端:对应 Java 教务 OpenApi 调用封装 + SchClass /oapi 同构客户端 SDK,支持 yh:open / yh:schclass:open 配置节绑定与 DI 注册 + + + + + + + + diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ErrorCodeAndExceptionTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ErrorCodeAndExceptionTests.cs new file mode 100644 index 0000000..89adb03 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ErrorCodeAndExceptionTests.cs @@ -0,0 +1,43 @@ +using Xunit; +using Yh.Platform.Abstractions; + +namespace Yh.Platform.Abstractions.Tests; + +public class ErrorCodeAndExceptionTests +{ + [Fact] + public void ErrorCode_常量值与Java一比一() + { + Assert.Equal(0, ErrorCode.Success.Code); + Assert.Equal(1, ErrorCode.Error.Code); + Assert.Equal(2, ErrorCode.ParamError.Code); + Assert.Equal(3, ErrorCode.Forbidden.Code); + Assert.Equal(4, ErrorCode.Unauthorized.Code); + Assert.Equal(5, ErrorCode.Custom.Code); + Assert.Equal(6, ErrorCode.InternalServerError.Code); + Assert.Equal(405, ErrorCode.HttpMethodNotAllowed.Code); + Assert.Equal("登录已经过期,请重新登录", ErrorCode.Unauthorized.Msg); + } + + [Fact] + public void DException_便捷构造_默认Custom且message为纯msg() + { + var e = new DException("业务失败"); + Assert.Equal(5, e.Code!.Code); + Assert.Equal("业务失败", e.Message); + } + + [Fact] + public void DException_携带code_msg_data_等价Java语义() + { + var code = new OpenApiLike(5, "组下存在子组或用户,禁止删除"); + var data = new { groupId = 9 }; + var e = new DException(code, code.Msg, data); + + Assert.Equal(5, e.Code!.Code); + Assert.Equal("组下存在子组或用户,禁止删除", e.Message); + Assert.Same(data, e.EnvelopeData); + } + + private sealed record OpenApiLike(int Code, string Msg) : IErrorCode; +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/GlobalUsings.cs new file mode 100644 index 0000000..61adf69 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Threading; +global using System.Threading.Tasks; +global using Yh.Platform.Abstractions; diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ModelSerializationTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ModelSerializationTests.cs new file mode 100644 index 0000000..ea01730 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ModelSerializationTests.cs @@ -0,0 +1,130 @@ +using System.IO; +using System.Text.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using JsonSerializer = System.Text.Json.JsonSerializer; +using Xunit; +using Yh.Platform.Abstractions; + +namespace Yh.Platform.Abstractions.Tests; + +public class ModelSerializationTests +{ + private static JObject LoadFixtureBareData() + { + var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "envelope-success.json"); + return JObject.Parse(File.ReadAllText(path))["bareData"]!.Value()!; + } + + private static JObject LoadFixtureWrapped() + { + var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "envelope-success.json"); + return JObject.Parse(File.ReadAllText(path))["wrappedData"]!.Value()!; + } + + [Fact] + public void Newtonsoft_反序列化fixture_含currentRole嵌套() + { + var user = LoadFixtureBareData().ToObject(new Newtonsoft.Json.JsonSerializer + { + // net5 消费方形态:ASP.NET Core AddNewtonsoftJson 默认大小写不敏感、保留属性名 + ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(), + }); + + Assert.Equal(1001, user!.Id); + Assert.Equal("zhangsan", user.Account); + Assert.Equal("张三", user.Name); + Assert.Equal(2, user.School!.Id); + Assert.Equal("云海第一中学", user.School.Name); + Assert.Equal(3, user.CurrentRole!.RoleId); + Assert.Equal("教师", user.CurrentRole.RoleName); + Assert.Equal("云海第一中学", user.CurrentRole.SchName); + Assert.Equal(21, user.CurrentRole.ChildId); + Assert.Equal(2, user.Schools!.Count); + Assert.Equal(new List { 3, 5 }, user.RoleIds); + Assert.Equal(3, user.Groups!.Count); + } + + [Fact] + public void Newtonsoft_默认设置_大小写不敏感匹配camelCase() + { + var user = LoadFixtureBareData().ToObject(); + Assert.Equal("zhangsan", user!.Account); + Assert.Equal("教师", user.CurrentRole!.RoleName); + } + + [Fact] + public void SystemTextJson_Web序列化_与fixture逐节点一致() + { + var user = LoadFixtureBareData().ToObject(); + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + // 对齐 Java 侧 NON_NULL 语义 + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + var json = JsonSerializer.Serialize(user, options); + + // 语义等价:键序无关、null 节点差异容忍(fixture 中显式 null 与省略 null 视为一致) + Assert.True(JToken.DeepEquals(JObject.Parse(json), StripNulls(LoadFixtureBareData())), + "STJ 序列化结果应与 fixture 语义一致\n" + json); + } + + private static JObject StripNulls(JObject obj) + { + var clone = (JObject)obj.DeepClone(); + var toRemove = new List(); + CollectNullProps(clone, toRemove); + foreach (var p in toRemove) + { + p.Remove(); + } + return clone; + } + + private static void CollectNullProps(JToken token, List collected) + { + foreach (var child in token.Children()) + { + if (child is JProperty p) + { + if (p.Value.Type == JTokenType.Null) + { + collected.Add(p); + continue; + } + CollectNullProps(p.Value, collected); + } + else if (child.Type is JTokenType.Object or JTokenType.Array) + { + CollectNullProps(child, collected); + } + } + } + + [Fact] + public void SystemTextJson_反序列化信封包裹成功样例() + { + var wrapped = LoadFixtureWrapped(); + var envelope = wrapped.ToObject>(new Newtonsoft.Json.JsonSerializer + { + ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(), + }); + + Assert.True(envelope!.IsSuccess); + Assert.Equal(0, envelope.Code); + Assert.Equal(1001, envelope.Data!.Id); + } + + [Fact] + public void BoaApp_Models_双序列化往返() + { + var app = new BoaApp { Id = 7, Name = "作业提交", Clients = (int)ClientEnum.PcBrowser | (int)ClientEnum.MobileApp, IsPublic = false, BuiltInTag = 0 }; + var stj = JsonSerializer.Deserialize(JsonSerializer.Serialize(app, new JsonSerializerOptions(JsonSerializerDefaults.Web)), new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var nf = JObject.Parse(JsonConvert.SerializeObject(app, new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() })).ToObject(); + + Assert.Equal(app.Name, stj!.Name); + Assert.Equal(app.Clients, stj.Clients); + Assert.Equal(app.Name, nf!.Name); + Assert.Equal(false, nf.IsPublic); + } +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/Yh.Platform.Abstractions.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/Yh.Platform.Abstractions.Tests.csproj new file mode 100644 index 0000000..a97e0f3 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/Yh.Platform.Abstractions.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + false + + + + + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/AppSignHandlerTests.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/AppSignHandlerTests.cs new file mode 100644 index 0000000..a7dad8d --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/AppSignHandlerTests.cs @@ -0,0 +1,131 @@ +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace Yh.Platform.AppAuth.Tests; + +/// +/// AppSignHandler basePath 剥离对拍(验收 P1 修复): +/// 链路 handler(fullUrl) → 剥离 basePath → SignUtil(剥离后 canonicalTarget) → 向量基线(Java 口径)。 +/// 证明:经 handler 发出的签名 = Java 客户端对同一 URL 的签名(同 ts 时逐字节一致)。 +/// +public class AppSignHandlerTests +{ + private sealed class CaptureHandler : HttpMessageHandler + { + public HttpRequestMessage? Last { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Last = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + private const string AppKey = "100476"; + private const string Secret = "app-secret-100476-yunhai"; + + private static async Task SendThroughHandlerAsync(AppSignHandler handler, HttpMethod method, string url) + { + using var client = new HttpClient(handler); + var request = new HttpRequestMessage(method, url); + await client.SendAsync(request); + // handler 已把三头写入同一个 request 实例;CaptureHandler 捕获的是该实例 + return request; + } + + /// 读取请求级向量:固定 ts 后,经 handler 对 fullUrl 的签名须与向量基线(Java 口径)逐字节一致。 + public static IEnumerable RequestLevelCases() + { + var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json"); + var root = JsonDocument.Parse(File.ReadAllText(path)).RootElement; + foreach (var c in root.GetProperty("cases").EnumerateArray()) + { + if (c.TryGetProperty("fullUrl", out _)) + { + yield return new object[] { c.Clone() }; + } + } + } + + [Theory] + [MemberData(nameof(RequestLevelCases))] + public async Task Handler对fullUrl签名_与Java基线逐字节一致(JsonElement c) + { + var name = c.GetProperty("name").GetString(); + var method = new HttpMethod(c.GetProperty("method").GetString()!); + var fullUrl = c.GetProperty("fullUrl").GetString()!; + var basePath = c.GetProperty("basePath").GetString()!; + var ts = c.GetProperty("ts").GetString()!; + var secret = c.GetProperty("secret").GetString()!; + var expected = c.GetProperty("expectedRequestSignBase64").GetString()!; + + // 固定 ts = 向量 ts(SignUtil.NowMillis 为 handler 与 SignUtil 共用的时钟源) + var original = SignUtil.NowMillis; + SignUtil.NowMillis = () => long.Parse(ts); + try + { + var handler = new AppSignHandler(AppKey, secret, basePath, new CaptureHandler()); + var sent = await SendThroughHandlerAsync(handler, method, fullUrl); + + Assert.Equal(AppKey, sent.Headers.GetValues(AppSignHandler.HeaderAppKey).First()); + Assert.Equal(ts, sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First()); + // 同 URL、同 ts 下与 Java 客户端(AppSignInterceptor 剥 basePath 后)签名逐字节一致 + Assert.Equal(expected, sent.Headers.GetValues(AppSignHandler.HeaderSignature).First()); + } + finally + { + SignUtil.NowMillis = original; + } + } + + [Fact] + public async Task Handler剥离basePath_并排序query后签名() + { + var handler = new AppSignHandler(AppKey, Secret, "/userbase", new CaptureHandler()); + var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get, + "http://base.bestsch.com/userbase/oapi/user/byId?b=2&a=1"); + + var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First(); + var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First(); + + // 剥离 /userbase + query 排序:canonical = /oapi/user/byId?a=1&b=2 + Assert.Equal(SignUtil.SignRequest("GET", "/oapi/user/byId?a=1&b=2", ts, Secret), signature); + } + + [Fact] + public async Task Handler未配置basePath_不剥离原样签名() + { + var handler = new AppSignHandler(AppKey, Secret, null, new CaptureHandler()); + var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get, + "http://base.bestsch.com/userbase/oapi/user/byId"); + + var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First(); + var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First(); + + Assert.Equal(SignUtil.SignRequest("GET", "/userbase/oapi/user/byId", ts, Secret), signature); + } + + [Theory] + [InlineData("/userbase/oapi/u", "/userbase", "/oapi/u")] + [InlineData("/userbase", "/userbase", "/")] + [InlineData("/userbase/", "/userbase", "/")] + [InlineData("/userbase/oapi/u", "/userbase/", "/oapi/u")] // basePath 带尾斜杠归一化 + [InlineData("/userbase2/x", "/userbase", "/userbase2/x")] // 前缀不完整匹配不剥离(对齐 Java) + [InlineData("/oapi/u", "/userbase", "/oapi/u")] // 无前缀不剥离 + [InlineData("/userbase/oapi/u", null, "/userbase/oapi/u")] // basePath 空 = 挂根路径 + public void StripBasePath_边界与Java一致(string path, string? basePath, string expected) + { + Assert.Equal(expected, SignCanonical.StripBasePath(path, basePath)); + } + + [Fact] + public void BuildTarget_带basePath_剥离并排序query() + { + Assert.Equal("/oapi/user/byId?a=1&b=2", + SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1"), "/userbase")); + // 不传 basePath 保持既有行为(不剥离,query 仍排序) + Assert.Equal("/userbase/oapi/user/byId?a=1&b=2", + SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1"))); + } +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/GlobalUsings.cs new file mode 100644 index 0000000..cca7d8f --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/GlobalUsings.cs @@ -0,0 +1,10 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Net; +global using System.Net.Http; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; +global using Yh.Platform.Abstractions; +global using Yh.Platform.AppAuth; diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignCanonicalTests.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignCanonicalTests.cs new file mode 100644 index 0000000..494c82b --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignCanonicalTests.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Xunit; +using Yh.Platform.AppAuth; + +namespace Yh.Platform.AppAuth.Tests; + +public class SignCanonicalTests +{ + [Theory] + [InlineData("b=2&a=1", "a=1&b=2")] + [InlineData("a=1", "a=1")] + [InlineData("", "")] + [InlineData("groupId=9&withSub=true", "groupId=9&withSub=true")] + public void SortRawQuery_键值整串排序(string raw, string expected) + { + Assert.Equal(expected, SignCanonical.SortRawQuery(raw)); + } + + [Fact] + public void SortRawQuery_URL解码后排序() + { + // %20 与 + 均解码为空格,解码后按 "k=v" 整串排序 + Assert.Equal("a b=1&a b=2", SignCanonical.SortRawQuery("a%20b=1&a+b=2")); + } + + [Fact] + public void BuildTarget_无query只取path() + { + Assert.Equal("/userbase/oapi/user/byId", SignCanonical.BuildTarget(new Uri("http://host:8080/userbase/oapi/user/byId"))); + Assert.Equal("/userbase/oapi/user/byId?id=123", SignCanonical.BuildTarget(new Uri("http://host:8080/userbase/oapi/user/byId?id=123"))); + } + + [Fact] + public void BuildTarget_query乱序归一化() + { + Assert.Equal("/p?a=1&b=2", SignCanonical.BuildTarget(new Uri("http://h/p?b=2&a=1"))); + } + + [Fact] + public async Task MemoryReplayGuard_同三元组只放行一次() + { + var guard = new MemoryReplayGuard(); + Assert.True(await guard.TryBeginAsync("100476", "1700000000000", "n1")); + Assert.False(await guard.TryBeginAsync("100476", "1700000000000", "n1")); + Assert.True(await guard.TryBeginAsync("100476", "1700000000000", "n2")); + Assert.True(await guard.TryBeginAsync("100477", "1700000000000", "n1")); + } +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignVectorTests.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignVectorTests.cs new file mode 100644 index 0000000..55ddf7f --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignVectorTests.cs @@ -0,0 +1,98 @@ +using System.IO; +using System.Linq; +using Xunit; +using Yh.Platform.AppAuth; + +namespace Yh.Platform.AppAuth.Tests; + +/// 签名向量逐条对拍(基线由独立实现产出,见 contract/fixtures/sign-vectors.json)。 +public class SignVectorTests +{ + public static System.Collections.Generic.IEnumerable VectorCases() + { + var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json"); + var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement; + foreach (var c in root.GetProperty("cases").EnumerateArray()) + { + yield return new object[] { c.Clone() }; + } + } + + [Theory] + [MemberData(nameof(VectorCases))] + public void 向量对拍_逐字节一致(System.Text.Json.JsonElement c) + { + var name = c.GetProperty("name").GetString(); + var ts = c.GetProperty("ts").GetString()!; + var secret = c.GetProperty("secret").GetString()!; + + if (c.TryGetProperty("expectedDataSignHex", out var hex)) + { + var data = c.GetProperty("data").GetString()!; + Assert.True(SignUtil.Sign(data, ts, secret) == hex.GetString(), "数据级向量不一致: " + name); + } + else + { + var method = c.GetProperty("method").GetString()!; + var target = c.GetProperty("canonicalTarget").GetString()!; + var expected = c.GetProperty("expectedRequestSignBase64").GetString()!; + Assert.True(SignUtil.SignRequest(method, target, ts, secret) == expected, "请求级向量不一致: " + name); + } + } + + [Fact] + public void 数据级验签_忽略大小写() + { + var secret = "0123456789abcdef"; + var sign = SignUtil.Sign("hello", "1700000000000", secret); + Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToUpperInvariant())); + Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToLowerInvariant())); + Assert.False(SignUtil.Verify("hello2", "1700000000000", secret, sign)); + } + + [Fact] + public void 请求级验签_精确比对() + { + var secret = "0123456789abcdef"; + var sign = SignUtil.SignRequest("GET", "/p?a=1", "1700000000000", secret); + Assert.True(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign)); + Assert.False(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign.ToLowerInvariant())); + Assert.False(SignUtil.VerifyRequest("POST", "/p?a=1", "1700000000000", secret, sign)); + } + + [Fact] + public void 密钥过短_抛参数异常() + { + Assert.Throws(() => SignUtil.Sign("x", "1700000000000", "short")); + // 16 位恰为最低边界,合法不抛 + Assert.Equal(SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456"), + SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456")); + } + + [Fact] + public void 过期时间戳_拒绝_双向偏差() + { + var secret = "0123456789abcdef"; + var data = "x"; + SignUtil.NowMillis = static () => 1700000000000L; + try + { + var sign = SignUtil.Sign(data, "1700000000000", secret); + Assert.True(SignUtil.VerifyWithExpire(data, "1700000000000", secret, sign)); // 恰好等于 now + Assert.False(SignUtil.VerifyWithExpire(data, "1699999699001", secret, sign)); // 5min-999ms 前 + Assert.False(SignUtil.VerifyWithExpire(data, "1700000301000", secret, sign)); // 5min+1s 后 + } + finally + { + SignUtil.NowMillis = static () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + } + + [Fact] + public void 向量文件至少十组() + { + var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json"); + var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement; + Assert.True(root.GetProperty("cases").GetArrayLength() >= 10); + } +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/Yh.Platform.AppAuth.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/Yh.Platform.AppAuth.Tests.csproj new file mode 100644 index 0000000..83847c4 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/Yh.Platform.AppAuth.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + false + + + + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/AuthContextTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/AuthContextTests.cs new file mode 100644 index 0000000..6648e11 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/AuthContextTests.cs @@ -0,0 +1,161 @@ +using System.Security.Claims; +using Yh.Platform.Abstractions; +using Yh.Platform.Auth; +using Xunit; + +namespace Yh.Platform.Auth.Tests; + +/// CAS serviceValidate 响应 XML 用户提取对拍(对齐 Java CasServiceTicketValidator)。 +public class CasServiceTicketValidatorTests +{ + [Fact] + public void 成功响应_提取cas_user() + { + var xml = """ + + + zhangsan + + + """; + Assert.Equal("zhangsan", CasServiceTicketValidator.ExtractUser(xml)); + } + + [Fact] + public void 带属性容错_提取cas_user() + { + var xml = "lisi"; + Assert.Equal("lisi", CasServiceTicketValidator.ExtractUser(xml)); + } + + [Fact] + public void 失败响应_返回null() + { + var xml = """ + + + Ticket ST-1856339 not recognized + + + """; + Assert.Null(CasServiceTicketValidator.ExtractUser(xml)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("")] + public void 无用户节点或空输入_返回null(string? xml) + { + Assert.Null(CasServiceTicketValidator.ExtractUser(xml)); + } + + [Fact] + public async Task 未配置CAS地址_抛AuthException() + { + var validator = new CasServiceTicketValidator(""); + var ex = await Assert.ThrowsAsync(() => validator.ValidateAsync("ST-1", "http://app/cb")); + Assert.Contains("cas-server-url-prefix", ex.Message); + } +} + +public class BoaUserContextTests +{ + [Fact] + public async Task 异步流内可读取_请求结束清理() + { + Assert.Null(BoaUserContext.Get()); + Assert.Null(UserContextHolder.Get()); + + BoaUser? seenInChild = null; + var user = new BoaUser(1001, "zhangsan", "张三"); + BoaUserContext.Set(user); + UserContextHolder.Set(new LoginUser("1001") { LoginName = "zhangsan", DisplayName = "张三" }); + + await Task.Run(() => + { + // AsyncLocal 值随执行上下文流入子任务 + seenInChild = BoaUserContext.Get(); + }); + + Assert.Same(user, seenInChild); + Assert.Equal("zhangsan", UserContextHolder.Get()?.LoginName); + + BoaUserContext.Clear(); + UserContextHolder.Clear(); + Assert.Null(BoaUserContext.Get()); + Assert.Null(UserContextHolder.Get()); + } + + [Fact] + public void LoginUser_属性封装() + { + var login = new LoginUser("1001") + { + LoginName = "zhangsan", + DisplayName = "张三", + AppKey = "app-001", + }; + Assert.Equal("1001", login.UserId); + Assert.Equal("zhangsan", login.LoginName); + Assert.Equal("张三", login.DisplayName); + Assert.Equal("app-001", login.AppKey); + } +} + +public class UserContextMiddlewareTests +{ + private sealed class StubProvider : IUserDetailsProvider + { + public BoaUser? User { get; set; } = new(1001, "zhangsan", "张三"); + + public Task GetByLoginNameAsync(string loginName, CancellationToken ct = default) + => Task.FromResult(User); + } + + private static HttpContext HttpContextWithLogin(string loginName) + { + var identity = new ClaimsIdentity("CAS"); + identity.AddClaim(new Claim("loginName", loginName)); + var context = new DefaultHttpContext(); + context.User = new ClaimsPrincipal(identity); + return context; + } + + [Fact] + public async Task 认证请求_中间件写入上下文_业务管道内可读() + { + var provider = new StubProvider(); + BoaUser? seen = null; + LoginUser? seenLogin = null; + var middleware = new UserContextMiddleware(_ => + { + seen = BoaUserContext.Get(); + seenLogin = UserContextHolder.Get(); + return Task.CompletedTask; + }); + + await middleware.InvokeAsync(HttpContextWithLogin("zhangsan"), provider); + + Assert.NotNull(seen); + Assert.Equal(1001, seen!.Id); + Assert.Equal("zhangsan", seenLogin?.LoginName); + Assert.Equal("张三", seenLogin?.DisplayName); + // 请求结束后必须清空,防串扰 + Assert.Null(BoaUserContext.Get()); + Assert.Null(UserContextHolder.Get()); + } + + [Fact] + public async Task 未认证请求_不写上下文直接放行() + { + var provider = new StubProvider(); + var middleware = new UserContextMiddleware(_ => Task.CompletedTask); + + var context = new DefaultHttpContext(); // 匿名 + await middleware.InvokeAsync(context, provider); + + Assert.Null(BoaUserContext.Get()); + Assert.Null(UserContextHolder.Get()); + } +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/GlobalUsings.cs new file mode 100644 index 0000000..a628c55 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Collections.Generic; +global using System.Security.Claims; +global using System.Threading; +global using System.Threading.Tasks; +global using Microsoft.AspNetCore.Http; +global using Yh.Platform.Abstractions; +global using Yh.Platform.Auth; diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/SessionTicketDataFormatTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/SessionTicketDataFormatTests.cs new file mode 100644 index 0000000..431492f --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/SessionTicketDataFormatTests.cs @@ -0,0 +1,104 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Yh.Platform.Auth; +using Xunit; + +namespace Yh.Platform.Auth.Tests; + +/// +/// 会话票据格式(SessionTicketDataFormat)单测: +/// 32 位 key 往返、未知 key / 过期票据失效、显式退出(Revoke)立即吊销。 +/// +public class SessionTicketDataFormatTests +{ + private static AuthenticationTicket NewTicket(DateTimeOffset? expiresUtc = null) + { + var identity = new ClaimsIdentity("CAS"); + identity.AddClaim(new Claim(ClaimTypes.Name, "zhangsan")); + var principal = new ClaimsPrincipal(identity); + var props = new AuthenticationProperties(); + if (expiresUtc.HasValue) + { + props.ExpiresUtc = expiresUtc; + } + return new AuthenticationTicket(principal, props, "YhCookie"); + } + + [Fact] + public void Protect_返回32位key_且可往返() + { + var format = new SessionTicketDataFormat(); + var key = format.Protect(NewTicket()); + + Assert.Equal(32, key.Length); // Guid N 格式 = 32 个十六进制字符 + var ticket = format.Unprotect(key); + Assert.NotNull(ticket); + Assert.Equal("zhangsan", ticket!.Principal.FindFirstValue(ClaimTypes.Name)); + } + + [Fact] + public void Unprotect_未知key_返回null() + { + var format = new SessionTicketDataFormat(); + Assert.Null((AuthenticationTicket?)format.Unprotect("00000000000000000000000000000000")); + } + + [Fact] + public void Unprotect_空或null_返回null() + { + var format = new SessionTicketDataFormat(); + Assert.Null((AuthenticationTicket?)format.Unprotect(null)); + Assert.Null((AuthenticationTicket?)format.Unprotect("")); + } + + [Fact] + public void Revoke_票据立即失效() + { + var format = new SessionTicketDataFormat(); + var key = format.Protect(NewTicket()); + + format.Revoke(key); + + Assert.Null((AuthenticationTicket?)format.Unprotect(key)); + } + + [Fact] + public void Revoke_空key_不抛异常() + { + var format = new SessionTicketDataFormat(); + format.Revoke(null); + format.Revoke(""); + format.Revoke(" "); + } + + [Fact] + public void Unprotect_过期票据_返回null() + { + var format = new SessionTicketDataFormat(); + var key = format.Protect(NewTicket(DateTimeOffset.UtcNow.AddSeconds(-1))); + + Assert.Null((AuthenticationTicket?)format.Unprotect(key)); + } + + [Fact] + public void Unprotect_未过期票据_正常往返() + { + var format = new SessionTicketDataFormat(); + var key = format.Protect(NewTicket(DateTimeOffset.UtcNow.AddMinutes(1))); + + Assert.NotNull(format.Unprotect(key)); + } + + [Fact] + public void Revoke_不影响其他票据() + { + var format = new SessionTicketDataFormat(); + var keyA = format.Protect(NewTicket()); + var keyB = format.Protect(NewTicket()); + + format.Revoke(keyA); + + Assert.Null((AuthenticationTicket?)format.Unprotect(keyA)); + Assert.NotNull(format.Unprotect(keyB)); + } +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/Yh.Platform.Auth.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/Yh.Platform.Auth.Tests.csproj new file mode 100644 index 0000000..6bb8921 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/Yh.Platform.Auth.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + false + + + + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs new file mode 100644 index 0000000..18ef2bc --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using Yh.Platform.Abstractions; +using Xunit; + +namespace Yh.Platform.OpenApi.Tests; + +/// +/// 契约快照对拍(双端同步机制的核心防线): +/// 基线 = UserBase springdoc 导出的权威快照 contract/openapi-oapi.exported.json +/// (GET /userbase/v3/api-docs/oapi,UserBase 契约变更后重新导出更新该文件即可激活本测试发现漂移)。 +/// +/// 端点对拍:SDK 契约路由表 ⊆ 快照(method+path 逐项),多出的快照端点以未覆盖清单输出; +/// 模型对拍:SDK POCO 属性集(camelCase)⇔ 快照 schema properties 键集,双向差额为零。 +/// +/// 更新基线流程:UserBase 起服务 → GET /v3/api-docs/oapi 覆盖保存 exported.json → 跑本测试,红了就是契约漂移。 +/// +public class ContractSnapshotTests +{ + private static readonly string SnapshotPath = + Path.Combine(AppContext.BaseDirectory, "contract", "openapi-oapi.exported.json"); + + private static JsonDocument LoadSnapshot() + { + Assert.True(File.Exists(SnapshotPath), $"权威契约快照缺失:{SnapshotPath}(从 UserBase GET /v3/api-docs/oapi 导出)"); + return JsonDocument.Parse(File.ReadAllText(SnapshotPath)); + } + + /// PascalCase → camelCase(首字母小写,覆盖 Id→id、Idcard→idcard 等既有键形)。 + private static string ToCamel(string name) + => string.IsNullOrEmpty(name) ? name : char.ToLowerInvariant(name[0]) + name.Substring(1); + + private static HashSet PocoPropertyNames(Type type) + => type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => ToCamel(p.Name)) + .ToHashSet(StringComparer.Ordinal); + + private static HashSet SchemaPropertyNames(JsonElement schemas, string schemaName) + { + Assert.True(schemas.TryGetProperty(schemaName, out var schema), + $"快照缺少 schema:{schemaName}(UserBase 侧可能已改名/删除)"); + return schema.GetProperty("properties").EnumerateObject() + .Select(p => p.Name) + .ToHashSet(StringComparer.Ordinal); + } + + // ───────────────────────── 端点对拍 ───────────────────────── + + /// SDK 覆盖的契约路由表(method + path;来源:三个 Client 实现,加端点必须同步登记于此)。 + public static readonly (string Method, string Path)[] SdkRoutes = + { + // user 域 + ("GET", "/oapi/user/byId"), + ("GET", "/oapi/user/byLoginName"), + ("GET", "/oapi/user/byPhone"), + ("GET", "/oapi/user/modules"), + ("GET", "/oapi/user/roles"), + ("GET", "/oapi/user/groupState"), + ("POST", "/oapi/user/add"), + ("POST", "/oapi/user/edit/{id}"), + ("POST", "/oapi/user/delete/{id}/{groupId}/{schoolId}"), + ("POST", "/oapi/user/deleteUsers"), + ("POST", "/oapi/user/batchAdd"), + ("POST", "/oapi/user/moveToGroup"), + ("POST", "/oapi/user/password"), + ("POST", "/oapi/user/groupState"), + ("POST", "/oapi/user/transferToSchool"), + ("POST", "/oapi/user/changeStatus"), + ("POST", "/oapi/user/batchChangeStatus"), + // identity 域 + ("GET", "/oapi/identity/current"), + ("GET", "/oapi/identity/available"), + ("POST", "/oapi/identity/switch"), + // group 域 + ("GET", "/oapi/group/tree"), + ("GET", "/oapi/group/byId"), + ("GET", "/oapi/group/list"), + ("POST", "/oapi/group/add"), + ("POST", "/oapi/group/edit/{id}"), + ("POST", "/oapi/group/delete/{id}/{schoolId}"), + ("POST", "/oapi/group/batchAdd"), + ("POST", "/oapi/group/move/{schoolId}"), + // app 域 + ("GET", "/oapi/app/{appId}"), + ("GET", "/oapi/app/list"), + }; + + private static HashSet<(string, string)> SnapshotRoutes(JsonDocument snapshot) + { + var routes = new HashSet<(string, string)>(); + foreach (var path in snapshot.RootElement.GetProperty("paths").EnumerateObject()) + { + foreach (var op in path.Value.EnumerateObject()) + { + if (op.Name is "get" or "post" or "put" or "delete" or "patch") + { + routes.Add((op.Name.ToUpperInvariant(), path.Name)); + } + } + } + return routes; + } + + [Fact] + public void SDK端点必须在快照中存在() + { + var snapshot = LoadSnapshot(); + var snapshotRoutes = SnapshotRoutes(snapshot); + + var missing = SdkRoutes + .Where(r => !snapshotRoutes.Contains((r.Method, r.Path))) + .Select(r => $"{r.Method} {r.Path}") + .ToList(); + Assert.True(missing.Count == 0, + $"以下 SDK 端点在权威快照中不存在(UserBase 侧已删除或改路径,须同步修正 SDK):\n{string.Join('\n', missing)}"); + } + + [Fact] + public void 快照端点若SDK未覆盖须显式登记() + { + var snapshot = LoadSnapshot(); + var snapshotRoutes = SnapshotRoutes(snapshot); + var sdk = SdkRoutes.ToHashSet(); + + var uncovered = snapshotRoutes + .Where(r => !sdk.Contains(r)) + .Select(r => $"{r.Item1} {r.Item2}") + .OrderBy(x => x) + .ToList(); + + // 不直接失败(UserBase 新增端点不应阻塞 SDK CI),但必须显式白名单化——未登记的未覆盖端点视为漂移信号 + var acknowledged = new HashSet(StringComparer.Ordinal) + { + // 当前无未覆盖端点;UserBase 新增 /oapi 端点后,此清单须显式登记并排期跟进,防止无声失联 + }; + + var unacknowledged = uncovered.Where(u => !acknowledged.Contains(u)).ToList(); + Assert.True(unacknowledged.Count == 0, + $"快照存在 SDK 未覆盖且未登记白名单的端点(评估跟进或显式放弃):\n{string.Join('\n', unacknowledged)}"); + } + + // ───────────────────────── 模型对拍 ───────────────────────── + + /// SDK 模型 ↔ 快照 schema 映射表(新模型对齐必须登记于此)。 + public static readonly (Type Type, string Schema)[] ModelMappings = + { + (typeof(BoaUser), "BoaUser"), + (typeof(BoaRoleInfo), "BoaRoleInfo"), + (typeof(BoaGroup), "BoaGroup"), + (typeof(BoaModule), "BoaModule"), + (typeof(BoaApp), "BoaApp"), + (typeof(BoaAppType), "BoaAppType"), + (typeof(SysUserRequest), "SysUserRequest"), + (typeof(SysUserGroupDeleteItem), "SysUserGroupDeleteItem"), + (typeof(SysUserMoveRequest), "SysUserMoveRequest"), + (typeof(SysUserGroupStateRequest), "SysUserGroupStateRequest"), + (typeof(SysUserTransferSchoolRequest), "SysUserTransferSchoolRequest"), + (typeof(SysGroupRequest), "SysGroupRequest"), + (typeof(SysGroupMoveRequest), "SysGroupMoveRequest"), + }; + + [Theory] + [MemberData(nameof(ModelCases))] + public void 模型字段集与快照双向一致(Type type, string schemaName) + { + var snapshot = LoadSnapshot(); + var schemas = snapshot.RootElement.GetProperty("components").GetProperty("schemas"); + + var poco = PocoPropertyNames(type); + var server = SchemaPropertyNames(schemas, schemaName); + + var sdkOnly = poco.Except(server).ToList(); + var serverOnly = server.Except(poco).ToList(); + + Assert.True(sdkOnly.Count == 0, + $"{type.Name} 存在快照没有的属性(服务端收不到,序列化键漂移):[{string.Join(", ", sdkOnly)}]"); + Assert.True(serverOnly.Count == 0, + $"{type.Name} 缺少快照属性(服务端字段被丢弃):[{string.Join(", ", serverOnly)}]"); + } + + public static IEnumerable ModelCases() + => ModelMappings.Select(m => new object[] { m.Type, m.Schema }); +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/EnvelopeParsingTests.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/EnvelopeParsingTests.cs new file mode 100644 index 0000000..7fd3cb1 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/EnvelopeParsingTests.cs @@ -0,0 +1,185 @@ +using System.Net; +using System.Text; +using Yh.Platform.Abstractions; +using Yh.Platform.OpenApi; +using Xunit; + +namespace Yh.Platform.OpenApi.Tests; + +/// 伪造 HttpMessageHandler:捕获最近一次请求并回放预设响应。 +public sealed class FakeHandler : HttpMessageHandler +{ + private readonly Func _responder; + + public FakeHandler(Func responder) + { + _responder = responder; + } + + public HttpRequestMessage? LastRequest { get; private set; } + + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastRequestBody = request.Content == null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return _responder(request, LastRequestBody); + } + + public static FakeHandler Json(HttpStatusCode statusCode, string json) + => new((_, _) => new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }); +} + +/// +/// 信封感知解析对拍(对齐 contract/fixtures/envelope-*.json 约定): +/// 成功为裸数据;业务失败为 HTTP 200 + {code≠0,msg,data},客户端必须抛 DException。 +/// +public class EnvelopeParsingTests +{ + private static OpenApiOptions Options() => new() { BaseHost = "http://base.bestsch.com", BasePath = "/userbase" }; + + private static OpenApiUserClient Client(FakeHandler handler) => new(new HttpClient(handler), Options()); + + [Fact] + public async Task 裸数据成功响应_解析为BoaUser() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, + """{"id":1001,"account":"zhangsan","name":"张三","currentRole":{"roleId":3,"roleName":"教师","schId":2}}"""); + var user = await Client(handler).GetUserByIdAsync("1001"); + + Assert.NotNull(user); + Assert.Equal(1001, user!.Id); + Assert.Equal("zhangsan", user.Account); + Assert.Equal("张三", user.Name); + Assert.NotNull(user.CurrentRole); + Assert.Equal(3, user.CurrentRole!.RoleId); + Assert.Equal("教师", user.CurrentRole.RoleName); + } + + [Fact] + public async Task 包裹信封code0_提取data节点() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, + """{"code":0,"msg":"","data":{"id":1001,"account":"zhangsan","name":"张三"}}"""); + var user = await Client(handler).GetUserByIdAsync("1001"); + + Assert.NotNull(user); + Assert.Equal("zhangsan", user!.Account); + } + + [Fact] + public async Task 业务失败信封_抛DException并携带code与data() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, + """{"code":5,"msg":"组下存在子组或用户,禁止删除","data":{"groupId":9,"users":35}}"""); + + var ex = await Assert.ThrowsAsync(() => new OpenApiGroupClient(new HttpClient(handler), Options()).GetGroupByIdAsync(9)); + + var ec = Assert.IsType(ex.Code); + Assert.Equal(5, ec.Code); + Assert.Equal("组下存在子组或用户,禁止删除", ec.Msg); + Assert.Contains("禁止删除", ex.Message); + Assert.NotNull(ex.Data); + } + + [Fact] + public async Task 业务失败信封data为null_EnvelopeData为null() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, + """{"code":4,"msg":"登录已经过期,请重新登录","data":null}"""); + + var ex = await Assert.ThrowsAsync(() => Client(handler).GetUserByIdAsync("1")); + + Assert.Equal(4, Assert.IsType(ex.Code).Code); + Assert.Null(ex.EnvelopeData); + } + + [Fact] + public async Task 非成功HTTP状态_抛OpenApiException() + { + var handler = FakeHandler.Json(HttpStatusCode.InternalServerError, "boom"); + + var ex = await Assert.ThrowsAsync(() => Client(handler).GetUserByIdAsync("1")); + + Assert.Contains("500", ex.Message); + } + + [Fact] + public async Task 列表解析_裸数组与单对象两种形态() + { + var arrayHandler = FakeHandler.Json(HttpStatusCode.OK, + """[{"roleId":3,"roleName":"教师","schId":2},{"roleId":5,"roleName":"班主任","schId":2}]"""); + var roles = await Client(arrayHandler).GetUserRoleListAsync(1001); + Assert.Equal(2, roles.Count); + Assert.Equal("教师", roles[0].RoleName); + Assert.Equal(5, roles[1].RoleId); + + var singleHandler = FakeHandler.Json(HttpStatusCode.OK, """{"roleId":3,"roleName":"教师","schId":2}"""); + var single = await Client(singleHandler).GetUserRoleListAsync(1001); + Assert.Single(single); + } + + [Fact] + public async Task GET请求_URL与query构造正确() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, "null"); + await Client(handler).GetUserModuleAsync(1001, 2); + + var url = handler.LastRequest!.RequestUri!; + Assert.Equal("http://base.bestsch.com/userbase/oapi/user/modules", $"{url.Scheme}://{url.Host}{url.AbsolutePath}"); + Assert.Equal("?userId=1001&schId=2", url.Query); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + } + + [Fact] + public async Task query中的null值参数被丢弃() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, "[]"); + await Client(handler).GetUserModuleAsync(1001, null); + + Assert.DoesNotContain("schId", handler.LastRequest!.RequestUri!.Query); + Assert.Equal("?userId=1001", handler.LastRequest.RequestUri.Query); + } + + [Fact] + public async Task POST请求_编辑用户时路径id回写请求体() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """{"id":7,"account":"lisi","name":"李四"}"""); + var request = new SysUserRequest { Account = "lisi", RealName = "李四" }; + + await Client(handler).EditUserAsync(7, request); + + Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); + Assert.EndsWith("/userbase/oapi/user/edit/7", handler.LastRequest.RequestUri!.AbsolutePath); + Assert.NotNull(handler.LastRequestBody); + Assert.Contains("\"id\":7", handler.LastRequestBody); + } + + [Fact] + public async Task void解析_业务失败仍抛异常_成功不抛() + { + var ok = FakeHandler.Json(HttpStatusCode.OK, """{"code":0,"msg":"","data":null}"""); + await Client(ok).DeleteUserAsync(1, 2, 3); + + var fail = FakeHandler.Json(HttpStatusCode.OK, """{"code":5,"msg":"无权限","data":null}"""); + await Assert.ThrowsAsync(() => Client(fail).DeleteUserAsync(1, 2, 3)); + } + + [Fact] + public async Task 空响应体_返回default不抛() + { + var handler = new FakeHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("", Encoding.UTF8, "application/json"), + }); + + var user = await Client(handler).GetUserByIdAsync("1"); + Assert.Null(user); + } +} diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/GlobalUsings.cs new file mode 100644 index 0000000..1a73b47 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using System; +global using System.Collections.Generic; +global using System.Net; +global using System.Net.Http; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; +global using Yh.Platform.Abstractions; +global using Yh.Platform.OpenApi; diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/Yh.Platform.OpenApi.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/Yh.Platform.OpenApi.Tests.csproj new file mode 100644 index 0000000..b47999a --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/Yh.Platform.OpenApi.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + false + + + + + + + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/YschBaseOpenApiTests.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/YschBaseOpenApiTests.cs new file mode 100644 index 0000000..f9baeae --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/YschBaseOpenApiTests.cs @@ -0,0 +1,117 @@ +using Yh.Platform.Abstractions; +using Yh.Platform.OpenApi; +using Xunit; + +namespace Yh.Platform.OpenApi.Tests; + +public class OpenApiOptionsTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("/userbase", "/userbase")] + [InlineData("/userbase/", "/userbase")] + [InlineData("userbase", "/userbase")] + public void NormalizeBasePath_归一化规则(string? input, string? expected) + { + Assert.Equal(expected, OpenApiOptions.NormalizeBasePath(input)); + } + + [Fact] + public void ResolveBaseUrl_拼接host与path并去尾斜杠() + { + var options = new OpenApiOptions { BaseHost = "http://base.bestsch.com/", BasePath = "/userbase/" }; + Assert.Equal("http://base.bestsch.com/userbase", options.ResolveBaseUrl()); + } + + [Fact] + public void ResolveBaseUrl_path为空时挂根路径() + { + var options = new OpenApiOptions { BaseHost = "http://base.bestsch.com", BasePath = "" }; + Assert.Equal("http://base.bestsch.com", options.ResolveBaseUrl()); + } + + [Fact] + public void 默认值_对齐Java客户端() + { + var options = new OpenApiOptions(); + Assert.True(options.Enabled); + Assert.Equal("/userbase", options.BasePath); + Assert.Equal(1500, options.ConnectTimeoutMs); + Assert.Equal(5000, options.ReadTimeoutMs); + Assert.Equal(5000, options.WriteTimeoutMs); + } +} + +/// 静态门面 的初始化准入与委托行为。 +public class YschBaseOpenApiTests : IDisposable +{ + private sealed class StubUserClient : IOpenApiUserClient + { + public int Calls; + public Task GetUserInfoAsync(int userId, CancellationToken ct = default) + { + Calls++; + return Task.FromResult(new BoaUser(userId, "stub", "桩用户")); + } + + public Task GetUserByIdAsync(string userId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetUserByPhoneAsync(string phone, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetUserInfoAsync(string loginName, CancellationToken ct = default) => throw new NotImplementedException(); + public Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) => throw new NotImplementedException(); + public Task AddUserAsync(SysUserRequest request, CancellationToken ct = default) => throw new NotImplementedException(); + public Task EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default) => throw new NotImplementedException(); + public Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task DeleteUsersAsync(IReadOnlyList items, CancellationToken ct = default) => throw new NotImplementedException(); + public Task> BatchAddUsersAsync(IReadOnlyList requests, CancellationToken ct = default) => throw new NotImplementedException(); + public Task MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default) => throw new NotImplementedException(); + public Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default) => throw new NotImplementedException(); + public Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default) => throw new NotImplementedException(); + public Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default) => throw new NotImplementedException(); + public Task BatchChangeUserStatusAsync(IReadOnlyList userIds, bool disabled, CancellationToken ct = default) => throw new NotImplementedException(); + } + + public YschBaseOpenApiTests() + { + YschBaseOpenApi.SetUserClient(null); + YschBaseOpenApi.SetGroupClient(null); + YschBaseOpenApi.SetAppClient(null); + } + + public void Dispose() + { + YschBaseOpenApi.SetUserClient(null); + YschBaseOpenApi.SetGroupClient(null); + YschBaseOpenApi.SetAppClient(null); + } + + [Fact] + public async Task 未初始化时抛InvalidOperationException并给出引导信息() + { + var ex = await Assert.ThrowsAsync(() => YschBaseOpenApi.GetUserInfoAsync(1)); + Assert.Contains("AddYhOpenApi", ex.Message); + Assert.Contains("SetUserClient", ex.Message); + } + + [Fact] + public async Task 初始化后门面委托到注入的client() + { + var stub = new StubUserClient(); + YschBaseOpenApi.SetUserClient(stub); + + var user = await YschBaseOpenApi.GetUserInfoAsync(1001); + + Assert.Equal(1, stub.Calls); + Assert.NotNull(user); + Assert.Equal(1001, user!.Id); + Assert.Equal("stub", user.Account); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/DebugMain.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/DebugMain.cs new file mode 100644 index 0000000..54828b7 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/DebugMain.cs @@ -0,0 +1,53 @@ +// 测试工程的运行调试入口(非测试用例)。 +// +// 用法: +// dotnet run --project tests/Yh.SchClass.OpenApi.Tests # 一键跑通全部 20 个端点 +// dotnet run --project tests/Yh.SchClass.OpenApi.Tests -- --only=OAStu +// dotnet run --project tests/Yh.SchClass.DebugTool -- --list # 端点名清单见 DebugTool +// +// 地址优先级同 SchClassLiveDebug:环境变量 YH_SCHCLASS_DEBUG_* → appsettings.json → 内置默认。 +// 在 IDE 里直接 F5 调试本项目即可进入这里(在 Main 内打断点)。 +// +// 注意:csproj 已设 GenerateProgramFile=false(关掉 Test SDK 自动 Main),dotnet test 不受影响。 + +using System.Text; + +namespace Yh.SchClass.OpenApi.Tests; + +public static class DebugMain +{ + public static async Task Main(string[] args) + { + Console.OutputEncoding = Encoding.UTF8; + + string? only = null; + foreach (var arg in args) + { + if (arg.StartsWith("--only=", StringComparison.OrdinalIgnoreCase)) + { + only = arg[7..].Trim(); + } + else if (arg.Equals("--off", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("跳过联调:--off(同 YH_SCHCLASS_DEBUG_OFF=1)"); + return 0; + } + } + + if (Environment.GetEnvironmentVariable(SchClassLiveDebug.OffVar) is "1" or "true") + { + Console.WriteLine("跳过联调:已设置 " + SchClassLiveDebug.OffVar + "=1(清除该变量即可恢复联调)"); + return 0; + } + + Console.WriteLine("== SchClass OpenAPI 测试工程调试入口 =="); + var failures = await SchClassLiveDebug.RunAllAsync(Console.WriteLine, only); + if (failures.Count == 0) + { + Console.WriteLine("全部通过 ✅"); + return 0; + } + Console.WriteLine($"失败 {failures.Count} 个:{string.Join(", ", failures)}"); + return 1; + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/FakeHandler.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/FakeHandler.cs new file mode 100644 index 0000000..6049c13 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/FakeHandler.cs @@ -0,0 +1,58 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// +/// 伪造 :捕获最近一次请求并回放预设响应。 +/// 与 Yh.Platform.OpenApi.Tests 的 FakeHandler 同构,便于两个 SDK 的测试互相参照。 +/// +public sealed class FakeHandler : HttpMessageHandler +{ + private readonly Func _responder; + + public FakeHandler(Func responder) + { + _responder = responder; + } + + /// 最近一次捕获到的请求。 + public HttpRequestMessage? LastRequest { get; private set; } + + /// 最近一次请求体(无请求体时为 null)。 + public string? LastRequestBody { get; private set; } + + /// 最近一次请求体的 Content-Type(无请求体时为 null)。 + public string? LastContentType { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + if (request.Content == null) + { + LastRequestBody = null; + LastContentType = null; + } + else + { + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + LastContentType = request.Content.Headers.ContentType?.MediaType; + } + return _responder(request, LastRequestBody); + } + + /// 回放一段固定 JSON(application/json)。 + public static FakeHandler Json(HttpStatusCode statusCode, string json) + => new((_, _) => new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }); + + /// 回放指定状态码且无响应体(模拟 204 / 404 空响应)。 + public static FakeHandler Empty(HttpStatusCode statusCode) + => new((_, _) => new HttpResponseMessage(statusCode)); + + /// 回放纯文本响应(模拟非 JSON 的错误页)。 + public static FakeHandler Text(HttpStatusCode statusCode, string text) + => new((_, _) => new HttpResponseMessage(statusCode) + { + Content = new StringContent(text, Encoding.UTF8, "text/plain"), + }); +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/GlobalUsings.cs new file mode 100644 index 0000000..ba4abeb --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/GlobalUsings.cs @@ -0,0 +1,16 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Net; +global using System.Net.Http; +global using System.Text; +global using System.Text.Json; +global using System.Threading; +global using System.Threading.Tasks; +global using Yh.SchClass.OpenApi; +global using Yh.SchClass.OpenApi.Model; +global using Xunit; + +// Java 侧 Map 兼容行的 .NET 承载类型(与 src 侧同名别名保持一致) +global using SchClassRow = System.Collections.Generic.Dictionary; diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebug.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebug.cs new file mode 100644 index 0000000..76e2c2c --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebug.cs @@ -0,0 +1,187 @@ +using Microsoft.Extensions.Configuration; + +namespace Yh.SchClass.OpenApi.Tests; + +/// +/// 联调调试用的环境配置。地址来源优先级: +/// +/// 1. 环境变量(YH_SCHCLASS_DEBUG_HOST / YH_SCHCLASS_DEBUG_PATH); +/// 2. appsettings.json:先独立节 yh:schclass:open,再共享节 yh:open 的 +/// BaseHost + SchClassPath; +/// 3. 内置默认 + 。 +/// +/// +/// 关闭联调:置 YH_SCHCLASS_DEBUG_OFF=1,用例全部空跑通过(CI 用)。 +/// +/// +internal static class SchClassLiveDebug +{ + public const string HostVar = "YH_SCHCLASS_DEBUG_HOST"; + public const string PathVar = "YH_SCHCLASS_DEBUG_PATH"; + public const string SchVar = "YH_SCHCLASS_DEBUG_SCH"; + public const string ClassVar = "YH_SCHCLASS_DEBUG_CLASS"; + public const string TeaVar = "YH_SCHCLASS_DEBUG_TEA"; + public const string GroupVar = "YH_SCHCLASS_DEBUG_GROUP"; + public const string OffVar = "YH_SCHCLASS_DEBUG_OFF"; + + /// 配置与配置文件都未提供时的兜底联调主机。 + public const string DefaultHost = "http://base.yunhaixingtong.com"; + + /// 配置与配置文件都未提供时的兜底应用路径。 + public const string DefaultPath = "/bschclass"; + + private static readonly IConfiguration? Config; + + static SchClassLiveDebug() + { + var file = System.IO.Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (File.Exists(file)) + { + Config = new ConfigurationBuilder().AddJsonFile(file, optional: true).Build(); + } + } + + /// 是否开启联调(默认开启;置 YH_SCHCLASS_DEBUG_OFF=1 可关闭,CI 用它空跑)。 + public static bool Enabled + { + get + { + var off = Environment.GetEnvironmentVariable(OffVar); + return !(off == "1" || "true".Equals(off, StringComparison.OrdinalIgnoreCase)); + } + } + + private static string? Env(string name) + { + var value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string? Configured(string path) + { + var value = Config?[path]; + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + /// 联调主机:环境变量 → yh:schclass:open:BaseHost → yh:open:BaseHost → 内置默认。 + public static string Host => + Env(HostVar) + ?? Configured("yh:schclass:open:BaseHost") + ?? Configured("yh:open:BaseHost") + ?? DefaultHost; + + /// 联调基础路径:环境变量 → yh:schclass:open:BasePath → yh:open:SchClassPath → 内置默认。 + public static string? Path => + Env(PathVar) + ?? Configured("yh:open:SchClassPath") + ?? DefaultPath; + + /// 按当前配置构建门面。 + public static SchClassOpenApi Create() + { + var options = new SchClassOpenApiOptions { BaseHost = Host, BasePath = Path }; + return SchClassOpenApiFactory.Create(options); + } + + /// 联调目标地址(用于日志输出)。 + public static string Target() + { + var options = new SchClassOpenApiOptions { BaseHost = Host, BasePath = Path }; + return options.ResolveBaseUrl(); + } + + public static string? Sch => Env(SchVar); + public static string? ClassId => Env(ClassVar); + public static int? TeaSerId => int.TryParse(Env(TeaVar), out var id) ? id : null; + + /// DbClass 分组号,缺省 3(对联调环境真实有效的分组)。 + public static int? Group => int.TryParse(Env(GroupVar), out var g) ? g : null; + + /// + /// 一键跑通全部端点(测试用例与 DebugMain.Main 调试入口共用)。 + /// 缺省 ID 一律从真实数据推导:用捏造的 ID 会让端点恒成立 0 条,把真实问题伪装成"接口空"。 + /// 返回失败端点名列表; 输出进度。 + /// + public static async Task> RunAllAsync(Action log, string? only = null) + { + var api = Create(); + var sch = Sch + ?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId ?? "S001"; + var group = Group ?? 3; + + var classId = ClassId; + if (classId == null) + { + var cls = (await api.VClass.GetVClassListAsync(sch)).FirstOrDefault(); + classId = cls != null && cls.TryGetValue("ClassID", out var cid) + ? cid.ToString() + : "1001"; + } + + var teaId = TeaSerId; + if (teaId == null) + { + var tea = (await api.DbClass.GetSchteaListAsync(group)).FirstOrDefault(); + teaId = tea != null + && tea.TryGetValue("UserTeaSerID", out var tid) + && int.TryParse(tid.ToString(), out var parsed) + ? parsed + : 77; + } + + var cases = new List<(string Name, Func> Call)> + { + // Group=3 对应联调环境真实存在的分组;可用 YH_SCHCLASS_DEBUG_GROUP 覆盖 + ("DbClass.GetSchteaList", async () => (await api.DbClass.GetSchteaListAsync(group)).Count), + ("VSchClass.GetVClassList", async () => (await api.VClass.GetVClassListAsync(sch)).Count), + ("VSchClass.GetSchArrVClassListAll", async () => (await api.VClass.GetSchArrVClassListAllAsync()).Count), + ("VSchClass.GetSchArrVClassListPost", async () => (await api.VClass.GetSchArrVClassListPostAsync(sch)).Count), + ("Schoolen.GetSchLenLevellist", async () => (await api.Schooling.GetSchLenLevelListAsync(sch)).Count), + ("Schoolen.GetSchLenlist", async () => (await api.Schooling.GetSchLenListAsync(sch)).Count), + ("UserInfo.GetUserList", async () => (await api.UserInfo.GetUserListAsync(sch)).Count), + ("SchInfo.AllList", async () => (await api.SchInfo.AllListAsync()).Count), + ("SchInfo.GetSchlist", async () => (await api.SchInfo.GetSchListAsync(null)).Count), + ("OATea.GetSchTeainfo", async () => (await api.TeaInfo.GetSchTeaInfoAsync(sch, null)).Count), + ("OATea.GetArrTeainfo", async () => (await api.TeaInfo.GetArrTeaInfoAsync(teaId.Value.ToString())).Count), + ("OAStu.GetAllStulist", async () => (await api.StuInfo.GetAllStuListAsync()).Count), + ("OAStu.GetSchStulist", async () => (await api.StuInfo.GetSchStuListAsync(sch, null)).Count), + ("OAStu.GetClaStulist", async () => (await api.StuInfo.GetClaStuListAsync(classId, null)).Count), + ("TeachInfo.GetSchTeachlist", async () => (await api.TeachInfo.GetSchTeachListAsync(sch, teaId)).Count), + ("TeachInfo.GetSchClaTeachlist", async () => (await api.TeachInfo.GetSchClaTeachListAsync(classId)).Count), + ("SubInfo.GetSchTeachSublist", async () => (await api.SubInfo.GetSchTeachSubListAsync(sch)).Count), + ("SubInfo.GetSchSublist", async () => (await api.SubInfo.GetSchSubListAsync(sch, null, null)).Count), + ("FamCom.GetStuUserFamIDList", async () => (await api.FamilyCompat.GetStuUserFamIdListAsync("1,2")).Count), + ("FamCom.GetAllStuFamMem", async () => (await api.FamilyCompat.GetAllStuFamMemAsync("1,2")).Count), + }; + + if (only != null) + { + cases = cases.Where(c => c.Name.Contains(only, StringComparison.OrdinalIgnoreCase)).ToList(); + if (cases.Count == 0) + { + log($"--only={only} 未匹配任何端点"); + return new List { "--only" }; + } + } + + var failures = new List(); + log($"联调目标:{Target()} sch={sch} classId={classId} teaId={teaId} group={group}"); + + foreach (var (name, call) in cases) + { + try + { + var count = await call(); + log($" OK {name} -> {count} 条"); + } + catch (Exception e) + { + failures.Add(name); + log($" FAIL {name} -> {e.GetType().Name}: {e.Message}"); + } + } + + log($"汇总:{cases.Count - failures.Count}/{cases.Count} 通过"); + return failures; + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebugTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebugTests.cs new file mode 100644 index 0000000..7589706 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebugTests.cs @@ -0,0 +1,88 @@ +using Xunit.Abstractions; + +namespace Yh.SchClass.OpenApi.Tests; + +/// +/// 联调调试用例:默认请求联调环境 http://base.yunhaixingtong.com/bschclass, +/// 并把每个端点的返回条数/异常打印到测试输出,便于在 IDE 调试窗口里逐个端点排查。 +/// +/// 用 YH_SCHCLASS_DEBUG_HOST / YH_SCHCLASS_DEBUG_PATH 覆盖目标地址, +/// 置 YH_SCHCLASS_DEBUG_OFF=1 可关闭联调(CI 用它空跑通过)。 +/// +/// +public class SchClassLiveDebugTests(ITestOutputHelper output) +{ + private bool Live() + { + if (SchClassLiveDebug.Enabled) + { + return true; + } + output.WriteLine("跳过联调:已设置 " + SchClassLiveDebug.OffVar + "=1(清除该变量即可恢复联调)"); + return false; + } + + [Fact] + public async Task 联调_学校列表() + { + if (!Live()) return; + + var api = SchClassLiveDebug.Create(); + var list = await api.SchInfo.AllListAsync(); + + foreach (var s in list) + { + output.WriteLine($"SchInfo: {s.SchSerId} / {s.UserSchSerId} / {s.SchName}"); + } + output.WriteLine($"共 {list.Count} 所学校 —— {SchClassLiveDebug.Target()}"); + } + + [Fact] + public async Task 联调_班级与学生() + { + if (!Live()) return; + + var api = SchClassLiveDebug.Create(); + var sch = SchClassLiveDebug.Sch + ?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId; + if (string.IsNullOrEmpty(sch)) + { + output.WriteLine("未取到学校序列号(设置 YH_SCHCLASS_DEBUG_SCH 或确认学校列表非空)"); + return; + } + + var classes = await api.VClass.GetVClassListAsync(sch); + output.WriteLine($"VSchClass[{sch}] 共 {classes.Count} 个班级"); + + var students = await api.StuInfo.GetSchStuListAsync(sch, null); + output.WriteLine($"OAStu[{sch}] 共 {students.Count} 名学生"); + } + + [Fact] + public async Task 联调_教师与任课() + { + if (!Live()) return; + + var api = SchClassLiveDebug.Create(); + var sch = SchClassLiveDebug.Sch + ?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId; + + var teachers = await api.TeaInfo.GetSchTeaInfoAsync(sch, null); + output.WriteLine($"OATea[{sch}] 共 {teachers.Count} 名教师"); + + if (SchClassLiveDebug.TeaSerId is { } teaId) + { + var teach = await api.TeachInfo.GetSchTeachListAsync(sch, teaId); + output.WriteLine($"TeachInfo[{teaId}] 共 {teach.Count} 条任课"); + } + } + + [Fact] + public async Task 联调_一键跑通全部端点() + { + if (!Live()) return; + + var failures = await SchClassLiveDebug.RunAllAsync(output.WriteLine); + Assert.Empty(failures); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiConfigTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiConfigTests.cs new file mode 100644 index 0000000..750322c --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiConfigTests.cs @@ -0,0 +1,142 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Yh.SchClass.OpenApi.Tests; + +/// +/// 配置读取测试:对应 appsettings.json 的真实结构 —— +/// +/// "yh": { "open": { +/// "BaseHost": "http://base.yunhaixingtong.com", +/// "BasePath": "/userbase", // UserBase 用,教务不用它 +/// "SchClassPath": "/bschclass" // 教务用它 +/// }} +/// +/// +public class SchClassOpenApiConfigTests +{ + private static IConfiguration BuildConfig(Dictionary kv) + => new ConfigurationBuilder().AddInMemoryCollection(kv).Build()!; + + [Fact] + public void 共享节_SchClassPath映射为教务BasePath() + { + var config = BuildConfig(new Dictionary + { + ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", + ["yh:open:BasePath"] = "/userbase", + ["yh:open:SchClassPath"] = "/bschclass", + }); + + var options = new SchClassOpenApiOptions(); + var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); + options.FillFromSharedSection(key => shared[key]); + + Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost); + // 关键:教务用 SchClassPath,不是共享节里的 BasePath(/userbase) + Assert.Equal("/bschclass", options.BasePath); + Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl()); + } + + [Fact] + public void 共享节_路径带空格与斜杠可归一化() + { + var config = BuildConfig(new Dictionary + { + ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", + ["yh:open:SchClassPath"] = " /bschclass/", // 模拟配置里常见的多余空格/尾斜杠 + }); + + var options = new SchClassOpenApiOptions(); + var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); + options.FillFromSharedSection(key => shared[key]); + + Assert.Equal("/bschclass", options.BasePath); + Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl()); + } + + [Fact] + public void 共享节_未配SchClassPath时BasePath保持空() + { + var config = BuildConfig(new Dictionary + { + ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", + ["yh:open:BasePath"] = "/userbase", // 不允许被教务误用 + }); + + var options = new SchClassOpenApiOptions(); + var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); + options.FillFromSharedSection(key => shared[key]); + + Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost); + Assert.Null(options.BasePath); + Assert.Equal("http://base.yunhaixingtong.com", options.ResolveBaseUrl()); + } + + [Fact] + public void 独立节优先_共享节仅回填缺失项() + { + var config = BuildConfig(new Dictionary + { + ["yh:schclass:open:BasePath"] = "/sch-only", // 独立节只给了路径 + ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", + ["yh:open:SchClassPath"] = "/bschclass", + ["yh:open:AppKey"] = "100002", + ["yh:open:AppSecret"] = "secret", + }); + + var options = new SchClassOpenApiOptions(); + config.GetSection(SchClassOpenApiOptions.SectionName).Bind(options); + var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); + options.FillFromSharedSection(key => shared[key]); + + Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost); // 从共享节回填 + Assert.Equal("/sch-only", options.BasePath); // 独立节优先,不被覆盖 + Assert.Equal("100002", options.AppKey); // AppKey 回填 + Assert.Equal("secret", options.AppSecret); + } + + [Fact] + public void DI_注册后可解析门面且地址来自配置() + { + var config = BuildConfig(new Dictionary + { + ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", + ["yh:open:SchClassPath"] = "/bschclass", + }); + + var services = new ServiceCollection(); + services.AddSchClassOpenApi(config); + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl()); + + var facade = provider.GetRequiredService(); + Assert.NotNull(facade.SchInfo); + Assert.NotNull(facade.StuInfo); + Assert.Equal(new Uri("http://base.yunhaixingtong.com/bschclass"), facade.HttpBaseAddress); + } + + [Fact] + public void DI_独立节配置整节生效() + { + var config = BuildConfig(new Dictionary + { + ["yh:schclass:open:BaseHost"] = "http://sch.example.com", + ["yh:schclass:open:BasePath"] = "/sch", + ["yh:schclass:open:ReadTimeoutMs"] = "3000", + ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", + }); + + var services = new ServiceCollection(); + services.AddSchClassOpenApi(config); + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + Assert.Equal("http://sch.example.com", options.BaseHost); + Assert.Equal("/sch", options.BasePath); + Assert.Equal(3000, options.ReadTimeoutMs); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiFacadeTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiFacadeTests.cs new file mode 100644 index 0000000..8cf243c --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiFacadeTests.cs @@ -0,0 +1,94 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// 聚合门面 与工厂 的装配行为。 +public class SchClassOpenApiFacadeTests +{ + [Fact] + public void 门面装配全部10个子客户端() + { + var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com/bschclass/oapi/" }; + using var http = new HttpClient(); + + var api = new SchClassOpenApi(http, options); + + Assert.NotNull(api.DbClass); + Assert.NotNull(api.VClass); + Assert.NotNull(api.Schooling); + Assert.NotNull(api.UserInfo); + Assert.NotNull(api.SchInfo); + Assert.NotNull(api.TeaInfo); + Assert.NotNull(api.StuInfo); + Assert.NotNull(api.TeachInfo); + Assert.NotNull(api.SubInfo); + Assert.NotNull(api.FamilyCompat); + Assert.Same(options, api.Options); + } + + [Fact] + public void 门面构造_httpClient为空时抛() + { + Assert.Throws( + () => new SchClassOpenApi(null!, new SchClassOpenApiOptions())); + } + + [Fact] + public void 门面构造_options为空时抛() + { + using var http = new HttpClient(); + + Assert.Throws(() => new SchClassOpenApi(http, null!)); + } + + [Fact] + public void 子客户端构造_参数为空时抛() + { + using var http = new HttpClient(); + var options = new SchClassOpenApiOptions(); + + Assert.Throws(() => new SchClassVClassClient(null!, options)); + Assert.Throws(() => new SchClassVClassClient(http, null!)); + } + + [Fact] + public void 工厂_按配置创建HttpClient() + { + var options = new SchClassOpenApiOptions + { + BaseHost = "http://base.yunhaixingtong.com/", + BasePath = "/bschclass", + ReadTimeoutMs = 3000, + }; + + using var http = SchClassOpenApiFactory.CreateHttpClient(options); + + Assert.Equal(new Uri("http://base.yunhaixingtong.com/bschclass"), http.BaseAddress); + Assert.Equal(TimeSpan.FromMilliseconds(3000), http.Timeout); + } + + [Fact] + public void 工厂_读超时非法时回落5秒() + { + var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com", ReadTimeoutMs = 0 }; + + using var http = SchClassOpenApiFactory.CreateHttpClient(options); + + Assert.Equal(TimeSpan.FromMilliseconds(5000), http.Timeout); + } + + [Fact] + public void 工厂_创建完整门面() + { + var facade = SchClassOpenApiFactory.Create(new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com" }); + + Assert.NotNull(facade); + Assert.NotNull(facade.VClass); + Assert.NotNull(facade.StuInfo); + } + + [Fact] + public void 工厂_配置为空时抛() + { + Assert.Throws(() => SchClassOpenApiFactory.CreateHttpClient(null!)); + Assert.Throws(() => SchClassOpenApiFactory.Create(null!)); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiOptionsTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiOptionsTests.cs new file mode 100644 index 0000000..7e40e7d --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiOptionsTests.cs @@ -0,0 +1,64 @@ +namespace Yh.SchClass.OpenApi.Tests; + +public class SchClassOpenApiOptionsTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("/base.yunhaixingtong.com", "/base.yunhaixingtong.com")] + [InlineData("/base.yunhaixingtong.com/", "/base.yunhaixingtong.com")] + [InlineData("base.yunhaixingtong.com", "/base.yunhaixingtong.com")] + [InlineData(" /base.yunhaixingtong.com/ ", "/base.yunhaixingtong.com")] + public void NormalizeBasePath_归一化规则(string? input, string? expected) + { + Assert.Equal(expected, SchClassOpenApiOptions.NormalizeBasePath(input)); + } + + [Fact] + public void ResolveBaseUrl_拼接host与path并去尾斜杠() + { + var options = new SchClassOpenApiOptions + { + BaseHost = "http://base.yunhaixingtong.com/", + BasePath = "/base.yunhaixingtong.com/", + }; + + Assert.Equal("http://base.yunhaixingtong.com/base.yunhaixingtong.com", options.ResolveBaseUrl()); + } + + [Fact] + public void ResolveBaseUrl_path为空时挂根路径() + { + var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com", BasePath = "" }; + + Assert.Equal("http://base.yunhaixingtong.com", options.ResolveBaseUrl()); + } + + [Fact] + public void ResolveBaseUrl_host为空时返回空串() + { + Assert.Equal("", new SchClassOpenApiOptions().ResolveBaseUrl()); + } + + [Fact] + public void 默认值_对齐Java客户端() + { + var options = new SchClassOpenApiOptions(); + + Assert.True(options.Enabled); + Assert.Null(options.BaseHost); + Assert.Null(options.BasePath); + Assert.Equal(1500, options.ConnectTimeoutMs); + Assert.Equal(5000, options.ReadTimeoutMs); + Assert.Equal(5000, options.WriteTimeoutMs); + Assert.Null(options.AppKey); + Assert.Null(options.AppSecret); + } + + [Fact] + public void 配置节名_独立于用户中心() + { + Assert.Equal("yh:schclass:open", SchClassOpenApiOptions.SectionName); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOtherClientsTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOtherClientsTests.cs new file mode 100644 index 0000000..a6a16ba --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOtherClientsTests.cs @@ -0,0 +1,104 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// 其余客户端(学制 / 科目 / 任课 / 学校教师 / 家长兼容)的端点契约对拍。 +public class SchClassOtherClientsTests +{ + [Fact] + public async Task Schoolen_学制与年级_5个端点() + { + const string root = "/oapi/Schoolen/"; + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.Schooling.GetSchLenLevelListAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenLevellist", "?SchSerID=S1"); + + await api.Schooling.GetLevelIdListAsync(3); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetLevelIdlist", "?Schooling=3"); + + await api.Schooling.GetSchLenLevelFirstAsync("11"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenLevelFirst", "?GradeId=11"); + + await api.Schooling.GetSchLenListAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenlist", "?SchSerID=S1"); + + await api.Schooling.GetAllClassSubjectAsync(2, "S1", "2026-09-01"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetAllClassSubject", "?schId=2&schserid=S1&startTime=2026-09-01"); + } + + [Fact] + public async Task SubInfo_科目_4个端点() + { + const string root = "/oapi/SubInfo/"; + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.SubInfo.GetSchSubListAsync("S1", 3, 11); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchSublist", "?SchID=S1&schooling=3&gradeId=11"); + + await api.SubInfo.GetSchIdsSubListAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchIdsSublist", "?SchIds=S1,S2"); + + await api.SubInfo.GetSchTeaSubListAsync("77"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeaSublist", "?UserTeaSerID=77"); + + await api.SubInfo.GetSchTeachSubListAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeachSublist", "?SchID=S1"); + } + + [Fact] + public async Task TeachInfo_任课_8个端点() + { + const string root = "/oapi/TeachInfo/"; + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.TeachInfo.GetSchTeachListAsync("S1", 77); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeachlist", "?SchID=S1&UserTeaSerID=77"); + + await api.TeachInfo.GetEleSchTeachListAsync("S1", 77); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchTeachlist", "?SchID=S1&UserTeaSerID=77"); + + await api.TeachInfo.GetSchArrTeachListAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchArrTeachlist", "?SchIDArr=S1,S2"); + + await api.TeachInfo.GetEleSchArrTeachListAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchArrTeachList", "?SchIDArr=S1,S2"); + + await api.TeachInfo.GetSchClaTeachListAsync("1001"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchClaTeachlist", "?ClassID=1001"); + + await api.TeachInfo.GetEleSchClaTeachListAsync("1001"); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchClaTeachlist", "?ClassID=1001"); + + await api.TeachInfo.GetElectiveClassByGradeAsync(1, 2); + SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetElectiveClassByGrade", "?schoolId=1&gradeId=2"); + + await api.TeachInfo.GetElectiveClassByTeachersAsync("77,78"); + SchClassTestHost.VerifyBody(handler, root + "GetElectiveClassByTeachers", "77,78"); + } + + [Fact] + public async Task DbClass_学校教师名单_1个端点() + { + var handler = SchClassTestHost.Ok(); + + await SchClassTestHost.Api(handler).DbClass.GetSchteaListAsync(30); + + SchClassTestHost.Verify(handler, HttpMethod.Get, "/oapi/DbClass/GetSchteaList", "?Group=30"); + } + + [Fact] + public async Task FamCom_家长兼容_2个端点均为POST文本体() + { + const string root = "/oapi/Common/FamCom/"; + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.FamilyCompat.GetStuUserFamIdListAsync("1,2"); + SchClassTestHost.VerifyBody(handler, root + "GetStuUserFamIDList", "1,2"); + + await api.FamilyCompat.GetAllStuFamMemAsync("1,2"); + SchClassTestHost.VerifyBody(handler, root + "GetAllStuFamMem", "1,2"); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassResponseParsingTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassResponseParsingTests.cs new file mode 100644 index 0000000..63c1076 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassResponseParsingTests.cs @@ -0,0 +1,192 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// +/// 响应解析与状态码归一(教务 OA 兼容端点约定): +/// 成功返回裸数据;404 / 204 表示"无数据"归一为 null / 空列表;400 及其它非 2xx 抛异常。 +/// +public class SchClassResponseParsingTests +{ + private static readonly SchClassOpenApiOptions Options = new() { BaseHost = "http://base.yunhaixingtong.com" }; + + private static SchClassVClassClient VClass(FakeHandler handler) => new(new HttpClient(handler), Options); + + private static SchClassTeaInfoClient Tea(FakeHandler handler) => new(new HttpClient(handler), Options); + + private static SchClassSchoolingClient Schooling(FakeHandler handler) => new(new HttpClient(handler), Options); + + [Fact] + public async Task 裸数组_解析为兼容行列表() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """ + [{"Id":1,"Name":"高一(1)班","ClassID":1001,"SchSerID":"S001"}, + {"Id":2,"Name":"高一(2)班","ClassID":1002,"SchSerID":"S001"}] + """); + + var rows = await VClass(handler).GetVClassListAsync("S001"); + + Assert.Equal(2, rows.Count); + Assert.Equal("高一(1)班", rows[0]["Name"].GetString()); + Assert.Equal(1001, rows[0]["ClassID"].GetInt32()); + Assert.Equal("S001", rows[1]["SchSerID"].GetString()); + } + + [Fact] + public async Task 单对象_归一为单项列表() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """{"Id":9,"Name":"高三(7)班"}"""); + + var rows = await VClass(handler).GetVClassListAsync("S001"); + + Assert.Single(rows); + Assert.Equal(9, rows[0]["Id"].GetInt32()); + } + + [Fact] + public async Task 裸字符串数组_解析为List_string() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """["语文","数学"]"""); + + var subs = await new SchClassSubInfoClient(new HttpClient(handler), Options) + .GetSchTeachSubListAsync("S001"); + + Assert.Equal(new[] { "语文", "数学" }, subs); + } + + [Fact] + public async Task HTTP404_列表归一为空列表() + { + var rows = await VClass(FakeHandler.Empty(HttpStatusCode.NotFound)).GetVClassListAsync("S001"); + + Assert.Empty(rows); + } + + [Fact] + public async Task HTTP404_单对象归一为null() + { + var row = await VClass(FakeHandler.Empty(HttpStatusCode.NotFound)).GetVClassFirstAsync("1001"); + + Assert.Null(row); + } + + [Fact] + public async Task HTTP204_归一为空列表() + { + var handler = FakeHandler.Json(HttpStatusCode.NoContent, ""); + + var list = await Tea(handler).GetArrTeaInfoAsync("1,2,3"); + + Assert.Empty(list); + } + + [Fact] + public async Task HTTP400_抛异常并携带状态码与响应体() + { + var handler = FakeHandler.Text(HttpStatusCode.BadRequest, "Required String parameter 'userIds' is not present"); + + var ex = await Assert.ThrowsAsync(() => Tea(handler).GetArrTeaInfoAsync(null!)); + + Assert.Equal(400, ex.StatusCode); + Assert.Contains("400", ex.Message); + Assert.Contains("userIds", ex.ResponseBody!); + } + + [Fact] + public async Task HTTP500_抛异常() + { + var handler = FakeHandler.Text(HttpStatusCode.InternalServerError, "boom"); + + var ex = await Assert.ThrowsAsync(() => VClass(handler).GetVClassListAsync("S001")); + + Assert.Equal(500, ex.StatusCode); + Assert.Contains("500", ex.Message); + } + + [Fact] + public async Task 空响应体_返回空列表不抛() + { + var handler = new FakeHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("", Encoding.UTF8, "application/json"), + }); + + Assert.Empty(await VClass(handler).GetVClassListAsync("S001")); + Assert.Null(await VClass(handler).GetVClassFirstAsync("1")); + } + + [Fact] + public async Task JSON字面量null_返回空列表() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, "null"); + + Assert.Empty(await VClass(handler).GetVClassListAsync("S001")); + } + + [Fact] + public async Task 信封code0_提取data节点() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, + """{"code":0,"msg":"","data":[{"Id":1,"Name":"高一(1)班"}]}"""); + + var rows = await VClass(handler).GetVClassListAsync("S001"); + + Assert.Single(rows); + Assert.Equal("高一(1)班", rows[0]["Name"].GetString()); + } + + [Fact] + public async Task 信封code0且data为null_归一为空() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """{"code":0,"msg":"ok","data":null}"""); + + Assert.Empty(await VClass(handler).GetVClassListAsync("S001")); + } + + [Fact] + public async Task 信封业务失败_抛异常并携带msg() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, + """{"code":3,"msg":"学校不存在","data":null}"""); + + var ex = await Assert.ThrowsAsync(() => VClass(handler).GetVClassListAsync("S001")); + + Assert.Equal("学校不存在", ex.Message); + Assert.Equal(0, ex.StatusCode); + Assert.Contains("学校不存在", ex.ResponseBody!); + } + + [Fact] + public async Task 业务行含code字段_不被误判为信封() + { + // 学制等级行自身带 code=gradeId 字段,只有 code 而没有 data/msg,必须原样返回 + var handler = FakeHandler.Json(HttpStatusCode.OK, + """[{"Id":1,"Name":"一年级","OwnerId":2,"Levell":1,"code":11,"sx":1}]"""); + + var rows = await Schooling(handler).GetSchLenLevelListAsync("S001"); + + Assert.Single(rows); + Assert.Equal(11, rows[0]["code"].GetInt32()); + Assert.Equal("一年级", rows[0]["Name"].GetString()); + } + + [Fact] + public async Task 非法JSON_抛解析异常() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, "502 Bad Gateway"); + + var ex = await Assert.ThrowsAsync(() => VClass(handler).GetVClassListAsync("S001")); + + Assert.Contains("解析失败", ex.Message); + } + + [Fact] + public async Task 兼容行键名_大小写与Java侧put顺序一致() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, + """[{"SchSerID":"S001","TeaTel":"13800000000","TeaName":"王老师","UserTeaSerID":77}]"""); + + var rows = await new SchClassDbClassClient(new HttpClient(handler), Options).GetSchteaListAsync(1); + + Assert.Equal(new[] { "SchSerID", "TeaTel", "TeaName", "UserTeaSerID" }, rows[0].Keys); + Assert.Equal("王老师", rows[0]["TeaName"].GetString()); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassSchInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassSchInfoClientTests.cs new file mode 100644 index 0000000..dff794c --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassSchInfoClientTests.cs @@ -0,0 +1,59 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// SchInfo(学校)端点契约与 反序列化。 +public class SchClassSchInfoClientTests +{ + private const string Root = "/oapi/SchInfo/"; + + [Fact] + public async Task SchInfo_全部7个端点_路径与参数名对齐() + { + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.SchInfo.GetSchListAsync("1,2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchlist", "?SchIDArr=1,2"); + + await api.SchInfo.AllListAsync(); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "AllList"); + + await api.SchInfo.GetSchFirstAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchFirst", "?SchID=S1"); + + await api.SchInfo.GetUserSchFirstAsync(9); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserSchFirst", "?UserSchID=9"); + + await api.SchInfo.GetUserSchListAsync("9,10"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserSchlist", "?UserSchIds=9,10"); + + await api.SchInfo.GetSchoolByGroupIdAsync(3); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchoolByGroupId", "?groupId=3"); + + await api.SchInfo.GetSchoolBySchoolenIdAsync(4); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchoolBySchoolenId", "?groupId=4"); + } + + [Fact] + public async Task SchInfoVO_按JsonProperty锚定的键名反序列化() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """ + [{"SchSerId":"S001","UserSchSerId":9,"SchName":"第一中学"}, + {"SchSerId":"S002","UserSchSerId":10,"SchName":"第二中学"}] + """); + + var list = await SchClassTestHost.Api(handler).SchInfo.AllListAsync(); + + Assert.Equal(2, list.Count); + Assert.Equal("S001", list[0].SchSerId); + Assert.Equal(9, list[0].UserSchSerId); + Assert.Equal("第二中学", list[1].SchName); + } + + [Fact] + public async Task GetSchFirst_未找到时返回null() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, "null"); + + Assert.Null(await SchClassTestHost.Api(handler).SchInfo.GetSchFirstAsync("nope")); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassStuInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassStuInfoClientTests.cs new file mode 100644 index 0000000..756d546 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassStuInfoClientTests.cs @@ -0,0 +1,109 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// +/// OAStu(学生)端点的请求契约对拍。注意本控制器参数名几乎全小写 +/// (schserid / schgroupid / stuids / schserids), +/// 与 VSchClass 的 SchSerID 形成对照,不可混用。 +/// +public class SchClassStuInfoClientTests +{ + private const string Root = "/oapi/OAStu/"; + + [Fact] + public async Task OAStu_全部19个端点_路径与参数名对齐() + { + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.StuInfo.GetAllStuListAsync(); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetAllStulist"); + + await api.StuInfo.GetEleAllStuListAsync(); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleAllStulist"); + + await api.StuInfo.GetSchStuListAsync("S1", "10"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchStulist", "?schserid=S1&schgroupid=10"); + + await api.StuInfo.GetEleSchStuListAsync("S1", "10"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchStulist", "?schserid=S1&schgroupid=10"); + + await api.StuInfo.GetClaStuListAsync("1001", "20"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetClaStulist", "?ClassID=1001&clagroupid=20"); + + await api.StuInfo.GetEleClaStuListAsync("1001", "20"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleClaStulist", "?ClassID=1001&clagroupid=20"); + + await api.StuInfo.GetClaArrStuListAsync("1001,1002", "20,21"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetClaArrStulist", "?ClassIDs=1001,1002&clagroupids=20,21"); + + await api.StuInfo.GetEleClaArrStuListAsync("1001,1002", "20,21"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleClaArrStulist", "?ClassIDs=1001,1002&clagroupids=20,21"); + + await api.StuInfo.GetStuIdsListAsync("1,2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuIdslist", "?stuids=1,2"); + + await api.StuInfo.GetEleStuIdsListAsync("1,2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleStuIdslist", "?stuids=1,2"); + + await api.StuInfo.GetStuUserFamIdListAsync("1,2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuUserFamIDList", "?stuids=1,2"); + + await api.StuInfo.GetFamStuListAsync("IC1,IC2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetFamStulist", "?invitecodes=IC1,IC2"); + + await api.StuInfo.GetStuFamMemAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuFamMem", "?SchSerID=S1"); + + await api.StuInfo.GetSchBySchIdsAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchBySchIds", "?schserids=S1,S2"); + + await api.StuInfo.GetEleSchIdsStuListAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchIdsStulist", "?schserids=S1,S2"); + + await api.StuInfo.GetEleSchBySchIdsAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchBySchIds", "?schserids=S1,S2"); + + await api.StuInfo.GetStudentsByElectiveIdAsync(5); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStudentsByElectiveId", "?electiveIds=5"); + + await api.StuInfo.GetHistoryStudentsBySchoolIdAsync(1, 2026); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getHistoryStudentsBySchoolId", "?schoolId=1&year=2026"); + + await api.StuInfo.GetHistoryClassAsync(1, 2026); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getHistoryClass", "?schoolId=1&year=2026"); + } + + [Fact] + public async Task 学生行字段_按键名原样读取() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """ + [{"Account":"2026001","UserStuSerID":11,"StuName":"小明","StuIDCard":"110101","StuSex":"男", + "StuStatus":1,"ClassID":1001,"ClassStuID":2001,"GroupID":30,"StuPhoto":null,"CardID":"C1", + "StuCurrentTel":"138","invitecode":"IC1","SchSerID":"S1","Graduated":0,"isjiedu":0}] + """); + + var rows = await SchClassTestHost.Api(handler).StuInfo.GetAllStuListAsync(); + + var row = Assert.Single(rows); + Assert.Equal("小明", row["StuName"].GetString()); + Assert.Equal(1001, row["ClassID"].GetInt32()); + Assert.Equal("S1", row["SchSerID"].GetString()); + Assert.Equal(JsonValueKind.Null, row["StuPhoto"].ValueKind); + } + + [Fact] + public async Task 历史学生行_使用小写字段名() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """ + [{"stuId":11,"stuName":"小明","classId":1001,"className":"高一(1)班", + "grade":1,"name":"第一中学","school_system_id":3,"system_grade":10}] + """); + + var rows = await SchClassTestHost.Api(handler).StuInfo.GetHistoryStudentsBySchoolIdAsync(1, 2026); + + var row = Assert.Single(rows); + Assert.Equal("小明", row["stuName"].GetString()); + Assert.Equal("高一(1)班", row["className"].GetString()); + Assert.Equal(3, row["school_system_id"].GetInt32()); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTeaInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTeaInfoClientTests.cs new file mode 100644 index 0000000..1b277bb --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTeaInfoClientTests.cs @@ -0,0 +1,70 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// OATea(教师)端点契约与 反序列化。 +public class SchClassTeaInfoClientTests +{ + private const string Root = "/oapi/OATea/"; + + [Fact] + public async Task OATea_全部4个端点_路径与参数名对齐() + { + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.TeaInfo.GetTeaInfoAsync(77); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetTeainfo", "?uid=77"); + + await api.TeaInfo.GetArrTeaInfoAsync("77,78"); + SchClassTestHost.VerifyBody(handler, Root + "GetArrTeainfo", "77,78"); + + await api.TeaInfo.GetSchTeaInfoAsync("S1", 2); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchTeainfo", "?SchSerID=S1&schId=2"); + + await api.TeaInfo.GetSchTeaListAsync("S1", 2); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchTeaList", "?SchSerID=S1&schId=2"); + } + + [Fact] + public async Task TeaInfoVO_字段按Java原名反序列化() + { + var handler = FakeHandler.Json(HttpStatusCode.OK, """ + {"SchSerID":"S001","Teapolitical":"党员","TeaWorkStart":"2010-09-01","Account":"wang", + "SchSerIDs":"S001","role":3,"JiaoYuJu":"市教育局","TeaSex":"男","IsLeadingMiddle":0, + "TeaOriginPlace":"北京","Photo":null,"TeaIDCard":"110101","Name":"王老师","GroupID":30, + "SchName":"第一中学","TeaTeachStart":"2010-09-01","TeaSerID":"77","UserID":1001, + "TeaNation":"汉","Tel":"13800000000","TeaWorkType":"专任","TeaNo":7,"TeaBirth":"1985-01-01"} + """); + + var tea = await SchClassTestHost.Api(handler).TeaInfo.GetTeaInfoAsync(77); + + Assert.NotNull(tea); + Assert.Equal("王老师", tea!.Name); + Assert.Equal("S001", tea.SchSerId); + // 注意:查询参数 uid 传的是教师 teaId(77),而 VO.UserID 是用户中心 ID(1001),两者不是一回事 + Assert.Equal("77", tea.TeaSerId); + Assert.Equal(1001, tea.UserId); + Assert.Equal(3, tea.Role); + Assert.Equal(30, tea.GroupId); + Assert.Equal("汉", tea.TeaNation); + Assert.Null(tea.Photo); + } + + [Fact] + public async Task 教师不存在_服务端404时返回null() + { + var handler = FakeHandler.Empty(HttpStatusCode.NotFound); + + Assert.Null(await SchClassTestHost.Api(handler).TeaInfo.GetTeaInfoAsync(999999)); + } + + [Fact] + public async Task GetSchTeainfo_缺参数时服务端400抛异常() + { + var handler = FakeHandler.Text(HttpStatusCode.BadRequest, "schId 与 SchSerID 不能同时为空"); + + var ex = await Assert.ThrowsAsync( + () => SchClassTestHost.Api(handler).TeaInfo.GetSchTeaInfoAsync()); + + Assert.Equal(400, ex.StatusCode); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTestHost.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTestHost.cs new file mode 100644 index 0000000..c52b1d7 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTestHost.cs @@ -0,0 +1,37 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// 测试宿主:提供共享配置、门面实例与端点契约断言辅助。 +internal static class SchClassTestHost +{ + /// 测试用配置(不设 BasePath,端点直接挂在根路径下)。 + public static SchClassOpenApiOptions Options() + => new() { BaseHost = "http://base.yunhaixingtong.com" }; + + /// 用伪造 handler 装配门面。 + public static SchClassOpenApi Api(FakeHandler handler) + => new(new HttpClient(handler), Options()); + + /// 回放一段无害响应(JSON 字面量 null),使任意端点都能安全走完解析流程。 + public static FakeHandler Ok() => FakeHandler.Json(HttpStatusCode.OK, "null"); + + /// 断言最近一次请求的 method / 路径 / query。 + public static void Verify(FakeHandler handler, HttpMethod method, string path, string query = "") + { + var request = handler.LastRequest; + Assert.NotNull(request); + Assert.Equal(method, request!.Method); + Assert.Equal(path, request.RequestUri!.AbsolutePath); + Assert.Equal(query, Uri.UnescapeDataString(request.RequestUri.Query)); + } + + /// 断言最近一次请求的方法、路径与请求体(用于 POST @RequestBody String 端点)。 + public static void VerifyBody(FakeHandler handler, string path, string? body, string contentType = "text/plain") + { + var request = handler.LastRequest; + Assert.NotNull(request); + Assert.Equal(HttpMethod.Post, request!.Method); + Assert.Equal(path, request.RequestUri!.AbsolutePath); + Assert.Equal(body, handler.LastRequestBody); + Assert.Equal(contentType, handler.LastContentType); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassUserInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassUserInfoClientTests.cs new file mode 100644 index 0000000..565b154 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassUserInfoClientTests.cs @@ -0,0 +1,58 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// UserInfo(师生统一用户)端点契约与 反序列化。 +public class SchClassUserInfoClientTests +{ + private const string Root = "/oapi/UserInfo/"; + + [Fact] + public async Task UserInfo_全部2个端点_路径与参数名对齐() + { + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.UserInfo.GetUserListAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserList", "?SchSerID=S1"); + + await api.UserInfo.GetUserIdListAsync(1001); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getUserIdList", "?userid=1001"); + } + + [Fact] + public async Task UserInfoVO_大小写不敏感匹配Jackson推导键名() + { + // Jackson 从 getter 推导的键名大小写与 Java 字段不完全一致(如 schSerID / idcard), + // Web 默认大小写不敏感,故两种形态都能命中。 + var handler = FakeHandler.Json(HttpStatusCode.OK, """ + [{"schSerID":"S001","no":1,"idcard":"110101","userID":1001,"sex":1,"usertype":1, + "serID":null,"photo":null,"tel":"13800000000","uids":"wang","groupID":30,"name":"王老师"}] + """); + + var list = await SchClassTestHost.Api(handler).UserInfo.GetUserListAsync("S001"); + + var user = Assert.Single(list); + Assert.Equal("S001", user.SchSerId); + Assert.Equal("110101", user.IdCard); + Assert.Equal(1001, user.UserId); + Assert.Equal(30, user.GroupId); + Assert.Equal("王老师", user.Name); + Assert.Null(user.SerId); + Assert.Null(user.Photo); + } + + [Fact] + public async Task GetUserList_服务端204无内容时返回空列表() + { + var handler = FakeHandler.Empty(HttpStatusCode.NoContent); + + Assert.Empty(await SchClassTestHost.Api(handler).UserInfo.GetUserListAsync("S1")); + } + + [Fact] + public async Task getUserIdList_未找到时服务端404返回空列表() + { + var handler = FakeHandler.Empty(HttpStatusCode.NotFound); + + Assert.Empty(await SchClassTestHost.Api(handler).UserInfo.GetUserIdListAsync(-1)); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassVClassClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassVClassClientTests.cs new file mode 100644 index 0000000..1893060 --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassVClassClientTests.cs @@ -0,0 +1,104 @@ +namespace Yh.SchClass.OpenApi.Tests; + +/// +/// VSchClass(班级)端点的请求契约对拍:路径、HTTP 方法、query 参数名必须与 Java +/// VClassController 逐字一致(参数名大小写敏感)。 +/// +public class SchClassVClassClientTests +{ + private const string Root = "/oapi/VSchClass/"; + + [Fact] + public async Task VSchClass_全部17个端点_路径与参数名对齐() + { + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.VClass.GetVClassListAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassList", "?SchSerID=S1"); + + await api.VClass.GetEleVClassListAsync("S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassList", "?SchSerID=S1"); + + await api.VClass.GetSchArrVClassListAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchArrVClassList", "?SchIDArr=S1,S2"); + + await api.VClass.GetEleSchArrVClassListAsync("S1,S2"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchArrVClassList", "?SchIDArr=S1,S2"); + + await api.VClass.GetFunTimeSchClassAsync("2026-09-01", "S1"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetFunTimeSchClass", "?time=2026-09-01&SchSerID=S1"); + + await api.VClass.GetVClassFirstAsync("1001"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassFirst", "?ClassID=1001"); + + await api.VClass.GetEleVClassFirstAsync("1001"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassFirst", "?ClassID=1001"); + + await api.VClass.GetVClassIdsListAsync("1001,1002"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassIdsList", "?ClassIds=1001,1002"); + + await api.VClass.GetEleVClassIdsListAsync("1001,1002"); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassIdsList", "?ClassIds=1001,1002"); + + await api.VClass.GetGradeClassListAsync("S1", 3, 11); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetGradeClassList", "?SchSerID=S1&Schooling=3&GradeId=11"); + + await api.VClass.GetTeachClassArrAsync(77); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetTeachClassArr", "?UserTeaSerID=77"); + + await api.VClass.GetSchArrVClassListAllAsync(); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchArrVClassListAll"); + + await api.VClass.GetEleSchArrVClassListAllAsync(); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchArrVClassListAll"); + + // POST @RequestBody String:按 text/plain 原样提交,不加引号 + await api.VClass.GetSchArrVClassListPostAsync("S1,S2"); + SchClassTestHost.VerifyBody(handler, Root + "GetSchArrVClassListPost", "S1,S2"); + + await api.VClass.GetElectiveClassByIdAsync(5); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetElectiveClassById", "?electiveId=5"); + + await api.VClass.GetElectiveClassByGradeAsync(1, 2); + SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetElectiveClassByGrade", "?schoolId=1&gradeId=2"); + + await api.VClass.GetElectiveClassByTeachersAsync("7,8"); + SchClassTestHost.VerifyBody(handler, Root + "GetElectiveClassByTeachers", "7,8"); + } + + [Fact] + public async Task null参数_从query中丢弃() + { + var handler = SchClassTestHost.Ok(); + var api = SchClassTestHost.Api(handler); + + await api.VClass.GetGradeClassListAsync("S1", null, null); + + Assert.Equal("?SchSerID=S1", Uri.UnescapeDataString(handler.LastRequest!.RequestUri!.Query)); + } + + [Fact] + public async Task 完整URL_由baseHost与oapi前缀拼成() + { + var handler = SchClassTestHost.Ok(); + + await SchClassTestHost.Api(handler).VClass.GetVClassListAsync("S1"); + + Assert.Equal("http://base.yunhaixingtong.com/oapi/VSchClass/GetVClassList?SchSerID=S1", + handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task 配置了BasePath时_路径前缀正确拼接() + { + var handler = SchClassTestHost.Ok(); + var api = new SchClassOpenApi(new HttpClient(handler), + new SchClassOpenApiOptions { BaseHost = "http://gw", BasePath = "/base.yunhaixingtong.com" }); + + await api.VClass.GetSchArrVClassListAllAsync(); + + Assert.Equal("/base.yunhaixingtong.com/oapi/VSchClass/GetSchArrVClassListAll", + handler.LastRequest!.RequestUri!.AbsolutePath); + } +} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/Yh.SchClass.OpenApi.Tests.csproj b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/Yh.SchClass.OpenApi.Tests.csproj new file mode 100644 index 0000000..129aa4d --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/Yh.SchClass.OpenApi.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + false + + Exe + false + + + + + + + + + + + + + + + + + + + diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/appsettings.json b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/appsettings.json new file mode 100644 index 0000000..4a6f4bd --- /dev/null +++ b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/appsettings.json @@ -0,0 +1,12 @@ +{ + "yh": { + "open": { + "Enabled": true, + "BaseHost": "http://base.yunhaixingtong.com", + "BasePath": "/userbase", + "SchClassPath": "/bschclass", + "AppKey": "100002", + "AppSecret": "2314a3b158b24a838b2b9b69fd3db118" + } + } +} diff --git a/BasePlatformDotnet/tools/gen_sign_vectors.py b/BasePlatformDotnet/tools/gen_sign_vectors.py new file mode 100644 index 0000000..4fa2503 --- /dev/null +++ b/BasePlatformDotnet/tools/gen_sign_vectors.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""生成 sign-vectors.json 契约基线(Python hmac 独立实现,与 Java SignUtil / C# SignUtil 对拍)。 + +规则(与 Java yh-utils-core SignUtil 一致): +- 数据级: source = data + "|" + ts, HmacSHA256(secret), 输出小写 hex +- 请求级: source = METHOD + "\n" + canonicalTarget + "\n" + ts, HmacSHA256(secret), 输出 Base64 +约束: secret 长度 >= 16 +""" +import base64 +import hashlib +import hmac +import json + +def data_sign(data, ts, secret): + return hmac.new(secret.encode("utf-8"), (data + "|" + ts).encode("utf-8"), hashlib.sha256).hexdigest() + +def request_sign(method, target, ts, secret): + source = method + "\n" + target + "\n" + ts + return base64.b64encode(hmac.new(secret.encode("utf-8"), source.encode("utf-8"), hashlib.sha256).digest()).decode() + +S1 = "0123456789abcdef" # 16 位(最低边界) +S2 = "app-secret-100476-yunhai" # 常规 22 位 +S3 = "X" * 64 # 超长 + +cases = [ + {"name": "数据级-常规", "data": "hello", "ts": "1700000000000", "secret": S1}, + {"name": "数据级-空串", "data": "", "ts": "1700000000000", "secret": S1}, + {"name": "数据级-含分隔符", "data": "a|b", "ts": "1700000000000", "secret": S1}, + {"name": "数据级-中文UTF8", "data": "云海学通", "ts": "1700000000000", "secret": S2}, + {"name": "数据级-JSON体", "data": '{"id":1,"name":"zhang"}', "ts": "1699999999999", "secret": S2}, + {"name": "数据级-超长数据", "data": "x" * 4096, "ts": "1700000000000", "secret": S2}, + {"name": "数据级-超长密钥", "data": "user/login", "ts": "1700000000010", "secret": S3}, + {"name": "请求级-GET无参", "method": "GET", "target": "/userbase/oapi/user/byId", "ts": "1700000000000", "secret": S2}, + {"name": "请求级-GET带query", "method": "GET", "target": "/userbase/oapi/user/byId?id=123", "ts": "1700000000000", "secret": S2}, + {"name": "请求级-GET多参数", "method": "GET", "target": "/userbase/oapi/group/byId?groupId=9&withSub=true", "ts": "1700000000000", "secret": S1}, + {"name": "请求级-POST", "method": "POST", "target": "/userbase/oapi/identity/switch", "ts": "1700000000000", "secret": S2}, + {"name": "请求级-中文query", "method": "GET", "target": "/userbase/oapi/user/byLoginName?loginName=张三", "ts": "1700000000001", "secret": S2}, +] + +vectors = [] +for c in cases: + v = {"name": c["name"], "ts": c["ts"], "secret": c["secret"]} + if "method" in c: + v["method"] = c["method"] + v["canonicalTarget"] = c["target"] + v["expectedRequestSignBase64"] = request_sign(c["method"], c["target"], c["ts"], c["secret"]) + else: + v["data"] = c["data"] + v["expectedDataSignHex"] = data_sign(c["data"], c["ts"], c["secret"]) + vectors.append(v) + +# 请求级大小写敏感验证用例:小写 METHOD "get" 与 "GET" 签名不同(请求级验签精确比对) +vectors.append({ + "name": "请求级-小写method(与GET不同)", + "method": "get", "canonicalTarget": "/userbase/oapi/user/byId", "ts": "1700000000000", "secret": S1, + "expectedRequestSignBase64": request_sign("get", "/userbase/oapi/user/byId", "1700000000000", S1), +}) + +out = { + "algorithm": "HmacSHA256", + "dataLevel": {"canonical": "data + '|' + ts", "output": "lowercase hex"}, + "requestLevel": {"canonical": "METHOD + '\\n' + canonicalTarget + '\\n' + ts", "output": "base64"}, + "minSecretLength": 16, + "cases": vectors, +} + +with open("contract/fixtures/sign-vectors.json", "w", encoding="utf-8") as f: + json.dump(out, f, ensure_ascii=False, indent=2) +print("vectors:", len(vectors)) diff --git a/BasePlatformDotnet/yh-platform-net.sln b/BasePlatformDotnet/yh-platform-net.sln new file mode 100644 index 0000000..f1ad5c7 --- /dev/null +++ b/BasePlatformDotnet/yh-platform-net.sln @@ -0,0 +1,192 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.10.12120.281 insiders +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Sample.Web", "samples\Yh.Platform.Sample.Web\Yh.Platform.Sample.Web.csproj", "{DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Abstractions", "src\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj", "{3A3DC74D-6982-4054-83B5-18940DFA4193}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.AppAuth", "src\Yh.Platform.AppAuth\Yh.Platform.AppAuth.csproj", "{4299568D-5868-4811-A1E1-A81B1CCD3996}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Auth", "src\Yh.Platform.Auth\Yh.Platform.Auth.csproj", "{68691023-148A-4F0B-AB33-ED4A2D458B78}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.OpenApi", "src\Yh.Platform.OpenApi\Yh.Platform.OpenApi.csproj", "{0C204FCD-ECA1-4C90-BE0E-43A0043B2294}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.SchClass.OpenApi", "src\Yh.SchClass.OpenApi\Yh.SchClass.OpenApi.csproj", "{2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Abstractions.Tests", "tests\Yh.Platform.Abstractions.Tests\Yh.Platform.Abstractions.Tests.csproj", "{CCE1711C-C99B-4956-A73E-7DCEECD90CB9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.AppAuth.Tests", "tests\Yh.Platform.AppAuth.Tests\Yh.Platform.AppAuth.Tests.csproj", "{AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Auth.Tests", "tests\Yh.Platform.Auth.Tests\Yh.Platform.Auth.Tests.csproj", "{490A1B14-ECF2-4582-8306-1C6BAB216369}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.OpenApi.Tests", "tests\Yh.Platform.OpenApi.Tests\Yh.Platform.OpenApi.Tests.csproj", "{016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.SchClass.OpenApi.Tests", "tests\Yh.SchClass.OpenApi.Tests\Yh.SchClass.OpenApi.Tests.csproj", "{E70492B2-109A-4E6F-AF91-443AC2956E3A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x64.ActiveCfg = Debug|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x64.Build.0 = Debug|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x86.ActiveCfg = Debug|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x86.Build.0 = Debug|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|Any CPU.Build.0 = Release|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x64.ActiveCfg = Release|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x64.Build.0 = Release|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x86.ActiveCfg = Release|Any CPU + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x86.Build.0 = Release|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x64.Build.0 = Debug|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x86.Build.0 = Debug|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|Any CPU.Build.0 = Release|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x64.ActiveCfg = Release|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x64.Build.0 = Release|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x86.ActiveCfg = Release|Any CPU + {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x86.Build.0 = Release|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x64.ActiveCfg = Debug|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x64.Build.0 = Debug|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x86.ActiveCfg = Debug|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x86.Build.0 = Debug|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|Any CPU.Build.0 = Release|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x64.ActiveCfg = Release|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x64.Build.0 = Release|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x86.ActiveCfg = Release|Any CPU + {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x86.Build.0 = Release|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x64.ActiveCfg = Debug|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x64.Build.0 = Debug|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x86.ActiveCfg = Debug|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x86.Build.0 = Debug|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|Any CPU.Build.0 = Release|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x64.ActiveCfg = Release|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x64.Build.0 = Release|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x86.ActiveCfg = Release|Any CPU + {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x86.Build.0 = Release|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x64.ActiveCfg = Debug|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x64.Build.0 = Debug|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x86.ActiveCfg = Debug|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x86.Build.0 = Debug|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|Any CPU.Build.0 = Release|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x64.ActiveCfg = Release|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x64.Build.0 = Release|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x86.ActiveCfg = Release|Any CPU + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x86.Build.0 = Release|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x64.Build.0 = Debug|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x86.Build.0 = Debug|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|Any CPU.Build.0 = Release|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x64.ActiveCfg = Release|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x64.Build.0 = Release|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x86.ActiveCfg = Release|Any CPU + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x86.Build.0 = Release|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x64.ActiveCfg = Debug|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x64.Build.0 = Debug|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x86.ActiveCfg = Debug|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x86.Build.0 = Debug|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|Any CPU.Build.0 = Release|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x64.ActiveCfg = Release|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x64.Build.0 = Release|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x86.ActiveCfg = Release|Any CPU + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x86.Build.0 = Release|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x64.ActiveCfg = Debug|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x64.Build.0 = Debug|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x86.ActiveCfg = Debug|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x86.Build.0 = Debug|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|Any CPU.Build.0 = Release|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x64.ActiveCfg = Release|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x64.Build.0 = Release|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x86.ActiveCfg = Release|Any CPU + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x86.Build.0 = Release|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|Any CPU.Build.0 = Debug|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x64.ActiveCfg = Debug|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x64.Build.0 = Debug|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x86.ActiveCfg = Debug|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x86.Build.0 = Debug|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|Any CPU.ActiveCfg = Release|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|Any CPU.Build.0 = Release|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x64.ActiveCfg = Release|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x64.Build.0 = Release|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x86.ActiveCfg = Release|Any CPU + {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x86.Build.0 = Release|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x64.ActiveCfg = Debug|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x64.Build.0 = Debug|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x86.ActiveCfg = Debug|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x86.Build.0 = Debug|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|Any CPU.Build.0 = Release|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x64.ActiveCfg = Release|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x64.Build.0 = Release|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x86.ActiveCfg = Release|Any CPU + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x86.Build.0 = Release|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x64.ActiveCfg = Debug|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x64.Build.0 = Debug|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x86.ActiveCfg = Debug|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x86.Build.0 = Debug|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|Any CPU.Build.0 = Release|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x64.ActiveCfg = Release|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x64.Build.0 = Release|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x86.ActiveCfg = Release|Any CPU + {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {3A3DC74D-6982-4054-83B5-18940DFA4193} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {4299568D-5868-4811-A1E1-A81B1CCD3996} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {68691023-148A-4F0B-AB33-ED4A2D458B78} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {0C204FCD-ECA1-4C90-BE0E-43A0043B2294} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {CCE1711C-C99B-4956-A73E-7DCEECD90CB9} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {490A1B14-ECF2-4582-8306-1C6BAB216369} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {E70492B2-109A-4E6F-AF91-443AC2956E3A} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8A5DD7AE-0289-46E7-B1C9-6F711893008A} + EndGlobalSection +EndGlobal diff --git a/yh-platform-dotnet建设方案.md b/yh-platform-dotnet建设方案.md new file mode 100644 index 0000000..ba2784b --- /dev/null +++ b/yh-platform-dotnet建设方案.md @@ -0,0 +1,370 @@ +# yh-platform-dotnet 建设方案 v3 —— 从空文件夹到可发布 NuGet + +> 版本:v3.0 | 日期:2026-08-28 | 状态:方案评审 +> 定位:**平台侧工件建设** —— 建设与 Java `yh-platform` 同构的 .NET SDK(`yh-platform-net`),使平台基础能力(用户信息、/oapi 开放接口、应用签名、CAS SSO)具备跨语言辐射能力 +> v2 增量:基于 GrowthHealth 既有 .NET 代码实证,给出可直接从空文件夹落地的工程实施方案(框架选型、仓库脚手架、命令序列、分阶段实施) +> v3 增量:把 v2 的多阶段门禁简化为**做完后一次性自检 + 功能逻辑审计核对**——建完后照单跑一遍,有问题修,没问题即收 + +*** + +## 一、消费方现状实证(决定 SDK 设计约束) + +### 1.1 GrowthHealth 技术栈考古(2026-08-28 代码核查) + +| 项 | 实证 | 对 SDK 设计的约束 | +| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| 目标框架 | **net5.0**(五项目:Bsch.Web/Entity/Common/Repository/Service,[Bsch.Web.csproj:4](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Web/Bsch.Web.csproj)) | SDK 最低必须可被 net5.0 消费 | +| JSON | **Newtonsoft.Json 13.0.1** + `AddNewtonsoftJson`(非 System.Text.Json) | 模型须 Newtonsoft 可反序列化(纯 POCO 即可,Newtonsoft 属性匹配大小写不敏感) | +| ORM | EF Core 5.0.9 + SqlServer + Dapper(本地 dll) | 与 SDK 无关,仅确认不冲突 | +| 旧平台 SDK | **BschOpenApi.dll**(本地 Library 引用,无 NuGet):`new BaseOpenApi(host, appKey, appSecret)`,命名空间 `BschBaseOpenApi`,类型 `BoaUser` / `CurrentRole`([ControllerAbstract.cs:18](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Web/Controllers/ControllerAbstract.cs)、[SchClassService.cs:22](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Service/SchClassService.cs)) | **yh-platform-net = BschOpenApi 的重生**。需提供同构构造函数形态 `(host, appKey, appSecret)` 降低迁移摩擦;`BoaUser` 类型名保留 | +| CAS 认证 | 本地 dll `AspNetCore.Security.CAS.dll` + Cookie scheme(`AddCasSso()`,[ServiceCollectionExtensions.cs:158-184](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Web/Code/Extensions/ServiceCollectionExtensions.cs)) | Auth 包需内置等价 CAS handler,摆脱本地 dll | +| 配置 | 静态 `SiteConfig.AppSetting`(appsettings.json `AppTicket.AppKey=100476`、`UserCenterHost`) | SDK 用 Options 模式,同时提供静态兼容入口 | +| Redis | `Microsoft.Extensions.Caching.Redis 2.2.0`(旧 IDistributedCache 风格) | SDK 自带 StackExchange.Redis,不依赖消费方缓存栈 | +| 部署 | Docker `sdk:5.0`/`aspnet:5.0` | SDK 发布无运行时假定 | + +### 1.2 核心判断(承 v1) + +* yh-platform 的本质是"HTTP 契约 + 可移植协议 + 客户端封装",服务本体是 UserBase/CasSso/Redis,C# 侧做**同构 NuGet SDK**即可,无需 Java sidecar。 + +* **原则:不要让 .NET 去"依赖 Java",而是让两个 SDK 依赖同一份契约。** + +* v2 新增判断:**BschOpenApi.dll 已证明".NET 版平台 SDK"这条路的可行性**(GrowthHealth 全面使用其形态);yh-platform-net 不是发明新事物,而是把它升级为:有源码、有版本、有 CI、对齐新 UserBase /oapi 契约(含 BoaRoleInfo 多角色体系)的正规工件。 + +### 1.3 建设目标 + +1. 产出 `yh-platform-net` 独立仓库与 NuGet 包族,能力与 `yh-platform` 同构,且 **net5.0 旧栈可直接消费**; +2. 产出跨语言契约工件(/oapi OpenAPI spec、签名协议规范、共享测试向量),确立 UserBase 为契约权威源; +3. 建立双 SDK 版本治理机制(集中版本管理 + 兼容矩阵 + 契约测试),防双端漂移。 + +## 二、目标框架与兼容策略(v2 新增,先拍板) + +| 决策点 | 结论 | 理由 | +| -------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| SDK 目标框架 | **多目标** **`net5.0; net8.0`**(`netstandard2.0` 不选:需要 `IHttpClientFactory`/`AsyncLocal`/中间件等运行时能力,且 net5 消费方天然兼容) | net5.0 覆盖 GrowthHealth 现状;net8.0(LTS)覆盖新项目与未来升级 | +| 构建工具链 | .NET SDK **8.0**(可构建 net5.0 目标,targeting pack 经 NuGet 自动还原) | 单一工具链构建双目标 | +| 测试运行 | 单测跑 **net8.0**;net5.0 仅做编译 + 契约 fixture 反序列化冒烟(net5 runtime 已 EOL,不为测试单独装机) | 平衡成本与置信度 | +| JSON 序列化 | 模型**纯 POCO 零注解**:net5 消费方 Newtonsoft(大小写不敏感)天然兼容 camelCase;net8 内部用 `JsonSerializerOptions.Web` | 双序列化器兼容且不引入注解漂移 | +| 依赖策略 | SDK 最小依赖:`Microsoft.Extensions.Http` / `StackExchange.Redis` /(Auth 包)`Microsoft.AspNetCore.Authentication`——net5.0 版本线对齐消费方可用版本 | 避免 SDK 拖动消费方依赖升级 | + +> 未决事项升级:若决定 GrowthHealth 先升级 .NET 8 再接入,可砍掉 net5.0 目标线(见 9.3 裁决 #1)。 + +## 三、仓库脚手架(从空文件夹开始) + +### 3.1 目录结构 + +``` +yh-platform-net/ # 独立仓库(与 Java 不混仓) +├── Directory.Build.props # 共享编译属性(多目标 TFM、LangVersion、确定性构建) +├── Directory.Packages.props # 集中包版本管理(= Java BOM 等价物) +├── yh-platform-net.sln +├── src/ +│ ├── Yh.Platform.Abstractions/ # 模型与信封(对应 yh-utils-core) +│ │ └── Yh.Platform.Abstractions.csproj +│ ├── Yh.Platform.OpenApi/ # /oapi 客户端(对应 yh-openapi-client) +│ ├── Yh.Platform.AppAuth/ # HMAC 签名 + 防重放(对应 yh-app-auth-starter) +│ └── Yh.Platform.Auth/ # CAS SSO + 用户上下文(对应 yh-auth-starter) +├── tests/ +│ ├── Yh.Platform.Abstractions.Tests/ +│ ├── Yh.Platform.OpenApi.Tests/ # 含契约 fixture 对拍 +│ ├── Yh.Platform.AppAuth.Tests/ # 含共享测试向量对拍 +│ └── Yh.Platform.Auth.Tests/ +├── samples/ +│ └── Yh.Platform.Sample.Web/ # net8.0 示例应用(平台验收载体) +├── contract/ # 契约工件(单一事实源派生物) +│ ├── openapi-oapi.yaml # /oapi OpenAPI 3 spec +│ ├── sign-protocol.md # 签名协议规范 +│ └── fixtures/ +│ ├── sign-vectors.json # 共享签名测试向量 +│ └── envelope-*.json # 信封解析 fixture(与 Java 对拍) +├── .gitignore # dotnet 标准模板 +├── .gitea/workflows/ci.yml # 对齐 UserBase/Notice 的 Gitea CI 惯例 +└── README.md +``` + +### 3.2 初始化命令序列 + +```powershell +# 前置:已安装 .NET SDK 8.0(dotnet --version >= 8.0) +mkdir yh-platform-net; cd yh-platform-net +git init +dotnet new gitignore +dotnet new sln -n yh-platform-net + +# 四包 + 测试 + 示例(Abstractions 为 classlib,其余按依赖逐个建) +dotnet new classlib -n Yh.Platform.Abstractions -o src/Yh.Platform.Abstractions -f net8.0 +dotnet new classlib -n Yh.Platform.OpenApi -o src/Yh.Platform.OpenApi -f net8.0 +dotnet new classlib -n Yh.Platform.AppAuth -o src/Yh.Platform.AppAuth -f net8.0 +dotnet new classlib -n Yh.Platform.Auth -o src/Yh.Platform.Auth -f net8.0 +dotnet new xunit -n Yh.Platform.OpenApi.Tests -o tests/Yh.Platform.OpenApi.Tests -f net8.0 +dotnet new web -n Yh.Platform.Sample.Web -o samples/Yh.Platform.Sample.Web -f net8.0 + +# 引用关系(镜像 Java 模块依赖图) +dotnet add src/Yh.Platform.OpenApi/src reference src/Yh.Platform.Abstractions/src # openapi → abstractions +dotnet add src/Yh.Platform.AppAuth/src reference src/Yh.Platform.Abstractions/src # appauth → abstractions +dotnet add src/Yh.Platform.Auth/src reference src/Yh.Platform.Abstractions/src # auth → abstractions +dotnet add src/Yh.Platform.OpenApi.Tests/src reference src/Yh.Platform.OpenApi/src +# ... 其余同理 + +# 多目标改写:手工把各 csproj 的 改为 +# net5.0;net8.0 +# 并在 Directory.Build.props 固化公共属性 + +foreach f in (sln 里所有项目) { dotnet sln add $f } +dotnet build # 双目标全绿即脚手架完成 +``` + +### 3.3 集中版本管理(BOM 等价物) + +`Directory.Packages.props`(Central Package Management): + +```xml + + + true + 1.0.0 + + + + + + + + + + + +``` + +> net5.0 目标线的扩展包版本(如 `Microsoft.Extensions.Http 5.0.x`)通过条件 ItemGroup 区分;`#if NET5_0_OR_GREATER` 符号处理 API 差异(预计极少——`IHttpClientFactory`/`DelegatingHandler` 自 2.1 起稳定)。 + +## 四、四包详细设计(对齐 Java 模块) + +### 4.1 Yh.Platform.Abstractions(对应 yh-utils-core) + +| C# 类型 | Java 对齐 | 说明 | +| -------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `BoaUser` | `utils.model.BoaUser` | 字段一比一;`CurrentRole` 属性类型 `BoaRoleInfo`(对齐 Java `BoaUser.currentRole`,**替代** GrowthHealth 本地 `Bsch.Entity.Custom.CurrentRole` 散落实现) | +| `BoaRoleInfo` | `utils.model.BoaRoleInfo` | roleId/roleName/schId/schName/childId/childName,`int?`/`string` | +| `BoaGroup` / `BoaModule` / `BoaApp` / `BoaAppType` | 同名 | 纯 POCO 零注解(双序列化器兼容,见 2) | +| `ApiEnvelope` | `ApiResult` | `{code,msg,data}` 信封 + `IsSuccess` 判别(对齐 `AbstractOpenApiClient.unwrap()` 语义) | +| `DException` | `utils.exception.DException` | 携带 code/msg/data;`code≠0` 时由 OpenApi 包抛出 | +| `YhErrorCode` | `ErrorCode`/`IErrorCode` | 错误码抽象 | + +### 4.2 Yh.Platform.OpenApi(对应 yh-openapi-client + starter) + +| C# 类型 | Java 对齐 | 说明 | +| -------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `YschBaseOpenApi` | 门面同名 | **静态门面 + 实例构造** **`(baseUrl, appKey, appSecret)`** **双形态**:前者对齐 Java `YschBaseOpenApi` 静态入口;后者复刻 BschOpenApi `BaseOpenApi(host, appKey, appSecret)` 构造习惯,GrowthHealth 迁移时改 using 即可 | +| `IOpenApiUserClient` / `OpenApiUserClient` | `OpenApiUserClient`/`Default*` | 用户域:byId/byLoginName/byPhone/roles/identity current+switch/password/groupState/transferToSchool 等,端点清单从 Java 接口 javadoc 反推 | +| `IOpenApiGroupClient` / `OpenApiGroupClient` | 同名 | 组域:tree/byId/list/add/edit/remove | +| `IOpenApiAppClient` / `OpenApiAppClient` | 同名 | 应用域:byId/list | +| `OpenApiClientBase` | `AbstractOpenApiClient` | **信封感知解析单点**:成功裸数据 → 反序列化;HTTP 200 + `code≠0` → 抛 `DException`(携带 code/msg/data);`PREFIX="/oapi"` | +| `OpenApiOptions` | `OpenApiProperties` | `yh:open:` 配置节(base-host/base-path/app-key/app-secret/超时,默认值对齐 Java:connect 1500ms / read 5000ms) | +| `ServiceCollectionExtensions.AddYhOpenApi()` | `OpenApiAutoConfiguration` | `IHttpClientFactory` 注册 + typed client + `DelegatingHandler` 挂签名 | + +### 4.3 Yh.Platform.AppAuth(对应 yh-app-auth-starter) + +| C# 类型 | Java 对齐 | 说明 | +| ----------------- | ----------------------- | --------------------------------------------------------------------------- | +| `SignUtil` | `utils.crypto.SignUtil` | `HmacSHA256` 静态方法集,输出格式逐一对齐(见契约 `sign-protocol.md` 裁决) | +| `AppSignHandler` | `AppSignInterceptor` | `DelegatingHandler`:自动附加 `X-App-Key/X-Timestamp/X-Signature` 头(对齐 Java 头契约) | +| `SignKeyResolver` | `SignKeyResolver` | 三层:Redis → IMemoryCache → UserBase `/oapi/internal/sign-key/{appKey}` 兜底 | +| `ReplayGuard` | 防重放 | nonce + 5min 窗口,Redis 键空间与 Java 约定一致 | +| `AppAuthOptions` | `yh.app-auth.*` | 配置节 | + +### 4.4 Yh.Platform.Auth(对应 yh-auth-starter) + +| C# 类型 | Java 对齐 | 说明 | +| -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `CasAuthenticationHandler` | `AddCasSso()` 用到的本地 dll | 自研 CAS 远程认证 handler(AuthenticationHandler 基类),替换 `AspNetCore.Security.CAS.dll`:CasServerUrlBase、service ticket 校验 `/serviceValidate` | +| `BoaUserContext` | `UserContextHolder` | `AsyncLocal`(替代 Java ThreadLocal 语义) | +| `IUserDetailsProvider` | `UserDetailsProvider` SPI | 默认实现走 `IOpenApiUserClient` 换取 `BoaUser` | +| `AddYhAuth()` | `YhAuthAutoConfiguration` | Cookie + CAS 认证注册 + 用户上下文中间件 | + +**聚合入口**:`AddYhPlatform()` 一次带齐(等价 Java `yh-spring-boot-starter`),也可按包拆引。 + +## 五、契约工件(MVP 前置门禁,承 v1 第六章) + +1. `contract/sign-protocol.md`:权威签名方案(数据级 `data|ts` 小写 hex vs 请求级 `METHOD\nurl\nts` Base64——需裁决)、验签大小写、时间戳毫秒、5min 窗口; +2. `contract/openapi-oapi.yaml`:UserBase `controller.oapi` 四域全端点(springdoc 导出 + 人工补 `oneOf` 错误信封),排除 `/oapi/internal/**`; +3. `contract/fixtures/sign-vectors.json`:≥10 组签名向量(**由 Java** **`SignUtil`** **实际运行产出基线**,含空 body/超长参数/时间戳边界); +4. `contract/fixtures/envelope-*.json`:信封样例(成功裸数据/失败 code=4/5),双端解析对拍。 + +## 六、构建、CI 与发布 + +| 环节 | 方案 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| 本地构建 | `dotnet build`(双目标);`dotnet test`(net8.0 全量) | +| 契约对拍 | 测试内加载 `contract/fixtures/*.json`(`CopyToOutputDirectory`),断言 C# 输出与 fixture 逐字节一致 | +| CI(Gitea,对齐 UserBase/Notice 惯例) | `dotnet build -c Release` → `dotnet test` → `dotnet pack -c Release -o artifacts`;Java 侧 CI 增加一步"重算签名向量 fixture"防漂移 | +| 发布 | `dotnet nuget push` 到 Nexus 3 NuGet 仓库(`nexus.yunhaixingtong.com`),与 Maven 同私服 | +| 版本策略 | SemVer;包版本统一随 `YhPlatformNetVersion` 走(对齐 BOM 集中钉版本);兼容矩阵登记 `yh-platform ↔ yh-platform-net ↔ /oapi 契约` | + +## 七、分阶段实施步骤(每步有完成标志) + +| 阶段 | 内容 | 完成标志 | +| ------------------- | -------------------------------------------------------------------------------- | ------------------------------------------- | +| **P0 环境与裁决** | 装 .NET SDK 8;裁决 9.3 的 #1(net5.0 目标线去留)与 #2(签名方案);Java 侧跑出 `sign-vectors.json` 基线 | `dotnet --version ≥ 8`;裁决记录进本文档 9.3 | +| **P1 契约提纯** | 产出第五节四件契约工件 | 自检清单 A 全勾 | +| **P2 脚手架** | 第三节命令序列跑通,空壳四包 + 测试 + sample 编译绿 | `dotnet build` 双目标零 warning(新增代码零 warning) | +| **P3 Abstractions** | 全模型 + 信封 + 异常;用 `envelope-*.json` 做反序列化对拍测试 | Abstractions.Tests 绿 | +| **P4 AppAuth** | `SignUtil` + `AppSignHandler` + `SignKeyResolver`;`sign-vectors.json` 100% 对拍 | AppAuth.Tests 绿,含向量逐条断言 | +| **P5 OpenApi** | 三域 client + 门面双形态 + `AddYhOpenApi()`;对接 UserBase 测试环境端到端 | sample 应用成功取到 `BoaUser`(含 currentRole)与角色列表 | +| **P6 Auth** | CAS handler + `BoaUserContext` + `AddYhAuth()` | sample 应用 CAS 登录 → 上下文注入 `BoaUser` | +| **P7 打包发布** | `dotnet pack` + Nexus push + CI 固化 + 兼容矩阵登记 | Nexus 可 `dotnet add package` 拉到 | +| **P8 消费方就绪文档** | 《.NET 消费方接入指南》(appKey 注册、配置样例、从 BschOpenApi 迁移对照表) | 指南成文并评审 | + +## 八、完成后自检清单(Self-Check) + +> 用法:**SDK 建完(P0\~P6 收尾)后,一次性照单跑一遍**。全部打勾即合格;任何一项不勾,修完再勾,不留尾巴。 + +### 8.1 工程自检(有没有问题) + +* [ ] `git clone` 空机器 + `dotnet build` 一次通过(含 net5.0;net8.0 双目标,前提是裁决保留了 net5.0 线) + +* [ ] `dotnet test` 全绿(含契约 fixture 对拍) + +* [ ] 无 `#if` 之外的 TFM 差异残留;无 TODO/FIXME 遗留 + +* [ ] 包引用关系正确:OpenApi/AppAuth/Auth → Abstractions,无反向依赖 + +* [ ] `Directory.Packages.props` 全部包版本集中管理,csproj 内无散落版本号 + +* [ ] NuGet 包元数据完整(作者、描述、仓库地址、license) + +* [ ] `dotnet pack` 产物可被空控制台项目 `dotnet add package` 正常引用还原 + +### 8.2 契约自检(和 Java 侧对不对得上) + +* [ ] `sign-vectors.json` 全部向量 C# 输出逐字节一致(100% 对拍) + +* [ ] `envelope-*.json` 全部样例:成功裸数据反序列化正确;失败信封抛 `DException` 且携带 code/msg/data + +* [ ] 模型字段与 Java `BoaUser/BoaRoleInfo` 等一比一(含 `BoaUser.CurrentRole: BoaRoleInfo` 嵌套) + +* [ ] 同一份 fixture JSON 用 Newtonsoft(net5 形态)与 STJ(net8 形态)都能正确反序列化 + +* [ ] `X-App-Key/X-Timestamp/X-Signature` 头名与 Java 逐字一致 + +### 8.3 运行自检(跑起来对不对) + +* [ ] sample 应用签名调 /oapi 成功取到 `BoaUser`(含 currentRole) + +* [ ] CAS 登录 → `BoaUserContext` 注入正确;请求结束上下文清空 + +* [ ] 超时默认 connect 1500ms / read 5000ms 生效 + +* [ ] 防重放:同一签名 5 分钟内二次请求被拒 + +## 九、审计核对(功能逻辑逐项验证) + +> 用法:**自检全勾后**,按功能逐项核对"输入 → 预期行为",确认逻辑与 Java 侧等价。核对方式:跑 sample/单测实际验证,不靠读代码。每项给结论:通过 / 不通过(说明原因)。 + +### 9.1 核对表 + +| # | 功能 | 核对内容(输入 → 预期) | 方式 | 结果 | +| -- | --------- | ----------------------------------------------------------------------------- | -------------- | ------------------------------------------------- | +| 1 | HMAC 签名生成 | 固定 `(data, ts, secret)` → 输出与 Java `SignUtil` 完全一致 | 测试向量对拍 | ✅ 通过(向量 100% 对拍 + 独立实现抽检 6/6) | +| 2 | 签名请求头附加 | 任意经 SDK 发出的 HTTP 请求 → 自动带三头,值与手算一致 | 抓包/单测 | ✅ 通过(P1 修复后回归:handler 对 fullUrl 签名与 Java 基线逐字节一致) | +| 3 | 信封成功分支 | /oapi 返回裸数据/裸数组 → 正确反序列化为模型;数组含单元素与多元素 | fixture 单测 | ✅ 通过 | +| 4 | 信封失败分支 | HTTP 200 + `{code:4/5, msg, data}` → 抛 `DException`,code/msg/data 原样携带 | fixture 单测 | ✅ 通过(data 载体更名 `EnvelopeData`,P2 修复) | +| 5 | 业务异常等价性 | Java 侧 `catch(DException)` 的代码,C# 侧 `catch(DException)` 能拿到同样的 code/msg | 对照 | ✅ 通过 | +| 6 | 用户查询三入口 | byId / byLoginName / byPhone → 同一测试用户返回字段一致的 `BoaUser` | sample 实调 | ⏳ 待环境(UserBase 测试环境 + appKey 注册) | +| 7 | 多角色读取 | `getUserRoleList` → 角色列表字段完整(roleId/roleName/schId/schName/childId/childName) | sample 实调 | ⏳ 待环境 | +| 8 | 当前身份 | `getCurrentIdentity` → 与 UserBase `sys_user_current_role` 状态一致;未切换时回退主身份 | sample 实调 | ⏳ 待环境 | +| 9 | 身份切换(若需) | `switchIdentity` → 白名单 appKey 通过、非白名单拒绝、三元组非法拒绝 | sample 实调 | ⏳ 待环境 | +| 10 | 防重放 | 同 timestamp+nonce 二次请求 → 拒绝;5 分钟前时间戳 → 拒绝 | 实调 | ✅ 通过(逻辑单测:MemoryReplayGuard + 双向偏差窗口) | +| 11 | 密钥解析链 | Redis 有值 → 不请求 UserBase;Redis 空 → 兜底取到并回填缓存 | 断点/日志 | ✅ 通过(代码级核对,兜底实调待环境) | +| 12 | CAS 登录 | sample 走完 CAS 重定向 + ticket 校验 → 登录态建立、`BoaUser` 注入 | 浏览器实走 | ⏳ 待环境(CAS server 联通) | +| 13 | 用户上下文生命周期 | 请求内任何层可取 `BoaUserContext.Current`;请求结束不串到下一请求(并发压一下) | 单测/实调 | ✅ 通过(单测) | +| 14 | 错误传播 | UserBase 不可达/超时 → 抛超时类异常而非挂死;HTTP 非 2xx → 异常带状态码 | 实调(断 UserBase) | ✅ 通过(FakeHandler 模拟 500/异常路径) | +| 15 | 双形态门面 | `YschBaseOpenApi` 静态门面与 `new (baseUrl, appKey, appSecret)` 实例,行为一致 | 单测 | ✅ 通过 | + +### 9.2 审计结论记录 + +> 全部核对完成后填写;"不通过"项须附原因与修复决定。 + +| 日期 | 核对人 | 通过 / 不通过项 | 遗留问题与决定 | +| ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------- | +| 2026-08-28 | 架构验收(工具辅助) | **通过 11 项**(#1 签名向量、#3/#4 信封双分支、#5 异常等价、#10 防重放逻辑、#11 密钥链代码、#14 错误传播、#15 双形态门面等);**不通过 1 项(#2)**;待环境 3 项(#6\~#9、#12 实调类) | 见下方 2026-08-28 验收详情 | + +**2026-08-28 验收详情(BasePlatformDotnet,测试 67/67 绿)**: + +* **不通过 #2(P1 阻断)**:`AppSignHandler` 签名 target 未剥离 basePath。实证:Java 客户端 `AppSignInterceptor` 签名前 `stripBasePath`(配置 base-path=/userbase),服务端 `AppAuthFilter.buildSignedTarget` 剥 `request.getContextPath()`——两端验签口径为 `/oapi/**`;而 C# `AppSignHandler` 用 `uri.AbsolutePath`(含 `/userbase` 前缀)。UserBase 挂 context-path `/userbase` 时 C# 出站签名必被服务端拒绝。**根因连带**:`sign-vectors.json` 请求级向量的 `canonicalTarget` 含 `/userbase` 前缀(生成时 Java 侧 basePath 未配置),向量与 C# 实现"自洽"但均偏离服务端口径。**修复决定**:AppSignHandler 增加 basePath 剥离参数;同步修正请求级向量(canonicalTarget 去 `/userbase` 前缀)并在 `sign-protocol.md` 写明剥离规则;补一个"剥离后与 Java 拦截器同 URL 签名一致"的对拍测试。 + +* **P2**:`DException.Data` 遮蔽 `Exception.Data`(编译警告 CS0114,违反"零 warning"自检项)。`Exception.Data` 是 IDictionary 语义,被 `object?` 遮蔽后框架代码按基类契约访问会异常。决定:改名 `EnvelopeData`(或至少显式 `new` + 文档化),消除警告。 + +* **P3 观察项**:path 解码差异——Java 拦截器 `URLDecoder`(`+`→空格)vs C# `AbsolutePath`(保留 `+`);pack/Nexus push 未实际执行(P7 范畴);8.3 运行自检整组待 UserBase 测试环境(appKey 注册、网络可达)后补验 #6\~#9、#12。 + +* **独立验证**:签名向量经第三方实现(独立 HMACSHA256 脚本)抽样 6/6 MATCH,确认拼接顺序/编码/大小写跨实现一致。 + +**2026-08-28 修复执行记录(P1/P2 已闭环,回归 83/83 绿、双目标构建零 warning)**: + +* **P1 basePath 剥离**:`SignCanonical` 新增 `StripBasePath`(逐行对齐 Java `stripBasePath`,含尾斜杠归一化与前缀不完整匹配不剥离边界)与 `BuildTarget(Uri, basePath)` 重载;`AppSignHandler` 增加 basePath 参数(`NormalizeBasePath` 归一化,空=挂根不剥离),DI 扩展与 `BaseOpenApi` 均已接线传入 `OpenApiOptions.BasePath`;时钟源统一走 `SignUtil.NowMillis`(可注入,测试可固定 ts 与基线逐字节对拍)。 + +* **P1 向量修正**:`sign-vectors.json` 六个请求级用例 canonicalTarget 去 `/userbase` 前缀并以独立 HMAC 实现重算期望值;新增 `fullUrl`/`basePath` 字段复现剥离前 URL;`requestLevel.note` 写明剥离口径。 + +* **P1 对拍测试**:新增 `AppSignHandlerTests`(6 个向量驱动的 handler 对 fullUrl 签名与 Java 基线逐字节一致、剥离+query 排序、未配置 basePath 不剥离、7 组 StripBasePath 边界、BuildTarget 双形态)。 + +* **P2**:`DException.Data` → `EnvelopeData`(消除 CS0114 对 `Exception.Data` 的遮蔽),`sign-protocol.md` 2.2 节补 basePath 剥离规则。 + +* **协议文档**:`sign-protocol.md` 2.2 新增"basePath 剥离(客户端与服务端共同口径)"条款。 + +* 遗留:P3 观察项(path `+` 解码差异)与待环境项(#6\~#9、#12、Nexus push)不变。 + +**2026-08-28 双端同步闭环(契约快照对拍机制建成)**: + +* **流程补课**:UserBase 加 `/oapi` 文档分组(`SwaggerConfig.oapiGroup`:pathsToMatch /oapi/\*\* + pathsToExclude /oapi/internal/\*\*;prod 用 `SPRINGDOC_API_DOCS_ENABLED=false` 关闭)→ 真实导出 `contract/openapi-oapi.exported.json` 入库作为权威基线 → C# 侧 `ContractSnapshotTests`(15 个用例)对拍,双端同步机制正式生效。此前"手写 yaml 自称权威源"的流程偏差就此纠正。 + +* **对拍机制**:①端点对拍——SDK 路由表 ⊆ 快照(严格失败,UserBase 删改端点立刻红);快照多出的端点须显式登记白名单否则失败(防无声失联);②模型对拍——13 个 C# POCO 属性集(camelCase)⇔ 快照 schema properties 键集双向差额为零(反射驱动,UserBase 改字段立刻红)。 + +* **首战战果(快照暴露 4 个 SDK 真漂移,全部已修)**:① `SysUserRequest.Roles` → `RoleIds`(序列化键 `roles` 服务端收不到,会实际跑坏);② `SysUserRequest` 补 `SchoolId`;③ `SysGroupRequest` 补 `Id`;④ 新增 `GetAvailableIdentitiesAsync`(/oapi/identity/available,多角色配套端点此前缺失)。另修正手写 yaml 4 处文档偏差(byId id / appId 类型、clientType required、roles→roleIds+schoolId)。 + +* **基线更新流程**(写入测试 xml doc):UserBase 契约变更 → 起服务 `GET /v3/api-docs/oapi` 覆盖 exported.json → 跑测试,红了即漂移,同步修 SDK 或登记白名单。 + +* 回归:测试 98/98 绿(+15)。 + +### 9.3 方案合规核对(建设前依据,承 v2 方案审计) + +| 审计项 | 结论 | 依据 | +| ------------------------ | -- | ----------------------------------------------------- | +| 是否避免为 .NET 引入额外常驻运行单元 | 通过 | 无 sidecar、无网关耦合 | +| 是否与既有 yh-platform 架构风格一致 | 通过 | 四包 1:1 镜像;"core 纯契约 + 装配扩展"分层同源 | +| 是否与既有 BOM/私服治理一致 | 通过 | CPM = BOM 等价物;Nexus 3 同私服承载 NuGet | +| 是否复用既有基础设施 | 通过 | springdoc 已在 UserBase;Redis/CasSso 复用;模型编译期共享(Java 侧) | +| 影响面是否最小化 | 通过 | yh-platform 零改动;UserBase 仅配置级(spec 分组) | +| **兼容旧栈**(v2 新增) | 通过 | 多目标 net5.0;Newtonsoft 兼容模型;`BaseOpenApi` 构造形态复刻 | +| **可从空文件夹直接落地**(v2 新增) | 通过 | 第三节命令序列 + 第七节分阶段步骤,每步有完成标志 | +| 淘汰路径 | 通过 | 阶段化交付可独立回退;最坏降级方案 C(sidecar)过渡 | + +### 9.4 消费方代码实证(2026-08-28,建设依据) + +| 核查对象 | 核查点 | 结果 | +| ----------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `GrowthHealth/service/Bsch.Web/Bsch.Web.csproj` | 目标框架/依赖 | 实证:net5.0;Newtonsoft 5.0.2 适配器;本地 dll 引用 BschOpenApi/AspNetCore.Security.CAS/log4net | +| `Bsch.Common.csproj` | 基础设施栈 | 实证:Redis 缓存 2.2.0(旧式)、Newtonsoft 13.0.1、Serilog 4.1.0 | +| `Bsch.Repository/Repository/UserInfo.cs` | 旧 SDK 用法 | 实证:`new BaseOpenApi(UserCenterHost, appKey, appSecret)`;`BoaUser` 来自 `BschBaseOpenApi` 命名空间;CurrentRole 为本地实体+请求头拼装 | +| `ControllerAbstract.cs` | 控制器基类消费模式 | 实证:每个控制器 new 一个 BaseOpenApi(无 DI/无连接复用)——新 SDK 需以 IHttpClientFactory 修正此反模式 | +| `ServiceCollectionExtensions.cs`(AddCasSso) | CAS 认证形态 | 实证:Cookie + `AddCAS()`(本地 dll);CasServerUrlBase 取自配置 | +| `appsettings.json` | 配置契约 | 实证:`AppTicket.AppKey=100476`、UserCenterHost/SsoHost/SchClassHost——新 SDK Options 节需给出从旧键迁移的映射 | +| `Dockerfile` | 部署形态 | 实证:sdk:5.0/aspnet:5.0,aspnet:5.0 基础镜像已 EOL(升级建议列入消费方指南) | +| Java 侧核查(承 v1) | SignUtil 双规则/信封/springdoc/模型共享 | 结论不变:内容合格、形式待提纯;详见 v1 审计表 | + +### 9.5 未决事项(待裁决) + +| # | 事项 | 裁决人 | 截止 | v2 备注 | +| - | ------------------------------------------------------------ | ------------------- | ---- | -------------------------------------------------------- | +| 1 | 保留 `net5.0` 目标线,还是要求消费方先升 .NET 8 | 平台负责人 | P0 | 保留=SDK 复杂度+双目标;砍掉=GrowthHealth 必须先升级(含 EF Core/基础镜像连带升级) | +| 2 | 权威签名方案:数据级(`data\|ts` 小写 hex)还是请求级(`METHOD\nurl\nts` Base64) | 平台负责人 | P1 前 | 旧 BschOpenApi 实际用哪套可作为裁决输入(反编译或抓包确认) | +| 3 | 请求级验签大小写敏感是否设计意图 | yh-platform 维护者 | P1 前 | — | +| 4 | C# 模型手写 vs NSwag/Kiota 生成 | yh-platform-net 负责人 | P3 前 | v2 倾向手写(模型量小、需 Newtonsoft 零注解兼容) | +| 5 | 双签名规则长期收敛 | 平台负责人 | 长期 | 不阻塞 | + +### 9.6 变更记录 + +| 日期 | 版本 | 说明 | +| ---------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-08-28 | v1.0 | 初版:平台工件建设定位,方案选型、架构、影响面实证、自检清单、审计 | +| 2026-08-28 | v2.0 | 基于消费方实证(GrowthHealth net5.0 + BschOpenApi.dll 考古):新增目标框架兼容策略(net5.0;net8.0 多目标)、仓库脚手架命令序列(Directory.Build.props/CPM)、四包详细设计(含 `YschBaseOpenApi` 双形态复刻 BaseOpenApi 构造习惯)、分阶段实施步骤、自检清单 B 工程门禁、审计新增兼容旧栈与空文件夹可落地两项 | +| 2026-08-28 | v3.0 | 自检/审计简化落地:多阶段门禁改为"完成后一次性自检"(工程/契约/运行三组)+ "功能逻辑审计核对表"(15 项输入→预期核对,含审计结论记录表);原方案审计/代码实证降级为建设依据附录 | + diff --git a/yh-platform-dotnet建设方案v4.md b/yh-platform-dotnet建设方案v4.md new file mode 100644 index 0000000..9d319dd --- /dev/null +++ b/yh-platform-dotnet建设方案v4.md @@ -0,0 +1,257 @@ +# yh-platform-dotnet 建设方案 v4 —— 移交文档(已做 / 未做全记录) + +> 版本:v4.0 | 日期:2026-08-28 | 状态:**移交文档**(接手人从此文档开始) +> 定位:平台侧工件建设——与 Java `yh-platform` 同构的 .NET SDK(`yh-platform-net`,代码仓库名 `BasePlatformDotnet`),使平台基础能力(用户信息、/oapi 开放接口、应用签名、CAS SSO)具备跨语言辐射能力 +> v4 增量:**建设阶段基本完成**(四包 98 测试全绿、契约快照对拍机制建成、pack 产物验证通过)。本文档为移交版:完整记录已完成工作、待办事项、关键机制与快速上手路径 +> 前置阅读:v1(架构决策)→ v2(工程落地)→ v3(自检与审计),见 [yh-platform-dotnet建设方案.md](file:///d:/SoftProject/yunhai/yh-platform-dotnet建设方案.md) + +*** + +## 一、项目一句话说明 + +Java 侧有 `yh-platform`(Maven 聚合 SDK:取用户、调 /oapi 开放接口、应用签名、CAS SSO)。.NET 侧项目(如 GrowthHealth,由 dotnet core 迁入、部署于 projman 平台)原先用无源码的本地 `BschOpenApi.dll` 调旧接口。本项目产出 **`yh-platform-net` 四个 NuGet 包**,与 Java SDK 依赖**同一份契约**(UserBase /oapi + HMAC 签名协议),让 .NET 项目以正规 NuGet 依赖方式获得同等能力。 + +**核心原则:不要让 .NET 去"依赖 Java",而是让两个 SDK 依赖同一份契约。** + +## 二、仓库与关联工程地图 + +| 路径 | 角色 | 状态 | +|---|---|---| +| `d:\SoftProject\yunhai\BasePlatformDotnet\` | **本 SDK 仓库**(本移交的主对象) | 建设完成,待发布 | +| `d:\SoftProject\yunhai\BasePlatform\` | Java 侧 yh-platform(yh-utils-core / yh-openapi-client / yh-app-auth-starter / yh-auth-starter) | 零改动(契约对齐的参照系) | +| `d:\SoftProject\yunhai\UserBase\` | 用户中心服务(/oapi 契约权威源) | 本项目改了 2 个文件(见 3.3) | +| `d:\SoftProject\yunhai\GrowthHealth\` | 未来首个 .NET 消费方(net5.0 + Newtonsoft + BschOpenApi.dll) | 未接入(SDK 未发布的依赖项) | + +### BasePlatformDotnet 仓库结构(实物核对 2026-08-28) + +``` +BasePlatformDotnet/ +├── Directory.Build.props # 共享编译属性:双目标 net5.0;net8.0、LangVersion、NuGet 元数据、YhPlatformNetVersion +├── Directory.Packages.props # CPM 集中包版本(= Java BOM 等价物) +├── yh-platform-net.sln +├── .gitea/workflows/ci.yml # CI:build → test → pack +├── src/ +│ ├── Yh.Platform.Abstractions/ # 模型与信封(对应 yh-utils-core) +│ │ ├── BoaUser.cs / BoaRoleInfo.cs / BoaGroup.cs / BoaModule.cs / BoaApp.cs / BoaAppType.cs +│ │ ├── ApiEnvelope.cs / DException.cs / ErrorCode.cs / IErrorCode.cs / ClientEnum.cs +│ ├── Yh.Platform.AppAuth/ # HMAC 签名 + 防重放(对应 yh-app-auth-starter) +│ │ ├── SignUtil.cs / SignCanonical.cs / AppSignHandler.cs +│ │ ├── SignKeyResolver.cs / SignKeyFallback.cs / ReplayGuard.cs / SignAppKeyPayload.cs +│ │ └── AppAuthOptions.cs / AppAuthServiceCollectionExtensions.cs +│ ├── Yh.Platform.OpenApi/ # /oapi 客户端(对应 yh-openapi-client) +│ │ ├── YschBaseOpenApi.cs # 静态门面(对齐 Java 同名) +│ │ ├── BaseOpenApi.cs # 实例形态 (baseUrl, appKey, appSecret)——复刻旧 BschOpenApi 构造习惯 +│ │ ├── IOpenApiUserClient.cs / OpenApiUserClient.cs # 用户域 + 身份域 +│ │ ├── IOpenApiGroupClient.cs / OpenApiGroupClient.cs # 组域 +│ │ ├── OpenApiClientBase.cs # 信封感知解析单点(unwrap 语义) +│ │ ├── SysUserRequests.cs / SysGroupRequests.cs # 写操作请求模型 +│ │ └── OpenApiOptions.cs / OpenApiServiceCollectionExtensions.cs / OpenApiErrorCode.cs / OpenApiException.cs +│ └── Yh.Platform.Auth/ # CAS SSO + 用户上下文(对应 yh-auth-starter) +│ ├── CasAuthenticationHandler.cs / CasServiceTicketValidator.cs +│ ├── BoaUserContext.cs / UserContextMiddleware.cs / UserDetailsProvider.cs +│ └── YhAuthOptions.cs / AuthServiceCollectionExtensions.cs +├── tests/ # 4 个测试工程,共 98 个测试 +│ ├── Yh.Platform.Abstractions.Tests/ # 8(模型序列化双序列化器对拍) +│ ├── Yh.Platform.AppAuth.Tests/ # 42(签名向量对拍、basePath 剥离、防重放、密钥链) +│ ├── Yh.Platform.OpenApi.Tests/ # 37(信封解析、客户端行为、契约快照对拍 15 个) +│ └── Yh.Platform.Auth.Tests/ # 11(CAS handler、上下文生命周期) +├── samples/Yh.Platform.Sample.Web/ # net8.0 示例应用(平台验收载体,配置样例在 appsettings.json) +├── contract/ # 契约工件(单一事实源派生物) +│ ├── openapi-oapi.exported.json # ★ 权威契约快照(UserBase springdoc 真实导出,对拍基线) +│ ├── openapi-oapi.yaml # 手写契约(已与快照对齐,供人读) +│ ├── sign-protocol.md # 签名协议规范(含 basePath 剥离条款) +│ └── fixtures/ +│ ├── sign-vectors.json # 共享签名测试向量(含 fullUrl/basePath 字段) +│ └── envelope-success.json / envelope-error.json # 信封解析 fixture +└── README.md +``` + +## 三、已完成的工作(按时间序,全部可查证) + +### 3.1 建设主体(P0~P6 全部完成) + +| 阶段 | 内容 | 完成证据 | +|---|---|---| +| P0 框架裁决 | 多目标 `net5.0;net8.0`(net5 覆盖 GrowthHealth 旧栈,net8 覆盖新项目);SDK 8.0 工具链构建;模型纯 POCO 零注解(Newtonsoft 大小写不敏感天然兼容 camelCase) | [Directory.Build.props](file:///d:/SoftProject/yunhai/BasePlatformDotnet/Directory.Build.props) | +| P1 契约提纯 | sign-protocol.md(签名协议规范)+ openapi-oapi.yaml + sign-vectors.json(Java SignUtil 实际运行产出基线)+ envelope fixtures | contract/ 目录 | +| P2 脚手架 | 四包 + 四测试工程 + sample + CPM + CI | 仓库根 | +| P3 Abstractions | 全模型(BoaUser 含 CurrentRole:BoaRoleInfo 多角色嵌套)+ 信封 + DException + 错误码 | 8 测试绿 | +| P4 AppAuth | SignUtil(HMAC-SHA256)+ AppSignHandler(DelegatingHandler 三头)+ SignKeyResolver(Redis→Memory→UserBase 兜底)+ ReplayGuard(5min 窗口) | 42 测试绿 | +| P5 OpenApi | 用户/组/应用三域 client + 身份域(current/available/switch)+ 双形态门面 + AddYhOpenApi() | 37 测试绿 | +| P6 Auth | CasAuthenticationHandler(自研,替换旧 dll)+ BoaUserContext(AsyncLocal)+ AddYhAuth() | 11 测试绿 | + +**当前基线(2026-08-28 实测):双目标构建零 warning,测试 98/98 绿。** + +### 3.2 验收与缺陷修复记录(重要:都是真实踩过的坑,接手人必读) + +**第一轮验收(测试 67 绿时)发现:** + +| 级别 | 缺陷 | 修复 | +|---|---|---| +| P1 阻断 | `AppSignHandler` 签名未剥离 basePath:C# 用 `uri.AbsolutePath`(含 `/userbase` 前缀)签出 `/userbase/oapi/...`,而 Java 客户端(AppSignInterceptor.stripBasePath)与服务端(AppAuthFilter 剥 contextPath)口径都是 `/oapi/...`——UserBase 挂 context-path 时 C# 签名必被拒 | `SignCanonical.StripBasePath`(逐行对齐 Java,含尾斜杠归一化/前缀不完整匹配不剥离边界);`AppSignHandler` 增加 basePath 参数并接线 `OpenApiOptions.BasePath`;sign-vectors.json 六个请求级向量重算;新增 6 个逐字节对拍测试。回归 83 绿 | +| P2 | `DException.Data` 遮蔽 `Exception.Data`(CS0114 警告,违反零 warning 门禁) | 改名 `EnvelopeData` | +| P3 观察项 | path `+` 解码差异:Java 拦截器 URLDecoder(`+`→空格)vs C# AbsolutePath(保留 `+`)——仅当路径参数含 `+` 时有影响 | **未修**,列入待办(低概率场景) | + +**第二轮(契约快照对拍上线)发现 4 个 SDK 真漂移,全部已修:** + +| # | 缺陷 | 危害 | 修复 | +|---|---|---|---| +| 1 | `SysUserRequest.Roles` 序列化键 `roles`,服务端字段 `roleIds` | **新增/编辑用户带角色时服务端静默丢弃**(实现没错,是契约认知错误——67 个测试全绿也没发现,因为它只能靠权威快照暴露) | 改名 `RoleIds` | +| 2 | `SysUserRequest` 缺 `SchoolId` | 无法传参 | 补字段 | +| 3 | `SysGroupRequest` 缺 `Id` | 契约键不完整 | 补字段 | +| 4 | 缺 `/oapi/identity/available` 端点 | 多角色"可切换身份列表"能力缺失 | 新增 `GetAvailableIdentitiesAsync` | + +> 教训(写给接手人):**手写翻译代码无法发现契约认知错误,只有权威快照对拍能**。这就是 3.4 节机制存在的理由,请务必维持它。 + +### 3.3 UserBase 侧改动(共 2 个文件,已编译验证) + +| 文件 | 改动 | +|---|---| +| [SwaggerConfig.java](file:///d:/SoftProject/yunhai/UserBase/service/src/main/java/com/yunhai/userbase/config/SwaggerConfig.java) | 新增两个 `GroupedOpenApi`:**oapi 组**(pathsToMatch `/oapi/**` + pathsToExclude `/oapi/internal/**`,契约权威源导出入口)+ api 组(`/api/**` 业务接口隔离) | +| [application.yml](file:///d:/SoftProject/yunhai/UserBase/service/src/main/resources/application.yml) | 新增 springdoc 开关(默认开;生产用环境变量 `SPRINGDOC_API_DOCS_ENABLED=false` + `SPRINGDOC_SWAGGER_UI_ENABLED=false` 关闭) | + +> **部署注意**:UserBase 生产环境部署时记得设置上述两个环境变量,否则文档端点外泄。 + +### 3.4 契约快照对拍机制(本项目最重要的长效机制,移交后必须维护) + +**双端同步流程:** + +``` +UserBase 改 /oapi 契约 + → 起服务 GET /userbase/v3/api-docs/oapi + → 覆盖保存 BasePlatformDotnet/contract/openapi-oapi.exported.json(入库) + → cd BasePlatformDotnet && dotnet test + → ContractSnapshotTests 红了 = 契约漂移 + → 二选一:修 SDK(同步四包实现) / 在测试的 acknowledged 白名单显式登记(有意不跟) +``` + +**机制构成**([ContractSnapshotTests.cs](file:///d:/SoftProject/yunhai/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs),15 个用例): + +- **端点对拍**:`SdkRoutes` 路由表 ⊆ 快照(UserBase 删/改端点立刻红);快照多出的端点须显式登记 `acknowledged` 白名单否则红(防无声失联) +- **模型对拍**:`ModelMappings` 13 个 POCO 反射取属性集(camelCase 化)⇔ 快照 schema properties 键集,双向差额为零 +- **维护纪律**:SDK 加端点 → 必须同步登记 `SdkRoutes`;加模型 → 必须登记 `ModelMappings`;UserBase 加端点 → 登记白名单并排期跟进 + +### 3.5 发布产物验证(pack 已跑通,未推送) + +2026-08-28 实测 `dotnet pack -c Release`: + +- 4 包生成:`Yh.Platform.Abstractions/AppAuth/OpenApi/Auth.1.0.0.nupkg`(版本统一随 `YhPlatformNetVersion`) +- 包内 `lib/net5.0` + `lib/net8.0` 双 dll 齐全 +- 依赖元数据按 TFM 正确分组:net5.0 → 内部包 1.0.0 + Microsoft.Extensions.* 5.0.0;net8.0 → 8.0.x。**GrowthHealth(net5.0)引用时自动命中 net5.0 组,老项目零升级** +- 模型纯 POCO,与 GrowthHealth 的 Newtonsoft 13.0.1 无冲突(有双序列化器对拍测试锁定) + +### 3.6 配置契约(消费方视角,sample 已验证写法) + +```json +{ + "Yh": { + "OpenApi": { + "BaseHost": "http://userbase-host:port", + "BasePath": "/userbase", + "AppKey": "100xxx", + "AppSecret": "xxx" + } + } +} +``` + +- `BasePath`:UserBase context-path,**签名剥离与 URL 拼接都依赖它**(P1 缺陷修复后成为关键配置,挂根则留空) +- 聚合入口 `AddYhPlatform()`(等价 Java yh-spring-boot-starter),也可按包拆引 `AddYhOpenApi()` / `AddYhAppAuth()` / `AddYhAuth()` +- 超时默认对齐 Java:connect 1500ms / read 5000ms + +## 四、未完成的工作(移交待办,按优先级) + +### 4.1 🔴 发布链(阻塞所有消费方,预计半天) + +- [ ] **Nexus 建仓**(需管理员):登录 `nexus.yunhaixingtong.com` → Repositories → Create → `nuget-hosted`(Version policy: Release;Deployment policy: **Disable redeploy**)→ 拿 API Key。可选:`nuget-proxy`(代理 nuget.org)+ `nuget-public`(group) +- [ ] **版本号裁决**:当前 `YhPlatformNetVersion=1.0.0`。因 9.1 核对表 #6~#9/#12 尚未真实环境实调,**建议先发 `1.0.0-preview.1`**(改 Directory.Build.props 后重 pack),实调通过再定 1.0.0;或直接 1.0.0 接受风险 +- [ ] **推送**: + ```powershell + dotnet nuget add source "http://nexus.yunhaixingtong.com/repository/nuget-hosted/index.json" ` + --name nexus-nuget --username <账号> --password <密码> --store-password-in-clear-text + dotnet nuget push "d:\SoftProject\yunhai\BasePlatformDotnet\artifacts\*.nupkg" ` + --source nexus-nuget --api-key --skip-duplicate + ``` +- [ ] **推送后验证**:空项目 `dotnet add package Yh.Platform.OpenApi` 能还原;注意 http 源在新 SDK 需 `nuget.config` 加 `allowInsecureConnections="true"` +- [ ] **CI push 固化**:Gitea 仓库 Secrets 加 `NEXUS_NUGET_APIKEY`/`NEXUS_NUGET_URL`,ci.yml 的 pack 步骤后追加 push 步骤(仅 main 分支) + +### 4.2 🟠 真实环境实调(依赖 UserBase 测试环境 + appKey 注册) + +- [ ] 核对表 #6:用户查询三入口(byId/byLoginName/byPhone)实调,返回字段一致 +- [ ] 核对表 #7:`getUserRoleList` 多角色字段完整 +- [ ] 核对表 #8:`getCurrentIdentity` 与 UserBase 状态一致 +- [ ] 核对表 #9:`switchIdentity` 白名单通过/拒绝行为 +- [ ] 核对表 #12:CAS 登录全流程(sample 浏览器实走,需 CAS server 联通 + 回调域名注册) +- [ ] 8.3 运行自检整组(超时生效、防重放真实拒绝、SignKeyResolver 兜底实调) +- [ ] **appKey 注册**:消费方(含 GrowthHealth 既有 100476 或新申请)须在 UserBase 侧注册 appKey+secret;身份切换端点另有白名单(现仅 100001/100002) + +### 4.3 🟡 文档与治理 + +- [ ] **P8 消费方接入指南**(原方案承诺未做):appKey 注册流程、配置样例、**从 BschOpenApi.dll 迁移对照表**(`new BaseOpenApi(host,appKey,appSecret)` → 新 SDK 双形态对照、`BschBaseOpenApi.BoaUser` → `Yh.Platform.Abstractions.BoaUser`、本地 CurrentRole → 服务端 currentRole)、EF Core/基础镜像升级建议 +- [ ] **兼容矩阵登记**:`yh-platform Java 版本 ↔ yh-platform-net 版本 ↔ /oapi 契约快照日期` 三方对应关系表(首次发布时建立) +- [ ] **Java 侧 CI 补一步**:重算 sign-vectors.json 防漂移(原方案第六节设想,未实施;当前防线只在 C# 侧) + +### 4.4 ⚪ 长期/低优先 + +- [ ] P3 观察项:path 参数含 `+` 时的解码差异(Java `+`→空格 vs C# 保留)——出现实际案例再修 +- [ ] 未决裁决 #1:net5.0 目标线去留(若 GrowthHealth 升级 .NET 8,可砍掉简化 SDK) +- [ ] 未决裁决 #5:Java 双签名规则(数据级/请求级)长期收敛——不阻塞,属 Java 侧治理 +- [ ] GrowthHealth 实际接入(SDK 发布 + 接入指南就绪后启动,属消费方项目不属于本项目) + +## 五、接手人快速上手(30 分钟路径) + +```powershell +# 前置:.NET SDK 8.0+(dotnet --version) +cd d:\SoftProject\yunhai\BasePlatformDotnet +dotnet build # 双目标编译,预期零 warning +dotnet test # 预期 98/98 绿 +dotnet pack -c Release -o artifacts # 预期 4 包生成 +``` + +**理解项目的阅读顺序:** + +1. 本文档第三节(做了什么、踩过什么坑) +2. [contract/sign-protocol.md](file:///d:/SoftProject/yunhai/BasePlatformDotnet/contract/sign-protocol.md)——签名协议(含 basePath 剥离条款,P1 缺陷的教训沉淀) +3. [ContractSnapshotTests.cs](file:///d:/SoftProject/yunhai/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs)——双端同步机制(日常维护最主要触点) +4. [samples/Yh.Platform.Sample.Web/Program.cs](file:///d:/SoftProject/yunhai/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs) + appsettings.json——SDK 用法样例 +5. Java 参照系:`BasePlatform/yh-app-auth-spring-boot-starter`(签名口径)、`UserBase/controller/oapi`(契约源头) + +**日常最可能的变更场景与操作:** + +| 场景 | 操作 | +|---|---| +| UserBase 改了 /oapi 契约 | 重新导出快照 → dotnet test → 按红项修 SDK 或登记白名单 → 升版本 → push | +| SDK 加新端点 | Client 实现 + `SdkRoutes` 登记 + (新模型则)`ModelMappings` 登记 + 测试 | +| 发新版本 | 改 `Directory.Build.props` 的 `YhPlatformNetVersion` → pack → push(**Disable redeploy,同版本不可覆盖**) | + +## 六、验证状态总览 + +| 验证维度 | 状态 | +|---|---| +| 双目标编译(net5.0;net8.0) | ✅ 零 warning | +| 单元测试 | ✅ 98/98(Abstractions 8 / AppAuth 42 / OpenApi 37 / Auth 11) | +| 签名正确性 | ✅ 向量逐字节对拍 + 独立 HMAC 实现抽检 6/6 + handler 全链路对拍(P1 修复后) | +| 信封解析 | ✅ 成功裸数据/code≠0 抛 DException(EnvelopeData 携带),fixture 锁定 | +| 模型契约 | ✅ 13 POCO ⇔ 权威快照双向对拍 | +| 双序列化器 | ✅ Newtonsoft(大小写不敏感)+ STJ Web 逐节点对拍 | +| pack 产物 | ✅ 4 包、双 TFM lib、依赖分组正确(本地验证) | +| Nexus 发布 | ❌ 未推送(待 4.1) | +| 真实环境实调 | ❌ 待 UserBase 测试环境(待 4.2,核对表 15 项中 11 过 4 待) | +| 消费方实际接入 | ❌ 未开始(待 4.3 指南 + 4.1 发布) | + +## 七、历史文档索引 + +| 文档 | 内容 | 位置 | +|---|---|---| +| 方案 v1~v3(含全部演进过程、9.1 十五项核对表、9.5 未决裁决) | [yh-platform-dotnet建设方案.md](file:///d:/SoftProject/yunhai/yh-platform-dotnet建设方案.md) | 仓库根 | +| 签名协议规范 | contract/sign-protocol.md | SDK 仓库 | +| 项目 README | [README.md](file:///d:/SoftProject/yunhai/BasePlatformDotnet/README.md) | SDK 仓库 | + +### 变更记录 + +| 日期 | 版本 | 说明 | +|---|---|---| +| 2026-08-28 | v1.0 | 平台工件定位、方案选型、架构、影响面实证 | +| 2026-08-28 | v2.0 | 消费方实证(GrowthHealth net5.0 + BschOpenApi 考古)、脚手架命令、四包设计、分阶段实施 | +| 2026-08-28 | v3.0 | 自检清单(工程/契约/运行)+ 15 项审计核对表 | +| 2026-08-28 | v4.0 | **移交版**:已完成(P0~P6 + 两轮缺陷修复 + 快照对拍机制 + pack 验证)与待办(发布链/实调/指南/长期项)全记录,含接手人快速上手路径 |