@@ -0,0 +1,324 @@
|
|||||||
|
# YhCore — .NET 5.0 底层框架搭建方案
|
||||||
|
|
||||||
|
> 参考项目:`E:\YHXT\java\bschclassmanage`(Spring Boot 2.7 + JPA + MySQL + Redis + Nacos)
|
||||||
|
> 本地类库支撑:`E:\YHXT\net\Library`
|
||||||
|
> 范围:**仅底层框架**(分层结构、基础设施、横切组件),不含任何业务逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
将现有 Java(Spring Boot 2.7)项目 `bschclassmanage` 的底层框架能力平移到 .NET 5.0,形成一套可复用的后端基础框架,供 `YhCore` 及后续 .NET 项目使用。
|
||||||
|
|
||||||
|
**目标:**
|
||||||
|
|
||||||
|
1. 保持与 Java 版一致的分层模型与开发习惯(Controller → Service → Repository → Entity),降低团队切换成本。
|
||||||
|
2. 复用 `E:\YHXT\net\Library` 下已有的 DLL(日志、调度、文档处理、JSON 等),减少重复采购/引入。
|
||||||
|
3. 提供统一的基础设施:配置管理、日志、异常处理、统一响应、用户上下文、操作日志 AOP、数据访问、缓存、任务调度、接口文档、鉴权。
|
||||||
|
4. 只搭骨架,不写业务——框架落地后业务模块以"填空"方式接入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Java 参考项目结构分析
|
||||||
|
|
||||||
|
`bschclassmanage` 的包结构即框架的职责划分,逐项映射如下:
|
||||||
|
|
||||||
|
| Java 包 / 组件 | 职责 | 关键实现 |
|
||||||
|
| -------------------------------------------- | ------------------------------------------------ | ------------------------------- |
|
||||||
|
| `controller` (api / mobiapi / oapi) | 接口入口,按渠道分三套 | Spring MVC |
|
||||||
|
| `service` / `service.impl` | 业务服务层 | 接口 + 实现 |
|
||||||
|
| `Repository` / `specification` | 数据访问层 + 动态查询 | Spring Data JPA + Specification |
|
||||||
|
| `entity` | 数据库实体 | Hibernate 注解实体 |
|
||||||
|
| `model` (dto / vo / excel / constant / tool) | 传输与视图模型 | POJO + ModelMapper 转换 |
|
||||||
|
| `config` | 框架配置(Async / Redis / Swagger / WebMvc / File) | `@Configuration` |
|
||||||
|
| `interceptor` | 用户上下文拦截器 | `UserContextInterceptor` |
|
||||||
|
| `annotation` + `aspect` | 操作日志、同步日志 AOP | `@OperationLog` + AspectJ |
|
||||||
|
| `scheduler` | 定时任务(升级、日志清理) | Spring `@Scheduled` |
|
||||||
|
| `validation` | 自定义校验(密码等) | Hibernate Validator |
|
||||||
|
| `utils` | 30+ 工具类(日期/分页/签名/加解压/Redis/HTTP…) | 静态工具类 |
|
||||||
|
| 配置体系 | `bootstrap.yml`(Nacos) + `application-{env}.yml` | Nacos 配置中心,多 Profile |
|
||||||
|
| 日志 | `logback-spring.xml` | Logback + Slf4j |
|
||||||
|
|
||||||
|
**中间件清单**(来自 pom.xml / application.yml):MySQL 8、Redis、RabbitMQ(已预留配置)、Nacos、CAS SSO(yh-auth)、应用间签名鉴权(yh-app-auth)、Swagger(SpringDoc)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术选型对照表(Java → .NET 5.0)
|
||||||
|
|
||||||
|
| 能力 | Java 实现 | .NET 5.0 方案 | 来源 |
|
||||||
|
| -------- | -------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Web 框架 | Spring MVC | **ASP.NET Core 5.0 Web API** | 内置 |
|
||||||
|
| 依赖注入 | Spring IoC | **内置 DI**(Autofac 可选,AOP 需要时引入) | 内置 / NuGet |
|
||||||
|
| ORM | Spring Data JPA (Hibernate) | **EF Core 5.0** 为主;高频 SQL 用 **Dapper** | NuGet `Microsoft.EntityFrameworkCore.SqlServer 5.x` + Library `Dapper.Common.dll` |
|
||||||
|
| 数据库 | mysql-connector-java 8.0.33(MySQL 8) | **主库 SQL Server**(`Microsoft.EntityFrameworkCore.SqlServer 5.x`);**MySQL 可切换**(`Pomelo.EntityFrameworkCore.MySql 5.x`,按 `DatabaseProvider` 配置切换) | NuGet |
|
||||||
|
| 动态条件查询 | JPA Specification | EF Core `IQueryable` 链式拼装 + 自定义 Specification 模式 | 框架自研(轻量) |
|
||||||
|
| 缓存 | spring-data-redis | **StackExchange.Redis**(封装 `RedisUtil` 对应 Java 版) | NuGet |
|
||||||
|
| 配置中心 | Nacos + 多 Profile | **appsettings.{env}.json** 为基础;预留 **nacos-sdk-csharp** 接入点 | NuGet(可选) |
|
||||||
|
| 日志 | Logback + Slf4j接口日志 | **log4net**(Library 已有),**仅用于接口访问日志**:单一中间件记录接口调用详情,其余日志(SQL/框架/业务 Debug)全部关闭 | Library |
|
||||||
|
| JSON | fastjson / Jackson | **Newtonsoft.Json**(Library 已有,兼容旧接口格式) | Library |
|
||||||
|
| 对象映射 | ModelMapper | **AutoMapper** | NuGet |
|
||||||
|
| 参数校验 | Hibernate Validator | **FluentValidation**(或 DataAnnotations) | NuGet |
|
||||||
|
| 接口文档 | SpringDoc OpenAPI | **Swashbuckle.AspNetCore 5.x** | NuGet |
|
||||||
|
| 定时任务 | `@Scheduled` / scheduler 包 | **Quartz.NET 3.x**(Library 已有 `Quartz.dll`,需核版本)或 `BackgroundService` | Library |
|
||||||
|
| AOP | AspectJ(`aspect` + `annotation`) | **ActionFilter + 自定义 Attribute**(操作日志);全局逻辑用 **Middleware** | 内置 |
|
||||||
|
| 拦截器 | HandlerInterceptor | **Middleware / ActionFilter** | 内置 |
|
||||||
|
| HTTP 客户端 | HttpClient / OkHttp | **IHttpClientFactory** | NuGet `Microsoft.Extensions.Http` |
|
||||||
|
| Excel/文档 | easypoi (POI) | **Aspose.Cells / Aspose.Words**(Library 已有,打印场景核心) | Library |
|
||||||
|
| 压缩 | GzipUtil | `ICSharpCode.SharpZipLib`(Library 已有)或 BCL `System.IO.Compression` | Library |
|
||||||
|
| HTML 解析 | CommonMark / jsoup 类 | **HtmlAgilityPack**(Library 已有) | Library |
|
||||||
|
| SSO / 鉴权 | CAS(yh-auth)+ yh-app-auth 签名 | 自定义 `AuthenticationHandler` + 签名中间件(对接 `BschOpenApi.dll`) | Library + 自研 |
|
||||||
|
| 短信/消息 | — | `HyCloud.Sms.dll` / `BestschSendMsg.dll`(按需接入) | Library |
|
||||||
|
| Lombok | `@Getter/@Setter` | C# 9 `record` / 自动属性,无需替代 | 语言内置 |
|
||||||
|
| 异步线程池 | AsyncConfig | `async/await` + `Task.Run`,无需独立配置 | 语言内置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Library DLL 适配性评估(.NET 5.0 兼容性)
|
||||||
|
|
||||||
|
> .NET 5.0 可引用面向 **.NET Standard 2.0/2.1** 或 **.NET Core/.NET 5+** 的程序集;纯 .NET Framework 4.x 程序集多数可运行但需逐一验证。
|
||||||
|
|
||||||
|
| DLL | 用途 | .NET 5 兼容策略 |
|
||||||
|
| ------------------------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| `Aspose.Cells/Words/Slides.dll` | Excel/Word/PPT 文档生成(打印核心) | 需确认版本 ≥ 20.x(支持 .NET Standard 2.0)。旧 .NET Framework 版建议升级 NuGet 官方包 |
|
||||||
|
| `EntityFramework.dll` + `EntityFramework.SqlServer.dll` | EF6(SQL Server) | ⚠️ **不建议使用**:EF6 仅 6.4+ 支持 .NET Core;本方案选 EF Core 5(SqlServer 提供方) |
|
||||||
|
| `Dapper.Common.dll` | Dapper 扩展 | 验证目标框架;不兼容则改用 NuGet `Dapper` + `Dapper.Contrib` |
|
||||||
|
| `log4net.dll` + `Common.Logging*.dll` | 日志 | log4net 2.0.8+ 支持 .NET Standard 2.0,可直接用;建议只保留 log4net,弃用 Common.Logging(.NET 5 有官方 `ILogger` 抽象) |
|
||||||
|
| `Quartz.dll` | 任务调度 | 需 ≥ 3.1(.NET Standard 2.0);旧 2.x 不兼容,则 NuGet 升级 `Quartz` 3.x |
|
||||||
|
| `Newtonsoft.Json.dll` | JSON | 直接用(或 NuGet 对齐版本) |
|
||||||
|
| `ICSharpCode.SharpZipLib.dll` | 压缩/解压 | 1.2+ 支持 .NET Standard 2.0,可用 |
|
||||||
|
| `HtmlAgilityPack.dll` | HTML 解析 | 1.11+ 支持 .NET Standard 2.0,可用 |
|
||||||
|
| `C5.dll` | 扩展集合 | 2.5+ 支持 .NET Standard,可用(优先级低) |
|
||||||
|
| `BschOpenApi.dll` | 开放平台签名 | ⚠️ 需反编译确认目标框架;若为 .NET Framework 4.x 且无 Core 版,封装为独立 Facade 隔离风险 |
|
||||||
|
| `BestschSendMsg.dll` / `HyCloud.Sms.dll` / `BestSchool.Unread.dll` | 消息/短信 | 同上,逐个验证;不兼容走 HTTP API 调用替代 |
|
||||||
|
| `HTTPSSSL.dll` / `DecompressFile.dll` | HTTPS / 解压 | .NET 5 原生支持,建议**弃用**,用 BCL 替代 |
|
||||||
|
|
||||||
|
**结论**:核心三件(Aspose、log4net、Quartz)+ Newtonsoft.Json + SharpZipLib + HtmlAgilityPack 构成 Library 复用主力;EntityFramework、HTTPSSSL、DecompressFile 弃用;业务 DLL(BschOpenApi/Sms 等)做兼容性验证后封装接入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 解决方案结构设计
|
||||||
|
|
||||||
|
```
|
||||||
|
E:\YHXT\net\YhCore\
|
||||||
|
├─ YhCore.sln
|
||||||
|
├─ docs\ # 方案与设计文档
|
||||||
|
├─ lib\ # 本地 DLL 引用(软链或复制自 E:\YHXT\net\Library)
|
||||||
|
├─ sqlscript\ # 数据库脚本(沿用 Java 项目惯例)
|
||||||
|
├─ src\
|
||||||
|
│ ├─ YhCore.WebApi\ # ★ 启动项目(对应 Java 的 controller + config)
|
||||||
|
│ │ ├─ Controllers\
|
||||||
|
│ │ │ ├─ Api\ # 对应 controller/api
|
||||||
|
│ │ │ ├─ MobiApi\ # 对应 controller/mobiapi
|
||||||
|
│ │ │ └─ OApi\ # 对应 controller/oapi
|
||||||
|
│ │ ├─ Middleware\ # 对应 interceptor:接口访问日志、用户上下文、异常、签名
|
||||||
|
│ │ ├─ Filters\ # 对应 aspect+annotation:操作日志、校验
|
||||||
|
│ │ ├─ Attributes\ # 对应 annotation:[OperationLog] 等
|
||||||
|
│ │ ├─ Extensions\ # ServiceCollection / ApplicationBuilder 扩展
|
||||||
|
│ │ ├─ appsettings.json
|
||||||
|
│ │ ├─ appsettings.Development.json
|
||||||
|
│ │ ├─ appsettings.Production.json
|
||||||
|
│ │ ├─ log4net.config
|
||||||
|
│ │ ├─ Program.cs
|
||||||
|
│ │ └─ Startup.cs
|
||||||
|
│ ├─ YhCore.Service\ # 业务服务层(对应 service + service.impl)
|
||||||
|
│ │ ├─ Interfaces\
|
||||||
|
│ │ └─ Impl\
|
||||||
|
│ ├─ YhCore.Repository\ # 数据访问层(对应 Repository + specification)
|
||||||
|
│ │ ├─ Base\ # Repository 基类、IRepository<T>、UnitOfWork
|
||||||
|
│ │ ├─ Specification\ # 动态查询条件构造
|
||||||
|
│ │ └─ YHDbContext.cs # EF Core 上下文
|
||||||
|
│ ├─ YhCore.Entity\ # 数据库实体(对应 entity,DB First 生成)
|
||||||
|
│ ├─ YhCore.Model\ # 对应 model:DTO / VO / Excel / 常量
|
||||||
|
│ │ ├─ Dto\
|
||||||
|
│ │ ├─ Vo\
|
||||||
|
│ │ ├─ Excel\
|
||||||
|
│ │ └─ Constant\
|
||||||
|
│ ├─ YhCore.Common\ # 对应 utils:30+ 工具类的 .NET 版
|
||||||
|
│ │ ├─ Result\ # ServiceResult / ApiResult 统一响应
|
||||||
|
│ │ ├─ UserContext\ # 当前用户上下文(AsyncLocal 实现)
|
||||||
|
│ │ ├─ Cache\ # RedisUtil 封装
|
||||||
|
│ │ ├─ Http\ # HttpClient 封装
|
||||||
|
│ │ ├─ Security\ # Signature 签名、加密
|
||||||
|
│ │ └─ Utils\ # 日期/分页/枚举/校验/Gzip…
|
||||||
|
│ └─ YhCore.Task\ # 对应 scheduler:Quartz 任务宿主
|
||||||
|
│ ├─ Jobs\
|
||||||
|
│ └─ QuartzHostedService.cs
|
||||||
|
├─ tests\
|
||||||
|
│ └─ YhCore.Tests\ # xUnit 单元测试
|
||||||
|
└─ docker\ # Dockerfile / compose(沿用 Java 项目惯例)
|
||||||
|
```
|
||||||
|
|
||||||
|
**分层依赖方向**(单向,禁止反向引用):
|
||||||
|
|
||||||
|
```
|
||||||
|
WebApi → Service → Repository → Entity
|
||||||
|
└──────→ Model ←──────┘
|
||||||
|
└──────────→ Common ←──────────┘(Common 不依赖任何上层)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 底层框架核心组件设计
|
||||||
|
|
||||||
|
### 6.1 配置体系(对应 bootstrap.yml + application-{env}.yml)
|
||||||
|
|
||||||
|
- 本地开发:`appsettings.json` + `appsettings.Development.json`(默认不连 Nacos,对应 Java 的 `profile=local`)。
|
||||||
|
- 生产:环境变量注入(对应 CI 注入 `SPRING_PROFILES_ACTIVE=prod`)+ 可选 Nacos(`nacos-sdk-csharp`),加载顺序沿用"共享配置 → 应用配置 → 环境变量覆盖"的三级优先级约定。
|
||||||
|
- 强类型配置:`services.Configure<YhSsoOptions>(configuration.GetSection("Yh:Sso"))`,Options 类按 Java yml 的 key 结构一一对应(`yh.sso.*`、`yh.app-auth.*`、datasource、redis 等)。
|
||||||
|
|
||||||
|
### 6.2 日志(对应 logback-spring.xml)
|
||||||
|
|
||||||
|
**只保留接口访问日志,其余日志一律去除。**
|
||||||
|
|
||||||
|
- 通过 `ApiAccessLogMiddleware`(请求管道最外层)统一记录每次接口调用详情,框架不再输出任何其他日志(SQL 日志、框架运行日志、业务 Debug/Info 日志全部关闭)。
|
||||||
|
- **记录字段**:请求时间、渠道(api / mobiapi / oapi)、请求路径与方法、Query 与 Body(敏感字段脱敏,对应 `SensitiveDataUtil`)、客户端 IP、当前用户(取 `UserContext`)、响应状态码、响应耗时、异常摘要(若本次请求出错)。
|
||||||
|
- **落地**:log4net(Library 复用)配置单个 `RollingFileAppender`,按天滚动一个接口日志文件,不做级别分文件。
|
||||||
|
- **屏蔽其他输出**:`appsettings.json` 中将 `Microsoft.*`、`System.*`、`Pomelo.*`(EF Core SQL 日志)等内置日志类别全部设为 `None`;代码规范上禁止在业务代码中调用 `ILogger` 写日志。
|
||||||
|
|
||||||
|
### 6.3 统一响应(对应 yh-api-std / BestSchool.Common 的 ServiceResult)
|
||||||
|
|
||||||
|
复用 `Library\新建文件夹\BestSchool.Common\ServiceResult.cs` 的思路,定义:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ApiResult<T>
|
||||||
|
{
|
||||||
|
public int Code { get; set; } // 0=成功
|
||||||
|
public string Message { get; set; }
|
||||||
|
public T Data { get; set; }
|
||||||
|
public static ApiResult<T> Ok(T data) => new() { Code = 0, Data = data };
|
||||||
|
public static ApiResult<T> Fail(string msg, int code = -1) => new() { Code = code, Message = msg };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
配合 `ActionFilter` 自动包装 Controller 返回值,业务代码只返回 `T`。
|
||||||
|
|
||||||
|
### 6.4 全局异常处理中间件
|
||||||
|
|
||||||
|
- 捕获未处理异常 → 返回统一 `ApiResult.Fail`(对应 Java 的 `@ControllerAdvice`);异常详情由 6.2 的接口访问日志统一记录,不单独输出错误日志。
|
||||||
|
- 自定义 `BizException`:Service 层抛出业务异常,中间件翻译为友好错误码。
|
||||||
|
|
||||||
|
### 6.5 用户上下文(对应 UserContextInterceptor + UserContext + ControllerUserContextAspect)
|
||||||
|
|
||||||
|
- `UserContextMiddleware`:从 CAS Ticket / Header 解析当前用户,写入 `AsyncLocal<UserContext>`。
|
||||||
|
- 全链路任意位置通过 `UserContextHolder.Current` 获取(对应 Java 的 ThreadLocal 方案,AsyncLocal 是 .NET 的等价物且对 async 安全)。
|
||||||
|
- **Java `DWebMvcConfig` 不迁移、直接移除**:它仅负责把 `UserContextInterceptor` 注册到 `/**`,中间件方式天然作用于全管道,无需等价的注册配置类。
|
||||||
|
|
||||||
|
### 6.6 操作日志 AOP(对应 @OperationLog + OperationLogAspect)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
|
public class OperationLogAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string Module { get; }
|
||||||
|
public string Action { get; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `[OperationLog("班级", "升级")]` 标注在 Controller 方法上;
|
||||||
|
- `OperationLogFilter : IAsyncActionFilter` 在方法执行后解析 SpEL 等价表达式(用路由参数/参数名占位符),异步落库到操作日志表(对应 `OperationLogResolver` + `OperationLogRecordRepository`)。
|
||||||
|
|
||||||
|
### 6.7 数据访问(对应 JPA Repository + Specification)
|
||||||
|
|
||||||
|
- **数据库方案**:主库 **SQL Server**(`Server=localhost;Database=EduCloudDocPrint`),同时**支持切换 MySQL**——`appsettings.json` 中 `DatabaseProvider` 取 `SqlServer`(默认)或 `MySql`,`AddDataLayer` 扩展按此选择 EF Core 提供方(`Microsoft.EntityFrameworkCore.SqlServer 5.x` / `Pomelo.EntityFrameworkCore.MySql 5.x`)。连接串生产环境用环境变量覆盖(`ConnectionStrings__SqlServer`),不入库密码。
|
||||||
|
- **EF Core 5**:`YHDbContext` 集中管理实体;旧 EF6 实体无 `[Key]` 标注,统一按约定推断主键(优先 `Id`,其次 `SerID`,皆无则 Keyless);`IRepository<T>` / `RepositoryBase<T>` 提供 CRUD、分页(对应 `PageUtil`)、批量操作。
|
||||||
|
- **Specification 轻量实现**:`Expression<Func<T, bool>>` 组合器(And/Or/Not),对应 Java `specification` 包的动态查询。
|
||||||
|
- **Dapper 通道**:复杂报表/批量快照插入(对应 Java 用 JdbcTemplate 的 `backupAllSnapshot` 场景)走 Dapper,与 EF 共存。
|
||||||
|
- 事务:`IDbContextTransaction` 显式事务 + UnitOfWork 模式;生产 `ddl-auto=none` 对应 EF **禁用自动迁移**,结构变更走 `sqlscript` 脚本。
|
||||||
|
|
||||||
|
### 6.8 Redis 缓存(对应 RedisConfig + RedisUtil)
|
||||||
|
|
||||||
|
- `StackExchange.Redis` 单例 `ConnectionMultiplexer` 注入;
|
||||||
|
- `RedisUtil` 封装 String/Hash/Set/分布式锁,Key 规范常量集中在 `RedisConstants`(对应 Java 同名类)。
|
||||||
|
|
||||||
|
### 6.9 任务调度(对应 scheduler 包)
|
||||||
|
|
||||||
|
- Quartz.NET 3.x + `QuartzHostedService` 承载;Job 通过 DI 构造(可访问 Service 层)。
|
||||||
|
- 框架内置两个示例 Job 骨架:定时任务模板、日志清理任务模板(对应 `SysOperationLogCleanupTask`),业务 Job 后续按模板添加。
|
||||||
|
- 轻量场景可直接用 `BackgroundService` + `PeriodicTimer`。
|
||||||
|
|
||||||
|
### 6.10 接口文档(对应 SwaggerConfig)
|
||||||
|
|
||||||
|
- Swashbuckle:按渠道分组(api / mobiapi / oapi 三个 Swagger Doc),Header 鉴权参数预置,对应 Java 的三套 controller 分组。
|
||||||
|
|
||||||
|
### 6.11 鉴权(对应 yh-auth CAS + yh-app-auth 签名)
|
||||||
|
|
||||||
|
- `CasAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>`:复刻 CAS ticket 校验与 SSO 重定向逻辑(配置项对齐 `yh.sso.*`)。
|
||||||
|
- `AppAuthMiddleware`:应用间调用签名校验(app-key / timestamp / sign),对应 `yh.app-auth.*`;优先尝试复用 `BschOpenApi.dll` 的签名算法,不兼容则按 Signature.java 逻辑重写。
|
||||||
|
|
||||||
|
### 6.12 参数校验(对应 validation 包)
|
||||||
|
|
||||||
|
- FluentValidation:每个 DTO 一个 `AbstractValidator<T>`,自动注册;
|
||||||
|
- 自定义校验规则(如密码强度,对应 `PasswordValidation`)实现为可复用扩展方法。
|
||||||
|
|
||||||
|
### 6.13 文件上传(对应 FileConfig + multipart 50MB)
|
||||||
|
|
||||||
|
- `FormOptions` 配置 `MultipartBodyLengthLimit = 50MB`;
|
||||||
|
- 上传路径走强类型配置 `FileOptions`,对应 Java `FileConfig`。
|
||||||
|
|
||||||
|
### 6.14 路由常量(对应 Java `Config` 枚举的 API_PATH / MOBILEAPI_PATH / OAPI)
|
||||||
|
|
||||||
|
- Java `Config` 枚举中的三个渠道路由前缀,集中在 `Common` 层 `ApiRoutes` 常量类:
|
||||||
|
`ApiPath = "api"`、`MobiApiPath = "mobiapi"`、`OApiPath = "oapi"`。
|
||||||
|
- Controller 一律用 `[Route(ApiRoutes.ApiPath + "/log")]` 引用常量,**禁止硬编码 `"api/..."` 字符串**,与 Java 版 `Config.API_PATH` 常量化做法保持一致。
|
||||||
|
- `Config` 枚举的 `FILE_PATH` / `FILE_WEB_PATH` 已由 6.13 `FileOptions` 覆盖;`getString/getInt`(ResourceBundle 读 config.properties)无对应需求,不迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 关键骨架代码示例
|
||||||
|
|
||||||
|
**Program.cs / Startup.cs 装配顺序:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddControllers()
|
||||||
|
.AddNewtonsoftJson(o => { // 对齐 Java Jackson 配置
|
||||||
|
o.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss";
|
||||||
|
});
|
||||||
|
services.AddDataLayer(Configuration); // SQL Server 主库;DatabaseProvider=MySql 时切 Pomelo
|
||||||
|
services.AddRedisCache(Configuration); // RedisUtil 单例(StackExchange.Redis,连接串取 Redis:ConnectionString)
|
||||||
|
services.AddRepositories(); // 扩展方法:批量注册 IRepository<T>
|
||||||
|
services.AddServices(); // 扩展方法:批量注册 Service
|
||||||
|
services.AddAutoMapper(typeof(Startup));
|
||||||
|
services.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Startup>());
|
||||||
|
services.AddSwaggerGen(/* 三组文档 + 鉴权 Header */);
|
||||||
|
services.AddQuartzHosted(); // 调度
|
||||||
|
services.AddLog4Net(); // 日志
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
app.UseMiddleware<ApiAccessLogMiddleware>(); // 0. 接口访问日志(唯一日志出口)
|
||||||
|
app.UseMiddleware<GlobalExceptionMiddleware>(); // 1. 异常兜底(异常摘要并入接口日志)
|
||||||
|
app.UseMiddleware<UserContextMiddleware>(); // 2. 用户上下文
|
||||||
|
app.UseMiddleware<AppAuthMiddleware>(); // 3. 应用签名(oapi 渠道)
|
||||||
|
app.UseAuthentication(); // 4. CAS
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseEndpoints(e => e.MapControllers());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 实施步骤
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 产出 |
|
||||||
|
| -------- | ------------------------------------------------------------------- | ----------------- |
|
||||||
|
| 1. 环境准备 | 安装 .NET 5 SDK、创建解决方案与 7 个类库项目、配置 `lib` 本地引用 | sln 骨架可编译 |
|
||||||
|
| 2. 基础件接入 | log4net、Newtonsoft、Swagger、AutoMapper、FluentValidation | 启动即出 Swagger 页 |
|
||||||
|
| 3. 数据层 | 建 YHDbContext + Repository 基类 + Specification + Dapper 通道(暂不生成业务实体) | 连通 SQL Server 冒烟通过 |
|
||||||
|
| 4. 横切组件 | 统一响应、异常中间件、**接口访问日志中间件(唯一日志出口)**、用户上下文、操作日志 Filter | Postman 验证全链路 |
|
||||||
|
| 5. 缓存与调度 | RedisUtil + Quartz 宿主 + Job 模板 | Redis 读写、Job 触发验证 |
|
||||||
|
| 6. 鉴权 | CAS Handler + 签名中间件(对接 yh 平台配置) | SSO 联调通过 |
|
||||||
|
| 7. 收尾 | Dockerfile、CI 配置、开发规范文档 | 框架交付 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险与注意事项
|
||||||
|
|
||||||
|
1. **.NET 5 已 EOS**(2022-05 停止支持):按用户要求采用 5.0,但建议方案预留升级路径(代码只使用与 .NET 6/8 兼容的 API,将来改 `TargetFramework` 即可)。
|
||||||
|
2. **Library DLL 多为 .NET Framework 时代产物**:开工第一件事是对 `lib` 全部 DLL 做目标框架核验(可用 `dotnet` 反编译或 `Assembly.LoadFrom` 试加载),不兼容的立即走 NuGet 替代,避免后期返工。
|
||||||
|
3. **EF6 → EF Core 语义差异**:Java JPA 的 `ddl-auto: update` 在 EF Core 中对应 Migrate,生产环境一律禁用自动迁移,结构变更走 SQL 脚本(与 Java 侧生产约定一致)。
|
||||||
|
4. **AsyncLocal vs ThreadLocal**:用户上下文必须用 `AsyncLocal`,否则 async 方法链中上下文丢失。
|
||||||
|
5. **Nacos 为可选项**:本地/单机部署先用 `appsettings.{env}.json` + 环境变量;需接入配置中心时再启用 `nacos-sdk-csharp`,配置 key 结构保持与 Java yml 一致,降低运维心智成本。
|
||||||
|
6. **命名习惯迁移**:Java 的 `getClassHistoryTree` 等接口路径与返回结构保持原样(URL 与 JSON 字段不变),保证前端/移动端零改动切换。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/target/
|
||||||
|
**/.vs/
|
||||||
|
**/.idea/
|
||||||
|
**/logs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
# .NET Core 应用 CI:clone 源码 -> docker build(dotnet restore/build 在 Dockerfile 内)-> push Nexus -> 通知 projman 建档
|
||||||
|
# 触发:push 到 main / 手动 Run workflow(可勾选缓存重建)
|
||||||
|
# 注意:dotnet 构建发生在 docker build 内部,宿主卷挂不进去;其缓存 = docker 层缓存
|
||||||
|
# (Dockerfile 应按「COPY *.csproj -> dotnet restore -> COPY 其余」分层以命中层缓存)。
|
||||||
|
# cache_bust 勾选时清空 docker builder 层缓存,强制下次全量重建。
|
||||||
|
# 依赖仓库 Secrets:NEXUS_USER / NEXUS_PASSWORD(docker-hosted 推送)/ DEPLOY_TOKEN(webhook 鉴权)
|
||||||
|
# 依赖 docker/Dockerfile(由 projman 注入,开发者也可自备——已写则尊重不覆盖)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
cache_bust:
|
||||||
|
description: '构建前清空 NuGet 缓存'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: docker:24
|
||||||
|
options: --network infra-infra-net
|
||||||
|
steps:
|
||||||
|
- name: 安装 git / curl(alpine)
|
||||||
|
run: apk add --no-cache git curl
|
||||||
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
|
||||||
|
cp -a /tmp/src/. .
|
||||||
|
rm -rf /tmp/src
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
|
||||||
|
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
|
||||||
|
# 可选:清空 docker 构建层缓存(仅手动勾选 cache_bust 时;.NET 构建缓存即 docker 层缓存)
|
||||||
|
- name: 可选清空构建缓存
|
||||||
|
if: ${{ inputs.cache_bust == 'true' }}
|
||||||
|
run: |
|
||||||
|
docker builder prune -f
|
||||||
|
echo "docker build cache busted"
|
||||||
|
- name: 构建并推送镜像
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u "${{ secrets.NEXUS_USER }}" --password-stdin
|
||||||
|
docker build -t "nexus:8083/docker-hosted/docprint-service:${TAG}" -f docker/Dockerfile .
|
||||||
|
docker push "nexus:8083/docker-hosted/docprint-service:${TAG}"
|
||||||
|
echo "TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
- name: 通知 projman 部署
|
||||||
|
run: |
|
||||||
|
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
|
||||||
|
-d "{\"app\":\"docprint\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}"
|
||||||
@@ -37,3 +37,11 @@ artifacts/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# 业务侧特有
|
||||||
|
# 临时验证目录(Mock CAS 脚本、curl 日志、清理清单等)
|
||||||
|
Temp/
|
||||||
|
# 项目记忆与本地技能(含会话数据,不入库)
|
||||||
|
.workbuddy/
|
||||||
|
# 日志
|
||||||
|
*.log
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# 2026-09-01
|
|
||||||
|
|
||||||
## 修复:CS0121 Deserialize 重载二义性(Yh.Platform.AppAuth)
|
|
||||||
|
|
||||||
- 文件:`src/Yh.Platform.AppAuth/SignKeyResolver.cs` 第 49~52 行。
|
|
||||||
- 现象:`JsonSerializer.Deserialize<T>(json!, JsonOpts)` 报 CS0121,在
|
|
||||||
`Deserialize<TValue>(ReadOnlySpan<byte>, JsonSerializerOptions?)` 与
|
|
||||||
`Deserialize<TValue>(string, JsonSerializerOptions?)` 之间二义。
|
|
||||||
- 根因:`json` 是 StackExchange.Redis 的 `RedisValue`,它同时定义了到 `string` 和 `byte[]` 的隐式转换运算符,
|
|
||||||
`byte[]` 又可隐式转 `ReadOnlySpan<byte>`,两条用户定义转换路径都可行 → 编译器无法取舍。
|
|
||||||
- 修复:先显式取 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<String,Object>` 兼容行用 `Dictionary<string, JsonElement>`,通过 `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<string,string?>)` —— 只回填未显式配置项
|
|
||||||
(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:`<OutputType>Exe</OutputType>` + `<GenerateProgramFile>false</GenerateProgramFile>`
|
|
||||||
(关掉 Test SDK 自动生成的空 Main,避免 CS0017;`dotnet test` 不受影响,69 个用例照常发现)。
|
|
||||||
- `DebugMain.cs`:`DebugMain.Main(string[])` 调试入口,支持 `--only=名称子串`、`--off`;
|
|
||||||
调 `SchClassLiveDebug.RunAllAsync`,失败退出码非 0。
|
|
||||||
- 重构:把"一键跑通 20 个端点"的执行逻辑从 `SchClassLiveDebugTests.联调_一键跑通全部端点`
|
|
||||||
抽成 `SchClassLiveDebug.RunAllAsync(Action<string> log, string? only = null)`,测试与 Main 共用。
|
|
||||||
- 验证:`dotnet run --project tests/Yh.SchClass.OpenApi.Tests -- --only=VSchClass` → 3/3 通过;
|
|
||||||
`dotnet test` → 69 全通过;全方案 0 警告 0 错误。
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<!-- 类库多目标:net5.0 覆盖 GrowthHealth 旧栈;net8.0(LTS)覆盖新项目。
|
|
||||||
测试与 sample 仅 net8.0(各自 csproj 覆盖)。 -->
|
|
||||||
<TargetFrameworks>net5.0;net8.0</TargetFrameworks>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<Deterministic>true</Deterministic>
|
|
||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
|
||||||
<IsPackable>true</IsPackable>
|
|
||||||
<!-- 包版本统一随 YhPlatformNetVersion 走(对齐 BOM 集中钉版本) -->
|
|
||||||
<YhPlatformNetVersion>1.0.0</YhPlatformNetVersion>
|
|
||||||
<Version>$(YhPlatformNetVersion)</Version>
|
|
||||||
<Authors>yunhai-platform</Authors>
|
|
||||||
<Company>yunhai</Company>
|
|
||||||
<Product>yh-platform-net</Product>
|
|
||||||
<RepositoryUrl>https://git.yunhaixingtong.com/platform/yh-platform-net</RepositoryUrl>
|
|
||||||
<PackageProjectUrl>https://git.yunhaixingtong.com/platform/yh-platform-net</PackageProjectUrl>
|
|
||||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
|
||||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<!-- Central Package Management = Java BOM 等价物;csproj 内禁止散落版本号 -->
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- net5.0 目标线:扩展包版本对齐消费方可用版本(5.0.x);
|
|
||||||
StackExchange.Redis 用 2.5.x(其依赖链不强制 M.E.Logging 6.0,避免 net5.0 不受支持警告) -->
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net5.0'">
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
|
|
||||||
<PackageVersion Include="StackExchange.Redis" Version="2.5.43" />
|
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- net8.0(及测试)目标线:8.0.x LTS -->
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' != 'net5.0'">
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
|
||||||
<PackageVersion Include="StackExchange.Redis" Version="2.7.33" />
|
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
|
||||||
<PackageVersion Include="xunit" Version="2.9.0" />
|
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "张三" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
{
|
|
||||||
"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="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,580 +0,0 @@
|
|||||||
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: {}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# 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# 侧逐字节对拍。
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"profiles": {
|
|
||||||
"Yh.Platform.Sample.Web": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "http://localhost:5080",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net8.0</TargetFrameworks>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Yh.Platform.Auth\Yh.Platform.Auth.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
// 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<Task<int>> 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<string>();
|
|
||||||
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;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFrameworks>net8.0</TargetFrameworks>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
<RootNamespace>Yh.SchClass.DebugTool</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Yh.SchClass.OpenApi\Yh.SchClass.OpenApi.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"yh": {
|
|
||||||
"open": {
|
|
||||||
"Enabled": true,
|
|
||||||
"BaseHost": "http://base.yunhaixingtong.com",
|
|
||||||
"BasePath": "/userbase",
|
|
||||||
"SchClassPath": "/bschclass",
|
|
||||||
"AppKey": "100002",
|
|
||||||
"AppSecret": "2314a3b158b24a838b2b9b69fd3db118"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 通用接口响应包装,对应大多数后端接口返回的 <c>code/msg/data</c> 结构。
|
|
||||||
/// 纯数据载体,双序列化器兼容(Newtonsoft 大小写不敏感 / System.Text.Json camelCase)。
|
|
||||||
/// </summary>
|
|
||||||
public class ApiEnvelope<T>
|
|
||||||
{
|
|
||||||
/// <summary>业务状态码,0 表示成功</summary>
|
|
||||||
public int Code { get; set; }
|
|
||||||
|
|
||||||
/// <summary>提示信息</summary>
|
|
||||||
public string? Msg { get; set; }
|
|
||||||
|
|
||||||
/// <summary>业务数据</summary>
|
|
||||||
public T? Data { get; set; }
|
|
||||||
|
|
||||||
/// <summary>是否成功响应(Code == 0)</summary>
|
|
||||||
public bool IsSuccess => Code == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>非泛型信封(data 为通用对象/数组),用于信封感知解析前的判别。</summary>
|
|
||||||
public class ApiEnvelope : ApiEnvelope<object>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应用富信息载体。替代旧 bsch <c>BoaApp</c>。
|
|
||||||
/// 不含 appSecret(避免 oapi 泄露密钥);字段命名保留旧 bsch 风格。
|
|
||||||
/// </summary>
|
|
||||||
public class BoaApp
|
|
||||||
{
|
|
||||||
/// <summary>应用ID</summary>
|
|
||||||
public int? Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>应用名称</summary>
|
|
||||||
public string? Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>图标URL</summary>
|
|
||||||
public string? Icon { get; set; }
|
|
||||||
|
|
||||||
/// <summary>版本号</summary>
|
|
||||||
public string? Version { get; set; }
|
|
||||||
|
|
||||||
/// <summary>应用类型ID</summary>
|
|
||||||
public int? Type { get; set; }
|
|
||||||
|
|
||||||
/// <summary>PC端访问地址</summary>
|
|
||||||
public string? WebAddr { get; set; }
|
|
||||||
|
|
||||||
/// <summary>管理端地址</summary>
|
|
||||||
public string? AdminAddress { get; set; }
|
|
||||||
|
|
||||||
/// <summary>API根路径</summary>
|
|
||||||
public string? ApiRoot { get; set; }
|
|
||||||
|
|
||||||
/// <summary>appKey(不含 appSecret,避免泄露密钥)</summary>
|
|
||||||
public int? AppKey { get; set; }
|
|
||||||
|
|
||||||
/// <summary>排序号</summary>
|
|
||||||
public int? OrderNumber { get; set; }
|
|
||||||
|
|
||||||
/// <summary>创建人ID</summary>
|
|
||||||
public int? CreateUser { get; set; }
|
|
||||||
|
|
||||||
/// <summary>创建时间</summary>
|
|
||||||
public DateTime? CreateTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>移动端图标URL</summary>
|
|
||||||
public string? MobiIcon { get; set; }
|
|
||||||
|
|
||||||
/// <summary>支持的客户端类型(位掩码叠加值,对齐 UserBase ApplicationInfo.clients)</summary>
|
|
||||||
public int? Clients { get; set; }
|
|
||||||
|
|
||||||
/// <summary>集成方式</summary>
|
|
||||||
public int? IntegMode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>内置标记:1=内置 0=非内置</summary>
|
|
||||||
public int? BuiltInTag { get; set; } = 0;
|
|
||||||
|
|
||||||
/// <summary>网页包地址</summary>
|
|
||||||
public string? WebPagePkgAddr { get; set; }
|
|
||||||
|
|
||||||
/// <summary>网页访问地址</summary>
|
|
||||||
public string? WebAddress { get; set; }
|
|
||||||
|
|
||||||
/// <summary>微信公众号地址</summary>
|
|
||||||
public string? WxgzhAddress { get; set; }
|
|
||||||
|
|
||||||
/// <summary>钉钉应用地址</summary>
|
|
||||||
public string? DingAddress { get; set; }
|
|
||||||
|
|
||||||
/// <summary>是否公开应用</summary>
|
|
||||||
public bool? IsPublic { get; set; }
|
|
||||||
|
|
||||||
/// <summary>模块列表</summary>
|
|
||||||
public List<BoaModule>? Modules { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应用类型 + 该类型下挂的应用列表。平台级富信息载体,替代旧 bsch <c>BoaAppType</c>。
|
|
||||||
/// </summary>
|
|
||||||
public class BoaAppType
|
|
||||||
{
|
|
||||||
/// <summary>应用类型ID</summary>
|
|
||||||
public int? Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>应用类型名称</summary>
|
|
||||||
public string? Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>该类型下的应用列表</summary>
|
|
||||||
public List<BoaApp>? Apps { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 组织机构单元(部门 / 学校 / 教育局等)。对应旧 bsch 的 <c>com.bestsch.openapi.BoaGroup</c>。
|
|
||||||
/// </summary>
|
|
||||||
public class BoaGroup
|
|
||||||
{
|
|
||||||
public int? Id { get; set; }
|
|
||||||
|
|
||||||
public string? Name { get; set; }
|
|
||||||
|
|
||||||
public int? ParentId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>节点类型:1=单位/教育局 2=学校 3=部门 等,由业务自定义</summary>
|
|
||||||
public int? Type { get; set; }
|
|
||||||
|
|
||||||
/// <summary>排序号(同层级升序;可空)</summary>
|
|
||||||
public int? Sort { get; set; }
|
|
||||||
|
|
||||||
/// <summary>子节点列表;仅树接口展开时填充,未展开时为 null</summary>
|
|
||||||
public List<BoaGroup>? Children { get; set; }
|
|
||||||
|
|
||||||
public BoaGroup()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public BoaGroup(int? id, string? name)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>功能模块载体。与 Java <c>utils.model.BoaModule</c> 一比一。</summary>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 用户角色身份信息载体。与 Java <c>utils.model.BoaRoleInfo</c> 一比一:
|
|
||||||
/// 用户在某校某子节点的激活角色快照,由 UserBase oapi 端组装。
|
|
||||||
/// </summary>
|
|
||||||
public class BoaRoleInfo
|
|
||||||
{
|
|
||||||
/// <summary>角色ID</summary>
|
|
||||||
public int? RoleId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>角色名称</summary>
|
|
||||||
public string? RoleName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校ID(组ID)</summary>
|
|
||||||
public int? SchId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校名称</summary>
|
|
||||||
public string? SchName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>子节点ID(班级/学段等)</summary>
|
|
||||||
public int? ChildId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>子节点名称</summary>
|
|
||||||
public string? ChildName { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已登录用户的富信息载体(含部门/学校/角色)。与 Java <c>utils.model.BoaUser</c> 字段一比一,
|
|
||||||
/// 替代旧 bsch 的 <c>com.bestsch.openapi.BoaUser</c>。
|
|
||||||
/// 纯 POCO 零注解:Newtonsoft(大小写不敏感)与 System.Text.Json(camelCase)双兼容。
|
|
||||||
/// </summary>
|
|
||||||
public class BoaUser
|
|
||||||
{
|
|
||||||
/// <summary>用户主键</summary>
|
|
||||||
public int? Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>登录账号</summary>
|
|
||||||
public string? Account { get; set; }
|
|
||||||
|
|
||||||
/// <summary>姓名</summary>
|
|
||||||
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; }
|
|
||||||
|
|
||||||
/// <summary>用户类型:1=教师 2=家长 3=学生 等,由业务自定义</summary>
|
|
||||||
public int? Type { get; set; }
|
|
||||||
|
|
||||||
/// <summary>状态:1=正常 0=禁用</summary>
|
|
||||||
public int? Status { get; set; }
|
|
||||||
|
|
||||||
/// <summary>所属部门</summary>
|
|
||||||
public BoaGroup? Dept { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 当前激活身份的学校(来自 sys_user_current_role.school_id,数值即学校组 sys_group.id)。
|
|
||||||
/// 由 UserBase 侧填充:优先取用户当前切换的学校,缺省兜底为主身份所在学校;
|
|
||||||
/// 区域组/学段组/根直属用户(无学校归属)时为 null。
|
|
||||||
/// 与 <see cref="Schools"/> 平行:本字段表示"当前",Schools 表示"全部可选"。
|
|
||||||
/// </summary>
|
|
||||||
public BoaGroup? School { get; set; }
|
|
||||||
|
|
||||||
/// <summary>所属教育局/主管单位</summary>
|
|
||||||
public BoaGroup? Bureau { get; set; }
|
|
||||||
|
|
||||||
/// <summary>多级组织路径(含父级)</summary>
|
|
||||||
public List<BoaGroup>? Groups { get; set; }
|
|
||||||
|
|
||||||
/// <summary>角色 id 列表</summary>
|
|
||||||
public List<int>? RoleIds { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 当前激活身份(角色 × 学校)。来自 sys_user_current_role(未切换时兜底 sys_user_role_rel.is_main=1 主身份)。
|
|
||||||
/// 由 UserBase 侧填充;用户无任何身份时为 null。子应用做"当前身份/当前学校"判断时优先读本字段。
|
|
||||||
/// </summary>
|
|
||||||
public BoaRoleInfo? CurrentRole { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 用户所有可选学校列表(来自 sys_user_role_rel 按 user_id 去重的 school_id,不含 school_id=0)。
|
|
||||||
/// 每个元素即一所学校(type=SCHOOL_GROUP 的 sys_group),本列表即"切换身份"页的可选学校集合。
|
|
||||||
/// 区域组/学段组/根直属用户(无学校归属)此列表为空。
|
|
||||||
/// 与 <see cref="School"/> 平行:School=当前激活学校,Schools=全部可选学校。
|
|
||||||
/// </summary>
|
|
||||||
public List<BoaGroup>? Schools { get; set; }
|
|
||||||
|
|
||||||
public BoaUser()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public BoaUser(int? id, string? account, string? name)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Account = account;
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 客户端来源(对齐 Java <c>ClientEnum</c>)。key 为 2 的幂,支持位运算叠加。
|
|
||||||
/// </summary>
|
|
||||||
[Flags]
|
|
||||||
public enum ClientEnum
|
|
||||||
{
|
|
||||||
PcBrowser = 1,
|
|
||||||
MobileApp = 2,
|
|
||||||
SystemModule = 4,
|
|
||||||
WechatOfficial = 8,
|
|
||||||
WorkWechat = 16,
|
|
||||||
DingTalk = 32,
|
|
||||||
StudentDevice = 64,
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 平台业务异常(对齐 Java <c>utils.exception.DException</c>)。
|
|
||||||
/// 携带 code/EnvelopeData/msg/rawException/httpError;/oapi 业务失败(HTTP 200 + code≠0 信封)由 OpenApi 包抛出本异常。
|
|
||||||
/// 注意:信封 data 属性命名为 <see cref="EnvelopeData"/> 而非 Data,避免遮蔽 <see cref="Exception.Data"/>(IDictionary 契约)。
|
|
||||||
/// </summary>
|
|
||||||
public class DException : Exception
|
|
||||||
{
|
|
||||||
public IErrorCode? Code { get; }
|
|
||||||
|
|
||||||
/// <summary>信封 data 原样携带(通常为 System.Text.Json.JsonElement);对应 Java 侧 data 字段。</summary>
|
|
||||||
public object? EnvelopeData { get; }
|
|
||||||
|
|
||||||
public Exception? RawException { get; }
|
|
||||||
|
|
||||||
public object? HttpError { get; }
|
|
||||||
|
|
||||||
public DException(IErrorCode? code)
|
|
||||||
: base(ResolveMessage(code, null))
|
|
||||||
{
|
|
||||||
Code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>便捷构造:默认 code = Custom(5),message 为纯 msg。</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 平台已知错误码(对齐 Java <c>ErrorCode</c> 枚举的常量值与默认文案)。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛)
|
|
||||||
global using System;
|
|
||||||
global using System.Collections.Generic;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>错误码抽象(对齐 Java <c>IErrorCode</c>)。</summary>
|
|
||||||
public interface IErrorCode
|
|
||||||
{
|
|
||||||
/// <summary>业务状态码</summary>
|
|
||||||
int Code { get; }
|
|
||||||
|
|
||||||
/// <summary>默认提示信息</summary>
|
|
||||||
string Msg { get; }
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<PackageId>Yh.Platform.Abstractions</PackageId>
|
|
||||||
<Description>yunhai 平台模型与信封(对应 Java yh-utils-core):BoaUser/BoaRoleInfo 等纯 POCO、ApiEnvelope、DException、错误码抽象</Description>
|
|
||||||
<Summary>平台模型与信封契约,双序列化器(Newtonsoft/System.Text.Json)兼容</Summary>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应用签名鉴权配置(对齐 Java <c>yh.app-auth.*</c> 配置节)。
|
|
||||||
/// </summary>
|
|
||||||
public class AppAuthOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "yh:app-auth";
|
|
||||||
|
|
||||||
/// <summary>是否启用(默认 true)</summary>
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>请求有效期(毫秒),默认 5 分钟</summary>
|
|
||||||
public long ExpireMs { get; set; } = SignUtil.DefaultExpireMs;
|
|
||||||
|
|
||||||
/// <summary>本应用的 appKey(客户端出站签名用)</summary>
|
|
||||||
public string? AppKey { get; set; }
|
|
||||||
|
|
||||||
/// <summary>本应用的 appSecret(客户端出站签名用)</summary>
|
|
||||||
public string? AppSecret { get; set; }
|
|
||||||
|
|
||||||
/// <summary>服务端受信应用表(appKey → appSecret),用于固定密钥验签场景</summary>
|
|
||||||
public Dictionary<string, string> TrustedApps { get; set; } = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
/// <summary>Redis 连接串;为空时密钥解析链跳过 Redis 层、防重放退化为进程内实现</summary>
|
|
||||||
public string? RedisConnectionString { get; set; }
|
|
||||||
|
|
||||||
/// <summary>验签密钥在 Redis 中的键前缀(键空间与 Java 约定一致:sign:app:{appKey})</summary>
|
|
||||||
public string SignKeyRedisKeyPrefix { get; set; } = "sign:app:";
|
|
||||||
|
|
||||||
/// <summary>是否启用 UserBase 密钥兜底(GET {userbase}/oapi/internal/sign-key/{appKey})</summary>
|
|
||||||
public bool UserBaseSignKeyFallbackEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>UserBase 基址(兜底取密钥用),如 http://userbase 或 http://host/userbase</summary>
|
|
||||||
public string? UserBaseBaseUrl { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using StackExchange.Redis;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>配置型密钥来源(对齐 Java <c>PropertyAppSecretProvider</c>):从 TrustedApps 映射取密钥。</summary>
|
|
||||||
public sealed class PropertyAppSecretProvider : IAppSecretProvider
|
|
||||||
{
|
|
||||||
private readonly AppAuthOptions _options;
|
|
||||||
|
|
||||||
public PropertyAppSecretProvider(AppAuthOptions options)
|
|
||||||
{
|
|
||||||
_options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<string?> GetSecretAsync(string appKey, CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(_options.TrustedApps.TryGetValue(appKey, out var secret) ? secret : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Redis 密钥来源(对齐 Java <c>RedisAppSecretProvider</c>):委托 <see cref="SignKeyResolver"/> 三层解析链。</summary>
|
|
||||||
public sealed class SignKeyResolverAppSecretProvider : IAppSecretProvider
|
|
||||||
{
|
|
||||||
private readonly SignKeyResolver _resolver;
|
|
||||||
|
|
||||||
public SignKeyResolverAppSecretProvider(SignKeyResolver resolver)
|
|
||||||
{
|
|
||||||
_resolver = resolver;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<string?> GetSecretAsync(string appKey, CancellationToken ct = default)
|
|
||||||
=> _resolver.GetSecretAsync(appKey, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>服务端验签器:请求级签名 + 过期 + 防重放一次性校验入口。</summary>
|
|
||||||
public class AppAuthVerifier
|
|
||||||
{
|
|
||||||
private readonly IAppSecretProvider _secretProvider;
|
|
||||||
private readonly IReplayGuard _replayGuard;
|
|
||||||
private readonly AppAuthOptions _options;
|
|
||||||
|
|
||||||
public AppAuthVerifier(IAppSecretProvider secretProvider, IReplayGuard replayGuard, IOptions<AppAuthOptions> options)
|
|
||||||
{
|
|
||||||
_secretProvider = secretProvider;
|
|
||||||
_replayGuard = replayGuard;
|
|
||||||
_options = options.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 校验签名三件套。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="appKey">X-App-Key</param>
|
|
||||||
/// <param name="timestamp">X-Timestamp</param>
|
|
||||||
/// <param name="signature">X-Signature</param>
|
|
||||||
/// <param name="method">HTTP 方法</param>
|
|
||||||
/// <param name="canonicalTarget">canonical target(path[?sortedQuery],见 <see cref="SignCanonical"/>)</param>
|
|
||||||
/// <param name="nonce">可选防重放 nonce;提供时参与防重放判定</param>
|
|
||||||
/// <param name="ct">取消令牌</param>
|
|
||||||
/// <returns>校验通过返回 true;签名错/过期/重放返回 false</returns>
|
|
||||||
public async Task<bool> 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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 注册应用签名鉴权(客户端 AppSignHandler + 服务端 SignKeyResolver/ReplayGuard/AppAuthVerifier)。
|
|
||||||
/// </summary>
|
|
||||||
public static IServiceCollection AddYhAppAuth(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
services.AddOptions<AppAuthOptions>()
|
|
||||||
.Bind(configuration.GetSection(AppAuthOptions.SectionName))
|
|
||||||
.PostConfigure(o =>
|
|
||||||
{
|
|
||||||
if (o.ExpireMs <= 0)
|
|
||||||
{
|
|
||||||
o.ExpireMs = SignUtil.DefaultExpireMs;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
services.AddMemoryCache();
|
|
||||||
|
|
||||||
// Redis(可选):连接串为空时整条 Redis 链路自动降级
|
|
||||||
services.AddSingleton<Func<IDatabase?>>(sp =>
|
|
||||||
{
|
|
||||||
var options = sp.GetRequiredService<IOptions<AppAuthOptions>>().Value;
|
|
||||||
if (string.IsNullOrEmpty(options.RedisConnectionString))
|
|
||||||
{
|
|
||||||
return () => null;
|
|
||||||
}
|
|
||||||
var multiplexer = ConnectionMultiplexer.Connect(options.RedisConnectionString);
|
|
||||||
return () => multiplexer.GetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 密钥兜底:仅当配置开启时直调 UserBase internal 端点
|
|
||||||
services.AddSingleton<ISignKeyFallback>(sp =>
|
|
||||||
{
|
|
||||||
var options = sp.GetRequiredService<IOptions<AppAuthOptions>>().Value;
|
|
||||||
if (!options.UserBaseSignKeyFallbackEnabled || string.IsNullOrEmpty(options.UserBaseBaseUrl))
|
|
||||||
{
|
|
||||||
return NoOpSignKeyFallback.Instance;
|
|
||||||
}
|
|
||||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
|
||||||
return new UserBaseSignKeyFallback(factory.CreateClient("yh-appauth-signkey"), options.UserBaseBaseUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddSingleton<SignKeyResolver>();
|
|
||||||
services.AddSingleton<IAppSecretProvider>(sp => new SignKeyResolverAppSecretProvider(sp.GetRequiredService<SignKeyResolver>()));
|
|
||||||
services.AddSingleton<IReplayGuard>(sp =>
|
|
||||||
{
|
|
||||||
var options = sp.GetRequiredService<IOptions<AppAuthOptions>>().Value;
|
|
||||||
return string.IsNullOrEmpty(options.RedisConnectionString)
|
|
||||||
? new MemoryReplayGuard(options.ExpireMs)
|
|
||||||
: new RedisReplayGuard(sp.GetRequiredService<Func<IDatabase?>>(), options.ExpireMs);
|
|
||||||
});
|
|
||||||
services.AddSingleton<AppAuthVerifier>();
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
using System.Net.Http;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 客户端出站签名 <see cref="DelegatingHandler"/>(对齐 Java <c>AppSignInterceptor</c>):
|
|
||||||
/// 为请求自动附加 <c>X-App-Key / X-Timestamp / X-Signature</c> 三头;
|
|
||||||
/// 签名为请求级(canonical target 只取 path + 排序 query,Base64 输出)。
|
|
||||||
/// 签名前剥离 <paramref name="basePath"/>(对齐 Java stripBasePath):canonical 口径与服务端验签统一为 /oapi/**,
|
|
||||||
/// basePath 须与 UserBase 部署 context-path 一致(如 /userbase)。
|
|
||||||
/// </summary>
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 构造签名处理器。innerHandler 为 null 时 InnerHandler 保持 null(DelegatingHandler 默认),
|
|
||||||
/// 由 HttpClientFactory 注入 primary handler(AddHttpMessageHandler 场景要求 InnerHandler 初始为 null);
|
|
||||||
/// 独立使用时显式传入内层 handler(见 BaseOpenApi / 单元测试)。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>归一化 basePath:trim、去尾斜杠;null/空白返回 null(挂根路径,不剥离)。</summary>
|
|
||||||
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<HttpResponseMessage> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 跨 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;
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using StackExchange.Redis;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 防重放守卫:同一 (appKey, timestamp, nonce) 只允许提交一次,有效期同请求窗口(默认 5 分钟)。
|
|
||||||
/// </summary>
|
|
||||||
public interface IReplayGuard
|
|
||||||
{
|
|
||||||
/// <summary>true = 首次提交放行;false = 重复提交拒绝。</summary>
|
|
||||||
Task<bool> TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>进程内防重放(单实例部署或 Redis 未配置时使用)。</summary>
|
|
||||||
public sealed class MemoryReplayGuard : IReplayGuard
|
|
||||||
{
|
|
||||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _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<bool> 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 _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Redis 防重放:SETNX 键 <c>sign:replay:{appKey}:{timestamp}:{nonce}</c>,TTL = 请求窗口。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class RedisReplayGuard : IReplayGuard
|
|
||||||
{
|
|
||||||
private readonly Func<IDatabase?> _redisDatabaseFactory;
|
|
||||||
private readonly long _windowMs;
|
|
||||||
|
|
||||||
public RedisReplayGuard(Func<IDatabase?> redisDatabaseFactory, long windowMs = SignUtil.DefaultExpireMs)
|
|
||||||
{
|
|
||||||
_redisDatabaseFactory = redisDatabaseFactory;
|
|
||||||
_windowMs = windowMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应用密钥载荷(对齐 Java <c>SignAppKeyPayload</c>,与 UserBase SignKeyInternalController 返回契约一致)。
|
|
||||||
/// </summary>
|
|
||||||
public class SignAppKeyPayload
|
|
||||||
{
|
|
||||||
public string? AppKey { get; set; }
|
|
||||||
|
|
||||||
public string? Secret { get; set; }
|
|
||||||
|
|
||||||
public bool Enabled { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 请求签名规范化工具(调用方签名与服务端验签共用)。
|
|
||||||
/// canonical 的 target 部分只取 <b>path(不含 host/port/context-path)+ 排序后的 query</b>:
|
|
||||||
/// <c>METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp</c>。
|
|
||||||
/// 只编码 path 可让服务发现/负载均衡解析前后两端 host 不一致时仍天然对齐。
|
|
||||||
/// </summary>
|
|
||||||
public static class SignCanonical
|
|
||||||
{
|
|
||||||
/// <summary>拼接 target:path +(有 query 时 ?sortedQuery)。</summary>
|
|
||||||
public static string BuildTarget(string path, string sortedQuery)
|
|
||||||
=> string.IsNullOrEmpty(sortedQuery) ? path : path + "?" + sortedQuery;
|
|
||||||
|
|
||||||
/// <summary>从 <see cref="Uri"/> 构建 canonical target(AbsolutePath + 排序 query),不剥离 basePath。</summary>
|
|
||||||
public static string BuildTarget(Uri uri)
|
|
||||||
=> BuildTarget(uri, null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从 <see cref="Uri"/> 构建 canonical target,并在签名前剥离部署 basePath
|
|
||||||
/// (对齐 Java <c>AppSignInterceptor.stripBasePath</c>:客户端配置 base-path 与部署 context-path 一致时,
|
|
||||||
/// 签名口径与服务端 <c>AppAuthFilter</c> 剥 <c>request.getContextPath()</c> 后严格对齐,canonical 统一为 /oapi/**)。
|
|
||||||
/// </summary>
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从请求路径剥离部署基础路径(对齐 Java <c>AppSignInterceptor.stripBasePath</c>,含尾斜杠归一化边界):
|
|
||||||
/// path 不以 basePath 开头时原样返回;剥离后为空或丢失前导斜杠时补 "/"。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从原始 query 串(a=1&b=2,可能已编码)解码并整体排序,输出 k=v&k2=v2。
|
|
||||||
/// 与 Java <c>sortRawQuery</c> 一致:先拆 k/v、各自 URL 解码、按 "k=v" 整串排序后拼接。
|
|
||||||
/// </summary>
|
|
||||||
public static string SortRawQuery(string? rawQuery)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(rawQuery))
|
|
||||||
{
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
var pairs = new List<string>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从多值参数表排序,输出 k=v&k2=v2(键排序、值也排序,保证顺序无关)。
|
|
||||||
/// 对齐 Java <c>sortMultiValue</c>。
|
|
||||||
/// </summary>
|
|
||||||
public static string SortMultiValue(IEnumerable<KeyValuePair<string, IEnumerable<string?>>> multiValueParams)
|
|
||||||
{
|
|
||||||
if (multiValueParams == null)
|
|
||||||
{
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
var pairs = new List<string>();
|
|
||||||
foreach (var (key, values) in multiValueParams)
|
|
||||||
{
|
|
||||||
var vals = (values ?? Enumerable.Empty<string?>())
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>服务端验签密钥来源(对齐 Java <c>AppSecretProvider</c>):未知应用返回 null。</summary>
|
|
||||||
public interface IAppSecretProvider
|
|
||||||
{
|
|
||||||
Task<string?> GetSecretAsync(string appKey, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>签名密钥兜底来源(对齐 Java <c>SignKeyFallback</c>)。</summary>
|
|
||||||
public interface ISignKeyFallback
|
|
||||||
{
|
|
||||||
Task<string?> ResolveAsync(string appKey, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>空实现兜底。</summary>
|
|
||||||
public sealed class NoOpSignKeyFallback : ISignKeyFallback
|
|
||||||
{
|
|
||||||
public static readonly NoOpSignKeyFallback Instance = new();
|
|
||||||
|
|
||||||
public Task<string?> ResolveAsync(string appKey, CancellationToken ct = default) => Task.FromResult<string?>(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// UserBase 密钥兜底:直调 <c>GET {baseUrl}/oapi/internal/sign-key/{appKey}</c>,
|
|
||||||
/// 解析 <see cref="SignAppKeyPayload"/>;仅当 enabled 且 secret 非空时返回;404/异常/超时均返回 null。
|
|
||||||
/// </summary>
|
|
||||||
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<string?> 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<Yh.Platform.Abstractions.ApiEnvelope<SignAppKeyPayload>>(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);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using StackExchange.Redis;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验签密钥解析链(对齐 Java <c>SignKeyResolver</c>):
|
|
||||||
/// 配置内置应用(yh:app-auth AppKey/AppSecret)→ Redis(sign:app:{appKey})→ IMemoryCache → ISignKeyFallback(UserBase /oapi/internal/sign-key/{appKey});
|
|
||||||
/// 未知 app 返回 null(调用方直接拒绝)。
|
|
||||||
/// </summary>
|
|
||||||
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<IDatabase?>? _redisDatabaseFactory;
|
|
||||||
private IDatabase? _redis;
|
|
||||||
|
|
||||||
public SignKeyResolver(IOptions<AppAuthOptions> options, IMemoryCache memoryCache, ISignKeyFallback? fallback = null)
|
|
||||||
: this(options.Value, memoryCache, fallback ?? NoOpSignKeyFallback.Instance, null)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public SignKeyResolver(AppAuthOptions options, IMemoryCache memoryCache, ISignKeyFallback fallback, Func<IDatabase?>? redisDatabaseFactory)
|
|
||||||
{
|
|
||||||
_options = options;
|
|
||||||
_memoryCache = memoryCache;
|
|
||||||
_fallback = fallback;
|
|
||||||
_redisDatabaseFactory = redisDatabaseFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>解析调用方 appKey 对应的 appSecret;未知/不可用返回 null。</summary>
|
|
||||||
public async Task<string?> 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<SignAppKeyPayload>(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// HmacSHA256 接口签名工具类(对齐 Java <c>utils.crypto.SignUtil</c>,输出格式逐字节一致)。
|
|
||||||
/// 双签名规则:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>数据级:source = data + "|" + timeStamp,HmacSHA256 输出<b>小写十六进制</b>;</item>
|
|
||||||
/// <item>请求级:source = METHOD + "\n" + url + "\n" + timeStamp,HmacSHA256 输出 <b>Base64</b>。</item>
|
|
||||||
/// </list>
|
|
||||||
/// </summary>
|
|
||||||
public static class SignUtil
|
|
||||||
{
|
|
||||||
private const string AlgHmacSha256 = "HmacSHA256";
|
|
||||||
private const string SignSplitChar = "|";
|
|
||||||
|
|
||||||
/// <summary>签名密钥最低长度</summary>
|
|
||||||
public const int MinSecretLength = 16;
|
|
||||||
|
|
||||||
/// <summary>默认请求有效时长:5 分钟(毫秒)</summary>
|
|
||||||
public const long DefaultExpireMs = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// ====================== 数据级签名 ======================
|
|
||||||
|
|
||||||
/// <summary>生成数据级签名(data|ts → HmacSHA256 小写十六进制)。</summary>
|
|
||||||
public static string Sign(string data, string timeStamp, string secret)
|
|
||||||
{
|
|
||||||
CheckParam(data, timeStamp, secret);
|
|
||||||
return HmacSha256Hex(data + SignSplitChar + timeStamp, secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>数据级验签(不校验时间戳,忽略大小写比对)。</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>数据级验签 + 时间戳过期校验(默认 5 分钟)。</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== 请求级签名(统一方案) ======================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 对一次 HTTP 请求签名,输出 Base64。
|
|
||||||
/// canonical = METHOD + "\n" + url + "\n" + timestamp;url 为 canonical target(path[?sortedQuery],见 <see cref="SignCanonical"/>)。
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>请求级验签(不校验时间戳,精确比对)。</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>请求级验签 + 时间戳过期校验(默认 5 分钟)。</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== 内部工具 ======================
|
|
||||||
|
|
||||||
/// <summary>当前毫秒时间戳(供测试注入替代)。</summary>
|
|
||||||
public static Func<long> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<PackageId>Yh.Platform.AppAuth</PackageId>
|
|
||||||
<Description>yunhai 平台应用签名与防重放(对应 Java yh-app-auth-starter):HmacSHA256 SignUtil、AppSignHandler、SignKeyResolver、ReplayGuard</Description>
|
|
||||||
<Summary>HMAC 应用签名(X-App-Key/X-Timestamp/X-Signature)与验签密钥三层解析链</Summary>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
|
||||||
<PackageReference Include="StackExchange.Redis" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
/// <summary>认证装配扩展(对齐 Java <c>YhAuthAutoConfiguration</c> 与聚合 starter)。</summary>
|
|
||||||
public static class AuthServiceCollectionExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 注册 Cookie + CAS 认证与用户上下文依赖(含 AddYhOpenApi)。
|
|
||||||
/// 配置节:<c>yh:auth</c>(通用)与 <c>yh:sso</c>(CAS 服务端)。
|
|
||||||
/// </summary>
|
|
||||||
public static IServiceCollection AddYhAuth(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
services.AddYhOpenApi(configuration);
|
|
||||||
|
|
||||||
// 会话票据表单例:Cookie TicketDataFormat 与 OnSigningOut 退出吊销必须共享同一实例(同一票据表)
|
|
||||||
var sessionTicketFormat = new SessionTicketDataFormat();
|
|
||||||
services.AddSingleton(sessionTicketFormat);
|
|
||||||
|
|
||||||
services.AddOptions<YhAuthOptions>().Bind(configuration.GetSection(YhAuthOptions.SectionName));
|
|
||||||
// 注:AddScheme 的 configure 才是 handler 实际读取的命名 options(AuthenticationHandler<TOptions>.Options);
|
|
||||||
// 必须在此绑定 yh:sso,否则 handler 拿到的 CasAuthenticationOptions 为空(Challenge 时 CasServerUrlPrefix 缺失)。
|
|
||||||
services.AddOptions<CasAuthenticationOptions>().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<YhAuthOptions>() ?? 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<CasAuthenticationOptions, CasAuthenticationHandler>(YhAuthDefaults.CasScheme,
|
|
||||||
o => configuration.GetSection("yh:sso").Bind(o));
|
|
||||||
|
|
||||||
services.AddSingleton<CasServiceTicketValidator>(sp =>
|
|
||||||
{
|
|
||||||
var opts = sp.GetRequiredService<IOptionsMonitor<CasAuthenticationOptions>>().CurrentValue;
|
|
||||||
return new CasServiceTicketValidator(opts.CasServerUrlPrefix, opts.TicketValidateTimeoutMs);
|
|
||||||
});
|
|
||||||
services.AddScoped<IUserDetailsProvider, OpenApiUserDetailsProvider>();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 聚合入口(等价 Java <c>yh-spring-boot-starter</c>):一次带齐 OpenApi + AppAuth + Auth。
|
|
||||||
/// </summary>
|
|
||||||
public static IServiceCollection AddYhPlatform(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
=> services
|
|
||||||
.AddYhAppAuth(configuration)
|
|
||||||
.AddYhAuth(configuration);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.Auth;
|
|
||||||
|
|
||||||
/// <summary>登录用户载体(对齐 Java <c>LoginUser</c>)。</summary>
|
|
||||||
public class LoginUser
|
|
||||||
{
|
|
||||||
public string? UserId { get; set; }
|
|
||||||
|
|
||||||
public string? LoginName { get; set; }
|
|
||||||
|
|
||||||
public string? DisplayName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>应用身份标识。普通用户登录态为 null;由应用签名鉴权写入调用方 appKey。</summary>
|
|
||||||
public string? AppKey { get; set; }
|
|
||||||
|
|
||||||
public LoginUser()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public LoginUser(string? userId)
|
|
||||||
{
|
|
||||||
UserId = userId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 当前请求的 <see cref="BoaUser"/> 持有器。
|
|
||||||
/// 对齐 Java <c>BoaUserContext</c>(ThreadLocal)语义;C# 侧用 <see cref="AsyncLocal{T}"/>,
|
|
||||||
/// 请求内任何层可取,请求结束由 <see cref="UserContextMiddleware"/> 清空(不串请求)。
|
|
||||||
/// </summary>
|
|
||||||
public static class BoaUserContext
|
|
||||||
{
|
|
||||||
private static readonly AsyncLocal<BoaUser?> Current = new();
|
|
||||||
|
|
||||||
public static void Set(BoaUser? boaUser) => Current.Value = boaUser;
|
|
||||||
|
|
||||||
public static BoaUser? Get() => Current.Value;
|
|
||||||
|
|
||||||
public static void Clear() => Current.Value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 当前请求的 <see cref="LoginUser"/> 持有器(对齐 Java <c>UserContextHolder</c>):
|
|
||||||
/// 认证中间件写入、业务读取、请求结束清理。
|
|
||||||
/// </summary>
|
|
||||||
public static class UserContextHolder
|
|
||||||
{
|
|
||||||
private static readonly AsyncLocal<LoginUser?> Current = new();
|
|
||||||
|
|
||||||
public static void Set(LoginUser? user) => Current.Value = user;
|
|
||||||
|
|
||||||
public static LoginUser? Get() => Current.Value;
|
|
||||||
|
|
||||||
public static void Clear() => Current.Value = null;
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 自研 CAS 远程认证 handler(替换 AspNetCore.Security.CAS 本地 dll):
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>Challenge:302 到 CAS 登录页(service 指向本应用 <see cref="CasAuthenticationOptions.SignInCallbackPath"/>);</item>
|
|
||||||
/// <item>Authenticate:回调带 ticket 时调 /serviceValidate 校验,成功后写入 Cookie 登录态并回跳原地址。</item>
|
|
||||||
/// </list>
|
|
||||||
/// </summary>
|
|
||||||
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
|
|
||||||
{
|
|
||||||
private readonly CasServiceTicketValidator _validator;
|
|
||||||
|
|
||||||
#if NET8_0_OR_GREATER
|
|
||||||
public CasAuthenticationHandler(
|
|
||||||
IOptionsMonitor<CasAuthenticationOptions> options,
|
|
||||||
ILoggerFactory logger,
|
|
||||||
UrlEncoder encoder,
|
|
||||||
CasServiceTicketValidator validator)
|
|
||||||
: base(options, logger, encoder)
|
|
||||||
{
|
|
||||||
_validator = validator;
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
public CasAuthenticationHandler(
|
|
||||||
IOptionsMonitor<CasAuthenticationOptions> 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<AuthenticateResult> 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<Claim>
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。</summary>
|
|
||||||
private static bool IsLocalUrl(string? url)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(url) || url[0] != '/')
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !url.StartsWith("//", StringComparison.Ordinal)
|
|
||||||
&& !url.StartsWith("/\\", StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
namespace Yh.Platform.Auth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 轻量 CAS service ticket 校验器(对齐 Java <c>CasServiceTicketValidator</c>,不依赖 CAS 客户端库)。
|
|
||||||
/// 调用 CAS 的 <c>/serviceValidate</c> 端点,从响应 XML 中提取 <c><cas:user></c>。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>校验 ticket,成功返回登录账号,失败/无效返回 null。</summary>
|
|
||||||
public async Task<string?> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从 CAS 响应 XML 提取用户名(公开以便对拍测试)。
|
|
||||||
/// 成功响应形如 <cas:serviceResponse><cas:authenticationSuccess><cas:user>NAME</cas:user>...
|
|
||||||
/// </summary>
|
|
||||||
public static string? ExtractUser(string? xml)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(xml))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var start = xml.IndexOf("<cas:user>", StringComparison.Ordinal);
|
|
||||||
if (start < 0)
|
|
||||||
{
|
|
||||||
start = xml.IndexOf("<cas:user ", StringComparison.Ordinal); // 容错带属性
|
|
||||||
}
|
|
||||||
if (start < 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var tagEnd = xml.IndexOf('>', start);
|
|
||||||
var end = xml.IndexOf("</cas:user>", tagEnd, StringComparison.Ordinal);
|
|
||||||
if (tagEnd < 0 || end < 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return xml.Substring(tagEnd + 1, end - tagEnd - 1).Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// 跨 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;
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
|
|
||||||
namespace Yh.Platform.Auth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 会话票据数据格式(自定义 <see cref="ISecureDataFormat{TData}"/>):
|
|
||||||
/// 登录票据(AuthenticationTicket)保存在服务端内存,Cookie 只写 32 位 key(Guid N 格式),
|
|
||||||
/// 实现「Cookie 短值 + 服务端可控过期」。
|
|
||||||
/// <para>
|
|
||||||
/// 为什么不用框架 SessionStore:.NET 5 的 CookieAuthenticationHandler 即便配置了 SessionStore,
|
|
||||||
/// cookie 值仍是 <c>TicketDataFormat.Protect(精简票据)</c> 的加密长串(CfDJ8…),无法满足"Cookie 32 位"。
|
|
||||||
/// 直接自定义 TicketDataFormat:Protect 落库返回 32 位 key,Unprotect 查表并校验过期。
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// 语义:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>关闭浏览器失效:<c>AuthenticationProperties.IsPersistent=false</c>(会话 Cookie,无持久化头);</item>
|
|
||||||
/// <item>固定过期:服务端票据按 <c>Properties.ExpiresUtc</c>(由 ExpireTimeSpan 决定)过期,Unprotect 过期返回 null → 未认证;</item>
|
|
||||||
/// <item>显式退出立即失效:<see cref="Revoke"/> 删除指定 key(由 Cookie 注册的 OnSigningOut 事件触发,退出不等过期);</item>
|
|
||||||
/// <item>安全:key 为 128 位随机熵(Guid),不可枚举猜测;查表失败/过期即无效。</item>
|
|
||||||
/// </list>
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class SessionTicketDataFormat : ISecureDataFormat<AuthenticationTicket>
|
|
||||||
{
|
|
||||||
private readonly ConcurrentDictionary<string, AuthenticationTicket> _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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 吊销指定票据:显式退出(SignOut,如 /api/Logout)时服务端票据立即失效,无需等待过期兜底。
|
|
||||||
/// 由 Cookie 注册处的 <c>OnSigningOut</c> 事件从请求 Cookie 读取 32 位 key 后调用。
|
|
||||||
/// </summary>
|
|
||||||
public void Revoke(string? key)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(key))
|
|
||||||
{
|
|
||||||
_tickets.TryRemove(key, out _);
|
|
||||||
}
|
|
||||||
CleanupExpiredIfDue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分钟级摊还清理过期票据,防内存膨胀:Unprotect/Revoke 路径触发,最多每分钟一次全量扫描,
|
|
||||||
/// 并发下仅一个线程进入(Interlocked.CompareExchange 门闩)。
|
|
||||||
/// </summary>
|
|
||||||
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 _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
namespace Yh.Platform.Auth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 用户上下文中间件:认证成功后按登录名换取 <see cref="BoaUser"/> 并写入 <see cref="BoaUserContext"/>;
|
|
||||||
/// 请求结束(含异常)保证清空,防止上下文跨请求串扰。
|
|
||||||
/// 注意:<see cref="IUserDetailsProvider"/> 为 Scoped 依赖,必须走 InvokeAsync 方法注入(每请求解析);
|
|
||||||
/// 若放构造函数会被 UseMiddleware 启动时从根容器激活,抛出
|
|
||||||
/// "Cannot resolve scoped service ... from root provider"(ASP.NET Core 中间件约束)。
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>用户上下文中间件注册扩展。</summary>
|
|
||||||
public static class UserContextMiddlewareExtensions
|
|
||||||
{
|
|
||||||
public static IApplicationBuilder UseYhUserContext(this IApplicationBuilder app)
|
|
||||||
=> app.UseMiddleware<UserContextMiddleware>();
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
using Yh.Platform.Abstractions;
|
|
||||||
using Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
namespace Yh.Platform.Auth;
|
|
||||||
|
|
||||||
/// <summary>认证链路异常(对齐 Java <c>AuthException</c>)。</summary>
|
|
||||||
public class AuthException : Exception
|
|
||||||
{
|
|
||||||
public AuthException(string message) : base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public AuthException(string message, Exception? innerException) : base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 用户明细 SPI(对齐 Java <c>UserDetailsProvider</c>):认证链路解析出登录名后,由本接口换取 <see cref="BoaUser"/>。
|
|
||||||
/// 默认实现 <see cref="OpenApiUserDetailsProvider"/> 走 <c>IOpenApiUserClient</c>;业务侧可替换为本地查询。
|
|
||||||
/// </summary>
|
|
||||||
public interface IUserDetailsProvider
|
|
||||||
{
|
|
||||||
Task<BoaUser?> GetByLoginNameAsync(string loginName, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>默认实现:调 UserBase /oapi/user/byLoginName。</summary>
|
|
||||||
public class OpenApiUserDetailsProvider : IUserDetailsProvider
|
|
||||||
{
|
|
||||||
private readonly IOpenApiUserClient _userClient;
|
|
||||||
|
|
||||||
public OpenApiUserDetailsProvider(IOpenApiUserClient userClient)
|
|
||||||
{
|
|
||||||
_userClient = userClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BoaUser?> 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 信封)视同未找到,交由认证链路按未认证处理
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<PackageId>Yh.Platform.Auth</PackageId>
|
|
||||||
<Description>yunhai 平台认证与用户上下文(对应 Java yh-auth-starter):CAS SSO 认证 handler、BoaUserContext、IUserDetailsProvider、AddYhAuth/AddYhPlatform 聚合入口</Description>
|
|
||||||
<Summary>CAS 单点登录 + BoaUser 请求上下文(AsyncLocal),替换 AspNetCore.Security.CAS 本地 dll</Summary>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
|
|
||||||
<ProjectReference Include="..\Yh.Platform.OpenApi\Yh.Platform.OpenApi.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
|
|
||||||
namespace Yh.Platform.Auth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 认证方案名常量。
|
|
||||||
/// </summary>
|
|
||||||
public static class YhAuthDefaults
|
|
||||||
{
|
|
||||||
/// <summary>Cookie 认证方案(登录态载体,替代 GrowthHealth 既有 Cookie scheme)</summary>
|
|
||||||
public const string CookieScheme = "YhCookie";
|
|
||||||
|
|
||||||
/// <summary>CAS 认证方案(challenge 时重定向 CAS 登录页)</summary>
|
|
||||||
public const string CasScheme = "YhCas";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// CAS 认证配置(对齐 GrowthHealth <c>AddCasSso()</c> 与 Java <c>yh.sso.*</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public class CasAuthenticationOptions : AuthenticationSchemeOptions
|
|
||||||
{
|
|
||||||
/// <summary>CAS 服务端地址前缀,如 https://sso.example.com/cas</summary>
|
|
||||||
public string? CasServerUrlPrefix { get; set; }
|
|
||||||
|
|
||||||
/// <summary>本应用根地址(用于拼 service 回调地址),为空时从当前请求推断</summary>
|
|
||||||
public string? AppBaseUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>ticket 回调路径</summary>
|
|
||||||
public string SignInCallbackPath { get; set; } = "/signin-cas";
|
|
||||||
|
|
||||||
/// <summary>ticket 校验超时(毫秒),对齐 Java 轻量校验器 5 秒</summary>
|
|
||||||
public int TicketValidateTimeoutMs { get; set; } = 5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>通用鉴权配置(对齐 Java <c>yh.auth.*</c>)。</summary>
|
|
||||||
public class YhAuthOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "yh:auth";
|
|
||||||
|
|
||||||
/// <summary>是否启用(默认 true)</summary>
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>登录态有效期(分钟),默认 30 分钟(固定过期,不滑动续期)</summary>
|
|
||||||
public int CookieExpireMinutes { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>Cookie 名</summary>
|
|
||||||
public string CookieName { get; set; } = ".yh.auth";
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using Yh.Platform.AppAuth;
|
|
||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 实例形态门面:复刻旧 BschOpenApi 的 <c>new BaseOpenApi(host, appKey, appSecret)</code> 构造习惯,
|
|
||||||
/// GrowthHealth 等消费方迁移时改 using 即可。行为与静态门面 <see cref="YschBaseOpenApi"/> 一致。
|
|
||||||
/// </summary>
|
|
||||||
public class BaseOpenApi
|
|
||||||
{
|
|
||||||
public IOpenApiUserClient User { get; }
|
|
||||||
|
|
||||||
public IOpenApiGroupClient Group { get; }
|
|
||||||
|
|
||||||
public IOpenApiAppClient App { get; }
|
|
||||||
|
|
||||||
/// <summary>旧 SDK 同构构造:host + appKey + appSecret(basePath 默认 /userbase)。</summary>
|
|
||||||
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 高频方法直通 ============================
|
|
||||||
|
|
||||||
/// <summary>按登录账号查询用户明细(旧 SDK 直通方法)。</summary>
|
|
||||||
public Task<BoaUser?> GetUserInfoByLoginNameAsync(string loginName, CancellationToken ct = default)
|
|
||||||
=> User.GetUserInfoAsync(loginName, ct);
|
|
||||||
|
|
||||||
/// <summary>按用户主键查询用户信息(旧 SDK 直通方法)。</summary>
|
|
||||||
public Task<BoaUser?> GetUserInfoByIdAsync(int userId, CancellationToken ct = default)
|
|
||||||
=> User.GetUserInfoAsync(userId, ct);
|
|
||||||
|
|
||||||
/// <summary>按用户查询角色身份列表(旧 SDK 直通方法)。</summary>
|
|
||||||
public Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default)
|
|
||||||
=> User.GetUserRoleListAsync(userId, ct);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 跨 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;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 组织/组域开放接口客户端契约(对齐 UserBase /oapi/group/**,镜像 Java <c>OpenApiGroupClient</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public interface IOpenApiGroupClient
|
|
||||||
{
|
|
||||||
/// <summary>获取整个平台的组树结构(GET /oapi/group/tree)</summary>
|
|
||||||
Task<BoaGroup?> GetGroupTreeAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按组ID查询单个组;withSub=true 时递归返回整棵子树(GET /oapi/group/byId)</summary>
|
|
||||||
Task<BoaGroup?> GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按多个组ID(逗号/空白分隔)批量查询(GET /oapi/group/list)</summary>
|
|
||||||
Task<List<BoaGroup>> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>新增组(POST /oapi/group/add)</summary>
|
|
||||||
Task<BoaGroup?> AddGroupAsync(SysGroupRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>编辑组(路径 id 权威;body 不含 id):POST /oapi/group/edit/{id}</summary>
|
|
||||||
Task<BoaGroup?> EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>删除组(组下存在子组或用户时服务端拒绝):POST /oapi/group/delete/{id}/{schoolId}</summary>
|
|
||||||
Task DeleteGroupAsync(int id, int schoolId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>批量新增子组(POST /oapi/group/batchAdd)</summary>
|
|
||||||
Task<List<BoaGroup>> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>移动组(学校限定):POST /oapi/group/move/{schoolId},body 仅含 moveId/pid</summary>
|
|
||||||
Task MoveGroupAsync(int schoolId, SysGroupMoveRequest request, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应用域开放接口客户端契约(对齐 UserBase /oapi/app/**,镜像 Java <c>OpenApiAppClient</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public interface IOpenApiAppClient
|
|
||||||
{
|
|
||||||
/// <summary>按应用ID查询应用信息(GET /oapi/app/{appId};无 byId 端点时返回 404 错误信封)</summary>
|
|
||||||
Task<System.Text.Json.JsonElement?> GetAppByIdAsync(string appId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按用户 + 客户端类型查询有权访问的应用列表(按应用类型分组):GET /oapi/app/list</summary>
|
|
||||||
Task<List<BoaAppType>> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 用户域开放接口客户端契约(对齐 UserBase /oapi/user/**,镜像 Java <c>OpenApiUserClient</c>)。
|
|
||||||
/// 基址取自 <see cref="OpenApiOptions.ResolveBaseUrl"/>。
|
|
||||||
/// </summary>
|
|
||||||
public interface IOpenApiUserClient
|
|
||||||
{
|
|
||||||
/// <summary>按用户主键查询(GET /oapi/user/byId?id=)</summary>
|
|
||||||
Task<BoaUser?> GetUserByIdAsync(string userId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按登录账号查询用户明细(GET /oapi/user/byLoginName?loginName=)</summary>
|
|
||||||
Task<BoaUser?> GetUserByLoginNameAsync(string loginName, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按手机号查询用户明细(GET /oapi/user/byPhone?phone=)</summary>
|
|
||||||
Task<BoaUser?> GetUserByPhoneAsync(string phone, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按用户主键(int)查询用户信息(新接口,与旧 byId 并存)</summary>
|
|
||||||
Task<BoaUser?> GetUserInfoAsync(int userId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按登录账号查询用户信息(新接口,与旧 byLoginName 并存)</summary>
|
|
||||||
Task<BoaUser?> GetUserInfoAsync(string loginName, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按用户查询模块列表(GET /oapi/user/modules?userId=&schId=)</summary>
|
|
||||||
Task<List<BoaModule>> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>新增用户(POST /oapi/user/add)</summary>
|
|
||||||
Task<BoaUser?> AddUserAsync(SysUserRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>编辑用户(POST /oapi/user/edit/{id};路径 id 权威,自动写入 request.Id)</summary>
|
|
||||||
Task<BoaUser?> EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>删除用户(按组解绑):POST /oapi/user/delete/{id}/{groupId}/{schoolId}</summary>
|
|
||||||
Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>批量删除用户-组关系(POST /oapi/user/deleteUsers,请求体为数组)</summary>
|
|
||||||
Task DeleteUsersAsync(IReadOnlyList<SysUserGroupDeleteItem> items, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>批量新增用户,整批回滚(POST /oapi/user/batchAdd,请求体为裸数组)</summary>
|
|
||||||
Task<List<BoaUser>> BatchAddUsersAsync(IReadOnlyList<SysUserRequest> requests, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>移动用户到指定组(同校内,覆盖式替换主组):POST /oapi/user/moveToGroup</summary>
|
|
||||||
Task<BoaUser?> MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>按用户查询角色身份列表(GET /oapi/user/roles?userId=)</summary>
|
|
||||||
Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>查询用户当前激活身份(未切换时兜底 is_main 主身份):GET /oapi/identity/current?userId=</summary>
|
|
||||||
Task<BoaRoleInfo?> GetCurrentIdentityAsync(int userId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>查询用户可切换的身份列表(多角色迁移配套):GET /oapi/identity/available?userId=</summary>
|
|
||||||
Task<List<BoaRoleInfo>> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>切换身份(调用方须在 UserBase 侧白名单内):POST /oapi/identity/switch</summary>
|
|
||||||
Task<BoaRoleInfo?> SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>修改用户密码(先校验旧密码):POST /oapi/user/password</summary>
|
|
||||||
Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>设置用户在指定组上的状态(转出/恢复):POST /oapi/user/groupState</summary>
|
|
||||||
Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>查询用户在指定组上的状态:0=正常,1=转出(GET /oapi/user/groupState)</summary>
|
|
||||||
Task<int?> GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>跨校转入:POST /oapi/user/transferToSchool</summary>
|
|
||||||
Task<BoaUser?> TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>设置单个用户状态(启用/禁用):POST /oapi/user/changeStatus</summary>
|
|
||||||
Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>批量设置用户状态(任一 userId 无效则整体拒绝):POST /oapi/user/batchChangeStatus</summary>
|
|
||||||
Task BatchChangeUserStatusAsync(IReadOnlyList<int> userIds, bool disabled, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 开放接口客户端基类(对齐 Java <c>AbstractOpenApiClient</c>):
|
|
||||||
/// 统一持有 HttpClient / 配置,沉淀请求构造与"信封感知"的响应解析单点。
|
|
||||||
/// 响应解析约定(UserBase oapi):成功为裸数据对象/数组;业务/校验失败为 {code:4/5, msg, data:null} 但 HTTP 仍为 200。
|
|
||||||
/// </summary>
|
|
||||||
public abstract class OpenApiClientBase
|
|
||||||
{
|
|
||||||
/// <summary>平台统一开放接口前缀(与 UserBase Config.OPEN_API_PATH 严格一致)</summary>
|
|
||||||
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<string, string?> Params(params (string key, object? value)[] kv)
|
|
||||||
{
|
|
||||||
var m = new Dictionary<string, string?>(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<string, string?>? 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== 信封感知解析 ======================
|
|
||||||
|
|
||||||
/// <summary>发送请求并解析:返回信封感知后的裸数据节点(可能为 null)。</summary>
|
|
||||||
protected async Task<JsonElement?> SendAsync(HttpMethod method, string path, Dictionary<string, string?>? 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>裸数据反序列化为 <typeparamref name="T"/>;含 code 且非 0 时抛 <see cref="DException"/>。</summary>
|
|
||||||
protected async Task<T?> ResolveAsync<T>(HttpMethod method, string path, Dictionary<string, string?>? 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<T>(data.Value.GetRawText(), JsonOpts);
|
|
||||||
}
|
|
||||||
catch (JsonException e)
|
|
||||||
{
|
|
||||||
throw new OpenApiException("OpenAPI 反序列化失败: " + typeof(T).Name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>裸数组/单对象反序列化为列表;含 code 且非 0 时抛 <see cref="DException"/>。</summary>
|
|
||||||
protected async Task<List<T>> ResolveListAsync<T>(HttpMethod method, string path, Dictionary<string, string?>? query = null, object? body = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var data = await SendAsync(method, path, query, body, ct).ConfigureAwait(false);
|
|
||||||
var result = new List<T>();
|
|
||||||
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<T>(item.GetRawText(), JsonOpts)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result.Add(JsonSerializer.Deserialize<T>(data.Value.GetRawText(), JsonOpts)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException e)
|
|
||||||
{
|
|
||||||
throw new OpenApiException("OpenAPI 列表反序列化失败: " + typeof(T).Name, e);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>信封感知 void 解析:含 code 且非 0 时抛 <see cref="DException"/>,否则忽略 data。</summary>
|
|
||||||
protected async Task ResolveVoidAsync(HttpMethod method, string path, Dictionary<string, string?>? query = null, object? body = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
await SendAsync(method, path, query, body, ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 信封感知:对象含 code 字段且非 0 → 抛 <see cref="DException"/>(原样携带服务端 code/msg/data);
|
|
||||||
/// code==0 → 返回 data 节点(可能为 null);非信封 → 原样返回。
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 业务错误码载体(对齐 Java <c>OpenApiErrorCode</c>):原样保存服务端返回的 code 与 msg,
|
|
||||||
/// 由业务失败信封解析后携带给 <see cref="DException"/> 的调用方。
|
|
||||||
/// </summary>
|
|
||||||
public class OpenApiErrorCode : IErrorCode
|
|
||||||
{
|
|
||||||
public int Code { get; }
|
|
||||||
|
|
||||||
public string Msg { get; }
|
|
||||||
|
|
||||||
public OpenApiErrorCode(int code, string msg)
|
|
||||||
{
|
|
||||||
Code = code;
|
|
||||||
Msg = msg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP 层异常(对齐 Java <c>OpenApiException</c>):非 2xx 响应或反序列化失败时抛出,保留 HTTP 状态码与响应体。
|
|
||||||
/// 业务失败(HTTP 200 + code≠0 信封)抛 <see cref="DException"/>。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary><see cref="IOpenApiGroupClient"/> 的 HTTP 实现(镜像 Java <c>DefaultOpenApiGroupClient</c>)。</summary>
|
|
||||||
public class OpenApiGroupClient : OpenApiClientBase, IOpenApiGroupClient
|
|
||||||
{
|
|
||||||
public OpenApiGroupClient(HttpClient httpClient, OpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<BoaGroup?> GetGroupTreeAsync(CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaGroup>(HttpMethod.Get, Prefix + "/group/tree", ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaGroup?> GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaGroup>(HttpMethod.Get, Prefix + "/group/byId",
|
|
||||||
Params(("groupId", groupId), ("withSub", withSub)), ct: ct);
|
|
||||||
|
|
||||||
public Task<List<BoaGroup>> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<BoaGroup>(HttpMethod.Get, Prefix + "/group/list",
|
|
||||||
Params(("ids", ids), ("withSub", withSub)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaGroup?> AddGroupAsync(SysGroupRequest request, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaGroup>(HttpMethod.Post, Prefix + "/group/add", body: request, ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaGroup?> EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaGroup>(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<List<BoaGroup>> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<BoaGroup>(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary><see cref="IOpenApiAppClient"/> 的 HTTP 实现(镜像 Java <c>DefaultOpenApiAppClient</c>)。</summary>
|
|
||||||
public class OpenApiAppClient : OpenApiClientBase, IOpenApiAppClient
|
|
||||||
{
|
|
||||||
public OpenApiAppClient(HttpClient httpClient, OpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<JsonElement?> GetAppByIdAsync(string appId, CancellationToken ct = default)
|
|
||||||
=> SendAsync(HttpMethod.Get, Prefix + "/app/" + Uri.EscapeDataString(appId), ct: ct);
|
|
||||||
|
|
||||||
public Task<List<BoaAppType>> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<BoaAppType>(HttpMethod.Get, Prefix + "/app/list",
|
|
||||||
Params(("userId", userId), ("clientType", (int)clientType)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 基础平台开放接口(UserBase)客户端配置,配置节 <c>yh:open</c>(对齐 Java <c>yh.open</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public class OpenApiOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "yh:open";
|
|
||||||
|
|
||||||
/// <summary>是否启用(默认 true)</summary>
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>UserBase 基础地址,例如 http://base.bestsch.com</summary>
|
|
||||||
public string? BaseHost { get; set; }
|
|
||||||
|
|
||||||
/// <summary>UserBase 基础路径(默认 /userbase;代理改挂其他路径时配置兜底)</summary>
|
|
||||||
public string? BasePath { get; set; } = "/userbase";
|
|
||||||
|
|
||||||
/// <summary>连接超时(毫秒),对齐 Java 默认 1500ms</summary>
|
|
||||||
public long ConnectTimeoutMs { get; set; } = 1500;
|
|
||||||
|
|
||||||
/// <summary>读超时(毫秒),对齐 Java 默认 5000ms</summary>
|
|
||||||
public long ReadTimeoutMs { get; set; } = 5000;
|
|
||||||
|
|
||||||
/// <summary>写超时(毫秒),对齐 Java 默认 5000ms</summary>
|
|
||||||
public long WriteTimeoutMs { get; set; } = 5000;
|
|
||||||
|
|
||||||
/// <summary>调用方在 UserBase 注册的应用标识(用于签名)</summary>
|
|
||||||
public string? AppKey { get; set; }
|
|
||||||
|
|
||||||
/// <summary>调用方在 UserBase 注册的密钥(用于签名)</summary>
|
|
||||||
public string? AppSecret { get; set; }
|
|
||||||
|
|
||||||
/// <summary>拼接 baseHost + basePath 为统一开放接口基址。</summary>
|
|
||||||
public string ResolveBaseUrl()
|
|
||||||
{
|
|
||||||
var baseHost = BaseHost?.Trim() ?? "";
|
|
||||||
if (baseHost.EndsWith('/'))
|
|
||||||
{
|
|
||||||
baseHost = baseHost.TrimEnd('/');
|
|
||||||
}
|
|
||||||
return baseHost + (NormalizeBasePath(BasePath) ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null(挂载在根路径)。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>静态门面自动装配宿主服务(等价 Java starter 启动时 Set*Client 注入)。</summary>
|
|
||||||
internal sealed class YschFacadeWiring : IHostedService
|
|
||||||
{
|
|
||||||
private readonly IServiceProvider _provider;
|
|
||||||
|
|
||||||
public YschFacadeWiring(IServiceProvider provider)
|
|
||||||
{
|
|
||||||
_provider = provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
YschBaseOpenApi.SetUserClient(_provider.GetRequiredService<OpenApiUserClient>());
|
|
||||||
YschBaseOpenApi.SetGroupClient(_provider.GetRequiredService<OpenApiGroupClient>());
|
|
||||||
YschBaseOpenApi.SetAppClient(_provider.GetRequiredService<OpenApiAppClient>());
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class OpenApiServiceCollectionExtensions
|
|
||||||
{
|
|
||||||
public const string HttpClientName = "yh-openapi";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 注册 /oapi 客户端(对齐 Java <c>OpenApiAutoConfiguration</c>):
|
|
||||||
/// IHttpClientFactory + typed client + 签名 DelegatingHandler,并在宿主启动时装配静态门面。
|
|
||||||
/// </summary>
|
|
||||||
public static IServiceCollection AddYhOpenApi(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
services.AddOptions<OpenApiOptions>()
|
|
||||||
.Bind(configuration.GetSection(OpenApiOptions.SectionName))
|
|
||||||
.PostConfigure(o => o.BasePath = OpenApiOptions.NormalizeBasePath(o.BasePath));
|
|
||||||
|
|
||||||
services.AddHttpClient(HttpClientName)
|
|
||||||
.ConfigureHttpClient((sp, client) =>
|
|
||||||
{
|
|
||||||
var options = sp.GetRequiredService<IOptions<OpenApiOptions>>().Value;
|
|
||||||
client.Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000);
|
|
||||||
})
|
|
||||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
|
||||||
{
|
|
||||||
var options = sp.GetRequiredService<IOptions<OpenApiOptions>>().Value;
|
|
||||||
return new SocketsHttpHandler
|
|
||||||
{
|
|
||||||
ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.AddHttpMessageHandler(sp =>
|
|
||||||
{
|
|
||||||
var options = sp.GetRequiredService<IOptions<OpenApiOptions>>().Value;
|
|
||||||
return new AppSignHandler(options.AppKey ?? "", options.AppSecret ?? "", options.BasePath);
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddSingleton(sp =>
|
|
||||||
{
|
|
||||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
|
||||||
return new OpenApiUserClient(factory.CreateClient(HttpClientName), sp.GetRequiredService<IOptions<OpenApiOptions>>().Value);
|
|
||||||
});
|
|
||||||
services.AddSingleton(sp =>
|
|
||||||
{
|
|
||||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
|
||||||
return new OpenApiGroupClient(factory.CreateClient(HttpClientName), sp.GetRequiredService<IOptions<OpenApiOptions>>().Value);
|
|
||||||
});
|
|
||||||
services.AddSingleton(sp =>
|
|
||||||
{
|
|
||||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
|
||||||
return new OpenApiAppClient(factory.CreateClient(HttpClientName), sp.GetRequiredService<IOptions<OpenApiOptions>>().Value);
|
|
||||||
});
|
|
||||||
services.AddSingleton<IOpenApiUserClient>(sp => sp.GetRequiredService<OpenApiUserClient>());
|
|
||||||
services.AddSingleton<IOpenApiGroupClient>(sp => sp.GetRequiredService<OpenApiGroupClient>());
|
|
||||||
services.AddSingleton<IOpenApiAppClient>(sp => sp.GetRequiredService<OpenApiAppClient>());
|
|
||||||
|
|
||||||
services.AddHostedService<YschFacadeWiring>();
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary><see cref="IOpenApiUserClient"/> 的 HTTP 实现(镜像 Java <c>DefaultOpenApiUserClient</c>)。</summary>
|
|
||||||
public class OpenApiUserClient : OpenApiClientBase, IOpenApiUserClient
|
|
||||||
{
|
|
||||||
public OpenApiUserClient(HttpClient httpClient, OpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<BoaUser?> GetUserByIdAsync(string userId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> GetUserByLoginNameAsync(string loginName, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byLoginName", Params(("loginName", loginName)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> GetUserByPhoneAsync(string phone, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byPhone", Params(("phone", phone)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> GetUserInfoAsync(int userId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> GetUserInfoAsync(string loginName, CancellationToken ct = default)
|
|
||||||
=> GetUserByLoginNameAsync(loginName, ct);
|
|
||||||
|
|
||||||
public Task<List<BoaModule>> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<BoaModule>(HttpMethod.Get, Prefix + "/user/modules",
|
|
||||||
Params(("userId", userId), ("schId", schoolId)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> AddUserAsync(SysUserRequest request, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/add", body: request, ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
request.Id = id;
|
|
||||||
return ResolveAsync<BoaUser>(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<SysUserGroupDeleteItem> items, CancellationToken ct = default)
|
|
||||||
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/deleteUsers", body: items, ct: ct);
|
|
||||||
|
|
||||||
public Task<List<BoaUser>> BatchAddUsersAsync(IReadOnlyList<SysUserRequest> requests, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/batchAdd", body: requests, ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/moveToGroup", body: request, ct: ct);
|
|
||||||
|
|
||||||
public Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<BoaRoleInfo>(HttpMethod.Get, Prefix + "/user/roles", Params(("userId", userId)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaRoleInfo?> GetCurrentIdentityAsync(int userId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaRoleInfo>(HttpMethod.Get, Prefix + "/identity/current", Params(("userId", userId)), ct: ct);
|
|
||||||
|
|
||||||
public Task<List<BoaRoleInfo>> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<BoaRoleInfo>(HttpMethod.Get, Prefix + "/identity/available", Params(("userId", userId)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaRoleInfo?> SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaRoleInfo>(HttpMethod.Post, Prefix + "/identity/switch",
|
|
||||||
body: new Dictionary<string, object> { ["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<string, object> { ["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<int?> GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<int?>(HttpMethod.Get, Prefix + "/user/groupState",
|
|
||||||
Params(("userId", userId), ("groupId", groupId), ("schoolId", schoolId)), ct: ct);
|
|
||||||
|
|
||||||
public Task<BoaUser?> TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<BoaUser>(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<string, object> { ["userId"] = userId, ["disabled"] = disabled }, ct: ct);
|
|
||||||
|
|
||||||
public Task BatchChangeUserStatusAsync(IReadOnlyList<int> userIds, bool disabled, CancellationToken ct = default)
|
|
||||||
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/batchChangeStatus",
|
|
||||||
body: new Dictionary<string, object> { ["userIds"] = userIds, ["disabled"] = disabled }, ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>新增/编辑组请求(对齐 UserBase SysGroupRequest)。编辑以路径 id 为准;Id 字段对齐服务端契约键(快照对拍 2026-08-28)。</summary>
|
|
||||||
public class SysGroupRequest
|
|
||||||
{
|
|
||||||
/// <summary>组 ID(新增忽略;编辑时以路径 id 为权威,此字段仅保持契约键完整)</summary>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>批量新增子组请求:在 Pid 下生成一批同类型子组。</summary>
|
|
||||||
public class SysGroupBatchRequest
|
|
||||||
{
|
|
||||||
/// <summary>父组 ID(挂载点,必须存在)</summary>
|
|
||||||
public int? Pid { get; set; }
|
|
||||||
|
|
||||||
/// <summary>应用于本次生成的所有子组的类型</summary>
|
|
||||||
public int? Type { get; set; }
|
|
||||||
|
|
||||||
/// <summary>子组列表,每项仅需 Name</summary>
|
|
||||||
public List<BatchItem>? Items { get; set; }
|
|
||||||
|
|
||||||
/// <summary>单个子组项,仅需 Name</summary>
|
|
||||||
public class BatchItem
|
|
||||||
{
|
|
||||||
public string? Name { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>移动组请求:MoveId 移动到 Pid 下,两者都必须落在 schoolId 子树内;schoolId 走路径参数。</summary>
|
|
||||||
public class SysGroupMoveRequest
|
|
||||||
{
|
|
||||||
/// <summary>待移动节点ID</summary>
|
|
||||||
public int? MoveId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>目标父节点ID(移动后的挂载点)</summary>
|
|
||||||
public int? Pid { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>新增/编辑用户请求(对齐 UserBase SysUserRequest)。编辑时 Id 必填且与路径 id 一致。</summary>
|
|
||||||
public class SysUserRequest
|
|
||||||
{
|
|
||||||
/// <summary>用户 ID(编辑必填,新增忽略)</summary>
|
|
||||||
public int? Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>初始归属组 ID;新增必填,编辑不校验</summary>
|
|
||||||
public int? GroupId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>登录账号(唯一)</summary>
|
|
||||||
public string? Account { get; set; }
|
|
||||||
|
|
||||||
/// <summary>登录密码(明文,服务端 BCrypt 加密存储)</summary>
|
|
||||||
public string? Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>确认密码,需与 Password 一致</summary>
|
|
||||||
public string? ConfirmPassword { get; set; }
|
|
||||||
|
|
||||||
/// <summary>姓名</summary>
|
|
||||||
public string? RealName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>手机号</summary>
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
|
|
||||||
/// <summary>身份证号</summary>
|
|
||||||
public string? Idcard { get; set; }
|
|
||||||
|
|
||||||
/// <summary>邮箱</summary>
|
|
||||||
public string? Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>性别</summary>
|
|
||||||
public int? Gender { get; set; }
|
|
||||||
|
|
||||||
/// <summary>生日(YYYY-MM-DD)</summary>
|
|
||||||
public string? Birthday { get; set; }
|
|
||||||
|
|
||||||
/// <summary>头像 URL</summary>
|
|
||||||
public string? ProfilePicture { get; set; }
|
|
||||||
|
|
||||||
/// <summary>归属学校组 ID(服务端 SysUserRequest.roleIds/schoolId 契约;与 GroupId 二选一维度,见 UserBase 文档)</summary>
|
|
||||||
public int? SchoolId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>角色 ID 列表(契约键为 roleIds,对齐 UserBase 导出快照 2026-08-28 对拍)</summary>
|
|
||||||
public List<int>? RoleIds { get; set; }
|
|
||||||
|
|
||||||
/// <summary>禁用状态(0=正常,1=禁用;不传默认启用)</summary>
|
|
||||||
public int? Disabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>是否过期;不传默认 false</summary>
|
|
||||||
public bool? Expired { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>批量删除用户-组关系单项(对齐 UserBase SysUserGroupDeleteItem)。</summary>
|
|
||||||
public class SysUserGroupDeleteItem
|
|
||||||
{
|
|
||||||
/// <summary>待解绑用户 ID</summary>
|
|
||||||
public int? UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>待解绑的组 ID(必须属于 schoolId 子树,且不能等于 schoolId)</summary>
|
|
||||||
public int? GroupId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校组 ID:限定该条解绑的学校范围</summary>
|
|
||||||
public int? SchoolId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>移动用户到指定组(同校内,覆盖式替换主组)请求。</summary>
|
|
||||||
public class SysUserMoveRequest
|
|
||||||
{
|
|
||||||
/// <summary>待移动用户 ID</summary>
|
|
||||||
public int? UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>目标组 ID(移动后的主归属组)</summary>
|
|
||||||
public int? GroupId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校组 ID:限定移动范围,仅允许同一学校内移动</summary>
|
|
||||||
public int? SchoolId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>设置用户组状态请求:state=1 转出、0=恢复正常;groupId 必须属于 schoolId 子树。</summary>
|
|
||||||
public class SysUserGroupStateRequest
|
|
||||||
{
|
|
||||||
/// <summary>用户 ID</summary>
|
|
||||||
public int? UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>组 ID(用户所在的具体组)</summary>
|
|
||||||
public int? GroupId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校组 ID:限定操作范围</summary>
|
|
||||||
public int? SchoolId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>目标状态:0=恢复正常,1=转出</summary>
|
|
||||||
public int? State { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>跨校转入请求:前置为源学校子树内存在至少一条「转出」状态组关系。</summary>
|
|
||||||
public class SysUserTransferSchoolRequest
|
|
||||||
{
|
|
||||||
/// <summary>待迁移用户 ID</summary>
|
|
||||||
public int? UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>原学校组 ID(源学校)</summary>
|
|
||||||
public int? FromSchoolId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>目标学校组 ID</summary>
|
|
||||||
public int? SchoolId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>目标组 ID(迁移后的主归属组)</summary>
|
|
||||||
public int? GroupId { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<PackageId>Yh.Platform.OpenApi</PackageId>
|
|
||||||
<Description>yunhai 平台 /oapi 开放接口客户端(对应 Java yh-openapi-client):用户/组/应用三域客户端、信封感知解析、静态门面 YschBaseOpenApi 与实例门面 BaseOpenApi</Description>
|
|
||||||
<Summary>UserBase /oapi 同构客户端 SDK,签名请求自动附加三头</Summary>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
|
|
||||||
<ProjectReference Include="..\Yh.Platform.AppAuth\Yh.Platform.AppAuth.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Yh.Platform.Abstractions;
|
|
||||||
|
|
||||||
namespace Yh.Platform.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 基础平台开放接口统一门面(静态入口,对齐 Java <c>YschBaseOpenApi</c>)。
|
|
||||||
/// 下游业务只依赖本类;内部按域聚合三客户端,由 <see cref="OpenApiServiceCollectionExtensions.AddYhOpenApi"/>
|
|
||||||
/// 在宿主启动时自动注入(或手动 Set*Client)。
|
|
||||||
/// 门面准入原则:仅保留高频读接口;低频写操作请注入 <see cref="IOpenApiUserClient"/> / <see cref="IOpenApiGroupClient"/>。
|
|
||||||
/// </summary>
|
|
||||||
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<BoaUser?> GetUserInfoAsync(int userId, CancellationToken ct = default) => User().GetUserInfoAsync(userId, ct);
|
|
||||||
|
|
||||||
public static Task<BoaUser?> GetUserInfoAsync(string loginName, CancellationToken ct = default) => User().GetUserInfoAsync(loginName, ct);
|
|
||||||
|
|
||||||
public static Task<BoaUser?> GetUserByIdAsync(string userId, CancellationToken ct = default) => User().GetUserByIdAsync(userId, ct);
|
|
||||||
|
|
||||||
public static Task<BoaUser?> GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) => User().GetUserByLoginNameAsync(loginName, ct);
|
|
||||||
|
|
||||||
public static Task<BoaUser?> GetUserByPhoneAsync(string phone, CancellationToken ct = default) => User().GetUserByPhoneAsync(phone, ct);
|
|
||||||
|
|
||||||
public static Task<List<BoaModule>> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) => User().GetUserModuleAsync(userId, schoolId, ct);
|
|
||||||
|
|
||||||
public static Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default) => User().GetUserRoleListAsync(userId, ct);
|
|
||||||
|
|
||||||
/// <summary>查询用户当前激活身份(未切换时兜底 is_main 主身份;无任何身份返回 null)。</summary>
|
|
||||||
public static Task<BoaRoleInfo?> GetCurrentIdentityAsync(int userId, CancellationToken ct = default) => User().GetCurrentIdentityAsync(userId, ct);
|
|
||||||
|
|
||||||
// ============================ 组织/组域 ============================
|
|
||||||
|
|
||||||
public static Task<BoaGroup?> GetGroupTreeAsync(CancellationToken ct = default) => Group().GetGroupTreeAsync(ct);
|
|
||||||
|
|
||||||
public static Task<BoaGroup?> GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupByIdAsync(groupId, withSub, ct);
|
|
||||||
|
|
||||||
public static Task<List<BoaGroup>> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupListAsync(ids, withSub, ct);
|
|
||||||
|
|
||||||
// ============================ 应用域 ============================
|
|
||||||
|
|
||||||
public static Task<JsonElement?> GetAppByIdAsync(string appId, CancellationToken ct = default) => App().GetAppByIdAsync(appId, ct);
|
|
||||||
|
|
||||||
public static Task<List<BoaAppType>> 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(...)。");
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// 跨 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<String,Object> 兼容行的 .NET 承载类型(net5.0 可用,不依赖 net6+ 的 JsonNode)
|
|
||||||
global using SchClassRow = System.Collections.Generic.Dictionary<string, System.Text.Json.JsonElement>;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Yh.SchClass.OpenApi.Model;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 学校视图对象(镜像 Java <c>com.yunhai.bschclassmanage.model.vo.oa.SchInfoVO</c>)。
|
|
||||||
/// Java 侧以 <c>@JsonProperty</c> 显式指定键名,故此处按原样锚定。
|
|
||||||
/// </summary>
|
|
||||||
public class SchInfoVo
|
|
||||||
{
|
|
||||||
/// <summary>学校外部序列号</summary>
|
|
||||||
[JsonPropertyName("SchSerId")]
|
|
||||||
public string? SchSerId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>用户中心学校 ID</summary>
|
|
||||||
[JsonPropertyName("UserSchSerId")]
|
|
||||||
public int? UserSchSerId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校名称</summary>
|
|
||||||
[JsonPropertyName("SchName")]
|
|
||||||
public string? SchName { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Yh.SchClass.OpenApi.Model;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教师信息视图对象(镜像 Java <c>com.yunhai.bschclassmanage.model.oa.TeaInfoVO</c>)。
|
|
||||||
/// <para>
|
|
||||||
/// Java 侧为 Lombok <c>@Data</c>,序列化键名由 Jackson 从 getter 推导;本客户端大小写不敏感反序列化,
|
|
||||||
/// 故按 Java 字段原名锚定即可双向兼容。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class TeaInfoVo
|
|
||||||
{
|
|
||||||
/// <summary>学校外部序列号</summary>
|
|
||||||
[JsonPropertyName("SchSerID")] public string? SchSerId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>政治面貌</summary>
|
|
||||||
[JsonPropertyName("Teapolitical")] public string? TeaPolitical { get; set; }
|
|
||||||
|
|
||||||
/// <summary>参加工作日期</summary>
|
|
||||||
[JsonPropertyName("TeaWorkStart")] public string? TeaWorkStart { get; set; }
|
|
||||||
|
|
||||||
/// <summary>登录账户</summary>
|
|
||||||
[JsonPropertyName("Account")] public string? Account { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校序列号(多校场景,当前与 SchSerID 相同)</summary>
|
|
||||||
[JsonPropertyName("SchSerIDs")] public string? SchSerIds { get; set; }
|
|
||||||
|
|
||||||
/// <summary>角色</summary>
|
|
||||||
[JsonPropertyName("role")] public int? Role { get; set; }
|
|
||||||
|
|
||||||
/// <summary>教育局</summary>
|
|
||||||
[JsonPropertyName("JiaoYuJu")] public string? JiaoYuJu { get; set; }
|
|
||||||
|
|
||||||
/// <summary>性别(文本)</summary>
|
|
||||||
[JsonPropertyName("TeaSex")] public string? TeaSex { get; set; }
|
|
||||||
|
|
||||||
/// <summary>是否中层以上干部</summary>
|
|
||||||
[JsonPropertyName("IsLeadingMiddle")] public int? IsLeadingMiddle { get; set; }
|
|
||||||
|
|
||||||
/// <summary>籍贯</summary>
|
|
||||||
[JsonPropertyName("TeaOriginPlace")] public string? TeaOriginPlace { get; set; }
|
|
||||||
|
|
||||||
/// <summary>头像</summary>
|
|
||||||
[JsonPropertyName("Photo")] public string? Photo { get; set; }
|
|
||||||
|
|
||||||
/// <summary>身份证号</summary>
|
|
||||||
[JsonPropertyName("TeaIDCard")] public string? TeaIdCard { get; set; }
|
|
||||||
|
|
||||||
/// <summary>姓名</summary>
|
|
||||||
[JsonPropertyName("Name")] public string? Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>分组 ID</summary>
|
|
||||||
[JsonPropertyName("GroupID")] public int? GroupId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>学校名称</summary>
|
|
||||||
[JsonPropertyName("SchName")] public string? SchName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>从教日期</summary>
|
|
||||||
[JsonPropertyName("TeaTeachStart")] public string? TeaTeachStart { get; set; }
|
|
||||||
|
|
||||||
/// <summary>教师序列号(teaId 的字符串形式)</summary>
|
|
||||||
[JsonPropertyName("TeaSerID")] public string? TeaSerId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>用户 ID</summary>
|
|
||||||
[JsonPropertyName("UserID")] public int? UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>民族</summary>
|
|
||||||
[JsonPropertyName("TeaNation")] public string? TeaNation { get; set; }
|
|
||||||
|
|
||||||
/// <summary>电话</summary>
|
|
||||||
[JsonPropertyName("Tel")] public string? Tel { get; set; }
|
|
||||||
|
|
||||||
/// <summary>工作类型</summary>
|
|
||||||
[JsonPropertyName("TeaWorkType")] public string? TeaWorkType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>教师编号</summary>
|
|
||||||
[JsonPropertyName("TeaNo")] public int? TeaNo { get; set; }
|
|
||||||
|
|
||||||
/// <summary>出生日期</summary>
|
|
||||||
[JsonPropertyName("TeaBirth")] public string? TeaBirth { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Yh.SchClass.OpenApi.Model;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 用户信息视图对象(镜像 Java <c>com.yunhai.bschclassmanage.model.oa.UserInfoVO</c>)。
|
|
||||||
/// <para>
|
|
||||||
/// Java 侧为 Lombok <c>@Data</c>,序列化键名由 Jackson 从 getter 推导(如 <c>getSchSerID()</c> → <c>schSerID</c>);
|
|
||||||
/// 本客户端使用大小写不敏感反序列化(<see cref="JsonSerializerDefaults.Web"/>),故此处按 Java 字段原名锚定即可双向兼容。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class UserInfoVo
|
|
||||||
{
|
|
||||||
/// <summary>学校外部序列号</summary>
|
|
||||||
[JsonPropertyName("SchSerID")] public string? SchSerId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>编号(教师为 sort)</summary>
|
|
||||||
[JsonPropertyName("No")] public int? No { get; set; }
|
|
||||||
|
|
||||||
/// <summary>身份证号</summary>
|
|
||||||
[JsonPropertyName("IDCard")] public string? IdCard { get; set; }
|
|
||||||
|
|
||||||
/// <summary>用户 ID(教师为 teaId,学生为 stuId)</summary>
|
|
||||||
[JsonPropertyName("UserID")] public int? UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>性别</summary>
|
|
||||||
[JsonPropertyName("sex")] public int? Sex { get; set; }
|
|
||||||
|
|
||||||
/// <summary>用户类型(教师 1 / 学生按 UserRole)</summary>
|
|
||||||
[JsonPropertyName("usertype")] public int? UserType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>序列号(Java 侧恒为 null)</summary>
|
|
||||||
[JsonPropertyName("SerID")] public string? SerId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>头像(Java 侧恒为 null)</summary>
|
|
||||||
[JsonPropertyName("Photo")] public string? Photo { get; set; }
|
|
||||||
|
|
||||||
/// <summary>电话(学生为 null)</summary>
|
|
||||||
[JsonPropertyName("Tel")] public string? Tel { get; set; }
|
|
||||||
|
|
||||||
/// <summary>登录账户</summary>
|
|
||||||
[JsonPropertyName("Uids")] public string? Uids { get; set; }
|
|
||||||
|
|
||||||
/// <summary>分组 ID</summary>
|
|
||||||
[JsonPropertyName("GroupID")] public int? GroupId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>姓名</summary>
|
|
||||||
[JsonPropertyName("Name")] public string? Name { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务(SchClass / bschclassmanage)开放接口客户端基类。
|
|
||||||
/// <para>
|
|
||||||
/// 与 <c>Yh.Platform.OpenApi.OpenApiClientBase</c> 同构,但刻意保持零项目依赖,便于单独打包引用。
|
|
||||||
/// </para>
|
|
||||||
/// <para><b>响应约定</b>(Java 侧 OA 兼容端点):成功时直接返回裸数据(对象 / 数组 / 字符串数组),无统一信封;
|
|
||||||
/// 部分端点以 HTTP 404 / 204 表示"无数据",以 HTTP 400 表示参数缺失。因此本基类默认把 404 与 204
|
|
||||||
/// 归一为 <c>null</c>(列表则为空列表),仅对 400 及其它非 2xx 抛 <see cref="SchClassOpenApiException"/>。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public abstract class SchClassClientBase
|
|
||||||
{
|
|
||||||
/// <summary>教务开放接口统一前缀(与 Java <c>Config.OAPI</c> 严格一致)</summary>
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== 请求构造 ======================
|
|
||||||
|
|
||||||
/// <summary>构造查询参数;值为 null 的参数会被跳过(对应 Java 侧 required=false)。</summary>
|
|
||||||
protected static Dictionary<string, string?> Params(params (string key, object? value)[] kv)
|
|
||||||
{
|
|
||||||
var map = new Dictionary<string, string?>(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<string, string?>? 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== 发送与解析 ======================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 发送请求并返回裸数据节点(可能为 null)。
|
|
||||||
/// <para><paramref name="body"/> 为 <see cref="string"/> 时按 text/plain 原样提交(对应 Java <c>@RequestBody String</c>),
|
|
||||||
/// 其余类型按 application/json 序列化后提交。</para>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="notFoundAsNull">为 true 时把 HTTP 404 / 204 归一为 null(OA 端点惯用的"无数据"语义)。</param>
|
|
||||||
protected async Task<JsonElement?> SendAsync(HttpMethod method, string path,
|
|
||||||
Dictionary<string, string?>? 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>裸数据反序列化为 <typeparamref name="T"/>。</summary>
|
|
||||||
protected async Task<T?> ResolveAsync<T>(HttpMethod method, string path,
|
|
||||||
Dictionary<string, string?>? 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<T>(data.Value.GetRawText(), JsonOpts);
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
throw new SchClassOpenApiException("SchClass OpenAPI 反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>裸数组 / 单对象反序列化为列表;无数据时返回空列表(不会为 null)。</summary>
|
|
||||||
protected async Task<List<T>> ResolveListAsync<T>(HttpMethod method, string path,
|
|
||||||
Dictionary<string, string?>? 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<T>();
|
|
||||||
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<T>(item.GetRawText(), JsonOpts)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result.Add(JsonSerializer.Deserialize<T>(data.Value.GetRawText(), JsonOpts)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
throw new SchClassOpenApiException("SchClass OpenAPI 列表反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText());
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 弱类型兼容行(Java <c>Map<String,Object></c>)列表;无数据时返回空列表。
|
|
||||||
/// 每行键名与 Java 侧 <c>LinkedHashMap</c> 的 put 顺序完全一致。
|
|
||||||
/// </summary>
|
|
||||||
protected Task<List<SchClassRow>> ResolveRowsAsync(HttpMethod method, string path,
|
|
||||||
Dictionary<string, string?>? query = null, object? body = null,
|
|
||||||
bool notFoundAsNull = true, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<SchClassRow>(method, path, query, body, notFoundAsNull, ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 信封感知:仅当同时出现数字 <c>code</c> 与(<c>data</c> 或字符串 <c>msg</c>)时才认定为信封,
|
|
||||||
/// 避免把业务行里恰好叫 <c>code</c> 的字段(如学制等级的 code=gradeId)误判成错误响应。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 DbClass 接口客户端(镜像 Java <c>DbClassController</c>,路径前缀 <c>/oapi/DbClass/</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassDbClassClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/DbClass/";
|
|
||||||
|
|
||||||
public SchClassDbClassClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询学校教师名单(<c>GET /oapi/DbClass/GetSchteaList</c>)。
|
|
||||||
/// 每位教师的每个有效分组归属返回一行(同一教师多分组会重复出现)。
|
|
||||||
/// 行字段:<c>SchSerID / TeaTel / TeaName / UserTeaSerID / TeaIDCard / TeaNo / GroupID</c>。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="group">学校 ID(Java 参数名 <c>Group</c>),必须大于 0;<=0 时服务端返回 400。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchteaListAsync(int group, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchteaList", Params(("Group", group)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务家长兼容接口客户端(镜像 Java <c>FamilyCompatController</c>,路径前缀 <c>/oapi/Common/FamCom/</c>)。
|
|
||||||
/// 两个端点当前服务端均恒返回空数据,保留调用以兼容既有消费方。
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassFamilyCompatClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/Common/FamCom/";
|
|
||||||
|
|
||||||
public SchClassFamilyCompatClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询学生家长用户 ID 列表,<c>POST /oapi/Common/FamCom/GetStuUserFamIDList</c>;
|
|
||||||
/// 请求体为逗号分隔的学生 ID(Java 侧 <c>@RequestBody String</c>,按 text/plain 提交)。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<string>> GetStuUserFamIdListAsync(string? stuIds, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<string>(HttpMethod.Post, Root + "GetStuUserFamIDList", body: stuIds, ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询学生家庭成员,<c>POST /oapi/Common/FamCom/GetAllStuFamMem</c>;
|
|
||||||
/// 请求体为逗号分隔的学生 ID(text/plain)。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<SchClassRow>> GetAllStuFamMemAsync(string? stuIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetAllStuFamMem", body: stuIds, ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务(SchClass)开放接口聚合门面:一个入口访问全部 OA 兼容端点。
|
|
||||||
/// <para>
|
|
||||||
/// 用法:
|
|
||||||
/// <code>
|
|
||||||
/// var api = SchClassOpenApiFactory.Create(new SchClassOpenApiOptions { BaseHost = "http://bschclassmanage" });
|
|
||||||
/// var schools = await api.SchInfo.AllListAsync();
|
|
||||||
/// </code>
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>当前生效的配置。</summary>
|
|
||||||
public SchClassOpenApiOptions Options { get; }
|
|
||||||
|
|
||||||
/// <summary>底层 HttpClient 的 BaseAddress(配置为空时为 null)。</summary>
|
|
||||||
public Uri? HttpBaseAddress => HttpClient.BaseAddress;
|
|
||||||
|
|
||||||
private HttpClient HttpClient { get; }
|
|
||||||
|
|
||||||
/// <summary>DbClass:学校教师名单(<c>/oapi/DbClass/</c>)。</summary>
|
|
||||||
public SchClassDbClassClient DbClass { get; }
|
|
||||||
|
|
||||||
/// <summary>VSchClass:班级(<c>/oapi/VSchClass/</c>)。</summary>
|
|
||||||
public SchClassVClassClient VClass { get; }
|
|
||||||
|
|
||||||
/// <summary>Schoolen:学制与年级(<c>/oapi/Schoolen/</c>)。</summary>
|
|
||||||
public SchClassSchoolingClient Schooling { get; }
|
|
||||||
|
|
||||||
/// <summary>UserInfo:师生统一用户(<c>/oapi/UserInfo/</c>)。</summary>
|
|
||||||
public SchClassUserInfoClient UserInfo { get; }
|
|
||||||
|
|
||||||
/// <summary>SchInfo:学校(<c>/oapi/SchInfo/</c>)。</summary>
|
|
||||||
public SchClassSchInfoClient SchInfo { get; }
|
|
||||||
|
|
||||||
/// <summary>OATea:教师(<c>/oapi/OATea/</c>)。</summary>
|
|
||||||
public SchClassTeaInfoClient TeaInfo { get; }
|
|
||||||
|
|
||||||
/// <summary>OAStu:学生(<c>/oapi/OAStu/</c>)。</summary>
|
|
||||||
public SchClassStuInfoClient StuInfo { get; }
|
|
||||||
|
|
||||||
/// <summary>TeachInfo:任课(<c>/oapi/TeachInfo/</c>)。</summary>
|
|
||||||
public SchClassTeachInfoClient TeachInfo { get; }
|
|
||||||
|
|
||||||
/// <summary>SubInfo:科目(<c>/oapi/SubInfo/</c>)。</summary>
|
|
||||||
public SchClassSubInfoClient SubInfo { get; }
|
|
||||||
|
|
||||||
/// <summary>FamCom:家长兼容(<c>/oapi/Common/FamCom/</c>)。</summary>
|
|
||||||
public SchClassFamilyCompatClient FamilyCompat { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary><see cref="SchClassOpenApi"/> 的轻量工厂(不依赖 DI 容器)。</summary>
|
|
||||||
public static class SchClassOpenApiFactory
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 按配置创建 <see cref="HttpClient"/>:预设 BaseAddress 与读超时。
|
|
||||||
/// 生产环境建议改用 <c>IHttpClientFactory</c> 注入实例,本方法仅用于快速接入与测试。
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>按配置创建一个完整的门面实例(内部自建 HttpClient,调用方负责复用而非频繁创建)。</summary>
|
|
||||||
public static SchClassOpenApi Create(SchClassOpenApiOptions options)
|
|
||||||
=> new SchClassOpenApi(CreateHttpClient(options), options);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>教务开放接口调用异常(HTTP 非 2xx 或响应解析失败)。</summary>
|
|
||||||
public class SchClassOpenApiException : Exception
|
|
||||||
{
|
|
||||||
/// <summary>HTTP 状态码;非 HTTP 层错误时为 0。</summary>
|
|
||||||
public int StatusCode { get; }
|
|
||||||
|
|
||||||
/// <summary>原始响应文本(若有)。</summary>
|
|
||||||
public string? ResponseBody { get; }
|
|
||||||
|
|
||||||
public SchClassOpenApiException(string message, int statusCode = 0, string? responseBody = null)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
StatusCode = statusCode;
|
|
||||||
ResponseBody = responseBody;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 云海教务(SchClass / bschclassmanage)开放接口客户端配置,配置节 <c>yh:schclass:open</c>。
|
|
||||||
/// 与 UserBase 的 OpenApiOptions 同构,但独立配置节,便于分别指向不同后端。
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassOpenApiOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "yh:schclass:open";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 共享开放接口配置节(与 UserBase 的 OpenApiOptions 同节):
|
|
||||||
/// 其中 <c>BaseHost</c> 与 <c>SchClassPath</c> 可作为教务客户端的缺省来源。
|
|
||||||
/// <code>
|
|
||||||
/// "yh": {
|
|
||||||
/// "open": {
|
|
||||||
/// "BaseHost": "http://base.yunhaixingtong.com",
|
|
||||||
/// "BasePath": "/userbase", ← UserBase 用
|
|
||||||
/// "SchClassPath": "/bschclass" ← 教务(SchClass)用
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </summary>
|
|
||||||
public const string SharedSectionName = "yh:open";
|
|
||||||
|
|
||||||
/// <summary>共享节里教务路径的键名。</summary>
|
|
||||||
public const string SchClassPathKey = "SchClassPath";
|
|
||||||
|
|
||||||
/// <summary>HTTP 客户端逻辑名(IHttpClientFactory)。</summary>
|
|
||||||
public const string HttpClientName = "yh-schclass-openapi";
|
|
||||||
|
|
||||||
/// <summary>是否启用(默认 true)</summary>
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>教务开放接口基础地址,例如 http://base.yunhaixingtong.com</summary>
|
|
||||||
public string? BaseHost { get; set; }
|
|
||||||
|
|
||||||
/// <summary>基础路径(默认 null,挂载在根路径);联调环境为 /bschclass</summary>
|
|
||||||
public string? BasePath { get; set; }
|
|
||||||
|
|
||||||
/// <summary>连接超时(毫秒),默认 1500</summary>
|
|
||||||
public long ConnectTimeoutMs { get; set; } = 1500;
|
|
||||||
|
|
||||||
/// <summary>读超时(毫秒),默认 5000</summary>
|
|
||||||
public long ReadTimeoutMs { get; set; } = 5000;
|
|
||||||
|
|
||||||
/// <summary>写超时(毫秒),默认 5000</summary>
|
|
||||||
public long WriteTimeoutMs { get; set; } = 5000;
|
|
||||||
|
|
||||||
/// <summary>调用方在教务平台注册的应用标识(用于签名,可选)</summary>
|
|
||||||
public string? AppKey { get; set; }
|
|
||||||
|
|
||||||
/// <summary>调用方在教务平台注册的密钥(用于签名,可选)</summary>
|
|
||||||
public string? AppSecret { get; set; }
|
|
||||||
|
|
||||||
/// <summary>拼接 baseHost + basePath 为统一开放接口基址。</summary>
|
|
||||||
public string ResolveBaseUrl()
|
|
||||||
{
|
|
||||||
var baseHost = BaseHost?.Trim() ?? "";
|
|
||||||
if (baseHost.EndsWith('/'))
|
|
||||||
{
|
|
||||||
baseHost = baseHost.TrimEnd('/');
|
|
||||||
}
|
|
||||||
return baseHost + (NormalizeBasePath(BasePath) ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null。</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 从共享节(<see cref="SharedSectionName"/>,即 <c>yh:open</c>)按键读取,回填本选项中**未显式配置**的项:
|
|
||||||
/// <para><c>BaseHost</c> ← <c>yh:open:BaseHost</c>;<c>BasePath</c> ← <c>yh:open:SchClassPath</c>(注意不是 BasePath —— 那是 UserBase 的路径)。</para>
|
|
||||||
/// <paramref name="readShared"/> 传相对键名(如 "BaseHost"),返回 null/空白视为未配置。
|
|
||||||
/// </summary>
|
|
||||||
public void FillFromSharedSection(Func<string, string?> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DI 注册扩展:从配置读取地址并装配全部教务客户端。
|
|
||||||
/// <para>
|
|
||||||
/// 配置来源(优先级从高到低):
|
|
||||||
/// 1. 独立节 <c>yh:schclass:open</c>(整节绑定,适合教务单独部署、地址与 UserBase 无关的场景);
|
|
||||||
/// 2. 共享节 <c>yh:open</c> 回填:<c>BaseHost</c> ← <c>yh:open:BaseHost</c>、
|
|
||||||
/// <c>BasePath</c> ← <c>yh:open:SchClassPath</c>(注意共享节的 <c>BasePath=/userbase</c> 是 UserBase 的,不会被教务采用)、
|
|
||||||
/// <c>AppKey</c>/<c>AppSecret</c> 同名回填。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static class SchClassOpenApiServiceCollectionExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 注册教务 /oapi 全部客户端:IHttpClientFactory + typed client + 聚合门面 <see cref="SchClassOpenApi"/>(Singleton)。
|
|
||||||
/// <code>
|
|
||||||
/// builder.Services.AddSchClassOpenApi(builder.Configuration);
|
|
||||||
/// // 注入使用:
|
|
||||||
/// public class X(SchClassOpenApi sch) { ... sch.SchInfo.AllListAsync() ... }
|
|
||||||
/// </code>
|
|
||||||
/// </summary>
|
|
||||||
public static IServiceCollection AddSchClassOpenApi(this IServiceCollection services, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
services.AddOptions<SchClassOpenApiOptions>()
|
|
||||||
.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<IOptions<SchClassOpenApiOptions>>().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<IOptions<SchClassOpenApiOptions>>().Value;
|
|
||||||
return new SocketsHttpHandler
|
|
||||||
{
|
|
||||||
ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddSingleton(sp =>
|
|
||||||
{
|
|
||||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
|
||||||
return new SchClassOpenApi(factory.CreateClient(SchClassOpenApiOptions.HttpClientName),
|
|
||||||
sp.GetRequiredService<IOptions<SchClassOpenApiOptions>>().Value);
|
|
||||||
});
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using Yh.SchClass.OpenApi.Model;
|
|
||||||
|
|
||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 SchInfo(学校)接口客户端(镜像 Java <c>SchInfoController</c>,路径前缀 <c>/oapi/SchInfo/</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassSchInfoClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/SchInfo/";
|
|
||||||
|
|
||||||
public SchClassSchInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>按多个学校 ID 查询学校列表,<c>GET /oapi/SchInfo/GetSchlist</c>。</summary>
|
|
||||||
/// <param name="schIdArr">逗号分隔的学校 ID 列表(Java 参数名 <c>SchIDArr</c>);为 null 时等价于查询全部。</param>
|
|
||||||
public Task<List<SchInfoVo>> GetSchListAsync(string? schIdArr, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "GetSchlist",
|
|
||||||
Params(("SchIDArr", schIdArr)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>查询全部学校列表,<c>GET /oapi/SchInfo/AllList</c>。</summary>
|
|
||||||
public Task<List<SchInfoVo>> AllListAsync(CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "AllList", ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校外部序列号查询单个学校,<c>GET /oapi/SchInfo/GetSchFirst</c>;未找到返回 null。</summary>
|
|
||||||
/// <param name="schId">学校外部序列号(Java 参数名 <c>SchID</c>)。</param>
|
|
||||||
public Task<SchInfoVo?> GetSchFirstAsync(string? schId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<SchInfoVo>(HttpMethod.Get, Root + "GetSchFirst", Params(("SchID", schId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按用户中心学校 ID 查询单个学校,<c>GET /oapi/SchInfo/GetUserSchFirst</c>;未找到返回 null。</summary>
|
|
||||||
/// <param name="userSchId">用户中心学校 ID(Java 参数名 <c>UserSchID</c>)。</param>
|
|
||||||
public Task<SchInfoVo?> GetUserSchFirstAsync(int? userSchId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<SchInfoVo>(HttpMethod.Get, Root + "GetUserSchFirst",
|
|
||||||
Params(("UserSchID", userSchId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个用户中心学校 ID 查询学校列表,<c>GET /oapi/SchInfo/GetUserSchlist</c>。</summary>
|
|
||||||
/// <param name="userSchIds">逗号分隔的用户中心学校 ID 列表(Java 参数名 <c>UserSchIds</c>)。</param>
|
|
||||||
public Task<List<SchInfoVo>> GetUserSchListAsync(string? userSchIds, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "GetUserSchlist",
|
|
||||||
Params(("UserSchIds", userSchIds)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按用户中心组 ID 查询学校列表,<c>GET /oapi/SchInfo/GetSchoolByGroupId</c>;0 或空时返回空列表。</summary>
|
|
||||||
/// <param name="groupId">用户中心组 ID(Java 参数名 <c>groupId</c>)。</param>
|
|
||||||
public Task<List<SchInfoVo>> GetSchoolByGroupIdAsync(int? groupId, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "GetSchoolByGroupId",
|
|
||||||
Params(("groupId", groupId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学制 ID 查询学校,<c>GET /oapi/SchInfo/GetSchoolBySchoolenId</c>;当前服务端恒返回空列表。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetSchoolBySchoolenIdAsync(int? groupId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchoolBySchoolenId",
|
|
||||||
Params(("groupId", groupId)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 Schoolen(学制 / 年级)接口客户端(镜像 Java <c>SchoolingController</c>,路径前缀 <c>/oapi/Schoolen/</c>)。
|
|
||||||
/// <para>
|
|
||||||
/// 年级行字段:<c>Id / Name / OwnerId / Levell / code / sx</c>;
|
|
||||||
/// 学制行字段:<c>Id / Name / FormerName / Type</c>。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassSchoolingClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/Schoolen/";
|
|
||||||
|
|
||||||
public SchClassSchoolingClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>按学校查询年级(学制层级)列表,<c>GET /oapi/Schoolen/GetSchLenLevellist</c>。</summary>
|
|
||||||
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchLenLevelListAsync(string? schSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenLevellist", Params(("SchSerID", schSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学制 ID 查询年级列表,<c>GET /oapi/Schoolen/GetLevelIdlist</c>。</summary>
|
|
||||||
/// <param name="schooling">学制 ID(Java 参数名 <c>Schooling</c>);0 或空时服务端返回空列表。</param>
|
|
||||||
public Task<List<SchClassRow>> GetLevelIdListAsync(int? schooling, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetLevelIdlist", Params(("Schooling", schooling)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按年级 ID 查询单个年级,<c>GET /oapi/Schoolen/GetSchLenLevelFirst</c>;未找到返回 null。</summary>
|
|
||||||
/// <param name="gradeId">年级 ID(Java 参数名 <c>GradeId</c>,字符串形式)。</param>
|
|
||||||
public Task<SchClassRow?> GetSchLenLevelFirstAsync(string? gradeId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetSchLenLevelFirst",
|
|
||||||
Params(("GradeId", gradeId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校查询学制列表,<c>GET /oapi/Schoolen/GetSchLenlist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetSchLenListAsync(string? schSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenlist", Params(("SchSerID", schSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询班级科目,<c>GET /oapi/Schoolen/GetAllClassSubject</c>;当前服务端恒返回空列表。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<SchClassRow>> GetAllClassSubjectAsync(int? schId, string? schserid, string? startTime, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetAllClassSubject",
|
|
||||||
Params(("schId", schId), ("schserid", schserid), ("startTime", startTime)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 OAStu(学生)接口客户端(镜像 Java <c>StuInfoController</c>,路径前缀 <c>/oapi/OAStu/</c>)。
|
|
||||||
/// <para>
|
|
||||||
/// 学生行字段(<c>OaCompatSupport.student</c>):
|
|
||||||
/// <c>Account / UserStuSerID / StuName / StuIDCard / StuSex / StuStatus / ClassID / ClassStuID /
|
|
||||||
/// GroupID / StuPhoto / CardID / StuCurrentTel / invitecode / SchSerID / Graduated / isjiedu</c>;
|
|
||||||
/// 走班(Elective)行在此基础上追加 <c>electiveId / schoolId</c> 且 <c>StuSex</c> 为数值。
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// 历史学生行字段:<c>stuId / stuName / classId / className / grade / name / school_system_id / system_grade</c>。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassStuInfoClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/OAStu/";
|
|
||||||
|
|
||||||
public SchClassStuInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>查询全部学生,<c>GET /oapi/OAStu/GetAllStulist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetAllStuListAsync(CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetAllStulist", ct: ct);
|
|
||||||
|
|
||||||
/// <summary>查询全部走班学生,<c>GET /oapi/OAStu/GetEleAllStulist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleAllStuListAsync(CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleAllStulist", ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校查询学生,<c>GET /oapi/OAStu/GetSchStulist</c>。</summary>
|
|
||||||
/// <param name="schserid">学校外部序列号(Java 参数名 <c>schserid</c>,全小写)。</param>
|
|
||||||
/// <param name="schgroupid">学校分组 ID(Java 参数名 <c>schgroupid</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchStulist",
|
|
||||||
Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校查询走班学生,<c>GET /oapi/OAStu/GetEleSchStulist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchStulist",
|
|
||||||
Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按班级查询学生,<c>GET /oapi/OAStu/GetClaStulist</c>。</summary>
|
|
||||||
/// <param name="classId">班级 ID(Java 参数名 <c>ClassID</c>)。</param>
|
|
||||||
/// <param name="claGroupId">班级分组 ID(Java 参数名 <c>clagroupid</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetClaStulist",
|
|
||||||
Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按班级查询走班学生,<c>GET /oapi/OAStu/GetEleClaStulist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaStulist",
|
|
||||||
Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个班级查询学生,<c>GET /oapi/OAStu/GetClaArrStulist</c>。</summary>
|
|
||||||
/// <param name="classIds">逗号分隔的班级 ID 列表(Java 参数名 <c>ClassIDs</c>)。</param>
|
|
||||||
/// <param name="claGroupIds">逗号分隔的班级分组 ID 列表(Java 参数名 <c>clagroupids</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetClaArrStulist",
|
|
||||||
Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个班级查询走班学生,<c>GET /oapi/OAStu/GetEleClaArrStulist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaArrStulist",
|
|
||||||
Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学生 ID 查询学生,<c>GET /oapi/OAStu/GetStuIdslist</c>。</summary>
|
|
||||||
/// <param name="stuids">逗号分隔的学生 ID 列表(Java 参数名 <c>stuids</c>,全小写)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetStuIdsListAsync(string? stuids, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetStuIdslist", Params(("stuids", stuids)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学生 ID 查询走班学生,<c>GET /oapi/OAStu/GetEleStuIdslist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleStuIdsListAsync(string? stuids, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleStuIdslist", Params(("stuids", stuids)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>查询学生家长用户 ID 列表,<c>GET /oapi/OAStu/GetStuUserFamIDList</c>;当前服务端恒返回空列表。</summary>
|
|
||||||
public Task<List<string>> GetStuUserFamIdListAsync(string? stuids, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetStuUserFamIDList",
|
|
||||||
Params(("stuids", stuids)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按邀请码查询学生,<c>GET /oapi/OAStu/GetFamStulist</c>。</summary>
|
|
||||||
/// <param name="invitecodes">逗号分隔的邀请码列表(Java 参数名 <c>invitecodes</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetFamStuListAsync(string? invitecodes, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetFamStulist",
|
|
||||||
Params(("invitecodes", invitecodes)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>查询学生家庭成员,<c>GET /oapi/OAStu/GetStuFamMem</c>;当前服务端恒返回空列表。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetStuFamMemAsync(string? schSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetStuFamMem", Params(("SchSerID", schSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校序列号查询学生,<c>GET /oapi/OAStu/GetSchBySchIds</c>。</summary>
|
|
||||||
/// <param name="schserids">逗号分隔的学校序列号列表(Java 参数名 <c>schserids</c>,全小写)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchBySchIdsAsync(string? schserids, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchBySchIds", Params(("schserids", schserids)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校序列号查询走班学生,<c>GET /oapi/OAStu/GetEleSchIdsStulist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchIdsStuListAsync(string? schserids, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchIdsStulist", Params(("schserids", schserids)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校序列号查询走班学生,<c>GET /oapi/OAStu/GetEleSchBySchIds</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchBySchIdsAsync(string? schserids, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchBySchIds", Params(("schserids", schserids)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按选课 ID 查询学生,<c>GET /oapi/OAStu/GetStudentsByElectiveId</c>;当前服务端恒返回空列表。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetStudentsByElectiveIdAsync(int? electiveIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetStudentsByElectiveId",
|
|
||||||
Params(("electiveIds", electiveIds)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校 + 年度查询历史学生,<c>GET /oapi/OAStu/getHistoryStudentsBySchoolId</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetHistoryStudentsBySchoolIdAsync(int schoolId, int year, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryStudentsBySchoolId",
|
|
||||||
Params(("schoolId", schoolId), ("year", year)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校 + 年度查询历史班级,<c>GET /oapi/OAStu/getHistoryClass</c>;当前服务端恒返回空列表。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetHistoryClassAsync(int schoolId, int year, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryClass",
|
|
||||||
Params(("schoolId", schoolId), ("year", year)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 SubInfo(科目)接口客户端(镜像 Java <c>SchSubController</c>,路径前缀 <c>/oapi/SubInfo/</c>)。
|
|
||||||
/// <para>科目行字段:<c>SchSerID / SubID / SubName / No</c>。</para>
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassSubInfoClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/SubInfo/";
|
|
||||||
|
|
||||||
public SchClassSubInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>按学校 + 学制查询科目列表,<c>GET /oapi/SubInfo/GetSchSublist</c>。</summary>
|
|
||||||
/// <param name="schId">学校外部序列号(Java 参数名 <c>SchID</c>)。</param>
|
|
||||||
/// <param name="schooling">学制 ID(Java 参数名 <c>schooling</c>,全小写)。</param>
|
|
||||||
/// <param name="gradeId">年级 ID(Java 参数名 <c>gradeId</c>;当前服务端未使用该参数)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchSubListAsync(string? schId, int? schooling, int? gradeId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchSublist",
|
|
||||||
Params(("SchID", schId), ("schooling", schooling), ("gradeId", gradeId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校查询科目列表,<c>GET /oapi/SubInfo/GetSchIdsSublist</c>。</summary>
|
|
||||||
/// <param name="schIds">逗号分隔的学校序列号列表(Java 参数名 <c>SchIds</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchIdsSubListAsync(string? schIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchIdsSublist", Params(("SchIds", schIds)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按教师查询其任教的科目编码列表(去重字符串),<c>GET /oapi/SubInfo/GetSchTeaSublist</c>。</summary>
|
|
||||||
/// <param name="userTeaSerId">教师 ID(Java 参数名 <c>UserTeaSerID</c>,字符串形式)。</param>
|
|
||||||
public Task<List<string>> GetSchTeaSubListAsync(string? userTeaSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetSchTeaSublist",
|
|
||||||
Params(("UserTeaSerID", userTeaSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按学校查询该校全部任课科目编码(小写去重字符串),<c>GET /oapi/SubInfo/GetSchTeachSublist</c>。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<string>> GetSchTeachSubListAsync(string? schId, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetSchTeachSublist",
|
|
||||||
Params(("SchID", schId)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using Yh.SchClass.OpenApi.Model;
|
|
||||||
|
|
||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 OATea(教师)接口客户端(镜像 Java <c>TeainfoController</c>,路径前缀 <c>/oapi/OATea/</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassTeaInfoClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/OATea/";
|
|
||||||
|
|
||||||
public SchClassTeaInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按教师 ID 查询教师(<c>GET /oapi/OATea/GetTeainfo</c>)。
|
|
||||||
/// 教师不存在或无分组归属时服务端返回 404,本方法返回 null。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="uid">教师 ID(Java 参数名 <c>uid</c>,全小写)。</param>
|
|
||||||
public Task<TeaInfoVo?> GetTeaInfoAsync(int uid, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<TeaInfoVo>(HttpMethod.Get, Root + "GetTeainfo", Params(("uid", uid)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按多个教师 ID 查询教师列表(<c>POST /oapi/OATea/GetArrTeainfo</c>)。
|
|
||||||
/// 请求体为逗号分隔的教师 ID(Java 侧 <c>@RequestBody String</c>,按 text/plain 提交);
|
|
||||||
/// 参数缺失时服务端返回 400,查询为空时返回 204(本方法返回空列表)。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<TeaInfoVo>> GetArrTeaInfoAsync(string userIds, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<TeaInfoVo>(HttpMethod.Post, Root + "GetArrTeainfo", body: userIds, ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按学校查询教师列表(<c>GET /oapi/OATea/GetSchTeainfo</c>)。
|
|
||||||
/// <paramref name="schId"/> 与 <paramref name="schSerId"/> 至少提供一个,否则服务端返回 400。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
|
|
||||||
/// <param name="schId">学校内部 ID(Java 参数名 <c>schId</c>,优先使用)。</param>
|
|
||||||
public Task<List<TeaInfoVo>> GetSchTeaInfoAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<TeaInfoVo>(HttpMethod.Get, Root + "GetSchTeainfo",
|
|
||||||
Params(("SchSerID", schSerId), ("schId", schId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按学校查询教师列表(<c>GET /oapi/OATea/GetSchTeaList</c>),与 <see cref="GetSchTeaInfoAsync"/> 等价。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<TeaInfoVo>> GetSchTeaListAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<TeaInfoVo>(HttpMethod.Get, Root + "GetSchTeaList",
|
|
||||||
Params(("SchSerID", schSerId), ("schId", schId)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 TeachInfo(任课)接口客户端(镜像 Java <c>TeaChesController</c>,路径前缀 <c>/oapi/TeachInfo/</c>)。
|
|
||||||
/// <para>
|
|
||||||
/// 任课行字段(<c>OaCompatSupport.teaching</c>):
|
|
||||||
/// <c>SerID / SchSerID / UserTeaSerID / SubID / ClassID / UserClassID / CreationTime / static</c>。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassTeachInfoClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/TeachInfo/";
|
|
||||||
|
|
||||||
public SchClassTeachInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>按学校 + 教师查询任课,<c>GET /oapi/TeachInfo/GetSchTeachlist</c>。</summary>
|
|
||||||
/// <param name="schId">学校外部序列号(Java 参数名 <c>SchID</c>)。</param>
|
|
||||||
/// <param name="userTeaSerId">教师 ID(Java 参数名 <c>UserTeaSerID</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchTeachlist",
|
|
||||||
Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校 + 教师查询走班任课,<c>GET /oapi/TeachInfo/GetEleSchTeachlist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchTeachlist",
|
|
||||||
Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校查询任课,<c>GET /oapi/TeachInfo/GetSchArrTeachlist</c>。</summary>
|
|
||||||
/// <param name="schIdArr">逗号分隔的学校序列号列表(Java 参数名 <c>SchIDArr</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrTeachlist", Params(("SchIDArr", schIdArr)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校查询走班任课,<c>GET /oapi/TeachInfo/GetEleSchArrTeachList</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrTeachList", Params(("SchIDArr", schIdArr)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按班级查询任课,<c>GET /oapi/TeachInfo/GetSchClaTeachlist</c>。</summary>
|
|
||||||
/// <param name="classId">班级 ID(Java 参数名 <c>ClassID</c>);非法或为空时返回空列表。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchClaTeachListAsync(string? classId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchClaTeachlist", Params(("ClassID", classId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按班级查询走班任课,<c>GET /oapi/TeachInfo/GetEleSchClaTeachlist</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchClaTeachListAsync(string? classId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchClaTeachlist", Params(("ClassID", classId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校 + 年级查询选课班级,<c>GET /oapi/TeachInfo/GetElectiveClassByGrade</c>;当前服务端恒返回空列表。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade",
|
|
||||||
Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按多个教师查询选课班级,<c>POST /oapi/TeachInfo/GetElectiveClassByTeachers</c>;
|
|
||||||
/// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<SchClassRow>> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using Yh.SchClass.OpenApi.Model;
|
|
||||||
|
|
||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 UserInfo(师生统一用户)接口客户端(镜像 Java <c>UserInfoController</c>,路径前缀 <c>/oapi/UserInfo/</c>)。
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassUserInfoClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/UserInfo/";
|
|
||||||
|
|
||||||
public SchClassUserInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询学校全部教师 + 学生(<c>GET /oapi/UserInfo/GetUserList</c>)。
|
|
||||||
/// 参数缺失或学校不存在时服务端返回 400;查询为空时返回 204(本方法返回空列表)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
|
|
||||||
public Task<List<UserInfoVo>> GetUserListAsync(string schSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<UserInfoVo>(HttpMethod.Get, Root + "GetUserList",
|
|
||||||
Params(("SchSerID", schSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按用户 ID 查询用户信息(<c>GET /oapi/UserInfo/getUserIdList</c>)。
|
|
||||||
/// 教师 / 学生均未命中时服务端返回 404,本方法返回空列表。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userid">用户 ID(Java 参数名 <c>userid</c>,全小写)。</param>
|
|
||||||
public Task<List<UserInfoVo>> GetUserIdListAsync(int userid, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<UserInfoVo>(HttpMethod.Get, Root + "getUserIdList",
|
|
||||||
Params(("userid", userid)), ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
namespace Yh.SchClass.OpenApi;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 教务 VSchClass(班级)接口客户端(镜像 Java <c>VClassController</c>,路径前缀 <c>/oapi/VSchClass/</c>)。
|
|
||||||
/// <para>
|
|
||||||
/// 班级行字段(<c>OaCompatSupport.classInfo</c>):
|
|
||||||
/// <c>Id / Name / OwnerId / Levell / sx / SchSerID / ClassID / UserClassID / Schooling / MajorCode /
|
|
||||||
/// ClassCode / ClassMotto / ClassName / StartDate / FirstTeaUserID / SecondTeaUserID / UpgradeYear /
|
|
||||||
/// CurrentIndex / CreationTime / GradtionTime / GreduatedYear / Graduated</c>。
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class SchClassVClassClient : SchClassClientBase
|
|
||||||
{
|
|
||||||
private const string Root = Prefix + "/VSchClass/";
|
|
||||||
|
|
||||||
public SchClassVClassClient(HttpClient httpClient, SchClassOpenApiOptions options)
|
|
||||||
: base(httpClient, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>按学校查询班级(排除已毕业),<c>GET /oapi/VSchClass/GetVClassList</c>。</summary>
|
|
||||||
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetVClassListAsync(string? schSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassList", Params(("SchSerID", schSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校查询班级(含已毕业),<c>GET /oapi/VSchClass/GetEleVClassList</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleVClassListAsync(string? schSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassList", Params(("SchSerID", schSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校查询班级(排除已毕业),<c>GET /oapi/VSchClass/GetSchArrVClassList</c>。</summary>
|
|
||||||
/// <param name="schIdArr">逗号分隔的学校序列号列表(Java 参数名 <c>SchIDArr</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个学校查询班级(含已毕业),<c>GET /oapi/VSchClass/GetEleSchArrVClassList</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按时间查询学校班级,<c>GET /oapi/VSchClass/GetFunTimeSchClass</c>。
|
|
||||||
/// 注意:Java 实现忽略 <paramref name="time"/>,实际按学校过滤。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<SchClassRow>> GetFunTimeSchClassAsync(string? time, string? schSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetFunTimeSchClass",
|
|
||||||
Params(("time", time), ("SchSerID", schSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按班级 ID 查询单个班级,<c>GET /oapi/VSchClass/GetVClassFirst</c>;未找到返回 null。</summary>
|
|
||||||
/// <param name="classId">班级 ID(Java 参数名 <c>ClassID</c>)。</param>
|
|
||||||
public Task<SchClassRow?> GetVClassFirstAsync(string? classId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetVClassFirst", Params(("ClassID", classId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按班级 ID 查询单个班级(含已毕业),<c>GET /oapi/VSchClass/GetEleVClassFirst</c>。</summary>
|
|
||||||
public Task<SchClassRow?> GetEleVClassFirstAsync(string? classId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetEleVClassFirst", Params(("ClassID", classId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个班级 ID 查询班级,<c>GET /oapi/VSchClass/GetVClassIdsList</c>。</summary>
|
|
||||||
/// <param name="classIds">逗号分隔的班级 ID 列表(Java 参数名 <c>ClassIds</c>)。</param>
|
|
||||||
public Task<List<SchClassRow>> GetVClassIdsListAsync(string? classIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassIdsList", Params(("ClassIds", classIds)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按多个班级 ID 查询班级(含已毕业),<c>GET /oapi/VSchClass/GetEleVClassIdsList</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleVClassIdsListAsync(string? classIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassIdsList", Params(("ClassIds", classIds)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校 + 学制 + 年级查询班级,<c>GET /oapi/VSchClass/GetGradeClassList</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetGradeClassListAsync(string? schSerId, int? schooling, int? gradeId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetGradeClassList",
|
|
||||||
Params(("SchSerID", schSerId), ("Schooling", schooling), ("GradeId", gradeId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>查询教师任课的班级 ID 列表(去重后的字符串),<c>GET /oapi/VSchClass/GetTeachClassArr</c>。</summary>
|
|
||||||
/// <param name="userTeaSerId">教师 ID(Java 参数名 <c>UserTeaSerID</c>)。</param>
|
|
||||||
public Task<List<string>> GetTeachClassArrAsync(int? userTeaSerId, CancellationToken ct = default)
|
|
||||||
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetTeachClassArr",
|
|
||||||
Params(("UserTeaSerID", userTeaSerId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>查询全部班级(排除已毕业),<c>GET /oapi/VSchClass/GetSchArrVClassListAll</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetSchArrVClassListAllAsync(CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassListAll", ct: ct);
|
|
||||||
|
|
||||||
/// <summary>查询全部班级(含已毕业),<c>GET /oapi/VSchClass/GetEleSchArrVClassListAll</c>。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetEleSchArrVClassListAllAsync(CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassListAll", ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 以 POST 方式按多个学校查询班级,<c>POST /oapi/VSchClass/GetSchArrVClassListPost</c>。
|
|
||||||
/// 请求体为逗号分隔的学校序列号(Java 侧 <c>@RequestBody String</c>,按 text/plain 提交)。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<SchClassRow>> GetSchArrVClassListPostAsync(string? body, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetSchArrVClassListPost", body: body, ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按选课 ID 查询班级,<c>GET /oapi/VSchClass/GetElectiveClassById</c>;当前服务端恒返回 null。</summary>
|
|
||||||
public Task<SchClassRow?> GetElectiveClassByIdAsync(int? electiveId, CancellationToken ct = default)
|
|
||||||
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetElectiveClassById",
|
|
||||||
Params(("electiveId", electiveId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>按学校 + 年级查询选课班级,<c>GET /oapi/VSchClass/GetElectiveClassByGrade</c>;当前服务端恒返回空列表。</summary>
|
|
||||||
public Task<List<SchClassRow>> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade",
|
|
||||||
Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按多个教师查询选课班级,<c>POST /oapi/VSchClass/GetElectiveClassByTeachers</c>;
|
|
||||||
/// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。
|
|
||||||
/// </summary>
|
|
||||||
public Task<List<SchClassRow>> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default)
|
|
||||||
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<PackageId>Yh.SchClass.OpenApi</PackageId>
|
|
||||||
<Description>云海教务(SchClass)开放接口客户端:对应 Java 教务 OpenApi 调用封装</Description>
|
|
||||||
<Summary>SchClass /oapi 同构客户端 SDK,支持 yh:open / yh:schclass:open 配置节绑定与 DI 注册</Summary>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
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<JObject>()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JObject LoadFixtureWrapped()
|
|
||||||
{
|
|
||||||
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "envelope-success.json");
|
|
||||||
return JObject.Parse(File.ReadAllText(path))["wrappedData"]!.Value<JObject>()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Newtonsoft_反序列化fixture_含currentRole嵌套()
|
|
||||||
{
|
|
||||||
var user = LoadFixtureBareData().ToObject<BoaUser>(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<int> { 3, 5 }, user.RoleIds);
|
|
||||||
Assert.Equal(3, user.Groups!.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Newtonsoft_默认设置_大小写不敏感匹配camelCase()
|
|
||||||
{
|
|
||||||
var user = LoadFixtureBareData().ToObject<BoaUser>();
|
|
||||||
Assert.Equal("zhangsan", user!.Account);
|
|
||||||
Assert.Equal("教师", user.CurrentRole!.RoleName);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SystemTextJson_Web序列化_与fixture逐节点一致()
|
|
||||||
{
|
|
||||||
var user = LoadFixtureBareData().ToObject<BoaUser>();
|
|
||||||
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<JProperty>();
|
|
||||||
CollectNullProps(clone, toRemove);
|
|
||||||
foreach (var p in toRemove)
|
|
||||||
{
|
|
||||||
p.Remove();
|
|
||||||
}
|
|
||||||
return clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CollectNullProps(JToken token, List<JProperty> 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<ApiEnvelope<BoaUser>>(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<BoaApp>(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<BoaApp>();
|
|
||||||
|
|
||||||
Assert.Equal(app.Name, stj!.Name);
|
|
||||||
Assert.Equal(app.Clients, stj.Clients);
|
|
||||||
Assert.Equal(app.Name, nf!.Name);
|
|
||||||
Assert.Equal(false, nf.IsPublic);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net8.0</TargetFrameworks>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Newtonsoft.Json" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
|
||||||
<PackageReference Include="xunit" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="..\..\contract\fixtures\**\*.*" CopyToOutputDirectory="PreserveNewest" LinkBase="fixtures" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// AppSignHandler basePath 剥离对拍(验收 P1 修复):
|
|
||||||
/// 链路 handler(fullUrl) → 剥离 basePath → SignUtil(剥离后 canonicalTarget) → 向量基线(Java 口径)。
|
|
||||||
/// 证明:经 handler 发出的签名 = Java 客户端对同一 URL 的签名(同 ts 时逐字节一致)。
|
|
||||||
/// </summary>
|
|
||||||
public class AppSignHandlerTests
|
|
||||||
{
|
|
||||||
private sealed class CaptureHandler : HttpMessageHandler
|
|
||||||
{
|
|
||||||
public HttpRequestMessage? Last { get; private set; }
|
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> 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<HttpRequestMessage> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>读取请求级向量:固定 ts 后,经 handler 对 fullUrl 的签名须与向量基线(Java 口径)逐字节一致。</summary>
|
|
||||||
public static IEnumerable<object[]> 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")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using Xunit;
|
|
||||||
using Yh.Platform.AppAuth;
|
|
||||||
|
|
||||||
namespace Yh.Platform.AppAuth.Tests;
|
|
||||||
|
|
||||||
/// <summary>签名向量逐条对拍(基线由独立实现产出,见 contract/fixtures/sign-vectors.json)。</summary>
|
|
||||||
public class SignVectorTests
|
|
||||||
{
|
|
||||||
public static System.Collections.Generic.IEnumerable<object[]> 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<ArgumentException>(() => 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net8.0</TargetFrameworks>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
|
||||||
<PackageReference Include="xunit" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Yh.Platform.AppAuth\Yh.Platform.AppAuth.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="..\..\contract\fixtures\**\*.*" CopyToOutputDirectory="PreserveNewest" LinkBase="fixtures" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user