diff --git a/.NET5底层框架搭建方案.md b/.NET5底层框架搭建方案.md new file mode 100644 index 0000000..4d9c939 --- /dev/null +++ b/.NET5底层框架搭建方案.md @@ -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、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(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 +{ + public int Code { get; set; } // 0=成功 + public string Message { get; set; } + public T Data { get; set; } + public static ApiResult Ok(T data) => new() { Code = 0, Data = data }; + public static ApiResult 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`。 +- 全链路任意位置通过 `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` / `RepositoryBase` 提供 CRUD、分页(对应 `PageUtil`)、批量操作。 +- **Specification 轻量实现**:`Expression>` 组合器(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`:复刻 CAS ticket 校验与 SSO 重定向逻辑(配置项对齐 `yh.sso.*`)。 +- `AppAuthMiddleware`:应用间调用签名校验(app-key / timestamp / sign),对应 `yh.app-auth.*`;优先尝试复用 `BschOpenApi.dll` 的签名算法,不兼容则按 Signature.java 逻辑重写。 + +### 6.12 参数校验(对应 validation 包) + +- FluentValidation:每个 DTO 一个 `AbstractValidator`,自动注册; +- 自定义校验规则(如密码强度,对应 `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 + services.AddServices(); // 扩展方法:批量注册 Service + services.AddAutoMapper(typeof(Startup)); + services.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining()); + services.AddSwaggerGen(/* 三组文档 + 鉴权 Header */); + services.AddQuartzHosted(); // 调度 + services.AddLog4Net(); // 日志 +} + +public void Configure(IApplicationBuilder app) +{ + app.UseMiddleware(); // 0. 接口访问日志(唯一日志出口) + app.UseMiddleware(); // 1. 异常兜底(异常摘要并入接口日志) + app.UseMiddleware(); // 2. 用户上下文 + app.UseMiddleware(); // 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 字段不变),保证前端/移动端零改动切换。 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..247619f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/bin/ +**/obj/ +**/target/ +**/.vs/ +**/.idea/ +**/logs/ +*.user +*.suo +.DS_Store diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..7ccf62c --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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}\"}" diff --git a/BasePlatformDotnet/.gitignore b/.gitignore similarity index 71% rename from BasePlatformDotnet/.gitignore rename to .gitignore index b276142..223eddc 100644 --- a/BasePlatformDotnet/.gitignore +++ b/.gitignore @@ -37,3 +37,11 @@ artifacts/ # OS .DS_Store Thumbs.db + +# 业务侧特有 +# 临时验证目录(Mock CAS 脚本、curl 日志、清理清单等) +Temp/ +# 项目记忆与本地技能(含会话数据,不入库) +.workbuddy/ +# 日志 +*.log diff --git a/BasePlatformDotnet/.gitea/workflows/ci.yml b/BasePlatformDotnet/.gitea/workflows/ci.yml deleted file mode 100644 index 00b8211..0000000 --- a/BasePlatformDotnet/.gitea/workflows/ci.yml +++ /dev/null @@ -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 diff --git a/BasePlatformDotnet/.workbuddy/memory/2026-09-01.md b/BasePlatformDotnet/.workbuddy/memory/2026-09-01.md deleted file mode 100644 index 992eb02..0000000 --- a/BasePlatformDotnet/.workbuddy/memory/2026-09-01.md +++ /dev/null @@ -1,149 +0,0 @@ -# 2026-09-01 - -## 修复:CS0121 Deserialize 重载二义性(Yh.Platform.AppAuth) - -- 文件:`src/Yh.Platform.AppAuth/SignKeyResolver.cs` 第 49~52 行。 -- 现象:`JsonSerializer.Deserialize(json!, JsonOpts)` 报 CS0121,在 - `Deserialize(ReadOnlySpan, JsonSerializerOptions?)` 与 - `Deserialize(string, JsonSerializerOptions?)` 之间二义。 -- 根因:`json` 是 StackExchange.Redis 的 `RedisValue`,它同时定义了到 `string` 和 `byte[]` 的隐式转换运算符, - `byte[]` 又可隐式转 `ReadOnlySpan`,两条用户定义转换路径都可行 → 编译器无法取舍。 -- 修复:先显式取 string,`var json = (string?)await redis.StringGetAsync(...)`,再用 - `!string.IsNullOrEmpty(json)` 判断(原 `json.HasValue` 语义等价,RedisValue.Null 转 string 得 null)。 -- 验证:`dotnet build yh-platform-net.sln` 全解决方案 0 警告 0 错误。 - -### 通用经验 -凡是把 `RedisValue`(或其他带多个隐式转换运算符的类型)直接传给重载方法,都可能触发 CS0121。 -统一约定:从 Redis 取出的值先显式转换成目标 CLR 类型(`(string?)` / `(byte[]?)` / `(long?)`),再传给下游 API。 - -## 新增项目:Yh.SchClass.OpenApi(类库,net5.0;net8.0) - -- 路径:`src/Yh.SchClass.OpenApi/`,目录下原有空文件夹(sln 未引用),本次补齐。 -- 新增文件: - - `Yh.SchClass.OpenApi.csproj`:继承 `Directory.Build.props` 默认 `TargetFrameworks=net5.0;net8.0`,仅设 `PackageId`/`Description`/`Summary`,无额外 PackageReference(最小起步)。 - - `SchClassOpenApiEntry.cs`:根命名空间 `Yh.SchClass.OpenApi` 的占位静态类 `SchClassOpenApi`,避免空库 NETSDK1082 警告。 -- sln 注册:GUID `{2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}`,已加入 Project 声明、ProjectConfigurationPlatforms(6 行 Debug/Release × AnyCPU/x64/x86)与 NestedProjects(归入 samples 方案文件夹 `{5D20AA90...}`)。 -- 验证:`dotnet build yh-platform-net.sln` 全方案 0 警告 0 错误,新建项目双目标均通过。 -- 约定:新增 src 类库时默认沿用中央包管理(Directory.Packages.props),csproj 内不写版本号;项目需手动在 sln 注册到 `samples` 文件夹下。 - -## 新功能:Yh.SchClass.OpenApi 实现 Java OA 端点客户端(69 个方法) - -- 需求来源:`E:\YHXT\java\bschclassmanage\...\controller\oapi\oa` 下 12 个 Java 控制器,逐一映射为 .NET 客户端方法。 -- Java 侧关键事实: - - 前缀 `Config.OAPI = "/oapi"`(config/Config.java:16);控制器各自追加 `/DbClass/`、`/VSchClass/`、`/Schoolen/`、`/UserInfo/`、`/SchInfo/`、`/OATea/`、`/OAStu/`、`/TeachInfo/`、`/SubInfo/`、`/Common/FamCom/`。 - - 端点返回**裸 JSON**(对象/数组/字符串数组),无统一信封。 - - 用 HTTP 404 / 204 表达"查无数据",HTTP 400 表达参数缺失 —— 客户端需把 404/204 归一为 null/空列表,不能当异常抛。 - - 参数名大小写混用且敏感:`SchSerID` / `schserid` / `Schooling` / `schId` / `Group` / `ClassID` / `SchIDArr` / `userid` 等,必须逐字照搬。 - - `SchGroupController` 是空壳(0 端点),不生成客户端;`OaCompatSupport` 是行映射工具类,非端点。 -- 设计决策: - - **零项目依赖**:自建 `SchClassClientBase`,不引用 `Yh.Platform.OpenApi`(避免拖入 AppAuth 依赖链),可独立打包。 - - `Map` 兼容行用 `Dictionary`,通过 `global using SchClassRow = ...` 别名收敛(net5.0 可用,不用 net6+ 的 JsonNode)。 - - VO 用 `[JsonPropertyName]` 锚定 **Java 字段原名**(如 `IDCard`、`SchSerID`),配合 `JsonSerializerDefaults.Web` 的大小写不敏感匹配,可同时兼容 Jackson 从 getter 推导出的 `idcard`、`schSerID` 等变体。 - - POST 的 `@RequestBody String` 端点按 `text/plain` 提交原始逗号分隔串(非 JSON 包裹),否则 Spring 侧会带到引号。 - - **信封识别加严**:只有同时出现数字 `code` 且(有 `data` 或有字符串 `msg`)才判为信封。否则学制等级行 `{Id,Name,OwnerId,Levell,code,sx}` 中的 `code=gradeId` 会被误判成错误响应而抛异常。 -- 交付文件(`src/Yh.SchClass.OpenApi/`): - - 基础设施:`GlobalUsings.cs`、`SchClassClientBase.cs`、`SchClassOpenApiOptions.cs`、`SchClassOpenApiException.cs` - - DTO:`SchInfoVo.cs`、`UserInfoVo.cs`、`TeaInfoVo.cs` - - 客户端 10 个:DbClass(1) / VClass(17) / Schooling(5) / UserInfo(2) / SchInfo(7) / TeaInfo(4) / StuInfo(19) / TeachInfo(8) / SubInfo(4) / FamilyCompat(2) - - 门面:`SchClassOpenApi.cs`(含 `SchClassOpenApiFactory` 轻量工厂) - - 端点数与方法数 1:1 对齐(合计 69),已用 grep 双向核对。 -- 踩坑:`Unwrap` 里先把 `out var codeEl` 的 `&&` 链赋值给 bool 变量再使用,编译器不做相关性分析 → CS0165「使用了未赋值的局部变量」。改为直接 `if` 分支判定即可。 -- 验证:`dotnet build yh-platform-net.sln` 全方案 **0 警告 0 错误**,net5.0 与 net8.0 双目标均通过。 - -## 新增项目:Yh.SchClass.OpenApi.Tests(xunit,net8.0)+ 联调调试项 - -- 路径:`tests/Yh.SchClass.OpenApi.Tests/`,GUID `{E70492B2-109A-4E6F-AF91-443AC2956E3A}`,sln 三处已注册(Project / ProjectConfigurationPlatforms / NestedProjects 归入 `samples` 方案文件夹 `{5D20AA90...}`)。 -- 结构对齐 `Yh.Platform.OpenApi.Tests`:`net8.0` + `IsPackable=false` + xunit 三件套(版本号走 CPM,csproj 内不写版本)。 -- 交付文件:`GlobalUsings.cs`、`FakeHandler.cs`(同构于平台测试的捕获/回放 handler,额外记录 Content-Type)、 - `SchClassTestHost.cs`(共享 Options、`Verify`/`VerifyBody` 契约断言)、 - `SchClassOpenApiOptionsTests.cs`、`SchClassResponseParsingTests.cs`(裸数据/信封/404/204/400/500/非法JSON/学制行 code 不误判)、 - `SchClassVClassClientTests.cs`(17 端点)、`SchClassStuInfoClientTests.cs`(19 端点)、 - `SchClassSchInfoClientTests.cs`(7)、`SchClassTeaInfoClientTests.cs`(4)、`SchClassUserInfoClientTests.cs`(2)、 - `SchClassOtherClientsTests.cs`(Schooling/SubInfo/TeachInfo/DbClass/FamCom 共 20)、 - `SchClassOpenApiFacadeTests.cs`(门面装配与工厂)。 -- **调试项**:`SchClassLiveDebug.cs` + `SchClassLiveDebugTests.cs`。设 `YH_SCHCLASS_DEBUG_HOST`(可选 `_PATH`/`_SCH`/`_CLASS`/`_TEA`)后对真实后端发请求并逐端点打印条数与异常;未设则自动空跑通过,CI 安全。一键入口是 `联调_一键跑通全部端点`(20 个端点汇总表)。用法示例见文件头注释。 -- 顺带修复:`src/Yh.SchClass.OpenApi/Model/*.cs` 的 namespace 仍是 `Yh.SchClass.OpenApi`,而 3 个 client 写的是 `using Yh.SchClass.OpenApi.Model;` → 6 个 CS0234。已把三个 VO 的 namespace 改为 `Yh.SchClass.OpenApi.Model`(与 `Model/` 目录一致)。 -- 测试踩坑:`TeaInfoVo.UserID` 是**用户中心 ID**,与查询参数 `uid`(教师 teaId)不是同一个值,写断言时易混。 -- 验证:`dotnet test yh-platform-net.sln` 全绿 —— Abstractions 8 / SchClass 63 / OpenApi 37 / Auth 19 / AppAuth 42,共 169。 - -## 联调地址改为真实环境 + 首次真实联调通过 - -- **教务开放接口真实地址**:`http://base.yunhaixingtong.com/bschclass` - - 拆解:`BaseHost = http://base.yunhaixingtong.com`,`BasePath = /bschclass`(Java 工程的 context-path)。 - - 完整示例:`http://base.yunhaixingtong.com/bschclass/oapi/DbClass/GetSchteaList?Group=3` -- 改动: - - `SchClassLiveDebug.cs`:新增内置默认 `DefaultHost`/`DefaultPath`,**默认即联调环境,开箱即用**; - 新增 `Enabled` 开关(默认开)与 `YH_SCHCLASS_DEBUG_OFF=1` 逃生开关(CI 用,置 1 则全部空跑通过); - 新增 `GroupVar`(`YH_SCHCLASS_DEBUG_GROUP`,缺省 3)。 - - `SchClassLiveDebugTests.cs`:缺省 `classId`/`teaId` 由捏造值(1001/77)改为**从真实数据推导** —— - classId 取 `VClass.GetVClassList` 首条的 `ClassID`,teaId 取 `DbClass.GetSchteaList` 首条的 `UserTeaSerID`。 - 这一改让 `GetClaStulist` 0→28 条、`TeachInfo` 两个端点 0→2/4 条,证明此前是参数不对而非接口空。 - - `SchClassOpenApiOptions.cs`:XML 注释示例改为真实地址。 -- **真实联调结果(20/20 通过)**:SchoolInfo 5 / VSchClass 29·65 / UserInfo 51 / OAStu 89·64·28 / - SubInfo 4·18 / DbClass 19 / TeachInfo 2·4。 -- 仍为 0 条的端点(**非客户端问题,已核实**): - - `OATea.GetSchTeainfo` / `GetArrTeainfo` —— 后端返回 `[null,null,...]`(19 个 null), - `ResolveListAsync` 会跳过数组中的 null 元素 → 0 条。属后端 VO 转换失败的数据问题。 - - `FamCom.*` 两个 —— Java 源码里就是 `return new ArrayList<>()`,恒空。 -- 修复:`SchClassOpenApiFacadeTests.工厂_按配置创建HttpClient` 断言里的 path 被误写成 - `/base.yunhaixingtong.com`(应为 `/bschclass`),之前因 host 也是该串而偶然通过 → 改为 `/bschclass`。 -- 修复:`SchClassLiveDebugTests.cs:124` CS8604(nullable 警告)—— `teaId` 推导后类型仍是 `int?`, - 改用 `teaId.Value.ToString()`。 -- 踩坑:`curl -o /tmp/xxx` 在 Git Bash 下 exit 23 写文件失败(0B),改用工作区相对路径输出。 -- 验证:`dotnet build yh-platform-net.sln` 0 警告 0 错误;`dotnet test` 169 全通过。 - -## 配置文件读取:yh:open 共享节 + yh:schclass:open 独立节 - -- 真实配置结构(`tests/Yh.SchClass.OpenApi.Tests/appsettings.json` 的 `yh:open` 节,UserBase 与教务共享): - ```json - "open": { "BaseHost": "http://base.yunhaixingtong.com", - "BasePath": "/userbase", "SchClassPath": "/bschclass", - "AppKey": "100002", "AppSecret": "..." } - ``` - 关键:共享节里的 `BasePath` 是 **UserBase** 的路径,教务走 `SchClassPath`。 -- src 改动(`Yh.SchClass.OpenApi` 不再零依赖,引入 M.E.Options.ConfigurationExtensions + M.E.Http): - - `SchClassOpenApiOptions`:新增 `SharedSectionName="yh:open"`、`SchClassPathKey`、`HttpClientName` - 常量;新增 `FillFromSharedSection(Func)` —— 只回填未显式配置项 - (BaseHost ← yh:open:BaseHost;**BasePath ← yh:open:SchClassPath**(绝不回退到 yh:open:BasePath); - AppKey/AppSecret 同名回填),最后 NormalizeBasePath。 - - 新增 `SchClassOpenApiServiceCollectionExtensions.AddSchClassOpenApi(services, IConfiguration)`: - Bind 独立节 `yh:schclass:open` + PostConfigure 用共享节回填 → AddHttpClient(BaseAddress+超时+SocketsHttpHandler 连接超时)→ 注册门面 Singleton。 - - `SchClassOpenApi` 新增只读属性 `HttpBaseAddress`(底层 HttpClient.BaseAddress,测试/诊断用)。 -- 测试改动: - - `SchClassOpenApiConfigTests.cs`(6 个):SchClassPath 映射、`" /bschclass"` 空格/尾斜杠归一化、 - 未配 SchClassPath 时 BasePath 保持 null(**不会误用 /userbase**)、独立节优先+共享节回填、 - DI 解析门面+BaseAddress、独立节整节生效。 - - `SchClassLiveDebug`:地址来源优先级 = 环境变量 → appsettings.json(yh:schclass:open → yh:open:SchClassPath)→ 内置默认。 - 测试项目 csproj 加 `Microsoft.Extensions.Configuration(.Json)` 包 + appsettings.json CopyToOutputDirectory。 -- CPM 新增:`Microsoft.Extensions.Configuration(.Json)` 5.0.0/8.0.0。 -- 踩坑:测试项目显式引用 `Microsoft.Extensions.DependencyInjection` 8.0.0 与 M.E.Http 8.0.1 的传递依赖 - (要求 ≥8.0.1)冲突报 NU1605 包降级 → 删掉显式引用走传递依赖即可。 -- 验证:`dotnet build` 0 警告 0 错误;`dotnet test` 175 全通过(SchClass 63→69); - 联调一键跑通 20/20,联调目标确认取自 appsettings.json。 - -## 新增调试入口:Yh.SchClass.DebugTool(控制台) - -- 动机:联调此前只能 `dotnet test --filter` 跑,需要独立可执行入口。 -- 路径:`samples/Yh.SchClass.DebugTool/`(net8.0 控制台,IsPackable=false), - GUID `{0B1C9571-DCC3-436A-9DF6-02AB380E6CAA}`,sln 三处已注册(归入 `samples` 方案文件夹)。 -- `Program.cs`:读 appsettings.json(yh:schclass:open → yh:open 的 BaseHost+SchClassPath), - 命令行参数可覆盖:`--host --path --sch --class --tea --group --only=名称子串 --list`(列出 20 个端点名,零网络请求)。 - 缺省 ID 从真实数据推导(学校列表首条 → sch;班级首条 ClassID;教师首条 UserTeaSerID)。 - 输出逐端点 OK/FAIL + 条数,末尾汇总,失败退出码非 0(可接入脚本)。 -- 用法:`dotnet run --project samples/Yh.SchClass.DebugTool [-- --参数]`。 -- 踩坑:`SchClassOpenApi` 未实现 IDisposable,`using var` 报 CS1674,去掉 using 即可。 -- 验证:全方案 0 警告 0 错误;测试 175 全通过;实跑真实环境 20/20 通过。 - -## 测试工程添加 Main 运行调试入口(Yh.SchClass.OpenApi.Tests) - -- 需求:在 IDE 里直接 F5 / `dotnet run` 测试工程即可联调,不必经 vstest。 -- 改动: - - 测试 csproj:`Exe` + `false` - (关掉 Test SDK 自动生成的空 Main,避免 CS0017;`dotnet test` 不受影响,69 个用例照常发现)。 - - `DebugMain.cs`:`DebugMain.Main(string[])` 调试入口,支持 `--only=名称子串`、`--off`; - 调 `SchClassLiveDebug.RunAllAsync`,失败退出码非 0。 - - 重构:把"一键跑通 20 个端点"的执行逻辑从 `SchClassLiveDebugTests.联调_一键跑通全部端点` - 抽成 `SchClassLiveDebug.RunAllAsync(Action log, string? only = null)`,测试与 Main 共用。 -- 验证:`dotnet run --project tests/Yh.SchClass.OpenApi.Tests -- --only=VSchClass` → 3/3 通过; - `dotnet test` → 69 全通过;全方案 0 警告 0 错误。 - diff --git a/BasePlatformDotnet/Directory.Build.props b/BasePlatformDotnet/Directory.Build.props deleted file mode 100644 index 0fbb85b..0000000 --- a/BasePlatformDotnet/Directory.Build.props +++ /dev/null @@ -1,23 +0,0 @@ - - - - net5.0;net8.0 - latest - enable - disable - true - false - true - - 1.0.0 - $(YhPlatformNetVersion) - yunhai-platform - yunhai - yh-platform-net - https://git.yunhaixingtong.com/platform/yh-platform-net - https://git.yunhaixingtong.com/platform/yh-platform-net - MIT - true - - diff --git a/BasePlatformDotnet/Directory.Packages.props b/BasePlatformDotnet/Directory.Packages.props deleted file mode 100644 index dac3e2d..0000000 --- a/BasePlatformDotnet/Directory.Packages.props +++ /dev/null @@ -1,38 +0,0 @@ - - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/README.md b/BasePlatformDotnet/README.md deleted file mode 100644 index cc7b574..0000000 --- a/BasePlatformDotnet/README.md +++ /dev/null @@ -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 | diff --git a/BasePlatformDotnet/contract/fixtures/envelope-error.json b/BasePlatformDotnet/contract/fixtures/envelope-error.json deleted file mode 100644 index cf04d4c..0000000 --- a/BasePlatformDotnet/contract/fixtures/envelope-error.json +++ /dev/null @@ -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 } - } - ] -} diff --git a/BasePlatformDotnet/contract/fixtures/envelope-success.json b/BasePlatformDotnet/contract/fixtures/envelope-success.json deleted file mode 100644 index 28b922b..0000000 --- a/BasePlatformDotnet/contract/fixtures/envelope-success.json +++ /dev/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": "张三" } - } -} diff --git a/BasePlatformDotnet/contract/fixtures/sign-vectors.json b/BasePlatformDotnet/contract/fixtures/sign-vectors.json deleted file mode 100644 index cea7379..0000000 --- a/BasePlatformDotnet/contract/fixtures/sign-vectors.json +++ /dev/null @@ -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=" - } - ] -} \ No newline at end of file diff --git a/BasePlatformDotnet/contract/openapi-oapi.exported.json b/BasePlatformDotnet/contract/openapi-oapi.exported.json deleted file mode 100644 index 82230d5..0000000 --- a/BasePlatformDotnet/contract/openapi-oapi.exported.json +++ /dev/null @@ -1 +0,0 @@ -{"openapi":"3.0.1","info":{"title":"用户中心接口文档","description":"UserBase 用户中心 OpenAPI 文档(springdoc-openapi)","version":"0.0.1"},"servers":[{"url":"http://localhost:8060","description":"Generated server url"}],"paths":{"/oapi/user/transferToSchool":{"post":{"tags":["user-controller"],"operationId":"transferToSchool","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserTransferSchoolRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/password":{"post":{"tags":["user-controller"],"operationId":"password","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/moveToGroup":{"post":{"tags":["user-controller"],"operationId":"moveToGroup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserMoveRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/groupState":{"get":{"tags":["user-controller"],"operationId":"groupState_1","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"groupId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schoolId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"integer","format":"int32"}}}}}},"post":{"tags":["user-controller"],"operationId":"groupState","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserGroupStateRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/edit/{id}":{"post":{"tags":["user-controller"],"operationId":"edit","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/deleteUsers":{"post":{"tags":["user-controller"],"operationId":"deleteUsers","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SysUserGroupDeleteItem"}}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/delete/{id}/{groupId}/{schoolId}":{"post":{"tags":["user-controller"],"operationId":"delete","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"groupId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schoolId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK"}}}},"/oapi/user/changeStatus":{"post":{"tags":["user-controller"],"operationId":"changeStatus","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserChangeStatusRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/batchChangeStatus":{"post":{"tags":["user-controller"],"operationId":"batchChangeStatus","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchChangeStatusRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/user/batchAdd":{"post":{"tags":["user-controller"],"operationId":"batchAdd","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SysUserRequest"}}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaUser"}}}}}}}},"/oapi/user/add":{"post":{"tags":["user-controller"],"operationId":"add","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/identity/switch":{"post":{"tags":["identity-controller"],"operationId":"switchIdentity","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdentitySwitchOapiRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}},"/oapi/group/move/{schoolId}":{"post":{"tags":["group-controller"],"operationId":"move","parameters":[{"name":"schoolId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupMoveRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/oapi/group/edit/{id}":{"post":{"tags":["group-controller"],"operationId":"edit_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/group/delete/{id}/{schoolId}":{"post":{"tags":["group-controller"],"operationId":"delete_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schoolId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK"}}}},"/oapi/group/batchAdd":{"post":{"tags":["group-controller"],"operationId":"batchAdd_1","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupBatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}}}}}},"/oapi/group/add":{"post":{"tags":["group-controller"],"operationId":"add_1","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SysGroupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/user/roles":{"get":{"tags":["user-controller"],"operationId":"roles","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}}},"/oapi/user/modules":{"get":{"tags":["user-controller"],"operationId":"modules","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"schId","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaModule"}}}}}}}},"/oapi/user/byPhone":{"get":{"tags":["user-controller"],"operationId":"byPhone","parameters":[{"name":"phone","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/byLoginName":{"get":{"tags":["user-controller"],"operationId":"byLoginName","parameters":[{"name":"loginName","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/user/byId":{"get":{"tags":["user-controller"],"operationId":"byId","parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaUser"}}}}}}},"/oapi/identity/current":{"get":{"tags":["identity-controller"],"operationId":"current","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}},"/oapi/identity/available":{"get":{"tags":["identity-controller"],"operationId":"available","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaRoleInfo"}}}}}}}},"/oapi/group/tree":{"get":{"tags":["group-controller"],"operationId":"tree","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/group/list":{"get":{"tags":["group-controller"],"operationId":"list","parameters":[{"name":"ids","in":"query","required":true,"schema":{"type":"string"}},{"name":"withSub","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}}}}}},"/oapi/group/byId":{"get":{"tags":["group-controller"],"operationId":"byId_1","parameters":[{"name":"groupId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"withSub","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BoaGroup"}}}}}}},"/oapi/app/{appId}":{"get":{"tags":["app-controller"],"operationId":"byId_2","parameters":[{"name":"appId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApplicationInfoVo"}}}}}}},"/oapi/app/list":{"get":{"tags":["app-controller"],"operationId":"list_1","parameters":[{"name":"userId","in":"query","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"clientType","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoaAppType"}}}}}}}}},"components":{"schemas":{"SysUserTransferSchoolRequest":{"required":["fromSchoolId","groupId","schoolId","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"fromSchoolId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"}}},"BoaGroup":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"parentId":{"type":"integer","format":"int32"},"type":{"type":"integer","format":"int32"},"sort":{"type":"integer","format":"int32"},"children":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}},"BoaRoleInfo":{"type":"object","properties":{"roleId":{"type":"integer","format":"int32"},"roleName":{"type":"string"},"schId":{"type":"integer","format":"int32"},"schName":{"type":"string"},"childId":{"type":"integer","format":"int32"},"childName":{"type":"string"}}},"BoaUser":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"account":{"type":"string"},"name":{"type":"string"},"idcard":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"gender":{"type":"integer","format":"int32"},"profilePicture":{"type":"string"},"type":{"type":"integer","format":"int32"},"status":{"type":"integer","format":"int32"},"dept":{"$ref":"#/components/schemas/BoaGroup"},"school":{"$ref":"#/components/schemas/BoaGroup"},"bureau":{"$ref":"#/components/schemas/BoaGroup"},"groups":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}},"roleIds":{"type":"array","items":{"type":"integer","format":"int32"}},"currentRole":{"$ref":"#/components/schemas/BoaRoleInfo"},"schools":{"type":"array","items":{"$ref":"#/components/schemas/BoaGroup"}}}},"SysUserPasswordRequest":{"required":["newPassword","oldPassword","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"oldPassword":{"type":"string"},"newPassword":{"type":"string"}}},"SysUserMoveRequest":{"required":["groupId","schoolId","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"}}},"SysUserGroupStateRequest":{"required":["groupId","schoolId","state","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"},"state":{"type":"integer","format":"int32"}}},"SysUserRequest":{"required":["account","groupId","id","password"],"type":"object","properties":{"id":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"account":{"type":"string"},"password":{"type":"string"},"confirmPassword":{"type":"string"},"realName":{"type":"string"},"phone":{"type":"string"},"birthday":{"type":"string","format":"date"},"idcard":{"type":"string"},"email":{"type":"string"},"gender":{"type":"integer","format":"int32"},"profilePicture":{"type":"string"},"schoolId":{"type":"integer","format":"int32"},"roleIds":{"type":"array","items":{"type":"integer","format":"int32"}},"disabled":{"type":"integer","format":"int32"},"expired":{"type":"boolean"}}},"SysUserGroupDeleteItem":{"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"groupId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"}}},"SysUserChangeStatusRequest":{"required":["disabled","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"disabled":{"type":"boolean"}}},"BatchChangeStatusRequest":{"required":["disabled","userIds"],"type":"object","properties":{"userIds":{"type":"array","items":{"type":"integer","format":"int32"}},"disabled":{"type":"boolean"}}},"IdentitySwitchOapiRequest":{"required":["roleId","schoolId","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"roleId":{"type":"integer","format":"int32"},"schoolId":{"type":"integer","format":"int32"}}},"SysGroupMoveRequest":{"required":["moveId","pid"],"type":"object","properties":{"moveId":{"type":"integer","format":"int32"},"pid":{"type":"integer","format":"int32"}}},"SysGroupRequest":{"required":["name","parentId","type"],"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"parentId":{"type":"integer","format":"int32"},"type":{"type":"integer","format":"int32"},"sort":{"type":"integer","format":"int32"}}},"BatchItem":{"required":["name"],"type":"object","properties":{"name":{"type":"string"}}},"SysGroupBatchRequest":{"required":["items","pid","type"],"type":"object","properties":{"pid":{"type":"integer","format":"int32"},"type":{"type":"integer","format":"int32"},"items":{"type":"array","items":{"$ref":"#/components/schemas/BatchItem"}}}},"BoaModule":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"parentId":{"type":"integer","format":"int32"},"customId":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"level":{"type":"integer","format":"int32"},"sortNo":{"type":"integer","format":"int32"},"icon":{"type":"string"}}},"ApplicationInfoVo":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"categoryId":{"type":"integer","format":"int32"},"sortNo":{"type":"integer","format":"int32"},"desc":{"type":"string"},"appKey":{"type":"integer","format":"int32"},"appSecret":{"type":"string"},"clients":{"type":"array","items":{"type":"integer","format":"int32"}},"pcUrl":{"type":"string"},"pcIcon":{"type":"string"},"appUrl":{"type":"string"},"appIcon":{"type":"string"},"officialAccountUrl":{"type":"string"},"dingtalkUrl":{"type":"string"}}},"BoaApp":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"icon":{"type":"string"},"version":{"type":"string"},"type":{"type":"integer","format":"int32"},"webAddr":{"type":"string"},"adminAddress":{"type":"string"},"apiRoot":{"type":"string"},"appKey":{"type":"integer","format":"int32"},"orderNumber":{"type":"integer","format":"int32"},"createUser":{"type":"integer","format":"int32"},"createTime":{"type":"string","format":"date-time"},"mobiIcon":{"type":"string"},"clients":{"type":"integer","format":"int32"},"integMode":{"type":"integer","format":"int32"},"builtInTag":{"type":"integer","format":"int32"},"webPagePkgAddr":{"type":"string"},"webAddress":{"type":"string"},"wxgzhAddress":{"type":"string"},"dingAddress":{"type":"string"},"isPublic":{"type":"boolean"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/BoaModule"}}}},"BoaAppType":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"apps":{"type":"array","items":{"$ref":"#/components/schemas/BoaApp"}}}}}}} \ No newline at end of file diff --git a/BasePlatformDotnet/contract/openapi-oapi.yaml b/BasePlatformDotnet/contract/openapi-oapi.yaml deleted file mode 100644 index 2bc8022..0000000 --- a/BasePlatformDotnet/contract/openapi-oapi.yaml +++ /dev/null @@ -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: {} diff --git a/BasePlatformDotnet/contract/sign-protocol.md b/BasePlatformDotnet/contract/sign-protocol.md deleted file mode 100644 index 41099c1..0000000 --- a/BasePlatformDotnet/contract/sign-protocol.md +++ /dev/null @@ -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# 侧逐字节对拍。 diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs deleted file mode 100644 index 2790324..0000000 --- a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs +++ /dev/null @@ -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(); diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Properties/launchSettings.json b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Properties/launchSettings.json deleted file mode 100644 index a3c2df3..0000000 --- a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "Yh.Platform.Sample.Web": { - "commandName": "Project", - "launchBrowser": false, - "applicationUrl": "http://localhost:5080", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Yh.Platform.Sample.Web.csproj b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Yh.Platform.Sample.Web.csproj deleted file mode 100644 index 7985afd..0000000 --- a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Yh.Platform.Sample.Web.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - false - - - - - - - diff --git a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/appsettings.json b/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/appsettings.json deleted file mode 100644 index a7bd6da..0000000 --- a/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/appsettings.json +++ /dev/null @@ -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" - } - } -} diff --git a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Program.cs b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Program.cs deleted file mode 100644 index 73a02a2..0000000 --- a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Program.cs +++ /dev/null @@ -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> Call)>(); - -// 先探测学校列表,推导缺省参数 -try -{ - var schools = await api.SchInfo.AllListAsync(); - Console.WriteLine($"学校列表:{schools.Count} 所(前 3:{string.Join(" | ", schools.Take(3).Select(s => $"{s.SchName}[{s.SchSerId}]"))})"); - schResolved ??= schools.FirstOrDefault()?.SchSerId ?? "S001"; -} -catch (Exception e) -{ - Console.WriteLine($"!! 学校列表探测失败:{e.GetType().Name}: {e.Message}"); - Console.WriteLine("后端不可达时后续端点大概率全部失败,仍继续逐个执行以便核对。"); - schResolved ??= "S001"; -} - -if (!listOnly) -{ - var sch2 = schResolved; - string cid = classId ?? ""; - if (cid.Length == 0) - { - var cls = (await api.VClass.GetVClassListAsync(sch2)).FirstOrDefault(); - cid = cls != null && cls.TryGetValue("ClassID", out var c) ? c.ToString() ?? "1001" : "1001"; - } - if (teaId == 0) - { - var tea = (await api.DbClass.GetSchteaListAsync(group)).FirstOrDefault(); - teaId = tea != null && tea.TryGetValue("UserTeaSerID", out var d) && int.TryParse(d.ToString(), out var p) ? p : 77; - } - - Console.WriteLine($"参数:sch={sch2} classId={cid} teaId={teaId} group={group}"); - - cases.Add(("DbClass.GetSchteaList", async () => (await api.DbClass.GetSchteaListAsync(group)).Count)); - cases.Add(("VSchClass.GetVClassList", async () => (await api.VClass.GetVClassListAsync(sch2)).Count)); - cases.Add(("VSchClass.GetSchArrVClassListAll", async () => (await api.VClass.GetSchArrVClassListAllAsync()).Count)); - cases.Add(("VSchClass.GetSchArrVClassListPost", async () => (await api.VClass.GetSchArrVClassListPostAsync(sch2)).Count)); - cases.Add(("Schoolen.GetSchLenLevellist", async () => (await api.Schooling.GetSchLenLevelListAsync(sch2)).Count)); - cases.Add(("Schoolen.GetSchLenlist", async () => (await api.Schooling.GetSchLenListAsync(sch2)).Count)); - cases.Add(("UserInfo.GetUserList", async () => (await api.UserInfo.GetUserListAsync(sch2)).Count)); - cases.Add(("SchInfo.AllList", async () => (await api.SchInfo.AllListAsync()).Count)); - cases.Add(("SchInfo.GetSchlist", async () => (await api.SchInfo.GetSchListAsync(null)).Count)); - cases.Add(("OATea.GetSchTeainfo", async () => (await api.TeaInfo.GetSchTeaInfoAsync(sch2, null)).Count)); - cases.Add(("OATea.GetArrTeainfo", async () => (await api.TeaInfo.GetArrTeaInfoAsync(teaId.ToString())).Count)); - cases.Add(("OAStu.GetAllStulist", async () => (await api.StuInfo.GetAllStuListAsync()).Count)); - cases.Add(("OAStu.GetSchStulist", async () => (await api.StuInfo.GetSchStuListAsync(sch2, null)).Count)); - cases.Add(("OAStu.GetClaStulist", async () => (await api.StuInfo.GetClaStuListAsync(cid, null)).Count)); - cases.Add(("TeachInfo.GetSchTeachlist", async () => (await api.TeachInfo.GetSchTeachListAsync(sch2, teaId)).Count)); - cases.Add(("TeachInfo.GetSchClaTeachlist", async () => (await api.TeachInfo.GetSchClaTeachListAsync(cid)).Count)); - cases.Add(("SubInfo.GetSchTeachSublist", async () => (await api.SubInfo.GetSchTeachSubListAsync(sch2)).Count)); - cases.Add(("SubInfo.GetSchSublist", async () => (await api.SubInfo.GetSchSubListAsync(sch2, null, null)).Count)); - cases.Add(("FamCom.GetStuUserFamIDList", async () => (await api.FamilyCompat.GetStuUserFamIdListAsync("1,2")).Count)); - cases.Add(("FamCom.GetAllStuFamMem", async () => (await api.FamilyCompat.GetAllStuFamMemAsync("1,2")).Count)); -} - -var failures = new List(); -if (only != null) -{ - cases = cases.Where(c => c.Name.Contains(only, StringComparison.OrdinalIgnoreCase)).ToList(); - if (cases.Count == 0) - { - Console.WriteLine($"--only={only} 未匹配任何端点(用 --list 查看全部名称)"); - return 2; - } -} - -foreach (var (name, call) in cases) -{ - try - { - var count = await call(); - Console.WriteLine($" OK {name} -> {count} 条"); - } - catch (Exception e) - { - failures.Add(name); - Console.WriteLine($" FAIL {name} -> {e.GetType().Name}: {e.Message}"); - } -} - -Console.WriteLine($"汇总:{cases.Count - failures.Count}/{cases.Count} 通过"); -return failures.Count == 0 ? 0 : 1; diff --git a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Yh.SchClass.DebugTool.csproj b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Yh.SchClass.DebugTool.csproj deleted file mode 100644 index 1c536e5..0000000 --- a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/Yh.SchClass.DebugTool.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net8.0 - enable - enable - false - Yh.SchClass.DebugTool - - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/appsettings.json b/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/appsettings.json deleted file mode 100644 index 4a6f4bd..0000000 --- a/BasePlatformDotnet/samples/Yh.SchClass.DebugTool/appsettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "yh": { - "open": { - "Enabled": true, - "BaseHost": "http://base.yunhaixingtong.com", - "BasePath": "/userbase", - "SchClassPath": "/bschclass", - "AppKey": "100002", - "AppSecret": "2314a3b158b24a838b2b9b69fd3db118" - } - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ApiEnvelope.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ApiEnvelope.cs deleted file mode 100644 index 7520729..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ApiEnvelope.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 通用接口响应包装,对应大多数后端接口返回的 code/msg/data 结构。 -/// 纯数据载体,双序列化器兼容(Newtonsoft 大小写不敏感 / System.Text.Json camelCase)。 -/// -public class ApiEnvelope -{ - /// 业务状态码,0 表示成功 - public int Code { get; set; } - - /// 提示信息 - public string? Msg { get; set; } - - /// 业务数据 - public T? Data { get; set; } - - /// 是否成功响应(Code == 0) - public bool IsSuccess => Code == 0; -} - -/// 非泛型信封(data 为通用对象/数组),用于信封感知解析前的判别。 -public class ApiEnvelope : ApiEnvelope -{ -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaApp.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaApp.cs deleted file mode 100644 index 8d7ab00..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaApp.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 应用富信息载体。替代旧 bsch BoaApp。 -/// 不含 appSecret(避免 oapi 泄露密钥);字段命名保留旧 bsch 风格。 -/// -public class BoaApp -{ - /// 应用ID - public int? Id { get; set; } - - /// 应用名称 - public string? Name { get; set; } - - /// 图标URL - public string? Icon { get; set; } - - /// 版本号 - public string? Version { get; set; } - - /// 应用类型ID - public int? Type { get; set; } - - /// PC端访问地址 - public string? WebAddr { get; set; } - - /// 管理端地址 - public string? AdminAddress { get; set; } - - /// API根路径 - public string? ApiRoot { get; set; } - - /// appKey(不含 appSecret,避免泄露密钥) - public int? AppKey { get; set; } - - /// 排序号 - public int? OrderNumber { get; set; } - - /// 创建人ID - public int? CreateUser { get; set; } - - /// 创建时间 - public DateTime? CreateTime { get; set; } - - /// 移动端图标URL - public string? MobiIcon { get; set; } - - /// 支持的客户端类型(位掩码叠加值,对齐 UserBase ApplicationInfo.clients) - public int? Clients { get; set; } - - /// 集成方式 - public int? IntegMode { get; set; } - - /// 内置标记:1=内置 0=非内置 - public int? BuiltInTag { get; set; } = 0; - - /// 网页包地址 - public string? WebPagePkgAddr { get; set; } - - /// 网页访问地址 - public string? WebAddress { get; set; } - - /// 微信公众号地址 - public string? WxgzhAddress { get; set; } - - /// 钉钉应用地址 - public string? DingAddress { get; set; } - - /// 是否公开应用 - public bool? IsPublic { get; set; } - - /// 模块列表 - public List? Modules { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaAppType.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaAppType.cs deleted file mode 100644 index 67aa9b5..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaAppType.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 应用类型 + 该类型下挂的应用列表。平台级富信息载体,替代旧 bsch BoaAppType。 -/// -public class BoaAppType -{ - /// 应用类型ID - public int? Id { get; set; } - - /// 应用类型名称 - public string? Name { get; set; } - - /// 该类型下的应用列表 - public List? Apps { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaGroup.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaGroup.cs deleted file mode 100644 index 1476ac0..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaGroup.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 组织机构单元(部门 / 学校 / 教育局等)。对应旧 bsch 的 com.bestsch.openapi.BoaGroup。 -/// -public class BoaGroup -{ - public int? Id { get; set; } - - public string? Name { get; set; } - - public int? ParentId { get; set; } - - /// 节点类型:1=单位/教育局 2=学校 3=部门 等,由业务自定义 - public int? Type { get; set; } - - /// 排序号(同层级升序;可空) - public int? Sort { get; set; } - - /// 子节点列表;仅树接口展开时填充,未展开时为 null - public List? Children { get; set; } - - public BoaGroup() - { - } - - public BoaGroup(int? id, string? name) - { - Id = id; - Name = name; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaModule.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaModule.cs deleted file mode 100644 index 2074f0c..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaModule.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// 功能模块载体。与 Java utils.model.BoaModule 一比一。 -public class BoaModule -{ - public int? Id { get; set; } - - public int? ParentId { get; set; } - - public string? CustomId { get; set; } - - public string? Name { get; set; } - - public string? Path { get; set; } - - public int? Level { get; set; } - - public int? SortNo { get; set; } - - public string? Icon { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaRoleInfo.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaRoleInfo.cs deleted file mode 100644 index 8ea0286..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaRoleInfo.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 用户角色身份信息载体。与 Java utils.model.BoaRoleInfo 一比一: -/// 用户在某校某子节点的激活角色快照,由 UserBase oapi 端组装。 -/// -public class BoaRoleInfo -{ - /// 角色ID - public int? RoleId { get; set; } - - /// 角色名称 - public string? RoleName { get; set; } - - /// 学校ID(组ID) - public int? SchId { get; set; } - - /// 学校名称 - public string? SchName { get; set; } - - /// 子节点ID(班级/学段等) - public int? ChildId { get; set; } - - /// 子节点名称 - public string? ChildName { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaUser.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaUser.cs deleted file mode 100644 index fa209ba..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/BoaUser.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 已登录用户的富信息载体(含部门/学校/角色)。与 Java utils.model.BoaUser 字段一比一, -/// 替代旧 bsch 的 com.bestsch.openapi.BoaUser。 -/// 纯 POCO 零注解:Newtonsoft(大小写不敏感)与 System.Text.Json(camelCase)双兼容。 -/// -public class BoaUser -{ - /// 用户主键 - public int? Id { get; set; } - - /// 登录账号 - public string? Account { get; set; } - - /// 姓名 - public string? Name { get; set; } - - public string? Idcard { get; set; } - - public string? Phone { get; set; } - - public string? Email { get; set; } - - public int? Gender { get; set; } - - public string? ProfilePicture { get; set; } - - /// 用户类型:1=教师 2=家长 3=学生 等,由业务自定义 - public int? Type { get; set; } - - /// 状态:1=正常 0=禁用 - public int? Status { get; set; } - - /// 所属部门 - public BoaGroup? Dept { get; set; } - - /// - /// 当前激活身份的学校(来自 sys_user_current_role.school_id,数值即学校组 sys_group.id)。 - /// 由 UserBase 侧填充:优先取用户当前切换的学校,缺省兜底为主身份所在学校; - /// 区域组/学段组/根直属用户(无学校归属)时为 null。 - /// 与 平行:本字段表示"当前",Schools 表示"全部可选"。 - /// - public BoaGroup? School { get; set; } - - /// 所属教育局/主管单位 - public BoaGroup? Bureau { get; set; } - - /// 多级组织路径(含父级) - public List? Groups { get; set; } - - /// 角色 id 列表 - public List? RoleIds { get; set; } - - /// - /// 当前激活身份(角色 × 学校)。来自 sys_user_current_role(未切换时兜底 sys_user_role_rel.is_main=1 主身份)。 - /// 由 UserBase 侧填充;用户无任何身份时为 null。子应用做"当前身份/当前学校"判断时优先读本字段。 - /// - public BoaRoleInfo? CurrentRole { get; set; } - - /// - /// 用户所有可选学校列表(来自 sys_user_role_rel 按 user_id 去重的 school_id,不含 school_id=0)。 - /// 每个元素即一所学校(type=SCHOOL_GROUP 的 sys_group),本列表即"切换身份"页的可选学校集合。 - /// 区域组/学段组/根直属用户(无学校归属)此列表为空。 - /// 与 平行:School=当前激活学校,Schools=全部可选学校。 - /// - public List? Schools { get; set; } - - public BoaUser() - { - } - - public BoaUser(int? id, string? account, string? name) - { - Id = id; - Account = account; - Name = name; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ClientEnum.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ClientEnum.cs deleted file mode 100644 index 5a3be6e..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ClientEnum.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 客户端来源(对齐 Java ClientEnum)。key 为 2 的幂,支持位运算叠加。 -/// -[Flags] -public enum ClientEnum -{ - PcBrowser = 1, - MobileApp = 2, - SystemModule = 4, - WechatOfficial = 8, - WorkWechat = 16, - DingTalk = 32, - StudentDevice = 64, -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/DException.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/DException.cs deleted file mode 100644 index 96688ed..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/DException.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 平台业务异常(对齐 Java utils.exception.DException)。 -/// 携带 code/EnvelopeData/msg/rawException/httpError;/oapi 业务失败(HTTP 200 + code≠0 信封)由 OpenApi 包抛出本异常。 -/// 注意:信封 data 属性命名为 而非 Data,避免遮蔽 (IDictionary 契约)。 -/// -public class DException : Exception -{ - public IErrorCode? Code { get; } - - /// 信封 data 原样携带(通常为 System.Text.Json.JsonElement);对应 Java 侧 data 字段。 - public object? EnvelopeData { get; } - - public Exception? RawException { get; } - - public object? HttpError { get; } - - public DException(IErrorCode? code) - : base(ResolveMessage(code, null)) - { - Code = code; - } - - /// 便捷构造:默认 code = Custom(5),message 为纯 msg。 - public DException(string message) - : this(ErrorCode.Custom, message) - { - } - - public DException(IErrorCode? code, string? message) - : base(ResolveMessage(code, message)) - { - Code = code; - } - - public DException(IErrorCode? code, object? data) - : base(ResolveMessage(code, null)) - { - Code = code; - EnvelopeData = data; - } - - public DException(IErrorCode? code, Exception? rawException) - : base(ResolveMessage(code, null), rawException) - { - Code = code; - RawException = rawException; - } - - public DException(IErrorCode? code, object? data, Exception? rawException) - : base(ResolveMessage(code, null), rawException) - { - Code = code; - EnvelopeData = data; - RawException = rawException; - } - - public DException(IErrorCode? code, string? message, object? data) - : base(ResolveMessage(code, message)) - { - Code = code; - EnvelopeData = data; - } - - public DException(IErrorCode? code, string? message, object? data, Exception? rawException) - : base(ResolveMessage(code, message), rawException) - { - Code = code; - EnvelopeData = data; - RawException = rawException; - } - - public DException(IErrorCode? code, string? message, object? data, Exception? rawException, object? httpError) - : base(ResolveMessage(code, message), rawException) - { - Code = code; - EnvelopeData = data; - RawException = rawException; - HttpError = httpError; - } - - private static string? ResolveMessage(IErrorCode? code, string? message) - { - if (message != null) - { - return message; - } - return code?.Msg; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ErrorCode.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/ErrorCode.cs deleted file mode 100644 index afc1052..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/ErrorCode.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// -/// 平台已知错误码(对齐 Java ErrorCode 枚举的常量值与默认文案)。 -/// -public static class ErrorCode -{ - public static readonly IErrorCode Success = new SimpleErrorCode(0, ""); - public static readonly IErrorCode Error = new SimpleErrorCode(1, "未知错误"); - public static readonly IErrorCode ParamError = new SimpleErrorCode(2, "错误的请求参数"); - public static readonly IErrorCode Forbidden = new SimpleErrorCode(3, "非法操作,没有权限"); - public static readonly IErrorCode Unauthorized = new SimpleErrorCode(4, "登录已经过期,请重新登录"); - public static readonly IErrorCode Custom = new SimpleErrorCode(5, ""); - public static readonly IErrorCode InternalServerError = new SimpleErrorCode(6, "内部服务错误"); - public static readonly IErrorCode HttpMethodNotAllowed = new SimpleErrorCode(405, "HTTP方法不支持"); - - private sealed record SimpleErrorCode(int Code, string Msg) : IErrorCode; -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/GlobalUsings.cs deleted file mode 100644 index 8868ea6..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/GlobalUsings.cs +++ /dev/null @@ -1,3 +0,0 @@ -// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛) -global using System; -global using System.Collections.Generic; diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/IErrorCode.cs b/BasePlatformDotnet/src/Yh.Platform.Abstractions/IErrorCode.cs deleted file mode 100644 index b8f1e6d..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/IErrorCode.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Yh.Platform.Abstractions; - -/// 错误码抽象(对齐 Java IErrorCode)。 -public interface IErrorCode -{ - /// 业务状态码 - int Code { get; } - - /// 默认提示信息 - string Msg { get; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Abstractions/Yh.Platform.Abstractions.csproj b/BasePlatformDotnet/src/Yh.Platform.Abstractions/Yh.Platform.Abstractions.csproj deleted file mode 100644 index 1b52845..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Abstractions/Yh.Platform.Abstractions.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - Yh.Platform.Abstractions - yunhai 平台模型与信封(对应 Java yh-utils-core):BoaUser/BoaRoleInfo 等纯 POCO、ApiEnvelope、DException、错误码抽象 - 平台模型与信封契约,双序列化器(Newtonsoft/System.Text.Json)兼容 - - - diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthOptions.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthOptions.cs deleted file mode 100644 index b1e5b4a..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthOptions.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Yh.Platform.AppAuth; - -/// -/// 应用签名鉴权配置(对齐 Java yh.app-auth.* 配置节)。 -/// -public class AppAuthOptions -{ - public const string SectionName = "yh:app-auth"; - - /// 是否启用(默认 true) - public bool Enabled { get; set; } = true; - - /// 请求有效期(毫秒),默认 5 分钟 - public long ExpireMs { get; set; } = SignUtil.DefaultExpireMs; - - /// 本应用的 appKey(客户端出站签名用) - public string? AppKey { get; set; } - - /// 本应用的 appSecret(客户端出站签名用) - public string? AppSecret { get; set; } - - /// 服务端受信应用表(appKey → appSecret),用于固定密钥验签场景 - public Dictionary TrustedApps { get; set; } = new(StringComparer.Ordinal); - - /// Redis 连接串;为空时密钥解析链跳过 Redis 层、防重放退化为进程内实现 - public string? RedisConnectionString { get; set; } - - /// 验签密钥在 Redis 中的键前缀(键空间与 Java 约定一致:sign:app:{appKey}) - public string SignKeyRedisKeyPrefix { get; set; } = "sign:app:"; - - /// 是否启用 UserBase 密钥兜底(GET {userbase}/oapi/internal/sign-key/{appKey}) - public bool UserBaseSignKeyFallbackEnabled { get; set; } - - /// UserBase 基址(兜底取密钥用),如 http://userbase 或 http://host/userbase - public string? UserBaseBaseUrl { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthServiceCollectionExtensions.cs deleted file mode 100644 index 60f644d..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppAuthServiceCollectionExtensions.cs +++ /dev/null @@ -1,135 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace Yh.Platform.AppAuth; - -/// 配置型密钥来源(对齐 Java PropertyAppSecretProvider):从 TrustedApps 映射取密钥。 -public sealed class PropertyAppSecretProvider : IAppSecretProvider -{ - private readonly AppAuthOptions _options; - - public PropertyAppSecretProvider(AppAuthOptions options) - { - _options = options; - } - - public Task GetSecretAsync(string appKey, CancellationToken ct = default) - => Task.FromResult(_options.TrustedApps.TryGetValue(appKey, out var secret) ? secret : null); -} - -/// Redis 密钥来源(对齐 Java RedisAppSecretProvider):委托 三层解析链。 -public sealed class SignKeyResolverAppSecretProvider : IAppSecretProvider -{ - private readonly SignKeyResolver _resolver; - - public SignKeyResolverAppSecretProvider(SignKeyResolver resolver) - { - _resolver = resolver; - } - - public Task GetSecretAsync(string appKey, CancellationToken ct = default) - => _resolver.GetSecretAsync(appKey, ct); -} - -/// 服务端验签器:请求级签名 + 过期 + 防重放一次性校验入口。 -public class AppAuthVerifier -{ - private readonly IAppSecretProvider _secretProvider; - private readonly IReplayGuard _replayGuard; - private readonly AppAuthOptions _options; - - public AppAuthVerifier(IAppSecretProvider secretProvider, IReplayGuard replayGuard, IOptions options) - { - _secretProvider = secretProvider; - _replayGuard = replayGuard; - _options = options.Value; - } - - /// - /// 校验签名三件套。 - /// - /// X-App-Key - /// X-Timestamp - /// X-Signature - /// HTTP 方法 - /// canonical target(path[?sortedQuery],见 ) - /// 可选防重放 nonce;提供时参与防重放判定 - /// 取消令牌 - /// 校验通过返回 true;签名错/过期/重放返回 false - public async Task VerifyAsync(string appKey, string timestamp, string signature, - string method, string canonicalTarget, string? nonce = null, CancellationToken ct = default) - { - var secret = await _secretProvider.GetSecretAsync(appKey, ct); - if (secret == null) - { - return false; // 未知 app 直接拒绝 - } - if (!SignUtil.VerifyRequestWithExpire(method, canonicalTarget, timestamp, secret, signature, _options.ExpireMs)) - { - return false; - } - if (nonce == null) - { - return true; - } - return await _replayGuard.TryBeginAsync(appKey, timestamp, nonce, ct); - } -} - -public static class AppAuthServiceCollectionExtensions -{ - /// - /// 注册应用签名鉴权(客户端 AppSignHandler + 服务端 SignKeyResolver/ReplayGuard/AppAuthVerifier)。 - /// - public static IServiceCollection AddYhAppAuth(this IServiceCollection services, IConfiguration configuration) - { - services.AddOptions() - .Bind(configuration.GetSection(AppAuthOptions.SectionName)) - .PostConfigure(o => - { - if (o.ExpireMs <= 0) - { - o.ExpireMs = SignUtil.DefaultExpireMs; - } - }); - services.AddMemoryCache(); - - // Redis(可选):连接串为空时整条 Redis 链路自动降级 - services.AddSingleton>(sp => - { - var options = sp.GetRequiredService>().Value; - if (string.IsNullOrEmpty(options.RedisConnectionString)) - { - return () => null; - } - var multiplexer = ConnectionMultiplexer.Connect(options.RedisConnectionString); - return () => multiplexer.GetDatabase(); - }); - - // 密钥兜底:仅当配置开启时直调 UserBase internal 端点 - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - if (!options.UserBaseSignKeyFallbackEnabled || string.IsNullOrEmpty(options.UserBaseBaseUrl)) - { - return NoOpSignKeyFallback.Instance; - } - var factory = sp.GetRequiredService(); - return new UserBaseSignKeyFallback(factory.CreateClient("yh-appauth-signkey"), options.UserBaseBaseUrl); - }); - - services.AddSingleton(); - services.AddSingleton(sp => new SignKeyResolverAppSecretProvider(sp.GetRequiredService())); - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - return string.IsNullOrEmpty(options.RedisConnectionString) - ? new MemoryReplayGuard(options.ExpireMs) - : new RedisReplayGuard(sp.GetRequiredService>(), options.ExpireMs); - }); - services.AddSingleton(); - return services; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppSignHandler.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppSignHandler.cs deleted file mode 100644 index c202e64..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/AppSignHandler.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Net.Http; - -namespace Yh.Platform.AppAuth; - -/// -/// 客户端出站签名 (对齐 Java AppSignInterceptor): -/// 为请求自动附加 X-App-Key / X-Timestamp / X-Signature 三头; -/// 签名为请求级(canonical target 只取 path + 排序 query,Base64 输出)。 -/// 签名前剥离 (对齐 Java stripBasePath):canonical 口径与服务端验签统一为 /oapi/**, -/// basePath 须与 UserBase 部署 context-path 一致(如 /userbase)。 -/// -public class AppSignHandler : DelegatingHandler -{ - public const string HeaderAppKey = "X-App-Key"; - public const string HeaderTimestamp = "X-Timestamp"; - public const string HeaderSignature = "X-Signature"; - - private readonly string _appKey; - private readonly string _appSecret; - private readonly string? _basePath; - - public AppSignHandler(string appKey, string appSecret) - : this(appKey, appSecret, null, null) - { - } - - /// - /// 构造签名处理器。innerHandler 为 null 时 InnerHandler 保持 null(DelegatingHandler 默认), - /// 由 HttpClientFactory 注入 primary handler(AddHttpMessageHandler 场景要求 InnerHandler 初始为 null); - /// 独立使用时显式传入内层 handler(见 BaseOpenApi / 单元测试)。 - /// - public AppSignHandler(string appKey, string appSecret, string? basePath, HttpMessageHandler? innerHandler = null) - : base() - { - _appKey = appKey ?? throw new ArgumentNullException(nameof(appKey)); - _appSecret = appSecret ?? throw new ArgumentNullException(nameof(appSecret)); - _basePath = NormalizeBasePath(basePath); - if (innerHandler != null) - { - InnerHandler = innerHandler; - } - } - - /// 归一化 basePath:trim、去尾斜杠;null/空白返回 null(挂根路径,不剥离)。 - internal static string? NormalizeBasePath(string? basePath) - { - if (string.IsNullOrWhiteSpace(basePath)) - { - return null; - } - var trimmed = basePath.Trim().TrimEnd('/'); - if (trimmed.Length == 0) - { - return null; - } - return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - if (request.RequestUri is { IsAbsoluteUri: true } uri) - { - // 时钟源统一走 SignUtil.NowMillis(与 SignUtil 共用同一可注入时钟,测试可固定 ts 与基线逐字节对拍) - var ts = SignUtil.NowMillis().ToString(); - var target = SignCanonical.BuildTarget(uri, _basePath); - var sign = SignUtil.SignRequest(request.Method.Method, target, ts, _appSecret); - var headers = request.Headers; - headers.Remove(HeaderAppKey); - headers.Remove(HeaderTimestamp); - headers.Remove(HeaderSignature); - headers.TryAddWithoutValidation(HeaderAppKey, _appKey); - headers.TryAddWithoutValidation(HeaderTimestamp, ts); - headers.TryAddWithoutValidation(HeaderSignature, sign); - } - return base.SendAsync(request, cancellationToken); - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/GlobalUsings.cs deleted file mode 100644 index dd34d99..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/GlobalUsings.cs +++ /dev/null @@ -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; diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/ReplayGuard.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/ReplayGuard.cs deleted file mode 100644 index fdbb05a..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/ReplayGuard.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections.Concurrent; -using StackExchange.Redis; - -namespace Yh.Platform.AppAuth; - -/// -/// 防重放守卫:同一 (appKey, timestamp, nonce) 只允许提交一次,有效期同请求窗口(默认 5 分钟)。 -/// -public interface IReplayGuard -{ - /// true = 首次提交放行;false = 重复提交拒绝。 - Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default); -} - -/// 进程内防重放(单实例部署或 Redis 未配置时使用)。 -public sealed class MemoryReplayGuard : IReplayGuard -{ - private readonly ConcurrentDictionary _seen = new(StringComparer.Ordinal); - private readonly long _windowMs; - private DateTimeOffset _lastSweep = DateTimeOffset.MinValue; - private readonly object _sweepLock = new(); - - public MemoryReplayGuard(long windowMs = SignUtil.DefaultExpireMs) - { - _windowMs = windowMs; - } - - public Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default) - { - Sweep(); - var key = appKey + ":" + timestamp + ":" + nonce; - var now = DateTimeOffset.UtcNow; - var expiry = now.AddMilliseconds(_windowMs); - // 原子插入:首次出现即放行;已存在且未过期 → 拒绝;已过期 → 允许覆盖重放窗口 - if (!_seen.TryAdd(key, expiry)) - { - if (_seen.TryGetValue(key, out var existing) && existing > now) - { - return Task.FromResult(false); - } - _seen[key] = expiry; - } - return Task.FromResult(true); - } - - private void Sweep() - { - var now = DateTimeOffset.UtcNow; - lock (_sweepLock) - { - if (now - _lastSweep < TimeSpan.FromSeconds(30)) - { - return; - } - _lastSweep = now; - } - foreach (var kv in _seen) - { - if (kv.Value <= now) - { - _seen.TryRemove(kv.Key, out _); - } - } - } -} - -/// -/// Redis 防重放:SETNX 键 sign:replay:{appKey}:{timestamp}:{nonce},TTL = 请求窗口。 -/// -public sealed class RedisReplayGuard : IReplayGuard -{ - private readonly Func _redisDatabaseFactory; - private readonly long _windowMs; - - public RedisReplayGuard(Func redisDatabaseFactory, long windowMs = SignUtil.DefaultExpireMs) - { - _redisDatabaseFactory = redisDatabaseFactory; - _windowMs = windowMs; - } - - public async Task TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default) - { - var redis = _redisDatabaseFactory(); - if (redis == null) - { - return true; // Redis 未配置时不拦截 - } - var key = "sign:replay:" + appKey + ":" + timestamp + ":" + nonce; - return await redis.StringSetAsync(key, "1", TimeSpan.FromMilliseconds(_windowMs), When.NotExists); - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignAppKeyPayload.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignAppKeyPayload.cs deleted file mode 100644 index 050d36e..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignAppKeyPayload.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Yh.Platform.AppAuth; - -/// -/// 应用密钥载荷(对齐 Java SignAppKeyPayload,与 UserBase SignKeyInternalController 返回契约一致)。 -/// -public class SignAppKeyPayload -{ - public string? AppKey { get; set; } - - public string? Secret { get; set; } - - public bool Enabled { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignCanonical.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignCanonical.cs deleted file mode 100644 index ff91ab3..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignCanonical.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Net; - -namespace Yh.Platform.AppAuth; - -/// -/// 请求签名规范化工具(调用方签名与服务端验签共用)。 -/// canonical 的 target 部分只取 path(不含 host/port/context-path)+ 排序后的 query: -/// METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp。 -/// 只编码 path 可让服务发现/负载均衡解析前后两端 host 不一致时仍天然对齐。 -/// -public static class SignCanonical -{ - /// 拼接 target:path +(有 query 时 ?sortedQuery)。 - public static string BuildTarget(string path, string sortedQuery) - => string.IsNullOrEmpty(sortedQuery) ? path : path + "?" + sortedQuery; - - /// 从 构建 canonical target(AbsolutePath + 排序 query),不剥离 basePath。 - public static string BuildTarget(Uri uri) - => BuildTarget(uri, null); - - /// - /// 从 构建 canonical target,并在签名前剥离部署 basePath - /// (对齐 Java AppSignInterceptor.stripBasePath:客户端配置 base-path 与部署 context-path 一致时, - /// 签名口径与服务端 AppAuthFilter 剥 request.getContextPath() 后严格对齐,canonical 统一为 /oapi/**)。 - /// - public static string BuildTarget(Uri uri, string? basePath) - { - var rawQuery = uri.Query; - if (rawQuery.StartsWith('?')) - { - rawQuery = rawQuery.Substring(1); - } - var path = string.IsNullOrEmpty(basePath) ? uri.AbsolutePath : StripBasePath(uri.AbsolutePath, basePath); - return BuildTarget(path, SortRawQuery(rawQuery)); - } - - /// - /// 从请求路径剥离部署基础路径(对齐 Java AppSignInterceptor.stripBasePath,含尾斜杠归一化边界): - /// path 不以 basePath 开头时原样返回;剥离后为空或丢失前导斜杠时补 "/"。 - /// - public static string StripBasePath(string path, string? basePath) - { - if (string.IsNullOrEmpty(basePath)) - { - return path; - } - var bp = basePath.EndsWith('/') ? basePath : basePath + "/"; - var p = path.EndsWith('/') ? path : path + "/"; - if (!p.StartsWith(bp, StringComparison.Ordinal)) - { - return path; - } - var stripped = path.Substring(basePath.Length); - if (stripped.Length == 0 || stripped[0] != '/') - { - return "/" + stripped; - } - return stripped; - } - - /// - /// 从原始 query 串(a=1&b=2,可能已编码)解码并整体排序,输出 k=v&k2=v2。 - /// 与 Java sortRawQuery 一致:先拆 k/v、各自 URL 解码、按 "k=v" 整串排序后拼接。 - /// - public static string SortRawQuery(string? rawQuery) - { - if (string.IsNullOrEmpty(rawQuery)) - { - return ""; - } - var pairs = new List(); - foreach (var part in rawQuery.Split('&')) - { - if (part.Length == 0) - { - continue; - } - var idx = part.IndexOf('='); - var k = idx < 0 ? part : part.Substring(0, idx); - var v = idx < 0 ? "" : part.Substring(idx + 1); - pairs.Add(UrlDecode(k) + "=" + UrlDecode(v)); - } - pairs.Sort(StringComparer.Ordinal); - return string.Join("&", pairs); - } - - /// - /// 从多值参数表排序,输出 k=v&k2=v2(键排序、值也排序,保证顺序无关)。 - /// 对齐 Java sortMultiValue。 - /// - public static string SortMultiValue(IEnumerable>> multiValueParams) - { - if (multiValueParams == null) - { - return ""; - } - var pairs = new List(); - foreach (var (key, values) in multiValueParams) - { - var vals = (values ?? Enumerable.Empty()) - .Select(v => v ?? "") - .OrderBy(v => v, StringComparer.Ordinal) - .ToList(); - foreach (var v in vals) - { - pairs.Add(key + "=" + v); - } - } - pairs.Sort(StringComparer.Ordinal); - return string.Join("&", pairs); - } - - private static string UrlDecode(string s) - { - try - { - return Uri.UnescapeDataString(s.Replace("+", " ")); - } - catch (UriFormatException) - { - // 非法百分号编码(如 ?a=100%):原样参与排序即可,服务端不崩溃(验签必失败 → 401,而非 500) - return s; - } - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyFallback.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyFallback.cs deleted file mode 100644 index 0f2b50e..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyFallback.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Yh.Platform.AppAuth; - -/// 服务端验签密钥来源(对齐 Java AppSecretProvider):未知应用返回 null。 -public interface IAppSecretProvider -{ - Task GetSecretAsync(string appKey, CancellationToken ct = default); -} - -/// 签名密钥兜底来源(对齐 Java SignKeyFallback)。 -public interface ISignKeyFallback -{ - Task ResolveAsync(string appKey, CancellationToken ct = default); -} - -/// 空实现兜底。 -public sealed class NoOpSignKeyFallback : ISignKeyFallback -{ - public static readonly NoOpSignKeyFallback Instance = new(); - - public Task ResolveAsync(string appKey, CancellationToken ct = default) => Task.FromResult(null); -} - -/// -/// UserBase 密钥兜底:直调 GET {baseUrl}/oapi/internal/sign-key/{appKey}, -/// 解析 ;仅当 enabled 且 secret 非空时返回;404/异常/超时均返回 null。 -/// -public sealed class UserBaseSignKeyFallback : ISignKeyFallback -{ - private readonly HttpClient _httpClient; - private readonly string _baseUrl; - - public UserBaseSignKeyFallback(HttpClient httpClient, string baseUrl) - { - _httpClient = httpClient; - _baseUrl = (baseUrl ?? "").TrimEnd('/'); - } - - public async Task ResolveAsync(string appKey, CancellationToken ct = default) - { - if (string.IsNullOrEmpty(_baseUrl) || string.IsNullOrEmpty(appKey)) - { - return null; - } - try - { - using var resp = await _httpClient.GetAsync(_baseUrl + "/oapi/internal/sign-key/" + Uri.EscapeDataString(appKey), ct); - if (!resp.IsSuccessStatusCode) - { - return null; - } - var json = await resp.Content.ReadAsStringAsync(ct); - var envelope = System.Text.Json.JsonSerializer.Deserialize>(json, JsonOpts); - var payload = envelope?.Data; - if (payload == null || !payload.Enabled || string.IsNullOrEmpty(payload.Secret)) - { - return null; - } - return payload.Secret; - } - catch - { - return null; - } - } - - private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new(System.Text.Json.JsonSerializerDefaults.Web); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyResolver.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyResolver.cs deleted file mode 100644 index 36cc3d3..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignKeyResolver.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace Yh.Platform.AppAuth; - -/// -/// 验签密钥解析链(对齐 Java SignKeyResolver): -/// 配置内置应用(yh:app-auth AppKey/AppSecret)→ Redis(sign:app:{appKey})→ IMemoryCache → ISignKeyFallback(UserBase /oapi/internal/sign-key/{appKey}); -/// 未知 app 返回 null(调用方直接拒绝)。 -/// -public class SignKeyResolver -{ - private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new(System.Text.Json.JsonSerializerDefaults.Web); - - private readonly AppAuthOptions _options; - private readonly IMemoryCache _memoryCache; - private readonly ISignKeyFallback _fallback; - private readonly Func? _redisDatabaseFactory; - private IDatabase? _redis; - - public SignKeyResolver(IOptions options, IMemoryCache memoryCache, ISignKeyFallback? fallback = null) - : this(options.Value, memoryCache, fallback ?? NoOpSignKeyFallback.Instance, null) - { - } - - public SignKeyResolver(AppAuthOptions options, IMemoryCache memoryCache, ISignKeyFallback fallback, Func? redisDatabaseFactory) - { - _options = options; - _memoryCache = memoryCache; - _fallback = fallback; - _redisDatabaseFactory = redisDatabaseFactory; - } - - /// 解析调用方 appKey 对应的 appSecret;未知/不可用返回 null。 - public async Task GetSecretAsync(string appKey, CancellationToken ct = default) - { - if (string.IsNullOrEmpty(appKey)) - { - return null; - } - - // 0. 配置内置应用(自服务/联调):调用方 appKey 与本应用 yh:app-auth 配置一致时, - // 直接用配置的 AppSecret 验签。覆盖"系统 A 以自身 AppKey/AppSecret 调系统 B 的 /oapi": - // B 验签时 appKey==B 配置 AppKey → 用 B 配置 AppSecret 校验,无需 Redis/UserBase 预置密钥。 - if (string.Equals(appKey, _options.AppKey, StringComparison.Ordinal)) - { - return string.IsNullOrEmpty(_options.AppSecret) ? null : _options.AppSecret; - } - - // 1. Redis 主路径(宕机/未配置时静默降级) - var redis = TryGetRedis(); - if (redis != null) - { - try - { - // RedisValue 同时可隐式转换为 string / byte[],直接传给 Deserialize 会触发重载二义性,故先显式取 string - var json = (string?)await redis.StringGetAsync(RedisKey(appKey)); - if (!string.IsNullOrEmpty(json)) - { - var payload = System.Text.Json.JsonSerializer.Deserialize(json, JsonOpts); - var secret = Normalize(payload); - if (secret != null) - { - CacheLocal(appKey, secret); - return secret; - } - } - } - catch (RedisConnectionException) - { - // 降级到本地缓存/fallback - } - catch (System.Text.Json.JsonException) - { - // 脏数据视同未命中 - } - } - - // 2. 本地缓存 - if (_memoryCache.TryGetValue(CacheKey(appKey), out string? cached) && cached != null) - { - return cached; - } - - // 3. 兜底(UserBase),成功后回填本地缓存 - var fallbackSecret = await _fallback.ResolveAsync(appKey, ct); - if (fallbackSecret != null) - { - CacheLocal(appKey, fallbackSecret); - } - return fallbackSecret; - } - - private string RedisKey(string appKey) => _options.SignKeyRedisKeyPrefix + appKey; - - private static string CacheKey(string appKey) => "yh:signkey:local:" + appKey; - - private void CacheLocal(string appKey, string secret) - => _memoryCache.Set(CacheKey(appKey), secret, TimeSpan.FromMinutes(5)); - - private static string? Normalize(SignAppKeyPayload? payload) - => payload == null || !payload.Enabled || string.IsNullOrEmpty(payload.Secret) ? null : payload.Secret; - - private IDatabase? TryGetRedis() - { - if (_redisDatabaseFactory == null) - { - return null; - } - if (_redis != null) - { - return _redis; - } - try - { - _redis = _redisDatabaseFactory(); - } - catch - { - _redis = null; - } - return _redis; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignUtil.cs b/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignUtil.cs deleted file mode 100644 index f42e36c..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/SignUtil.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Yh.Platform.AppAuth; - -/// -/// HmacSHA256 接口签名工具类(对齐 Java utils.crypto.SignUtil,输出格式逐字节一致)。 -/// 双签名规则: -/// -/// 数据级:source = data + "|" + timeStamp,HmacSHA256 输出小写十六进制; -/// 请求级:source = METHOD + "\n" + url + "\n" + timeStamp,HmacSHA256 输出 Base64。 -/// -/// -public static class SignUtil -{ - private const string AlgHmacSha256 = "HmacSHA256"; - private const string SignSplitChar = "|"; - - /// 签名密钥最低长度 - public const int MinSecretLength = 16; - - /// 默认请求有效时长:5 分钟(毫秒) - public const long DefaultExpireMs = 5 * 60 * 1000; - - // ====================== 数据级签名 ====================== - - /// 生成数据级签名(data|ts → HmacSHA256 小写十六进制)。 - public static string Sign(string data, string timeStamp, string secret) - { - CheckParam(data, timeStamp, secret); - return HmacSha256Hex(data + SignSplitChar + timeStamp, secret); - } - - /// 数据级验签(不校验时间戳,忽略大小写比对)。 - public static bool Verify(string data, string timeStamp, string secret, string? clientSign) - { - if (string.IsNullOrWhiteSpace(clientSign)) - { - return false; - } - var serverSign = Sign(data, timeStamp, secret); - return string.Equals(serverSign, clientSign, StringComparison.OrdinalIgnoreCase); - } - - /// 数据级验签 + 时间戳过期校验(默认 5 分钟)。 - public static bool VerifyWithExpire(string data, string timeStamp, string secret, string? clientSign) - => VerifyWithExpire(data, timeStamp, secret, clientSign, DefaultExpireMs); - - public static bool VerifyWithExpire(string data, string timeStamp, string secret, string? clientSign, long expireMs) - { - if (!Verify(data, timeStamp, secret, clientSign)) - { - return false; - } - return !IsExpired(timeStamp, expireMs); - } - - // ====================== 请求级签名(统一方案) ====================== - - /// - /// 对一次 HTTP 请求签名,输出 Base64。 - /// canonical = METHOD + "\n" + url + "\n" + timestamp;url 为 canonical target(path[?sortedQuery],见 )。 - /// - public static string SignRequest(string method, string url, string? timeStamp, string secret) - { - if (string.IsNullOrWhiteSpace(method) || string.IsNullOrWhiteSpace(url)) - { - throw new ArgumentException("method/url 不能为空"); - } - CheckSecret(secret); - if (string.IsNullOrWhiteSpace(timeStamp)) - { - timeStamp = NowMillis().ToString(); - } - return HmacSha256Base64(method + "\n" + url + "\n" + timeStamp, secret); - } - - /// 请求级验签(不校验时间戳,精确比对)。 - public static bool VerifyRequest(string method, string url, string timeStamp, string secret, string? clientSign) - { - if (string.IsNullOrWhiteSpace(clientSign)) - { - return false; - } - var serverSign = SignRequest(method, url, timeStamp, secret); - return string.Equals(serverSign, clientSign, StringComparison.Ordinal); - } - - /// 请求级验签 + 时间戳过期校验(默认 5 分钟)。 - public static bool VerifyRequestWithExpire(string method, string url, string timeStamp, string secret, string? clientSign) - => VerifyRequestWithExpire(method, url, timeStamp, secret, clientSign, DefaultExpireMs); - - public static bool VerifyRequestWithExpire(string method, string url, string timeStamp, string secret, string? clientSign, long expireMs) - { - if (!VerifyRequest(method, url, timeStamp, secret, clientSign)) - { - return false; - } - return !IsExpired(timeStamp, expireMs); - } - - // ====================== 内部工具 ====================== - - /// 当前毫秒时间戳(供测试注入替代)。 - public static Func NowMillis { get; set; } = static () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - private static bool IsExpired(string timeStamp, long expireMs) - { - if (!long.TryParse(timeStamp, out var clientTs)) - { - return true; - } - return Math.Abs(NowMillis() - clientTs) > expireMs; - } - - private static void CheckParam(string data, string timeStamp, string secret) - { - if (data == null) - { - throw new ArgumentException("业务数据data不能为null"); - } - if (string.IsNullOrWhiteSpace(timeStamp)) - { - throw new ArgumentException("时间戳timeStamp不能为空"); - } - CheckSecret(secret); - } - - private static void CheckSecret(string secret) - { - if (string.IsNullOrWhiteSpace(secret)) - { - throw new ArgumentException("签名密钥secret不能为空"); - } - if (secret.Length < MinSecretLength) - { - throw new ArgumentException("签名密钥长度不能小于" + MinSecretLength + "位"); - } - } - - private static string HmacSha256Hex(string source, string secret) - { - using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); - var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(source)); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - private static string HmacSha256Base64(string source, string secret) - { - using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); - var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(source)); - return Convert.ToBase64String(hash); - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.AppAuth/Yh.Platform.AppAuth.csproj b/BasePlatformDotnet/src/Yh.Platform.AppAuth/Yh.Platform.AppAuth.csproj deleted file mode 100644 index 6980626..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.AppAuth/Yh.Platform.AppAuth.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Yh.Platform.AppAuth - yunhai 平台应用签名与防重放(对应 Java yh-app-auth-starter):HmacSHA256 SignUtil、AppSignHandler、SignKeyResolver、ReplayGuard - HMAC 应用签名(X-App-Key/X-Timestamp/X-Signature)与验签密钥三层解析链 - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs deleted file mode 100644 index a94bdaa..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs +++ /dev/null @@ -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; - -/// 认证装配扩展(对齐 Java YhAuthAutoConfiguration 与聚合 starter)。 -public static class AuthServiceCollectionExtensions -{ - /// - /// 注册 Cookie + CAS 认证与用户上下文依赖(含 AddYhOpenApi)。 - /// 配置节:yh:auth(通用)与 yh:sso(CAS 服务端)。 - /// - public static IServiceCollection AddYhAuth(this IServiceCollection services, IConfiguration configuration) - { - services.AddYhOpenApi(configuration); - - // 会话票据表单例:Cookie TicketDataFormat 与 OnSigningOut 退出吊销必须共享同一实例(同一票据表) - var sessionTicketFormat = new SessionTicketDataFormat(); - services.AddSingleton(sessionTicketFormat); - - services.AddOptions().Bind(configuration.GetSection(YhAuthOptions.SectionName)); - // 注:AddScheme 的 configure 才是 handler 实际读取的命名 options(AuthenticationHandler.Options); - // 必须在此绑定 yh:sso,否则 handler 拿到的 CasAuthenticationOptions 为空(Challenge 时 CasServerUrlPrefix 缺失)。 - services.AddOptions().Bind(configuration.GetSection("yh:sso")); - - services.AddAuthentication(options => - { - // DefaultScheme 必须为 CasScheme(而非 CookieScheme): - // UseAuthentication 每请求调用 AuthenticateAsync(默认 scheme),若默认是 CookieScheme, - // CAS 票据校验(CasAuthenticationHandler.HandleAuthenticateAsync 的 ticket 分支)永远没有触发点—— - // 回调带 ticket 时认证不校验、SsoProtectionMiddleware 见未认证直接 401,CAS 登录永远失败。 - // CasAuthenticationHandler.HandleAuthenticateAsync 第一步就是 Context.AuthenticateAsync(CookieScheme) - // 恢复 Cookie 登录态,因此 CasScheme 作为默认认证 scheme 语义等价"先 Cookie 恢复、后 ticket 校验"。 - options.DefaultScheme = YhAuthDefaults.CasScheme; - options.DefaultChallengeScheme = YhAuthDefaults.CasScheme; - }) - .AddCookie(YhAuthDefaults.CookieScheme, options => - { - var auth = configuration.GetSection(YhAuthOptions.SectionName).Get() ?? new YhAuthOptions(); - options.Cookie.Name = auth.CookieName; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax; - // 会话级 Cookie + 服务端票据固定过期: - // - TicketDataFormat(SessionTicketDataFormat):票据存服务端,Cookie 只写 32 位 key - // (.NET 5 SessionStore 模式下 cookie 值仍是 Protect 的加密长串,无法满足 32 位,故自定义 DataFormat; - // 实例与 DI 单例同一对象——TicketDataFormat 与下方 OnSigningOut 事件必须操作同一票据表) - // - IsPersistent=false(CasAuthenticationHandler):会话 Cookie,关闭浏览器即失效 - // - ExpireTimeSpan=1 分钟且 SlidingExpiration=false:服务端票据固定 1 分钟过期,不滑动续期 - options.TicketDataFormat = sessionTicketFormat; - options.ExpireTimeSpan = TimeSpan.FromMinutes(auth.CookieExpireMinutes > 0 ? auth.CookieExpireMinutes : 1); - options.SlidingExpiration = false; - options.LoginPath = "/signin-cas"; - options.ReturnUrlParameter = "returnUrl"; - // 退出即吊销:SignOutAsync(如业务 GET /api/Logout)清除 Cookie 时, - // 从请求 Cookie 取 32 位 key 立即删除服务端票据——否则票据残留到 ExpireTimeSpan 过期才失效。 - // ("关闭浏览器"本身服务端无法感知,依赖会话 Cookie 删除 + 1 分钟过期兜底;显式退出则双删。) - options.Events = new CookieAuthenticationEvents - { - OnSigningOut = context => - { - if (context.HttpContext.Request.Cookies.TryGetValue(auth.CookieName, out var rawKey)) - { - sessionTicketFormat.Revoke(rawKey); - } - return Task.CompletedTask; - }, - }; - }) - .AddScheme(YhAuthDefaults.CasScheme, - o => configuration.GetSection("yh:sso").Bind(o)); - - services.AddSingleton(sp => - { - var opts = sp.GetRequiredService>().CurrentValue; - return new CasServiceTicketValidator(opts.CasServerUrlPrefix, opts.TicketValidateTimeoutMs); - }); - services.AddScoped(); - - return services; - } - - /// - /// 聚合入口(等价 Java yh-spring-boot-starter):一次带齐 OpenApi + AppAuth + Auth。 - /// - public static IServiceCollection AddYhPlatform(this IServiceCollection services, IConfiguration configuration) - => services - .AddYhAppAuth(configuration) - .AddYhAuth(configuration); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/BoaUserContext.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/BoaUserContext.cs deleted file mode 100644 index f2cdfc7..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/BoaUserContext.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Yh.Platform.Abstractions; - -namespace Yh.Platform.Auth; - -/// 登录用户载体(对齐 Java LoginUser)。 -public class LoginUser -{ - public string? UserId { get; set; } - - public string? LoginName { get; set; } - - public string? DisplayName { get; set; } - - /// 应用身份标识。普通用户登录态为 null;由应用签名鉴权写入调用方 appKey。 - public string? AppKey { get; set; } - - public LoginUser() - { - } - - public LoginUser(string? userId) - { - UserId = userId; - } -} - -/// -/// 当前请求的 持有器。 -/// 对齐 Java BoaUserContext(ThreadLocal)语义;C# 侧用 , -/// 请求内任何层可取,请求结束由 清空(不串请求)。 -/// -public static class BoaUserContext -{ - private static readonly AsyncLocal Current = new(); - - public static void Set(BoaUser? boaUser) => Current.Value = boaUser; - - public static BoaUser? Get() => Current.Value; - - public static void Clear() => Current.Value = null; -} - -/// -/// 当前请求的 持有器(对齐 Java UserContextHolder): -/// 认证中间件写入、业务读取、请求结束清理。 -/// -public static class UserContextHolder -{ - private static readonly AsyncLocal Current = new(); - - public static void Set(LoginUser? user) => Current.Value = user; - - public static LoginUser? Get() => Current.Value; - - public static void Clear() => Current.Value = null; -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/CasAuthenticationHandler.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/CasAuthenticationHandler.cs deleted file mode 100644 index 7e0daad..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/CasAuthenticationHandler.cs +++ /dev/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; - -/// -/// 自研 CAS 远程认证 handler(替换 AspNetCore.Security.CAS 本地 dll): -/// -/// Challenge:302 到 CAS 登录页(service 指向本应用 ); -/// Authenticate:回调带 ticket 时调 /serviceValidate 校验,成功后写入 Cookie 登录态并回跳原地址。 -/// -/// -public class CasAuthenticationHandler : AuthenticationHandler -{ - private readonly CasServiceTicketValidator _validator; - -#if NET8_0_OR_GREATER - public CasAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - CasServiceTicketValidator validator) - : base(options, logger, encoder) - { - _validator = validator; - } -#else - public CasAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock, - CasServiceTicketValidator validator) - : base(options, logger, encoder, clock) - { - _validator = validator; - } -#endif - - private string BuildServiceUrl(string? returnUrl) - { - var request = Context.Request; - var root = Options.AppBaseUrl?.TrimEnd('/'); - if (string.IsNullOrEmpty(root)) - { - root = request.Scheme + "://" + request.Host.Value; - } - var callback = root + Options.SignInCallbackPath; - // 只允许本地相对路径回跳,杜绝开放重定向(returnUrl 不得为外站绝对地址 / 协议相对地址) - if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl)) - { - return callback; - } - return callback + "?returnUrl=" + Uri.EscapeDataString(returnUrl); - } - - protected override Task HandleChallengeAsync(AuthenticationProperties properties) - { - var casBase = Options.CasServerUrlPrefix; - if (string.IsNullOrWhiteSpace(casBase)) - { - Response.StatusCode = 500; - return Response.WriteAsync("未配置 CAS 服务端地址(yh:sso:cas-server-url-prefix)"); - } - properties.Items.TryGetValue("returnUrl", out var returnUrl); - var service = BuildServiceUrl(returnUrl ?? properties.RedirectUri); - var loginUrl = (casBase!.EndsWith('/') ? casBase : casBase + "/") + "login?service=" + UrlEncoder.Encode(service); - Response.Redirect(loginUrl); - return Task.CompletedTask; - } - - protected override async Task HandleAuthenticateAsync() - { - // 1. 已有 Cookie 登录态直接放行 - var cookieResult = await Context.AuthenticateAsync(YhAuthDefaults.CookieScheme); - if (cookieResult.Succeeded && cookieResult.Principal != null) - { - return AuthenticateResult.Success(cookieResult.Ticket!); - } - - // 2. CAS 回调:校验 ticket - var ticketValue = Context.Request.Query["ticket"].ToString(); - if (string.IsNullOrEmpty(ticketValue)) - { - return AuthenticateResult.NoResult(); - } - - // service 必须与登录时完全一致:剥掉本回调的 ticket 参数后重建 - var service = BuildServiceUrl(Context.Request.Query["returnUrl"].ToString()); - string? loginName; - try - { - loginName = await _validator.ValidateAsync(ticketValue, service, Context.RequestAborted); - } - catch (AuthException e) - { - return AuthenticateResult.Fail(e); - } - if (string.IsNullOrEmpty(loginName)) - { - return AuthenticateResult.Fail("CAS ticket 校验失败或已失效"); - } - - var claims = new List - { - new(ClaimTypes.Name, loginName!), - new(ClaimTypes.NameIdentifier, loginName!), - new("loginName", loginName!), - }; - var identity = new ClaimsIdentity(claims, Scheme.Name); - var principal = new ClaimsPrincipal(identity); - - // 写入 Cookie 登录态(会话级:IsPersistent=false,关闭浏览器即失效;票据过期由 SessionStore + ExpireTimeSpan 控制), - // 然后回跳去掉 ticket 的原地址(仅限本地路径,防开放重定向) - var props = new AuthenticationProperties { IsPersistent = false }; - var returnUrl = Context.Request.Query["returnUrl"].ToString(); - await Context.SignInAsync(YhAuthDefaults.CookieScheme, principal, props); - // 同步当前请求上下文身份:SignInAsync 只写 Cookie 不改 context.User, - // 后续中间件(SsoProtectionMiddleware)需据此识别“刚登录成功”,避免被误判未认证再次 Challenge - Context.User = principal; - var redirect = IsLocalUrl(returnUrl) ? returnUrl : "/"; - Response.Redirect(redirect); - return AuthenticateResult.NoResult(); - } - - /// 仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。 - private static bool IsLocalUrl(string? url) - { - if (string.IsNullOrEmpty(url) || url[0] != '/') - { - return false; - } - return !url.StartsWith("//", StringComparison.Ordinal) - && !url.StartsWith("/\\", StringComparison.Ordinal); - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/CasServiceTicketValidator.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/CasServiceTicketValidator.cs deleted file mode 100644 index 83719ef..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/CasServiceTicketValidator.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Yh.Platform.Auth; - -/// -/// 轻量 CAS service ticket 校验器(对齐 Java CasServiceTicketValidator,不依赖 CAS 客户端库)。 -/// 调用 CAS 的 /serviceValidate 端点,从响应 XML 中提取 <cas:user>。 -/// -public class CasServiceTicketValidator -{ - private readonly string? _casServerUrl; - private readonly int _timeoutMs; - - public CasServiceTicketValidator(string? casServerUrl, int timeoutMs = 5000) - { - _casServerUrl = casServerUrl; - _timeoutMs = timeoutMs > 0 ? timeoutMs : 5000; - } - - /// 校验 ticket,成功返回登录账号,失败/无效返回 null。 - public async Task ValidateAsync(string ticket, string service, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(_casServerUrl)) - { - throw new AuthException("未配置 CasServerUrlPrefix(yh:sso:cas-server-url-prefix),无法进行 ticket 校验"); - } - var base_ = _casServerUrl!.EndsWith('/') ? _casServerUrl : _casServerUrl + "/"; - var endpoint = base_ + "serviceValidate" - + "?ticket=" + Uri.EscapeDataString(ticket ?? "") - + "&service=" + Uri.EscapeDataString(service ?? ""); - try - { - using var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromMilliseconds(_timeoutMs) }; - using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(_timeoutMs) }; - using var resp = await http.GetAsync(endpoint, ct).ConfigureAwait(false); - var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - if (!resp.IsSuccessStatusCode || body == null) - { - return null; - } - return ExtractUser(body); - } - catch (Exception e) when (e is HttpRequestException or TaskCanceledException) - { - throw new AuthException("CAS serviceValidate 请求失败: " + e.Message, e); - } - } - - /// - /// 从 CAS 响应 XML 提取用户名(公开以便对拍测试)。 - /// 成功响应形如 <cas:serviceResponse><cas:authenticationSuccess><cas:user>NAME</cas:user>... - /// - public static string? ExtractUser(string? xml) - { - if (string.IsNullOrEmpty(xml)) - { - return null; - } - var start = xml.IndexOf("", StringComparison.Ordinal); - if (start < 0) - { - start = xml.IndexOf("', start); - var end = xml.IndexOf("", tagEnd, StringComparison.Ordinal); - if (tagEnd < 0 || end < 0) - { - return null; - } - return xml.Substring(tagEnd + 1, end - tagEnd - 1).Trim(); - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/GlobalUsings.cs deleted file mode 100644 index 71d80c5..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/GlobalUsings.cs +++ /dev/null @@ -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; diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/SessionTicketDataFormat.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/SessionTicketDataFormat.cs deleted file mode 100644 index c31733a..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/SessionTicketDataFormat.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Collections.Concurrent; - -using Microsoft.AspNetCore.Authentication; - -namespace Yh.Platform.Auth; - -/// -/// 会话票据数据格式(自定义 ): -/// 登录票据(AuthenticationTicket)保存在服务端内存,Cookie 只写 32 位 key(Guid N 格式), -/// 实现「Cookie 短值 + 服务端可控过期」。 -/// -/// 为什么不用框架 SessionStore:.NET 5 的 CookieAuthenticationHandler 即便配置了 SessionStore, -/// cookie 值仍是 TicketDataFormat.Protect(精简票据) 的加密长串(CfDJ8…),无法满足"Cookie 32 位"。 -/// 直接自定义 TicketDataFormat:Protect 落库返回 32 位 key,Unprotect 查表并校验过期。 -/// -/// -/// 语义: -/// -/// 关闭浏览器失效:AuthenticationProperties.IsPersistent=false(会话 Cookie,无持久化头); -/// 固定过期:服务端票据按 Properties.ExpiresUtc(由 ExpireTimeSpan 决定)过期,Unprotect 过期返回 null → 未认证; -/// 显式退出立即失效: 删除指定 key(由 Cookie 注册的 OnSigningOut 事件触发,退出不等过期); -/// 安全:key 为 128 位随机熵(Guid),不可枚举猜测;查表失败/过期即无效。 -/// -/// -/// -public class SessionTicketDataFormat : ISecureDataFormat -{ - private readonly ConcurrentDictionary _tickets = new(StringComparer.Ordinal); - private long _lastCleanupUtc; - - public string Protect(AuthenticationTicket data) => Protect(data, null); - - public string Protect(AuthenticationTicket data, string? purpose) - { - var key = Guid.NewGuid().ToString("N"); // 32 个十六进制字符 - _tickets[key] = data; - return key; - } - -#if NET8_0_OR_GREATER - public AuthenticationTicket Unprotect(string? protectedText) => Unprotect(protectedText, null); -#else - public AuthenticationTicket Unprotect(string protectedText) => Unprotect(protectedText, null); -#endif - -#if NET8_0_OR_GREATER - public AuthenticationTicket Unprotect(string? protectedText, string? purpose) -#else - public AuthenticationTicket Unprotect(string protectedText, string? purpose) -#endif - { - if (protectedText is null || !_tickets.TryGetValue(protectedText, out var ticket)) - { - CleanupExpiredIfDue(); - return null!; - } - - // 服务端票据过期:视为无效并清理(配合 SlidingExpiration=false 实现固定 1 分钟过期) - if (ticket.Properties.ExpiresUtc.HasValue && ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow) - { - _tickets.TryRemove(protectedText, out _); - return null!; - } - - return ticket; - } - - /// - /// 吊销指定票据:显式退出(SignOut,如 /api/Logout)时服务端票据立即失效,无需等待过期兜底。 - /// 由 Cookie 注册处的 OnSigningOut 事件从请求 Cookie 读取 32 位 key 后调用。 - /// - public void Revoke(string? key) - { - if (!string.IsNullOrEmpty(key)) - { - _tickets.TryRemove(key, out _); - } - CleanupExpiredIfDue(); - } - - /// - /// 分钟级摊还清理过期票据,防内存膨胀:Unprotect/Revoke 路径触发,最多每分钟一次全量扫描, - /// 并发下仅一个线程进入(Interlocked.CompareExchange 门闩)。 - /// - private void CleanupExpiredIfDue() - { - var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var last = Volatile.Read(ref _lastCleanupUtc); - if (now - last < 60) - { - return; - } - if (Interlocked.CompareExchange(ref _lastCleanupUtc, now, last) != last) - { - return; - } - foreach (var kvp in _tickets) - { - if (kvp.Value.Properties.ExpiresUtc is { } exp && exp < DateTimeOffset.UtcNow) - { - _tickets.TryRemove(kvp.Key, out _); - } - } - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/UserContextMiddleware.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/UserContextMiddleware.cs deleted file mode 100644 index 1f575e6..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/UserContextMiddleware.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Yh.Platform.OpenApi; - -namespace Yh.Platform.Auth; - -/// -/// 用户上下文中间件:认证成功后按登录名换取 并写入 ; -/// 请求结束(含异常)保证清空,防止上下文跨请求串扰。 -/// 注意: 为 Scoped 依赖,必须走 InvokeAsync 方法注入(每请求解析); -/// 若放构造函数会被 UseMiddleware 启动时从根容器激活,抛出 -/// "Cannot resolve scoped service ... from root provider"(ASP.NET Core 中间件约束)。 -/// -public class UserContextMiddleware -{ - private readonly RequestDelegate _next; - - public UserContextMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext context, IUserDetailsProvider userDetailsProvider) - { - try - { - if (context.User.Identity?.IsAuthenticated == true) - { - var loginName = context.User.FindFirst("loginName")?.Value - ?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value; - var user = await userDetailsProvider.GetByLoginNameAsync(loginName ?? "", context.RequestAborted).ConfigureAwait(false); - if (user != null) - { - BoaUserContext.Set(user); - UserContextHolder.Set(new LoginUser(user.Id?.ToString()) - { - LoginName = loginName, - DisplayName = user.Name, - }); - } - } - await _next(context).ConfigureAwait(false); - } - finally - { - BoaUserContext.Clear(); - UserContextHolder.Clear(); - } - } -} - -/// 用户上下文中间件注册扩展。 -public static class UserContextMiddlewareExtensions -{ - public static IApplicationBuilder UseYhUserContext(this IApplicationBuilder app) - => app.UseMiddleware(); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/UserDetailsProvider.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/UserDetailsProvider.cs deleted file mode 100644 index 17cd442..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/UserDetailsProvider.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Yh.Platform.Abstractions; -using Yh.Platform.OpenApi; - -namespace Yh.Platform.Auth; - -/// 认证链路异常(对齐 Java AuthException)。 -public class AuthException : Exception -{ - public AuthException(string message) : base(message) - { - } - - public AuthException(string message, Exception? innerException) : base(message, innerException) - { - } -} - -/// -/// 用户明细 SPI(对齐 Java UserDetailsProvider):认证链路解析出登录名后,由本接口换取 。 -/// 默认实现 走 IOpenApiUserClient;业务侧可替换为本地查询。 -/// -public interface IUserDetailsProvider -{ - Task GetByLoginNameAsync(string loginName, CancellationToken ct = default); -} - -/// 默认实现:调 UserBase /oapi/user/byLoginName。 -public class OpenApiUserDetailsProvider : IUserDetailsProvider -{ - private readonly IOpenApiUserClient _userClient; - - public OpenApiUserDetailsProvider(IOpenApiUserClient userClient) - { - _userClient = userClient; - } - - public async Task GetByLoginNameAsync(string loginName, CancellationToken ct = default) - { - if (string.IsNullOrEmpty(loginName)) - { - return null; - } - try - { - return await _userClient.GetUserByLoginNameAsync(loginName, ct).ConfigureAwait(false); - } - catch (DException) - { - return null; // 用户不存在(code=4/5 信封)视同未找到,交由认证链路按未认证处理 - } - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/Yh.Platform.Auth.csproj b/BasePlatformDotnet/src/Yh.Platform.Auth/Yh.Platform.Auth.csproj deleted file mode 100644 index 1c97900..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/Yh.Platform.Auth.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Yh.Platform.Auth - yunhai 平台认证与用户上下文(对应 Java yh-auth-starter):CAS SSO 认证 handler、BoaUserContext、IUserDetailsProvider、AddYhAuth/AddYhPlatform 聚合入口 - CAS 单点登录 + BoaUser 请求上下文(AsyncLocal),替换 AspNetCore.Security.CAS 本地 dll - - - - - - - - - - - - diff --git a/BasePlatformDotnet/src/Yh.Platform.Auth/YhAuthOptions.cs b/BasePlatformDotnet/src/Yh.Platform.Auth/YhAuthOptions.cs deleted file mode 100644 index 72ff8bb..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.Auth/YhAuthOptions.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Net; - -using Microsoft.AspNetCore.Authentication; - -namespace Yh.Platform.Auth; - -/// -/// 认证方案名常量。 -/// -public static class YhAuthDefaults -{ - /// Cookie 认证方案(登录态载体,替代 GrowthHealth 既有 Cookie scheme) - public const string CookieScheme = "YhCookie"; - - /// CAS 认证方案(challenge 时重定向 CAS 登录页) - public const string CasScheme = "YhCas"; -} - -/// -/// CAS 认证配置(对齐 GrowthHealth AddCasSso() 与 Java yh.sso.*)。 -/// -public class CasAuthenticationOptions : AuthenticationSchemeOptions -{ - /// CAS 服务端地址前缀,如 https://sso.example.com/cas - public string? CasServerUrlPrefix { get; set; } - - /// 本应用根地址(用于拼 service 回调地址),为空时从当前请求推断 - public string? AppBaseUrl { get; set; } - - /// ticket 回调路径 - public string SignInCallbackPath { get; set; } = "/signin-cas"; - - /// ticket 校验超时(毫秒),对齐 Java 轻量校验器 5 秒 - public int TicketValidateTimeoutMs { get; set; } = 5000; -} - -/// 通用鉴权配置(对齐 Java yh.auth.*)。 -public class YhAuthOptions -{ - public const string SectionName = "yh:auth"; - - /// 是否启用(默认 true) - public bool Enabled { get; set; } = true; - - /// 登录态有效期(分钟),默认 30 分钟(固定过期,不滑动续期) - public int CookieExpireMinutes { get; set; } = 30; - - /// Cookie 名 - public string CookieName { get; set; } = ".yh.auth"; -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/BaseOpenApi.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/BaseOpenApi.cs deleted file mode 100644 index bb35d92..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/BaseOpenApi.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Yh.Platform.AppAuth; -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// -/// 实例形态门面:复刻旧 BschOpenApi 的 new BaseOpenApi(host, appKey, appSecret) 构造习惯, -/// GrowthHealth 等消费方迁移时改 using 即可。行为与静态门面 一致。 -/// -public class BaseOpenApi -{ - public IOpenApiUserClient User { get; } - - public IOpenApiGroupClient Group { get; } - - public IOpenApiAppClient App { get; } - - /// 旧 SDK 同构构造:host + appKey + appSecret(basePath 默认 /userbase)。 - public BaseOpenApi(string host, string appKey, string appSecret, string? basePath = "/userbase") - : this(new OpenApiOptions - { - BaseHost = host, - BasePath = basePath, - AppKey = appKey, - AppSecret = appSecret, - }) - { - } - - public BaseOpenApi(OpenApiOptions options, HttpMessageHandler? handler = null) - { - var signHandler = new AppSignHandler(options.AppKey ?? "", options.AppSecret ?? "", options.BasePath, handler); - var httpClient = new HttpClient(signHandler) - { - Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000), - }; - User = new OpenApiUserClient(httpClient, options); - Group = new OpenApiGroupClient(httpClient, options); - App = new OpenApiAppClient(httpClient, options); - } - - // ============================ 旧 BaseOpenApi 高频方法直通 ============================ - - /// 按登录账号查询用户明细(旧 SDK 直通方法)。 - public Task GetUserInfoByLoginNameAsync(string loginName, CancellationToken ct = default) - => User.GetUserInfoAsync(loginName, ct); - - /// 按用户主键查询用户信息(旧 SDK 直通方法)。 - public Task GetUserInfoByIdAsync(int userId, CancellationToken ct = default) - => User.GetUserInfoAsync(userId, ct); - - /// 按用户查询角色身份列表(旧 SDK 直通方法)。 - public Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) - => User.GetUserRoleListAsync(userId, ct); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/GlobalUsings.cs deleted file mode 100644 index 850e671..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/GlobalUsings.cs +++ /dev/null @@ -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; diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiGroupClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiGroupClient.cs deleted file mode 100644 index 69ab878..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiGroupClient.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// -/// 组织/组域开放接口客户端契约(对齐 UserBase /oapi/group/**,镜像 Java OpenApiGroupClient)。 -/// -public interface IOpenApiGroupClient -{ - /// 获取整个平台的组树结构(GET /oapi/group/tree) - Task GetGroupTreeAsync(CancellationToken ct = default); - - /// 按组ID查询单个组;withSub=true 时递归返回整棵子树(GET /oapi/group/byId) - Task GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default); - - /// 按多个组ID(逗号/空白分隔)批量查询(GET /oapi/group/list) - Task> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default); - - /// 新增组(POST /oapi/group/add) - Task AddGroupAsync(SysGroupRequest request, CancellationToken ct = default); - - /// 编辑组(路径 id 权威;body 不含 id):POST /oapi/group/edit/{id} - Task EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default); - - /// 删除组(组下存在子组或用户时服务端拒绝):POST /oapi/group/delete/{id}/{schoolId} - Task DeleteGroupAsync(int id, int schoolId, CancellationToken ct = default); - - /// 批量新增子组(POST /oapi/group/batchAdd) - Task> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default); - - /// 移动组(学校限定):POST /oapi/group/move/{schoolId},body 仅含 moveId/pid - Task MoveGroupAsync(int schoolId, SysGroupMoveRequest request, CancellationToken ct = default); -} - -/// -/// 应用域开放接口客户端契约(对齐 UserBase /oapi/app/**,镜像 Java OpenApiAppClient)。 -/// -public interface IOpenApiAppClient -{ - /// 按应用ID查询应用信息(GET /oapi/app/{appId};无 byId 端点时返回 404 错误信封) - Task GetAppByIdAsync(string appId, CancellationToken ct = default); - - /// 按用户 + 客户端类型查询有权访问的应用列表(按应用类型分组):GET /oapi/app/list - Task> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiUserClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiUserClient.cs deleted file mode 100644 index 3e1fa0b..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/IOpenApiUserClient.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// -/// 用户域开放接口客户端契约(对齐 UserBase /oapi/user/**,镜像 Java OpenApiUserClient)。 -/// 基址取自 。 -/// -public interface IOpenApiUserClient -{ - /// 按用户主键查询(GET /oapi/user/byId?id=) - Task GetUserByIdAsync(string userId, CancellationToken ct = default); - - /// 按登录账号查询用户明细(GET /oapi/user/byLoginName?loginName=) - Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default); - - /// 按手机号查询用户明细(GET /oapi/user/byPhone?phone=) - Task GetUserByPhoneAsync(string phone, CancellationToken ct = default); - - /// 按用户主键(int)查询用户信息(新接口,与旧 byId 并存) - Task GetUserInfoAsync(int userId, CancellationToken ct = default); - - /// 按登录账号查询用户信息(新接口,与旧 byLoginName 并存) - Task GetUserInfoAsync(string loginName, CancellationToken ct = default); - - /// 按用户查询模块列表(GET /oapi/user/modules?userId=&schId=) - Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default); - - /// 新增用户(POST /oapi/user/add) - Task AddUserAsync(SysUserRequest request, CancellationToken ct = default); - - /// 编辑用户(POST /oapi/user/edit/{id};路径 id 权威,自动写入 request.Id) - Task EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default); - - /// 删除用户(按组解绑):POST /oapi/user/delete/{id}/{groupId}/{schoolId} - Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default); - - /// 批量删除用户-组关系(POST /oapi/user/deleteUsers,请求体为数组) - Task DeleteUsersAsync(IReadOnlyList items, CancellationToken ct = default); - - /// 批量新增用户,整批回滚(POST /oapi/user/batchAdd,请求体为裸数组) - Task> BatchAddUsersAsync(IReadOnlyList requests, CancellationToken ct = default); - - /// 移动用户到指定组(同校内,覆盖式替换主组):POST /oapi/user/moveToGroup - Task MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default); - - /// 按用户查询角色身份列表(GET /oapi/user/roles?userId=) - Task> GetUserRoleListAsync(int userId, CancellationToken ct = default); - - /// 查询用户当前激活身份(未切换时兜底 is_main 主身份):GET /oapi/identity/current?userId= - Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default); - - /// 查询用户可切换的身份列表(多角色迁移配套):GET /oapi/identity/available?userId= - Task> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default); - - /// 切换身份(调用方须在 UserBase 侧白名单内):POST /oapi/identity/switch - Task SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default); - - /// 修改用户密码(先校验旧密码):POST /oapi/user/password - Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default); - - /// 设置用户在指定组上的状态(转出/恢复):POST /oapi/user/groupState - Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default); - - /// 查询用户在指定组上的状态:0=正常,1=转出(GET /oapi/user/groupState) - Task GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default); - - /// 跨校转入:POST /oapi/user/transferToSchool - Task TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default); - - /// 设置单个用户状态(启用/禁用):POST /oapi/user/changeStatus - Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default); - - /// 批量设置用户状态(任一 userId 无效则整体拒绝):POST /oapi/user/batchChangeStatus - Task BatchChangeUserStatusAsync(IReadOnlyList userIds, bool disabled, CancellationToken ct = default); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiClientBase.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiClientBase.cs deleted file mode 100644 index ad089f6..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiClientBase.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Text; -using System.Text.Json; -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// -/// 开放接口客户端基类(对齐 Java AbstractOpenApiClient): -/// 统一持有 HttpClient / 配置,沉淀请求构造与"信封感知"的响应解析单点。 -/// 响应解析约定(UserBase oapi):成功为裸数据对象/数组;业务/校验失败为 {code:4/5, msg, data:null} 但 HTTP 仍为 200。 -/// -public abstract class OpenApiClientBase -{ - /// 平台统一开放接口前缀(与 UserBase Config.OPEN_API_PATH 严格一致) - protected const string Prefix = "/oapi"; - - private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - - protected HttpClient HttpClient { get; } - - protected OpenApiOptions Options { get; } - - protected OpenApiClientBase(HttpClient httpClient, OpenApiOptions options) - { - HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - Options = options ?? throw new ArgumentNullException(nameof(options)); - } - - // ====================== 请求构造 ====================== - - protected static Dictionary Params(params (string key, object? value)[] kv) - { - var m = new Dictionary(StringComparer.Ordinal); - if (kv == null) - { - return m; - } - foreach (var (key, value) in kv) - { - if (key != null && value != null) - { - m[key] = value.ToString(); - } - } - return m; - } - - private static string BuildQuery(Dictionary? query) - { - if (query == null || query.Count == 0) - { - return ""; - } - var sb = new StringBuilder(); - foreach (var kv in query) - { - if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) - { - continue; - } - sb.Append(sb.Length == 0 ? '?' : '&') - .Append(Uri.EscapeDataString(kv.Key)) - .Append('=') - .Append(Uri.EscapeDataString(kv.Value)); - } - return sb.ToString(); - } - - // ====================== 信封感知解析 ====================== - - /// 发送请求并解析:返回信封感知后的裸数据节点(可能为 null)。 - protected async Task SendAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) - { - var url = Options.ResolveBaseUrl() + path + BuildQuery(query); - using var request = new HttpRequestMessage(method, url); - if (body != null) - { - request.Content = new StringContent(JsonSerializer.Serialize(body, JsonOpts), Encoding.UTF8, "application/json"); - } - using var response = await HttpClient.SendAsync(request, ct).ConfigureAwait(false); - var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - throw new OpenApiException("OpenAPI HTTP " + (int)response.StatusCode + ": " + text, (int)response.StatusCode, text); - } - try - { - using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "null" : text); - return Unwrap(doc.RootElement); - } - catch (JsonException e) - { - throw new OpenApiException("OpenAPI 响应解析失败: " + e.Message, (int)response.StatusCode, text); - } - } - - /// 裸数据反序列化为 ;含 code 且非 0 时抛 。 - protected async Task ResolveAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) - { - var data = await SendAsync(method, path, query, body, ct).ConfigureAwait(false); - if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - { - return default; - } - try - { - return JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts); - } - catch (JsonException e) - { - throw new OpenApiException("OpenAPI 反序列化失败: " + typeof(T).Name, e); - } - } - - /// 裸数组/单对象反序列化为列表;含 code 且非 0 时抛 。 - protected async Task> ResolveListAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) - { - var data = await SendAsync(method, path, query, body, ct).ConfigureAwait(false); - var result = new List(); - if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - { - return result; - } - try - { - if (data.Value.ValueKind == JsonValueKind.Array) - { - foreach (var item in data.Value.EnumerateArray()) - { - if (item.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined)) - { - result.Add(JsonSerializer.Deserialize(item.GetRawText(), JsonOpts)!); - } - } - } - else - { - result.Add(JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts)!); - } - } - catch (JsonException e) - { - throw new OpenApiException("OpenAPI 列表反序列化失败: " + typeof(T).Name, e); - } - return result; - } - - /// 信封感知 void 解析:含 code 且非 0 时抛 ,否则忽略 data。 - protected async Task ResolveVoidAsync(HttpMethod method, string path, Dictionary? query = null, object? body = null, CancellationToken ct = default) - { - await SendAsync(method, path, query, body, ct).ConfigureAwait(false); - } - - /// - /// 信封感知:对象含 code 字段且非 0 → 抛 (原样携带服务端 code/msg/data); - /// code==0 → 返回 data 节点(可能为 null);非信封 → 原样返回。 - /// - private static JsonElement? Unwrap(JsonElement root) - { - if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("code", out var codeEl) && codeEl.ValueKind == JsonValueKind.Number && codeEl.TryGetInt32(out var code)) - { - if (code != 0) - { - var msg = root.TryGetProperty("msg", out var msgEl) && msgEl.ValueKind == JsonValueKind.String ? msgEl.GetString() : null; - msg = string.IsNullOrEmpty(msg) ? "OpenAPI 业务错误" : msg; - JsonElement? data = root.TryGetProperty("data", out var dataEl) && dataEl.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) ? dataEl.Clone() : null; - object? dataObj = data?.ValueKind == JsonValueKind.String || data?.ValueKind == JsonValueKind.Number || data?.ValueKind == JsonValueKind.True || data?.ValueKind == JsonValueKind.False - ? data.Value.ToString() - : data; - throw new DException(new OpenApiErrorCode(code, msg!), msg, dataObj); - } - return root.TryGetProperty("data", out var dataOk) && dataOk.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) ? dataOk.Clone() : null; - } - return root.Clone(); - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiErrorCode.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiErrorCode.cs deleted file mode 100644 index b4333de..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiErrorCode.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// -/// 业务错误码载体(对齐 Java OpenApiErrorCode):原样保存服务端返回的 code 与 msg, -/// 由业务失败信封解析后携带给 的调用方。 -/// -public class OpenApiErrorCode : IErrorCode -{ - public int Code { get; } - - public string Msg { get; } - - public OpenApiErrorCode(int code, string msg) - { - Code = code; - Msg = msg; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiException.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiException.cs deleted file mode 100644 index c2edb31..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiException.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// -/// HTTP 层异常(对齐 Java OpenApiException):非 2xx 响应或反序列化失败时抛出,保留 HTTP 状态码与响应体。 -/// 业务失败(HTTP 200 + code≠0 信封)抛 。 -/// -public class OpenApiException : Exception -{ - public int? HttpStatus { get; } - - public string? ResponseBody { get; } - - public OpenApiException(string message) - : base(message) - { - } - - public OpenApiException(string message, Exception? rawException) - : base(message, rawException) - { - } - - public OpenApiException(string message, int? httpStatus, string? responseBody) - : base(message) - { - HttpStatus = httpStatus; - ResponseBody = responseBody; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiGroupClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiGroupClient.cs deleted file mode 100644 index 3fc0d41..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiGroupClient.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Text.Json; -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// 的 HTTP 实现(镜像 Java DefaultOpenApiGroupClient)。 -public class OpenApiGroupClient : OpenApiClientBase, IOpenApiGroupClient -{ - public OpenApiGroupClient(HttpClient httpClient, OpenApiOptions options) - : base(httpClient, options) - { - } - - public Task GetGroupTreeAsync(CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/group/tree", ct: ct); - - public Task GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/group/byId", - Params(("groupId", groupId), ("withSub", withSub)), ct: ct); - - public Task> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Prefix + "/group/list", - Params(("ids", ids), ("withSub", withSub)), ct: ct); - - public Task AddGroupAsync(SysGroupRequest request, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Post, Prefix + "/group/add", body: request, ct: ct); - - public Task EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Post, Prefix + "/group/edit/" + id, body: request, ct: ct); - - public Task DeleteGroupAsync(int id, int schoolId, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/group/delete/" + id + "/" + schoolId, ct: ct); - - public Task> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Post, Prefix + "/group/batchAdd", body: request, ct: ct); - - public Task MoveGroupAsync(int schoolId, SysGroupMoveRequest request, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/group/move/" + schoolId, body: request, ct: ct); -} - -/// 的 HTTP 实现(镜像 Java DefaultOpenApiAppClient)。 -public class OpenApiAppClient : OpenApiClientBase, IOpenApiAppClient -{ - public OpenApiAppClient(HttpClient httpClient, OpenApiOptions options) - : base(httpClient, options) - { - } - - public Task GetAppByIdAsync(string appId, CancellationToken ct = default) - => SendAsync(HttpMethod.Get, Prefix + "/app/" + Uri.EscapeDataString(appId), ct: ct); - - public Task> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Prefix + "/app/list", - Params(("userId", userId), ("clientType", (int)clientType)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiOptions.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiOptions.cs deleted file mode 100644 index 5b54710..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiOptions.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace Yh.Platform.OpenApi; - -/// -/// 基础平台开放接口(UserBase)客户端配置,配置节 yh:open(对齐 Java yh.open)。 -/// -public class OpenApiOptions -{ - public const string SectionName = "yh:open"; - - /// 是否启用(默认 true) - public bool Enabled { get; set; } = true; - - /// UserBase 基础地址,例如 http://base.bestsch.com - public string? BaseHost { get; set; } - - /// UserBase 基础路径(默认 /userbase;代理改挂其他路径时配置兜底) - public string? BasePath { get; set; } = "/userbase"; - - /// 连接超时(毫秒),对齐 Java 默认 1500ms - public long ConnectTimeoutMs { get; set; } = 1500; - - /// 读超时(毫秒),对齐 Java 默认 5000ms - public long ReadTimeoutMs { get; set; } = 5000; - - /// 写超时(毫秒),对齐 Java 默认 5000ms - public long WriteTimeoutMs { get; set; } = 5000; - - /// 调用方在 UserBase 注册的应用标识(用于签名) - public string? AppKey { get; set; } - - /// 调用方在 UserBase 注册的密钥(用于签名) - public string? AppSecret { get; set; } - - /// 拼接 baseHost + basePath 为统一开放接口基址。 - public string ResolveBaseUrl() - { - var baseHost = BaseHost?.Trim() ?? ""; - if (baseHost.EndsWith('/')) - { - baseHost = baseHost.TrimEnd('/'); - } - return baseHost + (NormalizeBasePath(BasePath) ?? ""); - } - - /// - /// 归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null(挂载在根路径)。 - /// - public static string? NormalizeBasePath(string? path) - { - if (path == null) - { - return null; - } - var trimmed = path.Trim().TrimEnd('/'); - if (trimmed.Length == 0) - { - return null; - } - return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiServiceCollectionExtensions.cs deleted file mode 100644 index 10accb5..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiServiceCollectionExtensions.cs +++ /dev/null @@ -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; - -/// 静态门面自动装配宿主服务(等价 Java starter 启动时 Set*Client 注入)。 -internal sealed class YschFacadeWiring : IHostedService -{ - private readonly IServiceProvider _provider; - - public YschFacadeWiring(IServiceProvider provider) - { - _provider = provider; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - YschBaseOpenApi.SetUserClient(_provider.GetRequiredService()); - YschBaseOpenApi.SetGroupClient(_provider.GetRequiredService()); - YschBaseOpenApi.SetAppClient(_provider.GetRequiredService()); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} - -public static class OpenApiServiceCollectionExtensions -{ - public const string HttpClientName = "yh-openapi"; - - /// - /// 注册 /oapi 客户端(对齐 Java OpenApiAutoConfiguration): - /// IHttpClientFactory + typed client + 签名 DelegatingHandler,并在宿主启动时装配静态门面。 - /// - public static IServiceCollection AddYhOpenApi(this IServiceCollection services, IConfiguration configuration) - { - services.AddOptions() - .Bind(configuration.GetSection(OpenApiOptions.SectionName)) - .PostConfigure(o => o.BasePath = OpenApiOptions.NormalizeBasePath(o.BasePath)); - - services.AddHttpClient(HttpClientName) - .ConfigureHttpClient((sp, client) => - { - var options = sp.GetRequiredService>().Value; - client.Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000); - }) - .ConfigurePrimaryHttpMessageHandler(sp => - { - var options = sp.GetRequiredService>().Value; - return new SocketsHttpHandler - { - ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500), - }; - }) - .AddHttpMessageHandler(sp => - { - var options = sp.GetRequiredService>().Value; - return new AppSignHandler(options.AppKey ?? "", options.AppSecret ?? "", options.BasePath); - }); - - services.AddSingleton(sp => - { - var factory = sp.GetRequiredService(); - return new OpenApiUserClient(factory.CreateClient(HttpClientName), sp.GetRequiredService>().Value); - }); - services.AddSingleton(sp => - { - var factory = sp.GetRequiredService(); - return new OpenApiGroupClient(factory.CreateClient(HttpClientName), sp.GetRequiredService>().Value); - }); - services.AddSingleton(sp => - { - var factory = sp.GetRequiredService(); - return new OpenApiAppClient(factory.CreateClient(HttpClientName), sp.GetRequiredService>().Value); - }); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - - services.AddHostedService(); - return services; - } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiUserClient.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiUserClient.cs deleted file mode 100644 index e067867..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/OpenApiUserClient.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// 的 HTTP 实现(镜像 Java DefaultOpenApiUserClient)。 -public class OpenApiUserClient : OpenApiClientBase, IOpenApiUserClient -{ - public OpenApiUserClient(HttpClient httpClient, OpenApiOptions options) - : base(httpClient, options) - { - } - - public Task GetUserByIdAsync(string userId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct); - - public Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/user/byLoginName", Params(("loginName", loginName)), ct: ct); - - public Task GetUserByPhoneAsync(string phone, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/user/byPhone", Params(("phone", phone)), ct: ct); - - public Task GetUserInfoAsync(int userId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct); - - public Task GetUserInfoAsync(string loginName, CancellationToken ct = default) - => GetUserByLoginNameAsync(loginName, ct); - - public Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Prefix + "/user/modules", - Params(("userId", userId), ("schId", schoolId)), ct: ct); - - public Task AddUserAsync(SysUserRequest request, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Post, Prefix + "/user/add", body: request, ct: ct); - - public Task EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default) - { - request.Id = id; - return ResolveAsync(HttpMethod.Post, Prefix + "/user/edit/" + id, body: request, ct: ct); - } - - public Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/delete/" + id + "/" + groupId + "/" + schoolId, ct: ct); - - public Task DeleteUsersAsync(IReadOnlyList items, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/deleteUsers", body: items, ct: ct); - - public Task> BatchAddUsersAsync(IReadOnlyList requests, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Post, Prefix + "/user/batchAdd", body: requests, ct: ct); - - public Task MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Post, Prefix + "/user/moveToGroup", body: request, ct: ct); - - public Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Prefix + "/user/roles", Params(("userId", userId)), ct: ct); - - public Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/identity/current", Params(("userId", userId)), ct: ct); - - public Task> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Prefix + "/identity/available", Params(("userId", userId)), ct: ct); - - public Task SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Post, Prefix + "/identity/switch", - body: new Dictionary { ["userId"] = userId, ["roleId"] = roleId, ["schoolId"] = schoolId }, ct: ct); - - public Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/password", - body: new Dictionary { ["userId"] = userId, ["oldPassword"] = oldPassword, ["newPassword"] = newPassword }, ct: ct); - - public Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/groupState", body: request, ct: ct); - - public Task GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Prefix + "/user/groupState", - Params(("userId", userId), ("groupId", groupId), ("schoolId", schoolId)), ct: ct); - - public Task TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Post, Prefix + "/user/transferToSchool", body: request, ct: ct); - - public Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/changeStatus", - body: new Dictionary { ["userId"] = userId, ["disabled"] = disabled }, ct: ct); - - public Task BatchChangeUserStatusAsync(IReadOnlyList userIds, bool disabled, CancellationToken ct = default) - => ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/batchChangeStatus", - body: new Dictionary { ["userIds"] = userIds, ["disabled"] = disabled }, ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysGroupRequests.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysGroupRequests.cs deleted file mode 100644 index b5d858e..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysGroupRequests.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace Yh.Platform.OpenApi; - -/// 新增/编辑组请求(对齐 UserBase SysGroupRequest)。编辑以路径 id 为准;Id 字段对齐服务端契约键(快照对拍 2026-08-28)。 -public class SysGroupRequest -{ - /// 组 ID(新增忽略;编辑时以路径 id 为权威,此字段仅保持契约键完整) - public int? Id { get; set; } - - public string? Name { get; set; } - - public int? ParentId { get; set; } - - public int? Type { get; set; } - - public int? Sort { get; set; } -} - -/// 批量新增子组请求:在 Pid 下生成一批同类型子组。 -public class SysGroupBatchRequest -{ - /// 父组 ID(挂载点,必须存在) - public int? Pid { get; set; } - - /// 应用于本次生成的所有子组的类型 - public int? Type { get; set; } - - /// 子组列表,每项仅需 Name - public List? Items { get; set; } - - /// 单个子组项,仅需 Name - public class BatchItem - { - public string? Name { get; set; } - } -} - -/// 移动组请求:MoveId 移动到 Pid 下,两者都必须落在 schoolId 子树内;schoolId 走路径参数。 -public class SysGroupMoveRequest -{ - /// 待移动节点ID - public int? MoveId { get; set; } - - /// 目标父节点ID(移动后的挂载点) - public int? Pid { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysUserRequests.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysUserRequests.cs deleted file mode 100644 index 0f8d128..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/SysUserRequests.cs +++ /dev/null @@ -1,111 +0,0 @@ -namespace Yh.Platform.OpenApi; - -/// 新增/编辑用户请求(对齐 UserBase SysUserRequest)。编辑时 Id 必填且与路径 id 一致。 -public class SysUserRequest -{ - /// 用户 ID(编辑必填,新增忽略) - public int? Id { get; set; } - - /// 初始归属组 ID;新增必填,编辑不校验 - public int? GroupId { get; set; } - - /// 登录账号(唯一) - public string? Account { get; set; } - - /// 登录密码(明文,服务端 BCrypt 加密存储) - public string? Password { get; set; } - - /// 确认密码,需与 Password 一致 - public string? ConfirmPassword { get; set; } - - /// 姓名 - public string? RealName { get; set; } - - /// 手机号 - public string? Phone { get; set; } - - /// 身份证号 - public string? Idcard { get; set; } - - /// 邮箱 - public string? Email { get; set; } - - /// 性别 - public int? Gender { get; set; } - - /// 生日(YYYY-MM-DD) - public string? Birthday { get; set; } - - /// 头像 URL - public string? ProfilePicture { get; set; } - - /// 归属学校组 ID(服务端 SysUserRequest.roleIds/schoolId 契约;与 GroupId 二选一维度,见 UserBase 文档) - public int? SchoolId { get; set; } - - /// 角色 ID 列表(契约键为 roleIds,对齐 UserBase 导出快照 2026-08-28 对拍) - public List? RoleIds { get; set; } - - /// 禁用状态(0=正常,1=禁用;不传默认启用) - public int? Disabled { get; set; } - - /// 是否过期;不传默认 false - public bool? Expired { get; set; } -} - -/// 批量删除用户-组关系单项(对齐 UserBase SysUserGroupDeleteItem)。 -public class SysUserGroupDeleteItem -{ - /// 待解绑用户 ID - public int? UserId { get; set; } - - /// 待解绑的组 ID(必须属于 schoolId 子树,且不能等于 schoolId) - public int? GroupId { get; set; } - - /// 学校组 ID:限定该条解绑的学校范围 - public int? SchoolId { get; set; } -} - -/// 移动用户到指定组(同校内,覆盖式替换主组)请求。 -public class SysUserMoveRequest -{ - /// 待移动用户 ID - public int? UserId { get; set; } - - /// 目标组 ID(移动后的主归属组) - public int? GroupId { get; set; } - - /// 学校组 ID:限定移动范围,仅允许同一学校内移动 - public int? SchoolId { get; set; } -} - -/// 设置用户组状态请求:state=1 转出、0=恢复正常;groupId 必须属于 schoolId 子树。 -public class SysUserGroupStateRequest -{ - /// 用户 ID - public int? UserId { get; set; } - - /// 组 ID(用户所在的具体组) - public int? GroupId { get; set; } - - /// 学校组 ID:限定操作范围 - public int? SchoolId { get; set; } - - /// 目标状态:0=恢复正常,1=转出 - public int? State { get; set; } -} - -/// 跨校转入请求:前置为源学校子树内存在至少一条「转出」状态组关系。 -public class SysUserTransferSchoolRequest -{ - /// 待迁移用户 ID - public int? UserId { get; set; } - - /// 原学校组 ID(源学校) - public int? FromSchoolId { get; set; } - - /// 目标学校组 ID - public int? SchoolId { get; set; } - - /// 目标组 ID(迁移后的主归属组) - public int? GroupId { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/Yh.Platform.OpenApi.csproj b/BasePlatformDotnet/src/Yh.Platform.OpenApi/Yh.Platform.OpenApi.csproj deleted file mode 100644 index 737e943..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/Yh.Platform.OpenApi.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Yh.Platform.OpenApi - yunhai 平台 /oapi 开放接口客户端(对应 Java yh-openapi-client):用户/组/应用三域客户端、信封感知解析、静态门面 YschBaseOpenApi 与实例门面 BaseOpenApi - UserBase /oapi 同构客户端 SDK,签名请求自动附加三头 - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/src/Yh.Platform.OpenApi/YschBaseOpenApi.cs b/BasePlatformDotnet/src/Yh.Platform.OpenApi/YschBaseOpenApi.cs deleted file mode 100644 index 72c973f..0000000 --- a/BasePlatformDotnet/src/Yh.Platform.OpenApi/YschBaseOpenApi.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Text.Json; -using Yh.Platform.Abstractions; - -namespace Yh.Platform.OpenApi; - -/// -/// 基础平台开放接口统一门面(静态入口,对齐 Java YschBaseOpenApi)。 -/// 下游业务只依赖本类;内部按域聚合三客户端,由 -/// 在宿主启动时自动注入(或手动 Set*Client)。 -/// 门面准入原则:仅保留高频读接口;低频写操作请注入 / 。 -/// -public static class YschBaseOpenApi -{ - private static volatile IOpenApiUserClient? _userClient; - private static volatile IOpenApiGroupClient? _groupClient; - private static volatile IOpenApiAppClient? _appClient; - private static readonly object Lock = new(); - - public static void SetUserClient(IOpenApiUserClient? client) { lock (Lock) { _userClient = client; } } - - public static void SetGroupClient(IOpenApiGroupClient? client) { lock (Lock) { _groupClient = client; } } - - public static void SetAppClient(IOpenApiAppClient? client) { lock (Lock) { _appClient = client; } } - - // ============================ 用户域 ============================ - - public static Task GetUserInfoAsync(int userId, CancellationToken ct = default) => User().GetUserInfoAsync(userId, ct); - - public static Task GetUserInfoAsync(string loginName, CancellationToken ct = default) => User().GetUserInfoAsync(loginName, ct); - - public static Task GetUserByIdAsync(string userId, CancellationToken ct = default) => User().GetUserByIdAsync(userId, ct); - - public static Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) => User().GetUserByLoginNameAsync(loginName, ct); - - public static Task GetUserByPhoneAsync(string phone, CancellationToken ct = default) => User().GetUserByPhoneAsync(phone, ct); - - public static Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) => User().GetUserModuleAsync(userId, schoolId, ct); - - public static Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) => User().GetUserRoleListAsync(userId, ct); - - /// 查询用户当前激活身份(未切换时兜底 is_main 主身份;无任何身份返回 null)。 - public static Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default) => User().GetCurrentIdentityAsync(userId, ct); - - // ============================ 组织/组域 ============================ - - public static Task GetGroupTreeAsync(CancellationToken ct = default) => Group().GetGroupTreeAsync(ct); - - public static Task GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupByIdAsync(groupId, withSub, ct); - - public static Task> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupListAsync(ids, withSub, ct); - - // ============================ 应用域 ============================ - - public static Task GetAppByIdAsync(string appId, CancellationToken ct = default) => App().GetAppByIdAsync(appId, ct); - - public static Task> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default) => App().GetUserAppAsync(userId, clientType, ct); - - private static IOpenApiUserClient User() - => _userClient ?? throw new InvalidOperationException( - "YschBaseOpenApi 尚未初始化(UserClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetUserClient(...)。"); - - private static IOpenApiGroupClient Group() - => _groupClient ?? throw new InvalidOperationException( - "YschBaseOpenApi 尚未初始化(GroupClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetGroupClient(...)。"); - - private static IOpenApiAppClient App() - => _appClient ?? throw new InvalidOperationException( - "YschBaseOpenApi 尚未初始化(AppClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetAppClient(...)。"); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/GlobalUsings.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/GlobalUsings.cs deleted file mode 100644 index 6d8f050..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/GlobalUsings.cs +++ /dev/null @@ -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 兼容行的 .NET 承载类型(net5.0 可用,不依赖 net6+ 的 JsonNode) -global using SchClassRow = System.Collections.Generic.Dictionary; diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/SchInfoVo.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/SchInfoVo.cs deleted file mode 100644 index 566a1a1..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/SchInfoVo.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yh.SchClass.OpenApi.Model; - -/// -/// 学校视图对象(镜像 Java com.yunhai.bschclassmanage.model.vo.oa.SchInfoVO)。 -/// Java 侧以 @JsonProperty 显式指定键名,故此处按原样锚定。 -/// -public class SchInfoVo -{ - /// 学校外部序列号 - [JsonPropertyName("SchSerId")] - public string? SchSerId { get; set; } - - /// 用户中心学校 ID - [JsonPropertyName("UserSchSerId")] - public int? UserSchSerId { get; set; } - - /// 学校名称 - [JsonPropertyName("SchName")] - public string? SchName { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/TeaInfoVo.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/TeaInfoVo.cs deleted file mode 100644 index cacb830..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/TeaInfoVo.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yh.SchClass.OpenApi.Model; - -/// -/// 教师信息视图对象(镜像 Java com.yunhai.bschclassmanage.model.oa.TeaInfoVO)。 -/// -/// Java 侧为 Lombok @Data,序列化键名由 Jackson 从 getter 推导;本客户端大小写不敏感反序列化, -/// 故按 Java 字段原名锚定即可双向兼容。 -/// -/// -public class TeaInfoVo -{ - /// 学校外部序列号 - [JsonPropertyName("SchSerID")] public string? SchSerId { get; set; } - - /// 政治面貌 - [JsonPropertyName("Teapolitical")] public string? TeaPolitical { get; set; } - - /// 参加工作日期 - [JsonPropertyName("TeaWorkStart")] public string? TeaWorkStart { get; set; } - - /// 登录账户 - [JsonPropertyName("Account")] public string? Account { get; set; } - - /// 学校序列号(多校场景,当前与 SchSerID 相同) - [JsonPropertyName("SchSerIDs")] public string? SchSerIds { get; set; } - - /// 角色 - [JsonPropertyName("role")] public int? Role { get; set; } - - /// 教育局 - [JsonPropertyName("JiaoYuJu")] public string? JiaoYuJu { get; set; } - - /// 性别(文本) - [JsonPropertyName("TeaSex")] public string? TeaSex { get; set; } - - /// 是否中层以上干部 - [JsonPropertyName("IsLeadingMiddle")] public int? IsLeadingMiddle { get; set; } - - /// 籍贯 - [JsonPropertyName("TeaOriginPlace")] public string? TeaOriginPlace { get; set; } - - /// 头像 - [JsonPropertyName("Photo")] public string? Photo { get; set; } - - /// 身份证号 - [JsonPropertyName("TeaIDCard")] public string? TeaIdCard { get; set; } - - /// 姓名 - [JsonPropertyName("Name")] public string? Name { get; set; } - - /// 分组 ID - [JsonPropertyName("GroupID")] public int? GroupId { get; set; } - - /// 学校名称 - [JsonPropertyName("SchName")] public string? SchName { get; set; } - - /// 从教日期 - [JsonPropertyName("TeaTeachStart")] public string? TeaTeachStart { get; set; } - - /// 教师序列号(teaId 的字符串形式) - [JsonPropertyName("TeaSerID")] public string? TeaSerId { get; set; } - - /// 用户 ID - [JsonPropertyName("UserID")] public int? UserId { get; set; } - - /// 民族 - [JsonPropertyName("TeaNation")] public string? TeaNation { get; set; } - - /// 电话 - [JsonPropertyName("Tel")] public string? Tel { get; set; } - - /// 工作类型 - [JsonPropertyName("TeaWorkType")] public string? TeaWorkType { get; set; } - - /// 教师编号 - [JsonPropertyName("TeaNo")] public int? TeaNo { get; set; } - - /// 出生日期 - [JsonPropertyName("TeaBirth")] public string? TeaBirth { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/UserInfoVo.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/UserInfoVo.cs deleted file mode 100644 index 067aa76..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Model/UserInfoVo.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Yh.SchClass.OpenApi.Model; - -/// -/// 用户信息视图对象(镜像 Java com.yunhai.bschclassmanage.model.oa.UserInfoVO)。 -/// -/// Java 侧为 Lombok @Data,序列化键名由 Jackson 从 getter 推导(如 getSchSerID() → schSerID); -/// 本客户端使用大小写不敏感反序列化(),故此处按 Java 字段原名锚定即可双向兼容。 -/// -/// -public class UserInfoVo -{ - /// 学校外部序列号 - [JsonPropertyName("SchSerID")] public string? SchSerId { get; set; } - - /// 编号(教师为 sort) - [JsonPropertyName("No")] public int? No { get; set; } - - /// 身份证号 - [JsonPropertyName("IDCard")] public string? IdCard { get; set; } - - /// 用户 ID(教师为 teaId,学生为 stuId) - [JsonPropertyName("UserID")] public int? UserId { get; set; } - - /// 性别 - [JsonPropertyName("sex")] public int? Sex { get; set; } - - /// 用户类型(教师 1 / 学生按 UserRole) - [JsonPropertyName("usertype")] public int? UserType { get; set; } - - /// 序列号(Java 侧恒为 null) - [JsonPropertyName("SerID")] public string? SerId { get; set; } - - /// 头像(Java 侧恒为 null) - [JsonPropertyName("Photo")] public string? Photo { get; set; } - - /// 电话(学生为 null) - [JsonPropertyName("Tel")] public string? Tel { get; set; } - - /// 登录账户 - [JsonPropertyName("Uids")] public string? Uids { get; set; } - - /// 分组 ID - [JsonPropertyName("GroupID")] public int? GroupId { get; set; } - - /// 姓名 - [JsonPropertyName("Name")] public string? Name { get; set; } -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassClientBase.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassClientBase.cs deleted file mode 100644 index 571e124..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassClientBase.cs +++ /dev/null @@ -1,217 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务(SchClass / bschclassmanage)开放接口客户端基类。 -/// -/// 与 Yh.Platform.OpenApi.OpenApiClientBase 同构,但刻意保持零项目依赖,便于单独打包引用。 -/// -/// 响应约定(Java 侧 OA 兼容端点):成功时直接返回裸数据(对象 / 数组 / 字符串数组),无统一信封; -/// 部分端点以 HTTP 404 / 204 表示"无数据",以 HTTP 400 表示参数缺失。因此本基类默认把 404 与 204 -/// 归一为 null(列表则为空列表),仅对 400 及其它非 2xx 抛 。 -/// -/// -public abstract class SchClassClientBase -{ - /// 教务开放接口统一前缀(与 Java Config.OAPI 严格一致) - protected const string Prefix = "/oapi"; - - private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); - - protected HttpClient HttpClient { get; } - - protected SchClassOpenApiOptions Options { get; } - - protected SchClassClientBase(HttpClient httpClient, SchClassOpenApiOptions options) - { - HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - Options = options ?? throw new ArgumentNullException(nameof(options)); - } - - // ====================== 请求构造 ====================== - - /// 构造查询参数;值为 null 的参数会被跳过(对应 Java 侧 required=false)。 - protected static Dictionary Params(params (string key, object? value)[] kv) - { - var map = new Dictionary(StringComparer.Ordinal); - if (kv == null) - { - return map; - } - foreach (var (key, value) in kv) - { - if (key != null && value != null) - { - map[key] = value.ToString(); - } - } - return map; - } - - private static string BuildQuery(Dictionary? query) - { - if (query == null || query.Count == 0) - { - return ""; - } - var sb = new StringBuilder(); - foreach (var kv in query) - { - if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) - { - continue; - } - sb.Append(sb.Length == 0 ? '?' : '&') - .Append(Uri.EscapeDataString(kv.Key)) - .Append('=') - .Append(Uri.EscapeDataString(kv.Value)); - } - return sb.ToString(); - } - - // ====================== 发送与解析 ====================== - - /// - /// 发送请求并返回裸数据节点(可能为 null)。 - /// 为 时按 text/plain 原样提交(对应 Java @RequestBody String), - /// 其余类型按 application/json 序列化后提交。 - /// - /// 为 true 时把 HTTP 404 / 204 归一为 null(OA 端点惯用的"无数据"语义)。 - protected async Task SendAsync(HttpMethod method, string path, - Dictionary? query = null, object? body = null, - bool notFoundAsNull = true, CancellationToken ct = default) - { - var url = Options.ResolveBaseUrl() + path + BuildQuery(query); - using var request = new HttpRequestMessage(method, url); - if (body != null) - { - request.Content = body is string raw - ? new StringContent(raw, Encoding.UTF8, "text/plain") - : new StringContent(JsonSerializer.Serialize(body, JsonOpts), Encoding.UTF8, "application/json"); - } - - using var response = await HttpClient.SendAsync(request, ct).ConfigureAwait(false); - var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - var status = (int)response.StatusCode; - - // OA 兼容端点惯用 404 / 204 表达"查无数据",不是调用失败 - if ((status == 404 && notFoundAsNull) || status == 204 || status == 205) - { - return null; - } - if (!response.IsSuccessStatusCode) - { - throw new SchClassOpenApiException("SchClass OpenAPI HTTP " + status + ": " + text, status, text); - } - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - try - { - using var doc = JsonDocument.Parse(text); - return Unwrap(doc.RootElement); - } - catch (JsonException e) - { - throw new SchClassOpenApiException("SchClass OpenAPI 响应解析失败: " + e.Message, status, text); - } - } - - /// 裸数据反序列化为 。 - protected async Task ResolveAsync(HttpMethod method, string path, - Dictionary? query = null, object? body = null, - bool notFoundAsNull = true, CancellationToken ct = default) - { - var data = await SendAsync(method, path, query, body, notFoundAsNull, ct).ConfigureAwait(false); - if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - { - return default; - } - try - { - return JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts); - } - catch (JsonException) - { - throw new SchClassOpenApiException("SchClass OpenAPI 反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText()); - } - } - - /// 裸数组 / 单对象反序列化为列表;无数据时返回空列表(不会为 null)。 - protected async Task> ResolveListAsync(HttpMethod method, string path, - Dictionary? query = null, object? body = null, - bool notFoundAsNull = true, CancellationToken ct = default) - { - var data = await SendAsync(method, path, query, body, notFoundAsNull, ct).ConfigureAwait(false); - var result = new List(); - if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - { - return result; - } - try - { - if (data.Value.ValueKind == JsonValueKind.Array) - { - foreach (var item in data.Value.EnumerateArray()) - { - if (item.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined)) - { - result.Add(JsonSerializer.Deserialize(item.GetRawText(), JsonOpts)!); - } - } - } - else - { - result.Add(JsonSerializer.Deserialize(data.Value.GetRawText(), JsonOpts)!); - } - } - catch (JsonException) - { - throw new SchClassOpenApiException("SchClass OpenAPI 列表反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText()); - } - return result; - } - - /// - /// 弱类型兼容行(Java Map<String,Object>)列表;无数据时返回空列表。 - /// 每行键名与 Java 侧 LinkedHashMap 的 put 顺序完全一致。 - /// - protected Task> ResolveRowsAsync(HttpMethod method, string path, - Dictionary? query = null, object? body = null, - bool notFoundAsNull = true, CancellationToken ct = default) - => ResolveListAsync(method, path, query, body, notFoundAsNull, ct); - - /// - /// 信封感知:仅当同时出现数字 code 与(data 或字符串 msg)时才认定为信封, - /// 避免把业务行里恰好叫 code 的字段(如学制等级的 code=gradeId)误判成错误响应。 - /// - private static JsonElement? Unwrap(JsonElement root) - { - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty("code", out var codeEl) - || codeEl.ValueKind != JsonValueKind.Number) - { - return root.Clone(); - } - - var hasData = root.TryGetProperty("data", out var dataEl); - var hasMsg = root.TryGetProperty("msg", out var msgEl) && msgEl.ValueKind == JsonValueKind.String; - if (!hasData && !hasMsg) - { - // 业务行里恰好含 code 字段(如学制等级的 code=gradeId),不是错误信封 - return root.Clone(); - } - - codeEl.TryGetInt32(out var code); - if (code != 0) - { - var msg = hasMsg ? msgEl.GetString() : null; - throw new SchClassOpenApiException( - string.IsNullOrEmpty(msg) ? "SchClass OpenAPI 业务错误" : msg!, 0, root.GetRawText()); - } - return hasData && dataEl.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) - ? dataEl.Clone() - : null; - } -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassDbClassClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassDbClassClient.cs deleted file mode 100644 index a6fa833..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassDbClassClient.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 DbClass 接口客户端(镜像 Java DbClassController,路径前缀 /oapi/DbClass/)。 -/// -public class SchClassDbClassClient : SchClassClientBase -{ - private const string Root = Prefix + "/DbClass/"; - - public SchClassDbClassClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// - /// 查询学校教师名单(GET /oapi/DbClass/GetSchteaList)。 - /// 每位教师的每个有效分组归属返回一行(同一教师多分组会重复出现)。 - /// 行字段:SchSerID / TeaTel / TeaName / UserTeaSerID / TeaIDCard / TeaNo / GroupID。 - /// - /// 学校 ID(Java 参数名 Group),必须大于 0;<=0 时服务端返回 400。 - public Task> GetSchteaListAsync(int group, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchteaList", Params(("Group", group)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassFamilyCompatClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassFamilyCompatClient.cs deleted file mode 100644 index 5338398..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassFamilyCompatClient.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务家长兼容接口客户端(镜像 Java FamilyCompatController,路径前缀 /oapi/Common/FamCom/)。 -/// 两个端点当前服务端均恒返回空数据,保留调用以兼容既有消费方。 -/// -public class SchClassFamilyCompatClient : SchClassClientBase -{ - private const string Root = Prefix + "/Common/FamCom/"; - - public SchClassFamilyCompatClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// - /// 查询学生家长用户 ID 列表,POST /oapi/Common/FamCom/GetStuUserFamIDList; - /// 请求体为逗号分隔的学生 ID(Java 侧 @RequestBody String,按 text/plain 提交)。 - /// - public Task> GetStuUserFamIdListAsync(string? stuIds, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Post, Root + "GetStuUserFamIDList", body: stuIds, ct: ct); - - /// - /// 查询学生家庭成员,POST /oapi/Common/FamCom/GetAllStuFamMem; - /// 请求体为逗号分隔的学生 ID(text/plain)。 - /// - public Task> GetAllStuFamMemAsync(string? stuIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Post, Root + "GetAllStuFamMem", body: stuIds, ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApi.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApi.cs deleted file mode 100644 index f66e598..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApi.cs +++ /dev/null @@ -1,100 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务(SchClass)开放接口聚合门面:一个入口访问全部 OA 兼容端点。 -/// -/// 用法: -/// -/// var api = SchClassOpenApiFactory.Create(new SchClassOpenApiOptions { BaseHost = "http://bschclassmanage" }); -/// var schools = await api.SchInfo.AllListAsync(); -/// -/// -/// -public sealed class SchClassOpenApi -{ - public SchClassOpenApi(HttpClient httpClient, SchClassOpenApiOptions options) - { - if (httpClient == null) throw new ArgumentNullException(nameof(httpClient)); - if (options == null) throw new ArgumentNullException(nameof(options)); - - Options = options; - HttpClient = httpClient; - - DbClass = new SchClassDbClassClient(httpClient, options); - VClass = new SchClassVClassClient(httpClient, options); - Schooling = new SchClassSchoolingClient(httpClient, options); - UserInfo = new SchClassUserInfoClient(httpClient, options); - SchInfo = new SchClassSchInfoClient(httpClient, options); - TeaInfo = new SchClassTeaInfoClient(httpClient, options); - StuInfo = new SchClassStuInfoClient(httpClient, options); - TeachInfo = new SchClassTeachInfoClient(httpClient, options); - SubInfo = new SchClassSubInfoClient(httpClient, options); - FamilyCompat = new SchClassFamilyCompatClient(httpClient, options); - } - - /// 当前生效的配置。 - public SchClassOpenApiOptions Options { get; } - - /// 底层 HttpClient 的 BaseAddress(配置为空时为 null)。 - public Uri? HttpBaseAddress => HttpClient.BaseAddress; - - private HttpClient HttpClient { get; } - - /// DbClass:学校教师名单(/oapi/DbClass/)。 - public SchClassDbClassClient DbClass { get; } - - /// VSchClass:班级(/oapi/VSchClass/)。 - public SchClassVClassClient VClass { get; } - - /// Schoolen:学制与年级(/oapi/Schoolen/)。 - public SchClassSchoolingClient Schooling { get; } - - /// UserInfo:师生统一用户(/oapi/UserInfo/)。 - public SchClassUserInfoClient UserInfo { get; } - - /// SchInfo:学校(/oapi/SchInfo/)。 - public SchClassSchInfoClient SchInfo { get; } - - /// OATea:教师(/oapi/OATea/)。 - public SchClassTeaInfoClient TeaInfo { get; } - - /// OAStu:学生(/oapi/OAStu/)。 - public SchClassStuInfoClient StuInfo { get; } - - /// TeachInfo:任课(/oapi/TeachInfo/)。 - public SchClassTeachInfoClient TeachInfo { get; } - - /// SubInfo:科目(/oapi/SubInfo/)。 - public SchClassSubInfoClient SubInfo { get; } - - /// FamCom:家长兼容(/oapi/Common/FamCom/)。 - public SchClassFamilyCompatClient FamilyCompat { get; } -} - -/// 的轻量工厂(不依赖 DI 容器)。 -public static class SchClassOpenApiFactory -{ - /// - /// 按配置创建 :预设 BaseAddress 与读超时。 - /// 生产环境建议改用 IHttpClientFactory 注入实例,本方法仅用于快速接入与测试。 - /// - public static HttpClient CreateHttpClient(SchClassOpenApiOptions options) - { - if (options == null) throw new ArgumentNullException(nameof(options)); - - var client = new HttpClient - { - Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs <= 0 ? 5000 : options.ReadTimeoutMs) - }; - var baseUrl = options.ResolveBaseUrl(); - if (!string.IsNullOrEmpty(baseUrl)) - { - client.BaseAddress = new Uri(baseUrl, UriKind.Absolute); - } - return client; - } - - /// 按配置创建一个完整的门面实例(内部自建 HttpClient,调用方负责复用而非频繁创建)。 - public static SchClassOpenApi Create(SchClassOpenApiOptions options) - => new SchClassOpenApi(CreateHttpClient(options), options); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiException.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiException.cs deleted file mode 100644 index 6757bbd..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiException.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// 教务开放接口调用异常(HTTP 非 2xx 或响应解析失败)。 -public class SchClassOpenApiException : Exception -{ - /// HTTP 状态码;非 HTTP 层错误时为 0。 - public int StatusCode { get; } - - /// 原始响应文本(若有)。 - public string? ResponseBody { get; } - - public SchClassOpenApiException(string message, int statusCode = 0, string? responseBody = null) - : base(message) - { - StatusCode = statusCode; - ResponseBody = responseBody; - } -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiOptions.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiOptions.cs deleted file mode 100644 index afa3cf0..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiOptions.cs +++ /dev/null @@ -1,107 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 云海教务(SchClass / bschclassmanage)开放接口客户端配置,配置节 yh:schclass:open。 -/// 与 UserBase 的 OpenApiOptions 同构,但独立配置节,便于分别指向不同后端。 -/// -public class SchClassOpenApiOptions -{ - public const string SectionName = "yh:schclass:open"; - - /// - /// 共享开放接口配置节(与 UserBase 的 OpenApiOptions 同节): - /// 其中 BaseHost 与 SchClassPath 可作为教务客户端的缺省来源。 - /// - /// "yh": { - /// "open": { - /// "BaseHost": "http://base.yunhaixingtong.com", - /// "BasePath": "/userbase", ← UserBase 用 - /// "SchClassPath": "/bschclass" ← 教务(SchClass)用 - /// } - /// } - /// - /// - public const string SharedSectionName = "yh:open"; - - /// 共享节里教务路径的键名。 - public const string SchClassPathKey = "SchClassPath"; - - /// HTTP 客户端逻辑名(IHttpClientFactory)。 - public const string HttpClientName = "yh-schclass-openapi"; - - /// 是否启用(默认 true) - public bool Enabled { get; set; } = true; - - /// 教务开放接口基础地址,例如 http://base.yunhaixingtong.com - public string? BaseHost { get; set; } - - /// 基础路径(默认 null,挂载在根路径);联调环境为 /bschclass - public string? BasePath { get; set; } - - /// 连接超时(毫秒),默认 1500 - public long ConnectTimeoutMs { get; set; } = 1500; - - /// 读超时(毫秒),默认 5000 - public long ReadTimeoutMs { get; set; } = 5000; - - /// 写超时(毫秒),默认 5000 - public long WriteTimeoutMs { get; set; } = 5000; - - /// 调用方在教务平台注册的应用标识(用于签名,可选) - public string? AppKey { get; set; } - - /// 调用方在教务平台注册的密钥(用于签名,可选) - public string? AppSecret { get; set; } - - /// 拼接 baseHost + basePath 为统一开放接口基址。 - public string ResolveBaseUrl() - { - var baseHost = BaseHost?.Trim() ?? ""; - if (baseHost.EndsWith('/')) - { - baseHost = baseHost.TrimEnd('/'); - } - return baseHost + (NormalizeBasePath(BasePath) ?? ""); - } - - /// 归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null。 - public static string? NormalizeBasePath(string? path) - { - if (path == null) - { - return null; - } - var trimmed = path.Trim().TrimEnd('/'); - if (trimmed.Length == 0) - { - return null; - } - return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; - } - - /// - /// 从共享节(,即 yh:open)按键读取,回填本选项中**未显式配置**的项: - /// BaseHost ← yh:open:BaseHost;BasePath ← yh:open:SchClassPath(注意不是 BasePath —— 那是 UserBase 的路径)。 - /// 传相对键名(如 "BaseHost"),返回 null/空白视为未配置。 - /// - public void FillFromSharedSection(Func readShared) - { - if (string.IsNullOrWhiteSpace(BaseHost)) - { - BaseHost = readShared("BaseHost"); - } - if (string.IsNullOrWhiteSpace(BasePath)) - { - BasePath = readShared(SchClassPathKey); - } - if (string.IsNullOrWhiteSpace(AppKey)) - { - AppKey = readShared("AppKey"); - } - if (string.IsNullOrWhiteSpace(AppSecret)) - { - AppSecret = readShared("AppSecret"); - } - BasePath = NormalizeBasePath(BasePath); - } -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiServiceCollectionExtensions.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiServiceCollectionExtensions.cs deleted file mode 100644 index f87766e..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassOpenApiServiceCollectionExtensions.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Yh.SchClass.OpenApi; - -/// -/// DI 注册扩展:从配置读取地址并装配全部教务客户端。 -/// -/// 配置来源(优先级从高到低): -/// 1. 独立节 yh:schclass:open(整节绑定,适合教务单独部署、地址与 UserBase 无关的场景); -/// 2. 共享节 yh:open 回填:BaseHost ← yh:open:BaseHost、 -/// BasePath ← yh:open:SchClassPath(注意共享节的 BasePath=/userbase 是 UserBase 的,不会被教务采用)、 -/// AppKey/AppSecret 同名回填。 -/// -/// -public static class SchClassOpenApiServiceCollectionExtensions -{ - /// - /// 注册教务 /oapi 全部客户端:IHttpClientFactory + typed client + 聚合门面 (Singleton)。 - /// - /// builder.Services.AddSchClassOpenApi(builder.Configuration); - /// // 注入使用: - /// public class X(SchClassOpenApi sch) { ... sch.SchInfo.AllListAsync() ... } - /// - /// - public static IServiceCollection AddSchClassOpenApi(this IServiceCollection services, IConfiguration configuration) - { - services.AddOptions() - .Bind(configuration.GetSection(SchClassOpenApiOptions.SectionName)) - .PostConfigure(o => - { - var shared = configuration.GetSection(SchClassOpenApiOptions.SharedSectionName); - o.FillFromSharedSection(key => shared[key]); - }); - - services.AddHttpClient(SchClassOpenApiOptions.HttpClientName) - .ConfigureHttpClient((sp, client) => - { - var options = sp.GetRequiredService>().Value; - client.Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000); - var baseUrl = options.ResolveBaseUrl(); - if (!string.IsNullOrEmpty(baseUrl)) - { - client.BaseAddress = new Uri(baseUrl, UriKind.Absolute); - } - }) - .ConfigurePrimaryHttpMessageHandler(sp => - { - var options = sp.GetRequiredService>().Value; - return new SocketsHttpHandler - { - ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500), - }; - }); - - services.AddSingleton(sp => - { - var factory = sp.GetRequiredService(); - return new SchClassOpenApi(factory.CreateClient(SchClassOpenApiOptions.HttpClientName), - sp.GetRequiredService>().Value); - }); - return services; - } -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchInfoClient.cs deleted file mode 100644 index 3df7758..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchInfoClient.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Yh.SchClass.OpenApi.Model; - -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 SchInfo(学校)接口客户端(镜像 Java SchInfoController,路径前缀 /oapi/SchInfo/)。 -/// -public class SchClassSchInfoClient : SchClassClientBase -{ - private const string Root = Prefix + "/SchInfo/"; - - public SchClassSchInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// 按多个学校 ID 查询学校列表,GET /oapi/SchInfo/GetSchlist。 - /// 逗号分隔的学校 ID 列表(Java 参数名 SchIDArr);为 null 时等价于查询全部。 - public Task> GetSchListAsync(string? schIdArr, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetSchlist", - Params(("SchIDArr", schIdArr)), ct: ct); - - /// 查询全部学校列表,GET /oapi/SchInfo/AllList。 - public Task> AllListAsync(CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "AllList", ct: ct); - - /// 按学校外部序列号查询单个学校,GET /oapi/SchInfo/GetSchFirst;未找到返回 null。 - /// 学校外部序列号(Java 参数名 SchID)。 - public Task GetSchFirstAsync(string? schId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Root + "GetSchFirst", Params(("SchID", schId)), ct: ct); - - /// 按用户中心学校 ID 查询单个学校,GET /oapi/SchInfo/GetUserSchFirst;未找到返回 null。 - /// 用户中心学校 ID(Java 参数名 UserSchID)。 - public Task GetUserSchFirstAsync(int? userSchId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Root + "GetUserSchFirst", - Params(("UserSchID", userSchId)), ct: ct); - - /// 按多个用户中心学校 ID 查询学校列表,GET /oapi/SchInfo/GetUserSchlist。 - /// 逗号分隔的用户中心学校 ID 列表(Java 参数名 UserSchIds)。 - public Task> GetUserSchListAsync(string? userSchIds, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetUserSchlist", - Params(("UserSchIds", userSchIds)), ct: ct); - - /// 按用户中心组 ID 查询学校列表,GET /oapi/SchInfo/GetSchoolByGroupId;0 或空时返回空列表。 - /// 用户中心组 ID(Java 参数名 groupId)。 - public Task> GetSchoolByGroupIdAsync(int? groupId, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetSchoolByGroupId", - Params(("groupId", groupId)), ct: ct); - - /// 按学制 ID 查询学校,GET /oapi/SchInfo/GetSchoolBySchoolenId;当前服务端恒返回空列表。 - public Task> GetSchoolBySchoolenIdAsync(int? groupId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchoolBySchoolenId", - Params(("groupId", groupId)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchoolingClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchoolingClient.cs deleted file mode 100644 index 560cfc7..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSchoolingClient.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 Schoolen(学制 / 年级)接口客户端(镜像 Java SchoolingController,路径前缀 /oapi/Schoolen/)。 -/// -/// 年级行字段:Id / Name / OwnerId / Levell / code / sx; -/// 学制行字段:Id / Name / FormerName / Type。 -/// -/// -public class SchClassSchoolingClient : SchClassClientBase -{ - private const string Root = Prefix + "/Schoolen/"; - - public SchClassSchoolingClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// 按学校查询年级(学制层级)列表,GET /oapi/Schoolen/GetSchLenLevellist。 - /// 学校外部序列号(Java 参数名 SchSerID)。 - public Task> GetSchLenLevelListAsync(string? schSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenLevellist", Params(("SchSerID", schSerId)), ct: ct); - - /// 按学制 ID 查询年级列表,GET /oapi/Schoolen/GetLevelIdlist。 - /// 学制 ID(Java 参数名 Schooling);0 或空时服务端返回空列表。 - public Task> GetLevelIdListAsync(int? schooling, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetLevelIdlist", Params(("Schooling", schooling)), ct: ct); - - /// 按年级 ID 查询单个年级,GET /oapi/Schoolen/GetSchLenLevelFirst;未找到返回 null。 - /// 年级 ID(Java 参数名 GradeId,字符串形式)。 - public Task GetSchLenLevelFirstAsync(string? gradeId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Root + "GetSchLenLevelFirst", - Params(("GradeId", gradeId)), ct: ct); - - /// 按学校查询学制列表,GET /oapi/Schoolen/GetSchLenlist。 - public Task> GetSchLenListAsync(string? schSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenlist", Params(("SchSerID", schSerId)), ct: ct); - - /// - /// 查询班级科目,GET /oapi/Schoolen/GetAllClassSubject;当前服务端恒返回空列表。 - /// - public Task> GetAllClassSubjectAsync(int? schId, string? schserid, string? startTime, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetAllClassSubject", - Params(("schId", schId), ("schserid", schserid), ("startTime", startTime)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassStuInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassStuInfoClient.cs deleted file mode 100644 index 35aa99f..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassStuInfoClient.cs +++ /dev/null @@ -1,119 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 OAStu(学生)接口客户端(镜像 Java StuInfoController,路径前缀 /oapi/OAStu/)。 -/// -/// 学生行字段(OaCompatSupport.student): -/// Account / UserStuSerID / StuName / StuIDCard / StuSex / StuStatus / ClassID / ClassStuID / -/// GroupID / StuPhoto / CardID / StuCurrentTel / invitecode / SchSerID / Graduated / isjiedu; -/// 走班(Elective)行在此基础上追加 electiveId / schoolId 且 StuSex 为数值。 -/// -/// -/// 历史学生行字段:stuId / stuName / classId / className / grade / name / school_system_id / system_grade。 -/// -/// -public class SchClassStuInfoClient : SchClassClientBase -{ - private const string Root = Prefix + "/OAStu/"; - - public SchClassStuInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// 查询全部学生,GET /oapi/OAStu/GetAllStulist。 - public Task> GetAllStuListAsync(CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetAllStulist", ct: ct); - - /// 查询全部走班学生,GET /oapi/OAStu/GetEleAllStulist。 - public Task> GetEleAllStuListAsync(CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleAllStulist", ct: ct); - - /// 按学校查询学生,GET /oapi/OAStu/GetSchStulist。 - /// 学校外部序列号(Java 参数名 schserid,全小写)。 - /// 学校分组 ID(Java 参数名 schgroupid)。 - public Task> GetSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchStulist", - Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct); - - /// 按学校查询走班学生,GET /oapi/OAStu/GetEleSchStulist。 - public Task> GetEleSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchStulist", - Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct); - - /// 按班级查询学生,GET /oapi/OAStu/GetClaStulist。 - /// 班级 ID(Java 参数名 ClassID)。 - /// 班级分组 ID(Java 参数名 clagroupid)。 - public Task> GetClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetClaStulist", - Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct); - - /// 按班级查询走班学生,GET /oapi/OAStu/GetEleClaStulist。 - public Task> GetEleClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaStulist", - Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct); - - /// 按多个班级查询学生,GET /oapi/OAStu/GetClaArrStulist。 - /// 逗号分隔的班级 ID 列表(Java 参数名 ClassIDs)。 - /// 逗号分隔的班级分组 ID 列表(Java 参数名 clagroupids)。 - public Task> GetClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetClaArrStulist", - Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct); - - /// 按多个班级查询走班学生,GET /oapi/OAStu/GetEleClaArrStulist。 - public Task> GetEleClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaArrStulist", - Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct); - - /// 按多个学生 ID 查询学生,GET /oapi/OAStu/GetStuIdslist。 - /// 逗号分隔的学生 ID 列表(Java 参数名 stuids,全小写)。 - public Task> GetStuIdsListAsync(string? stuids, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetStuIdslist", Params(("stuids", stuids)), ct: ct); - - /// 按多个学生 ID 查询走班学生,GET /oapi/OAStu/GetEleStuIdslist。 - public Task> GetEleStuIdsListAsync(string? stuids, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleStuIdslist", Params(("stuids", stuids)), ct: ct); - - /// 查询学生家长用户 ID 列表,GET /oapi/OAStu/GetStuUserFamIDList;当前服务端恒返回空列表。 - public Task> GetStuUserFamIdListAsync(string? stuids, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetStuUserFamIDList", - Params(("stuids", stuids)), ct: ct); - - /// 按邀请码查询学生,GET /oapi/OAStu/GetFamStulist。 - /// 逗号分隔的邀请码列表(Java 参数名 invitecodes)。 - public Task> GetFamStuListAsync(string? invitecodes, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetFamStulist", - Params(("invitecodes", invitecodes)), ct: ct); - - /// 查询学生家庭成员,GET /oapi/OAStu/GetStuFamMem;当前服务端恒返回空列表。 - public Task> GetStuFamMemAsync(string? schSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetStuFamMem", Params(("SchSerID", schSerId)), ct: ct); - - /// 按多个学校序列号查询学生,GET /oapi/OAStu/GetSchBySchIds。 - /// 逗号分隔的学校序列号列表(Java 参数名 schserids,全小写)。 - public Task> GetSchBySchIdsAsync(string? schserids, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchBySchIds", Params(("schserids", schserids)), ct: ct); - - /// 按多个学校序列号查询走班学生,GET /oapi/OAStu/GetEleSchIdsStulist。 - public Task> GetEleSchIdsStuListAsync(string? schserids, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchIdsStulist", Params(("schserids", schserids)), ct: ct); - - /// 按多个学校序列号查询走班学生,GET /oapi/OAStu/GetEleSchBySchIds。 - public Task> GetEleSchBySchIdsAsync(string? schserids, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchBySchIds", Params(("schserids", schserids)), ct: ct); - - /// 按选课 ID 查询学生,GET /oapi/OAStu/GetStudentsByElectiveId;当前服务端恒返回空列表。 - public Task> GetStudentsByElectiveIdAsync(int? electiveIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetStudentsByElectiveId", - Params(("electiveIds", electiveIds)), ct: ct); - - /// 按学校 + 年度查询历史学生,GET /oapi/OAStu/getHistoryStudentsBySchoolId。 - public Task> GetHistoryStudentsBySchoolIdAsync(int schoolId, int year, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryStudentsBySchoolId", - Params(("schoolId", schoolId), ("year", year)), ct: ct); - - /// 按学校 + 年度查询历史班级,GET /oapi/OAStu/getHistoryClass;当前服务端恒返回空列表。 - public Task> GetHistoryClassAsync(int schoolId, int year, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryClass", - Params(("schoolId", schoolId), ("year", year)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSubInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSubInfoClient.cs deleted file mode 100644 index dc5761a..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassSubInfoClient.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 SubInfo(科目)接口客户端(镜像 Java SchSubController,路径前缀 /oapi/SubInfo/)。 -/// 科目行字段:SchSerID / SubID / SubName / No。 -/// -public class SchClassSubInfoClient : SchClassClientBase -{ - private const string Root = Prefix + "/SubInfo/"; - - public SchClassSubInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// 按学校 + 学制查询科目列表,GET /oapi/SubInfo/GetSchSublist。 - /// 学校外部序列号(Java 参数名 SchID)。 - /// 学制 ID(Java 参数名 schooling,全小写)。 - /// 年级 ID(Java 参数名 gradeId;当前服务端未使用该参数)。 - public Task> GetSchSubListAsync(string? schId, int? schooling, int? gradeId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchSublist", - Params(("SchID", schId), ("schooling", schooling), ("gradeId", gradeId)), ct: ct); - - /// 按多个学校查询科目列表,GET /oapi/SubInfo/GetSchIdsSublist。 - /// 逗号分隔的学校序列号列表(Java 参数名 SchIds)。 - public Task> GetSchIdsSubListAsync(string? schIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchIdsSublist", Params(("SchIds", schIds)), ct: ct); - - /// 按教师查询其任教的科目编码列表(去重字符串),GET /oapi/SubInfo/GetSchTeaSublist。 - /// 教师 ID(Java 参数名 UserTeaSerID,字符串形式)。 - public Task> GetSchTeaSubListAsync(string? userTeaSerId, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeaSublist", - Params(("UserTeaSerID", userTeaSerId)), ct: ct); - - /// - /// 按学校查询该校全部任课科目编码(小写去重字符串),GET /oapi/SubInfo/GetSchTeachSublist。 - /// - public Task> GetSchTeachSubListAsync(string? schId, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeachSublist", - Params(("SchID", schId)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeaInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeaInfoClient.cs deleted file mode 100644 index 0e26c0f..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeaInfoClient.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Yh.SchClass.OpenApi.Model; - -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 OATea(教师)接口客户端(镜像 Java TeainfoController,路径前缀 /oapi/OATea/)。 -/// -public class SchClassTeaInfoClient : SchClassClientBase -{ - private const string Root = Prefix + "/OATea/"; - - public SchClassTeaInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// - /// 按教师 ID 查询教师(GET /oapi/OATea/GetTeainfo)。 - /// 教师不存在或无分组归属时服务端返回 404,本方法返回 null。 - /// - /// 教师 ID(Java 参数名 uid,全小写)。 - public Task GetTeaInfoAsync(int uid, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Root + "GetTeainfo", Params(("uid", uid)), ct: ct); - - /// - /// 按多个教师 ID 查询教师列表(POST /oapi/OATea/GetArrTeainfo)。 - /// 请求体为逗号分隔的教师 ID(Java 侧 @RequestBody String,按 text/plain 提交); - /// 参数缺失时服务端返回 400,查询为空时返回 204(本方法返回空列表)。 - /// - public Task> GetArrTeaInfoAsync(string userIds, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Post, Root + "GetArrTeainfo", body: userIds, ct: ct); - - /// - /// 按学校查询教师列表(GET /oapi/OATea/GetSchTeainfo)。 - /// 与 至少提供一个,否则服务端返回 400。 - /// - /// 学校外部序列号(Java 参数名 SchSerID)。 - /// 学校内部 ID(Java 参数名 schId,优先使用)。 - public Task> GetSchTeaInfoAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeainfo", - Params(("SchSerID", schSerId), ("schId", schId)), ct: ct); - - /// - /// 按学校查询教师列表(GET /oapi/OATea/GetSchTeaList),与 等价。 - /// - public Task> GetSchTeaListAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetSchTeaList", - Params(("SchSerID", schSerId), ("schId", schId)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeachInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeachInfoClient.cs deleted file mode 100644 index 8b885e2..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassTeachInfoClient.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 TeachInfo(任课)接口客户端(镜像 Java TeaChesController,路径前缀 /oapi/TeachInfo/)。 -/// -/// 任课行字段(OaCompatSupport.teaching): -/// SerID / SchSerID / UserTeaSerID / SubID / ClassID / UserClassID / CreationTime / static。 -/// -/// -public class SchClassTeachInfoClient : SchClassClientBase -{ - private const string Root = Prefix + "/TeachInfo/"; - - public SchClassTeachInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// 按学校 + 教师查询任课,GET /oapi/TeachInfo/GetSchTeachlist。 - /// 学校外部序列号(Java 参数名 SchID)。 - /// 教师 ID(Java 参数名 UserTeaSerID)。 - public Task> GetSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchTeachlist", - Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct); - - /// 按学校 + 教师查询走班任课,GET /oapi/TeachInfo/GetEleSchTeachlist。 - public Task> GetEleSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchTeachlist", - Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct); - - /// 按多个学校查询任课,GET /oapi/TeachInfo/GetSchArrTeachlist。 - /// 逗号分隔的学校序列号列表(Java 参数名 SchIDArr)。 - public Task> GetSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrTeachlist", Params(("SchIDArr", schIdArr)), ct: ct); - - /// 按多个学校查询走班任课,GET /oapi/TeachInfo/GetEleSchArrTeachList。 - public Task> GetEleSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrTeachList", Params(("SchIDArr", schIdArr)), ct: ct); - - /// 按班级查询任课,GET /oapi/TeachInfo/GetSchClaTeachlist。 - /// 班级 ID(Java 参数名 ClassID);非法或为空时返回空列表。 - public Task> GetSchClaTeachListAsync(string? classId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchClaTeachlist", Params(("ClassID", classId)), ct: ct); - - /// 按班级查询走班任课,GET /oapi/TeachInfo/GetEleSchClaTeachlist。 - public Task> GetEleSchClaTeachListAsync(string? classId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchClaTeachlist", Params(("ClassID", classId)), ct: ct); - - /// 按学校 + 年级查询选课班级,GET /oapi/TeachInfo/GetElectiveClassByGrade;当前服务端恒返回空列表。 - public Task> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade", - Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct); - - /// - /// 按多个教师查询选课班级,POST /oapi/TeachInfo/GetElectiveClassByTeachers; - /// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。 - /// - public Task> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassUserInfoClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassUserInfoClient.cs deleted file mode 100644 index ba9a2bd..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassUserInfoClient.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Yh.SchClass.OpenApi.Model; - -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 UserInfo(师生统一用户)接口客户端(镜像 Java UserInfoController,路径前缀 /oapi/UserInfo/)。 -/// -public class SchClassUserInfoClient : SchClassClientBase -{ - private const string Root = Prefix + "/UserInfo/"; - - public SchClassUserInfoClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// - /// 查询学校全部教师 + 学生(GET /oapi/UserInfo/GetUserList)。 - /// 参数缺失或学校不存在时服务端返回 400;查询为空时返回 204(本方法返回空列表)。 - /// - /// 学校外部序列号(Java 参数名 SchSerID)。 - public Task> GetUserListAsync(string schSerId, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetUserList", - Params(("SchSerID", schSerId)), ct: ct); - - /// - /// 按用户 ID 查询用户信息(GET /oapi/UserInfo/getUserIdList)。 - /// 教师 / 学生均未命中时服务端返回 404,本方法返回空列表。 - /// - /// 用户 ID(Java 参数名 userid,全小写)。 - public Task> GetUserIdListAsync(int userid, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "getUserIdList", - Params(("userid", userid)), ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassVClassClient.cs b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassVClassClient.cs deleted file mode 100644 index ed44eaa..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/SchClassVClassClient.cs +++ /dev/null @@ -1,107 +0,0 @@ -namespace Yh.SchClass.OpenApi; - -/// -/// 教务 VSchClass(班级)接口客户端(镜像 Java VClassController,路径前缀 /oapi/VSchClass/)。 -/// -/// 班级行字段(OaCompatSupport.classInfo): -/// Id / Name / OwnerId / Levell / sx / SchSerID / ClassID / UserClassID / Schooling / MajorCode / -/// ClassCode / ClassMotto / ClassName / StartDate / FirstTeaUserID / SecondTeaUserID / UpgradeYear / -/// CurrentIndex / CreationTime / GradtionTime / GreduatedYear / Graduated。 -/// -/// -public class SchClassVClassClient : SchClassClientBase -{ - private const string Root = Prefix + "/VSchClass/"; - - public SchClassVClassClient(HttpClient httpClient, SchClassOpenApiOptions options) - : base(httpClient, options) - { - } - - /// 按学校查询班级(排除已毕业),GET /oapi/VSchClass/GetVClassList。 - /// 学校外部序列号(Java 参数名 SchSerID)。 - public Task> GetVClassListAsync(string? schSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassList", Params(("SchSerID", schSerId)), ct: ct); - - /// 按学校查询班级(含已毕业),GET /oapi/VSchClass/GetEleVClassList。 - public Task> GetEleVClassListAsync(string? schSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassList", Params(("SchSerID", schSerId)), ct: ct); - - /// 按多个学校查询班级(排除已毕业),GET /oapi/VSchClass/GetSchArrVClassList。 - /// 逗号分隔的学校序列号列表(Java 参数名 SchIDArr)。 - public Task> GetSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct); - - /// 按多个学校查询班级(含已毕业),GET /oapi/VSchClass/GetEleSchArrVClassList。 - public Task> GetEleSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct); - - /// - /// 按时间查询学校班级,GET /oapi/VSchClass/GetFunTimeSchClass。 - /// 注意:Java 实现忽略 ,实际按学校过滤。 - /// - public Task> GetFunTimeSchClassAsync(string? time, string? schSerId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetFunTimeSchClass", - Params(("time", time), ("SchSerID", schSerId)), ct: ct); - - /// 按班级 ID 查询单个班级,GET /oapi/VSchClass/GetVClassFirst;未找到返回 null。 - /// 班级 ID(Java 参数名 ClassID)。 - public Task GetVClassFirstAsync(string? classId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Root + "GetVClassFirst", Params(("ClassID", classId)), ct: ct); - - /// 按班级 ID 查询单个班级(含已毕业),GET /oapi/VSchClass/GetEleVClassFirst。 - public Task GetEleVClassFirstAsync(string? classId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Root + "GetEleVClassFirst", Params(("ClassID", classId)), ct: ct); - - /// 按多个班级 ID 查询班级,GET /oapi/VSchClass/GetVClassIdsList。 - /// 逗号分隔的班级 ID 列表(Java 参数名 ClassIds)。 - public Task> GetVClassIdsListAsync(string? classIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassIdsList", Params(("ClassIds", classIds)), ct: ct); - - /// 按多个班级 ID 查询班级(含已毕业),GET /oapi/VSchClass/GetEleVClassIdsList。 - public Task> GetEleVClassIdsListAsync(string? classIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassIdsList", Params(("ClassIds", classIds)), ct: ct); - - /// 按学校 + 学制 + 年级查询班级,GET /oapi/VSchClass/GetGradeClassList。 - public Task> GetGradeClassListAsync(string? schSerId, int? schooling, int? gradeId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetGradeClassList", - Params(("SchSerID", schSerId), ("Schooling", schooling), ("GradeId", gradeId)), ct: ct); - - /// 查询教师任课的班级 ID 列表(去重后的字符串),GET /oapi/VSchClass/GetTeachClassArr。 - /// 教师 ID(Java 参数名 UserTeaSerID)。 - public Task> GetTeachClassArrAsync(int? userTeaSerId, CancellationToken ct = default) - => ResolveListAsync(HttpMethod.Get, Root + "GetTeachClassArr", - Params(("UserTeaSerID", userTeaSerId)), ct: ct); - - /// 查询全部班级(排除已毕业),GET /oapi/VSchClass/GetSchArrVClassListAll。 - public Task> GetSchArrVClassListAllAsync(CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassListAll", ct: ct); - - /// 查询全部班级(含已毕业),GET /oapi/VSchClass/GetEleSchArrVClassListAll。 - public Task> GetEleSchArrVClassListAllAsync(CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassListAll", ct: ct); - - /// - /// 以 POST 方式按多个学校查询班级,POST /oapi/VSchClass/GetSchArrVClassListPost。 - /// 请求体为逗号分隔的学校序列号(Java 侧 @RequestBody String,按 text/plain 提交)。 - /// - public Task> GetSchArrVClassListPostAsync(string? body, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Post, Root + "GetSchArrVClassListPost", body: body, ct: ct); - - /// 按选课 ID 查询班级,GET /oapi/VSchClass/GetElectiveClassById;当前服务端恒返回 null。 - public Task GetElectiveClassByIdAsync(int? electiveId, CancellationToken ct = default) - => ResolveAsync(HttpMethod.Get, Root + "GetElectiveClassById", - Params(("electiveId", electiveId)), ct: ct); - - /// 按学校 + 年级查询选课班级,GET /oapi/VSchClass/GetElectiveClassByGrade;当前服务端恒返回空列表。 - public Task> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade", - Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct); - - /// - /// 按多个教师查询选课班级,POST /oapi/VSchClass/GetElectiveClassByTeachers; - /// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。 - /// - public Task> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default) - => ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct); -} diff --git a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Yh.SchClass.OpenApi.csproj b/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Yh.SchClass.OpenApi.csproj deleted file mode 100644 index f4d8ab8..0000000 --- a/BasePlatformDotnet/src/Yh.SchClass.OpenApi/Yh.SchClass.OpenApi.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Yh.SchClass.OpenApi - 云海教务(SchClass)开放接口客户端:对应 Java 教务 OpenApi 调用封装 - SchClass /oapi 同构客户端 SDK,支持 yh:open / yh:schclass:open 配置节绑定与 DI 注册 - - - - - - - - diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ErrorCodeAndExceptionTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ErrorCodeAndExceptionTests.cs deleted file mode 100644 index 89adb03..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ErrorCodeAndExceptionTests.cs +++ /dev/null @@ -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; -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/GlobalUsings.cs deleted file mode 100644 index 61adf69..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/GlobalUsings.cs +++ /dev/null @@ -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; diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ModelSerializationTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ModelSerializationTests.cs deleted file mode 100644 index ea01730..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/ModelSerializationTests.cs +++ /dev/null @@ -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()!; - } - - private static JObject LoadFixtureWrapped() - { - var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "envelope-success.json"); - return JObject.Parse(File.ReadAllText(path))["wrappedData"]!.Value()!; - } - - [Fact] - public void Newtonsoft_反序列化fixture_含currentRole嵌套() - { - var user = LoadFixtureBareData().ToObject(new Newtonsoft.Json.JsonSerializer - { - // net5 消费方形态:ASP.NET Core AddNewtonsoftJson 默认大小写不敏感、保留属性名 - ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(), - }); - - Assert.Equal(1001, user!.Id); - Assert.Equal("zhangsan", user.Account); - Assert.Equal("张三", user.Name); - Assert.Equal(2, user.School!.Id); - Assert.Equal("云海第一中学", user.School.Name); - Assert.Equal(3, user.CurrentRole!.RoleId); - Assert.Equal("教师", user.CurrentRole.RoleName); - Assert.Equal("云海第一中学", user.CurrentRole.SchName); - Assert.Equal(21, user.CurrentRole.ChildId); - Assert.Equal(2, user.Schools!.Count); - Assert.Equal(new List { 3, 5 }, user.RoleIds); - Assert.Equal(3, user.Groups!.Count); - } - - [Fact] - public void Newtonsoft_默认设置_大小写不敏感匹配camelCase() - { - var user = LoadFixtureBareData().ToObject(); - Assert.Equal("zhangsan", user!.Account); - Assert.Equal("教师", user.CurrentRole!.RoleName); - } - - [Fact] - public void SystemTextJson_Web序列化_与fixture逐节点一致() - { - var user = LoadFixtureBareData().ToObject(); - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - // 对齐 Java 侧 NON_NULL 语义 - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - }; - var json = JsonSerializer.Serialize(user, options); - - // 语义等价:键序无关、null 节点差异容忍(fixture 中显式 null 与省略 null 视为一致) - Assert.True(JToken.DeepEquals(JObject.Parse(json), StripNulls(LoadFixtureBareData())), - "STJ 序列化结果应与 fixture 语义一致\n" + json); - } - - private static JObject StripNulls(JObject obj) - { - var clone = (JObject)obj.DeepClone(); - var toRemove = new List(); - CollectNullProps(clone, toRemove); - foreach (var p in toRemove) - { - p.Remove(); - } - return clone; - } - - private static void CollectNullProps(JToken token, List collected) - { - foreach (var child in token.Children()) - { - if (child is JProperty p) - { - if (p.Value.Type == JTokenType.Null) - { - collected.Add(p); - continue; - } - CollectNullProps(p.Value, collected); - } - else if (child.Type is JTokenType.Object or JTokenType.Array) - { - CollectNullProps(child, collected); - } - } - } - - [Fact] - public void SystemTextJson_反序列化信封包裹成功样例() - { - var wrapped = LoadFixtureWrapped(); - var envelope = wrapped.ToObject>(new Newtonsoft.Json.JsonSerializer - { - ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(), - }); - - Assert.True(envelope!.IsSuccess); - Assert.Equal(0, envelope.Code); - Assert.Equal(1001, envelope.Data!.Id); - } - - [Fact] - public void BoaApp_Models_双序列化往返() - { - var app = new BoaApp { Id = 7, Name = "作业提交", Clients = (int)ClientEnum.PcBrowser | (int)ClientEnum.MobileApp, IsPublic = false, BuiltInTag = 0 }; - var stj = JsonSerializer.Deserialize(JsonSerializer.Serialize(app, new JsonSerializerOptions(JsonSerializerDefaults.Web)), new JsonSerializerOptions(JsonSerializerDefaults.Web)); - var nf = JObject.Parse(JsonConvert.SerializeObject(app, new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() })).ToObject(); - - Assert.Equal(app.Name, stj!.Name); - Assert.Equal(app.Clients, stj.Clients); - Assert.Equal(app.Name, nf!.Name); - Assert.Equal(false, nf.IsPublic); - } -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/Yh.Platform.Abstractions.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/Yh.Platform.Abstractions.Tests.csproj deleted file mode 100644 index a97e0f3..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Abstractions.Tests/Yh.Platform.Abstractions.Tests.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net8.0 - false - - - - - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/AppSignHandlerTests.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/AppSignHandlerTests.cs deleted file mode 100644 index a7dad8d..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/AppSignHandlerTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System.Linq; -using System.Text.Json; -using Xunit; - -namespace Yh.Platform.AppAuth.Tests; - -/// -/// AppSignHandler basePath 剥离对拍(验收 P1 修复): -/// 链路 handler(fullUrl) → 剥离 basePath → SignUtil(剥离后 canonicalTarget) → 向量基线(Java 口径)。 -/// 证明:经 handler 发出的签名 = Java 客户端对同一 URL 的签名(同 ts 时逐字节一致)。 -/// -public class AppSignHandlerTests -{ - private sealed class CaptureHandler : HttpMessageHandler - { - public HttpRequestMessage? Last { get; private set; } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - Last = request; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); - } - } - - private const string AppKey = "100476"; - private const string Secret = "app-secret-100476-yunhai"; - - private static async Task SendThroughHandlerAsync(AppSignHandler handler, HttpMethod method, string url) - { - using var client = new HttpClient(handler); - var request = new HttpRequestMessage(method, url); - await client.SendAsync(request); - // handler 已把三头写入同一个 request 实例;CaptureHandler 捕获的是该实例 - return request; - } - - /// 读取请求级向量:固定 ts 后,经 handler 对 fullUrl 的签名须与向量基线(Java 口径)逐字节一致。 - public static IEnumerable RequestLevelCases() - { - var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json"); - var root = JsonDocument.Parse(File.ReadAllText(path)).RootElement; - foreach (var c in root.GetProperty("cases").EnumerateArray()) - { - if (c.TryGetProperty("fullUrl", out _)) - { - yield return new object[] { c.Clone() }; - } - } - } - - [Theory] - [MemberData(nameof(RequestLevelCases))] - public async Task Handler对fullUrl签名_与Java基线逐字节一致(JsonElement c) - { - var name = c.GetProperty("name").GetString(); - var method = new HttpMethod(c.GetProperty("method").GetString()!); - var fullUrl = c.GetProperty("fullUrl").GetString()!; - var basePath = c.GetProperty("basePath").GetString()!; - var ts = c.GetProperty("ts").GetString()!; - var secret = c.GetProperty("secret").GetString()!; - var expected = c.GetProperty("expectedRequestSignBase64").GetString()!; - - // 固定 ts = 向量 ts(SignUtil.NowMillis 为 handler 与 SignUtil 共用的时钟源) - var original = SignUtil.NowMillis; - SignUtil.NowMillis = () => long.Parse(ts); - try - { - var handler = new AppSignHandler(AppKey, secret, basePath, new CaptureHandler()); - var sent = await SendThroughHandlerAsync(handler, method, fullUrl); - - Assert.Equal(AppKey, sent.Headers.GetValues(AppSignHandler.HeaderAppKey).First()); - Assert.Equal(ts, sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First()); - // 同 URL、同 ts 下与 Java 客户端(AppSignInterceptor 剥 basePath 后)签名逐字节一致 - Assert.Equal(expected, sent.Headers.GetValues(AppSignHandler.HeaderSignature).First()); - } - finally - { - SignUtil.NowMillis = original; - } - } - - [Fact] - public async Task Handler剥离basePath_并排序query后签名() - { - var handler = new AppSignHandler(AppKey, Secret, "/userbase", new CaptureHandler()); - var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get, - "http://base.bestsch.com/userbase/oapi/user/byId?b=2&a=1"); - - var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First(); - var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First(); - - // 剥离 /userbase + query 排序:canonical = /oapi/user/byId?a=1&b=2 - Assert.Equal(SignUtil.SignRequest("GET", "/oapi/user/byId?a=1&b=2", ts, Secret), signature); - } - - [Fact] - public async Task Handler未配置basePath_不剥离原样签名() - { - var handler = new AppSignHandler(AppKey, Secret, null, new CaptureHandler()); - var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get, - "http://base.bestsch.com/userbase/oapi/user/byId"); - - var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First(); - var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First(); - - Assert.Equal(SignUtil.SignRequest("GET", "/userbase/oapi/user/byId", ts, Secret), signature); - } - - [Theory] - [InlineData("/userbase/oapi/u", "/userbase", "/oapi/u")] - [InlineData("/userbase", "/userbase", "/")] - [InlineData("/userbase/", "/userbase", "/")] - [InlineData("/userbase/oapi/u", "/userbase/", "/oapi/u")] // basePath 带尾斜杠归一化 - [InlineData("/userbase2/x", "/userbase", "/userbase2/x")] // 前缀不完整匹配不剥离(对齐 Java) - [InlineData("/oapi/u", "/userbase", "/oapi/u")] // 无前缀不剥离 - [InlineData("/userbase/oapi/u", null, "/userbase/oapi/u")] // basePath 空 = 挂根路径 - public void StripBasePath_边界与Java一致(string path, string? basePath, string expected) - { - Assert.Equal(expected, SignCanonical.StripBasePath(path, basePath)); - } - - [Fact] - public void BuildTarget_带basePath_剥离并排序query() - { - Assert.Equal("/oapi/user/byId?a=1&b=2", - SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1"), "/userbase")); - // 不传 basePath 保持既有行为(不剥离,query 仍排序) - Assert.Equal("/userbase/oapi/user/byId?a=1&b=2", - SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1"))); - } -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/GlobalUsings.cs deleted file mode 100644 index cca7d8f..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/GlobalUsings.cs +++ /dev/null @@ -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; diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignCanonicalTests.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignCanonicalTests.cs deleted file mode 100644 index 494c82b..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignCanonicalTests.cs +++ /dev/null @@ -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")); - } -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignVectorTests.cs b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignVectorTests.cs deleted file mode 100644 index 55ddf7f..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/SignVectorTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.IO; -using System.Linq; -using Xunit; -using Yh.Platform.AppAuth; - -namespace Yh.Platform.AppAuth.Tests; - -/// 签名向量逐条对拍(基线由独立实现产出,见 contract/fixtures/sign-vectors.json)。 -public class SignVectorTests -{ - public static System.Collections.Generic.IEnumerable VectorCases() - { - var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json"); - var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement; - foreach (var c in root.GetProperty("cases").EnumerateArray()) - { - yield return new object[] { c.Clone() }; - } - } - - [Theory] - [MemberData(nameof(VectorCases))] - public void 向量对拍_逐字节一致(System.Text.Json.JsonElement c) - { - var name = c.GetProperty("name").GetString(); - var ts = c.GetProperty("ts").GetString()!; - var secret = c.GetProperty("secret").GetString()!; - - if (c.TryGetProperty("expectedDataSignHex", out var hex)) - { - var data = c.GetProperty("data").GetString()!; - Assert.True(SignUtil.Sign(data, ts, secret) == hex.GetString(), "数据级向量不一致: " + name); - } - else - { - var method = c.GetProperty("method").GetString()!; - var target = c.GetProperty("canonicalTarget").GetString()!; - var expected = c.GetProperty("expectedRequestSignBase64").GetString()!; - Assert.True(SignUtil.SignRequest(method, target, ts, secret) == expected, "请求级向量不一致: " + name); - } - } - - [Fact] - public void 数据级验签_忽略大小写() - { - var secret = "0123456789abcdef"; - var sign = SignUtil.Sign("hello", "1700000000000", secret); - Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToUpperInvariant())); - Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToLowerInvariant())); - Assert.False(SignUtil.Verify("hello2", "1700000000000", secret, sign)); - } - - [Fact] - public void 请求级验签_精确比对() - { - var secret = "0123456789abcdef"; - var sign = SignUtil.SignRequest("GET", "/p?a=1", "1700000000000", secret); - Assert.True(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign)); - Assert.False(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign.ToLowerInvariant())); - Assert.False(SignUtil.VerifyRequest("POST", "/p?a=1", "1700000000000", secret, sign)); - } - - [Fact] - public void 密钥过短_抛参数异常() - { - Assert.Throws(() => SignUtil.Sign("x", "1700000000000", "short")); - // 16 位恰为最低边界,合法不抛 - Assert.Equal(SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456"), - SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456")); - } - - [Fact] - public void 过期时间戳_拒绝_双向偏差() - { - var secret = "0123456789abcdef"; - var data = "x"; - SignUtil.NowMillis = static () => 1700000000000L; - try - { - var sign = SignUtil.Sign(data, "1700000000000", secret); - Assert.True(SignUtil.VerifyWithExpire(data, "1700000000000", secret, sign)); // 恰好等于 now - Assert.False(SignUtil.VerifyWithExpire(data, "1699999699001", secret, sign)); // 5min-999ms 前 - Assert.False(SignUtil.VerifyWithExpire(data, "1700000301000", secret, sign)); // 5min+1s 后 - } - finally - { - SignUtil.NowMillis = static () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - } - } - - [Fact] - public void 向量文件至少十组() - { - var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json"); - var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement; - Assert.True(root.GetProperty("cases").GetArrayLength() >= 10); - } -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/Yh.Platform.AppAuth.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/Yh.Platform.AppAuth.Tests.csproj deleted file mode 100644 index 83847c4..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.AppAuth.Tests/Yh.Platform.AppAuth.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net8.0 - false - - - - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/AuthContextTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/AuthContextTests.cs deleted file mode 100644 index 6648e11..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/AuthContextTests.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System.Security.Claims; -using Yh.Platform.Abstractions; -using Yh.Platform.Auth; -using Xunit; - -namespace Yh.Platform.Auth.Tests; - -/// CAS serviceValidate 响应 XML 用户提取对拍(对齐 Java CasServiceTicketValidator)。 -public class CasServiceTicketValidatorTests -{ - [Fact] - public void 成功响应_提取cas_user() - { - var xml = """ - - - zhangsan - - - """; - Assert.Equal("zhangsan", CasServiceTicketValidator.ExtractUser(xml)); - } - - [Fact] - public void 带属性容错_提取cas_user() - { - var xml = "lisi"; - Assert.Equal("lisi", CasServiceTicketValidator.ExtractUser(xml)); - } - - [Fact] - public void 失败响应_返回null() - { - var xml = """ - - - Ticket ST-1856339 not recognized - - - """; - Assert.Null(CasServiceTicketValidator.ExtractUser(xml)); - } - - [Theory] - [InlineData("")] - [InlineData(null)] - [InlineData("")] - public void 无用户节点或空输入_返回null(string? xml) - { - Assert.Null(CasServiceTicketValidator.ExtractUser(xml)); - } - - [Fact] - public async Task 未配置CAS地址_抛AuthException() - { - var validator = new CasServiceTicketValidator(""); - var ex = await Assert.ThrowsAsync(() => validator.ValidateAsync("ST-1", "http://app/cb")); - Assert.Contains("cas-server-url-prefix", ex.Message); - } -} - -public class BoaUserContextTests -{ - [Fact] - public async Task 异步流内可读取_请求结束清理() - { - Assert.Null(BoaUserContext.Get()); - Assert.Null(UserContextHolder.Get()); - - BoaUser? seenInChild = null; - var user = new BoaUser(1001, "zhangsan", "张三"); - BoaUserContext.Set(user); - UserContextHolder.Set(new LoginUser("1001") { LoginName = "zhangsan", DisplayName = "张三" }); - - await Task.Run(() => - { - // AsyncLocal 值随执行上下文流入子任务 - seenInChild = BoaUserContext.Get(); - }); - - Assert.Same(user, seenInChild); - Assert.Equal("zhangsan", UserContextHolder.Get()?.LoginName); - - BoaUserContext.Clear(); - UserContextHolder.Clear(); - Assert.Null(BoaUserContext.Get()); - Assert.Null(UserContextHolder.Get()); - } - - [Fact] - public void LoginUser_属性封装() - { - var login = new LoginUser("1001") - { - LoginName = "zhangsan", - DisplayName = "张三", - AppKey = "app-001", - }; - Assert.Equal("1001", login.UserId); - Assert.Equal("zhangsan", login.LoginName); - Assert.Equal("张三", login.DisplayName); - Assert.Equal("app-001", login.AppKey); - } -} - -public class UserContextMiddlewareTests -{ - private sealed class StubProvider : IUserDetailsProvider - { - public BoaUser? User { get; set; } = new(1001, "zhangsan", "张三"); - - public Task GetByLoginNameAsync(string loginName, CancellationToken ct = default) - => Task.FromResult(User); - } - - private static HttpContext HttpContextWithLogin(string loginName) - { - var identity = new ClaimsIdentity("CAS"); - identity.AddClaim(new Claim("loginName", loginName)); - var context = new DefaultHttpContext(); - context.User = new ClaimsPrincipal(identity); - return context; - } - - [Fact] - public async Task 认证请求_中间件写入上下文_业务管道内可读() - { - var provider = new StubProvider(); - BoaUser? seen = null; - LoginUser? seenLogin = null; - var middleware = new UserContextMiddleware(_ => - { - seen = BoaUserContext.Get(); - seenLogin = UserContextHolder.Get(); - return Task.CompletedTask; - }); - - await middleware.InvokeAsync(HttpContextWithLogin("zhangsan"), provider); - - Assert.NotNull(seen); - Assert.Equal(1001, seen!.Id); - Assert.Equal("zhangsan", seenLogin?.LoginName); - Assert.Equal("张三", seenLogin?.DisplayName); - // 请求结束后必须清空,防串扰 - Assert.Null(BoaUserContext.Get()); - Assert.Null(UserContextHolder.Get()); - } - - [Fact] - public async Task 未认证请求_不写上下文直接放行() - { - var provider = new StubProvider(); - var middleware = new UserContextMiddleware(_ => Task.CompletedTask); - - var context = new DefaultHttpContext(); // 匿名 - await middleware.InvokeAsync(context, provider); - - Assert.Null(BoaUserContext.Get()); - Assert.Null(UserContextHolder.Get()); - } -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/GlobalUsings.cs deleted file mode 100644 index a628c55..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/GlobalUsings.cs +++ /dev/null @@ -1,8 +0,0 @@ -global using System; -global using System.Collections.Generic; -global using System.Security.Claims; -global using System.Threading; -global using System.Threading.Tasks; -global using Microsoft.AspNetCore.Http; -global using Yh.Platform.Abstractions; -global using Yh.Platform.Auth; diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/SessionTicketDataFormatTests.cs b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/SessionTicketDataFormatTests.cs deleted file mode 100644 index 431492f..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/SessionTicketDataFormatTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; -using Yh.Platform.Auth; -using Xunit; - -namespace Yh.Platform.Auth.Tests; - -/// -/// 会话票据格式(SessionTicketDataFormat)单测: -/// 32 位 key 往返、未知 key / 过期票据失效、显式退出(Revoke)立即吊销。 -/// -public class SessionTicketDataFormatTests -{ - private static AuthenticationTicket NewTicket(DateTimeOffset? expiresUtc = null) - { - var identity = new ClaimsIdentity("CAS"); - identity.AddClaim(new Claim(ClaimTypes.Name, "zhangsan")); - var principal = new ClaimsPrincipal(identity); - var props = new AuthenticationProperties(); - if (expiresUtc.HasValue) - { - props.ExpiresUtc = expiresUtc; - } - return new AuthenticationTicket(principal, props, "YhCookie"); - } - - [Fact] - public void Protect_返回32位key_且可往返() - { - var format = new SessionTicketDataFormat(); - var key = format.Protect(NewTicket()); - - Assert.Equal(32, key.Length); // Guid N 格式 = 32 个十六进制字符 - var ticket = format.Unprotect(key); - Assert.NotNull(ticket); - Assert.Equal("zhangsan", ticket!.Principal.FindFirstValue(ClaimTypes.Name)); - } - - [Fact] - public void Unprotect_未知key_返回null() - { - var format = new SessionTicketDataFormat(); - Assert.Null((AuthenticationTicket?)format.Unprotect("00000000000000000000000000000000")); - } - - [Fact] - public void Unprotect_空或null_返回null() - { - var format = new SessionTicketDataFormat(); - Assert.Null((AuthenticationTicket?)format.Unprotect(null)); - Assert.Null((AuthenticationTicket?)format.Unprotect("")); - } - - [Fact] - public void Revoke_票据立即失效() - { - var format = new SessionTicketDataFormat(); - var key = format.Protect(NewTicket()); - - format.Revoke(key); - - Assert.Null((AuthenticationTicket?)format.Unprotect(key)); - } - - [Fact] - public void Revoke_空key_不抛异常() - { - var format = new SessionTicketDataFormat(); - format.Revoke(null); - format.Revoke(""); - format.Revoke(" "); - } - - [Fact] - public void Unprotect_过期票据_返回null() - { - var format = new SessionTicketDataFormat(); - var key = format.Protect(NewTicket(DateTimeOffset.UtcNow.AddSeconds(-1))); - - Assert.Null((AuthenticationTicket?)format.Unprotect(key)); - } - - [Fact] - public void Unprotect_未过期票据_正常往返() - { - var format = new SessionTicketDataFormat(); - var key = format.Protect(NewTicket(DateTimeOffset.UtcNow.AddMinutes(1))); - - Assert.NotNull(format.Unprotect(key)); - } - - [Fact] - public void Revoke_不影响其他票据() - { - var format = new SessionTicketDataFormat(); - var keyA = format.Protect(NewTicket()); - var keyB = format.Protect(NewTicket()); - - format.Revoke(keyA); - - Assert.Null((AuthenticationTicket?)format.Unprotect(keyA)); - Assert.NotNull(format.Unprotect(keyB)); - } -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/Yh.Platform.Auth.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/Yh.Platform.Auth.Tests.csproj deleted file mode 100644 index 6bb8921..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.Auth.Tests/Yh.Platform.Auth.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net8.0 - false - - - - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs deleted file mode 100644 index 18ef2bc..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using Yh.Platform.Abstractions; -using Xunit; - -namespace Yh.Platform.OpenApi.Tests; - -/// -/// 契约快照对拍(双端同步机制的核心防线): -/// 基线 = UserBase springdoc 导出的权威快照 contract/openapi-oapi.exported.json -/// (GET /userbase/v3/api-docs/oapi,UserBase 契约变更后重新导出更新该文件即可激活本测试发现漂移)。 -/// -/// 端点对拍:SDK 契约路由表 ⊆ 快照(method+path 逐项),多出的快照端点以未覆盖清单输出; -/// 模型对拍:SDK POCO 属性集(camelCase)⇔ 快照 schema properties 键集,双向差额为零。 -/// -/// 更新基线流程:UserBase 起服务 → GET /v3/api-docs/oapi 覆盖保存 exported.json → 跑本测试,红了就是契约漂移。 -/// -public class ContractSnapshotTests -{ - private static readonly string SnapshotPath = - Path.Combine(AppContext.BaseDirectory, "contract", "openapi-oapi.exported.json"); - - private static JsonDocument LoadSnapshot() - { - Assert.True(File.Exists(SnapshotPath), $"权威契约快照缺失:{SnapshotPath}(从 UserBase GET /v3/api-docs/oapi 导出)"); - return JsonDocument.Parse(File.ReadAllText(SnapshotPath)); - } - - /// PascalCase → camelCase(首字母小写,覆盖 Id→id、Idcard→idcard 等既有键形)。 - private static string ToCamel(string name) - => string.IsNullOrEmpty(name) ? name : char.ToLowerInvariant(name[0]) + name.Substring(1); - - private static HashSet PocoPropertyNames(Type type) - => type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => ToCamel(p.Name)) - .ToHashSet(StringComparer.Ordinal); - - private static HashSet SchemaPropertyNames(JsonElement schemas, string schemaName) - { - Assert.True(schemas.TryGetProperty(schemaName, out var schema), - $"快照缺少 schema:{schemaName}(UserBase 侧可能已改名/删除)"); - return schema.GetProperty("properties").EnumerateObject() - .Select(p => p.Name) - .ToHashSet(StringComparer.Ordinal); - } - - // ───────────────────────── 端点对拍 ───────────────────────── - - /// SDK 覆盖的契约路由表(method + path;来源:三个 Client 实现,加端点必须同步登记于此)。 - public static readonly (string Method, string Path)[] SdkRoutes = - { - // user 域 - ("GET", "/oapi/user/byId"), - ("GET", "/oapi/user/byLoginName"), - ("GET", "/oapi/user/byPhone"), - ("GET", "/oapi/user/modules"), - ("GET", "/oapi/user/roles"), - ("GET", "/oapi/user/groupState"), - ("POST", "/oapi/user/add"), - ("POST", "/oapi/user/edit/{id}"), - ("POST", "/oapi/user/delete/{id}/{groupId}/{schoolId}"), - ("POST", "/oapi/user/deleteUsers"), - ("POST", "/oapi/user/batchAdd"), - ("POST", "/oapi/user/moveToGroup"), - ("POST", "/oapi/user/password"), - ("POST", "/oapi/user/groupState"), - ("POST", "/oapi/user/transferToSchool"), - ("POST", "/oapi/user/changeStatus"), - ("POST", "/oapi/user/batchChangeStatus"), - // identity 域 - ("GET", "/oapi/identity/current"), - ("GET", "/oapi/identity/available"), - ("POST", "/oapi/identity/switch"), - // group 域 - ("GET", "/oapi/group/tree"), - ("GET", "/oapi/group/byId"), - ("GET", "/oapi/group/list"), - ("POST", "/oapi/group/add"), - ("POST", "/oapi/group/edit/{id}"), - ("POST", "/oapi/group/delete/{id}/{schoolId}"), - ("POST", "/oapi/group/batchAdd"), - ("POST", "/oapi/group/move/{schoolId}"), - // app 域 - ("GET", "/oapi/app/{appId}"), - ("GET", "/oapi/app/list"), - }; - - private static HashSet<(string, string)> SnapshotRoutes(JsonDocument snapshot) - { - var routes = new HashSet<(string, string)>(); - foreach (var path in snapshot.RootElement.GetProperty("paths").EnumerateObject()) - { - foreach (var op in path.Value.EnumerateObject()) - { - if (op.Name is "get" or "post" or "put" or "delete" or "patch") - { - routes.Add((op.Name.ToUpperInvariant(), path.Name)); - } - } - } - return routes; - } - - [Fact] - public void SDK端点必须在快照中存在() - { - var snapshot = LoadSnapshot(); - var snapshotRoutes = SnapshotRoutes(snapshot); - - var missing = SdkRoutes - .Where(r => !snapshotRoutes.Contains((r.Method, r.Path))) - .Select(r => $"{r.Method} {r.Path}") - .ToList(); - Assert.True(missing.Count == 0, - $"以下 SDK 端点在权威快照中不存在(UserBase 侧已删除或改路径,须同步修正 SDK):\n{string.Join('\n', missing)}"); - } - - [Fact] - public void 快照端点若SDK未覆盖须显式登记() - { - var snapshot = LoadSnapshot(); - var snapshotRoutes = SnapshotRoutes(snapshot); - var sdk = SdkRoutes.ToHashSet(); - - var uncovered = snapshotRoutes - .Where(r => !sdk.Contains(r)) - .Select(r => $"{r.Item1} {r.Item2}") - .OrderBy(x => x) - .ToList(); - - // 不直接失败(UserBase 新增端点不应阻塞 SDK CI),但必须显式白名单化——未登记的未覆盖端点视为漂移信号 - var acknowledged = new HashSet(StringComparer.Ordinal) - { - // 当前无未覆盖端点;UserBase 新增 /oapi 端点后,此清单须显式登记并排期跟进,防止无声失联 - }; - - var unacknowledged = uncovered.Where(u => !acknowledged.Contains(u)).ToList(); - Assert.True(unacknowledged.Count == 0, - $"快照存在 SDK 未覆盖且未登记白名单的端点(评估跟进或显式放弃):\n{string.Join('\n', unacknowledged)}"); - } - - // ───────────────────────── 模型对拍 ───────────────────────── - - /// SDK 模型 ↔ 快照 schema 映射表(新模型对齐必须登记于此)。 - public static readonly (Type Type, string Schema)[] ModelMappings = - { - (typeof(BoaUser), "BoaUser"), - (typeof(BoaRoleInfo), "BoaRoleInfo"), - (typeof(BoaGroup), "BoaGroup"), - (typeof(BoaModule), "BoaModule"), - (typeof(BoaApp), "BoaApp"), - (typeof(BoaAppType), "BoaAppType"), - (typeof(SysUserRequest), "SysUserRequest"), - (typeof(SysUserGroupDeleteItem), "SysUserGroupDeleteItem"), - (typeof(SysUserMoveRequest), "SysUserMoveRequest"), - (typeof(SysUserGroupStateRequest), "SysUserGroupStateRequest"), - (typeof(SysUserTransferSchoolRequest), "SysUserTransferSchoolRequest"), - (typeof(SysGroupRequest), "SysGroupRequest"), - (typeof(SysGroupMoveRequest), "SysGroupMoveRequest"), - }; - - [Theory] - [MemberData(nameof(ModelCases))] - public void 模型字段集与快照双向一致(Type type, string schemaName) - { - var snapshot = LoadSnapshot(); - var schemas = snapshot.RootElement.GetProperty("components").GetProperty("schemas"); - - var poco = PocoPropertyNames(type); - var server = SchemaPropertyNames(schemas, schemaName); - - var sdkOnly = poco.Except(server).ToList(); - var serverOnly = server.Except(poco).ToList(); - - Assert.True(sdkOnly.Count == 0, - $"{type.Name} 存在快照没有的属性(服务端收不到,序列化键漂移):[{string.Join(", ", sdkOnly)}]"); - Assert.True(serverOnly.Count == 0, - $"{type.Name} 缺少快照属性(服务端字段被丢弃):[{string.Join(", ", serverOnly)}]"); - } - - public static IEnumerable ModelCases() - => ModelMappings.Select(m => new object[] { m.Type, m.Schema }); -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/EnvelopeParsingTests.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/EnvelopeParsingTests.cs deleted file mode 100644 index 7fd3cb1..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/EnvelopeParsingTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.Net; -using System.Text; -using Yh.Platform.Abstractions; -using Yh.Platform.OpenApi; -using Xunit; - -namespace Yh.Platform.OpenApi.Tests; - -/// 伪造 HttpMessageHandler:捕获最近一次请求并回放预设响应。 -public sealed class FakeHandler : HttpMessageHandler -{ - private readonly Func _responder; - - public FakeHandler(Func responder) - { - _responder = responder; - } - - public HttpRequestMessage? LastRequest { get; private set; } - - public string? LastRequestBody { get; private set; } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - LastRequest = request; - LastRequestBody = request.Content == null - ? null - : await request.Content.ReadAsStringAsync(cancellationToken); - return _responder(request, LastRequestBody); - } - - public static FakeHandler Json(HttpStatusCode statusCode, string json) - => new((_, _) => new HttpResponseMessage(statusCode) - { - Content = new StringContent(json, Encoding.UTF8, "application/json"), - }); -} - -/// -/// 信封感知解析对拍(对齐 contract/fixtures/envelope-*.json 约定): -/// 成功为裸数据;业务失败为 HTTP 200 + {code≠0,msg,data},客户端必须抛 DException。 -/// -public class EnvelopeParsingTests -{ - private static OpenApiOptions Options() => new() { BaseHost = "http://base.bestsch.com", BasePath = "/userbase" }; - - private static OpenApiUserClient Client(FakeHandler handler) => new(new HttpClient(handler), Options()); - - [Fact] - public async Task 裸数据成功响应_解析为BoaUser() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, - """{"id":1001,"account":"zhangsan","name":"张三","currentRole":{"roleId":3,"roleName":"教师","schId":2}}"""); - var user = await Client(handler).GetUserByIdAsync("1001"); - - Assert.NotNull(user); - Assert.Equal(1001, user!.Id); - Assert.Equal("zhangsan", user.Account); - Assert.Equal("张三", user.Name); - Assert.NotNull(user.CurrentRole); - Assert.Equal(3, user.CurrentRole!.RoleId); - Assert.Equal("教师", user.CurrentRole.RoleName); - } - - [Fact] - public async Task 包裹信封code0_提取data节点() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, - """{"code":0,"msg":"","data":{"id":1001,"account":"zhangsan","name":"张三"}}"""); - var user = await Client(handler).GetUserByIdAsync("1001"); - - Assert.NotNull(user); - Assert.Equal("zhangsan", user!.Account); - } - - [Fact] - public async Task 业务失败信封_抛DException并携带code与data() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, - """{"code":5,"msg":"组下存在子组或用户,禁止删除","data":{"groupId":9,"users":35}}"""); - - var ex = await Assert.ThrowsAsync(() => new OpenApiGroupClient(new HttpClient(handler), Options()).GetGroupByIdAsync(9)); - - var ec = Assert.IsType(ex.Code); - Assert.Equal(5, ec.Code); - Assert.Equal("组下存在子组或用户,禁止删除", ec.Msg); - Assert.Contains("禁止删除", ex.Message); - Assert.NotNull(ex.Data); - } - - [Fact] - public async Task 业务失败信封data为null_EnvelopeData为null() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, - """{"code":4,"msg":"登录已经过期,请重新登录","data":null}"""); - - var ex = await Assert.ThrowsAsync(() => Client(handler).GetUserByIdAsync("1")); - - Assert.Equal(4, Assert.IsType(ex.Code).Code); - Assert.Null(ex.EnvelopeData); - } - - [Fact] - public async Task 非成功HTTP状态_抛OpenApiException() - { - var handler = FakeHandler.Json(HttpStatusCode.InternalServerError, "boom"); - - var ex = await Assert.ThrowsAsync(() => Client(handler).GetUserByIdAsync("1")); - - Assert.Contains("500", ex.Message); - } - - [Fact] - public async Task 列表解析_裸数组与单对象两种形态() - { - var arrayHandler = FakeHandler.Json(HttpStatusCode.OK, - """[{"roleId":3,"roleName":"教师","schId":2},{"roleId":5,"roleName":"班主任","schId":2}]"""); - var roles = await Client(arrayHandler).GetUserRoleListAsync(1001); - Assert.Equal(2, roles.Count); - Assert.Equal("教师", roles[0].RoleName); - Assert.Equal(5, roles[1].RoleId); - - var singleHandler = FakeHandler.Json(HttpStatusCode.OK, """{"roleId":3,"roleName":"教师","schId":2}"""); - var single = await Client(singleHandler).GetUserRoleListAsync(1001); - Assert.Single(single); - } - - [Fact] - public async Task GET请求_URL与query构造正确() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, "null"); - await Client(handler).GetUserModuleAsync(1001, 2); - - var url = handler.LastRequest!.RequestUri!; - Assert.Equal("http://base.bestsch.com/userbase/oapi/user/modules", $"{url.Scheme}://{url.Host}{url.AbsolutePath}"); - Assert.Equal("?userId=1001&schId=2", url.Query); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - } - - [Fact] - public async Task query中的null值参数被丢弃() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, "[]"); - await Client(handler).GetUserModuleAsync(1001, null); - - Assert.DoesNotContain("schId", handler.LastRequest!.RequestUri!.Query); - Assert.Equal("?userId=1001", handler.LastRequest.RequestUri.Query); - } - - [Fact] - public async Task POST请求_编辑用户时路径id回写请求体() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """{"id":7,"account":"lisi","name":"李四"}"""); - var request = new SysUserRequest { Account = "lisi", RealName = "李四" }; - - await Client(handler).EditUserAsync(7, request); - - Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); - Assert.EndsWith("/userbase/oapi/user/edit/7", handler.LastRequest.RequestUri!.AbsolutePath); - Assert.NotNull(handler.LastRequestBody); - Assert.Contains("\"id\":7", handler.LastRequestBody); - } - - [Fact] - public async Task void解析_业务失败仍抛异常_成功不抛() - { - var ok = FakeHandler.Json(HttpStatusCode.OK, """{"code":0,"msg":"","data":null}"""); - await Client(ok).DeleteUserAsync(1, 2, 3); - - var fail = FakeHandler.Json(HttpStatusCode.OK, """{"code":5,"msg":"无权限","data":null}"""); - await Assert.ThrowsAsync(() => Client(fail).DeleteUserAsync(1, 2, 3)); - } - - [Fact] - public async Task 空响应体_返回default不抛() - { - var handler = new FakeHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("", Encoding.UTF8, "application/json"), - }); - - var user = await Client(handler).GetUserByIdAsync("1"); - Assert.Null(user); - } -} diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/GlobalUsings.cs deleted file mode 100644 index 1a73b47..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/GlobalUsings.cs +++ /dev/null @@ -1,9 +0,0 @@ -global using System; -global using System.Collections.Generic; -global using System.Net; -global using System.Net.Http; -global using System.Text; -global using System.Threading; -global using System.Threading.Tasks; -global using Yh.Platform.Abstractions; -global using Yh.Platform.OpenApi; diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/Yh.Platform.OpenApi.Tests.csproj b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/Yh.Platform.OpenApi.Tests.csproj deleted file mode 100644 index b47999a..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/Yh.Platform.OpenApi.Tests.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net8.0 - false - - - - - - - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/YschBaseOpenApiTests.cs b/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/YschBaseOpenApiTests.cs deleted file mode 100644 index f9baeae..0000000 --- a/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/YschBaseOpenApiTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Yh.Platform.Abstractions; -using Yh.Platform.OpenApi; -using Xunit; - -namespace Yh.Platform.OpenApi.Tests; - -public class OpenApiOptionsTests -{ - [Theory] - [InlineData(null, null)] - [InlineData("", null)] - [InlineData(" ", null)] - [InlineData("/userbase", "/userbase")] - [InlineData("/userbase/", "/userbase")] - [InlineData("userbase", "/userbase")] - public void NormalizeBasePath_归一化规则(string? input, string? expected) - { - Assert.Equal(expected, OpenApiOptions.NormalizeBasePath(input)); - } - - [Fact] - public void ResolveBaseUrl_拼接host与path并去尾斜杠() - { - var options = new OpenApiOptions { BaseHost = "http://base.bestsch.com/", BasePath = "/userbase/" }; - Assert.Equal("http://base.bestsch.com/userbase", options.ResolveBaseUrl()); - } - - [Fact] - public void ResolveBaseUrl_path为空时挂根路径() - { - var options = new OpenApiOptions { BaseHost = "http://base.bestsch.com", BasePath = "" }; - Assert.Equal("http://base.bestsch.com", options.ResolveBaseUrl()); - } - - [Fact] - public void 默认值_对齐Java客户端() - { - var options = new OpenApiOptions(); - Assert.True(options.Enabled); - Assert.Equal("/userbase", options.BasePath); - Assert.Equal(1500, options.ConnectTimeoutMs); - Assert.Equal(5000, options.ReadTimeoutMs); - Assert.Equal(5000, options.WriteTimeoutMs); - } -} - -/// 静态门面 的初始化准入与委托行为。 -public class YschBaseOpenApiTests : IDisposable -{ - private sealed class StubUserClient : IOpenApiUserClient - { - public int Calls; - public Task GetUserInfoAsync(int userId, CancellationToken ct = default) - { - Calls++; - return Task.FromResult(new BoaUser(userId, "stub", "桩用户")); - } - - public Task GetUserByIdAsync(string userId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) => throw new NotImplementedException(); - public Task GetUserByPhoneAsync(string phone, CancellationToken ct = default) => throw new NotImplementedException(); - public Task GetUserInfoAsync(string loginName, CancellationToken ct = default) => throw new NotImplementedException(); - public Task> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) => throw new NotImplementedException(); - public Task AddUserAsync(SysUserRequest request, CancellationToken ct = default) => throw new NotImplementedException(); - public Task EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default) => throw new NotImplementedException(); - public Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task DeleteUsersAsync(IReadOnlyList items, CancellationToken ct = default) => throw new NotImplementedException(); - public Task> BatchAddUsersAsync(IReadOnlyList requests, CancellationToken ct = default) => throw new NotImplementedException(); - public Task MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default) => throw new NotImplementedException(); - public Task> GetUserRoleListAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task GetCurrentIdentityAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default) => throw new NotImplementedException(); - public Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default) => throw new NotImplementedException(); - public Task GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default) => throw new NotImplementedException(); - public Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default) => throw new NotImplementedException(); - public Task BatchChangeUserStatusAsync(IReadOnlyList userIds, bool disabled, CancellationToken ct = default) => throw new NotImplementedException(); - } - - public YschBaseOpenApiTests() - { - YschBaseOpenApi.SetUserClient(null); - YschBaseOpenApi.SetGroupClient(null); - YschBaseOpenApi.SetAppClient(null); - } - - public void Dispose() - { - YschBaseOpenApi.SetUserClient(null); - YschBaseOpenApi.SetGroupClient(null); - YschBaseOpenApi.SetAppClient(null); - } - - [Fact] - public async Task 未初始化时抛InvalidOperationException并给出引导信息() - { - var ex = await Assert.ThrowsAsync(() => YschBaseOpenApi.GetUserInfoAsync(1)); - Assert.Contains("AddYhOpenApi", ex.Message); - Assert.Contains("SetUserClient", ex.Message); - } - - [Fact] - public async Task 初始化后门面委托到注入的client() - { - var stub = new StubUserClient(); - YschBaseOpenApi.SetUserClient(stub); - - var user = await YschBaseOpenApi.GetUserInfoAsync(1001); - - Assert.Equal(1, stub.Calls); - Assert.NotNull(user); - Assert.Equal(1001, user!.Id); - Assert.Equal("stub", user.Account); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/DebugMain.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/DebugMain.cs deleted file mode 100644 index 54828b7..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/DebugMain.cs +++ /dev/null @@ -1,53 +0,0 @@ -// 测试工程的运行调试入口(非测试用例)。 -// -// 用法: -// dotnet run --project tests/Yh.SchClass.OpenApi.Tests # 一键跑通全部 20 个端点 -// dotnet run --project tests/Yh.SchClass.OpenApi.Tests -- --only=OAStu -// dotnet run --project tests/Yh.SchClass.DebugTool -- --list # 端点名清单见 DebugTool -// -// 地址优先级同 SchClassLiveDebug:环境变量 YH_SCHCLASS_DEBUG_* → appsettings.json → 内置默认。 -// 在 IDE 里直接 F5 调试本项目即可进入这里(在 Main 内打断点)。 -// -// 注意:csproj 已设 GenerateProgramFile=false(关掉 Test SDK 自动 Main),dotnet test 不受影响。 - -using System.Text; - -namespace Yh.SchClass.OpenApi.Tests; - -public static class DebugMain -{ - public static async Task Main(string[] args) - { - Console.OutputEncoding = Encoding.UTF8; - - string? only = null; - foreach (var arg in args) - { - if (arg.StartsWith("--only=", StringComparison.OrdinalIgnoreCase)) - { - only = arg[7..].Trim(); - } - else if (arg.Equals("--off", StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine("跳过联调:--off(同 YH_SCHCLASS_DEBUG_OFF=1)"); - return 0; - } - } - - if (Environment.GetEnvironmentVariable(SchClassLiveDebug.OffVar) is "1" or "true") - { - Console.WriteLine("跳过联调:已设置 " + SchClassLiveDebug.OffVar + "=1(清除该变量即可恢复联调)"); - return 0; - } - - Console.WriteLine("== SchClass OpenAPI 测试工程调试入口 =="); - var failures = await SchClassLiveDebug.RunAllAsync(Console.WriteLine, only); - if (failures.Count == 0) - { - Console.WriteLine("全部通过 ✅"); - return 0; - } - Console.WriteLine($"失败 {failures.Count} 个:{string.Join(", ", failures)}"); - return 1; - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/FakeHandler.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/FakeHandler.cs deleted file mode 100644 index 6049c13..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/FakeHandler.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// -/// 伪造 :捕获最近一次请求并回放预设响应。 -/// 与 Yh.Platform.OpenApi.Tests 的 FakeHandler 同构,便于两个 SDK 的测试互相参照。 -/// -public sealed class FakeHandler : HttpMessageHandler -{ - private readonly Func _responder; - - public FakeHandler(Func responder) - { - _responder = responder; - } - - /// 最近一次捕获到的请求。 - public HttpRequestMessage? LastRequest { get; private set; } - - /// 最近一次请求体(无请求体时为 null)。 - public string? LastRequestBody { get; private set; } - - /// 最近一次请求体的 Content-Type(无请求体时为 null)。 - public string? LastContentType { get; private set; } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - LastRequest = request; - if (request.Content == null) - { - LastRequestBody = null; - LastContentType = null; - } - else - { - LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); - LastContentType = request.Content.Headers.ContentType?.MediaType; - } - return _responder(request, LastRequestBody); - } - - /// 回放一段固定 JSON(application/json)。 - public static FakeHandler Json(HttpStatusCode statusCode, string json) - => new((_, _) => new HttpResponseMessage(statusCode) - { - Content = new StringContent(json, Encoding.UTF8, "application/json"), - }); - - /// 回放指定状态码且无响应体(模拟 204 / 404 空响应)。 - public static FakeHandler Empty(HttpStatusCode statusCode) - => new((_, _) => new HttpResponseMessage(statusCode)); - - /// 回放纯文本响应(模拟非 JSON 的错误页)。 - public static FakeHandler Text(HttpStatusCode statusCode, string text) - => new((_, _) => new HttpResponseMessage(statusCode) - { - Content = new StringContent(text, Encoding.UTF8, "text/plain"), - }); -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/GlobalUsings.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/GlobalUsings.cs deleted file mode 100644 index ba4abeb..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/GlobalUsings.cs +++ /dev/null @@ -1,16 +0,0 @@ -global using System; -global using System.Collections.Generic; -global using System.IO; -global using System.Linq; -global using System.Net; -global using System.Net.Http; -global using System.Text; -global using System.Text.Json; -global using System.Threading; -global using System.Threading.Tasks; -global using Yh.SchClass.OpenApi; -global using Yh.SchClass.OpenApi.Model; -global using Xunit; - -// Java 侧 Map 兼容行的 .NET 承载类型(与 src 侧同名别名保持一致) -global using SchClassRow = System.Collections.Generic.Dictionary; diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebug.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebug.cs deleted file mode 100644 index 76e2c2c..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebug.cs +++ /dev/null @@ -1,187 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace Yh.SchClass.OpenApi.Tests; - -/// -/// 联调调试用的环境配置。地址来源优先级: -/// -/// 1. 环境变量(YH_SCHCLASS_DEBUG_HOST / YH_SCHCLASS_DEBUG_PATH); -/// 2. appsettings.json:先独立节 yh:schclass:open,再共享节 yh:open 的 -/// BaseHost + SchClassPath; -/// 3. 内置默认 + 。 -/// -/// -/// 关闭联调:置 YH_SCHCLASS_DEBUG_OFF=1,用例全部空跑通过(CI 用)。 -/// -/// -internal static class SchClassLiveDebug -{ - public const string HostVar = "YH_SCHCLASS_DEBUG_HOST"; - public const string PathVar = "YH_SCHCLASS_DEBUG_PATH"; - public const string SchVar = "YH_SCHCLASS_DEBUG_SCH"; - public const string ClassVar = "YH_SCHCLASS_DEBUG_CLASS"; - public const string TeaVar = "YH_SCHCLASS_DEBUG_TEA"; - public const string GroupVar = "YH_SCHCLASS_DEBUG_GROUP"; - public const string OffVar = "YH_SCHCLASS_DEBUG_OFF"; - - /// 配置与配置文件都未提供时的兜底联调主机。 - public const string DefaultHost = "http://base.yunhaixingtong.com"; - - /// 配置与配置文件都未提供时的兜底应用路径。 - public const string DefaultPath = "/bschclass"; - - private static readonly IConfiguration? Config; - - static SchClassLiveDebug() - { - var file = System.IO.Path.Combine(AppContext.BaseDirectory, "appsettings.json"); - if (File.Exists(file)) - { - Config = new ConfigurationBuilder().AddJsonFile(file, optional: true).Build(); - } - } - - /// 是否开启联调(默认开启;置 YH_SCHCLASS_DEBUG_OFF=1 可关闭,CI 用它空跑)。 - public static bool Enabled - { - get - { - var off = Environment.GetEnvironmentVariable(OffVar); - return !(off == "1" || "true".Equals(off, StringComparison.OrdinalIgnoreCase)); - } - } - - private static string? Env(string name) - { - var value = Environment.GetEnvironmentVariable(name); - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static string? Configured(string path) - { - var value = Config?[path]; - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - /// 联调主机:环境变量 → yh:schclass:open:BaseHost → yh:open:BaseHost → 内置默认。 - public static string Host => - Env(HostVar) - ?? Configured("yh:schclass:open:BaseHost") - ?? Configured("yh:open:BaseHost") - ?? DefaultHost; - - /// 联调基础路径:环境变量 → yh:schclass:open:BasePath → yh:open:SchClassPath → 内置默认。 - public static string? Path => - Env(PathVar) - ?? Configured("yh:open:SchClassPath") - ?? DefaultPath; - - /// 按当前配置构建门面。 - public static SchClassOpenApi Create() - { - var options = new SchClassOpenApiOptions { BaseHost = Host, BasePath = Path }; - return SchClassOpenApiFactory.Create(options); - } - - /// 联调目标地址(用于日志输出)。 - public static string Target() - { - var options = new SchClassOpenApiOptions { BaseHost = Host, BasePath = Path }; - return options.ResolveBaseUrl(); - } - - public static string? Sch => Env(SchVar); - public static string? ClassId => Env(ClassVar); - public static int? TeaSerId => int.TryParse(Env(TeaVar), out var id) ? id : null; - - /// DbClass 分组号,缺省 3(对联调环境真实有效的分组)。 - public static int? Group => int.TryParse(Env(GroupVar), out var g) ? g : null; - - /// - /// 一键跑通全部端点(测试用例与 DebugMain.Main 调试入口共用)。 - /// 缺省 ID 一律从真实数据推导:用捏造的 ID 会让端点恒成立 0 条,把真实问题伪装成"接口空"。 - /// 返回失败端点名列表; 输出进度。 - /// - public static async Task> RunAllAsync(Action log, string? only = null) - { - var api = Create(); - var sch = Sch - ?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId ?? "S001"; - var group = Group ?? 3; - - var classId = ClassId; - if (classId == null) - { - var cls = (await api.VClass.GetVClassListAsync(sch)).FirstOrDefault(); - classId = cls != null && cls.TryGetValue("ClassID", out var cid) - ? cid.ToString() - : "1001"; - } - - var teaId = TeaSerId; - if (teaId == null) - { - var tea = (await api.DbClass.GetSchteaListAsync(group)).FirstOrDefault(); - teaId = tea != null - && tea.TryGetValue("UserTeaSerID", out var tid) - && int.TryParse(tid.ToString(), out var parsed) - ? parsed - : 77; - } - - var cases = new List<(string Name, Func> Call)> - { - // Group=3 对应联调环境真实存在的分组;可用 YH_SCHCLASS_DEBUG_GROUP 覆盖 - ("DbClass.GetSchteaList", async () => (await api.DbClass.GetSchteaListAsync(group)).Count), - ("VSchClass.GetVClassList", async () => (await api.VClass.GetVClassListAsync(sch)).Count), - ("VSchClass.GetSchArrVClassListAll", async () => (await api.VClass.GetSchArrVClassListAllAsync()).Count), - ("VSchClass.GetSchArrVClassListPost", async () => (await api.VClass.GetSchArrVClassListPostAsync(sch)).Count), - ("Schoolen.GetSchLenLevellist", async () => (await api.Schooling.GetSchLenLevelListAsync(sch)).Count), - ("Schoolen.GetSchLenlist", async () => (await api.Schooling.GetSchLenListAsync(sch)).Count), - ("UserInfo.GetUserList", async () => (await api.UserInfo.GetUserListAsync(sch)).Count), - ("SchInfo.AllList", async () => (await api.SchInfo.AllListAsync()).Count), - ("SchInfo.GetSchlist", async () => (await api.SchInfo.GetSchListAsync(null)).Count), - ("OATea.GetSchTeainfo", async () => (await api.TeaInfo.GetSchTeaInfoAsync(sch, null)).Count), - ("OATea.GetArrTeainfo", async () => (await api.TeaInfo.GetArrTeaInfoAsync(teaId.Value.ToString())).Count), - ("OAStu.GetAllStulist", async () => (await api.StuInfo.GetAllStuListAsync()).Count), - ("OAStu.GetSchStulist", async () => (await api.StuInfo.GetSchStuListAsync(sch, null)).Count), - ("OAStu.GetClaStulist", async () => (await api.StuInfo.GetClaStuListAsync(classId, null)).Count), - ("TeachInfo.GetSchTeachlist", async () => (await api.TeachInfo.GetSchTeachListAsync(sch, teaId)).Count), - ("TeachInfo.GetSchClaTeachlist", async () => (await api.TeachInfo.GetSchClaTeachListAsync(classId)).Count), - ("SubInfo.GetSchTeachSublist", async () => (await api.SubInfo.GetSchTeachSubListAsync(sch)).Count), - ("SubInfo.GetSchSublist", async () => (await api.SubInfo.GetSchSubListAsync(sch, null, null)).Count), - ("FamCom.GetStuUserFamIDList", async () => (await api.FamilyCompat.GetStuUserFamIdListAsync("1,2")).Count), - ("FamCom.GetAllStuFamMem", async () => (await api.FamilyCompat.GetAllStuFamMemAsync("1,2")).Count), - }; - - if (only != null) - { - cases = cases.Where(c => c.Name.Contains(only, StringComparison.OrdinalIgnoreCase)).ToList(); - if (cases.Count == 0) - { - log($"--only={only} 未匹配任何端点"); - return new List { "--only" }; - } - } - - var failures = new List(); - log($"联调目标:{Target()} sch={sch} classId={classId} teaId={teaId} group={group}"); - - foreach (var (name, call) in cases) - { - try - { - var count = await call(); - log($" OK {name} -> {count} 条"); - } - catch (Exception e) - { - failures.Add(name); - log($" FAIL {name} -> {e.GetType().Name}: {e.Message}"); - } - } - - log($"汇总:{cases.Count - failures.Count}/{cases.Count} 通过"); - return failures; - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebugTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebugTests.cs deleted file mode 100644 index 7589706..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassLiveDebugTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Xunit.Abstractions; - -namespace Yh.SchClass.OpenApi.Tests; - -/// -/// 联调调试用例:默认请求联调环境 http://base.yunhaixingtong.com/bschclass, -/// 并把每个端点的返回条数/异常打印到测试输出,便于在 IDE 调试窗口里逐个端点排查。 -/// -/// 用 YH_SCHCLASS_DEBUG_HOST / YH_SCHCLASS_DEBUG_PATH 覆盖目标地址, -/// 置 YH_SCHCLASS_DEBUG_OFF=1 可关闭联调(CI 用它空跑通过)。 -/// -/// -public class SchClassLiveDebugTests(ITestOutputHelper output) -{ - private bool Live() - { - if (SchClassLiveDebug.Enabled) - { - return true; - } - output.WriteLine("跳过联调:已设置 " + SchClassLiveDebug.OffVar + "=1(清除该变量即可恢复联调)"); - return false; - } - - [Fact] - public async Task 联调_学校列表() - { - if (!Live()) return; - - var api = SchClassLiveDebug.Create(); - var list = await api.SchInfo.AllListAsync(); - - foreach (var s in list) - { - output.WriteLine($"SchInfo: {s.SchSerId} / {s.UserSchSerId} / {s.SchName}"); - } - output.WriteLine($"共 {list.Count} 所学校 —— {SchClassLiveDebug.Target()}"); - } - - [Fact] - public async Task 联调_班级与学生() - { - if (!Live()) return; - - var api = SchClassLiveDebug.Create(); - var sch = SchClassLiveDebug.Sch - ?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId; - if (string.IsNullOrEmpty(sch)) - { - output.WriteLine("未取到学校序列号(设置 YH_SCHCLASS_DEBUG_SCH 或确认学校列表非空)"); - return; - } - - var classes = await api.VClass.GetVClassListAsync(sch); - output.WriteLine($"VSchClass[{sch}] 共 {classes.Count} 个班级"); - - var students = await api.StuInfo.GetSchStuListAsync(sch, null); - output.WriteLine($"OAStu[{sch}] 共 {students.Count} 名学生"); - } - - [Fact] - public async Task 联调_教师与任课() - { - if (!Live()) return; - - var api = SchClassLiveDebug.Create(); - var sch = SchClassLiveDebug.Sch - ?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId; - - var teachers = await api.TeaInfo.GetSchTeaInfoAsync(sch, null); - output.WriteLine($"OATea[{sch}] 共 {teachers.Count} 名教师"); - - if (SchClassLiveDebug.TeaSerId is { } teaId) - { - var teach = await api.TeachInfo.GetSchTeachListAsync(sch, teaId); - output.WriteLine($"TeachInfo[{teaId}] 共 {teach.Count} 条任课"); - } - } - - [Fact] - public async Task 联调_一键跑通全部端点() - { - if (!Live()) return; - - var failures = await SchClassLiveDebug.RunAllAsync(output.WriteLine); - Assert.Empty(failures); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiConfigTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiConfigTests.cs deleted file mode 100644 index 750322c..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiConfigTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Yh.SchClass.OpenApi.Tests; - -/// -/// 配置读取测试:对应 appsettings.json 的真实结构 —— -/// -/// "yh": { "open": { -/// "BaseHost": "http://base.yunhaixingtong.com", -/// "BasePath": "/userbase", // UserBase 用,教务不用它 -/// "SchClassPath": "/bschclass" // 教务用它 -/// }} -/// -/// -public class SchClassOpenApiConfigTests -{ - private static IConfiguration BuildConfig(Dictionary kv) - => new ConfigurationBuilder().AddInMemoryCollection(kv).Build()!; - - [Fact] - public void 共享节_SchClassPath映射为教务BasePath() - { - var config = BuildConfig(new Dictionary - { - ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", - ["yh:open:BasePath"] = "/userbase", - ["yh:open:SchClassPath"] = "/bschclass", - }); - - var options = new SchClassOpenApiOptions(); - var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); - options.FillFromSharedSection(key => shared[key]); - - Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost); - // 关键:教务用 SchClassPath,不是共享节里的 BasePath(/userbase) - Assert.Equal("/bschclass", options.BasePath); - Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl()); - } - - [Fact] - public void 共享节_路径带空格与斜杠可归一化() - { - var config = BuildConfig(new Dictionary - { - ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", - ["yh:open:SchClassPath"] = " /bschclass/", // 模拟配置里常见的多余空格/尾斜杠 - }); - - var options = new SchClassOpenApiOptions(); - var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); - options.FillFromSharedSection(key => shared[key]); - - Assert.Equal("/bschclass", options.BasePath); - Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl()); - } - - [Fact] - public void 共享节_未配SchClassPath时BasePath保持空() - { - var config = BuildConfig(new Dictionary - { - ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", - ["yh:open:BasePath"] = "/userbase", // 不允许被教务误用 - }); - - var options = new SchClassOpenApiOptions(); - var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); - options.FillFromSharedSection(key => shared[key]); - - Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost); - Assert.Null(options.BasePath); - Assert.Equal("http://base.yunhaixingtong.com", options.ResolveBaseUrl()); - } - - [Fact] - public void 独立节优先_共享节仅回填缺失项() - { - var config = BuildConfig(new Dictionary - { - ["yh:schclass:open:BasePath"] = "/sch-only", // 独立节只给了路径 - ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", - ["yh:open:SchClassPath"] = "/bschclass", - ["yh:open:AppKey"] = "100002", - ["yh:open:AppSecret"] = "secret", - }); - - var options = new SchClassOpenApiOptions(); - config.GetSection(SchClassOpenApiOptions.SectionName).Bind(options); - var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName); - options.FillFromSharedSection(key => shared[key]); - - Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost); // 从共享节回填 - Assert.Equal("/sch-only", options.BasePath); // 独立节优先,不被覆盖 - Assert.Equal("100002", options.AppKey); // AppKey 回填 - Assert.Equal("secret", options.AppSecret); - } - - [Fact] - public void DI_注册后可解析门面且地址来自配置() - { - var config = BuildConfig(new Dictionary - { - ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", - ["yh:open:SchClassPath"] = "/bschclass", - }); - - var services = new ServiceCollection(); - services.AddSchClassOpenApi(config); - using var provider = services.BuildServiceProvider(); - - var options = provider.GetRequiredService>().Value; - Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl()); - - var facade = provider.GetRequiredService(); - Assert.NotNull(facade.SchInfo); - Assert.NotNull(facade.StuInfo); - Assert.Equal(new Uri("http://base.yunhaixingtong.com/bschclass"), facade.HttpBaseAddress); - } - - [Fact] - public void DI_独立节配置整节生效() - { - var config = BuildConfig(new Dictionary - { - ["yh:schclass:open:BaseHost"] = "http://sch.example.com", - ["yh:schclass:open:BasePath"] = "/sch", - ["yh:schclass:open:ReadTimeoutMs"] = "3000", - ["yh:open:BaseHost"] = "http://base.yunhaixingtong.com", - }); - - var services = new ServiceCollection(); - services.AddSchClassOpenApi(config); - using var provider = services.BuildServiceProvider(); - - var options = provider.GetRequiredService>().Value; - Assert.Equal("http://sch.example.com", options.BaseHost); - Assert.Equal("/sch", options.BasePath); - Assert.Equal(3000, options.ReadTimeoutMs); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiFacadeTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiFacadeTests.cs deleted file mode 100644 index 8cf243c..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiFacadeTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// 聚合门面 与工厂 的装配行为。 -public class SchClassOpenApiFacadeTests -{ - [Fact] - public void 门面装配全部10个子客户端() - { - var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com/bschclass/oapi/" }; - using var http = new HttpClient(); - - var api = new SchClassOpenApi(http, options); - - Assert.NotNull(api.DbClass); - Assert.NotNull(api.VClass); - Assert.NotNull(api.Schooling); - Assert.NotNull(api.UserInfo); - Assert.NotNull(api.SchInfo); - Assert.NotNull(api.TeaInfo); - Assert.NotNull(api.StuInfo); - Assert.NotNull(api.TeachInfo); - Assert.NotNull(api.SubInfo); - Assert.NotNull(api.FamilyCompat); - Assert.Same(options, api.Options); - } - - [Fact] - public void 门面构造_httpClient为空时抛() - { - Assert.Throws( - () => new SchClassOpenApi(null!, new SchClassOpenApiOptions())); - } - - [Fact] - public void 门面构造_options为空时抛() - { - using var http = new HttpClient(); - - Assert.Throws(() => new SchClassOpenApi(http, null!)); - } - - [Fact] - public void 子客户端构造_参数为空时抛() - { - using var http = new HttpClient(); - var options = new SchClassOpenApiOptions(); - - Assert.Throws(() => new SchClassVClassClient(null!, options)); - Assert.Throws(() => new SchClassVClassClient(http, null!)); - } - - [Fact] - public void 工厂_按配置创建HttpClient() - { - var options = new SchClassOpenApiOptions - { - BaseHost = "http://base.yunhaixingtong.com/", - BasePath = "/bschclass", - ReadTimeoutMs = 3000, - }; - - using var http = SchClassOpenApiFactory.CreateHttpClient(options); - - Assert.Equal(new Uri("http://base.yunhaixingtong.com/bschclass"), http.BaseAddress); - Assert.Equal(TimeSpan.FromMilliseconds(3000), http.Timeout); - } - - [Fact] - public void 工厂_读超时非法时回落5秒() - { - var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com", ReadTimeoutMs = 0 }; - - using var http = SchClassOpenApiFactory.CreateHttpClient(options); - - Assert.Equal(TimeSpan.FromMilliseconds(5000), http.Timeout); - } - - [Fact] - public void 工厂_创建完整门面() - { - var facade = SchClassOpenApiFactory.Create(new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com" }); - - Assert.NotNull(facade); - Assert.NotNull(facade.VClass); - Assert.NotNull(facade.StuInfo); - } - - [Fact] - public void 工厂_配置为空时抛() - { - Assert.Throws(() => SchClassOpenApiFactory.CreateHttpClient(null!)); - Assert.Throws(() => SchClassOpenApiFactory.Create(null!)); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiOptionsTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiOptionsTests.cs deleted file mode 100644 index 7e40e7d..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOpenApiOptionsTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -public class SchClassOpenApiOptionsTests -{ - [Theory] - [InlineData(null, null)] - [InlineData("", null)] - [InlineData(" ", null)] - [InlineData("/base.yunhaixingtong.com", "/base.yunhaixingtong.com")] - [InlineData("/base.yunhaixingtong.com/", "/base.yunhaixingtong.com")] - [InlineData("base.yunhaixingtong.com", "/base.yunhaixingtong.com")] - [InlineData(" /base.yunhaixingtong.com/ ", "/base.yunhaixingtong.com")] - public void NormalizeBasePath_归一化规则(string? input, string? expected) - { - Assert.Equal(expected, SchClassOpenApiOptions.NormalizeBasePath(input)); - } - - [Fact] - public void ResolveBaseUrl_拼接host与path并去尾斜杠() - { - var options = new SchClassOpenApiOptions - { - BaseHost = "http://base.yunhaixingtong.com/", - BasePath = "/base.yunhaixingtong.com/", - }; - - Assert.Equal("http://base.yunhaixingtong.com/base.yunhaixingtong.com", options.ResolveBaseUrl()); - } - - [Fact] - public void ResolveBaseUrl_path为空时挂根路径() - { - var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com", BasePath = "" }; - - Assert.Equal("http://base.yunhaixingtong.com", options.ResolveBaseUrl()); - } - - [Fact] - public void ResolveBaseUrl_host为空时返回空串() - { - Assert.Equal("", new SchClassOpenApiOptions().ResolveBaseUrl()); - } - - [Fact] - public void 默认值_对齐Java客户端() - { - var options = new SchClassOpenApiOptions(); - - Assert.True(options.Enabled); - Assert.Null(options.BaseHost); - Assert.Null(options.BasePath); - Assert.Equal(1500, options.ConnectTimeoutMs); - Assert.Equal(5000, options.ReadTimeoutMs); - Assert.Equal(5000, options.WriteTimeoutMs); - Assert.Null(options.AppKey); - Assert.Null(options.AppSecret); - } - - [Fact] - public void 配置节名_独立于用户中心() - { - Assert.Equal("yh:schclass:open", SchClassOpenApiOptions.SectionName); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOtherClientsTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOtherClientsTests.cs deleted file mode 100644 index a6a16ba..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassOtherClientsTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// 其余客户端(学制 / 科目 / 任课 / 学校教师 / 家长兼容)的端点契约对拍。 -public class SchClassOtherClientsTests -{ - [Fact] - public async Task Schoolen_学制与年级_5个端点() - { - const string root = "/oapi/Schoolen/"; - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.Schooling.GetSchLenLevelListAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenLevellist", "?SchSerID=S1"); - - await api.Schooling.GetLevelIdListAsync(3); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetLevelIdlist", "?Schooling=3"); - - await api.Schooling.GetSchLenLevelFirstAsync("11"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenLevelFirst", "?GradeId=11"); - - await api.Schooling.GetSchLenListAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenlist", "?SchSerID=S1"); - - await api.Schooling.GetAllClassSubjectAsync(2, "S1", "2026-09-01"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetAllClassSubject", "?schId=2&schserid=S1&startTime=2026-09-01"); - } - - [Fact] - public async Task SubInfo_科目_4个端点() - { - const string root = "/oapi/SubInfo/"; - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.SubInfo.GetSchSubListAsync("S1", 3, 11); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchSublist", "?SchID=S1&schooling=3&gradeId=11"); - - await api.SubInfo.GetSchIdsSubListAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchIdsSublist", "?SchIds=S1,S2"); - - await api.SubInfo.GetSchTeaSubListAsync("77"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeaSublist", "?UserTeaSerID=77"); - - await api.SubInfo.GetSchTeachSubListAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeachSublist", "?SchID=S1"); - } - - [Fact] - public async Task TeachInfo_任课_8个端点() - { - const string root = "/oapi/TeachInfo/"; - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.TeachInfo.GetSchTeachListAsync("S1", 77); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeachlist", "?SchID=S1&UserTeaSerID=77"); - - await api.TeachInfo.GetEleSchTeachListAsync("S1", 77); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchTeachlist", "?SchID=S1&UserTeaSerID=77"); - - await api.TeachInfo.GetSchArrTeachListAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchArrTeachlist", "?SchIDArr=S1,S2"); - - await api.TeachInfo.GetEleSchArrTeachListAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchArrTeachList", "?SchIDArr=S1,S2"); - - await api.TeachInfo.GetSchClaTeachListAsync("1001"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchClaTeachlist", "?ClassID=1001"); - - await api.TeachInfo.GetEleSchClaTeachListAsync("1001"); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchClaTeachlist", "?ClassID=1001"); - - await api.TeachInfo.GetElectiveClassByGradeAsync(1, 2); - SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetElectiveClassByGrade", "?schoolId=1&gradeId=2"); - - await api.TeachInfo.GetElectiveClassByTeachersAsync("77,78"); - SchClassTestHost.VerifyBody(handler, root + "GetElectiveClassByTeachers", "77,78"); - } - - [Fact] - public async Task DbClass_学校教师名单_1个端点() - { - var handler = SchClassTestHost.Ok(); - - await SchClassTestHost.Api(handler).DbClass.GetSchteaListAsync(30); - - SchClassTestHost.Verify(handler, HttpMethod.Get, "/oapi/DbClass/GetSchteaList", "?Group=30"); - } - - [Fact] - public async Task FamCom_家长兼容_2个端点均为POST文本体() - { - const string root = "/oapi/Common/FamCom/"; - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.FamilyCompat.GetStuUserFamIdListAsync("1,2"); - SchClassTestHost.VerifyBody(handler, root + "GetStuUserFamIDList", "1,2"); - - await api.FamilyCompat.GetAllStuFamMemAsync("1,2"); - SchClassTestHost.VerifyBody(handler, root + "GetAllStuFamMem", "1,2"); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassResponseParsingTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassResponseParsingTests.cs deleted file mode 100644 index 63c1076..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassResponseParsingTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// -/// 响应解析与状态码归一(教务 OA 兼容端点约定): -/// 成功返回裸数据;404 / 204 表示"无数据"归一为 null / 空列表;400 及其它非 2xx 抛异常。 -/// -public class SchClassResponseParsingTests -{ - private static readonly SchClassOpenApiOptions Options = new() { BaseHost = "http://base.yunhaixingtong.com" }; - - private static SchClassVClassClient VClass(FakeHandler handler) => new(new HttpClient(handler), Options); - - private static SchClassTeaInfoClient Tea(FakeHandler handler) => new(new HttpClient(handler), Options); - - private static SchClassSchoolingClient Schooling(FakeHandler handler) => new(new HttpClient(handler), Options); - - [Fact] - public async Task 裸数组_解析为兼容行列表() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """ - [{"Id":1,"Name":"高一(1)班","ClassID":1001,"SchSerID":"S001"}, - {"Id":2,"Name":"高一(2)班","ClassID":1002,"SchSerID":"S001"}] - """); - - var rows = await VClass(handler).GetVClassListAsync("S001"); - - Assert.Equal(2, rows.Count); - Assert.Equal("高一(1)班", rows[0]["Name"].GetString()); - Assert.Equal(1001, rows[0]["ClassID"].GetInt32()); - Assert.Equal("S001", rows[1]["SchSerID"].GetString()); - } - - [Fact] - public async Task 单对象_归一为单项列表() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """{"Id":9,"Name":"高三(7)班"}"""); - - var rows = await VClass(handler).GetVClassListAsync("S001"); - - Assert.Single(rows); - Assert.Equal(9, rows[0]["Id"].GetInt32()); - } - - [Fact] - public async Task 裸字符串数组_解析为List_string() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """["语文","数学"]"""); - - var subs = await new SchClassSubInfoClient(new HttpClient(handler), Options) - .GetSchTeachSubListAsync("S001"); - - Assert.Equal(new[] { "语文", "数学" }, subs); - } - - [Fact] - public async Task HTTP404_列表归一为空列表() - { - var rows = await VClass(FakeHandler.Empty(HttpStatusCode.NotFound)).GetVClassListAsync("S001"); - - Assert.Empty(rows); - } - - [Fact] - public async Task HTTP404_单对象归一为null() - { - var row = await VClass(FakeHandler.Empty(HttpStatusCode.NotFound)).GetVClassFirstAsync("1001"); - - Assert.Null(row); - } - - [Fact] - public async Task HTTP204_归一为空列表() - { - var handler = FakeHandler.Json(HttpStatusCode.NoContent, ""); - - var list = await Tea(handler).GetArrTeaInfoAsync("1,2,3"); - - Assert.Empty(list); - } - - [Fact] - public async Task HTTP400_抛异常并携带状态码与响应体() - { - var handler = FakeHandler.Text(HttpStatusCode.BadRequest, "Required String parameter 'userIds' is not present"); - - var ex = await Assert.ThrowsAsync(() => Tea(handler).GetArrTeaInfoAsync(null!)); - - Assert.Equal(400, ex.StatusCode); - Assert.Contains("400", ex.Message); - Assert.Contains("userIds", ex.ResponseBody!); - } - - [Fact] - public async Task HTTP500_抛异常() - { - var handler = FakeHandler.Text(HttpStatusCode.InternalServerError, "boom"); - - var ex = await Assert.ThrowsAsync(() => VClass(handler).GetVClassListAsync("S001")); - - Assert.Equal(500, ex.StatusCode); - Assert.Contains("500", ex.Message); - } - - [Fact] - public async Task 空响应体_返回空列表不抛() - { - var handler = new FakeHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("", Encoding.UTF8, "application/json"), - }); - - Assert.Empty(await VClass(handler).GetVClassListAsync("S001")); - Assert.Null(await VClass(handler).GetVClassFirstAsync("1")); - } - - [Fact] - public async Task JSON字面量null_返回空列表() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, "null"); - - Assert.Empty(await VClass(handler).GetVClassListAsync("S001")); - } - - [Fact] - public async Task 信封code0_提取data节点() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, - """{"code":0,"msg":"","data":[{"Id":1,"Name":"高一(1)班"}]}"""); - - var rows = await VClass(handler).GetVClassListAsync("S001"); - - Assert.Single(rows); - Assert.Equal("高一(1)班", rows[0]["Name"].GetString()); - } - - [Fact] - public async Task 信封code0且data为null_归一为空() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """{"code":0,"msg":"ok","data":null}"""); - - Assert.Empty(await VClass(handler).GetVClassListAsync("S001")); - } - - [Fact] - public async Task 信封业务失败_抛异常并携带msg() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, - """{"code":3,"msg":"学校不存在","data":null}"""); - - var ex = await Assert.ThrowsAsync(() => VClass(handler).GetVClassListAsync("S001")); - - Assert.Equal("学校不存在", ex.Message); - Assert.Equal(0, ex.StatusCode); - Assert.Contains("学校不存在", ex.ResponseBody!); - } - - [Fact] - public async Task 业务行含code字段_不被误判为信封() - { - // 学制等级行自身带 code=gradeId 字段,只有 code 而没有 data/msg,必须原样返回 - var handler = FakeHandler.Json(HttpStatusCode.OK, - """[{"Id":1,"Name":"一年级","OwnerId":2,"Levell":1,"code":11,"sx":1}]"""); - - var rows = await Schooling(handler).GetSchLenLevelListAsync("S001"); - - Assert.Single(rows); - Assert.Equal(11, rows[0]["code"].GetInt32()); - Assert.Equal("一年级", rows[0]["Name"].GetString()); - } - - [Fact] - public async Task 非法JSON_抛解析异常() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, "502 Bad Gateway"); - - var ex = await Assert.ThrowsAsync(() => VClass(handler).GetVClassListAsync("S001")); - - Assert.Contains("解析失败", ex.Message); - } - - [Fact] - public async Task 兼容行键名_大小写与Java侧put顺序一致() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, - """[{"SchSerID":"S001","TeaTel":"13800000000","TeaName":"王老师","UserTeaSerID":77}]"""); - - var rows = await new SchClassDbClassClient(new HttpClient(handler), Options).GetSchteaListAsync(1); - - Assert.Equal(new[] { "SchSerID", "TeaTel", "TeaName", "UserTeaSerID" }, rows[0].Keys); - Assert.Equal("王老师", rows[0]["TeaName"].GetString()); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassSchInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassSchInfoClientTests.cs deleted file mode 100644 index dff794c..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassSchInfoClientTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// SchInfo(学校)端点契约与 反序列化。 -public class SchClassSchInfoClientTests -{ - private const string Root = "/oapi/SchInfo/"; - - [Fact] - public async Task SchInfo_全部7个端点_路径与参数名对齐() - { - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.SchInfo.GetSchListAsync("1,2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchlist", "?SchIDArr=1,2"); - - await api.SchInfo.AllListAsync(); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "AllList"); - - await api.SchInfo.GetSchFirstAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchFirst", "?SchID=S1"); - - await api.SchInfo.GetUserSchFirstAsync(9); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserSchFirst", "?UserSchID=9"); - - await api.SchInfo.GetUserSchListAsync("9,10"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserSchlist", "?UserSchIds=9,10"); - - await api.SchInfo.GetSchoolByGroupIdAsync(3); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchoolByGroupId", "?groupId=3"); - - await api.SchInfo.GetSchoolBySchoolenIdAsync(4); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchoolBySchoolenId", "?groupId=4"); - } - - [Fact] - public async Task SchInfoVO_按JsonProperty锚定的键名反序列化() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """ - [{"SchSerId":"S001","UserSchSerId":9,"SchName":"第一中学"}, - {"SchSerId":"S002","UserSchSerId":10,"SchName":"第二中学"}] - """); - - var list = await SchClassTestHost.Api(handler).SchInfo.AllListAsync(); - - Assert.Equal(2, list.Count); - Assert.Equal("S001", list[0].SchSerId); - Assert.Equal(9, list[0].UserSchSerId); - Assert.Equal("第二中学", list[1].SchName); - } - - [Fact] - public async Task GetSchFirst_未找到时返回null() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, "null"); - - Assert.Null(await SchClassTestHost.Api(handler).SchInfo.GetSchFirstAsync("nope")); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassStuInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassStuInfoClientTests.cs deleted file mode 100644 index 756d546..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassStuInfoClientTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// -/// OAStu(学生)端点的请求契约对拍。注意本控制器参数名几乎全小写 -/// (schserid / schgroupid / stuids / schserids), -/// 与 VSchClass 的 SchSerID 形成对照,不可混用。 -/// -public class SchClassStuInfoClientTests -{ - private const string Root = "/oapi/OAStu/"; - - [Fact] - public async Task OAStu_全部19个端点_路径与参数名对齐() - { - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.StuInfo.GetAllStuListAsync(); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetAllStulist"); - - await api.StuInfo.GetEleAllStuListAsync(); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleAllStulist"); - - await api.StuInfo.GetSchStuListAsync("S1", "10"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchStulist", "?schserid=S1&schgroupid=10"); - - await api.StuInfo.GetEleSchStuListAsync("S1", "10"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchStulist", "?schserid=S1&schgroupid=10"); - - await api.StuInfo.GetClaStuListAsync("1001", "20"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetClaStulist", "?ClassID=1001&clagroupid=20"); - - await api.StuInfo.GetEleClaStuListAsync("1001", "20"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleClaStulist", "?ClassID=1001&clagroupid=20"); - - await api.StuInfo.GetClaArrStuListAsync("1001,1002", "20,21"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetClaArrStulist", "?ClassIDs=1001,1002&clagroupids=20,21"); - - await api.StuInfo.GetEleClaArrStuListAsync("1001,1002", "20,21"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleClaArrStulist", "?ClassIDs=1001,1002&clagroupids=20,21"); - - await api.StuInfo.GetStuIdsListAsync("1,2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuIdslist", "?stuids=1,2"); - - await api.StuInfo.GetEleStuIdsListAsync("1,2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleStuIdslist", "?stuids=1,2"); - - await api.StuInfo.GetStuUserFamIdListAsync("1,2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuUserFamIDList", "?stuids=1,2"); - - await api.StuInfo.GetFamStuListAsync("IC1,IC2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetFamStulist", "?invitecodes=IC1,IC2"); - - await api.StuInfo.GetStuFamMemAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuFamMem", "?SchSerID=S1"); - - await api.StuInfo.GetSchBySchIdsAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchBySchIds", "?schserids=S1,S2"); - - await api.StuInfo.GetEleSchIdsStuListAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchIdsStulist", "?schserids=S1,S2"); - - await api.StuInfo.GetEleSchBySchIdsAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchBySchIds", "?schserids=S1,S2"); - - await api.StuInfo.GetStudentsByElectiveIdAsync(5); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStudentsByElectiveId", "?electiveIds=5"); - - await api.StuInfo.GetHistoryStudentsBySchoolIdAsync(1, 2026); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getHistoryStudentsBySchoolId", "?schoolId=1&year=2026"); - - await api.StuInfo.GetHistoryClassAsync(1, 2026); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getHistoryClass", "?schoolId=1&year=2026"); - } - - [Fact] - public async Task 学生行字段_按键名原样读取() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """ - [{"Account":"2026001","UserStuSerID":11,"StuName":"小明","StuIDCard":"110101","StuSex":"男", - "StuStatus":1,"ClassID":1001,"ClassStuID":2001,"GroupID":30,"StuPhoto":null,"CardID":"C1", - "StuCurrentTel":"138","invitecode":"IC1","SchSerID":"S1","Graduated":0,"isjiedu":0}] - """); - - var rows = await SchClassTestHost.Api(handler).StuInfo.GetAllStuListAsync(); - - var row = Assert.Single(rows); - Assert.Equal("小明", row["StuName"].GetString()); - Assert.Equal(1001, row["ClassID"].GetInt32()); - Assert.Equal("S1", row["SchSerID"].GetString()); - Assert.Equal(JsonValueKind.Null, row["StuPhoto"].ValueKind); - } - - [Fact] - public async Task 历史学生行_使用小写字段名() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """ - [{"stuId":11,"stuName":"小明","classId":1001,"className":"高一(1)班", - "grade":1,"name":"第一中学","school_system_id":3,"system_grade":10}] - """); - - var rows = await SchClassTestHost.Api(handler).StuInfo.GetHistoryStudentsBySchoolIdAsync(1, 2026); - - var row = Assert.Single(rows); - Assert.Equal("小明", row["stuName"].GetString()); - Assert.Equal("高一(1)班", row["className"].GetString()); - Assert.Equal(3, row["school_system_id"].GetInt32()); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTeaInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTeaInfoClientTests.cs deleted file mode 100644 index 1b277bb..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTeaInfoClientTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// OATea(教师)端点契约与 反序列化。 -public class SchClassTeaInfoClientTests -{ - private const string Root = "/oapi/OATea/"; - - [Fact] - public async Task OATea_全部4个端点_路径与参数名对齐() - { - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.TeaInfo.GetTeaInfoAsync(77); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetTeainfo", "?uid=77"); - - await api.TeaInfo.GetArrTeaInfoAsync("77,78"); - SchClassTestHost.VerifyBody(handler, Root + "GetArrTeainfo", "77,78"); - - await api.TeaInfo.GetSchTeaInfoAsync("S1", 2); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchTeainfo", "?SchSerID=S1&schId=2"); - - await api.TeaInfo.GetSchTeaListAsync("S1", 2); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchTeaList", "?SchSerID=S1&schId=2"); - } - - [Fact] - public async Task TeaInfoVO_字段按Java原名反序列化() - { - var handler = FakeHandler.Json(HttpStatusCode.OK, """ - {"SchSerID":"S001","Teapolitical":"党员","TeaWorkStart":"2010-09-01","Account":"wang", - "SchSerIDs":"S001","role":3,"JiaoYuJu":"市教育局","TeaSex":"男","IsLeadingMiddle":0, - "TeaOriginPlace":"北京","Photo":null,"TeaIDCard":"110101","Name":"王老师","GroupID":30, - "SchName":"第一中学","TeaTeachStart":"2010-09-01","TeaSerID":"77","UserID":1001, - "TeaNation":"汉","Tel":"13800000000","TeaWorkType":"专任","TeaNo":7,"TeaBirth":"1985-01-01"} - """); - - var tea = await SchClassTestHost.Api(handler).TeaInfo.GetTeaInfoAsync(77); - - Assert.NotNull(tea); - Assert.Equal("王老师", tea!.Name); - Assert.Equal("S001", tea.SchSerId); - // 注意:查询参数 uid 传的是教师 teaId(77),而 VO.UserID 是用户中心 ID(1001),两者不是一回事 - Assert.Equal("77", tea.TeaSerId); - Assert.Equal(1001, tea.UserId); - Assert.Equal(3, tea.Role); - Assert.Equal(30, tea.GroupId); - Assert.Equal("汉", tea.TeaNation); - Assert.Null(tea.Photo); - } - - [Fact] - public async Task 教师不存在_服务端404时返回null() - { - var handler = FakeHandler.Empty(HttpStatusCode.NotFound); - - Assert.Null(await SchClassTestHost.Api(handler).TeaInfo.GetTeaInfoAsync(999999)); - } - - [Fact] - public async Task GetSchTeainfo_缺参数时服务端400抛异常() - { - var handler = FakeHandler.Text(HttpStatusCode.BadRequest, "schId 与 SchSerID 不能同时为空"); - - var ex = await Assert.ThrowsAsync( - () => SchClassTestHost.Api(handler).TeaInfo.GetSchTeaInfoAsync()); - - Assert.Equal(400, ex.StatusCode); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTestHost.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTestHost.cs deleted file mode 100644 index c52b1d7..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassTestHost.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// 测试宿主:提供共享配置、门面实例与端点契约断言辅助。 -internal static class SchClassTestHost -{ - /// 测试用配置(不设 BasePath,端点直接挂在根路径下)。 - public static SchClassOpenApiOptions Options() - => new() { BaseHost = "http://base.yunhaixingtong.com" }; - - /// 用伪造 handler 装配门面。 - public static SchClassOpenApi Api(FakeHandler handler) - => new(new HttpClient(handler), Options()); - - /// 回放一段无害响应(JSON 字面量 null),使任意端点都能安全走完解析流程。 - public static FakeHandler Ok() => FakeHandler.Json(HttpStatusCode.OK, "null"); - - /// 断言最近一次请求的 method / 路径 / query。 - public static void Verify(FakeHandler handler, HttpMethod method, string path, string query = "") - { - var request = handler.LastRequest; - Assert.NotNull(request); - Assert.Equal(method, request!.Method); - Assert.Equal(path, request.RequestUri!.AbsolutePath); - Assert.Equal(query, Uri.UnescapeDataString(request.RequestUri.Query)); - } - - /// 断言最近一次请求的方法、路径与请求体(用于 POST @RequestBody String 端点)。 - public static void VerifyBody(FakeHandler handler, string path, string? body, string contentType = "text/plain") - { - var request = handler.LastRequest; - Assert.NotNull(request); - Assert.Equal(HttpMethod.Post, request!.Method); - Assert.Equal(path, request.RequestUri!.AbsolutePath); - Assert.Equal(body, handler.LastRequestBody); - Assert.Equal(contentType, handler.LastContentType); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassUserInfoClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassUserInfoClientTests.cs deleted file mode 100644 index 565b154..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassUserInfoClientTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// UserInfo(师生统一用户)端点契约与 反序列化。 -public class SchClassUserInfoClientTests -{ - private const string Root = "/oapi/UserInfo/"; - - [Fact] - public async Task UserInfo_全部2个端点_路径与参数名对齐() - { - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.UserInfo.GetUserListAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserList", "?SchSerID=S1"); - - await api.UserInfo.GetUserIdListAsync(1001); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getUserIdList", "?userid=1001"); - } - - [Fact] - public async Task UserInfoVO_大小写不敏感匹配Jackson推导键名() - { - // Jackson 从 getter 推导的键名大小写与 Java 字段不完全一致(如 schSerID / idcard), - // Web 默认大小写不敏感,故两种形态都能命中。 - var handler = FakeHandler.Json(HttpStatusCode.OK, """ - [{"schSerID":"S001","no":1,"idcard":"110101","userID":1001,"sex":1,"usertype":1, - "serID":null,"photo":null,"tel":"13800000000","uids":"wang","groupID":30,"name":"王老师"}] - """); - - var list = await SchClassTestHost.Api(handler).UserInfo.GetUserListAsync("S001"); - - var user = Assert.Single(list); - Assert.Equal("S001", user.SchSerId); - Assert.Equal("110101", user.IdCard); - Assert.Equal(1001, user.UserId); - Assert.Equal(30, user.GroupId); - Assert.Equal("王老师", user.Name); - Assert.Null(user.SerId); - Assert.Null(user.Photo); - } - - [Fact] - public async Task GetUserList_服务端204无内容时返回空列表() - { - var handler = FakeHandler.Empty(HttpStatusCode.NoContent); - - Assert.Empty(await SchClassTestHost.Api(handler).UserInfo.GetUserListAsync("S1")); - } - - [Fact] - public async Task getUserIdList_未找到时服务端404返回空列表() - { - var handler = FakeHandler.Empty(HttpStatusCode.NotFound); - - Assert.Empty(await SchClassTestHost.Api(handler).UserInfo.GetUserIdListAsync(-1)); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassVClassClientTests.cs b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassVClassClientTests.cs deleted file mode 100644 index 1893060..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/SchClassVClassClientTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -namespace Yh.SchClass.OpenApi.Tests; - -/// -/// VSchClass(班级)端点的请求契约对拍:路径、HTTP 方法、query 参数名必须与 Java -/// VClassController 逐字一致(参数名大小写敏感)。 -/// -public class SchClassVClassClientTests -{ - private const string Root = "/oapi/VSchClass/"; - - [Fact] - public async Task VSchClass_全部17个端点_路径与参数名对齐() - { - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.VClass.GetVClassListAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassList", "?SchSerID=S1"); - - await api.VClass.GetEleVClassListAsync("S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassList", "?SchSerID=S1"); - - await api.VClass.GetSchArrVClassListAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchArrVClassList", "?SchIDArr=S1,S2"); - - await api.VClass.GetEleSchArrVClassListAsync("S1,S2"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchArrVClassList", "?SchIDArr=S1,S2"); - - await api.VClass.GetFunTimeSchClassAsync("2026-09-01", "S1"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetFunTimeSchClass", "?time=2026-09-01&SchSerID=S1"); - - await api.VClass.GetVClassFirstAsync("1001"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassFirst", "?ClassID=1001"); - - await api.VClass.GetEleVClassFirstAsync("1001"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassFirst", "?ClassID=1001"); - - await api.VClass.GetVClassIdsListAsync("1001,1002"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassIdsList", "?ClassIds=1001,1002"); - - await api.VClass.GetEleVClassIdsListAsync("1001,1002"); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassIdsList", "?ClassIds=1001,1002"); - - await api.VClass.GetGradeClassListAsync("S1", 3, 11); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetGradeClassList", "?SchSerID=S1&Schooling=3&GradeId=11"); - - await api.VClass.GetTeachClassArrAsync(77); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetTeachClassArr", "?UserTeaSerID=77"); - - await api.VClass.GetSchArrVClassListAllAsync(); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchArrVClassListAll"); - - await api.VClass.GetEleSchArrVClassListAllAsync(); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchArrVClassListAll"); - - // POST @RequestBody String:按 text/plain 原样提交,不加引号 - await api.VClass.GetSchArrVClassListPostAsync("S1,S2"); - SchClassTestHost.VerifyBody(handler, Root + "GetSchArrVClassListPost", "S1,S2"); - - await api.VClass.GetElectiveClassByIdAsync(5); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetElectiveClassById", "?electiveId=5"); - - await api.VClass.GetElectiveClassByGradeAsync(1, 2); - SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetElectiveClassByGrade", "?schoolId=1&gradeId=2"); - - await api.VClass.GetElectiveClassByTeachersAsync("7,8"); - SchClassTestHost.VerifyBody(handler, Root + "GetElectiveClassByTeachers", "7,8"); - } - - [Fact] - public async Task null参数_从query中丢弃() - { - var handler = SchClassTestHost.Ok(); - var api = SchClassTestHost.Api(handler); - - await api.VClass.GetGradeClassListAsync("S1", null, null); - - Assert.Equal("?SchSerID=S1", Uri.UnescapeDataString(handler.LastRequest!.RequestUri!.Query)); - } - - [Fact] - public async Task 完整URL_由baseHost与oapi前缀拼成() - { - var handler = SchClassTestHost.Ok(); - - await SchClassTestHost.Api(handler).VClass.GetVClassListAsync("S1"); - - Assert.Equal("http://base.yunhaixingtong.com/oapi/VSchClass/GetVClassList?SchSerID=S1", - handler.LastRequest!.RequestUri!.ToString()); - } - - [Fact] - public async Task 配置了BasePath时_路径前缀正确拼接() - { - var handler = SchClassTestHost.Ok(); - var api = new SchClassOpenApi(new HttpClient(handler), - new SchClassOpenApiOptions { BaseHost = "http://gw", BasePath = "/base.yunhaixingtong.com" }); - - await api.VClass.GetSchArrVClassListAllAsync(); - - Assert.Equal("/base.yunhaixingtong.com/oapi/VSchClass/GetSchArrVClassListAll", - handler.LastRequest!.RequestUri!.AbsolutePath); - } -} diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/Yh.SchClass.OpenApi.Tests.csproj b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/Yh.SchClass.OpenApi.Tests.csproj deleted file mode 100644 index 129aa4d..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/Yh.SchClass.OpenApi.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net8.0 - false - - Exe - false - - - - - - - - - - - - - - - - - - - diff --git a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/appsettings.json b/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/appsettings.json deleted file mode 100644 index 4a6f4bd..0000000 --- a/BasePlatformDotnet/tests/Yh.SchClass.OpenApi.Tests/appsettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "yh": { - "open": { - "Enabled": true, - "BaseHost": "http://base.yunhaixingtong.com", - "BasePath": "/userbase", - "SchClassPath": "/bschclass", - "AppKey": "100002", - "AppSecret": "2314a3b158b24a838b2b9b69fd3db118" - } - } -} diff --git a/BasePlatformDotnet/tools/gen_sign_vectors.py b/BasePlatformDotnet/tools/gen_sign_vectors.py deleted file mode 100644 index 4fa2503..0000000 --- a/BasePlatformDotnet/tools/gen_sign_vectors.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- -"""生成 sign-vectors.json 契约基线(Python hmac 独立实现,与 Java SignUtil / C# SignUtil 对拍)。 - -规则(与 Java yh-utils-core SignUtil 一致): -- 数据级: source = data + "|" + ts, HmacSHA256(secret), 输出小写 hex -- 请求级: source = METHOD + "\n" + canonicalTarget + "\n" + ts, HmacSHA256(secret), 输出 Base64 -约束: secret 长度 >= 16 -""" -import base64 -import hashlib -import hmac -import json - -def data_sign(data, ts, secret): - return hmac.new(secret.encode("utf-8"), (data + "|" + ts).encode("utf-8"), hashlib.sha256).hexdigest() - -def request_sign(method, target, ts, secret): - source = method + "\n" + target + "\n" + ts - return base64.b64encode(hmac.new(secret.encode("utf-8"), source.encode("utf-8"), hashlib.sha256).digest()).decode() - -S1 = "0123456789abcdef" # 16 位(最低边界) -S2 = "app-secret-100476-yunhai" # 常规 22 位 -S3 = "X" * 64 # 超长 - -cases = [ - {"name": "数据级-常规", "data": "hello", "ts": "1700000000000", "secret": S1}, - {"name": "数据级-空串", "data": "", "ts": "1700000000000", "secret": S1}, - {"name": "数据级-含分隔符", "data": "a|b", "ts": "1700000000000", "secret": S1}, - {"name": "数据级-中文UTF8", "data": "云海学通", "ts": "1700000000000", "secret": S2}, - {"name": "数据级-JSON体", "data": '{"id":1,"name":"zhang"}', "ts": "1699999999999", "secret": S2}, - {"name": "数据级-超长数据", "data": "x" * 4096, "ts": "1700000000000", "secret": S2}, - {"name": "数据级-超长密钥", "data": "user/login", "ts": "1700000000010", "secret": S3}, - {"name": "请求级-GET无参", "method": "GET", "target": "/userbase/oapi/user/byId", "ts": "1700000000000", "secret": S2}, - {"name": "请求级-GET带query", "method": "GET", "target": "/userbase/oapi/user/byId?id=123", "ts": "1700000000000", "secret": S2}, - {"name": "请求级-GET多参数", "method": "GET", "target": "/userbase/oapi/group/byId?groupId=9&withSub=true", "ts": "1700000000000", "secret": S1}, - {"name": "请求级-POST", "method": "POST", "target": "/userbase/oapi/identity/switch", "ts": "1700000000000", "secret": S2}, - {"name": "请求级-中文query", "method": "GET", "target": "/userbase/oapi/user/byLoginName?loginName=张三", "ts": "1700000000001", "secret": S2}, -] - -vectors = [] -for c in cases: - v = {"name": c["name"], "ts": c["ts"], "secret": c["secret"]} - if "method" in c: - v["method"] = c["method"] - v["canonicalTarget"] = c["target"] - v["expectedRequestSignBase64"] = request_sign(c["method"], c["target"], c["ts"], c["secret"]) - else: - v["data"] = c["data"] - v["expectedDataSignHex"] = data_sign(c["data"], c["ts"], c["secret"]) - vectors.append(v) - -# 请求级大小写敏感验证用例:小写 METHOD "get" 与 "GET" 签名不同(请求级验签精确比对) -vectors.append({ - "name": "请求级-小写method(与GET不同)", - "method": "get", "canonicalTarget": "/userbase/oapi/user/byId", "ts": "1700000000000", "secret": S1, - "expectedRequestSignBase64": request_sign("get", "/userbase/oapi/user/byId", "1700000000000", S1), -}) - -out = { - "algorithm": "HmacSHA256", - "dataLevel": {"canonical": "data + '|' + ts", "output": "lowercase hex"}, - "requestLevel": {"canonical": "METHOD + '\\n' + canonicalTarget + '\\n' + ts", "output": "base64"}, - "minSecretLength": 16, - "cases": vectors, -} - -with open("contract/fixtures/sign-vectors.json", "w", encoding="utf-8") as f: - json.dump(out, f, ensure_ascii=False, indent=2) -print("vectors:", len(vectors)) diff --git a/BasePlatformDotnet/yh-platform-net.sln b/BasePlatformDotnet/yh-platform-net.sln deleted file mode 100644 index f1ad5c7..0000000 --- a/BasePlatformDotnet/yh-platform-net.sln +++ /dev/null @@ -1,192 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.10.12120.281 insiders -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Sample.Web", "samples\Yh.Platform.Sample.Web\Yh.Platform.Sample.Web.csproj", "{DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Abstractions", "src\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj", "{3A3DC74D-6982-4054-83B5-18940DFA4193}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.AppAuth", "src\Yh.Platform.AppAuth\Yh.Platform.AppAuth.csproj", "{4299568D-5868-4811-A1E1-A81B1CCD3996}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Auth", "src\Yh.Platform.Auth\Yh.Platform.Auth.csproj", "{68691023-148A-4F0B-AB33-ED4A2D458B78}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.OpenApi", "src\Yh.Platform.OpenApi\Yh.Platform.OpenApi.csproj", "{0C204FCD-ECA1-4C90-BE0E-43A0043B2294}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.SchClass.OpenApi", "src\Yh.SchClass.OpenApi\Yh.SchClass.OpenApi.csproj", "{2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Abstractions.Tests", "tests\Yh.Platform.Abstractions.Tests\Yh.Platform.Abstractions.Tests.csproj", "{CCE1711C-C99B-4956-A73E-7DCEECD90CB9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.AppAuth.Tests", "tests\Yh.Platform.AppAuth.Tests\Yh.Platform.AppAuth.Tests.csproj", "{AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.Auth.Tests", "tests\Yh.Platform.Auth.Tests\Yh.Platform.Auth.Tests.csproj", "{490A1B14-ECF2-4582-8306-1C6BAB216369}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.Platform.OpenApi.Tests", "tests\Yh.Platform.OpenApi.Tests\Yh.Platform.OpenApi.Tests.csproj", "{016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yh.SchClass.OpenApi.Tests", "tests\Yh.SchClass.OpenApi.Tests\Yh.SchClass.OpenApi.Tests.csproj", "{E70492B2-109A-4E6F-AF91-443AC2956E3A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x64.ActiveCfg = Debug|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x64.Build.0 = Debug|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x86.ActiveCfg = Debug|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Debug|x86.Build.0 = Debug|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|Any CPU.Build.0 = Release|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x64.ActiveCfg = Release|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x64.Build.0 = Release|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x86.ActiveCfg = Release|Any CPU - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA}.Release|x86.Build.0 = Release|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x64.ActiveCfg = Debug|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x64.Build.0 = Debug|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x86.ActiveCfg = Debug|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Debug|x86.Build.0 = Debug|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|Any CPU.Build.0 = Release|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x64.ActiveCfg = Release|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x64.Build.0 = Release|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x86.ActiveCfg = Release|Any CPU - {3A3DC74D-6982-4054-83B5-18940DFA4193}.Release|x86.Build.0 = Release|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x64.ActiveCfg = Debug|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x64.Build.0 = Debug|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x86.ActiveCfg = Debug|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Debug|x86.Build.0 = Debug|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|Any CPU.Build.0 = Release|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x64.ActiveCfg = Release|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x64.Build.0 = Release|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x86.ActiveCfg = Release|Any CPU - {4299568D-5868-4811-A1E1-A81B1CCD3996}.Release|x86.Build.0 = Release|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|Any CPU.Build.0 = Debug|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x64.ActiveCfg = Debug|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x64.Build.0 = Debug|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x86.ActiveCfg = Debug|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Debug|x86.Build.0 = Debug|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|Any CPU.ActiveCfg = Release|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|Any CPU.Build.0 = Release|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x64.ActiveCfg = Release|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x64.Build.0 = Release|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x86.ActiveCfg = Release|Any CPU - {68691023-148A-4F0B-AB33-ED4A2D458B78}.Release|x86.Build.0 = Release|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x64.ActiveCfg = Debug|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x64.Build.0 = Debug|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x86.ActiveCfg = Debug|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Debug|x86.Build.0 = Debug|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|Any CPU.Build.0 = Release|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x64.ActiveCfg = Release|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x64.Build.0 = Release|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x86.ActiveCfg = Release|Any CPU - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294}.Release|x86.Build.0 = Release|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x64.ActiveCfg = Debug|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x64.Build.0 = Debug|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x86.ActiveCfg = Debug|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Debug|x86.Build.0 = Debug|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|Any CPU.Build.0 = Release|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x64.ActiveCfg = Release|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x64.Build.0 = Release|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x86.ActiveCfg = Release|Any CPU - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4}.Release|x86.Build.0 = Release|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x64.ActiveCfg = Debug|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x64.Build.0 = Debug|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x86.ActiveCfg = Debug|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Debug|x86.Build.0 = Debug|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|Any CPU.Build.0 = Release|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x64.ActiveCfg = Release|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x64.Build.0 = Release|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x86.ActiveCfg = Release|Any CPU - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9}.Release|x86.Build.0 = Release|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x64.ActiveCfg = Debug|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x64.Build.0 = Debug|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x86.ActiveCfg = Debug|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Debug|x86.Build.0 = Debug|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|Any CPU.Build.0 = Release|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x64.ActiveCfg = Release|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x64.Build.0 = Release|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x86.ActiveCfg = Release|Any CPU - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120}.Release|x86.Build.0 = Release|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|Any CPU.Build.0 = Debug|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x64.ActiveCfg = Debug|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x64.Build.0 = Debug|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x86.ActiveCfg = Debug|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Debug|x86.Build.0 = Debug|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|Any CPU.ActiveCfg = Release|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|Any CPU.Build.0 = Release|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x64.ActiveCfg = Release|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x64.Build.0 = Release|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x86.ActiveCfg = Release|Any CPU - {490A1B14-ECF2-4582-8306-1C6BAB216369}.Release|x86.Build.0 = Release|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x64.ActiveCfg = Debug|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x64.Build.0 = Debug|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x86.ActiveCfg = Debug|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Debug|x86.Build.0 = Debug|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|Any CPU.Build.0 = Release|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x64.ActiveCfg = Release|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x64.Build.0 = Release|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x86.ActiveCfg = Release|Any CPU - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99}.Release|x86.Build.0 = Release|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x64.ActiveCfg = Debug|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x64.Build.0 = Debug|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x86.ActiveCfg = Debug|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Debug|x86.Build.0 = Debug|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|Any CPU.Build.0 = Release|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x64.ActiveCfg = Release|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x64.Build.0 = Release|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x86.ActiveCfg = Release|Any CPU - {E70492B2-109A-4E6F-AF91-443AC2956E3A}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {DF30C55F-370F-4DAF-B3BF-37A3A7AA60BA} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {3A3DC74D-6982-4054-83B5-18940DFA4193} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {4299568D-5868-4811-A1E1-A81B1CCD3996} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {68691023-148A-4F0B-AB33-ED4A2D458B78} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {0C204FCD-ECA1-4C90-BE0E-43A0043B2294} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {2F8CD5DF-3CE6-405C-A15A-CC0CC04202C4} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {CCE1711C-C99B-4956-A73E-7DCEECD90CB9} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {AD1C35FF-A7B7-4449-ABA3-4C9FCDCBF120} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {490A1B14-ECF2-4582-8306-1C6BAB216369} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {016B8D0C-167A-4F25-ACAE-C4ABC76BCD99} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - {E70492B2-109A-4E6F-AF91-443AC2956E3A} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {8A5DD7AE-0289-46E7-B1C9-6F711893008A} - EndGlobalSection -EndGlobal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3aecd86 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# ============ 多阶段构建:.NET 5.0 ============ +# 构建阶段 +FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build +WORKDIR /src + +# 复制解决方案与全部项目文件(利用层缓存) +COPY YHDocPrintCore.sln* ./ +COPY src/*/*.csproj ./ +RUN for f in src/*/*.csproj; do \ + mkdir -p "$(dirname "$f")" && mv "$f" "$(dirname "$f")/"; \ + done \ + && dotnet restore YHDocPrintCore.sln + +# 复制源码并发布 +COPY src/ src/ +RUN dotnet publish src/YHDocPrintCore.WebApi/YHDocPrintCore.WebApi.csproj \ + -c Release -o /app/publish --no-restore + +# 运行阶段 +FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . + +# 日志目录(接口访问日志唯一输出,挂载卷便于采集) +RUN mkdir -p /app/logs +VOLUME ["/app/logs"] + +EXPOSE 80 +ENV ASPNETCORE_URLS=http://+:80 + +# 生产配置通过环境变量注入(ConnectionStrings__SqlServer / Redis:ConnectionString 等) +ENTRYPOINT ["dotnet", "YHDocPrintCore.WebApi.dll"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a80a29f --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# DocprintService + +> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。 +> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。 + +## 首次推送本地代码到本仓库 + +> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。 + +```bash +# 1. 进入本地代码目录 +cd /your/local/code/dir + +# 2. 初始化 git(目录已有 .git 也安全,git init 幂等无副作用) +git init +git branch -M main + +# 3. 先把本地代码提交成一个 commit(关键!不提交直接 merge 会丢失本地代码) +git add -A +git commit -m "init local code" + +# 4. 关联远程仓库(幂等:已存在 origin 则改地址,不报错) +git remote add origin http://gitea.yunhaixingtong.com/docprint/DocprintService.git 2>/dev/null \ + || git remote set-url origin http://gitea.yunhaixingtong.com/docprint/DocprintService.git + +# 5. 拉取远程信息(只下载,不合并) +git fetch origin + +# 6. 合并远程 main(关键步骤) +# 本地与远程是两条独立历史,需允许合并不相关历史。 +# 此步会把 projman 注入的 build.yml 与 Dockerfile 合并进你的本地工作区。 +git merge origin/main --allow-unrelated-histories -m "merge projman injected files" + +# 7. 查看状态(确认 .gitea/workflows/build.yml、docker/Dockerfile 已在本地) +git status + +# 8. 提交并推送 +git add -A +git commit -m "sync local code" 2>/dev/null || true +git push origin main +``` + +## 推送后验证 + +push 触发 CI 构建,前往 Gitea 仓库 → **Actions** 查看日志;构建成功后镜像推送到 Nexus,projman 建档待部署,在 projman 应用详情「后端发布」手动点部署。 + +## 禁止写法(会导致注入文件被删) + +`git reset origin/main` + `git add -A` 会把远程已注入、但你本地没有的文件(`build.yml`、`Dockerfile`)当作"删除"提交并推送,导致这些文件被删、CI 失败。务必用 `git merge` 而非 `git reset`,且 merge 前先提交本地代码。 + +## 关于 Dockerfile + +projman 注入的 Dockerfile 仅在仓库不存在时写入;你自备的 Dockerfile 优先,不会被覆盖。 diff --git a/YHDocPrintCore.slnx b/YHDocPrintCore.slnx new file mode 100644 index 0000000..14e6d79 --- /dev/null +++ b/YHDocPrintCore.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..2af89cc --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,36 @@ +# 由 projman 注入的 .NET 运行镜像(多阶段:sdk 容器内 restore/publish -> aspnet 运行) +# 构建上下文 = 仓库根:docker build -f docker/Dockerfile . +# 通用约定:仓库根含 .sln 或唯一 .csproj(dotnet publish 无参推断入口); +# 入口程序集按产物 *.deps.json 推断(X.deps.json <-> X.dll),无须硬编码项目名; +# 多可执行项目/特殊布局(如子目录为主项目)请自备本文件——已写则 projman 尊重不覆盖。 +# DOTNET_VERSION 默认 5.0(平台现有 .NET 应用均为 5.0);版本不合时可在仓库自带文件里 +# 用 --build-arg 覆盖(注入模板不支持外部传参,版本不合请自备)。 +ARG DOTNET_VERSION=5.0 + +# ---- 构建阶段 ---- +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build +WORKDIR /src +# 通用模板无法预知项目结构,整仓拷入一次构建(自备 Dockerfile 可按 csproj 分层命中缓存) +COPY . . +RUN dotnet publish -c Release -o /app /p:UseAppHost=false +# 推断入口:产物中 X.deps.json 对应可执行 X.dll(取第一个;多可执行项目需自备 Dockerfile) +RUN set -e; entry=$(ls /app/*.deps.json | head -1 | sed 's/\.deps\.json$//'); \ + echo "exec dotnet ${entry}.dll" > /app/entrypoint.sh + +# ---- 运行阶段 ---- +FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime +# curl 供 projman compose healthcheck(test: CMD curl -f ...)使用;tzdata 设上海时区 +RUN sed -i 's|deb.debian.org|archive.debian.org|g; s|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \ + && echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-valid-until \ + && apt-get update \ + && apt-get install -y --no-install-recommends curl tzdata \ + && ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && echo "Asia/Shanghai" > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /app/ . +ENV TZ=Asia/Shanghai +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:10003 +EXPOSE 10003 +ENTRYPOINT ["sh", "/app/entrypoint.sh"] diff --git a/docs/底层框架代码审查报告.md b/docs/底层框架代码审查报告.md new file mode 100644 index 0000000..f6faec3 --- /dev/null +++ b/docs/底层框架代码审查报告.md @@ -0,0 +1,285 @@ +# 底层框架代码审查报告 + +> 审查对象:`YHDocPrintCore`(业务侧,YhCore.\*)+ `YHBasePlatformDotnet`(底层框架,Yh.Platform.\*) +> 审查日期:2026-09-01 | 审查范围:Startup / 中间件 / 公共类 / 框架注册与验签链路 / CAS 认证 / OpenApi 客户端 + +## 结论 + +整体迁移质量良好:DI 生命周期、配置绑定、异常链路、签名协议与 Java 侧对齐度高,测试套件 98/98 通过。 +审查发现 **5 个 P1 契约缺陷**(3 个静态审查 + 2 个冒烟触发)与 **4 个 P2 健壮性问题**,均已修复。 + +--- + +## 一、P1 问题(已修复) + +### 1.1 管道顺序缺陷:/oapi 应用身份(AppKey)被下游中间件清空 + +**位置**:`YhCore.WebApi/Startup.cs`(管道顺序)+ `Yh.Platform.Auth/UserContextMiddleware.cs` + +**现象**:原管道 `AppAuth(2) → Authentication(3) → UseYhUserContext(4) → ...`。`AppAuthMiddleware` 验签通过后写入 `UserContextHolder.Set(LoginUser{AppKey})`(位置 2),而 `UserContextMiddleware` 的 `finally` 在请求结束前**无条件** `UserContextHolder.Clear()`(位置 4 更内层,其 finally 在整条链路末尾执行)。结果:`/oapi` 业务 Controller 中 `UserContextHolder.Get()` 恒为 `null`,`AppAuthMiddleware` 注释承诺的"业务可经 UserContextHolder 读取 AppKey"失效。 + +**修复**:调整管道顺序,`AppAuth` 移到 `UseYhUserContext` 之后——`Authentication → SsoProtection → UseYhUserContext → AppAuth → LegacyAdapter → Authorization`。`UserContextMiddleware` 的 finally 在 AppAuth 写入之后才执行,AppKey 可存活到业务层;且不再被误清空。 + +``` +修复前: ApiAccessLog → GlobalException → AppAuth → Auth → YhUserContext → Legacy → Sso → Authorization +修复后: ApiAccessLog → GlobalException → Auth → Sso → YhUserContext → AppAuth → Legacy → Authorization +``` + +### 1.2 业务自定义错误码被 `ErrorCodeUtil.From` 吞成 Error=1 + +**位置**:`YhCore.Common/Exceptions/BizException.cs` + +**现象**:`ErrorCodeUtil.From(int)` 对未知码 `_ => ErrorCode.Error`(Code=1)。业务 `throw new BizException("库存不足", 5001)` 经映射后 `Code` 变成 1,前端收到 `code:1` 而非 `code:5001`,错误码契约失效。 + +**修复**:未知码返回保留原始 code 的 `RawErrorCode(code, "")`(内部 record),msg 由业务消息承载。已知码(0/2/3/4/5/6/405)映射不变。 + +### 1.3 CAS 回跳开放重定向 + +**位置**:`Yh.Platform.Auth/CasAuthenticationHandler.cs` + +**现象**:`HandleAuthenticateAsync` 中 `Response.Redirect(returnUrl)` 未校验来源,攻击者可诱导已登录用户访问 `https://app/api/x?returnUrl=https://evil.com`,登录后浏览器被跳转到外站(开放重定向)。 + +**修复**:新增 `IsLocalUrl` 校验(仅放行 `/` 开头的本地相对路径,拒绝 `//host`、`/\host`、绝对地址),Challenge 拼 service 与回调回跳两处均校验;非法 returnUrl 回退 `/`。 + +### 1.4 UserContextMiddleware 构造注入 Scoped 服务,服务启动即崩溃 + +**位置**:`Yh.Platform.Auth/UserContextMiddleware.cs` + +**现象**:`IUserDetailsProvider` 注册为 **Scoped**,而中间件在**构造函数**注入它。ASP.NET Core 的 `UseMiddleware` 在管道构建(启动)时用 `ActivatorUtilities` 从**根容器**激活中间件,构造含 Scoped 依赖直接抛 `InvalidOperationException: Cannot resolve scoped service 'Yh.Platform.Auth.IUserDetailsProvider' from root provider`,**WebApi 起不来**(此前迁移冒烟未重启实例,缺陷被掩盖;本轮验证 /oapi 时首次触发)。 + +**修复**:改为 ASP.NET Core 官方模式——构造仅保留 `RequestDelegate`(Singleton),Scoped 依赖 `IUserDetailsProvider` 移到 `InvokeAsync(HttpContext, IUserDetailsProvider)` **方法注入**(每请求由 DI 从请求作用域解析)。同步更新 `AuthContextTests` 两处调用。 + +### 1.5 服务端验签密钥链不读配置,合法签名恒 401 + +**位置**:`Yh.Platform.AppAuth/SignKeyResolver.cs` + +**现象**:`GetSecretAsync` 解析链仅 `Redis(sign:app:{appKey}) → IMemoryCache → UserBase 兜底`,**不消费 `yh:app-auth` 的 AppKey/AppSecret**;且 `AppAuthOptions.TrustedApps` 对应的 `PropertyAppSecretProvider` 从未被注册(`IAppSecretProvider` 写死为 `SignKeyResolverAppSecretProvider`)。本地联调无 Redis 预置 key、无 UserBase → secret 恒为 null → **任意合法签名都 401**。此前冒烟只断言"无签名/假签名 → 401",未验证"合法签名 → 通过",契约缺陷未被发现。 + +**修复**:解析链头部增加「配置内置应用」层——`appKey == yh:app-auth:AppKey` 时直接使用配置的 `AppSecret`(覆盖"A 以自身密钥调 B 的 /oapi,B 用配置密钥验签"的自服务/联调场景);第三方应用密钥仍走 Redis → Cache → UserBase。配置密钥从此对服务端验签生效。 + +--- + +## 二、P2 问题(已修复) + +### 2.1 MemoryReplayGuard 并发下防重放失效 + +**位置**:`Yh.Platform.AppAuth/ReplayGuard.cs` + +**现象**:`TryGetValue` 检查后 `_seen[key] = ...` 写入,非原子。两个并发相同签名请求(同 appKey/timestamp/nonce)可同时通过检查同时放行,防重放被绕过。 + +**修复**:改为 `ConcurrentDictionary.TryAdd` 原子插入;未过期命中拒绝,过期条目允许覆盖。 + +### 2.2 SignCanonical 非法 URL 编码导致 500 + +**位置**:`Yh.Platform.AppAuth/SignCanonical.cs` + +**现象**:`Uri.UnescapeDataString` 对未编码的 `%`(如 `?a=100%`)抛 `UriFormatException`,`AppAuthMiddleware` 验签前调用 `BuildTarget` 未捕获 → 兜底返回 `code:6` 内部错误,而非 `401`。 + +**修复**:`UrlDecode` 捕获 `UriFormatException` 原样返回;恶意参数验签必失败 → 正确返回 401。 + +### 2.3 CasAuthenticationOptions.TicketValidateTimeoutMs 配置未生效 + +**位置**:`Yh.Platform.Auth/CasServiceTicketValidator.cs` + `AuthServiceCollectionExtensions.cs` + `CasAuthenticationHandler.cs` + +**现象**:配置项定义了(默认 5000ms)但 validator 硬编码 `5000`,改配置无效。 + +**修复(两阶段)**: +1. `CasServiceTicketValidator` 构造函数接收 `timeoutMs`,注册时从 `IOptions` 传入; +2. **补漏(9/1 SSO 故障排查发现)**:`CasAuthenticationHandler` 此前绕过 DI、自建 validator 时只传 URL 不传超时 → 配置仍不生效。改为**构造注入 DI 注册的 `CasServiceTicketValidator`**,注册源升级 `IOptionsMonitor.CurrentValue`。业务侧 `yh:sso:TicketValidateTimeoutMs` 提升到 10000(实测 CAS serviceValidate 延迟 0.5~4.5s+ 波动,5s 默认值存在偶发超时风险)。 + +### 2.4 AppAuthMiddleware 手拼 JSON 转义不完整 + +**位置**:`YhCore.WebApi/Middleware/AppAuthMiddleware.cs` + +**现象**:`"{\"code\":4,\"msg\":\"" + message.Replace("\"", "\\\"") + ...` 仅转义引号,含反斜杠/换行时产出非法 JSON(当前 message 均为内部常量,风险低)。 + +**修复**:改用 `System.Text.Json.JsonSerializer.Serialize`。 + +--- + +## 三、观察项(未改动,建议后续处理) + +| # | 位置 | 说明 | 建议 | +|---|---|---|---| +| 3.1 | `OpenApiOptions.Enabled` / `YhAuthOptions.Enabled` | 配置项定义了但注册链路未消费,`yh:open:Enabled=false` 仍会注册客户端并装配门面 | 消费方明确后接入(涉及门面装配语义,需测试覆盖) | +| 3.2 | `Startup` CORS | `AllowAnyOrigin` 宽松,注释已标注待 CAS 接入后按 `yh.sso.allow-origins` 收紧 | 上线前提供允许源清单后收紧 | +| 3.3 | 反代部署 | `BuildServiceUrl` 依赖 `request.Scheme/Host`,反代后 HTTPS 终止时 service 可能错配 | 生产配置 `AppBaseUrl` 或加 `UseForwardedHeaders` | +| 3.4 | `CasServiceTicketValidator` | 回调低频,每次 new `SocketsHttpHandler`,可接受 | 高并发可换共享 `HttpClient` | +| 3.5 | `GlobalExceptionMiddleware` | `OperationCanceledException`(客户端断开)走兜底 `code:6` | 若需区分可前置 catch | +| 3.6 | `OpenApiClientBase` | `GetRawText()` 二次反序列化,大响应内存开销 | 数据量大时改流式/直接反序列化 | +| 3.7 | `SsoProtectionMiddleware` | 直接读 `IConfiguration` 而非 `IOptions`,且保留旧节 `Yh:Sso:Enabled` 兼容 | 框架稳定后移除旧节回退逻辑 | + +--- + +## 四、验证结果 + +- ✅ 框架重编(Release):**0 警告 0 错误** +- ✅ 框架测试套件:**98/98 通过**(Abstractions 8 / AppAuth 42 / Auth 11 / OpenApi 37)——9 处修复零回归 +- ✅ 业务侧编译(Debug):**0 错误**(仅 net5.0 EOL 警告 NETSDK1138) +- ✅ 框架 Release net5.0 产物已拷回 `E:\YHXT\net\Library`(时间戳 2026-09-01 10:03) +- ✅ **/oapi 冒烟矩阵**(实例 51835 实启验证): + +| 用例 | 结果 | 断言 | +|---|---|---| +| 无签名 `GET /oapi/hello` | 401 `{code:4,"msg":"缺少签名头…"}` | 未带票据被拒 | +| 合法签名 `GET /oapi/hello`(无 Cookie) | **404**(直达路由层) | 验签通过、**匿名直访成功** | +| 合法签名 + 排序 query | 404 | 签名口径含 query 正确 | +| 假签名 | 401 `{code:4}` | 票据非法被拒 | +| 无 Cookie `GET /api/xxx` | **302** → CAS 登录页 | SSO 保护不受影响 | +| 无 Cookie `GET /mobiapi/xxx` | **302** → CAS 登录页 | SSO 保护不受影响 | + +> 备注:编译期间遇到 `MSB3021/MSB3027` 文件锁定,根因为**上次冒烟测试遗留的 YhCore.WebApi 实例进程(PID 21104,监听 51999)**仍持有 `bin` 下 DLL 句柄,`Stop-Process` 结束后编译恢复。排查路径:`netstat -ano | grep ` → 确认连 Redis → taskkill。 + +## 五、修复文件清单 + +**业务侧(YHDocPrintCore)** +- `src/YhCore.WebApi/Startup.cs` — 管道顺序调整(1.1) +- `src/YhCore.Common/Exceptions/BizException.cs` — 未知码透传(1.2) +- `src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs` — JSON 序列化(2.4) +- `src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs` — 显式放行 /oapi(渠道契约,1.1 配套) + +**框架侧(YHBasePlatformDotnet)** +- `src/Yh.Platform.Auth/CasAuthenticationHandler.cs` — 开放重定向修复(1.3) +- `src/Yh.Platform.Auth/UserContextMiddleware.cs` — Scoped 构造注入改方法注入(1.4) +- `tests/Yh.Platform.Auth.Tests/AuthContextTests.cs` — 同步新调用约定(1.4) +- `src/Yh.Platform.AppAuth/SignKeyResolver.cs` — 配置内置应用密钥层(1.5) +- `src/Yh.Platform.AppAuth/ReplayGuard.cs` — 并发防重放(2.1) +- `src/Yh.Platform.AppAuth/SignCanonical.cs` — 非法编码容错(2.2) +- `src/Yh.Platform.Auth/CasServiceTicketValidator.cs` — 超时配置接入(2.3) +- `src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs` — 超时配置接入(2.3) + +--- + +## 六、追加:BizException 退役(9/1) + +业务异常统一改为**直接使用框架 `DException`**(`Yh.Platform.Abstractions`),`BizException` 包装类已删除: + +- `src/YhCore.Common/Exceptions/BizException.cs` **已删除**;`ErrorCodeUtil.From(int)` 独立为 `ErrorCodeUtil.cs`(int → IErrorCode 映射保留,业务抛码仍可用) +- `GlobalExceptionMiddleware`:原先逻辑完全相同的 `catch (BizException)` + `catch (DException)` 合并为单个 `catch (DException)` +- `WrapBizExceptionAttribute` 更名 `WrapDExceptionAttribute`(当前无业务标注,改名安全) +- 业务写法:`throw new DException(ErrorCodeUtil.From(5001), "库存不足")`;仅提示语用 `new DException("提示语")`(默认 Custom=5) + +--- + +## 七、追加:SSO 单点登录失败排查(9/1) + +**现象**:浏览器打开 `http://localhost:51835/signin-cas?ticket=ST-7-...` 报"单点登录失败"。 + +### 根因(四层) + +1. **CAS 票据单次有效**:`ST-7` 来自更早会话(00:41 首个实例),已被消费或过期。curl 直调 `serviceValidate` 实测 CAS 返回 `Ticket 'ST-7-...' not recognized`——**任何重复校验必然失败**,这是 CAS 协议设计(防重放),非缺陷。 +2. **应用当时未运行**:51835 端口无监听进程(netstat 确认),回调 URL 无法被服务。 +3. **UX 缺陷**:修复前失效票据回调 → `AuthenticateResult.Fail` → 未认证 → 路径不在保护集 → 落 MVC 裸 **404**,用户感知为"登录失败"。 +4. **潜在超时风险**:实测 CAS serviceValidate 延迟 **0.5~4.5s+ 波动**,框架默认超时 5s 且此前配置未真正生效(见 2.3 补漏),CAS 慢时登录偶发失败。 + +### 修复(框架 2 处 + 业务 2 处,第一轮) + +- 框架 `CasAuthenticationHandler`:**构造注入 DI 注册的 `CasServiceTicketValidator`**(2.3 补漏),并补回旧版有、迁移时丢失的 **`Context.User = principal`**(SignInAsync 只写 Cookie 不改当前请求身份,缺它后续中间件误判未认证)。 +- 框架 `AuthServiceCollectionExtensions`:validator 注册源 `IOptions` → `IOptionsMonitor.CurrentValue`。 +- 业务 `SsoProtectionMiddleware`:CAS 回调路径(`yh:sso:signInCallbackPath`)纳入保护集——失效票据回调**自动 302 弹回 CAS 登录页重新登录**,不再裸 404。 +- 业务 `appsettings.json`:`yh:sso:TicketValidateTimeoutMs: 10000`(吸收 0.5~4.5s+ 延迟波动)。 + +### 验证(实例 51835 实启,第一轮) + +| 用例 | 修复前 | 修复后 | +|---|---|---| +| 旧票据 `GET /signin-cas?ticket=ST-7-...` | **404** | **302 → CAS 登录页**(自动重登) | +| `GET /` | 302 → CAS | 302 → CAS(不变) | +| 无 Cookie `GET /api/GetUserInfo` | 302 → CAS | 302 → CAS(不变) | +| 无签名 `GET /oapi/Test` | 401 | 401(不变) | +| `GET /swagger/index.html` | 200 | 200(不变) | + +⚠️ **此轮验证只覆盖了失败路径**:真实成功路径(票据校验 → Cookie → 回跳)依赖真实 CAS 账号,无法端到端验证。随后用本地 Mock CAS 服务器补测,**立刻暴露一个更深层的框架级缺陷**(见附录八)——第一轮的"302 弹回登录页"只是防循环表象,票据校验实际从未被触发。 + +> **给使用者的操作指引**:CAS 票据单次有效且时效短——登录流程必须**从受保护页面(`/` 或 `/api/xxx`)发起**,不要收藏/刷新带 `ticket` 的回调 URL(刷新必失败)。失败后回到 `/` 重新走一遍即可。 + +## 八、追加:SSO 成功路径端到端验证——DefaultScheme 缺陷(9/1 第二轮) + +**背景**:附录七修复后,用本地 Mock CAS(`Temp/mock-cas.js`,自动登录 + 单次票据语义)通过环境变量 `yh__sso__CasServerUrlPrefix` 覆盖指向 Mock,对**成功路径**做全链路验证。结果发现:票据校验请求**从未送达** CAS(Mock 日志只有 `/cas/login`,无 `/serviceValidate`),回调 2ms 即返回——校验在网络请求之前就被跳过。 + +### 根因(框架级,P0) + +`AuthServiceCollectionExtensions.AddAuthentication` 中 **`DefaultAuthenticateScheme = CookieScheme`**。`UseAuthentication` 每请求只调用 `AuthenticateAsync(默认 scheme)` 恢复 Cookie 登录态;**CAS 票据校验(`CasAuthenticationHandler.HandleAuthenticateAsync` 的 ticket 分支)在回调请求上没有任何触发点**: + +``` +回调 /signin-cas?ticket=ST-… 进入 + └─ UseAuthentication → 只查 Cookie(无)→ 未认证 + └─ SsoProtectionMiddleware:带 ticket + 未认证 → 直接 401 防循环(附录七引入) + └─ CasAuthenticationHandler.HandleAuthenticateAsync 从未执行 → 票据从不校验 → 登录永远失败 +``` + +即:第一轮"防循环"把**合法票据路径一并拦截**,与 `DefaultScheme` 错误叠加,导致任何 CAS 登录都不可能成功(Mock 端到端立即暴露,真实账号难以察觉)。 + +### 修复(框架 1 处 + 业务 1 处) + +- **框架 `AuthServiceCollectionExtensions`**:`DefaultAuthenticateScheme` 改为 `YhAuthDefaults.CasScheme`。`CasAuthenticationHandler.HandleAuthenticateAsync` 第一步就是 `AuthenticateAsync(CookieScheme)` 恢复登录态,因此语义等价"先 Cookie 恢复、后 ticket 校验",每请求行为不变、仅补上票据校验触发点。 +- **业务 `SsoProtectionMiddleware`**:回调路径**已认证且已 302**(handler 验票成功 `Response.Redirect`)时**短路管道**——handler 返回 `NoResult` 不短路,继续走 MVC 端点匹配会因 `/signin-cas` 无 action 返回 **404 覆盖 302**(实测现象),短路保住回跳响应。 + +### 验证(Mock CAS 端到端,实例 51835 实启) + +| # | 场景 | 结果 | +|---|---|---| +| A | `/` → Mock 登录 → 回调验票 → Cookie → 回跳 `/` | **302 → 200**,Cookie 落盘 ✓ | +| B | 带 Cookie `GET /api/GetUserInfo` | **200**(登录态生效)✓ | +| C | 复用已消费票据 `GET /signin-cas?ticket=同前` | **401 防循环页** ✓(Mock 第二次校验返回 INVALID_TICKET) | +| D | 无 Cookie `GET /api/GetUserInfo` | 302 → CAS ✓ | +| E | 无签名 `GET /oapi/Test` | 401(SSO 不覆盖)✓ | + +Mock 日志互证:`02:35:09 /cas/serviceValidate?ticket=ST-MOCK-…` 送达并成功、票据被消费;复用同一票据时 Mock 返回 `INVALID_TICKET` → 应用 401。 + +框架重编 0 错误、测试 **98/98 通过**、业务编译 0 错误;`Yh.Platform.Auth.dll` 已更新至 `E:\YHXT\net\Library`。 + +> **教训**:SSO 类链路缺陷必须覆盖**成功路径**验证——失败路径的"防循环 401/302"表象可能掩盖"校验从未发生"的框架级问题。无真实账号时用 Mock CAS 全链路补测是必要手段。 + +--- + +## 附录九:登录态 Cookie 会话改造(9/1 需求:关闭失效 / 1 分钟过期 / 32 位) + +**需求**:① 关闭浏览器 Cookie 失效;② 过期时间 1 分钟;③ Cookie 长度为 32 位。 + +**实现(框架 `Yh.Platform.Auth` 3 处 + 业务 1 处)**: + +| 文件 | 变更 | +|---|---| +| `SessionTicketDataFormat.cs`(新增) | 自定义 `ISecureDataFormat`:`Protect` 把完整票据存服务端内存、返回 **32 位 key**(Guid N 格式);`Unprotect` 查表并校验 `ExpiresUtc`(过期返回 null 并清理)。key 为 128 位随机熵,不可枚举 | +| `AuthServiceCollectionExtensions.cs` | Cookie 注册:`options.TicketDataFormat = new SessionTicketDataFormat()`;`ExpireTimeSpan` 由 `yh:auth:CookieExpireMinutes` 决定(默认 1 分钟);`SlidingExpiration = false`(固定过期不滑动) | +| `CasAuthenticationHandler.cs` | `IsPersistent: true → false`(会话级 Cookie,关闭浏览器失效;旧版 `_legacy` 本为 false,系迁移回归,已修正) | +| 业务 `appsettings.json` | `CookieExpireHours: 8` → `CookieExpireMinutes: 1`(注:文件曾被外部覆盖一次,CookieName 现为用户保存的 `.yh.auth`) | + +**关键技术发现**:**.NET 5 的 `CookieAuthenticationOptions.SessionStore` 并不能缩短 Cookie**——官方源码(v5.0.17 `CookieAuthenticationHandler.HandleSignInAsync`)显示即便配置了 SessionStore,cookie 值仍是 `TicketDataFormat.Protect(精简票据)` 的加密长串(`CfDJ8…`),只是把完整用户票据移到了服务端。**要实现"Cookie 32 位",必须自定义 `TicketDataFormat`(Protect 落库返回 key)**,而非依赖 SessionStore。首次尝试(SessionTicketStore + SessionStore)经最小复现程序(独立 net5.0 Web 项目直连框架 DLL)证实无效后放弃。 + +**验证(Mock CAS 端到端 + 真实配置冒烟)**: + +| 需求 | 验证结果 | +|---|---| +| ③ Cookie 32 位 | `Set-Cookie: .yh.auth=3a3f40993606438e86d8e756b99a6f07` = **32 字符** ✓ | +| ① 关闭浏览器失效 | Set-Cookie 无 `Max-Age`/`Expires`(会话 Cookie)+ `IsPersistent=false` ✓ | +| ② 1 分钟过期 | 登录后立即带 Cookie 访问 `/` → 200;**65 秒后** → **302 弹回 CAS 登录页**(服务端票据过期)✓ | + +框架双目标(net5.0/net8.0)编译 0 错误 0 警告、测试 **98/98 通过**、业务编译 0 错误;DLL 已更新至 `E:/YHXT/net/Library`。 + +**注意**:票据存进程内存(ConcurrentDictionary)——应用重启后所有登录态失效(用户需重新登录);多实例部署时各实例票据互不可见(如需跨实例共享可换 Redis 实现)。 + +### 附录九补充:退出即吊销——显式登出服务端票据立即失效(9/1 第二轮) + +**背景**:附录九初版只覆盖"登录态自然过期/关浏览器"。审查发现缺口:`SignOutAsync`(业务 `GET /api/Logout`)只删 Cookie,**服务端内存票据残留到 ExpireTimeSpan 过期**(默认 1 分钟)——期间若旧 Cookie key 被恢复/复用,登录态仍有效。 + +**修复(框架 `Yh.Platform.Auth` 2 处)**: + +| 文件 | 变更 | +|---|---| +| `SessionTicketDataFormat.cs` | 新增 `Revoke(string? key)`:删除指定票据(显式退出立即失效);新增分钟级摊还清理 `CleanupExpiredIfDue()`(Unprotect/Revoke 路径触发,Interlocked 门闩保证并发下每分钟最多一次全量扫描,防内存膨胀) | +| `AuthServiceCollectionExtensions.cs` | `SessionTicketDataFormat` 提升为 DI 单例(`services.AddSingleton`,TicketDataFormat 与事件共享同一票据表);Cookie 注册挂 `options.Events.OnSigningOut`:SignOutAsync 时从请求 Cookie 读 32 位 key → `Revoke(key)` → 服务端票据**立即删除** | + +**语义闭环**:① 关闭浏览器 → 会话 Cookie 删除(服务端无法感知浏览器关闭,1 分钟过期兜底);② 显式退出(/api/Logout)→ Cookie 删除 **+** 服务端票据立即吊销,不等过期。 + +**验证(Mock CAS 端到端,实例 51835 实启)**: + +| 步骤 | 期望 | 实测 | +|---|---|---| +| 登录流 → Cookie(32 位 key) | 200 + Cookie 落盘 | ✓ | +| 带 Cookie 访问 `/api/GetUserInfo` | 200 | ✓ | +| `GET /api/Logout`(带 Cookie) | 200 | ✓ | +| **Logout 后旧 key 手动放回访问** | **302 弹回 CAS**(服务端已吊销;旧行为下会 200) | ✓ **302** | + +框架测试 **106/106 通过**(Auth 19 = 原 11 + 新增 `SessionTicketDataFormatTests` 8 项:32 位 key 往返 / 未知 key null / Revoke 立即失效 / 过期 null / 未过期正常 / Revoke 不影响其他票据等),双目标编译 0 错误 0 警告;`Yh.Platform.Auth.dll`(Release net5.0)已更新至 `E:/YHXT/net/Library`。 diff --git a/docs/开发规范.md b/docs/开发规范.md new file mode 100644 index 0000000..cb2f3bd --- /dev/null +++ b/docs/开发规范.md @@ -0,0 +1,86 @@ +# YhCore 开发规范 + +> .NET 5.0 后端框架(参照 Java 版 bschclassmanage 迁移),仅框架无业务逻辑。 + +## 1. 解决方案结构 + +``` +YhCore.sln +├─ src/ +│ ├─ YhCore.WebApi # 启动项:Controllers / Middleware / Filters / Attributes / Auth +│ ├─ YhCore.Service # 业务服务层:Interfaces / Impl(不含业务时用 *Stub 占位) +│ ├─ YhCore.Repository # 数据层:YHDbContext / IRepository / Specification / DapperChannel +│ ├─ YhCore.Entity # 数据库实体(旧 EF6 POCO 迁移而来,无导航属性) +│ ├─ YhCore.Model # DTO / VO / Boa 平台占位模型 +│ ├─ YhCore.Common # 工具:ApiRoutes / UserContext / RedisUtil / PageUtil / ApiResult +│ └─ YhCore.Tasks # 调度:QuartzHostedService / Jobs +└─ lib / docker / docs / sqlscript +``` + +**依赖方向(单向,禁止反向引用)**:WebApi → Service → Repository → Entity;WebApi → Model ← Service;Common 不依赖任何上层。 + +**底层框架(Yh.Platform.\*)**:CAS 认证、用户上下文(BoaUserContext)、应用验签(AppAuthVerifier)、用户中心(OpenApi)均由底层框架提供: +- 引用 DLL:`Yh.Platform.Abstractions` / `Yh.Platform.AppAuth` / `Yh.Platform.Auth` / `Yh.Platform.OpenApi`(lib 目录,源码在 `E:\YHXT\net\YHBasePlatformDotnet`) +- `Startup` 用 `services.AddYhPlatform(Configuration)` 聚合注册(= AddYhAppAuth + AddYhAuth[含 AddYhOpenApi]),管道用 `app.UseYhUserContext()` 注入用户上下文 +- 旧自研 CAS/验签/用户中心实现已退役到 `src/_legacy/`(不参与编译,仅留档备份);业务代码取当前用户用 `BoaUserContext.Get()` + +**`/oapi` 开放渠道契约**:应用间调用渠道,**无需用户登录**,凭签名票据直接访问: +- 票据 = 请求头 `X-App-Key` / `X-Timestamp` / `X-Signature`(`signature = Base64(HmacSHA256(METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp, secret))`,详见框架仓库 `YHBasePlatformDotnet\BasePlatformDotnet\contract\sign-protocol.md`) +- 未带票据/票据非法 → `401 {code:4,msg:"缺少签名头…"/"签名校验失败…"}`;验签通过 → 放行到业务(无用户上下文,业务只可读 `UserContextHolder.Get().AppKey` 应用身份) +- SSO 登录保护**永不拦截** `/oapi`(SsoProtectionMiddleware 显式放行,即使 `yh:sso:urlPattern` 误配也不影响);服务端验签密钥解析链:配置内置应用(`yh:app-auth` AppKey/AppSecret)→ Redis `sign:app:{appKey}` → 本地缓存 → UserBase 兜底 + +**`/oapi` 冒烟验证方法**:本地联调/自测票据可用 `scripts/oapi-sign.js` 生成(对齐签名协议): +- 生成票据:`node scripts/oapi-sign.js GET "/oapi/hello?a=2&b=1"` → 输出 `appKey/ts/signature` 三头 +- 直接出 curl:`node scripts/oapi-sign.js --curl POST "/oapi/xxx"` → 复制替换 `PORT` 即可 +- 密钥默认取开发联调密钥(`yh:app-auth`),生产环境用 `APP_KEY` / `APP_SECRET` 环境变量覆盖,**禁止提交真实密钥到仓库** +- 断言基线:无票据 → 401 `{code:4}`;合法票据 → 直达业务(404/业务结果);假票据 → 401;`/api`、`/mobiapi` 无 Cookie → 302 CAS(SSO 保护不受影响) + +## 2. 强制规范 + +| 约定 | 说明 | +|---|---| +| **接口路径** | 一律用 `[Route(ApiRoutes.ApiPath + "/xxx")]`,禁止硬编码 `"api/..."`;渠道前缀见 `ApiRoutes`(api/mobiapi/oapi) | +| **接口返回结构** | 与 Java 版保持一致(裸返回),**全局 `GlobalWrapResultFilter` 自动统一包装**(ApiResult{code,msg,data});需要裸返回用 `[NoWrapResult]` 显式排除;异常由 `GlobalExceptionMiddleware` 统一转失败响应 | +| **业务异常** | Service 层抛 `new DException(ErrorCodeUtil.From(code), "提示语")`(框架 `Yh.Platform.Abstractions`);仅提示语可用便捷构造 `new DException("提示语")`(默认 Custom=5);中间件自动转统一失败响应 | +| **日志** | **只允许接口访问日志**(ApiAccessLogMiddleware 自动记录)。业务代码禁止调用 `ILogger` 写日志;异常摘要由接口日志统一带出 | +| **缓存 Key** | 必须用 `RedisConstants.KeyOf(...)` 生成;过期时间用 `RedisConstants.CACHE_EXPIRE_*` | +| **分页** | 页码从 0 开始(与 Spring Pageable 对齐);数据库分页用 `IRepository.PageAsync`,内存分页用 `PageUtil` | +| **动态条件** | 用 `Specification.True().And(...).AndIf(cond, ...)` 拼装 | +| **操作日志** | **接口级审计日志,全局自动**:`SysOperationLogFilter`(Startup 全局注册)每次 Controller 请求后落库 `sys_operation_log`(对齐 Java `SysOperationLog`,含 URI/参数/响应 GZIP/耗时/状态码/异常),无需任何标注;级别按 HTTP 方法推导(GET=QUERY/POST=ADD/PUT=EDIT/DELETE=DELETE);`LogController` 已显式排除防递归 | +| **实体主键** | 实体无 `[Key]` 时按约定推断(Id → SerID);新增表实体建议显式标注 | +| **数据库** | 默认 SQL Server 主库;`DatabaseProvider=MySql` 切换;**缺表自动补建**(`Database:AutoCreateMissingTables`,默认 true:启动时对比 EF 模型与数据库,缺失的表按模型自动 CREATE,幂等;已存在表的结构变更仍走 sqlscript 脚本) | +| **数据库写操作** | 注意 `RepositoryBase` 的 Add/Update 需调用 `SaveChangesAsync()` 提交(UnitOfWork 模式) | + +## 3. 常用命令 + +```bash +dotnet build # 编译 +dotnet run --project src/YhCore.WebApi # 本地运行(Swagger: /swagger/index.html) +dotnet watch run --project src/YhCore.WebApi +``` + +## 4. 环境配置(appsettings.json 或环境变量覆盖) + +| 配置 | 默认值 | 说明 | +|---|---|---| +| `DatabaseProvider` | `SqlServer` | `SqlServer` / `MySql` | +| `Database:AutoCreateMissingTables` | `true` | 启动时自动补建 EF 模型中有而数据库缺失的表(仅 SQL Server;幂等;已存在表不触碰,结构变更仍走 sqlscript) | +| `ConnectionStrings__SqlServer` | localhost/EduCloudDocPrint | 主库连接串(生产用环境变量,勿提交明文密码) | +| `ConnectionStrings__Mysql` | localhost:3306 | MySQL 备用连接串 | +| `Redis:ConnectionString` | localhost:6379,defaultDatabase=0 | Redis 连接串 | +| `yh:auth:CookieName` | `.yh.auth` | 认证 Cookie 名 | +| `yh:auth:CookieExpireMinutes` | `1` | 登录态固定过期(分钟),不滑动续期;票据存服务端内存(SessionTicketDataFormat),Cookie 只写 32 位 key;会话级 Cookie(IsPersistent=false),关闭浏览器即失效;**显式退出(/api/Logout)时服务端票据立即吊销**(OnSigningOut 事件,不等过期兜底) | +| `yh:sso:Enabled` | `true` | CAS SSO 开关(拦截 urlPattern / mobiApiPattern) | +| `yh:sso:CasServerUrlPrefix` | - | CAS 服务端地址前缀(Challenge 跳转用) | +| `yh:app-auth:Enabled` | `true` | /oapi 应用验签开关(X-App-Key / X-Timestamp / X-Signature) | +| `yh:open:AppKey` / `yh:open:AppSecret` | 100002 | OpenApi 调用方凭证 | + +## 5. 待办(TODO) + +- [x] CAS 认证:已由 `Yh.Platform.Auth`(CasScheme)接管,旧本地实现退役到 `_legacy/Auth` +- [x] 应用签名:`AppAuthMiddleware` 已接入 `AppAuthVerifier` + `SignCanonical` 真实验签 +- [x] 用户中心:`IUserCenterService` 已替换为 `Yh.Platform.OpenApi`(`BoaUserContext.Get()` 取上下文) +- [ ] `ILogService` 真实查询实现(替换 LogServiceStub) +- [x] Quartz Job 按业务启用:`CleanupLogJob` 已实现(每日凌晨 3:30 删除 30 天前 `sys_operation_log`),`QuartzHostedService.InitJobs` 已注册 `cleanup-log-job`(cron `0 30 3 * * ?`) +- [ ] 审计日志增强(`SysOperationLogFilter` 目前按 HTTP 方法推导级别,`Module/Operation` 两列恒空,可接入业务字典映射;日志查询走 `LogController` 但 `LogServiceStub` 尚未实现真实分页查询) +- [ ] .NET 5 已 EOL:框架代码避免使用 5.0 专属 API,便于日后升 6/8 diff --git a/scripts/oapi-sign.js b/scripts/oapi-sign.js new file mode 100644 index 0000000..d24665f --- /dev/null +++ b/scripts/oapi-sign.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// /oapi 签名票据生成器(对齐 yh-platform-net SignUtil.SignRequest / SignCanonical 协议) +// +// 协议:signature = Base64(HmacSHA256(METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp, secret)) +// - query 按 k=v 整体字典序排序后拼接参与签名 +// - timestamp 为毫秒级 Unix 时间戳(服务端校验 5 分钟窗口) +// +// 用法: +// node scripts/oapi-sign.js [METHOD] [path?query] +// node scripts/oapi-sign.js --curl POST /oapi/print/submit # 输出可直接复制的 curl 命令 +// +// 密钥来源(优先级):环境变量 APP_KEY / APP_SECRET → 文件默认值(开发联调密钥) +// 生产环境务必通过环境变量注入,勿提交真实密钥。 +const crypto = require('crypto'); + +const APP_KEY = process.env.APP_KEY || '100002'; +const APP_SECRET = process.env.APP_SECRET || '2314a3b158b24a838b2b9b69fd3db118'; + +// 排序 query(对齐 SignCanonical.SortRawQuery:k=v 整体排序,空值保留 = 号) +function sortQuery(raw) { + if (!raw) return ''; + return raw.split('&').filter(Boolean).map(p => { + const i = p.indexOf('='); + return (i < 0 ? p : p.slice(0, i)) + '=' + (i < 0 ? '' : p.slice(i + 1)); + }).sort().join('&'); +} + +function sign(method, pathWithQuery, ts) { + const qi = pathWithQuery.indexOf('?'); + const target = qi >= 0 + ? pathWithQuery.slice(0, qi) + '?' + sortQuery(pathWithQuery.slice(qi + 1)) + : pathWithQuery; + const source = method.toUpperCase() + '\n' + target + '\n' + ts; + const sig = crypto.createHmac('sha256', APP_SECRET).update(source, 'utf8').digest('base64'); + return { ts, target, signature: sig }; +} + +const args = process.argv.slice(2); +let method = 'GET'; +let pathWithQuery = '/oapi/hello'; +let curlMode = false; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--curl') { curlMode = true; continue; } + if (!/^-/.test(a)) { + if (method === 'GET' && !/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$/i.test(a) && !a.startsWith('/')) { + // 第一个非选项参数若不像 METHOD 也不像路径,则视为路径 + pathWithQuery = a; + } else if (a.startsWith('/')) { + pathWithQuery = a; + } else { + method = a; + } + } +} + +const { ts, target, signature } = sign(method, pathWithQuery, Date.now().toString()); + +if (curlMode) { + const u = target.replace(/^\//, ''); + console.log(`curl -i -X ${method.toUpperCase()} "http://localhost:PORT/${u}" \\`); + console.log(` -H "X-App-Key: ${APP_KEY}" \\`); + console.log(` -H "X-Timestamp: ${ts}" \\`); + console.log(` -H "X-Signature: ${signature}"`); +} else { + console.log(JSON.stringify({ appKey: APP_KEY, method, target, ts, signature, curl: [ + '-H', `X-App-Key: ${APP_KEY}`, + '-H', `X-Timestamp: ${ts}`, + '-H', `X-Signature: ${signature}` + ].join(' ') }, null, 2)); +} diff --git a/sqlscript/create-a-datalog-table.sql b/sqlscript/create-a-datalog-table.sql new file mode 100644 index 0000000..f153fa8 --- /dev/null +++ b/sqlscript/create-a-datalog-table.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- 数据变更日志表(A_DataLog) +-- 对应实体:YhCore.Entity.A_DataLog(旧 EF6 EDMX 生成 POCO,主键约定 SerID) +-- 用途:数据变更审计预留表(当前无业务代码使用,仅 DbContext 注册) +-- 数据库:SQL Server(EduCloudDocPrint) +-- 风格:对齐同库 Java 遗留表(SerID 主键自增、varchar 字符串、可空) +-- ============================================================ + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'A_DataLog') +BEGIN + CREATE TABLE [dbo].[A_DataLog] ( + [SerID] INT IDENTITY(1,1) NOT NULL CONSTRAINT [PK_A_DataLog] PRIMARY KEY, + [SchSerID] VARCHAR(200) NULL, + [ModuleName] VARCHAR(50) NULL, + [TableName] VARCHAR(50) NULL, + [OldSerID] INT NULL, + [NowSerID] INT NULL, + [RId] INT NULL + ); + + PRINT 'A_DataLog table created.'; +END +ELSE +BEGIN + PRINT 'A_DataLog table already exists, skip.'; +END diff --git a/sqlscript/create-sys-operation-log-table.sql b/sqlscript/create-sys-operation-log-table.sql new file mode 100644 index 0000000..bb835b3 --- /dev/null +++ b/sqlscript/create-sys-operation-log-table.sql @@ -0,0 +1,55 @@ +-- ============================================================ +-- 系统审计日志表(sys_operation_log) +-- 对应实体:YhCore.Entity.SysOperationLog(EF Core,OnModelCreating ToTable 映射) +-- 对应 Java:bschclassmanage 的 entity.SysOperationLog / sqlscript/sys_operation_log.sql +-- 用途:SysOperationLogFilter 全量记录 HTTP 接口日志 / CleanupLogJob 30 天清理 +-- 说明:本表取代旧业务操作日志表 OperationLogRecord(旧表无数据,直接删除) +-- 数据库:SQL Server(EduCloudDocPrint) +-- ============================================================ + +-- 1) 清理旧业务操作日志表(OperationLogRecord,已被 sys_operation_log 取代) +IF OBJECT_ID(N'[dbo].[OperationLogRecord]', N'U') IS NOT NULL +BEGIN + DROP TABLE [dbo].[OperationLogRecord]; + PRINT 'Legacy OperationLogRecord table dropped.'; +END + +-- 2) 创建审计日志表(幂等) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'sys_operation_log') +BEGIN + CREATE TABLE [dbo].[sys_operation_log] ( + [Id] BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT [PK_sys_operation_log] PRIMARY KEY, + [LogLevel] TINYINT NOT NULL, -- 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) + [OperationType] NVARCHAR(16) NOT NULL, -- DELETE/CREATE/UPDATE/QUERY + [UserId] INT NULL, -- 操作人 ID + [UserName] NVARCHAR(64) NULL, -- 操作人姓名 + [SchId] INT NULL, -- 学校 ID(多校隔离) + [RequestIp] NVARCHAR(64) NOT NULL, -- 客户端 IP + [RequestUri] NVARCHAR(512) NOT NULL, -- 请求 URI + [RequestMethod] NVARCHAR(16) NOT NULL, -- HTTP 方法 + [ClassMethod] NVARCHAR(512) NOT NULL, -- 类名.方法名 + [RequestParams] NVARCHAR(MAX) NULL, -- 请求参数(JSON,截断) + [ResponseData] VARBINARY(MAX) NULL, -- GZIP 压缩后的响应 JSON + [StatusCode] INT NOT NULL, -- 业务状态码:0 成功 / 非 0 失败 + [CostTime] BIGINT NOT NULL, -- 耗时(ms) + [UserAgent] NVARCHAR(512) NULL, + [ErrorMsg] NVARCHAR(MAX) NULL, -- 异常信息(截断 8000) + [RetryCount] INT NULL DEFAULT 0, -- 重试次数(对齐 Java 双轨字段,.NET 暂不写入) + [LastRetryStatus] INT NULL, -- 上次重试状态:1 成功 / 0 失败 + [LastRetryTime] DATETIME NULL, -- 上次重试时间 + [CreateTime] DATETIME NOT NULL -- 记录时间 + ); + + -- 查询/清理索引(对齐 Java idx_sol_*) + CREATE INDEX [IX_sol_CreateTime] ON [dbo].[sys_operation_log] ([CreateTime]); + CREATE INDEX [IX_sol_UserId_Time] ON [dbo].[sys_operation_log] ([UserId], [CreateTime]); + CREATE INDEX [IX_sol_SchId_Time] ON [dbo].[sys_operation_log] ([SchId], [CreateTime]); + CREATE INDEX [IX_sol_Uri_Time] ON [dbo].[sys_operation_log] ([RequestUri], [CreateTime]); + CREATE INDEX [IX_sol_Level_Time] ON [dbo].[sys_operation_log] ([LogLevel], [CreateTime]); + + PRINT 'sys_operation_log table created.'; +END +ELSE +BEGIN + PRINT 'sys_operation_log table already exists, skip.'; +END diff --git a/src/YhCore.Common/ApiRoutes.cs b/src/YhCore.Common/ApiRoutes.cs new file mode 100644 index 0000000..b66ddbd --- /dev/null +++ b/src/YhCore.Common/ApiRoutes.cs @@ -0,0 +1,20 @@ +namespace YhCore.Common +{ + /// + /// 渠道路由前缀常量(对应 Java 版 config.Config 枚举的 + /// API_PATH / MOBILEAPI_PATH / OAPI)。 + /// Controller 一律通过常量引用,禁止硬编码 "api/..." 字符串: + /// [Route(ApiRoutes.ApiPath + "/log")] + /// + public static class ApiRoutes + { + /// PC 端接口前缀(对应 Config.API_PATH) + public const string ApiPath = "api"; + + /// 移动端接口前缀(对应 Config.MOBILEAPI_PATH) + public const string MobiApiPath = "mobiapi"; + + /// 开放接口前缀(对应 Config.OAPI) + public const string OApiPath = "oapi"; + } +} diff --git a/src/YhCore.Common/Cache/RedisConstants.cs b/src/YhCore.Common/Cache/RedisConstants.cs new file mode 100644 index 0000000..da71d3a --- /dev/null +++ b/src/YhCore.Common/Cache/RedisConstants.cs @@ -0,0 +1,42 @@ +namespace YhCore.Common.Cache +{ + /// + /// Redis Key 规范常量(对应 Java 版 utils.RedisConstants)。 + /// 所有业务 Key 必须基于 通过 生成,禁止散落硬编码。 + /// + public static class RedisConstants + { + /// 本项目缓存前缀(Java 班级管理为 bsch.classmanage,本项目为 yh.docprint) + public const string CachePrefix = "yh.docprint"; + + // ---- 常用过期时间(秒),对应 Java CACHE_EXPIRE_* ---- + public const long CACHE_EXPIRE_1M = 60; + public const long CACHE_EXPIRE_5M = 5 * 60; + public const long CACHE_EXPIRE_10M = 10 * 60; + public const long CACHE_EXPIRE_30M = 3 * CACHE_EXPIRE_10M; + public const long CACHE_EXPIRE_1H = 60 * 60; + public const long CACHE_EXPIRE_1D = 24 * CACHE_EXPIRE_1H; + public const long CACHE_EXPIRE_7D = 7 * CACHE_EXPIRE_1D; + public const long CACHE_EXPIRE_30D = 30 * CACHE_EXPIRE_1D; + + /// 分布式锁 Key 前缀 + public const string LockPrefix = CachePrefix + ".lock"; + + /// + /// 生成规范 Key:CachePrefix + "." + 各段拼接。 + /// 例:KeyOf("sch", "list") → "yh.docprint.sch.list" + /// + public static string KeyOf(params string[] parts) + { + return CachePrefix + "." + string.Join(".", parts); + } + + /// 生成分布式锁 Key + public static string LockKeyOf(string name) + { + return LockPrefix + "." + name; + } + + // TODO:业务缓存 Key 按模块在此追加(参照 Java 版 SYSTEM_* / SCHOOL_* 的命名风格) + } +} diff --git a/src/YhCore.Common/Cache/RedisServiceCollectionExtensions.cs b/src/YhCore.Common/Cache/RedisServiceCollectionExtensions.cs new file mode 100644 index 0000000..84a8426 --- /dev/null +++ b/src/YhCore.Common/Cache/RedisServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace YhCore.Common.Cache +{ + /// + /// Redis 缓存注册扩展(对应 Java RedisConfig 的 Bean 装配)。 + /// 连接串取配置 "Redis:ConnectionString",如 "localhost:6379,defaultDatabase=0"。 + /// + public static class RedisServiceCollectionExtensions + { + public static IServiceCollection AddRedisCache(this IServiceCollection services, IConfiguration configuration) + { + var connStr = configuration["Redis:ConnectionString"]; + if (string.IsNullOrWhiteSpace(connStr)) + { + connStr = "localhost:6379,defaultDatabase=0"; + } + + // 连接多路复用器全局单例(对应 Java 单例 RedisTemplate) + services.AddSingleton( + ConnectionMultiplexer.Connect(connStr)); + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/YhCore.Common/Cache/RedisUtil.cs b/src/YhCore.Common/Cache/RedisUtil.cs new file mode 100644 index 0000000..91efade --- /dev/null +++ b/src/YhCore.Common/Cache/RedisUtil.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using StackExchange.Redis; + +namespace YhCore.Common.Cache +{ + /// + /// Redis 操作封装(对应 Java 版 utils.RedisUtil)。 + /// 基于 StackExchange.Redis,对象值统一 JSON 序列化(Newtonsoft,与全局 JSON 方案一致); + /// Incr/Decr 计数类 Key 存裸数字,不走 JSON。 + /// 注册为单例(内部复用 IConnectionMultiplexer,线程安全)。 + /// + public class RedisUtil + { + private readonly IConnectionMultiplexer _connection; + + public RedisUtil(IConnectionMultiplexer connection) + { + _connection = connection; + } + + private IDatabase Db => _connection.GetDatabase(); + + // ==================== Key / 过期 ==================== + + /// 设置过期时间(秒),对应 expire(key, time) + public bool Expire(string key, long seconds) + { + return Db.KeyExpire(key, TimeSpan.FromSeconds(seconds)); + } + + /// 获取剩余过期时间(秒;0=已过期或无此 key;-1=无过期时间),对应 getExpire + public long GetExpire(string key) + { + var ttl = Db.KeyTimeToLive(key); + return ttl.HasValue ? (long)ttl.Value.TotalSeconds : -1; + } + + /// 判断 key 是否存在,对应 hasKey + public bool HasKey(string key) + { + return Db.KeyExists(key); + } + + /// 删除一个或多个 key,对应 del(key...) + public void Del(params string[] keys) + { + Db.KeyDelete(keys.Select(k => (RedisKey)k).ToArray()); + } + + /// 按模式扫描 key(对应 keys(pattern);生产慎用通配全库) + public List Keys(string pattern) + { + var server = _connection.GetServer(_connection.GetEndPoints().First()); + return server.Keys(pattern: pattern).Select(k => (string)k).ToList(); + } + + // ==================== String ==================== + + /// 读取并反序列化为指定类型;不存在或反序列化失败返回 default + public T Get(string key) + { + var value = Db.StringGet(key); + if (value.IsNullOrEmpty) + { + return default; + } + + return JsonConvert.DeserializeObject(value); + } + + /// 读取并反序列化为 List<T>,对应 getList + public List GetList(string key) + { + return Get>(key); + } + + /// 写入(不过期),对应 set(key, value) + public bool Set(string key, object value) + { + return Db.StringSet(key, Serialize(value)); + } + + /// 写入并设置过期时间(秒),对应 set(key, value, time) + public bool Set(string key, object value, long seconds) + { + return Db.StringSet(key, Serialize(value), TimeSpan.FromSeconds(seconds)); + } + + /// 自增(delta 可为负),对应 incr/decr + public long Incr(string key, long delta = 1) + { + return delta >= 0 ? Db.StringIncrement(key, delta) : Db.StringDecrement(key, -delta); + } + + /// 自减,对应 decr + public long Decr(string key, long delta = 1) + { + return Db.StringDecrement(key, delta); + } + + // ==================== Hash ==================== + + public T HGet(string key, string field) + { + var value = Db.HashGet(key, field); + return value.IsNullOrEmpty ? default : JsonConvert.DeserializeObject(value); + } + + /// 获取整个 Hash(field → 原始 JSON 字符串),对应 hmget + public Dictionary HGetAll(string key) + { + return Db.HashGetAll(key).ToDictionary(e => (string)e.Name, e => (string)e.Value); + } + + public bool HSet(string key, string field, object value) + { + return Db.HashSet(key, field, Serialize(value)); + } + + /// 写入并设置整个 key 过期时间(秒),对应 hset(key, item, value, time) + public bool HSet(string key, string field, object value, long seconds) + { + var ok = HSet(key, field, value); + if (ok) + { + Expire(key, seconds); + } + return ok; + } + + /// 批量写入,对应 hmset + public void HMSet(string key, Dictionary map, long? seconds = null) + { + var entries = map.Select(kv => new HashEntry(kv.Key, Serialize(kv.Value))).ToArray(); + Db.HashSet(key, entries); + if (seconds.HasValue) + { + Expire(key, seconds.Value); + } + } + + public void HDel(string key, params string[] fields) + { + Db.HashDelete(key, fields.Select(f => (RedisValue)f).ToArray()); + } + + public bool HHasKey(string key, string field) + { + return Db.HashExists(key, field); + } + + public double HIncr(string key, string field, double by = 1) + { + return Db.HashIncrement(key, field, by); + } + + public double HDecr(string key, string field, double by = 1) + { + return Db.HashDecrement(key, field, by); + } + + // ==================== Set ==================== + + public List SGet(string key) + { + return Db.SetMembers(key) + .Select(v => JsonConvert.DeserializeObject(v)) + .ToList(); + } + + public bool SHasKey(string key, object value) + { + return Db.SetContains(key, Serialize(value)); + } + + /// 向 Set 添加成员,返回新增数量,对应 sSet + public long SAdd(string key, params object[] values) + { + return Db.SetAdd(key, values.Select(Serialize).ToArray()); + } + + /// 添加并设置过期时间(秒),对应 sSetAndTime + public long SAdd(string key, long seconds, params object[] values) + { + var count = SAdd(key, values); + Expire(key, seconds); + return count; + } + + public long SSize(string key) + { + return Db.SetLength(key); + } + + /// 移除成员,返回移除数量,对应 setRemove + public long SRemove(string key, params object[] values) + { + return Db.SetRemove(key, values.Select(Serialize).ToArray()); + } + + // ==================== List ==================== + + /// 按区间读取(-1 表示末尾),对应 lGet + public List LGet(string key, long start = 0, long end = -1) + { + return Db.ListRange(key, start, end) + .Select(v => JsonConvert.DeserializeObject(v)) + .ToList(); + } + + public long LSize(string key) + { + return Db.ListLength(key); + } + + public T LGetIndex(string key, long index) + { + var value = Db.ListGetByIndex(key, index); + return value.IsNullOrEmpty ? default : JsonConvert.DeserializeObject(value); + } + + /// 右推入单个元素,对应 lSet(key, value) + public long LPush(string key, object value, long? seconds = null) + { + var len = Db.ListRightPush(key, Serialize(value)); + if (seconds.HasValue) + { + Expire(key, seconds.Value); + } + return len; + } + + /// 右推入整个列表(覆盖式写入对应 Java lSet(key, list)) + public long LPushRange(string key, IEnumerable values, long? seconds = null) + { + var len = Db.ListRightPush(key, values.Select(Serialize).ToArray()); + if (seconds.HasValue) + { + Expire(key, seconds.Value); + } + return len; + } + + /// 按索引更新,对应 lUpdateIndex + public void LSetByIndex(string key, long index, object value) + { + Db.ListSetByIndex(key, index, Serialize(value)); + } + + /// 移除 count 个等于 value 的元素,对应 lRemove + public long LRemove(string key, long count, object value) + { + return Db.ListRemove(key, Serialize(value), count); + } + + // ==================== 分布式锁 ==================== + + /// + /// 尝试获取分布式锁(SET NX EX)。token 建议使用 Guid, + /// 释放时必须传入同一 token(防止误删他人锁)。 + /// + public bool TryLock(string name, string token, int expireSeconds = 30) + { + return Db.StringSet(RedisConstants.LockKeyOf(name), token, + TimeSpan.FromSeconds(expireSeconds), When.NotExists); + } + + /// 释放锁(Lua 校验 token 后删除,原子操作) + public bool ReleaseLock(string name, string token) + { + const string script = + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; + var result = (int)Db.ScriptEvaluate(script, + new RedisKey[] { RedisConstants.LockKeyOf(name) }, + new RedisValue[] { token }); + return result == 1; + } + + private static RedisValue Serialize(object value) + { + return value is string s ? s : JsonConvert.SerializeObject(value); + } + } +} diff --git a/src/YhCore.Common/Config/ApiPathConfig.cs b/src/YhCore.Common/Config/ApiPathConfig.cs new file mode 100644 index 0000000..8d41a1b --- /dev/null +++ b/src/YhCore.Common/Config/ApiPathConfig.cs @@ -0,0 +1,19 @@ +namespace YhCore.Common.Config +{ + /// + /// 全局配置门面(对齐 Java com.yunhai.bschclassmanage.config.Config 枚举: + /// API_PATH / MOBILEAPI_PATH / OAPI + FILE_WEB_PATH / FILE_PATH)。 + /// 路由常量复用 ;文件路径由 Startup 从配置初始化。 + /// + public static class ApiPathConfig + { + /// PC 端接口前缀(对应 Java Config.API_PATH) + public const string ApiPath = "api"; + + /// 移动端接口前缀(对应 Java Config.MOBILEAPI_PATH) + public const string MobiApiPath = "mobiapi"; + + /// 开放接口前缀(对应 Java Config.OAPI) + public const string OApiPath = "oapi"; + } +} diff --git a/src/YhCore.Common/Config/Config.cs b/src/YhCore.Common/Config/Config.cs new file mode 100644 index 0000000..e0ef7a9 --- /dev/null +++ b/src/YhCore.Common/Config/Config.cs @@ -0,0 +1,25 @@ +namespace YhCore.Common.Config +{ + /// + /// 全局配置静态门面(对齐 Java com.yunhai.bschclassmanage.config.Config 枚举: + /// API_PATH / MOBILEAPI_PATH / OAPI + FILE_WEB_PATH / FILE_PATH)。 + /// 路由常量复用 ;文件路径由 Startup 从配置初始化。 + /// + public static class Config + { + /// PC 端接口前缀(对应 Java Config.API_PATH) + public const string ApiPath = "api"; + + /// 移动端接口前缀(对应 Java Config.MOBILEAPI_PATH) + public const string MobiApiPath = "mobiapi"; + + /// 开放接口前缀(对应 Java Config.OAPI) + public const string OApiPath = "oapi"; + + /// 文件存储根路径(对应 Java FILE_PATH,由 Startup 注入) + public static string FilePath { get; set; } + + /// 文件访问 URL 前缀(对应 Java FILE_WEB_PATH,由 Startup 注入) + public static string FileWebPath { get; set; } + } +} diff --git a/src/YhCore.Common/Config/DatabaseConfig.cs b/src/YhCore.Common/Config/DatabaseConfig.cs new file mode 100644 index 0000000..4753bb1 --- /dev/null +++ b/src/YhCore.Common/Config/DatabaseConfig.cs @@ -0,0 +1,17 @@ +namespace YhCore.Common.Config +{ + /// + /// 数据库配置(对应 appsettings DatabaseProvider + ConnectionStrings 节)。 + /// + public class DatabaseConfig + { + /// 数据库提供者:SqlServer / MySql + public string DatabaseProvider { get; set; } = "SqlServer"; + + /// SQL Server 连接串 + public string SqlServer { get; set; } + + /// MySQL 连接串 + public string Mysql { get; set; } + } +} diff --git a/src/YhCore.Common/Config/FileConfig.cs b/src/YhCore.Common/Config/FileConfig.cs new file mode 100644 index 0000000..f1ec7ef --- /dev/null +++ b/src/YhCore.Common/Config/FileConfig.cs @@ -0,0 +1,14 @@ +namespace YhCore.Common.Config +{ + /// + /// 文件存储配置(对应 appsettings FILE_PATH / FILE_WEB_PATH,对齐 Java config.FileConfig)。 + /// + public class FileConfig + { + /// 文件存储根路径(对应 Java FILE_PATH) + public string FilePath { get; set; } + + /// 文件访问 URL 前缀(对应 Java FILE_WEB_PATH) + public string FileWebPath { get; set; } + } +} diff --git a/src/YhCore.Common/Config/RedisConfig.cs b/src/YhCore.Common/Config/RedisConfig.cs new file mode 100644 index 0000000..ad3fcf3 --- /dev/null +++ b/src/YhCore.Common/Config/RedisConfig.cs @@ -0,0 +1,11 @@ +namespace YhCore.Common.Config +{ + /// + /// Redis 配置(对应 appsettings Redis 节,对齐 Java config.RedisConfig)。 + /// + public class RedisConfig + { + /// 连接串(如 localhost:6379,defaultDatabase=0) + public string ConnectionString { get; set; } = "localhost:6379,defaultDatabase=0"; + } +} diff --git a/src/YhCore.Common/Config/SwaggerConfig.cs b/src/YhCore.Common/Config/SwaggerConfig.cs new file mode 100644 index 0000000..6edf126 --- /dev/null +++ b/src/YhCore.Common/Config/SwaggerConfig.cs @@ -0,0 +1,17 @@ +namespace YhCore.Common.Config +{ + /// + /// Swagger 配置(对应 appsettings Swagger 节,对齐 Java config.SwaggerConfig)。 + /// + public class SwaggerConfig + { + public string Title { get; set; } = "YhCore API"; + + public string Description { get; set; } + + public string Version { get; set; } = "v1"; + + /// 分组(api / mobiapi / oapi) + public string[] Groups { get; set; } = { ApiPathConfig.ApiPath, ApiPathConfig.MobiApiPath, ApiPathConfig.OApiPath }; + } +} diff --git a/src/YhCore.Common/Exceptions/ErrorCodeUtil.cs b/src/YhCore.Common/Exceptions/ErrorCodeUtil.cs new file mode 100644 index 0000000..1ec570a --- /dev/null +++ b/src/YhCore.Common/Exceptions/ErrorCodeUtil.cs @@ -0,0 +1,30 @@ +using Yh.Platform.Abstractions; + +namespace YhCore.Common.Exceptions +{ + /// int 错误码 → 新框架 映射(对齐 Java ErrorCode 常量值)。 + /// + /// 用途:业务直接抛 时,把数值错误码转成 IErrorCode: + /// throw new DException(ErrorCodeUtil.From(5001), "库存不足"); + /// + public static class ErrorCodeUtil + { + public const int Default = 1; + + /// 按数值映射平台已知错误码;未知码保留原值(避免业务自定义错误码被吞成 Error=1)。 + public static IErrorCode From(int code) => code switch + { + 0 => ErrorCode.Success, + 2 => ErrorCode.ParamError, + 3 => ErrorCode.Forbidden, + 4 => ErrorCode.Unauthorized, + 5 => ErrorCode.Custom, + 6 => ErrorCode.InternalServerError, + 405 => ErrorCode.HttpMethodNotAllowed, + _ => new RawErrorCode(code, ""), + }; + + /// 未知码透传实现:保留调用方原始 code,msg 由业务消息承载。 + private sealed record RawErrorCode(int Code, string Msg) : IErrorCode; + } +} diff --git a/src/YhCore.Common/Result/ApiResult.cs b/src/YhCore.Common/Result/ApiResult.cs new file mode 100644 index 0000000..67f3081 --- /dev/null +++ b/src/YhCore.Common/Result/ApiResult.cs @@ -0,0 +1,47 @@ +using Yh.Platform.Abstractions; + +namespace YhCore.Common.Result +{ + /// + /// 统一响应(对应 Java yh-api-std:com.yunhai.platform.apistd.ApiResponse)。 + /// 字段与 Java 对齐:code/msg/data(Newtonsoft CamelCase 序列化输出小写)。 + /// 注意:Controller 返回值由全局过滤器 GlobalWrapResultFilter 自动包装; + /// 不需要包装的接口用 [NoWrapResult] 特性显式排除(CAS 协议端点、文件下载等)。 + /// + public class ApiResult + { + /// 0=成功,非 0=失败(对齐 Java ErrorCode) + public int Code { get; set; } + + /// 提示消息(对齐 Java msg) + public string Msg { get; set; } + + public T Data { get; set; } + + public static ApiResult Ok(T data) => new() { Code = ErrorCode.Success.Code, Msg = "", Data = data }; + + public static ApiResult Ok() => new() { Code = ErrorCode.Success.Code, Msg = "" }; + + /// 失败(对齐 Java fail(IErrorCode) / fail(int, String)) + public static ApiResult Fail(int code, string msg) => new() { Code = code, Msg = msg }; + + /// 失败(保留旧签名:msg 在前,默认错误码 ERROR=1) + public static ApiResult Fail(string msg, int code = 1) => new() { Code = code, Msg = msg }; + + /// 失败(对齐 Java fail(IErrorCode[, String]),msg 缺省取错误码默认消息) + public static ApiResult Fail(IErrorCode errorCode, string msg = null) => new() + { + Code = errorCode.Code, + Msg = string.IsNullOrEmpty(msg) ? errorCode.Msg : msg + }; + } + + /// 无数据体的统一响应便捷类 + public static class ApiResult + { + public static ApiResult Ok(object data = null) => ApiResult.Ok(data); + public static ApiResult Fail(string msg, int code = 1) => ApiResult.Fail(msg, code); + public static ApiResult Fail(int code, string msg) => ApiResult.Fail(code, msg); + public static ApiResult Fail(IErrorCode errorCode, string msg = null) => ApiResult.Fail(errorCode, msg); + } +} diff --git a/src/YhCore.Common/UserContext/UserContext.cs b/src/YhCore.Common/UserContext/UserContext.cs new file mode 100644 index 0000000..ec27f5d --- /dev/null +++ b/src/YhCore.Common/UserContext/UserContext.cs @@ -0,0 +1,46 @@ +namespace YhCore.Common.UserContext +{ + /// + /// 当前登录用户上下文(对应 Java 版 com.yunhai.bschclassmanage.utils.UserContext)。 + /// 由 UserContextMiddleware 在请求入口写入,全链路通过 读取。 + /// + public class UserContext + { + /// 用户 ID(对应 BoaUser.id) + public int Id { get; set; } + + /// 用户姓名 + public string UserName { get; set; } + + /// 登录账号 + public string Account { get; set; } + + /// 所属学校 ID(对应 Java UserContext.getSchId()) + public int? SchoolId { get; set; } + + /// + /// 所属学校 UUID 标识(旧 SysUser.schId / SchInfoVo.SchSerId,如 "xxxx-xxxx-xxxx")。 + /// 由 LegacyUserContextAdapter 通过教务 SDK GetSchFirstAsync 反查填充(进程内缓存,避免每请求调用); + /// 反查失败或无学校时为 null。 + /// + public string SchSerID { get; set; } + + /// 所属学校名称 + public string SchoolName { get; set; } + + /// + /// 用户类型(对应 Java UserContext.getType() / BoaUser.Type;1=教师等,具体枚举见用户中心约定)。 + /// + public int? UserType { get; set; } + + /// + /// 角色 ID(对应 Java UserContext.getRoleId():取 BoaUser.RoleIds 首个,回退 CurrentRole.RoleId)。 + /// + public int? Role { get; set; } + + /// + /// 用户中心返回的完整 BoaUser(JSON 从认证 Claim 反序列化,GetUserInfo 直接返回)。 + /// + public object FullBoaUser { get; set; } + } +} diff --git a/src/YhCore.Common/UserContext/UserContextHolder.cs b/src/YhCore.Common/UserContext/UserContextHolder.cs new file mode 100644 index 0000000..be8378d --- /dev/null +++ b/src/YhCore.Common/UserContext/UserContextHolder.cs @@ -0,0 +1,23 @@ +using System.Threading; + +namespace YhCore.Common.UserContext +{ + /// + /// 用户上下文持有器。基于 实现, + /// 是 Java ThreadLocal 方案在 .NET async 环境下的安全等价物。 + /// + public static class UserContextHolder + { + private static readonly AsyncLocal _current = new AsyncLocal(); + + /// 当前请求的用户;未登录或中间件未写入时为 null。 + public static UserContext Current + { + get => _current.Value; + set => _current.Value = value; + } + + /// 当前用户学校 ID;无上下文时返回 0(对应 Java UserContext.getSchId())。 + public static int GetSchId() => _current.Value?.SchoolId ?? 0; + } +} diff --git a/src/YhCore.Common/Utils/PageInfo.cs b/src/YhCore.Common/Utils/PageInfo.cs new file mode 100644 index 0000000..5c5ffd3 --- /dev/null +++ b/src/YhCore.Common/Utils/PageInfo.cs @@ -0,0 +1,42 @@ +namespace YhCore.Common.Utils +{ + /// + /// 分页元信息(对应 Java 版 model.tool.PageInfo)。 + /// C# 属性为 PascalCase,ASP.NET Core 默认 camelCase 序列化后 + /// JSON 字段与 Java 版一致(pageNum/pageSize/total/totalPages/hasNext/hasPrev)。 + /// + public class PageInfo + { + /// 当前页码(从 0 开始,与 Spring Pageable 一致) + public int PageNum { get; set; } + + /// 每页大小 + public int PageSize { get; set; } + + /// 总记录数 + public int Total { get; set; } + + /// 总页数 + public int TotalPages { get; set; } + + /// 是否有下一页 + public bool HasNext { get; set; } + + /// 是否有上一页 + public bool HasPrev { get; set; } + + public PageInfo() + { + } + + public PageInfo(int pageNum, int pageSize, int total, int totalPages, bool hasNext, bool hasPrev) + { + PageNum = pageNum; + PageSize = pageSize; + Total = total; + TotalPages = totalPages; + HasNext = hasNext; + HasPrev = hasPrev; + } + } +} diff --git a/src/YhCore.Common/Utils/PageUtil.cs b/src/YhCore.Common/Utils/PageUtil.cs new file mode 100644 index 0000000..302ce48 --- /dev/null +++ b/src/YhCore.Common/Utils/PageUtil.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace YhCore.Common.Utils +{ + /// + /// 内存分页工具(对应 Java 版 utils.PageUtil)。 + /// 用于已对全量数据在内存中的场景(如外部接口聚合结果)做分页; + /// 数据库分页请在 Repository 层用 IQueryable Skip/Take 下推,不要本类。 + /// 页码从 0 开始,与 Spring Pageable 一致。 + /// + public static class PageUtil + { + /// + /// 对列表做内存分页,返回当前页数据(对应 Java paginate)。 + /// 空列表或页码越界时返回空列表。 + /// + /// 全量数据 + /// 页码,从 0 开始 + /// 每页大小 + public static List Paginate(IReadOnlyCollection list, int pageNum, int pageSize) + { + if (list == null || list.Count == 0 || pageSize <= 0) + { + return new List(); + } + + var fromIndex = pageNum * pageSize; + if (fromIndex >= list.Count) + { + return new List(); + } + + return list.Skip(fromIndex).Take(pageSize).ToList(); + } + + /// + /// 获取分页元信息(对应 Java getPageInfo)。 + /// + /// 原始数据列表 + /// 页码,从 0 开始 + /// 每页大小 + public static PageInfo GetPageInfo(IReadOnlyCollection list, int pageNum, int pageSize) + { + var total = list?.Count ?? 0; + var totalPages = pageSize > 0 ? (int)Math.Ceiling((double)total / pageSize) : 0; + return new PageInfo( + pageNum, + pageSize, + total, + totalPages, + hasNext: pageNum < totalPages - 1, + hasPrev: pageNum > 0); + } + + /// + /// 内存分页 + 元信息一次完成(Paginate 与 GetPageInfo 的组合便捷方法)。 + /// + public static (List Items, PageInfo Info) PaginateWithInfo( + IReadOnlyCollection list, int pageNum, int pageSize) + { + return (Paginate(list, pageNum, pageSize), GetPageInfo(list, pageNum, pageSize)); + } + } +} diff --git a/src/YhCore.Common/YhCore.Common.csproj b/src/YhCore.Common/YhCore.Common.csproj new file mode 100644 index 0000000..db4c87e --- /dev/null +++ b/src/YhCore.Common/YhCore.Common.csproj @@ -0,0 +1,24 @@ + + + + net5.0 + YhCore.Common + YhCore.Common + 9.0 + disable + + + + + + + + + + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.Platform.Abstractions\bin\Debug\net5.0\Yh.Platform.Abstractions.dll + + + + diff --git a/src/YhCore.Entity/A_DataLog.cs b/src/YhCore.Entity/A_DataLog.cs new file mode 100644 index 0000000..d8456ab --- /dev/null +++ b/src/YhCore.Entity/A_DataLog.cs @@ -0,0 +1,25 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class A_DataLog + { + public int SerID { get; set; } + public string SchSerID { get; set; } + public string ModuleName { get; set; } + public string TableName { get; set; } + public Nullable OldSerID { get; set; } + public Nullable NowSerID { get; set; } + public Nullable RId { get; set; } + } +} diff --git a/src/YhCore.Entity/AppMsgRecord.cs b/src/YhCore.Entity/AppMsgRecord.cs new file mode 100644 index 0000000..dd38c24 --- /dev/null +++ b/src/YhCore.Entity/AppMsgRecord.cs @@ -0,0 +1,33 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class AppMsgRecord + { + public int SerID { get; set; } + public int AppSerID { get; set; } + public string AppName { get; set; } + public string AppCode { get; set; } + public string MsgType { get; set; } + public string MsgContent { get; set; } + public string MsgError { get; set; } + public string UserID { get; set; } + public string SUserID { get; set; } + public string SUserPhone { get; set; } + public string SendType { get; set; } + public string TabCode { get; set; } + public string SchSerID { get; set; } + public Nullable Datetime { get; set; } + public Nullable Read { get; set; } + } +} diff --git a/src/YhCore.Entity/Base_Log.cs b/src/YhCore.Entity/Base_Log.cs new file mode 100644 index 0000000..167b918 --- /dev/null +++ b/src/YhCore.Entity/Base_Log.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Base_Log + { + public Nullable Date { get; set; } + public Nullable Thread { get; set; } + public string LEVEL { get; set; } + public string Logger { get; set; } + public string MESSAGE { get; set; } + public string EXCEPTION { get; set; } + public int Id { get; set; } + public string AppName { get; set; } + public string UserId { get; set; } + public string Type { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_Dictionary.cs b/src/YhCore.Entity/Common_Dictionary.cs new file mode 100644 index 0000000..7fdfa02 --- /dev/null +++ b/src/YhCore.Entity/Common_Dictionary.cs @@ -0,0 +1,30 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_Dictionary + { + public decimal SerID { get; set; } + public string typeID { get; set; } + public string typeName { get; set; } + public string CodeID { get; set; } + public string Name { get; set; } + public string Des { get; set; } + public int Enable { get; set; } + public string Schserid { get; set; } + public Nullable PID { get; set; } + public string Path { get; set; } + public Nullable Sort { get; set; } + public string Code { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_DictionaryOrd.cs b/src/YhCore.Entity/Common_DictionaryOrd.cs new file mode 100644 index 0000000..89e6d64 --- /dev/null +++ b/src/YhCore.Entity/Common_DictionaryOrd.cs @@ -0,0 +1,23 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_DictionaryOrd + { + public int SerID { get; set; } + public int DicSerID { get; set; } + public string SchSerID { get; set; } + public Nullable Open { get; set; } + public Nullable Ord { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_Groupuser.cs b/src/YhCore.Entity/Common_Groupuser.cs new file mode 100644 index 0000000..960e2f2 --- /dev/null +++ b/src/YhCore.Entity/Common_Groupuser.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_Groupuser + { + public int id { get; set; } + public Nullable usergroup { get; set; } + public Nullable userid { get; set; } + public Nullable flowid { get; set; } + public string SchSerID { get; set; } + public Nullable IsOk { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_Groupuser_SHRole.cs b/src/YhCore.Entity/Common_Groupuser_SHRole.cs new file mode 100644 index 0000000..ff2e1a6 --- /dev/null +++ b/src/YhCore.Entity/Common_Groupuser_SHRole.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_Groupuser_SHRole + { + public int id { get; set; } + public Nullable usergroup { get; set; } + public Nullable SHRoleid { get; set; } + public Nullable flowid { get; set; } + public string SchSerID { get; set; } + public Nullable IsOk { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_SendUserinfo.cs b/src/YhCore.Entity/Common_SendUserinfo.cs new file mode 100644 index 0000000..d45875c --- /dev/null +++ b/src/YhCore.Entity/Common_SendUserinfo.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_SendUserinfo + { + public int SerID { get; set; } + public Nullable userid { get; set; } + public string Senduserid { get; set; } + public string schserid { get; set; } + public Nullable createTime { get; set; } + public Nullable flowType { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_UpFiles.cs b/src/YhCore.Entity/Common_UpFiles.cs new file mode 100644 index 0000000..ef5a4c8 --- /dev/null +++ b/src/YhCore.Entity/Common_UpFiles.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_UpFiles + { + public int SerID { get; set; } + public string TopicType { get; set; } + public Nullable TopicID { get; set; } + public string FileTrueName { get; set; } + public string FileName { get; set; } + public Nullable FileSize { get; set; } + public string FileType { get; set; } + public Nullable DateAndTime { get; set; } + public Nullable DownTimes { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_UserFlowZhipai.cs b/src/YhCore.Entity/Common_UserFlowZhipai.cs new file mode 100644 index 0000000..1698ae9 --- /dev/null +++ b/src/YhCore.Entity/Common_UserFlowZhipai.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_UserFlowZhipai + { + public int SerID { get; set; } + public int UserID { get; set; } + public int MSerID { get; set; } + public Nullable FlowID { get; set; } + public Nullable FlowStepID { get; set; } + public string zhipai { get; set; } + public string type { get; set; } + public string SchSerID { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_flowinfo.cs b/src/YhCore.Entity/Common_flowinfo.cs new file mode 100644 index 0000000..d179de9 --- /dev/null +++ b/src/YhCore.Entity/Common_flowinfo.cs @@ -0,0 +1,30 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_flowinfo + { + public int SerID { get; set; } + public string flowname { get; set; } + public string flowdes { get; set; } + public int enable { get; set; } + public int flowType { get; set; } + public System.DateTime deitdate { get; set; } + public string schSerID { get; set; } + public string deptid { get; set; } + public string typeid { get; set; } + public string ChaoSend { get; set; } + public Nullable StepSend { get; set; } + public string WYPageGG { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_flowstep.cs b/src/YhCore.Entity/Common_flowstep.cs new file mode 100644 index 0000000..95c6ede --- /dev/null +++ b/src/YhCore.Entity/Common_flowstep.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_flowstep + { + public int SerID { get; set; } + public Nullable flowid { get; set; } + public string StepName { get; set; } + public Nullable ordernum { get; set; } + public Nullable prestep { get; set; } + public Nullable action_user { get; set; } + public string SchSerID { get; set; } + public Nullable cond { get; set; } + public string isshowJE { get; set; } + public string isshowBtn { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_flowtranctInfo.cs b/src/YhCore.Entity/Common_flowtranctInfo.cs new file mode 100644 index 0000000..b587974 --- /dev/null +++ b/src/YhCore.Entity/Common_flowtranctInfo.cs @@ -0,0 +1,30 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_flowtranctInfo + { + public int SerID { get; set; } + public string datatype { get; set; } + public int dataid { get; set; } + public int stepid { get; set; } + public Nullable actionuserid { get; set; } + public short actionstatus { get; set; } + public Nullable actiondate { get; set; } + public string remark { get; set; } + public string undertaker { get; set; } + public string Pid { get; set; } + public string DoneUserID { get; set; } + public string filespath { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_flowuser.cs b/src/YhCore.Entity/Common_flowuser.cs new file mode 100644 index 0000000..93becc4 --- /dev/null +++ b/src/YhCore.Entity/Common_flowuser.cs @@ -0,0 +1,23 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_flowuser + { + public int id { get; set; } + public Nullable usergroup { get; set; } + public Nullable userid { get; set; } + public Nullable flowid { get; set; } + public string SchSerID { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_freeGroupuser.cs b/src/YhCore.Entity/Common_freeGroupuser.cs new file mode 100644 index 0000000..b40bce9 --- /dev/null +++ b/src/YhCore.Entity/Common_freeGroupuser.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_freeGroupuser + { + public int id { get; set; } + public Nullable usergroup { get; set; } + public Nullable userid { get; set; } + public Nullable flowid { get; set; } + public string SchSerID { get; set; } + public Nullable IsOk { get; set; } + } +} diff --git a/src/YhCore.Entity/Common_freeflowinfo.cs b/src/YhCore.Entity/Common_freeflowinfo.cs new file mode 100644 index 0000000..2776f7b --- /dev/null +++ b/src/YhCore.Entity/Common_freeflowinfo.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class Common_freeflowinfo + { + public int SerID { get; set; } + public Nullable userid { get; set; } + public string shenheuserid { get; set; } + public string schserid { get; set; } + public Nullable steptype { get; set; } + public Nullable createTime { get; set; } + public Nullable flowType { get; set; } + public Nullable dataid { get; set; } + public Nullable laststep { get; set; } + } +} diff --git a/src/YhCore.Entity/SysOperationLog.cs b/src/YhCore.Entity/SysOperationLog.cs new file mode 100644 index 0000000..d7d9ffe --- /dev/null +++ b/src/YhCore.Entity/SysOperationLog.cs @@ -0,0 +1,73 @@ +using System; + +namespace YhCore.Entity +{ + /// + /// 系统审计日志(对应 Java 版 entity.SysOperationLog,表 sys_operation_log)。 + /// HTTP 接口级全量日志:由 SysOperationLogFilter 在每次 Controller 请求后写入。 + /// 字段与 Java 实体一一对齐;重试三字段(RetryCount/LastRetryStatus/LastRetryTime) + /// 为 Java 双轨同步保留,.NET 侧当前不写入(保持 NULL/默认)。 + /// 表名映射见 YHDbContext.OnModelCreating:ToTable("sys_operation_log")。 + /// + public class SysOperationLog + { + public long Id { get; set; } + + /// 日志级别:1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) + public int LogLevel { get; set; } + + /// 操作类型:DELETE / CREATE / UPDATE / QUERY + public string OperationType { get; set; } + + /// 操作人 ID(来自 UserContext.Id) + public int? UserId { get; set; } + + /// 操作人姓名(来自 UserContext.UserName,匿名时 anonymous) + public string UserName { get; set; } + + /// 学校 ID(多校隔离,来自 UserContext.SchoolId) + public int? SchId { get; set; } + + /// 客户端 IP + public string RequestIp { get; set; } + + /// 请求 URI + public string RequestUri { get; set; } + + /// HTTP 方法 + public string RequestMethod { get; set; } + + /// 类名.方法名 + public string ClassMethod { get; set; } + + /// 请求参数(JSON 序列化,含截断) + public string RequestParams { get; set; } + + /// GZIP 压缩后的响应 JSON + public byte[] ResponseData { get; set; } + + /// 业务状态码:0 成功 / 非 0 失败(全局返回对象 code) + public int StatusCode { get; set; } + + /// 耗时(ms) + public long CostTime { get; set; } + + /// User-Agent + public string UserAgent { get; set; } + + /// 异常信息(截断 8000) + public string ErrorMsg { get; set; } + + /// 重试次数(对齐 Java 双轨字段,暂不写入) + public int? RetryCount { get; set; } + + /// 上次重试状态:1 成功 / 0 失败(对齐 Java 双轨字段,暂不写入) + public int? LastRetryStatus { get; set; } + + /// 上次重试时间(对齐 Java 双轨字段,暂不写入) + public DateTime? LastRetryTime { get; set; } + + /// 记录时间 + public DateTime CreateTime { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_DocPrint.cs b/src/YhCore.Entity/WY_DocPrint.cs new file mode 100644 index 0000000..da8dad8 --- /dev/null +++ b/src/YhCore.Entity/WY_DocPrint.cs @@ -0,0 +1,47 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_DocPrint + { + public decimal SerID { get; set; } + public string DocTitle { get; set; } + public string DocType { get; set; } + public string DocStyle { get; set; } + public int DocNum { get; set; } + public string DocContent { get; set; } + public string UserID { get; set; } + public System.DateTime EditDate { get; set; } + public string ChkUserID { get; set; } + public string DocStatus { get; set; } + public string ChkDate { get; set; } + public string UpFile { get; set; } + public string DPType { get; set; } + public Nullable TakeDate { get; set; } + public string DocCode { get; set; } + public Nullable DocStep { get; set; } + public Nullable flowid { get; set; } + public Nullable rid { get; set; } + public Nullable reid { get; set; } + public string SchSerID { get; set; } + public string explain { get; set; } + public string grade { get; set; } + public string Examtype { get; set; } + public Nullable DocFix { get; set; } + public Nullable DocShare { get; set; } + public string FreeUserID { get; set; } + public string SendUserID { get; set; } + public Nullable freestepid { get; set; } + public string zhipai { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_DocPrint_Num.cs b/src/YhCore.Entity/WY_DocPrint_Num.cs new file mode 100644 index 0000000..3dc51c0 --- /dev/null +++ b/src/YhCore.Entity/WY_DocPrint_Num.cs @@ -0,0 +1,32 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_DocPrint_Num + { + public decimal SerID { get; set; } + public Nullable DocPrintID { get; set; } + public string YSLXName { get; set; } + public string YSLXtypeID { get; set; } + public Nullable YSLXOrder { get; set; } + public string YSGGName { get; set; } + public string YSGGtypeID { get; set; } + public Nullable YSGGOrder { get; set; } + public Nullable DocNum { get; set; } + public Nullable DocPage { get; set; } + public Nullable DocFix { get; set; } + public Nullable DocShare { get; set; } + public Nullable UnitPrice { get; set; } + public Nullable TotalPrice { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_DocPrint_SH.cs b/src/YhCore.Entity/WY_DocPrint_SH.cs new file mode 100644 index 0000000..3057d64 --- /dev/null +++ b/src/YhCore.Entity/WY_DocPrint_SH.cs @@ -0,0 +1,29 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_DocPrint_SH + { + public decimal SerID { get; set; } + public Nullable DocPrintSerID { get; set; } + public Nullable flowSerID { get; set; } + public Nullable stepSerID { get; set; } + public Nullable stepOrder { get; set; } + public Nullable SHTeaID { get; set; } + public string SHTeaName { get; set; } + public string AndOr { get; set; } + public string isSH { get; set; } + public Nullable inserttime { get; set; } + public string writor { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_SHRole.cs b/src/YhCore.Entity/WY_SHRole.cs new file mode 100644 index 0000000..fe8aeb2 --- /dev/null +++ b/src/YhCore.Entity/WY_SHRole.cs @@ -0,0 +1,23 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_SHRole + { + public decimal SerID { get; set; } + public string SchSerId { get; set; } + public string SHRoleName { get; set; } + public Nullable inserttime { get; set; } + public string writor { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_SHRole_BSHTea.cs b/src/YhCore.Entity/WY_SHRole_BSHTea.cs new file mode 100644 index 0000000..5d5ff87 --- /dev/null +++ b/src/YhCore.Entity/WY_SHRole_BSHTea.cs @@ -0,0 +1,25 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_SHRole_BSHTea + { + public decimal SerID { get; set; } + public string SchSerID { get; set; } + public Nullable SHRoleID { get; set; } + public Nullable BSHTeaID { get; set; } + public string BSHTeaName { get; set; } + public Nullable inserttime { get; set; } + public string writor { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_SHRole_SHTea.cs b/src/YhCore.Entity/WY_SHRole_SHTea.cs new file mode 100644 index 0000000..35838d2 --- /dev/null +++ b/src/YhCore.Entity/WY_SHRole_SHTea.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_SHRole_SHTea + { + public decimal SerID { get; set; } + public string SchSerID { get; set; } + public Nullable SHRoleID { get; set; } + public Nullable SHTeaID { get; set; } + public string SHTeaName { get; set; } + public string AndOr { get; set; } + public string photo { get; set; } + public Nullable inserttime { get; set; } + public string writor { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_SchZyBit.cs b/src/YhCore.Entity/WY_SchZyBit.cs new file mode 100644 index 0000000..edbe53e --- /dev/null +++ b/src/YhCore.Entity/WY_SchZyBit.cs @@ -0,0 +1,21 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_SchZyBit + { + public int SerID { get; set; } + public string SchSerID { get; set; } + public Nullable ziyuanbit { get; set; } + } +} diff --git a/src/YhCore.Entity/WY_reGetJERd.cs b/src/YhCore.Entity/WY_reGetJERd.cs new file mode 100644 index 0000000..36ce0b6 --- /dev/null +++ b/src/YhCore.Entity/WY_reGetJERd.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// +// 此代码已从模板生成。 +// +// 手动更改此文件可能导致应用程序出现意外的行为。 +// 如果重新生成代码,将覆盖对此文件的手动更改。 +// +//------------------------------------------------------------------------------ + +namespace YhCore.Entity +{ + using System; + using System.Collections.Generic; + + public partial class WY_reGetJERd + { + public decimal SerID { get; set; } + public string SchSerID { get; set; } + public Nullable DocPrintID { get; set; } + public string DocTitle { get; set; } + public string DocContent { get; set; } + public Nullable EditDatetime { get; set; } + public string writor { get; set; } + public string type { get; set; } + public string UnitPrice { get; set; } + } +} diff --git a/src/YhCore.Entity/YhCore.Entity.csproj b/src/YhCore.Entity/YhCore.Entity.csproj new file mode 100644 index 0000000..edb9955 --- /dev/null +++ b/src/YhCore.Entity/YhCore.Entity.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + YhCore.Entity + YhCore.Entity + 9.0 + disable + false + + + diff --git a/src/YhCore.Model/Dto/DictionaryDtos.cs b/src/YhCore.Model/Dto/DictionaryDtos.cs new file mode 100644 index 0000000..6c49041 --- /dev/null +++ b/src/YhCore.Model/Dto/DictionaryDtos.cs @@ -0,0 +1,129 @@ +using System; + +namespace YhCore.Model.Dto +{ + /// 字典下拉框选项(旧 GetDictList 的 {k=CodeID, v=Name})。 + public class DicOption + { + public string K { get; set; } + public string V { get; set; } + } + + /// 字典管理列表项(旧 GetDictMangeList 的 {SerID, CodeID, Name, Des, Enable})。 + public class DicManageItem + { + public decimal SerID { get; set; } + public string CodeID { get; set; } + public string Name { get; set; } + public string Des { get; set; } + public int Enable { get; set; } + } + + /// 文印分类 / 印制版面列表项(旧 CategoryList / YZBMList)。 + public class CategoryItem + { + public decimal SerID { get; set; } + public string CodeID { get; set; } + public string Name { get; set; } + public string Des { get; set; } + public int Enable { get; set; } + public int? Sort { get; set; } + public string SchSerID { get; set; } + public string UserSchSerID { get; set; } + public bool _disabled { get; set; } + } + + /// 公共类别列表项(旧 DictionaryTypeList)。 + public class DictionaryTypeItem + { + public string Code { get; set; } + public decimal SerID { get; set; } + public string CodeID { get; set; } + public string Name { get; set; } + public string Des { get; set; } + public int Enable { get; set; } + public int? Sort { get; set; } + public string SchSerID { get; set; } + public string UserSchSerID { get; set; } + public bool _disabled { get; set; } + public bool Status { get; set; } + public bool Open { get; set; } + public int Ord { get; set; } + public string Path { get; set; } + } + + /// + /// 字典类型常量与映射(对齐旧 EnumConfig.Dictionary 枚举及 CommonController 的硬编码 switch)。 + /// + public static class DictionaryTypeHelper + { + /// GetSysDic 入参 Type(1~10) → 字典 typeID(对齐旧 switch)。 + private static readonly string[] SysDic = + { null, "DEPT", "WYFL", "YZBM", "YZFL", "LeaveType", "FoodType", "Unit", "LevelOrder", "filetype", "doclevel" }; + + /// 旧 EnumConfig.Dictionary 枚举序值 → typeID(用于 GetDictMangeList 的 Type 参数)。 + private static readonly string[] EnumOrdinal = + { "DEPT", "BXBM", "WYFL", "YZBM", "YZFL", "LeaveType", "FoodType", "Unit", "VenueType", "Vefacilities", "Time", "Repeat", "LevelOrder", "filetype", "doclevel", "XKCategory" }; + + /// 全部字典类别(typeID, typeName),对齐旧 EnumConfig.Dictionary 的 Description。 + public static readonly (string Id, string Name)[] AllTypes = + { + ("DEPT", "部门类别"), + ("BXBM", "报修部门"), + ("WYFL", "文印分类"), + ("YZBM", "印制版面"), + ("YZFL", "印制分类"), + ("LeaveType", "请假类别"), + ("FoodType", "物品类别"), + ("Unit", "单位"), + ("VenueType", "场馆类别"), + ("Vefacilities", "场馆设施"), + ("Time", "场馆时间"), + ("Repeat", "场馆重复"), + ("LevelOrder", "奖次"), + ("filetype", "文件类型"), + ("doclevel", "公文级别"), + ("XKCategory", "选课类型"), + }; + + /// GetSysDic 的 Type(int) → typeID;越界返回 null。 + public static string FromSysDicType(int type) => type >= 1 && type <= 10 ? SysDic[type] : null; + + /// GetDictMangeList 的 Type 参数 → typeID(支持枚举名、枚举序值、typeID 直传)。 + public static string ResolveTypeId(string type) + { + if (string.IsNullOrEmpty(type)) return null; + foreach (var t in AllTypes) + { + if (t.Id == type) return t.Id; + } + + if (int.TryParse(type, out var n) && n >= 0 && n < EnumOrdinal.Length) + { + return EnumOrdinal[n]; + } + + return type; + } + + /// typeID → typeName(描述);未知则原样返回。 + public static string GetTypeName(string typeId) + { + foreach (var t in AllTypes) + { + if (t.Id == typeId) return t.Name; + } + + return typeId; + } + + public static bool IsDept(string typeId) => typeId == "DEPT"; + + /// Des(排序字符串)→ int,解析失败/空回退 int.MaxValue(对齐旧 int.Parse 的降级,避免非数值崩)。 + public static int ParseSort(string des) + { + if (string.IsNullOrEmpty(des)) return int.MaxValue; + return int.TryParse(des, out var n) ? n : int.MaxValue; + } + } +} diff --git a/src/YhCore.Model/Dto/DocPrintStatisticsDtos.cs b/src/YhCore.Model/Dto/DocPrintStatisticsDtos.cs new file mode 100644 index 0000000..0babbee --- /dev/null +++ b/src/YhCore.Model/Dto/DocPrintStatisticsDtos.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; + +namespace YhCore.Model.Dto +{ + /// + /// 文印统计看板 DTO(移植旧 BestSchool.DocPrint.DocPrintStatistics,5 接口)。 + /// 旧实现以 dynamic 匿名对象返回 {k,v} 结构,新项目统一为强类型 DTO。 + /// 说明:旧 GetSchSerID(int) 走 HTTP 把 int schId 映射为 GUID;新库 SchSerID 约定已是 int 字符串, + /// 故此处直接用 schId.ToString() 匹配,不再依赖外部转换。 + /// + + /// 统计项(旧 GetDocPrintCount 的 {k,v},v 为数量或类型名)。 + public class DocPrintCountItem + { + public string k { get; set; } + public object v { get; set; } + } + + /// 饼图分类项(旧 GetPieChart 的 series.list 元素 {k,v})。 + public class PieChartKv + { + public string k { get; set; } + public object v { get; set; } + } + + /// 饼图系列(旧 GetPieChart 的 {title,list})。 + public class PieChartSeries + { + public string title { get; set; } + public List list { get; set; } = new List(); + } + + /// 印刷类型下拉项(旧 GetDocPrintType 的 {CodeID,Name})。 + public class DocPrintTypeItem + { + public string CodeID { get; set; } + public string Name { get; set; } + } + + /// 文印统计列表项(旧 GetDocPrintList 的投影)。 + public class DocPrintStatItem + { + public int SerID { get; set; } + public string DocContent { get; set; } + public string DocType { get; set; } + public int DocNum { get; set; } + public string DocTypeName { get; set; } + public string DocStyle { get; set; } + public string DocStyleName { get; set; } + public string EditDate { get; set; } + public string TakeDate { get; set; } + public string UserID { get; set; } + public string teaname { get; set; } + public string UpFile { get; set; } + public string DocTitle { get; set; } + public string DocStatus { get; set; } + public string DocStatusName { get; set; } + public int? DocStep { get; set; } + public int? flowid { get; set; } + public string FreeUserID { get; set; } + public string SendUserID { get; set; } + public int? freestepid { get; set; } + public int? DocShare { get; set; } + public string explain { get; set; } + } + + /// 文印统计列表结果(旧 GetDocPrintList 的 {tlist,totle})。 + public class DocPrintListResult + { + public List tlist { get; set; } = new List(); + public int totle { get; set; } + } +} diff --git a/src/YhCore.Model/Dto/FileDtos.cs b/src/YhCore.Model/Dto/FileDtos.cs new file mode 100644 index 0000000..16bbfef --- /dev/null +++ b/src/YhCore.Model/Dto/FileDtos.cs @@ -0,0 +1,28 @@ +namespace YhCore.Model.Dto +{ + /// + /// 文件信息(旧 BestSchool.Entity.FileObj,GetEnclosure/FileSave/FileSaveImage/FileMergeSave 共用)。 + /// fileurl 为相对路径(如 /UploadFile/docprint/20260101/{guid}.jpg,与旧库数据格式一致); + /// host 为文件访问主机前缀(旧 AuthConfig.weboa_url,新 Config.FileWebPath)。 + /// + public class FileObj + { + /// 附件表 SerID(SaveEnclosure 落库后回填)。 + public int fileid { get; set; } + + /// 文件大小(字节;旧 FileMergeSave 存的是 MB 整数,保持旧语义)。 + public int filesize { get; set; } + + /// 扩展名(含点,如 ".jpg")。 + public string filetype { get; set; } + + /// 文件真实名称(含扩展名)。 + public string filename { get; set; } + + /// 相对访问路径(如 /UploadFile/docprint/20260101/{guid}.jpg)。 + public string fileurl { get; set; } + + /// 文件主机前缀(Config.FileWebPath)。 + public string host { get; set; } + } +} diff --git a/src/YhCore.Model/Dto/FreeApproveDtos.cs b/src/YhCore.Model/Dto/FreeApproveDtos.cs new file mode 100644 index 0000000..0f0790b --- /dev/null +++ b/src/YhCore.Model/Dto/FreeApproveDtos.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace YhCore.Model.Dto +{ + /// 自由流程审核人(旧 ApproveFlow.GetFleeFlow/GetAfterFleeFlow 的 user 数组元素)。 + public class FreeFlowUser + { + public int userid { get; set; } + public string name { get; set; } + public string photo { get; set; } + } + + /// 自由流程节点(旧 GetFleeFlow 列表元素:{ user: [...] })。 + public class FreeFlowNode + { + public List user { get; set; } + } + + /// 审核流程步骤信息(旧 ApproveFlow.FreeShenheinfo,Remark 序列化后为 camelCase 的 remark)。 + public class FreeShenheInfo + { + public string steptname { get; set; } + public string steptstatus { get; set; } + public string photo { get; set; } + public string Remark { get; set; } + public string actiondate { get; set; } + public string actionstatus { get; set; } + } + + /// 抄送人(旧 ChaoSendList 返回 { uid, photo, name, status })。 + public class ChaoSendUser + { + public int uid { get; set; } + public string photo { get; set; } + public string name { get; set; } + public bool status { get; set; } + } +} diff --git a/src/YhCore.Model/Dto/ReviewProcessDtos.cs b/src/YhCore.Model/Dto/ReviewProcessDtos.cs new file mode 100644 index 0000000..132c7ee --- /dev/null +++ b/src/YhCore.Model/Dto/ReviewProcessDtos.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; + +namespace YhCore.Model.Dto +{ + /// + /// DocPrint 应用常量(旧 WebHelper.AppCode / AuthConfig.AppKeyMap["docprint"][3])。 + /// + public static class DocPrintConstants + { + /// 文印申请 AppCode,即审核流程 / 自由流程的 flowType。 + public const int AppCode = 1003; + } + + /// 审核流程列表项(旧 Reviewprocess.List / List2 的匿名类型)。 + public class FlowListItem + { + public string SerID { get; set; } + public string flowname { get; set; } + public string deptid { get; set; } + public string typeid { get; set; } + public int enable { get; set; } + public string enablename { get; set; } + public string miaoshu { get; set; } + public string deitdate { get; set; } + } + + /// 步骤流程列表项(旧 StepList 的匿名类型)。 + public class StepListItem + { + public string SerID { get; set; } + public string StepName { get; set; } + public string flowid { get; set; } + public int? prestep { get; set; } + public string TeaName { get; set; } + public string Teaid { get; set; } + public bool istrue { get; set; } + } + + /// 流程绑定步骤项(旧 BindstepList)。 + public class BindStepItem + { + public int SerID { get; set; } + public string StepName { get; set; } + } + + /// 绑定步骤人员项(旧 BindGroupuserList)。 + public class BindGroupuserItem + { + public int StepId { get; set; } + public int? userid { get; set; } + public string StepName { get; set; } + public string TeaName { get; set; } + } + + /// 抄送人(旧 Bindflowinfo 的 ChaoSendArr 元素)。 + public class ChaoSendItem + { + public string uid { get; set; } + public string name { get; set; } + public string photo { get; set; } + } + + /// 绑定流程项(旧 Bindflowinfo)。 + public class BindFlowItem + { + public string SerID { get; set; } + public string flowname { get; set; } + public List ChaoSendArr { get; set; } + } + + /// 教师下拉选项(旧 GetTeaList 的 {value,label})。 + public class TeaOption + { + public int value { get; set; } + public string label { get; set; } + } + + /// 附件项(旧 filelist)。 + public class FileListItem + { + public int SerID { get; set; } + public string FileTrueName { get; set; } + public string FileName { get; set; } + public int? FileSize { get; set; } + public string FileType { get; set; } + } + + /// 自由流程审核人(旧 IsShenheflow 的 OtherData 元素)。 + public class FreeFlowItem + { + public string shenheuserid { get; set; } + public string teamnamestr { get; set; } + } + + /// 审核流程类型判断结果(旧 IsShenheflow:Data=flowflag,OtherData=自由流程审核人)。 + public class ShenheFlowResult + { + public bool flowflag { get; set; } + public List freeflowlist { get; set; } + } + + /// 绑定抄送人(旧 IsSendUser)。 + public class SendUserItem + { + public int uid { get; set; } + public string photo { get; set; } + public string name { get; set; } + public bool status { get; set; } + } + + /// 当前用户信息(旧 GetUser 返回 SysUser,新框架取 UserContext 有限字段)。 + public class CurrentUserInfo + { + public int Id { get; set; } + public string UserName { get; set; } + public string Account { get; set; } + public int? SchoolId { get; set; } + public string SchoolName { get; set; } + } +} diff --git a/src/YhCore.Model/Dto/WYDocPrintDtos.cs b/src/YhCore.Model/Dto/WYDocPrintDtos.cs new file mode 100644 index 0000000..1275ef4 --- /dev/null +++ b/src/YhCore.Model/Dto/WYDocPrintDtos.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; + +namespace YhCore.Model.Dto +{ + /// + /// 文印新增/修改入参(旧 BestSchool.Entity.Model.WY_DocPrintDto : WY_DocPrint + ctype/sms)。 + /// SerID 仅修改时使用;UserID/SchSerID 由控制器从登录态覆盖。 + /// + public class WYDocPrintSaveDto + { + public decimal SerID { get; set; } + public string DocTitle { get; set; } + public string DocType { get; set; } + public string DocStyle { get; set; } + public int DocNum { get; set; } + public string DocContent { get; set; } + public string UserID { get; set; } + public string UpFile { get; set; } + public string DPType { get; set; } + public DateTime? TakeDate { get; set; } + public int? DocStep { get; set; } + public int? flowid { get; set; } + public int? rid { get; set; } + public int? reid { get; set; } + public string SchSerID { get; set; } + public string explain { get; set; } + public string grade { get; set; } + public string Examtype { get; set; } + public int? DocFix { get; set; } + public int? DocShare { get; set; } + public string FreeUserID { get; set; } + public string SendUserID { get; set; } + public int? freestepid { get; set; } + public string zhipai { get; set; } + /// 旧 WY_DocPrintDto.ctype(AllList 四分类标记),保存时不用,仅保持入参兼容。 + public int ctype { get; set; } + /// 旧 sms 短信开关;新项目无短信/待办通道,仅保留入参不生效。 + public bool sms { get; set; } + } + + /// + /// 审核动作入参(旧 Common_flowtranctInfoDto : Common_flowtranctInfo + SendUserID/MySendUserID/sms), + /// 用于 Revoke / shenheok / AffirmApplyforOk;actionuserid 由控制器从登录态覆盖。 + /// + public class FlowTranctDto + { + public int dataid { get; set; } + public int stepid { get; set; } + public short actionstatus { get; set; } + public int? actionuserid { get; set; } + public string remark { get; set; } + public string undertaker { get; set; } + public string Pid { get; set; } + public string DoneUserID { get; set; } + public string filespath { get; set; } + public string SendUserID { get; set; } + public string MySendUserID { get; set; } + /// 旧 sms 短信开关;新项目无短信通道,仅保留入参不生效。 + public bool sms { get; set; } + } + + /// + /// 文印列表项(旧 MyList/SendRdList/SPList/ShenheList/YishenList/totalList/AllList 的匿名投影, + /// 序列化 camelCase 后字段名与旧 JSON 一致;ctype 仅 AllList 填充)。 + /// + public class DocPrintListItem + { + public int SerID { get; set; } + public string DocContent { get; set; } + public string DocType { get; set; } + public string DocTypeName { get; set; } + public string DocStyle { get; set; } + public string DocStyleName { get; set; } + public string DPType { get; set; } + public string DPTypeName { get; set; } + public string EditDate { get; set; } + public string TakeDate { get; set; } + public string UserID { get; set; } + public string teaname { get; set; } + public string TeaPhoto { get; set; } + public int? DocShare { get; set; } + public string UpFile { get; set; } + public string DocTitle { get; set; } + public string DocStatus { get; set; } + public string DocStatusName { get; set; } + public int? DocStep { get; set; } + public int? flowid { get; set; } + public bool _disabled { get; set; } + public string FreeUserID { get; set; } + public string SendUserID { get; set; } + public int? freestepid { get; set; } + public string explain { get; set; } + /// AllList 四分类标记:0=待我审 / 1=我已审 / 2=抄送我 / 3=我发起;仅 AllList 返回,其余列表为 null。 + public int? ctype { get; set; } + } + + /// + /// 文印单条详情(旧 MyListtoOne 的匿名投影:DocStep/flowid 为字符串,含完整业务字段; + /// 旧 OtherData.tempresultfile(附件明细)并入 data,因新统一响应无 OtherData 槽位)。 + /// + public class DocPrintDetailItem + { + public int SerID { get; set; } + public string DocContent { get; set; } + public string DocType { get; set; } + public string DocTypeName { get; set; } + public string DocStyle { get; set; } + public string DocStyleName { get; set; } + public string DPType { get; set; } + public string DPTypeName { get; set; } + public string EditDate { get; set; } + public string TakeDate { get; set; } + public string UserID { get; set; } + public string teaname { get; set; } + public string TeaPhoto { get; set; } + public string UpFile { get; set; } + public string DocTitle { get; set; } + public string DocStatus { get; set; } + public string DocStatusName { get; set; } + /// 旧实现为 item.DocStep.ToString()(null → 空串)。 + public string DocStep { get; set; } + public string explain { get; set; } + /// 旧实现为 item.flowid.ToString()(null → 空串)。 + public string flowid { get; set; } + public int? DocShare { get; set; } + public int? DocFix { get; set; } + public int DocNum { get; set; } + public bool _disabled { get; set; } + public string FreeUserID { get; set; } + public string SendUserID { get; set; } + public int? freestepid { get; set; } + public string zhipai { get; set; } + public int? rid { get; set; } + public int? reid { get; set; } + public string grade { get; set; } + public string Examtype { get; set; } + /// 旧 OtherData.tempresultfile:附件明细(office 文件替换为 .pdf 预览名)。 + public List tempresultfile { get; set; } + } + + /// 抄送人详情(旧 SendDetailsList 的 {TeaName, TeaPhoto})。 + public class SendDetailItem + { + public string TeaName { get; set; } + public string TeaPhoto { get; set; } + } + + /// 附件项(旧 WY_list.filelist / MyListtoOne.tempresultfile)。 + public class DocPrintFileItem + { + public int SerID { get; set; } + public string FileTrueName { get; set; } + public string FileName { get; set; } + public int? FileSize { get; set; } + public string FileType { get; set; } + public int DownTimes { get; set; } + } + + /// 绑定流程下拉项(旧 Bindflowinfo 的 {SerID: string, flowname})。 + public class BindFlowOption + { + public string SerID { get; set; } + public string flowname { get; set; } + } + + /// 文印数量按月统计项(旧 List1 / Exceldaochu1 的 {dateyear, datemonth, num, DocNum})。 + public class MonthStatItem + { + public int dateyear { get; set; } + public int datemonth { get; set; } + public int num { get; set; } + public int DocNum { get; set; } + } + + /// 文印详情统计项(旧 List2 匿名投影)。 + public class DocDetailStatItem + { + public string UserID { get; set; } + public string teaname { get; set; } + public string DocContent { get; set; } + public string DocType { get; set; } + public string DocTypeName { get; set; } + public string DocStyle { get; set; } + public string DocStyleName { get; set; } + public string DPType { get; set; } + public string DPTypeName { get; set; } + public string EditDate { get; set; } + public string UpFile { get; set; } + public string DocTitle { get; set; } + public int DocNum { get; set; } + public string DocStatus { get; set; } + public string DocStatusName { get; set; } + } + + /// 文印数量按用户排名项(旧 List3 / Exceldaochu3 的 {UserID, teaname, num, DocNum})。 + public class UserRankItem + { + public string UserID { get; set; } + public string teaname { get; set; } + public int num { get; set; } + public int DocNum { get; set; } + } + + /// 按月/按用户统计明细项(旧 List4 / List5 匿名投影,editstatus 对应旧 Common.GetEditDeleteStatus)。 + public class StatListItem + { + public int SerID { get; set; } + public string DocContent { get; set; } + public string DocType { get; set; } + public string DocTypeName { get; set; } + public string DocStyle { get; set; } + public string DocStyleName { get; set; } + public string DPType { get; set; } + public string DPTypeName { get; set; } + public string EditDate { get; set; } + public string UserID { get; set; } + public string teaname { get; set; } + public string UpFile { get; set; } + public string DocTitle { get; set; } + public string DocStatus { get; set; } + public string DocStatusName { get; set; } + /// 旧 Common.GetEditDeleteStatus(status, step):一审(且非通过/拒绝态)可编辑 → false。 + public bool editstatus { get; set; } + public int? DocStep { get; set; } + public int? flowid { get; set; } + } +} diff --git a/src/YhCore.Model/Dto/WYSHRoleDtos.cs b/src/YhCore.Model/Dto/WYSHRoleDtos.cs new file mode 100644 index 0000000..dd7fa7f --- /dev/null +++ b/src/YhCore.Model/Dto/WYSHRoleDtos.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using YhCore.Entity; + +namespace YhCore.Model.Dto +{ + /// + /// 审核角色扩展对象(旧 WY_SHRoleP.cs 文件尾 WYSHRoleExt):角色 + 审核人 + 被审核人树形结构。 + /// SerID == 0 表示新增,否则更新。 + /// + public class WYSHRoleExt + { + public decimal SerID { get; set; } + public string SchSerId { get; set; } + public string SHRoleName { get; set; } + public List SHTea { get; set; } = new List(); + public List BSHTea { get; set; } = new List(); + public DateTime? inserttime { get; set; } + public string writor { get; set; } + } + + /// 步骤 + 审核教师(旧 WYStepSHTea,SHState 形如 "待审核_0" / "同意_1")。 + public class WYStepSHTea + { + public int SerID { get; set; } + public int? flowid { get; set; } + public string StepName { get; set; } + public string SHState { get; set; } + public string SHtime { get; set; } + public int? ordernum { get; set; } + public int? prestep { get; set; } + public int? cond { get; set; } + public List SHTea { get; set; } = new List(); + } + + /// 用户可申请的流程(旧 WYFlowForUser,getLCOfUser / editDocPrint 的 data 段)。 + public class WYFlowForUser + { + public decimal SerID { get; set; } + public string txt { get; set; } + public string flowname { get; set; } + public string UserID { get; set; } + public string UserName { get; set; } + public string DateNowStr { get; set; } + public string ChaoSend { get; set; } + public string ChaoSendName { get; set; } + public int? StepSend { get; set; } + public string WYPageGG { get; set; } + public List StepSHTea { get; set; } = new List(); + } + + /// 审核轨迹展示项(旧 FreeShenheinfoSHRole)。 + public class FreeShenheinfoSHRole + { + public string steptname { get; set; } + public string steptstatus { get; set; } + public string photo { get; set; } + public string Remark { get; set; } + public string actiondate { get; set; } + public string actionstatus { get; set; } + public List SignaturePic { get; set; } = new List(); + } + + /// 审核角色流程列表项(旧 ListFlow 投影,含 ChaoSend/StepSend/WYPageGG)。 + public class SHRoleFlowListItem + { + public string SerID { get; set; } + public string flowname { get; set; } + public string deptid { get; set; } + public string typeid { get; set; } + public int enable { get; set; } + public string enablename { get; set; } + public string miaoshu { get; set; } + public string deitdate { get; set; } + public string ChaoSend { get; set; } + public int? StepSend { get; set; } + public string WYPageGG { get; set; } + } + + /// 步骤详情(旧 getStepInfo:Data=步骤,OtherData=步骤绑定的审核角色)。 + public class SHRoleStepInfo + { + public List Steps { get; set; } = new List(); + public List GroupUsers { get; set; } = new List(); + } + + /// + /// 审核轨迹结果(旧 getshenheFlow:Data=时间线,OtherData=步骤+审核教师明细,Page=当前步骤列表)。 + /// 新统一响应无 OtherData/Page 槽位,三部分合并为一个对象返回。 + /// + public class SHRoleShenheFlowResult + { + public List Flow { get; set; } = new List(); + public List Steps { get; set; } = new List(); + public List CurrentStep { get; set; } = new List(); + } + + /// + /// 文印规格表结果(旧 getdocOftable:Data=规格表,OtherData=每规格默认页数,Page=首列合并行数数组)。 + /// 合并为一个对象返回。 + /// + public class SHRoleDocOfTableResult + { + public List Table { get; set; } = new List(); + public List MinPageNum { get; set; } = new List(); + public List RowSpan { get; set; } = new List(); + } + + /// + /// 审核角色视角的文印列表项(旧 MyListSHRole / totalListSHRole / SendRdListSHRole / SPListSHRole 投影, + /// 各列表仅填充自身所需字段,其余为默认值)。 + /// + public class SHRoleDocPrintListItem + { + public int SerID { get; set; } + public string DocContent { get; set; } + public string DocType { get; set; } + public string DocTypeName { get; set; } + public string DocStyle { get; set; } + public string DocStyleName { get; set; } + public string DPType { get; set; } + public string DPTypeName { get; set; } + public string EditDate { get; set; } + public string TakeDate { get; set; } + public string UserID { get; set; } + public string teaname { get; set; } + public string TeaPhoto { get; set; } + public string UpFile { get; set; } + public string DocTitle { get; set; } + public string DocStatus { get; set; } + public string DocStatusName { get; set; } + public bool _disabled { get; set; } + public int? DocStep { get; set; } + public int? flowid { get; set; } + public string FreeUserID { get; set; } + public string SendUserID { get; set; } + public int? freestepid { get; set; } + public int? DocShare { get; set; } + public string explain { get; set; } + public int? rid { get; set; } + public int? reid { get; set; } + public string DWName { get; set; } + public string ZBName { get; set; } + public string grade { get; set; } + public string Examtype { get; set; } + public int DocNum { get; set; } + public int? DocFix { get; set; } + public string zhipai { get; set; } + public List DocPrintNum { get; set; } = new List(); + public string SHRName { get; set; } + public bool isshowje { get; set; } + public string ChkDate { get; set; } + public string firstSHDate { get; set; } + public string firstSHDateTiket { get; set; } + public int FJDownInfo { get; set; } + /// 附件明细(仅 MyListtoOneSHRole 填充,旧 OtherData.tempresultfile 并入 data)。 + public List tempresultfile { get; set; } + } + + /// + /// 按金额取步骤结果(旧 getStepByJE:Data=可见步骤+审核教师,OtherData=通过金额过滤的全部步骤)。 + /// 新统一响应无 OtherData 槽位,两部分合并为一个对象返回。 + /// + public class SHRoleStepByJEResult + { + public List StepSHTea { get; set; } = new List(); + public List Steps { get; set; } = new List(); + } + + /// 金额重算记录列表项(旧 getRegetJERd 投影)。 + public class SHRoleRegetJERdItem + { + public string teaname { get; set; } + public string writor { get; set; } + public int SerID { get; set; } + public int? DcoPrintID { get; set; } + public string DocTitle { get; set; } + public string DocContent { get; set; } + public string DocInfo { get; set; } + public string EditDatetime { get; set; } + public string datestr { get; set; } + public string type { get; set; } + public string typename { get; set; } + public string SchSerID { get; set; } + public string UnitPrice { get; set; } + } + + /// editDocPrint 入参(旧 form:isedit + data + docdata + docnumdata)。 + public class WYSHRoleEditDocPrintDto + { + public int isedit { get; set; } + public WYFlowForUser data { get; set; } + public WYDocPrintSaveDto docdata { get; set; } + public List docnumdata { get; set; } = new List(); + } + + /// editSHTea 入参(旧 form:SHRoleID + listSHTeaTxt)。 + public class EditSHTeaDto + { + public decimal SHRoleID { get; set; } + public List listSHTea { get; set; } = new List(); + } + + /// editBSHTea 入参(旧 form:SHRoleID + listBSHTeaTxt)。 + public class EditBSHTeaDto + { + public decimal SHRoleID { get; set; } + public List listBSHTea { get; set; } = new List(); + } + + /// delSHRole 入参(旧 form:listSHRoleIDTxt)。 + public class DelSHRoleDto + { + public List listSHRoleID { get; set; } = new List(); + } + + /// SaveSignature 入参(旧 form:pic)。 + public class SaveSignatureDto + { + public string pic { get; set; } + } + + /// TuiHui 入参(旧 form:DocID + cont)。 + public class TuiHuiDto + { + public decimal DocID { get; set; } + public string cont { get; set; } + } + + /// regetJE 入参(旧 form:DocID 逗号串 + type)。 + public class RegetJEDto + { + public string DocID { get; set; } + public string type { get; set; } + } +} diff --git a/src/YhCore.Model/Vo/PageVO.cs b/src/YhCore.Model/Vo/PageVO.cs new file mode 100644 index 0000000..39ce88c --- /dev/null +++ b/src/YhCore.Model/Vo/PageVO.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace YhCore.Model.Vo +{ + /// + /// 分页返回对象(对应 Java 版 PageVO,字段与 Spring Page 序列化结构保持一致, + /// 保证前端零改动切换)。 + /// + public class PageVO + { + /// 当前页数据 + public List Content { get; set; } = new List(); + + /// 总记录数 + public long TotalElements { get; set; } + + /// 总页数 + public int TotalPages { get; set; } + + /// 当前页码(从 0 开始,与 Spring Pageable 一致) + public int Number { get; set; } + + /// 每页大小 + public int Size { get; set; } + + public static PageVO Of(List content, long totalElements, int number, int size) + { + return new PageVO + { + Content = content ?? new List(), + TotalElements = totalElements, + TotalPages = size > 0 ? (int)((totalElements + size - 1) / size) : 0, + Number = number, + Size = size + }; + } + + public static PageVO Empty(int number = 0, int size = 20) => Of(new List(), 0, number, size); + } +} diff --git a/src/YhCore.Model/YhCore.Model.csproj b/src/YhCore.Model/YhCore.Model.csproj new file mode 100644 index 0000000..408c770 --- /dev/null +++ b/src/YhCore.Model/YhCore.Model.csproj @@ -0,0 +1,16 @@ + + + + net5.0 + YhCore.Model + YhCore.Model + 9.0 + disable + + + + + + + + diff --git a/src/YhCore.Repository/Dapper/DapperChannel.cs b/src/YhCore.Repository/Dapper/DapperChannel.cs new file mode 100644 index 0000000..07ece49 --- /dev/null +++ b/src/YhCore.Repository/Dapper/DapperChannel.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Microsoft.EntityFrameworkCore; + +namespace YhCore.Repository.Dapper +{ + /// + /// Dapper 通道(对应 Java 显式引入 JdbcTemplate 的场景,如 backupAllSnapshot 批量快照插入)。 + /// 与 EF Core 共用同一连接(YHDbContext.Database.GetDbConnection),连接生命周期由 DbContext 管理。 + /// + public class DapperChannel + { + private readonly YHDbContext _context; + + public DapperChannel(YHDbContext context) + { + _context = context; + } + + private IDbConnection Connection => _context.Database.GetDbConnection(); + + /// 执行非查询 SQL(增删改),返回受影响行数 + public async Task ExecuteAsync(string sql, object param = null) + { + return await Connection.ExecuteAsync(sql, param); + } + + /// 查询单值(如 SELECT COUNT(*)) + public async Task ExecuteScalarAsync(string sql, object param = null) + { + return await Connection.ExecuteScalarAsync(sql, param); + } + + /// 查询列表 + public async Task> QueryAsync(string sql, object param = null) + { + return await Connection.QueryAsync(sql, param); + } + + /// 查询单条 + public async Task QueryFirstOrDefaultAsync(string sql, object param = null) + { + return await Connection.QueryFirstOrDefaultAsync(sql, param); + } + + /// + /// 批量快照插入(对应 Java backupAllSnapshot 的批量插入场景)。 + /// 逐条循环 Insert 的通用实现;数据量大时可替换为 SqlBulkCopy / 表值参数提升性能。 + /// + public async Task BulkInsertAsync(string table, IEnumerable rows) + { + var affected = 0; + foreach (var row in rows) + { + affected += await Connection.ExecuteAsync( + $"INSERT INTO {table} ({string.Join(",", GetColumns(row))}) " + + $"VALUES ({string.Join(",", GetColumns(row).Select(c => "@" + c))})", row); + } + + return affected; + } + + private static string[] GetColumns(T row) + { + var props = row?.GetType().GetProperties() ?? System.Array.Empty(); + return props.Select(p => p.Name).ToArray(); + } + } +} diff --git a/src/YhCore.Repository/DatabaseInitializer.cs b/src/YhCore.Repository/DatabaseInitializer.cs new file mode 100644 index 0000000..6b27c3f --- /dev/null +++ b/src/YhCore.Repository/DatabaseInitializer.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace YhCore.Repository +{ + /// + /// 数据库自动补表初始化器:对比 EF Core 实体模型与数据库实际表, + /// 把「实体里有而数据库没有」的表自动创建(仅补建缺失表,不触碰已存在表; + /// 已存在表的结构变更仍走 sqlscript 脚本,与既有约定一致)。 + /// 免除新增实体后手写建表脚本。 + /// + public static class DatabaseInitializer + { + /// + /// 补建缺失表。返回本次创建的表名列表(空 = 无缺失表)。 + /// 仅支持 SQL Server(对象存在性守卫/脚本语法为 SQL Server 专用); + /// 其他 Provider(如 MySQL)跳过不动作,避免破坏其脚本语法。 + /// + public static List EnsureMissingTables(YHDbContext context) + { + if (!context.Database.IsSqlServer()) + { + return new List(); + } + + // 提前捕获连接串:此后不依赖 EF 托管连接(GenerateCreateScript 等内部操作 + // 可能干扰 EF 连接状态),DDL 用独立 SqlConnection 执行 + var connectionString = context.Database.GetDbConnection().ConnectionString; + + // 1. 实体模型期望的表(Keyless 实体不建表,与 EnsureCreated 语义一致) + var expected = context.Model.GetEntityTypes() + .Where(e => e.FindPrimaryKey() != null) + .Select(e => e.GetTableName()) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (expected.Count == 0) + { + return new List(); + } + + // 2. 数据库不存在时:EnsureCreated 一次创建库 + 全部表 + if (!context.Database.CanConnect()) + { + if (context.Database.EnsureCreated()) + { + return expected.ToList(); + } + } + + // 3. 数据库实际存在的表 + var existing = GetExistingTables(context); + + // 4. 差集:缺失的表 + var missing = expected.Except(existing, StringComparer.OrdinalIgnoreCase).ToList(); + if (missing.Count == 0) + { + return new List(); + } + + // 5. 从 EF 生成的建库脚本中提取缺失表的 DDL 并执行 + return ExecuteMissingTableScripts(context, missing, connectionString); + } + + private static HashSet GetExistingTables(YHDbContext context) + { + var tables = new HashSet(StringComparer.OrdinalIgnoreCase); + var conn = context.Database.GetDbConnection(); + var wasOpen = conn.State == ConnectionState.Open; + if (!wasOpen) + { + conn.Open(); + } + + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + tables.Add(reader.GetString(0)); + } + } + finally + { + // 只关闭不释放:连接由 EF 托管,外部释放会破坏其内部状态 + if (!wasOpen) + { + conn.Close(); + } + } + + return tables; + } + + private static List ExecuteMissingTableScripts( + YHDbContext context, List missing, string connectionString) + { + var missingSet = missing.ToHashSet(StringComparer.OrdinalIgnoreCase); + var script = context.Database.GenerateCreateScript(); + + // EF 生成的脚本以独立 GO 行为语句分隔;无 GO 时整体视为一段 + var segments = Regex.Split(script, @"(?m)^\s*GO\s*$", RegexOptions.IgnoreCase) + .Select(s => s.Trim()) + .Where(s => s.Length > 0); + + var statements = new List(); + foreach (var segment in segments) + { + var tableMatch = Regex.Match(segment, + @"^\s*CREATE\s+TABLE\s+(?:\[([^\]]+)\]\.)?\[([^\]]+)\]", + RegexOptions.IgnoreCase); + if (tableMatch.Success) + { + var schema = tableMatch.Groups[1].Success ? tableMatch.Groups[1].Value : "dbo"; + var name = tableMatch.Groups[2].Value; + if (missingSet.Contains(name)) + { + // 守卫包裹:竞态/重复执行安全(与迁移幂等脚本语义一致) + statements.Add( + $"IF OBJECT_ID(N'[{schema}].[{name}]', N'U') IS NULL\nBEGIN\n{segment}\nEND;"); + } + + continue; + } + + var indexMatch = Regex.Match(segment, + @"^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+\[([^\]]+)\]\s+ON\s+(?:\[([^\]]+)\]\.)?\[([^\]]+)\]", + RegexOptions.IgnoreCase); + if (indexMatch.Success) + { + var indexName = indexMatch.Groups[1].Value; + var schema = indexMatch.Groups[2].Success ? indexMatch.Groups[2].Value : "dbo"; + var tableName = indexMatch.Groups[3].Value; + if (missingSet.Contains(tableName)) + { + statements.Add( + $"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{indexName}' AND object_id = OBJECT_ID(N'[{schema}].[{tableName}]'))\nBEGIN\n{segment}\nEND;"); + } + } + } + + if (statements.Count == 0) + { + // 模型表与脚本段未匹配上(理论上不发生):保守起见不执行任何 DDL + return new List(); + } + + // 用独立 SqlConnection 执行 DDL(不依赖 EF 托管连接,避免其状态干扰) + using var conn = new SqlConnection(connectionString); + conn.Open(); + + using (var tx = conn.BeginTransaction()) + { + foreach (var sql in statements) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + tx.Commit(); + } + + return missing.ToList(); + } + } +} diff --git a/src/YhCore.Repository/Extensions/DataLayerServiceCollectionExtensions.cs b/src/YhCore.Repository/Extensions/DataLayerServiceCollectionExtensions.cs new file mode 100644 index 0000000..3068857 --- /dev/null +++ b/src/YhCore.Repository/Extensions/DataLayerServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using YhCore.Repository.Dapper; + +namespace YhCore.Repository.Extensions +{ + /// + /// 数据层注册扩展。按配置 DatabaseProvider 选择数据库提供方: + /// "SqlServer"(默认,主库)或 "MySql"(可切换)。 + /// + public static class DataLayerServiceCollectionExtensions + { + public static IServiceCollection AddDataLayer(this IServiceCollection services, IConfiguration configuration) + { + var provider = configuration["DatabaseProvider"]; + + services.AddDbContext(options => + { + if (string.Equals(provider, "MySql", StringComparison.OrdinalIgnoreCase)) + { + // MySQL 切换通道(对应 Java 版 application.yml 的 MySQL 数据源) + var conn = configuration.GetConnectionString("Mysql"); + options.UseMySql(conn, ServerVersion.AutoDetect(conn)); + } + else + { + // 默认主库:SQL Server(EduCloudDocPrint) + options.UseSqlServer(configuration.GetConnectionString("SqlServer")); + } + }); + + return services; + } + + /// + /// 批量注册泛型仓储与 Dapper 通道(对应 Java Repository 包由 Spring 自动装配)。 + /// 注入方式:IRepository<WY_DocPrint> / DapperChannel。 + /// + public static IServiceCollection AddRepositories(this IServiceCollection services) + { + services.AddScoped(typeof(IRepository<>), typeof(RepositoryBase<>)); + services.AddScoped(); + return services; + } + } +} diff --git a/src/YhCore.Repository/IRepository.cs b/src/YhCore.Repository/IRepository.cs new file mode 100644 index 0000000..bff9c53 --- /dev/null +++ b/src/YhCore.Repository/IRepository.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; +using YhCore.Model.Vo; + +namespace YhCore.Repository +{ + /// + /// 泛型仓储接口(对应 Java Spring Data JPA Repository)。 + /// + public interface IRepository where T : class + { + // ---- 读取 ---- + Task GetByIdAsync(object id); + Task> GetAllAsync(); + Task> FindAsync(Expression> predicate); + Task FirstOrDefaultAsync(Expression> predicate); + Task AnyAsync(Expression> predicate); + Task CountAsync(Expression> predicate = null); + + /// 分页查询(页码从 0 开始),对应 PageUtil + PageVO + Task> PageAsync(Expression> predicate, int page, int size, + Expression> orderBy = null, bool asc = true); + + // ---- 写入 ---- + Task AddAsync(T entity); + Task AddRangeAsync(IEnumerable entities); + Task UpdateAsync(T entity); + Task RemoveAsync(T entity); + Task RemoveAsync(object id); + Task SaveChangesAsync(); + } +} diff --git a/src/YhCore.Repository/RepositoryBase.cs b/src/YhCore.Repository/RepositoryBase.cs new file mode 100644 index 0000000..a59a822 --- /dev/null +++ b/src/YhCore.Repository/RepositoryBase.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using YhCore.Model.Vo; + +namespace YhCore.Repository +{ + /// + /// 泛型仓储基类(对应 Java Spring Data JPA 的 SimpleJpaRepository)。 + /// 基于 EF Core IQueryable,分页查询下推到数据库执行。 + /// + public class RepositoryBase : IRepository where T : class + { + protected readonly YHDbContext Context; + protected readonly DbSet DbSet; + + public RepositoryBase(YHDbContext context) + { + Context = context; + DbSet = context.Set(); + } + + public virtual async Task GetByIdAsync(object id) + { + return await DbSet.FindAsync(id); + } + + public virtual async Task> GetAllAsync() + { + return await DbSet.AsNoTracking().ToListAsync(); + } + + public virtual async Task> FindAsync(Expression> predicate) + { + return await DbSet.AsNoTracking().Where(predicate).ToListAsync(); + } + + public virtual async Task FirstOrDefaultAsync(Expression> predicate) + { + return await DbSet.AsNoTracking().FirstOrDefaultAsync(predicate); + } + + public virtual async Task AnyAsync(Expression> predicate) + { + return await DbSet.AnyAsync(predicate); + } + + public virtual async Task CountAsync(Expression> predicate = null) + { + return predicate == null + ? await DbSet.LongCountAsync() + : await DbSet.LongCountAsync(predicate); + } + + public virtual async Task> PageAsync( + Expression> predicate, + int page, int size, + Expression> orderBy = null, + bool asc = true) + { + if (page < 0) page = 0; + if (size <= 0) size = 20; + + IQueryable query = DbSet.AsNoTracking(); + if (predicate != null) + { + query = query.Where(predicate); + } + + var total = await query.LongCountAsync(); + + IOrderedQueryable ordered; + if (orderBy != null) + { + ordered = asc ? query.OrderBy(orderBy) : query.OrderByDescending(orderBy); + } + else + { + // 无显式排序时按主键约定排序(Id → SerID),保证 Skip/Take 稳定 + var keyName = typeof(T).GetProperty("Id") != null ? "Id" : "SerID"; + ordered = query.OrderBy(e => EF.Property(e, keyName)); + } + + var items = await ordered.Skip(page * size).Take(size).ToListAsync(); + return PageVO.Of(items, total, page, size); + } + + public virtual async Task AddAsync(T entity) + { + var entry = await DbSet.AddAsync(entity); + return entry.Entity; + } + + public virtual async Task AddRangeAsync(IEnumerable entities) + { + await DbSet.AddRangeAsync(entities); + } + + public virtual Task UpdateAsync(T entity) + { + DbSet.Update(entity); + return Task.CompletedTask; + } + + public virtual Task RemoveAsync(T entity) + { + DbSet.Remove(entity); + return Task.CompletedTask; + } + + public virtual async Task RemoveAsync(object id) + { + var entity = await GetByIdAsync(id); + if (entity != null) + { + DbSet.Remove(entity); + } + } + + public virtual async Task SaveChangesAsync() + { + return await Context.SaveChangesAsync(); + } + } +} diff --git a/src/YhCore.Repository/Specification/Specification.cs b/src/YhCore.Repository/Specification/Specification.cs new file mode 100644 index 0000000..205b798 --- /dev/null +++ b/src/YhCore.Repository/Specification/Specification.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace YhCore.Repository.Specification +{ + /// + /// 动态条件表达式组合器(对应 Java Repository/specification 包的轻量实现)。 + /// 用于按需拼装 where 条件: + /// + /// var spec = Specification.True<SchClass>() + /// .And(c => c.SchoolId == schId) + /// .AndIf(!string.IsNullOrEmpty(name), c => c.Name.Contains(name)); + /// + /// + public static class Specification + { + public static Expression> True() => _ => true; + + public static Expression> False() => _ => false; + + public static Expression> And( + this Expression> left, Expression> right) + { + if (left == null) return right; + if (right == null) return left; + + var body = Expression.AndAlso(left.Body, right.Body.ReplaceParameter(right.Parameters[0], left.Parameters[0])); + return Expression.Lambda>(body, left.Parameters); + } + + public static Expression> Or( + this Expression> left, Expression> right) + { + if (left == null) return right; + if (right == null) return left; + + var body = Expression.OrElse(left.Body, right.Body.ReplaceParameter(right.Parameters[0], left.Parameters[0])); + return Expression.Lambda>(body, left.Parameters); + } + + public static Expression> Not(this Expression> expr) + { + if (expr == null) return null; + return Expression.Lambda>(Expression.Not(expr.Body), expr.Parameters); + } + + /// 条件成立时才追加条件(Java Specification 常用写法) + public static Expression> AndIf( + this Expression> spec, bool condition, Expression> conditionExpr) + { + return condition ? spec.And(conditionExpr) : spec; + } + + /// 条件不成立时才追加条件 + public static Expression> AndIfNot( + this Expression> spec, bool condition, Expression> conditionExpr) + { + return spec.AndIf(!condition, conditionExpr); + } + + private static Expression ReplaceParameter(this Expression expression, ParameterExpression source, ParameterExpression target) + { + return new ParameterReplacer(source, target).Visit(expression); + } + + private class ParameterReplacer : ExpressionVisitor + { + private readonly ParameterExpression _source; + private readonly ParameterExpression _target; + + public ParameterReplacer(ParameterExpression source, ParameterExpression target) + { + _source = source; + _target = target; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + return node == _source ? _target : base.VisitParameter(node); + } + } + } +} diff --git a/src/YhCore.Repository/YHDbContext.cs b/src/YhCore.Repository/YHDbContext.cs new file mode 100644 index 0000000..fe82075 --- /dev/null +++ b/src/YhCore.Repository/YHDbContext.cs @@ -0,0 +1,102 @@ +using System.Linq; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using YhCore.Entity; + +namespace YhCore.Repository +{ + /// + /// EF Core 数据上下文。同时支持 SQL Server(主库)与 MySQL(可切换), + /// 提供方在 Startup 通过 AddDataLayer 按 DatabaseProvider 配置选择。 + /// 生产环境禁用自动迁移,结构变更走 sqlscript 脚本。 + /// + public class YHDbContext : DbContext + { + public YHDbContext(DbContextOptions options) : base(options) + { + } + + // 文印业务 + public DbSet WY_DocPrint { get; set; } + public DbSet WY_DocPrint_Num { get; set; } + public DbSet WY_DocPrint_SH { get; set; } + public DbSet WY_reGetJERd { get; set; } + public DbSet WY_SchZyBit { get; set; } + + // 审批流 + public DbSet Common_flowinfo { get; set; } + public DbSet Common_flowstep { get; set; } + public DbSet Common_flowtranctInfo { get; set; } + public DbSet Common_flowuser { get; set; } + public DbSet Common_freeflowinfo { get; set; } + public DbSet Common_freeGroupuser { get; set; } + public DbSet Common_Groupuser { get; set; } + public DbSet Common_Groupuser_SHRole { get; set; } + public DbSet Common_SendUserinfo { get; set; } + public DbSet Common_UserFlowZhipai { get; set; } + + // 审核角色 + public DbSet WY_SHRole { get; set; } + public DbSet WY_SHRole_BSHTea { get; set; } + public DbSet WY_SHRole_SHTea { get; set; } + + // 基础数据 + public DbSet Common_Dictionary { get; set; } + public DbSet Common_DictionaryOrd { get; set; } + public DbSet Common_UpFiles { get; set; } + + // 日志 + public DbSet A_DataLog { get; set; } + public DbSet Base_Log { get; set; } + public DbSet AppMsgRecord { get; set; } + // 系统审计日志(HTTP 接口级,对齐 Java sys_operation_log,表名在 OnModelCreating 映射) + public DbSet SysOperationLog { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // 主键约定:实体为旧 EF6 EDMX 生成的纯 POCO(无 [Key] 标注), + // 统一按属性名推断主键——优先 Id(不区分大小写,兼容小写 id),其次 SerID; + // 两者皆无的实体按 Keyless 处理(仅用于查询)。 + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (entityType.FindPrimaryKey() != null) + { + continue; + } + + var clrType = entityType.ClrType; + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + var keyProperty = clrType.GetProperty("Id", flags) + ?? clrType.GetProperty("SerID", flags); + + if (keyProperty != null) + { + modelBuilder.Entity(clrType).HasKey(keyProperty.Name); + } + else + { + modelBuilder.Entity(clrType).HasNoKey(); + } + } + + // decimal 主键(多数表的 SerID)EF Core 约定不自增; + // 若数据库对应列为 IDENTITY,需在此补充 ValueGeneratedOnAdd 配置。 + // WY_DocPrint.SerID:旧 EF6 依赖数据库 IDENTITY 回填新增单据号,此处显式声明。 + // WY_SHRole* / WY_DocPrint_SH / WY_DocPrint_Num / WY_reGetJERd / Common_Dictionary + // 同属旧库 IDENTITY 列(旧代码新增时均不显式赋值 SerID),一并补充。 + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(e => e.SerID).ValueGeneratedOnAdd(); + + // 系统审计日志表名映射:sys_operation_log(与 Java bschclassmanage 同表结构,双轨对齐) + modelBuilder.Entity().ToTable("sys_operation_log"); + } + } +} diff --git a/src/YhCore.Repository/YhCore.Repository.csproj b/src/YhCore.Repository/YhCore.Repository.csproj new file mode 100644 index 0000000..51b7b72 --- /dev/null +++ b/src/YhCore.Repository/YhCore.Repository.csproj @@ -0,0 +1,25 @@ + + + + net5.0 + YhCore.Repository + YhCore.Repository + 9.0 + disable + + + + + + + + + + + + + + + + + diff --git a/src/YhCore.Service/Impl/DictionaryService.cs b/src/YhCore.Service/Impl/DictionaryService.cs new file mode 100644 index 0000000..6fbfa5b --- /dev/null +++ b/src/YhCore.Service/Impl/DictionaryService.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Yh.Platform.Abstractions; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 公共字典服务实现(移植旧 BestSchool.Service.GetSysDict)。 + /// + public class DictionaryService : IDictionaryService + { + private readonly IRepository _dic; + + public DictionaryService(IRepository dic) + { + _dic = dic; + } + + public async Task> GetDictListAsync(string typeId, string schId) + { + var has = await _dic.AnyAsync(d => d.typeID == typeId); + if (!has) + { + throw new DException("尚未添加发文部门,请联系管理员添加!"); + } + + var list = await _dic.FindAsync(d => + d.typeID == typeId && + (string.IsNullOrEmpty(schId) || d.Schserid == schId) && + d.Enable == 1); + + return list + .OrderBy(d => DictionaryTypeHelper.ParseSort(d.Des)) + .Select(d => new DicOption { K = d.CodeID, V = d.Name }) + .ToList(); + } + + public Task> GetDicTypeListAsync() + { + var list = DictionaryTypeHelper.AllTypes + .Where(t => t.Id != "DEPT") + .Select(t => new DicOption { K = t.Id, V = t.Name }) + .ToList(); + return Task.FromResult(list); + } + + public async Task> GetDictMangeListAsync(string typeId, string schId) + { + var has = await _dic.AnyAsync(d => d.typeID == typeId); + if (!has) + { + throw new DException("字典类型错误,未找到该字典类型"); + } + + var list = await _dic.FindAsync(d => + d.typeID == typeId && + (string.IsNullOrEmpty(schId) || d.Schserid == schId)); + + return list + .OrderBy(d => string.IsNullOrEmpty(d.Des) ? 999 : DictionaryTypeHelper.ParseSort(d.Des)) + .Select(d => new DicManageItem + { + SerID = d.SerID, + CodeID = d.CodeID, + Name = d.Name, + Des = d.Des, + Enable = d.Enable + }) + .ToList(); + } + + public async Task AddDicAsync(Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + if (string.IsNullOrWhiteSpace(model.Name)) throw new DException("输入字符串格式不正确!"); + + var listed = await _dic.AnyAsync(b => + b.typeID == model.typeID && + (b.Schserid == model.Schserid || b.Schserid == null || b.Schserid == "") && + b.Name == model.Name && b.Enable != 3); + + if (listed || await IsDicnameAsync(model.typeID, model.Name)) + { + throw new DException("已存在该分类,请重新填写!"); + } + + var candidates = await _dic.FindAsync(x => + x.typeID == model.typeID && x.Enable != 3 && + (x.Schserid == model.Schserid || x.Schserid == null || x.Schserid == "")); + + if (candidates.Count == 0) + { + model.Sort = 1; + model.Des = "1"; + } + else + { + var maxSort = candidates.Max(c => c.Sort ?? (int)c.SerID); + model.Sort = maxSort + 1; + model.Des = model.Sort.ToString(); + } + + model.Enable = 1; + model.CodeID = model.typeID + model.Sort.ToString(); + + await _dic.AddAsync(model); + await _dic.SaveChangesAsync(); + } + + public async Task UpdateDicAsync(Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + + var dup = await _dic.AnyAsync(m => + m.SerID != model.SerID && m.Name == model.Name && + (m.Schserid == model.Schserid || m.Schserid == null) && m.Enable != 3); + + if (dup || await IsDicnameAsync(model.typeID, model.Name)) + { + throw new DException("已存在该分类,请重新填写!"); + } + + var obj = await _dic.FirstOrDefaultAsync(x => x.SerID == model.SerID); + if (obj == null) throw new DException("未找到要更新的分类!"); + + obj.Name = model.Name; + obj.Des = model.Des; + obj.Sort = model.Sort; + obj.Enable = model.Enable; + + await _dic.UpdateAsync(obj); + await _dic.SaveChangesAsync(); + } + + public async Task EditDicAsync(Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + + var current = await _dic.FirstOrDefaultAsync(m => m.SerID == model.SerID); + if (current == null) throw new DException("未找到要编辑的分类!"); + + var dup = await _dic.AnyAsync(m => + m.SerID != model.SerID && m.Enable != 3 && m.typeID == current.typeID && + m.Name == model.Name && (m.Schserid == model.Schserid || m.Schserid == null)); + + if (dup) + { + throw new DException("已存在该分类,请重新填写!"); + } + + current.Name = model.Name; + current.Code = model.Code; + current.Path = model.Path; + + await _dic.UpdateAsync(current); + await _dic.SaveChangesAsync(); + } + + /// 判断全局(Schserid 为空)是否已存在同名类别(对齐旧 IsDicname)。 + private async Task IsDicnameAsync(string typeId, string dicName) + { + var names = await _dic.FindAsync(b => b.typeID == typeId && b.Schserid == null); + return names.Select(a => a.Name).Distinct().Contains(dicName); + } + } +} diff --git a/src/YhCore.Service/Impl/DocPrintClassifyService.cs b/src/YhCore.Service/Impl/DocPrintClassifyService.cs new file mode 100644 index 0000000..306b610 --- /dev/null +++ b/src/YhCore.Service/Impl/DocPrintClassifyService.cs @@ -0,0 +1,338 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Yh.Platform.Abstractions; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 文印分类 / 印制版面 / 公共类别服务实现 + /// (移植旧 BestSchool.DocPrint.WY_list 分类部分 + WY_Classify)。 + /// + public class DocPrintClassifyService : IDocPrintClassifyService + { + private readonly IRepository _dic; + private readonly IRepository _ord; + private readonly IRepository _doc; + private readonly IRepository _groupuser; + private readonly IRepository _freegroup; + private readonly IRepository _zybit; + + public DocPrintClassifyService( + IRepository dic, + IRepository ord, + IRepository doc, + IRepository groupuser, + IRepository freegroup, + IRepository zybit) + { + _dic = dic; + _ord = ord; + _doc = doc; + _groupuser = groupuser; + _freegroup = freegroup; + _zybit = zybit; + } + + #region 文印分类 / 印制版面 + + public async Task AddDicAsync(Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + + var listed = await _dic.AnyAsync(b => + b.typeID == model.typeID && b.Name == model.Name && b.Schserid == model.Schserid); + if (listed) throw new DException("该分类已经存在!"); + + var max = (await _dic.FindAsync(x => x.typeID == model.typeID)) + .OrderByDescending(x => x.SerID).FirstOrDefault(); + model.Sort = max != null ? (max.Sort ?? 0) + 1 : 1; + model.Enable = 1; + model.CodeID = model.typeID + model.Sort; + model.typeName = "印制分类"; + + await _dic.AddAsync(model); + await _dic.SaveChangesAsync(); + } + + public async Task AddYZBMAsync(Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + + var listed = await _dic.AnyAsync(b => + b.typeID == model.typeID && b.Name == model.Name && b.Schserid == model.Schserid); + if (listed) throw new DException("该版面已经存在!"); + + var max = (await _dic.FindAsync(x => x.typeID == model.typeID)) + .OrderByDescending(x => x.SerID).FirstOrDefault(); + model.Sort = max != null ? (max.Sort ?? 0) + 1 : 1; + model.Enable = 1; + model.CodeID = model.typeID + model.Sort; + model.typeName = "印制版面"; + + await _dic.AddAsync(model); + await _dic.SaveChangesAsync(); + } + + public async Task> CategoryListAsync(string schSerId, string name) + => await QueryClassifyListAsync("印制分类", schSerId, name); + + public async Task> YZBMListAsync(string schSerId, string name) + => await QueryClassifyListAsync("印制版面", schSerId, name); + + private async Task> QueryClassifyListAsync(string typeName, string schSerId, string name) + { + var query = await _dic.FindAsync(m => m.typeName == typeName); + + if (!string.IsNullOrEmpty(schSerId)) + { + query = query.Where(m => m.Schserid == schSerId || m.Schserid == null || m.Schserid == "").ToList(); + } + + if (!string.IsNullOrEmpty(name)) + { + query = query.Where(m => m.Name != null && m.Name.IndexOf(name) >= 0).ToList(); + } + + return query + .OrderBy(x => string.IsNullOrEmpty(x.Des) ? 999 : DictionaryTypeHelper.ParseSort(x.Des)) + .Select(item => new CategoryItem + { + SerID = item.SerID, + CodeID = item.CodeID, + Name = item.Name, + Des = item.Des, + Enable = item.Enable, + Sort = item.Sort, + SchSerID = item.Schserid, + UserSchSerID = schSerId, + _disabled = string.IsNullOrEmpty(item.Schserid) + }) + .ToList(); + } + + public async Task DeleteDicAsync(decimal[] ids) + { + if (ids == null || ids.Length == 0) throw new DException("ids is not null"); + + var items = await _dic.FindAsync(m => ids.Contains(m.SerID)); + if (items.Count == 0) throw new DException("未找到要删除的分类!"); + + foreach (var item in items) + { + var used = await _doc.AnyAsync(m => m.DPType == item.CodeID); + if (used) throw new DException("该类别下有使用记录无法删除!"); + } + + foreach (var item in items) + { + await _dic.RemoveAsync(item); + } + + await _dic.SaveChangesAsync(); + } + + #endregion + + #region 公共类别 + + public async Task> DictionaryTypeListAsync(string schSerId, string name, bool showAll, string typeId) + { + var resultList = await _dic.FindAsync(m => m.typeID == typeId && m.Enable == 1); + + if (!string.IsNullOrEmpty(schSerId)) + { + resultList = resultList.Where(m => m.Schserid == schSerId || m.Schserid == null || m.Schserid == "").ToList(); + } + + if (!string.IsNullOrEmpty(name)) + { + resultList = resultList.Where(m => m.Name != null && m.Name.IndexOf(name) >= 0).ToList(); + } + + var ordList = await _ord.FindAsync(a => a.SchSerID == schSerId); + + var items = resultList.Select(item => new DictionaryTypeItem + { + Code = item.Code, + SerID = item.SerID, + CodeID = item.CodeID, + Name = item.Name, + Des = item.Des, + Enable = item.Enable, + Sort = item.Sort, + SchSerID = item.Schserid, + UserSchSerID = schSerId, + _disabled = string.IsNullOrEmpty(item.Schserid), + Status = false, + Open = ordList.FirstOrDefault(a => a.DicSerID == (int)item.SerID)?.Open ?? true, + Ord = ordList.FirstOrDefault(a => a.DicSerID == (int)item.SerID)?.Ord + ?? (string.IsNullOrEmpty(item.Des) ? 999 : DictionaryTypeHelper.ParseSort(item.Des)), + Path = item.Path + }) + .OrderBy(a => a.Ord) + .ToList(); + + if (!showAll) + { + items = items.Where(a => a.Open).ToList(); + } + + return items; + } + + public async Task DeleteDictionaryTypeAsync(string[] ids) + { + if (ids == null || ids.Length == 0) throw new DException("ids is not null"); + + var items = await _dic.FindAsync(m => ids.Contains(m.SerID.ToString())); + foreach (var item in items) + { + item.Enable = 3; + } + + await _dic.SaveChangesAsync(); + } + + public async Task SetDictionaryOrdAsync(string schSerId, int dicSerId, bool show) + { + var obj = await _ord.FirstOrDefaultAsync(m => m.DicSerID == dicSerId && m.SchSerID == schSerId); + if (obj == null) + { + await _ord.AddAsync(new Common_DictionaryOrd + { + DicSerID = dicSerId, + Open = show, + SchSerID = schSerId + }); + } + else + { + obj.Open = show; + await _ord.UpdateAsync(obj); + } + + await _ord.SaveChangesAsync(); + } + + public async Task OrderDicAsync(string ids, string schSerId) + { + if (string.IsNullOrEmpty(ids)) throw new DException("ids is not null"); + + var ssids = ids.Split(',').Select(a => int.Parse(a)).ToArray(); + var list = await _ord.FindAsync(m => m.SchSerID == schSerId); + + var i = 0; + foreach (var id in ssids) + { + var obj = list.FirstOrDefault(a => a.DicSerID == id && a.SchSerID == schSerId); + if (obj == null) + { + await _ord.AddAsync(new Common_DictionaryOrd + { + DicSerID = id, + Open = true, + SchSerID = schSerId, + Ord = i + }); + } + else + { + obj.Ord = i; + await _ord.UpdateAsync(obj); + } + + i++; + } + + await _ord.SaveChangesAsync(); + } + + #endregion + + #region 文印资源开关 / 结果链接 / 待审角标 + + /// + /// 结果链接:旧实现调用资源平台 ziyuan 的 /SaveResource/Batch 生成分享链接, + /// 新项目未接入资源平台,按「未配置 ziyuan」的旧分支降级返回 false。 + /// + public Task GetResultUrlAsync(int dataId, int uid, int schId, string schSerId) + { + return Task.FromResult(false); + } + + public async Task GetSchZyBitAsync(string schSerId) + { + // 旧实现仅在 ziyuan 配置存在时返回库值、否则 false;新项目未接入 ziyuan,直接返回库内开关值。 + var bit = await _zybit.FirstOrDefaultAsync(a => a.SchSerID == schSerId); + return bit?.ziyuanbit ?? true; + } + + public async Task SetSchZyBitAsync(string schSerId, bool zyBit) + { + var obj = await _zybit.FirstOrDefaultAsync(a => a.SchSerID == schSerId); + if (obj == null) + { + await _zybit.AddAsync(new WY_SchZyBit { SchSerID = schSerId, ziyuanbit = zyBit }); + } + else + { + obj.ziyuanbit = zyBit; + await _zybit.UpdateAsync(obj); + } + + await _zybit.SaveChangesAsync(); + } + + public async Task GetShnheCountAsync(string userId, string schSerId) + { + if (!int.TryParse(userId, out var userid)) + { + return 0; + } + + var docs = await _doc.FindAsync(a => a.SchSerID == schSerId && a.DocStatus == "0"); + var fixedGroupIds = (await _groupuser.FindAsync(b => b.userid == userid)) + .Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList(); + var freeGroupIds = (await _freegroup.FindAsync(b => b.userid == userid)) + .Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList(); + + var result = new HashSet(); + foreach (var a in docs) + { + bool match = false; + + // 固定流程:DocStep 命中本人组 + if (a.flowid != null && a.zhipai == null && a.DocStep.HasValue && fixedGroupIds.Contains(a.DocStep.Value)) + { + match = true; + } + + // 指派:zhipai 含本人 + if (!match && a.zhipai != null && a.flowid != null && a.zhipai.Contains(userId)) + { + match = true; + } + + // 自由流程:freestepid 命中本人自由组 + if (!match && a.freestepid.HasValue && a.freestepid.Value > 0 && freeGroupIds.Contains(a.freestepid.Value)) + { + match = true; + } + + if (match) + { + result.Add(a.SerID); + } + } + + return result.Count; + } + + #endregion + } +} diff --git a/src/YhCore.Service/Impl/DocPrintStatisticsService.cs b/src/YhCore.Service/Impl/DocPrintStatisticsService.cs new file mode 100644 index 0000000..96b038a --- /dev/null +++ b/src/YhCore.Service/Impl/DocPrintStatisticsService.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Yh.SchClass.OpenApi; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 文印统计看板服务(移植旧 BestSchool.DocPrint.DocPrintStatistics,4 接口,ExportRecord 延后)。 + /// 旧 [NoAuth] 无用户上下文,schId 由前端传入;新库 SchSerID 已是 int 字符串,直接 schId.ToString() 匹配, + /// 不再依赖旧 GetSchSerID(int) 的 HTTP 转换。roleId 参数旧实现零引用,已删除。 + /// 旧 StatusArray = {"1","11"}(印刷中/已印刷)。 + /// + public class DocPrintStatisticsService : IDocPrintStatisticsService + { + private static readonly string[] StatusArray = { "1", "11" }; + + private readonly IRepository _doc; + private readonly IRepository _dic; + private readonly SchClassOpenApi _sch; + + public DocPrintStatisticsService( + IRepository doc, + IRepository dic, + SchClassOpenApi sch) + { + _doc = doc; + _dic = dic; + _sch = sch; + } + + public async Task> GetDocPrintCountAsync(int schId, DateTime? startDate = null, DateTime? endDate = null) + { + var schSerId = schId.ToString(); + + var list = (await _doc.FindAsync(a => + a.SchSerID == schSerId && StatusArray.Contains(a.DocStatus))).ToList(); + + if (startDate != null) + list = list.Where(a => a.EditDate >= startDate).ToList(); + if (endDate != null) + list = list.Where(a => a.EditDate <= endDate).ToList(); + + var count = list.Count; + var xhzsl = list.Select(a => a.DocNum).DefaultIfEmpty(0).Sum(); + + var doctypeId = list.GroupBy(a => a.DPType) + .Select(a => new { type = a.Key, sum = a.Sum(s => s.DocNum) }) + .OrderByDescending(a => a.sum).FirstOrDefault()?.type ?? ""; + + string yzfl = ""; + if (!string.IsNullOrEmpty(doctypeId)) + { + yzfl = (await _dic.FindAsync(g => + g.typeID == "YZFL" && g.CodeID == doctypeId && + (g.Schserid == schSerId || g.Schserid == null))) + .OrderBy(g => g.Enable).Select(g => g.Name).FirstOrDefault() ?? ""; + } + + return new List + { + new DocPrintCountItem { k = "文印数量(条)", v = count }, + new DocPrintCountItem { k = "消耗总数(张)", v = xhzsl }, + new DocPrintCountItem { k = "最多消耗类型", v = yzfl }, + }; + } + + public async Task> GetPieChartAsync(int schId, DateTime? startDate = null, DateTime? endDate = null) + { + var schSerId = schId.ToString(); + + var list = (await _doc.FindAsync(a => + a.SchSerID == schSerId && StatusArray.Contains(a.DocStatus))).ToList(); + + if (startDate != null) + list = list.Where(a => a.EditDate >= startDate).ToList(); + if (endDate != null) + list = list.Where(a => a.EditDate <= endDate).ToList(); + + var yzfl = (await _dic.FindAsync(g => + g.typeID == "YZFL" && (g.Schserid == schSerId || g.Schserid == null))) + .OrderBy(g => g.Enable).ToList(); + + var yslx = list.GroupBy(a => a.DPType) + .Select(a => new { k = a.Key, v = a.Sum(s => s.DocNum) }).ToList() + .Select(a => new PieChartKv { k = yzfl.FirstOrDefault(s => s.CodeID == a.k)?.Name, v = a.v }).ToList(); + + var ysxh = list.GroupBy(a => a.DPType) + .Select(a => new { k = a.Key, v = a.Count() }).ToList() + .Select(a => new PieChartKv { k = yzfl.FirstOrDefault(s => s.CodeID == a.k)?.Name, v = a.v }).ToList(); + + return new List + { + new PieChartSeries { title = "印刷类型", list = yslx }, + new PieChartSeries { title = "印刷消耗", list = ysxh }, + }; + } + + public async Task> GetDocPrintTypeAsync(int schId) + { + var schSerId = schId.ToString(); + + var list = await _dic.FindAsync(g => + g.typeID == "YZFL" && g.Enable == 1 && (g.Schserid == schSerId || g.Schserid == null)); + + return list.OrderBy(g => g.Des) + .Select(a => new DocPrintTypeItem { CodeID = a.CodeID, Name = a.Name }) + .ToList(); + } + + public async Task GetDocPrintListAsync(int schId, string docType, string likeName, int pageIndex, int pageSize, DateTime? startDate = null, DateTime? endDate = null) + { + var schSerId = schId.ToString(); + if (pageSize <= 0) pageSize = 10; + + var list = (await _doc.FindAsync(a => + a.SchSerID == schSerId && StatusArray.Contains(a.DocStatus))).ToList(); + + if (!string.IsNullOrEmpty(docType) && docType != "ALL") + list = list.Where(a => a.DPType == docType).ToList(); + + if (startDate != null) + list = list.Where(a => a.TakeDate >= startDate).ToList(); + if (endDate != null) + list = list.Where(a => a.TakeDate <= endDate).ToList(); + + if (!string.IsNullOrEmpty(likeName)) + { + var teaMap = await GetTeaMapAsync(schSerId); + var userids = teaMap.Where(a => a.Value.Name.Contains(likeName)) + .Select(a => a.Key.ToString()).ToArray(); + list = list.Where(m => + (m.DocTitle != null && m.DocTitle.Contains(likeName)) || + (m.DocContent != null && m.DocContent.Contains(likeName)) || + (m.UserID != null && userids.Contains(m.UserID))).ToList(); + } + + var totle = list.Count; + if (totle == 0) + return new DocPrintListResult { tlist = new List(), totle = totle }; + + // 旧 1 基分页 → 0 基 + pageIndex -= 1; + if (pageIndex < 0) pageIndex = 0; + + var yzflList = (await _dic.FindAsync(g => + g.typeID == "YZFL" && (g.Schserid == schSerId || g.Schserid == null))) + .OrderBy(g => g.Des) + .Select(a => new DocPrintTypeItem { CodeID = a.CodeID, Name = a.Name }).ToList(); + + var yzbmList = (await _dic.FindAsync(g => + g.typeID == "YZBM" && (g.Schserid == schSerId || g.Schserid == null))) + .OrderBy(g => g.Des) + .Select(a => new DocPrintTypeItem { CodeID = a.CodeID, Name = a.Name }).ToList(); + + var teaMap2 = await GetTeaMapAsync(schSerId); + var tlist = list.OrderByDescending(a => a.TakeDate) + .Skip(pageIndex * pageSize).Take(pageSize) + .Select(item => new DocPrintStatItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DPType, + DocNum = item.DocNum, + DocTypeName = yzflList.FirstOrDefault(a => a.CodeID == item.DPType)?.Name, + DocStyle = item.DocStyle, + DocStyleName = yzbmList.FirstOrDefault(a => a.CodeID == item.DocStyle)?.Name, + EditDate = item.EditDate.ToString("yyyy-MM-dd HH:mm"), + TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = GetTeaName(teaMap2, item.UserID), + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = item.DocStatus == "1" ? "印刷中" : "已印刷", + DocStep = item.DocStep, + flowid = item.flowid, + FreeUserID = item.FreeUserID, + SendUserID = item.SendUserID, + freestepid = item.freestepid, + DocShare = item.DocShare, + explain = item.explain, + }) + .ToList(); + + return new DocPrintListResult { tlist = tlist, totle = totle }; + } + + #region 辅助 + + private async Task> GetTeaMapAsync(string schSerId) + { + var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null); + return teas + .Where(t => int.TryParse(t.TeaSerId, out _)) + .GroupBy(t => int.Parse(t.TeaSerId)) + .ToDictionary(g => g.Key, g => (g.First().Name ?? "", g.First().Photo ?? "")); + } + + private static string GetTeaName(Dictionary teaMap, string userId) + { + if (!int.TryParse(userId, out var uid)) return ""; + return teaMap.TryGetValue(uid, out var tea) ? tea.Name : ""; + } + + #endregion + } +} diff --git a/src/YhCore.Service/Impl/FileService.cs b/src/YhCore.Service/Impl/FileService.cs new file mode 100644 index 0000000..98ccae2 --- /dev/null +++ b/src/YhCore.Service/Impl/FileService.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Yh.Platform.Abstractions; +using YhCore.Common.Config; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 公共文件服务实现(见 IFileService 注释)。 + /// 降级:Office→PDF(ToOfficeHelper)新项目缺失,上传不转 PDF;预览兜底 officeapps.live.com。 + /// + public class FileService : IFileService + { + private readonly IRepository _upfiles; + + public FileService(IRepository upfiles) + { + _upfiles = upfiles; + } + + public async Task GetEnclosureAsync(int id) + { + var model = await _upfiles.FirstOrDefaultAsync(x => x.SerID == id); + if (model == null) + throw new DException("文件ID错误!"); + + return new FileObj + { + fileurl = model.FileName, + filename = model.FileTrueName, + filetype = model.FileType, + filesize = model.FileSize ?? 0, + }; + } + + public async Task SaveEnclosureAsync(FileObj file, string appType) + { + // 替换文件名中的特殊字符(旧 FileSaveEnclosure) + string safeName = file.filename ?? ""; + foreach (var c in Path.GetInvalidFileNameChars()) + safeName = safeName.Replace(c, '_'); + safeName = safeName.Replace(",", "_").Replace(",", "_"); + + var model = new Common_UpFiles + { + FileName = file.fileurl, + FileSize = file.filesize, + FileTrueName = safeName, + FileType = file.filetype, + DateAndTime = DateTime.Now, + TopicType = appType, + }; + await _upfiles.AddAsync(model); + await _upfiles.SaveChangesAsync(); + return model.SerID; + } + + public async Task SaveFileAsync(string appType, IFormFile file) + { + if (file == null || file.Length == 0) + throw new DException("上传失败!"); + + if (appType.ToLower() == "noticemsg") appType = "notice"; + string dtStr = DateTime.Now.ToString("yyyyMMdd"); + string physicalDir = Path.Combine(StorageRoot(), appType, dtStr); + string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/"; + + if (!Directory.Exists(physicalDir)) + Directory.CreateDirectory(physicalDir); + + string guid = Guid.NewGuid().ToString(); + string fileName = Path.GetFileName(file.FileName); + string suffixName = Path.GetExtension(file.FileName); + + string[] suffArry = { "jpg", "gif", "png", "xls", "doc", "txt", "docx", "xlsx", "zip", "rar", "pdf", "ppt", "pptx" }; + suffArry = suffArry.Select(x => "." + x).ToArray(); + bool isSuffType = suffArry.Any(x => x == suffixName.ToLower()); + if (!isSuffType) + throw new DException("不能上传" + suffixName + "格式的文件"); + + int fileSize = (int)file.Length; + string savePath = Path.Combine(physicalDir, guid + suffixName); + using (var stream = new FileStream(savePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + // 旧 ToOfficeHelper.OfficeConvertToPDF(Office COM)新项目缺失 → 降级不转 PDF + + return new FileObj + { + fileurl = outFilePath + guid + suffixName, + filename = fileName, + filetype = suffixName, + filesize = fileSize, + host = Config.FileWebPath, + }; + } + + public async Task> SaveFileImageAsync(string appType, IFormFileCollection files) + { + if (appType.ToLower() == "noticemsg") appType = "notice"; + string dtStr = DateTime.Now.ToString("yyyyMMdd"); + string physicalDir = Path.Combine(StorageRoot(), appType, dtStr); + string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/"; + + if (!Directory.Exists(physicalDir)) + Directory.CreateDirectory(physicalDir); + + string[] suffArry = { "jpg", "gif", "png", "jpeg" }; + suffArry = suffArry.Select(x => "." + x).ToArray(); + + var list = new List(); + foreach (var file in files) + { + if (file == null || file.Length == 0) continue; + string suffixName = Path.GetExtension(file.FileName); + if (!suffArry.Any(x => x == suffixName.ToLower())) continue; + + string guid = Guid.NewGuid().ToString(); + string fileName = Path.GetFileName(file.FileName); + int fileSize = (int)file.Length; + string savePath = Path.Combine(physicalDir, guid + suffixName); + using (var stream = new FileStream(savePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + list.Add(new FileObj + { + fileurl = outFilePath + guid + suffixName, + filename = fileName, + filetype = suffixName, + filesize = fileSize, + host = Config.FileWebPath, + }); + } + return list; + } + + public async Task SaveChunkAsync(string guid, string chunkIndex, IFormFile chunk) + { + if (string.IsNullOrEmpty(guid) || chunk == null || chunk.Length == 0) + throw new DException("上传失败!"); + + var dir = Path.Combine(StorageRoot(), guid); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + string filePath = Path.Combine(dir, chunkIndex); + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await chunk.CopyToAsync(stream); + } + } + + public async Task MergeSaveAsync(string appType, string guid, string fileName) + { + if (string.IsNullOrEmpty(guid) || string.IsNullOrEmpty(fileName)) + throw new DException("上传失败!"); + + if (appType.ToLower() == "noticemsg") appType = "notice"; + string dtStr = DateTime.Now.ToString("yyyyMMdd"); + string physicalDir = Path.Combine(StorageRoot(), appType, dtStr); + string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/"; + if (!Directory.Exists(physicalDir)) + Directory.CreateDirectory(physicalDir); + + var uploadDir = Path.Combine(StorageRoot()); + var dir = Path.Combine(uploadDir, guid); + if (!Directory.Exists(dir)) + throw new DException("上传失败!"); + + var files = Directory.GetFiles(dir); + string fileRealName = Path.GetFileName(fileName); + string suffixName = Path.GetExtension(fileRealName); + string fileGuid = Guid.NewGuid().ToString(); + string outUrl = outFilePath + fileGuid + suffixName; + string finalPath = Path.Combine(physicalDir, fileGuid + suffixName); + + using (var fs = new FileStream(finalPath, FileMode.Create)) + { + foreach (var part in files.OrderBy(x => x.Length).ThenBy(x => x)) + { + var bytes = await File.ReadAllBytesAsync(part); + fs.Write(bytes, 0, bytes.Length); + File.Delete(part); + } + } + int fileSize = (int)(new FileInfo(finalPath).Length / 1024 / 1024); // 旧实现以 MB 存储,保持一致 + Directory.Delete(dir); + + // 旧 ToOfficeHelper.OfficeConvertToPDF 缺失 → 降级不转 PDF + + return new FileObj + { + fileurl = outUrl, + filename = fileRealName, + filetype = suffixName, + filesize = fileSize, + host = Config.FileWebPath, + }; + } + + public string MapPhysicalPath(string fileUrl) + { + if (string.IsNullOrEmpty(fileUrl)) + return ""; + + // 相对路径如 /UploadFile/docprint/20260101/{guid}.jpg → Config.FilePath + 子路径 + var relative = fileUrl.TrimStart('/'); + const string prefix = "UploadFile/"; + if (relative.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + relative = relative.Substring(prefix.Length); + + return Path.Combine(StorageRoot(), relative.Replace('/', Path.DirectorySeparatorChar)); + } + + /// 存储根目录(旧 Server.MapPath("~/UploadFile/");Config.FilePath 缺省时退回进程目录/UploadFile)。 + private static string StorageRoot() + { + if (!string.IsNullOrEmpty(Config.FilePath)) + return Config.FilePath; + + return Path.Combine(AppContext.BaseDirectory, "UploadFile"); + } + } +} diff --git a/src/YhCore.Service/Impl/FreeApproveService.cs b/src/YhCore.Service/Impl/FreeApproveService.cs new file mode 100644 index 0000000..860c388 --- /dev/null +++ b/src/YhCore.Service/Impl/FreeApproveService.cs @@ -0,0 +1,595 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Yh.Platform.Abstractions; +using Yh.SchClass.OpenApi; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 自由审批流服务(移植旧 BestSchool.Service.ApproveFlow)。 + /// 教师查询改走教务 SDK(Yh.SchClass.OpenApi),教师标识用 teaId(TeaSerId)对齐新教务。 + /// + public class FreeApproveService : IFreeApproveService + { + private readonly IRepository _freeflowinfo; + private readonly IRepository _freegroup; + private readonly IRepository _flowtranct; + private readonly IRepository _flowstep; + private readonly IRepository _groupuser; + private readonly IRepository _userflowzhipai; + private readonly YHDbContext _db; + private readonly SchClassOpenApi _sch; + + public FreeApproveService( + IRepository freeflowinfo, + IRepository freegroup, + IRepository flowtranct, + IRepository flowstep, + IRepository groupuser, + IRepository userflowzhipai, + YHDbContext db, + SchClassOpenApi sch) + { + _freeflowinfo = freeflowinfo; + _freegroup = freegroup; + _flowtranct = flowtranct; + _flowstep = flowstep; + _groupuser = groupuser; + _userflowzhipai = userflowzhipai; + _db = db; + _sch = sch; + } + + public async Task> GetFleeFlowAsync(int userId, int? dataid) + { + int appid = DocPrintConstants.AppCode; + + if ((dataid ?? 0) == 0) + { + var all = await _freeflowinfo.FindAsync(a => a.flowType == appid && a.userid == userId); + dataid = all.Count == 0 ? 0 : all.Max(a => a.dataid ?? 0); + } + + if ((dataid ?? 0) <= 0) return new List(); + + var nodes = (await _freeflowinfo.FindAsync(a => a.flowType == appid && a.userid == userId && a.dataid == dataid)) + .OrderBy(a => a.SerID).ToList(); + return await BuildFreeFlowNodesAsync(nodes); + } + + public async Task EditFleeFlowAsync(int userId, string schSerId, int? dataid, string flowUids, bool isFlowNull) + { + if (string.IsNullOrEmpty(flowUids)) + { + if (!isFlowNull) + throw new DException("请选择审核步骤人员!"); + flowUids = ""; + } + + var flowUidArr = flowUids.Split('_'); + int appid = DocPrintConstants.AppCode; + int firstStepId = 0; + + using var tx = await _db.Database.BeginTransactionAsync(); + try + { + if ((dataid ?? 0) > 0) + { + var old = await _freeflowinfo.FindAsync(a => a.dataid == dataid && a.flowType == appid && a.userid == userId); + foreach (var item in old) + { + await _freeflowinfo.RemoveAsync(item.SerID); + } + await _freeflowinfo.SaveChangesAsync(); + } + + var upSeptid = 0; + var date = DateTime.Now; + foreach (var fw in flowUidArr) + { + var free = new Common_freeflowinfo + { + laststep = upSeptid, + userid = userId, + createTime = date, + dataid = dataid, + schserid = schSerId, + flowType = appid, + }; + await _freeflowinfo.AddAsync(free); + await _freeflowinfo.SaveChangesAsync(); + + if (upSeptid == 0) + firstStepId = free.SerID; + upSeptid = free.SerID; + + foreach (var guserid in fw.Split(',')) + { + await _freegroup.AddAsync(new Common_freeGroupuser + { + flowid = upSeptid, + SchSerID = schSerId, + usergroup = upSeptid, + userid = string.IsNullOrEmpty(guserid) ? (int?)null : int.Parse(guserid), + }); + } + await _freegroup.SaveChangesAsync(); + } + + await tx.CommitAsync(); + return firstStepId; + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task AddShenheRemarkAsync(int userId, int? dataid, string remark, int freestepid) + { + var info = new Common_flowtranctInfo + { + actionstatus = -1, + remark = remark, + dataid = dataid ?? 0, + stepid = freestepid, + datatype = DocPrintConstants.AppCode.ToString(), + actiondate = DateTime.Now, + actionuserid = userId, + }; + await _flowtranct.AddAsync(info); + await _flowtranct.SaveChangesAsync(); + return "备注成功!"; + } + + public async Task> GetAfterFleeFlowAsync(int? dataid, int freestepid) + { + int appid = DocPrintConstants.AppCode; + + if ((dataid ?? 0) <= 0) return new List(); + + var nodes = (await _freeflowinfo.FindAsync(a => a.flowType == appid && a.SerID > freestepid && a.dataid == dataid)) + .OrderBy(a => a.SerID).ToList(); + return await BuildFreeFlowNodesAsync(nodes); + } + + public async Task EditAfterFleeFlowAsync(int? dataid, int freestepid, string flowUids) + { + int appid = DocPrintConstants.AppCode; + + // 删除 freestepid 之后的剩余步骤 + var nextSteps = await _freeflowinfo.FindAsync(a => a.dataid == dataid && a.SerID > freestepid); + if (nextSteps.Count > 0) + { + foreach (var item in nextSteps) + { + await _freeflowinfo.RemoveAsync(item.SerID); + } + await _freeflowinfo.SaveChangesAsync(); + } + + if (string.IsNullOrEmpty(flowUids)) + return 0; + + var flowUidArr = flowUids.Split('_'); + var laststept = await _freeflowinfo.FirstOrDefaultAsync(a => a.SerID == freestepid); + if (laststept == null) + throw new DException("未找到自由流程步骤!"); + + var upSeptid = freestepid; + using var tx = await _db.Database.BeginTransactionAsync(); + try + { + var date = DateTime.Now; + foreach (var fw in flowUidArr) + { + var free = new Common_freeflowinfo + { + laststep = upSeptid, + userid = laststept.userid, + createTime = date, + dataid = dataid, + schserid = laststept.schserid, + flowType = appid, + }; + await _freeflowinfo.AddAsync(free); + await _freeflowinfo.SaveChangesAsync(); + + upSeptid = free.SerID; + foreach (var guserid in fw.Split(',')) + { + await _freegroup.AddAsync(new Common_freeGroupuser + { + flowid = upSeptid, + SchSerID = laststept.schserid, + usergroup = upSeptid, + userid = string.IsNullOrEmpty(guserid) ? (int?)null : int.Parse(guserid), + }); + } + await _freegroup.SaveChangesAsync(); + } + + await tx.CommitAsync(); + return upSeptid; + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task> ShenheFlowAsync(int uid, string ctime, int dataid, int? freestepid, int? flowid) + { + int flowType = DocPrintConstants.AppCode; + string appCode = DocPrintConstants.AppCode.ToString(); + + if ((freestepid ?? 0) > 0) + return await BuildFreeShenheFlowAsync(uid, ctime, dataid, freestepid, flowType, appCode); + + if ((flowid ?? 0) > 0) + return await BuildFixShenheFlowAsync(uid, ctime, dataid, flowid, appCode); + + return await BuildOnlyTranctShenheFlowAsync(uid, ctime, dataid, appCode); + } + + public async Task> ChaoSendListAsync(string uids) + { + var result = new List(); + + if (string.IsNullOrEmpty(uids) || uids == "undefined") + return result; + + var userids = uids.Split(',') + .Where(a => !string.IsNullOrEmpty(a) && int.TryParse(a, out _)) + .Select(int.Parse).ToArray(); + if (userids.Length == 0) + return result; + + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", userids)); + return teas.Select(a => new ChaoSendUser + { + uid = ParseTeaId(a.TeaSerId), + photo = a.Photo, + name = a.Name, + status = false, + }).ToList(); + } + + #region 审核流程回显 + + private async Task> BuildFreeShenheFlowAsync(int uid, string ctime, int dataid, int? freestepid, int flowType, string appCode) + { + var freeflow = (await _freeflowinfo.FindAsync(a => a.dataid == dataid && a.flowType == flowType)) + .OrderBy(a => a.SerID).ToList(); + var freeflowids = freeflow.Select(a => a.SerID).ToArray(); + var freeflowGroup = (await _freegroup.FindAsync(a => freeflowids.Contains(a.usergroup ?? 0))).ToList(); + var freeshenhe = await _flowtranct.FindAsync(a => a.dataid == dataid && a.datatype == appCode); + + var frees = new List(); + var firstFlow = freeflow.FirstOrDefault(); + frees.Add(new FreeShenheInfo + { + steptname = await GetUserNameAsync(firstFlow?.userid?.ToString() ?? ""), + steptstatus = "发起申请", + photo = await GetUserPhotoAsync(firstFlow?.userid?.ToString() ?? ""), + actiondate = firstFlow?.createTime?.ToString("yyyy-MM-dd HH:mm") ?? "", + }); + + if (freeshenhe.Count(a => a.actionstatus > 2) == 0) + { + foreach (var item in freeflow) + { + var act = new FreeShenheInfo(); + var isShok = freeshenhe.FirstOrDefault(a => a.stepid == item.SerID); + if (isShok != null) + { + act.steptname = await ChaoSendNameAsync(isShok.actionuserid?.ToString()); + act.photo = await GetUserPhotoAsync(isShok.actionuserid?.ToString()); + act.steptstatus = ActionstatusName(isShok.actionstatus); + act.actionstatus = isShok.actionstatus.ToString(); + act.Remark = isShok.remark; + act.actiondate = isShok.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? ""; + + if (isShok.actionstatus == 2) + { + frees.Add(act); + break; + } + } + else + { + var userids = ToUserIds(freeflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid)); + act.steptname = await ChaoSendNameAsync(string.Join(",", userids)); + act.photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png"; + act.steptstatus = "待审核"; + act.actionstatus = "0"; + } + frees.Add(act); + } + } + else + { + foreach (var item in freeshenhe) + { + frees.Add(new FreeShenheInfo + { + steptname = await ChaoSendNameAsync((item.actionuserid ?? uid).ToString()), + photo = await GetUserPhotoAsync((item.actionuserid ?? uid).ToString()), + steptstatus = ActionstatusName(item.actionstatus), + actionstatus = item.actionstatus.ToString(), + Remark = item.remark, + actiondate = item.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "", + }); + } + + var zuihouyibu = freeshenhe.Where(a => a.actionstatus >= 2).OrderByDescending(a => a.SerID).FirstOrDefault(); + if (zuihouyibu != null && zuihouyibu.actionstatus == 4) + { + var isShok = freeshenhe.FirstOrDefault(a => a.SerID > zuihouyibu.SerID); + if (isShok == null) + { + var userids = ToUserIds(freeflowGroup.Where(a => a.usergroup == freestepid).Select(a => a.userid)); + frees.Add(new FreeShenheInfo + { + steptname = await ChaoSendNameAsync(string.Join(",", userids)), + photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png", + steptstatus = "待审核", + actionstatus = "0", + }); + } + } + else if (zuihouyibu != null && zuihouyibu.actionstatus == 6) + { + foreach (var item in freeflow) + { + var isShok = freeshenhe.FirstOrDefault(a => a.stepid == item.SerID && a.SerID > zuihouyibu.SerID); + if (isShok == null) + { + var userids = ToUserIds(freeflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid)); + frees.Add(new FreeShenheInfo + { + steptname = await ChaoSendNameAsync(string.Join(",", userids)), + photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png", + steptstatus = "待审核", + actionstatus = "0", + }); + } + } + } + } + + return frees; + } + + private async Task> BuildFixShenheFlowAsync(int uid, string ctime, int dataid, int? flowid, string appCode) + { + var fixflow = (await _flowstep.FindAsync(a => a.flowid == flowid)).OrderBy(a => a.SerID).ToList(); + var fixflowids = fixflow.Select(a => a.SerID).ToArray(); + var fixflowGroup = (await _groupuser.FindAsync(a => fixflowids.Contains(a.usergroup ?? 0))).ToList(); + var fixshenhe = await _flowtranct.FindAsync(a => a.dataid == dataid && a.datatype == appCode); + + var frees = new List(); + frees.Add(new FreeShenheInfo + { + steptname = await GetUserNameAsync(uid.ToString()), + steptstatus = "发起申请", + actiondate = ctime, + photo = await GetUserPhotoAsync(uid.ToString()), + }); + + if (fixshenhe.Count(a => a.actionstatus > 2) == 0) + { + foreach (var item in fixflow) + { + var act = new FreeShenheInfo(); + var isShok = fixshenhe.FirstOrDefault(a => a.stepid == item.SerID); + if (isShok != null) + { + act.steptname = await ChaoSendNameAsync(isShok.actionuserid?.ToString()); + act.photo = await GetUserPhotoAsync(isShok.actionuserid?.ToString()); + act.steptstatus = ActionstatusName(isShok.actionstatus); + act.actionstatus = isShok.actionstatus.ToString(); + act.Remark = isShok.remark; + act.actiondate = isShok.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? ""; + + if (isShok.actionstatus == 2) + { + frees.Add(act); + break; + } + } + else + { + var userids = ToUserIds(fixflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid)); + + var userzhipai = await _userflowzhipai.FirstOrDefaultAsync( + a => a.MSerID == dataid && a.FlowID == flowid && a.FlowStepID == item.SerID); + if (!string.IsNullOrEmpty(userzhipai?.zhipai)) + { + userids = userzhipai.zhipai.Split(',') + .Where(s => int.TryParse(s, out _)) + .Select(int.Parse).Distinct().ToArray(); + } + + act.steptname = await ChaoSendNameAsync(string.Join(",", userids)); + act.photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png"; + act.steptstatus = "待审核"; + act.actionstatus = "0"; + } + frees.Add(act); + } + } + else + { + foreach (var item in fixshenhe) + { + frees.Add(new FreeShenheInfo + { + steptname = await ChaoSendNameAsync((item.actionuserid ?? uid).ToString()), + photo = await GetUserPhotoAsync((item.actionuserid ?? uid).ToString()), + steptstatus = ActionstatusName(item.actionstatus), + actionstatus = item.actionstatus.ToString(), + Remark = item.remark, + actiondate = item.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "", + }); + } + + var zuihouyibu = fixshenhe.Where(a => a.actionstatus >= 2).OrderByDescending(a => a.SerID).FirstOrDefault(); + if (zuihouyibu != null && zuihouyibu.actionstatus == 4) + { + var isShok = fixshenhe.FirstOrDefault(a => a.SerID > zuihouyibu.SerID); + if (isShok == null) + { + var lastStepId = fixflowids.OrderByDescending(p => p).FirstOrDefault(); + var userids = ToUserIds(fixflowGroup.Where(a => a.usergroup == lastStepId).Select(a => a.userid)); + frees.Add(new FreeShenheInfo + { + steptname = await ChaoSendNameAsync(string.Join(",", userids)), + photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png", + steptstatus = "待审核", + actionstatus = "0", + }); + } + } + else if (zuihouyibu != null && zuihouyibu.actionstatus == 6) + { + foreach (var item in fixflow) + { + var isShok = fixshenhe.FirstOrDefault(a => a.stepid == item.SerID && a.SerID > zuihouyibu.SerID); + if (isShok == null) + { + var userids = ToUserIds(fixflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid)); + frees.Add(new FreeShenheInfo + { + steptname = await ChaoSendNameAsync(string.Join(",", userids)), + photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png", + steptstatus = "待审核", + actionstatus = "0", + }); + } + } + } + } + + return frees; + } + + private async Task> BuildOnlyTranctShenheFlowAsync(int uid, string ctime, int dataid, string appCode) + { + var shenhe = await _flowtranct.FindAsync(a => a.dataid == dataid && a.datatype == appCode); + + var frees = new List(); + frees.Add(new FreeShenheInfo + { + steptname = await GetUserNameAsync(uid.ToString()), + steptstatus = "发起申请", + actiondate = ctime, + photo = await GetUserPhotoAsync(uid.ToString()), + }); + + if (shenhe.Count(a => a.actionstatus > 2) > 0) + { + foreach (var item in shenhe) + { + frees.Add(new FreeShenheInfo + { + steptname = await ChaoSendNameAsync(item.actionuserid?.ToString()), + photo = await GetUserPhotoAsync(item.actionuserid?.ToString()), + steptstatus = ActionstatusName(item.actionstatus), + actionstatus = item.actionstatus.ToString(), + Remark = item.remark, + actiondate = item.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "", + }); + } + } + + return frees; + } + + #endregion + + #region 辅助 + + private async Task> BuildFreeFlowNodesAsync(List nodes) + { + var result = new List(); + foreach (var node in nodes) + { + var userIds = (await _freegroup.FindAsync(n => n.flowid == node.SerID)) + .Select(n => n.userid).Where(u => u.HasValue).Select(u => u.Value).ToList(); + var users = new List(); + if (userIds.Count > 0) + { + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", userIds)); + users = teas.Select(t => new FreeFlowUser + { + userid = ParseTeaId(t.TeaSerId), + name = t.Name, + photo = t.Photo, + }).ToList(); + } + result.Add(new FreeFlowNode { user = users }); + } + return result; + } + + private static int ParseTeaId(string teaSerId) + => int.TryParse(teaSerId, out var id) ? id : 0; + + private static int[] ToUserIds(IEnumerable source) + => source.Select(u => u ?? 0).ToArray(); + + private async Task GetUserNameAsync(string teaId) + { + if (!int.TryParse(teaId, out var id)) return ""; + var tea = await _sch.TeaInfo.GetTeaInfoAsync(id); + return tea?.Name ?? ""; + } + + private async Task GetUserPhotoAsync(string teaId) + { + if (!int.TryParse(teaId, out var id)) return ""; + var tea = await _sch.TeaInfo.GetTeaInfoAsync(id); + return tea?.Photo ?? ""; + } + + private async Task ChaoSendNameAsync(string uids) + { + if (string.IsNullOrEmpty(uids) || uids == "undefined") return ""; + var ids = uids.Split(',') + .Where(s => int.TryParse(s, out _)).Select(int.Parse).Distinct().ToArray(); + if (ids.Length == 0) return ""; + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", ids)); + return string.Join(",", teas.Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n))); + } + + private static string ActionstatusName(int actionstatus) + { + return actionstatus == -1 ? "备注" + : actionstatus == 0 ? "发起" + : actionstatus == 1 ? "已同意" + : actionstatus == 2 ? "拒绝" + : actionstatus == 3 ? "已撤销" + : actionstatus == 4 ? "撤销发起" + : actionstatus == 5 ? "通过(撤销被拒绝)" + : actionstatus == 6 ? "发起修改申请" + : actionstatus == 7 ? "已领取" + : actionstatus == 8 ? "已维修" + : actionstatus == 9 ? "已归还" + : actionstatus == 10 ? "已领取" + : actionstatus == 11 ? "印刷中" + : "其他"; + } + + #endregion + } +} diff --git a/src/YhCore.Service/Impl/LogServiceStub.cs b/src/YhCore.Service/Impl/LogServiceStub.cs new file mode 100644 index 0000000..d3478e4 --- /dev/null +++ b/src/YhCore.Service/Impl/LogServiceStub.cs @@ -0,0 +1,18 @@ +using YhCore.Entity; +using YhCore.Model.Vo; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 日志查询服务占位实现(框架期保证接口可运行)。 + /// TODO:接入 Repository 层后实现真实查询(按 schId + systemScope 过滤、keyword 模糊匹配、分页)。 + /// + public class LogServiceStub : ILogService + { + public PageVO Search(int schId, string keyword, bool systemScope, int page, int size) + { + return PageVO.Empty(page, size); + } + } +} diff --git a/src/YhCore.Service/Impl/ReviewProcessService.cs b/src/YhCore.Service/Impl/ReviewProcessService.cs new file mode 100644 index 0000000..71cb8da --- /dev/null +++ b/src/YhCore.Service/Impl/ReviewProcessService.cs @@ -0,0 +1,530 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Yh.Platform.Abstractions; +using Yh.SchClass.OpenApi; +using Yh.SchClass.OpenApi.Model; +using YhCore.Common.UserContext; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Model.Vo; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 审核流程管理服务(移植旧 BestSchool.Service.Reviewprocess + UserFlowRole)。 + /// 教师查询改走教务 SDK(Yh.SchClass.OpenApi),缺失 groupId 的局版接口降级为当前学校。 + /// + public class ReviewProcessService : IReviewProcessService + { + private readonly IRepository _flowinfo; + private readonly IRepository _flowstep; + private readonly IRepository _groupuser; + private readonly IRepository _freeflowinfo; + private readonly IRepository _freegroup; + private readonly IRepository _senduser; + private readonly IRepository _upfiles; + private readonly SchClassOpenApi _sch; + + public ReviewProcessService( + IRepository flowinfo, + IRepository flowstep, + IRepository groupuser, + IRepository freeflowinfo, + IRepository freegroup, + IRepository senduser, + IRepository upfiles, + SchClassOpenApi sch) + { + _flowinfo = flowinfo; + _flowstep = flowstep; + _groupuser = groupuser; + _freeflowinfo = freeflowinfo; + _freegroup = freegroup; + _senduser = senduser; + _upfiles = upfiles; + _sch = sch; + } + + #region 流程与步骤 + + public Task> List2Async(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId) + => ListAsync(name, pageSize, pageIndex, flowType, edit, schSerId); + + public async Task> ListAsync(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId) + { + var resultList = await _flowinfo.FindAsync(m => m.flowType == flowType && m.schSerID == schSerId); + + if (pageSize == 100 && pageIndex == 0) + { + if (!edit) + resultList = resultList.Where(a => a.enable == 1).ToList(); + + var stepFlowIds = (await _flowstep.FindAsync(s => true)) + .Select(s => s.flowid).Where(f => f.HasValue).Select(f => f.Value).Distinct().ToHashSet(); + resultList = resultList.Where(a => stepFlowIds.Contains(a.SerID)).ToList(); + } + else + { + if (!edit) + resultList = resultList.Where(a => a.enable != 3).ToList(); + } + + if (!string.IsNullOrEmpty(name)) + { + resultList = resultList.Where(m => m.flowname != null && m.flowname.IndexOf(name) >= 0).ToList(); + } + + var ordered = resultList.OrderBy(m => m.SerID).ToList(); + var items = ordered.Skip(pageSize * pageIndex).Take(pageSize) + .Select(item => new FlowListItem + { + SerID = item.SerID.ToString(), + flowname = item.flowname, + deptid = item.deptid, + typeid = item.typeid, + enable = item.enable, + enablename = item.enable == 1 ? "是" : "否", + miaoshu = item.flowdes, + deitdate = item.deitdate.ToString("yyyy-MM-dd"), + }) + .ToList(); + + return PageVO.Of(items, ordered.Count, pageIndex, pageSize); + } + + public async Task> StepListAsync(string name, int serid) + { + var steps = await _flowstep.FindAsync(a => a.flowid == serid); + if (!string.IsNullOrEmpty(name)) + { + steps = steps.Where(m => m.StepName != null && m.StepName.IndexOf(name) >= 0).ToList(); + } + + var ordered = steps.OrderBy(m => m.SerID).ToList(); + var items = new List(); + foreach (var item in ordered) + { + items.Add(new StepListItem + { + SerID = item.SerID.ToString(), + StepName = item.StepName, + flowid = item.flowid?.ToString(), + prestep = item.prestep, + TeaName = await GetTeaNamesByStepAsync(item.SerID), + Teaid = await GetTeaIdsByStepAsync(item.SerID), + istrue = steps.Any(a => a.prestep == item.SerID), + }); + } + + return PageVO.Of(items, ordered.Count, 0, ordered.Count == 0 ? 20 : ordered.Count); + } + + public async Task CreateAsync(Common_flowinfo model) + { + if (model == null) throw new DException("参数错误!"); + + var flowname = model.flowname?.Trim(); + var dup = await _flowinfo.AnyAsync(a => + a.schSerID == model.schSerID && a.flowType == model.flowType && + a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3); + if (dup) throw new DException("流程名不能重名!"); + + var info = new Common_flowinfo + { + flowname = model.flowname, + deptid = model.deptid, + typeid = model.typeid, + flowdes = model.flowdes, + enable = model.enable, + flowType = model.flowType, + deitdate = DateTime.Now, + schSerID = model.schSerID, + }; + + await _flowinfo.AddAsync(info); + await _flowinfo.SaveChangesAsync(); + } + + public async Task EditAsync(Common_flowinfo model) + { + if (model == null) throw new DException("参数错误!"); + + var flowname = model.flowname?.Trim(); + var dup = (await _flowinfo.FindAsync(a => + a.schSerID == model.schSerID && a.flowType == DocPrintConstants.AppCode && + a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3)).Count; + if (dup > 1) throw new DException("流程名不能重名!"); + + var info = await _flowinfo.FirstOrDefaultAsync(a => a.SerID == model.SerID); + if (info == null) throw new DException("未找到要修改的流程!"); + + info.flowname = model.flowname; + info.deptid = model.deptid; + info.typeid = model.typeid; + info.flowdes = model.flowdes; + info.enable = model.enable; + + await _flowinfo.UpdateAsync(info); + await _flowinfo.SaveChangesAsync(); + } + + public async Task StepCreateAsync(Common_flowstep model) + { + if (model == null) throw new DException("参数错误!"); + + var info = new Common_flowstep + { + StepName = model.StepName, + prestep = model.prestep, + flowid = model.flowid, + ordernum = 0, + }; + await _flowstep.AddAsync(info); + await _flowstep.SaveChangesAsync(); + + if (string.IsNullOrEmpty(model.SchSerID)) + { + throw new DException("添加失败!"); + } + + await AddGroupUsersAsync(info.SerID, model.flowid, model.SchSerID); + } + + public async Task StepEditAsync(Common_flowstep model) + { + if (model == null) throw new DException("参数错误!"); + + var info = await _flowstep.FirstOrDefaultAsync(a => a.SerID == model.SerID); + if (info == null) throw new DException("未找到要修改的步骤!"); + + info.flowid = model.flowid; + info.StepName = model.StepName; + info.prestep = model.prestep; + await _flowstep.UpdateAsync(info); + + // 重写该步骤的人员绑定(先清后加) + var existing = await _groupuser.FindAsync(a => a.usergroup == info.SerID); + foreach (var itt in existing) + { + await _groupuser.RemoveAsync(itt); + } + + if (string.IsNullOrEmpty(model.SchSerID)) + { + await _groupuser.SaveChangesAsync(); + throw new DException("修改失败!"); + } + + await AddGroupUsersAsync(info.SerID, info.flowid, model.SchSerID); + } + + private async Task AddGroupUsersAsync(int stepSerId, int? flowId, string userIds) + { + foreach (var idStr in userIds.Split(',')) + { + if (!int.TryParse(idStr.Trim(), out var userId)) continue; + await _groupuser.AddAsync(new Common_Groupuser + { + usergroup = stepSerId, + flowid = flowId, + userid = userId, + IsOk = 0, + }); + } + await _groupuser.SaveChangesAsync(); + } + + public async Task DeleteAsync(string ids) + { + if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null"); + + var lids = ids.Split(',').Select(a => int.Parse(a)).ToArray(); + var flows = await _flowinfo.FindAsync(a => lids.Contains(a.SerID)); + foreach (var item in flows) + { + item.enable = 3; + } + await _flowinfo.SaveChangesAsync(); + } + + public async Task StepDeleteAsync(decimal[] ids) + { + if (ids == null || ids.Length == 0) throw new DException("ids is not null"); + + var intIds = ids.Select(d => (int)d).ToArray(); + var objlist = await _flowstep.FindAsync(m => intIds.Contains(m.SerID)); + foreach (var item in objlist) + { + await _flowstep.RemoveAsync(item); + } + await _flowstep.SaveChangesAsync(); + } + + public async Task> BindstepListAsync(int serid) + { + var steps = await _flowstep.FindAsync(a => a.flowid == serid); + return steps.OrderBy(a => a.prestep) + .Select(a => new BindStepItem { SerID = a.SerID, StepName = a.StepName }) + .ToList(); + } + + public async Task> BindGroupuserListAsync(int flowid, int prestep) + { + var steps = await _flowstep.FindAsync(a => a.flowid == flowid && a.prestep == prestep); + var result = new List(); + foreach (var step in steps) + { + var users = await _groupuser.FindAsync(b => b.usergroup == step.SerID); + foreach (var u in users) + { + result.Add(new BindGroupuserItem + { + StepId = step.SerID, + userid = u.userid, + StepName = step.StepName, + TeaName = await GetTeaNameAsync(u.userid?.ToString()), + }); + } + } + return result; + } + + public async Task> BindflowinfoAsync(string schSerId, int flowType) + { + var flows = await _flowinfo.FindAsync(a => a.flowType == flowType && a.schSerID == schSerId && a.enable == 1); + var result = new List(); + foreach (var item in flows.OrderBy(m => m.SerID)) + { + var chaoSend = new List(); + if (!string.IsNullOrEmpty(item.ChaoSend)) + { + foreach (var uid in item.ChaoSend.Split(',')) + { + if (string.IsNullOrEmpty(uid)) continue; + chaoSend.Add(new ChaoSendItem + { + uid = uid, + name = await GetTeaNameAsync(uid), + photo = await GetTeaPhotoAsync(uid), + }); + } + } + + result.Add(new BindFlowItem + { + SerID = item.SerID.ToString(), + flowname = item.flowname, + ChaoSendArr = chaoSend, + }); + } + return result; + } + + #endregion + + #region 教师 / 人员 + + public async Task> GetTeaListAsync(string schSerId) + { + var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null); + return teas.Select(a => new TeaOption { value = ParseTeaId(a.TeaSerId), label = a.Name }).ToList(); + } + + public Task> GetTeaListSupAsync(string schSerId) + => GetTeaListAsync(schSerId); + + public async Task> GetUserinfoAsync(string schSerId, string name, int statetype, int pageSize, int pageIndex) + { + var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null); + + if (!string.IsNullOrEmpty(name)) + { + teas = teas.Where(m => m.Name != null && m.Name.IndexOf(name) >= 0).ToList(); + } + if (statetype > -1) + { + teas = teas.Where(m => m.IsLeadingMiddle == statetype).ToList(); + } + + var ordered = teas.OrderBy(m => ParseTeaId(m.TeaSerId)).ToList(); + var items = ordered.Skip(pageSize * pageIndex).Take(pageSize).ToList(); + return PageVO.Of(items, ordered.Count, pageIndex, pageSize); + } + + public async Task GetNoLeadingMiddleInfoAsync(string schSerId) + { + var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null); + return teas.Where(m => m.IsLeadingMiddle != 1) + .Select(m => m.TeaSerId) + .Where(id => !string.IsNullOrEmpty(id)) + .ToArray(); + } + + public Task GetNoLeadingMiddleInfoSupAsync(string schSerId) + => GetNoLeadingMiddleInfoAsync(schSerId); + + public Task ChangeLeadingMiddleStatusAsync(int state, string userTeaSerIdStr) + { + // 旧实现调用 OATea/EditIntheBearingStatus 写教师状态;教务 SDK 未提供该写接口,降级拒绝。 + throw new DException("该功能依赖教师状态写接口,教务 SDK 暂未提供,无法修改中层领导状态!"); + } + + public async Task> FilelistAsync(string ids) + { + if (string.IsNullOrWhiteSpace(ids)) return new List(); + + var idStrings = ids.Split(','); + var all = await _upfiles.FindAsync(a => true); + return all.Where(a => idStrings.Contains(a.SerID.ToString())) + .Select(a => new FileListItem + { + SerID = a.SerID, + FileTrueName = string.IsNullOrEmpty(a.FileTrueName) ? a.FileTrueName : a.FileTrueName.Split('.')[0], + FileName = a.FileName, + FileSize = a.FileSize, + FileType = a.FileType, + }) + .ToList(); + } + + #endregion + + #region 流程判断 / 用户 + + public async Task IsShenheflowAsync(string schSerId, int userId) + { + var stepFlowIds = (await _flowstep.FindAsync(s => true)) + .Select(s => s.flowid).Where(f => f.HasValue).Select(f => f.Value).Distinct().ToHashSet(); + + var flowlist = await _flowinfo.FindAsync(a => + a.schSerID == schSerId && a.flowType == DocPrintConstants.AppCode && a.enable == 1); + var hasFixed = flowlist.Any(a => stepFlowIds.Contains(a.SerID)); + + var result = new ShenheFlowResult { flowflag = hasFixed, freeflowlist = new List() }; + + if (!hasFixed) + { + var freeflowlist = await _freeflowinfo.FindAsync(a => a.userid == userId && a.flowType == DocPrintConstants.AppCode); + foreach (var a in freeflowlist) + { + result.freeflowlist.Add(new FreeFlowItem + { + shenheuserid = a.shenheuserid, + teamnamestr = await GetTeaNamesStrAsync(a.shenheuserid), + }); + } + } + + return result; + } + + public async Task> IsSendUserAsync(string schSerId, int userId) + { + var result = new List(); + var senduserlist = await _senduser.FirstOrDefaultAsync(a => a.userid == userId && a.flowType == DocPrintConstants.AppCode); + if (senduserlist == null || string.IsNullOrEmpty(senduserlist.Senduserid)) + { + return result; + } + + var userids = senduserlist.Senduserid.Split(',') + .Where(a => !string.IsNullOrEmpty(a) && a != "undefined" && int.TryParse(a, out _)) + .Select(int.Parse).ToArray(); + + if (userids.Length == 0) return result; + + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", userids)); + return teas.Select(a => new SendUserItem + { + uid = ParseTeaId(a.TeaSerId), + photo = a.Photo, + name = a.Name, + status = false, + }).ToList(); + } + + public Task GetUserAsync() + { + var user = UserContextHolder.Current; + return Task.FromResult(new CurrentUserInfo + { + Id = user?.Id ?? 0, + UserName = user?.UserName, + Account = user?.Account, + SchoolId = user?.SchoolId, + SchoolName = user?.SchoolName, + }); + } + + public async Task GetUserFlowRoleAsync(int userId) + { + var fixFlowIds = (await _flowinfo.FindAsync(f => f.enable == 1 && f.flowType == DocPrintConstants.AppCode)) + .Select(f => f.SerID).ToHashSet(); + var fixFlow = await _groupuser.AnyAsync(a => a.userid == userId && a.flowid.HasValue && fixFlowIds.Contains(a.flowid.Value)); + + var freeFlowIds = (await _freeflowinfo.FindAsync(f => f.flowType == DocPrintConstants.AppCode)) + .Select(f => f.SerID).ToHashSet(); + var freeFlow = await _freegroup.AnyAsync(a => a.userid == userId && a.flowid.HasValue && freeFlowIds.Contains(a.flowid.Value)); + + return (fixFlow || freeFlow) ? 1 : 0; + } + + #endregion + + #region 教师名称/头像辅助 + + private static int ParseTeaId(string teaSerId) + => int.TryParse(teaSerId, out var id) ? id : 0; + + private async Task GetTeaNameAsync(string teaId) + { + if (!int.TryParse(teaId, out var id)) return ""; + var tea = await _sch.TeaInfo.GetTeaInfoAsync(id); + return tea?.Name ?? ""; + } + + private async Task GetTeaPhotoAsync(string teaId) + { + if (!int.TryParse(teaId, out var id)) return ""; + var tea = await _sch.TeaInfo.GetTeaInfoAsync(id); + return tea?.Photo ?? ""; + } + + private async Task GetTeaIdsByStepAsync(int stepSerId) + { + var ids = (await _groupuser.FindAsync(a => a.usergroup == stepSerId)) + .Select(a => a.userid).Where(u => u.HasValue).Select(u => u.Value).ToList(); + return string.Join(",", ids); + } + + private async Task GetTeaNamesByStepAsync(int stepSerId) + { + var ids = (await _groupuser.FindAsync(a => a.usergroup == stepSerId)) + .Select(a => a.userid).Where(u => u.HasValue).Select(u => u.Value).ToList(); + return await GetTeaNamesByIdsAsync(ids); + } + + private async Task GetTeaNamesByIdsAsync(IEnumerable ids) + { + var list = ids.Distinct().ToList(); + if (list.Count == 0) return ""; + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", list)); + return string.Join(",", teas.Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n))); + } + + private async Task GetTeaNamesStrAsync(string commaIds) + { + if (string.IsNullOrEmpty(commaIds)) return ""; + var ids = commaIds.Split(',') + .Where(s => int.TryParse(s, out _)).Select(int.Parse).Distinct().ToList(); + if (ids.Count == 0) return ""; + return await GetTeaNamesByIdsAsync(ids); + } + + #endregion + } +} diff --git a/src/YhCore.Service/Impl/WYDocPrintService.cs b/src/YhCore.Service/Impl/WYDocPrintService.cs new file mode 100644 index 0000000..74c882c --- /dev/null +++ b/src/YhCore.Service/Impl/WYDocPrintService.cs @@ -0,0 +1,1339 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Yh.Platform.Abstractions; +using Yh.SchClass.OpenApi; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 文印核心业务服务(移植旧 BestSchool.DocPrint.WY_list 的 CRUD/列表/审核/统计/指派部分)。 + /// 与旧实现差异(迁移降级说明): + /// 1. 旧 SendMessage/SendUnread/Backlog(短信、已读未读、待办中心)在新项目缺失,全部降级为纯落库; + /// 2. 教师/头像改走教务 SDK(Yh.SchClass.OpenApi),教师标识用 teaId(TeaSerId); + /// 3. 旧返回体的 OtherData(userCenterUrl 等)无新槽位,仅 MyListtoOne 的 tempresultfile 并入 data; + /// 4. 旧 SPList/ShenheList/AllList 的多段 Union 查询改为内存 HashSet 去重实现,语义一致。 + /// + public class WYDocPrintService : IWYDocPrintService + { + private readonly IRepository _doc; + private readonly IRepository _dic; + private readonly IRepository _flowinfo; + private readonly IRepository _flowstep; + private readonly IRepository _flowtranct; + private readonly IRepository _groupuser; + private readonly IRepository _freegroup; + private readonly IRepository _freeflowinfo; + private readonly IRepository _zhipai; + private readonly IRepository _senduser; + private readonly IRepository _files; + private readonly YHDbContext _db; + private readonly SchClassOpenApi _sch; + + private static readonly string[] DictTypes = { "WYFL", "YZBM", "YZFL" }; + private static readonly string[] OfficeExts = { ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx" }; + + public WYDocPrintService( + IRepository doc, + IRepository dic, + IRepository flowinfo, + IRepository flowstep, + IRepository flowtranct, + IRepository groupuser, + IRepository freegroup, + IRepository freeflowinfo, + IRepository zhipai, + IRepository senduser, + IRepository files, + YHDbContext db, + SchClassOpenApi sch) + { + _doc = doc; + _dic = dic; + _flowinfo = flowinfo; + _flowstep = flowstep; + _flowtranct = flowtranct; + _groupuser = groupuser; + _freegroup = freegroup; + _freeflowinfo = freeflowinfo; + _zhipai = zhipai; + _senduser = senduser; + _files = files; + _db = db; + _sch = sch; + } + + #region 抄送 + + /// 抄送列表(旧 SendRdList:DocStatus 1/11,SendUserID 含本人;旧为教师内连接)。 + public async Task> SendRdListAsync(string name, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex) + { + var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId)).ToList(); + + if (!string.IsNullOrEmpty(name)) + docs = docs.Where(m => m.DocContent != null && m.DocContent.Contains(name)).ToList(); + if (!string.IsNullOrEmpty(userId)) + docs = docs.Where(m => m.SendUserID != null && m.SendUserID.IndexOf(userId) > -1).ToList(); + if (DateTime.TryParse(startDate, out var date1)) + docs = docs.Where(m => m.TakeDate >= date1).ToList(); + if (DateTime.TryParse(endDate, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); // 防止跨天 + docs = docs.Where(m => m.TakeDate <= date2).ToList(); + } + + docs = docs.Where(m => m.DocStatus == "1" || m.DocStatus == "11") + .OrderByDescending(m => m.SerID) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + var result = new List(); + foreach (var item in docs) + { + // 旧实现为教师内连接:UserID 不在校内教师列表的记录被丢弃 + if (!TryGetTea(teaMap, item.UserID, out var tea)) continue; + result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd")); + } + return result; + } + + /// 查看抄送人员详情(旧 SendDetailsList)。 + public async Task> SendDetailsListAsync(int serId) + { + var result = new List(); + var model = await _doc.FirstOrDefaultAsync(m => m.SerID == serId); + if (model?.SendUserID == null) return result; + + var ids = model.SendUserID.Split(',') + .Where(a => !string.IsNullOrEmpty(a) && int.TryParse(a, out _)) + .Select(int.Parse).ToArray(); + if (ids.Length == 0) return result; + + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", ids)); + var teaMap = teas.Where(t => int.TryParse(t.TeaSerId, out _)) + .GroupBy(t => int.Parse(t.TeaSerId)) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var id in ids) + { + teaMap.TryGetValue(id, out var tea); + result.Add(new SendDetailItem + { + TeaName = tea?.Name ?? "", + TeaPhoto = tea?.Photo ?? "", + }); + } + return result; + } + + /// 修改抄送人(旧 EditSend:SetChaoSend + 回写 WY_DocPrint.SendUserID)。 + public async Task EditSendAsync(int serId, string senduserid, string mysenduserid, string schSerId, string uid) + { + await SetChaoSendAsync(int.Parse(uid), schSerId, mysenduserid); + + var model = await _doc.FirstOrDefaultAsync(m => m.SerID == serId); + if (model != null) + { + model.SendUserID = senduserid; + await _doc.SaveChangesAsync(); + } + return "修改成功!"; + } + + #endregion + + #region 附件 + + /// 获取附件数据源(旧 filelist)。 + public async Task> FileListAsync(string ids) + { + var result = new List(); + if (string.IsNullOrEmpty(ids)) return result; + + var idArr = ids.Split(',') + .Where(a => !string.IsNullOrEmpty(a) && int.TryParse(a, out _)) + .Select(int.Parse).ToArray(); + if (idArr.Length == 0) return result; + + var files = await _files.FindAsync(a => idArr.Contains(a.SerID)); + return files.Select(a => new DocPrintFileItem + { + SerID = a.SerID, + FileTrueName = a.FileTrueName, + FileName = a.FileName, + FileSize = a.FileSize, + FileType = a.FileType, + DownTimes = a.DownTimes ?? 0, + }).ToList(); + } + + #endregion + + #region 文印 CRUD + + /// + /// 添加文印(旧 Create):落库 + 首步骤定位 + 附件 TopicID 回写 + 指派 upsert + 自由流程挂链 + 抄送。 + /// 旧短信/待办/已读未读推送降级为纯落库;旧 OtherData(IsNextStepMyShenhe) 新响应无槽位,不再返回。 + /// + public async Task CreateAsync(WYDocPrintSaveDto model) + { + var info = new WY_DocPrint + { + DocType = model.DocType, + DocStyle = model.DocStyle, + DPType = model.DPType, + DocShare = model.DocShare, + DocFix = model.DocFix, + DocNum = model.DocNum, + DocContent = model.DocContent, + UserID = model.UserID, + DocTitle = model.DocTitle, + EditDate = DateTime.Now, + TakeDate = model.TakeDate, + DocStatus = "0", + UpFile = model.UpFile, + explain = model.explain, + SchSerID = model.SchSerID, + flowid = model.flowid, + freestepid = model.freestepid, + SendUserID = model.SendUserID, + FreeUserID = model.FreeUserID, + zhipai = model.zhipai, + }; + await _doc.AddAsync(info); + await _doc.SaveChangesAsync(); + + if ((model.flowid ?? 0) > 0) + { + // 固定流程:定位首步骤(prestep == 0);旧短信/已读未读推送已降级 + info.DocStep = (await _flowstep.FindAsync(a => a.flowid == model.flowid && a.prestep == 0)) + .Select(a => a.SerID).FirstOrDefault(); + await _doc.SaveChangesAsync(); + } + else if ((model.freestepid ?? 0) > 0) + { + // 自由流程:无附加落库(旧短信/已读未读推送已降级) + } + else + { + // 无流程直接完成 + info.DocStatus = "1"; + await _doc.SaveChangesAsync(); + } + + // 附件:UpFile 逗号分隔 SerID 回写 TopicID + await BindFilesTopicAsync(model.UpFile, (int)info.SerID); + + // 指派 upsert + await UserFlowZhipaiAsync(int.Parse(model.UserID), model.flowid ?? 0, info.DocStep, + (int)info.SerID, model.zhipai, model.SchSerID, "Add"); + + // 自定义流程挂链 + 抄送 + if ((info.freestepid ?? 0) > 0) + await SetPrimarySteptIDAsync((int)info.SerID, info.freestepid ?? 0); + await SetChaoSendAsync(int.Parse(info.UserID), info.SchSerID, info.SendUserID); + + return (int)info.SerID; + } + + /// + /// 修改文印(旧 Edit):字段覆盖 + 状态复位 0 + 附件/指派/自由流程/抄送处理。 + /// 旧实现附件差异比对因 UpFile 已先被覆盖而成为死代码(新增附件不回写 TopicID),此处保持同样行为。 + /// + public async Task EditAsync(WYDocPrintSaveDto model) + { + var info = await _doc.FirstOrDefaultAsync(x => x.SerID == model.SerID); + if (info == null) throw new DException("未找到该文印记录!"); + + info.DocType = model.DocType; + info.DocStyle = model.DocStyle; + info.DPType = model.DPType; + info.DocShare = model.DocShare; + info.DocFix = model.DocFix; + info.DocNum = model.DocNum; + info.DocContent = model.DocContent; + info.UserID = model.UserID; + info.DocTitle = model.DocTitle; + info.EditDate = DateTime.Now; + info.TakeDate = model.TakeDate; + info.DocStatus = "0"; + // 旧实现最终回写 join(oldFids),即过滤空串后的新附件 id 串 + info.UpFile = string.Join(",", ParseIds(model.UpFile)); + info.explain = model.explain; + info.SchSerID = model.SchSerID; + info.flowid = model.flowid; + info.freestepid = model.freestepid; + info.SendUserID = model.SendUserID; + info.FreeUserID = model.FreeUserID; + info.zhipai = model.zhipai; + + if ((model.flowid ?? 0) > 0) + { + info.DocStep = (await _flowstep.FindAsync(a => a.flowid == model.flowid && a.prestep == 0)) + .Select(a => a.SerID).FirstOrDefault(); + info.freestepid = null; + } + + await _doc.SaveChangesAsync(); + + // 指派 upsert + await UserFlowZhipaiAsync(int.Parse(info.UserID), info.flowid ?? 0, info.DocStep, + (int)info.SerID, info.zhipai, info.SchSerID, "Add"); + + // 自定义流程挂链 + 抄送 + if ((model.freestepid ?? 0) > 0) + await SetPrimarySteptIDAsync((int)info.SerID, model.freestepid ?? 0); + await SetChaoSendAsync(int.Parse(info.UserID), info.SchSerID, info.SendUserID); + + return (int)info.SerID; + } + + /// 删除文印(旧 Delete:按 ids 批量物理删除;旧待办中心清理已降级)。 + public async Task DeleteAsync(string ids) + { + if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null"); + + var idArr = ids.Split(',') + .Where(a => !string.IsNullOrEmpty(a) && decimal.TryParse(a, out _)) + .Select(decimal.Parse).ToArray(); + var objlist = await _doc.FindAsync(m => idArr.Contains(m.SerID)); + foreach (var item in objlist) + { + await _doc.RemoveAsync(item); + } + await _doc.SaveChangesAsync(); + } + + /// 撤销(旧 Revoke:写撤销流水 + 更新单据状态;旧待办/短信/已读未读已降级)。 + public async Task RevokeAsync(FlowTranctDto model) + { + if (string.IsNullOrEmpty(model.remark) && model.actionstatus == 3) + model.remark = "撤销"; + + var tranct = new Common_flowtranctInfo + { + dataid = model.dataid, + stepid = model.stepid, + actionuserid = model.actionuserid, + actionstatus = model.actionstatus, + actiondate = DateTime.Now, + remark = model.remark, + undertaker = model.undertaker, + Pid = model.Pid, + DoneUserID = model.DoneUserID, + filespath = model.filespath, + datatype = DocPrintConstants.AppCode.ToString(), + }; + await _flowtranct.AddAsync(tranct); + await _flowtranct.SaveChangesAsync(); + + var docModel = await _doc.FirstOrDefaultAsync(m => m.SerID == model.dataid); + if (docModel != null) + { + docModel.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + + return "撤销成功!"; + } + + #endregion + + #region 列表 + + /// 我的文印列表(旧 MyList;旧 DocTypeName 误用 YZBM 字典,此处保持旧行为)。 + public async Task> MyListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex) + { + var docs = (await _doc.FindAsync(m => m.UserID == userId && m.SchSerID == schSerId)).ToList(); + + if (!string.IsNullOrEmpty(name)) + docs = docs.Where(m => m.DocContent != null && m.DocContent.Contains(name)).ToList(); + if (!string.IsNullOrEmpty(status)) + docs = docs.Where(m => m.DocStatus == status).ToList(); + if (DateTime.TryParse(startDate, out var date1)) + docs = docs.Where(m => m.TakeDate >= date1).ToList(); + if (DateTime.TryParse(endDate, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + docs = docs.Where(m => m.TakeDate <= date2).ToList(); + } + + docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate).ThenByDescending(m => m.EditDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + var result = new List(); + foreach (var item in docs) + { + TryGetTea(teaMap, item.UserID, out var tea); // 旧为左连接,缺失补空 + result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm", "YZBM")); + } + return result; + } + + /// + /// 全部文印列表(旧 AllList 原生 SQL 四分类:x=待我审(0) y=我已审(1) z=抄送我(2) m=我发起(3),按优先级去重)。 + /// 旧 Name 过滤的 SQL 拼接存在运算符优先级缺陷,此处按语义实现:内容/教师名/字典名任一命中。 + /// + public async Task> AllListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex) + { + int uid = int.Parse(userId); + + var docs = await _doc.FindAsync(m => m.SchSerID == schSerId); + var fixedGroupIds = (await _groupuser.FindAsync(b => b.userid == uid)) + .Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList(); + var freeGroupIds = (await _freegroup.FindAsync(b => b.userid == uid)) + .Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList(); + var tranctDataIds = (await _flowtranct.FindAsync(a => a.actionuserid == uid && a.datatype == DocPrintConstants.AppCode.ToString())) + .Select(a => a.dataid).ToHashSet(); + + bool IsPending(WY_DocPrint a) => + a.DocStatus == "0" + && ((a.flowid != null && a.zhipai == null && a.DocStep.HasValue && fixedGroupIds.Contains(a.DocStep.Value)) + || (a.flowid != null && a.zhipai != null && a.zhipai.Contains(userId)) + || (a.freestepid > 0 && freeGroupIds.Contains(a.freestepid ?? 0))); + + var taken = new HashSet(); + var combined = new List<(WY_DocPrint doc, int ctype)>(); + + foreach (var a in docs.Where(a => IsPending(a))) { combined.Add((a, 0)); taken.Add(a.SerID); } + foreach (var a in docs.Where(a => a.DocStatus != "3" && tranctDataIds.Contains((int)a.SerID) && !taken.Contains(a.SerID))) { combined.Add((a, 1)); taken.Add(a.SerID); } + foreach (var a in docs.Where(a => a.DocStatus == "1" && a.SendUserID != null && a.SendUserID.Contains(userId) && !taken.Contains(a.SerID))) { combined.Add((a, 2)); taken.Add(a.SerID); } + foreach (var a in docs.Where(a => a.UserID == userId && !taken.Contains(a.SerID))) { combined.Add((a, 3)); taken.Add(a.SerID); } + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + + IEnumerable<(WY_DocPrint doc, int ctype)> query = combined; + if (!string.IsNullOrEmpty(name)) + { + query = query.Where(t => + (t.doc.DocContent != null && t.doc.DocContent.IndexOf(name) >= 0) + || (t.doc.UserID != null && int.TryParse(t.doc.UserID, out var touid) && teaMap.TryGetValue(touid, out var tt) && tt.Name != null && tt.Name.IndexOf(name) >= 0) + || DictNameContains(dictMap, "WYFL", t.doc.DocType, name) + || DictNameContains(dictMap, "YZBM", t.doc.DocStyle, name) + || DictNameContains(dictMap, "YZFL", t.doc.DPType, name)); + } + + var pageItems = query.OrderByDescending(t => t.doc.SerID).ThenByDescending(t => t.doc.TakeDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var result = new List(); + foreach (var (doc, ctype) in pageItems) + { + TryGetTea(teaMap, doc.UserID, out var tea); + var item = await BuildListItemAsync(doc, tea, dictMap, "yyyy-MM-dd HH:mm"); + item.ctype = ctype; + result.Add(item); + } + return result; + } + + /// + /// 文印审核列表(旧 ShenheList:固定流程 + 指派 + 自由流程三路并集,HashSet 去重)。 + /// 旧实现的 Name 过滤作用在未被使用的查询上(死代码),此处保持 Name 不生效的旧行为。 + /// + public async Task> ShenheListAsync(string userId, string schSerId, int pageSize, int pageIndex) + { + var docs = await GetPendingShenheDocsAsync(userId, schSerId, requireFlowIdNonZero: false); + docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + var result = new List(); + foreach (var item in docs) + { + TryGetTea(teaMap, item.UserID, out var tea); + result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm")); + } + return result; + } + + /// + /// 审批列表(旧 SPList:待审(DS,zt=false)∪ 我已审(YS,zt=true,排除 DS),_disabled 即 zt)。 + /// + public async Task> SPListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex) + { + var userid = int.Parse(userId); + + // DS:待审集合(固定流程 flowid!=0 + 指派 + 自由流程) + var dsDocs = await GetPendingShenheDocsAsync(userId, schSerId, requireFlowIdNonZero: true); + var dsIds = dsDocs.Select(a => a.SerID).ToHashSet(); + + // YS:本人已审(actionstatus != 0),排除 DS + var ysDataIds = (await _flowtranct.FindAsync(b => b.actionuserid == userid + && b.datatype == DocPrintConstants.AppCode.ToString() && b.actionstatus != 0)) + .Select(b => b.dataid).ToHashSet(); + var ysDocs = (await _doc.FindAsync(s => s.SchSerID == schSerId && s.DocStatus != null)) + .Where(s => !dsIds.Contains(s.SerID) && ysDataIds.Contains((int)s.SerID)) + .ToList(); + + IEnumerable<(WY_DocPrint doc, bool zt)> query = dsDocs.Select(a => (a, false)) + .Concat(ysDocs.Select(a => (a, true))); + + if (DateTime.TryParse(startDate, out var date1)) + query = query.Where(m => m.doc.TakeDate >= date1); + if (DateTime.TryParse(endDate, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + query = query.Where(m => m.doc.TakeDate <= date2); + } + if (!string.IsNullOrEmpty(status)) + query = query.Where(m => m.doc.DocStatus == status); + if (!string.IsNullOrEmpty(name)) + query = query.Where(m => m.doc.DocContent != null && m.doc.DocContent.IndexOf(name) >= 0); + + var ordered = query.OrderBy(m => m.doc.DocStatus).ThenByDescending(m => m.doc.EditDate).ToList(); + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + var result = new List(); + foreach (var (doc, zt) in ordered.Skip(pageSize * pageIndex).Take(pageSize)) + { + TryGetTea(teaMap, doc.UserID, out var tea); + var item = await BuildListItemAsync(doc, tea, dictMap, "yyyy-MM-dd HH:mm"); + item._disabled = zt; // 我已审的记录不可再编辑(覆盖通用 _disabled 语义,与旧一致) + result.Add(item); + } + return result; + } + + /// 文印已审列表(旧 YishenList:本人审核流水关联,排除撤销与已领取)。 + public async Task> YishenListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex) + { + int uid = int.Parse(userId); + var tranctDataIds = (await _flowtranct.FindAsync(b => b.actionuserid == uid && b.datatype == DocPrintConstants.AppCode.ToString() && b.actionstatus != 11)) + .Select(b => b.dataid).ToHashSet(); + + var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus != "3")) + .Where(m => tranctDataIds.Contains((int)m.SerID)) + .ToList(); + + if (!string.IsNullOrEmpty(name)) + docs = docs.Where(m => m.DocContent != null && m.DocContent.Contains(name)).ToList(); + + docs = docs.OrderByDescending(m => m.SerID).ThenByDescending(m => m.TakeDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + var result = new List(); + foreach (var item in docs) + { + TryGetTea(teaMap, item.UserID, out var tea); + result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm")); + } + return result; + } + + /// 全部文印列表(旧 totalList,管理员视角,按状态/时间过滤)。 + public async Task> TotalListAsync(string name, string stateid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex) + { + var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId)).ToList(); + + if (DateTime.TryParse(starttime, out var date1)) + docs = docs.Where(m => m.TakeDate >= date1).ToList(); + if (DateTime.TryParse(endtime, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + docs = docs.Where(m => m.TakeDate <= date2).ToList(); + } + if (!string.IsNullOrEmpty(stateid)) + docs = docs.Where(m => m.DocStatus == stateid).ToList(); + if (!string.IsNullOrEmpty(name)) + docs = docs.Where(m => m.DocContent != null && m.DocContent.IndexOf(name) >= 0).ToList(); + + docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + var result = new List(); + foreach (var item in docs) + { + TryGetTea(teaMap, item.UserID, out var tea); + result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd")); + } + return result; + } + + /// + /// 获取当前一条文印详情(旧 MyListtoOne:单元素列表 + 附件明细; + /// 旧 OtherData.tempresultfile 并入条目 tempresultfile,userCenterUrl/teaPhoto 头像不再返回)。 + /// + public async Task> MyListtoOneAsync(int serId, string schSerId) + { + var result = new List(); + var doc = await _doc.FirstOrDefaultAsync(m => m.SerID == serId); + if (doc == null) return result; + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + if (!TryGetTea(teaMap, doc.UserID, out var tea)) return result; // 旧为教师内连接 + + var tranctAny = await _flowtranct.AnyAsync(a => a.dataid == serId && a.datatype == DocPrintConstants.AppCode.ToString()); + + var item = new DocPrintDetailItem + { + SerID = (int)doc.SerID, + DocContent = doc.DocContent, + DocType = doc.DocType, + DocTypeName = DictName(dictMap, "WYFL", doc.DocType), + DocStyle = doc.DocStyle, + DocStyleName = DictName(dictMap, "YZBM", doc.DocStyle), + DPType = doc.DPType, + DPTypeName = DictName(dictMap, "YZFL", doc.DPType), + EditDate = doc.EditDate.ToString("yyyy-MM-dd HH:mm"), + TakeDate = doc.TakeDate?.ToString("yyyy-MM-dd"), + UserID = doc.UserID, + teaname = tea.Name, + TeaPhoto = tea.Photo, + UpFile = doc.UpFile, + DocTitle = doc.DocTitle, + DocStatus = doc.DocStatus, + DocStatusName = await GetStatusByCodeAsync(doc.DocStatus, doc.DocStep ?? 0, doc.zhipai, doc.freestepid, teaMap), + DocStep = doc.DocStep?.ToString() ?? "", + explain = doc.explain, + flowid = doc.flowid?.ToString() ?? "", + DocShare = doc.DocShare, + DocFix = doc.DocFix, + DocNum = doc.DocNum, + _disabled = !(doc.DocStatus == "0" && !tranctAny), // 旧 GetEditDeleteStatus + FreeUserID = doc.FreeUserID, + SendUserID = doc.SendUserID, + freestepid = doc.freestepid, + zhipai = doc.zhipai, + rid = doc.rid, + reid = doc.reid, + grade = doc.grade, + Examtype = doc.Examtype, + tempresultfile = new List(), + }; + + // 附件明细:office 文件替换为 .pdf 预览名并拼接文件 Web 前缀 + var fids = ParseIds(doc.UpFile); + if (fids.Count > 0) + { + var fileUrl = YhCore.Common.Config.Config.FileWebPath ?? ""; + var files = await _files.FindAsync(a => fids.Contains(a.SerID)); + item.tempresultfile = files.Select(a => new DocPrintFileItem + { + SerID = a.SerID, + FileTrueName = a.FileTrueName, + FileName = fileUrl + (OfficeExts.Contains(a.FileType) ? a.FileName?.Replace(a.FileType, ".pdf") : a.FileName), + FileSize = a.FileSize, + FileType = a.FileType, + DownTimes = a.DownTimes ?? 0, + }).ToList(); + } + + result.Add(item); + return result; + } + + /// 绑定流程(旧 Bindflowinfo:本校启用流程下拉)。 + public async Task> BindflowinfoAsync(string schSerId, int flowType) + { + var list = await _flowinfo.FindAsync(a => a.flowType == flowType && a.schSerID == schSerId && a.enable == 1); + return list.OrderBy(m => m.SerID) + .Select(item => new BindFlowOption + { + SerID = item.SerID.ToString(), + flowname = item.flowname, + }).ToList(); + } + + #endregion + + #region 审核动作 + + /// + /// 审核确认(旧 shenheok):事务内写流水 + 推进固定/自由流程或终态; + /// 重复审核 / 已处理单据按旧语义抛业务异常;旧短信/待办/已读未读已降级。 + /// + public async Task ShenheOkAsync(FlowTranctDto model) + { + using var tx = await _db.Database.BeginTransactionAsync(); + try + { + var rdmodel = await _doc.FirstOrDefaultAsync(m => m.SerID == model.dataid); + if (rdmodel == null) throw new DException("未找到该文印记录!"); + + if (rdmodel.DocStatus != "0") + { + var statusName = rdmodel.DocStatus switch + { + "1" => "已印好", + "2" => "已拒绝", + "3" => "已撤销", + "11" => "已领取", + _ => "", + }; + throw new DException(statusName); + } + + var audited = await _flowtranct.AnyAsync(a => a.dataid == model.dataid + && a.datatype == DocPrintConstants.AppCode.ToString() && a.stepid == model.stepid); + if (audited) throw new DException("已审核"); + + if (string.IsNullOrEmpty(model.remark)) + model.remark = model.actionstatus == 1 ? "同意" : "不同意"; + + rdmodel.zhipai = model.undertaker; + + var tranct = new Common_flowtranctInfo + { + dataid = model.dataid, + stepid = model.stepid, + actionuserid = model.actionuserid, + actionstatus = model.actionstatus, + actiondate = DateTime.Now, + remark = model.remark, + undertaker = model.undertaker, + Pid = model.Pid, + datatype = DocPrintConstants.AppCode.ToString(), + }; + await _flowtranct.AddAsync(tranct); + await _flowtranct.SaveChangesAsync(); + + if (model.actionstatus == 1) + { + if (rdmodel.DocStep == null || rdmodel.DocStep == 0) + { + // 自由流程:按 laststep 推进下一步 + var stepid = (await _freeflowinfo.FindAsync(a => a.laststep == model.stepid)) + .Select(a => a.SerID).FirstOrDefault(); + if (stepid > 0) + { + rdmodel.freestepid = stepid; + await _doc.SaveChangesAsync(); + } + else + { + // 无下一步:流程结束(旧抄送短信推送已降级) + rdmodel.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + } + else + { + // 固有流程:按 prestep 推进下一步 + if (!int.TryParse(model.Pid, out var flowid)) + throw new DException("审核失败:Pid 参数错误"); + var steps = await _flowstep.FindAsync(a => a.flowid == flowid && a.prestep == model.stepid); + if (steps.Count > 0) + { + rdmodel.DocStep = steps.Select(a => a.SerID).First(); + await _doc.SaveChangesAsync(); + } + else + { + rdmodel.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + } + } + else + { + // 拒绝:直接终态 + rdmodel.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + + await tx.CommitAsync(); + return "已审核"; + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + /// + /// 领取确认(旧 AffirmApplyforOk):写 actionstatus=11 流水 + 单据置为已领取(11)。 + /// 旧代码查重用 datatype=="1010" 而写入用 AppCode(1003),导致查重永远为空; + /// 此处统一为 1003 修正重复领取校验。 + /// + public async Task AffirmApplyforOkAsync(FlowTranctDto model) + { + var itt = await _doc.FirstOrDefaultAsync(m => m.SerID == model.dataid); + if (itt == null) throw new DException("未找到该文印记录!"); + + var claimed = await _flowtranct.AnyAsync(a => a.dataid == model.dataid + && a.datatype == DocPrintConstants.AppCode.ToString() && a.actionstatus == 11); + if (claimed) throw new DException("已领取,不能重复提交!"); + + var info = new Common_flowtranctInfo + { + remark = string.IsNullOrEmpty(model.remark) ? "已领取" : model.remark, + stepid = model.stepid, + dataid = model.dataid, + actionuserid = model.actionuserid, + actionstatus = 11, + datatype = DocPrintConstants.AppCode.ToString(), + actiondate = DateTime.Now, + }; + await _flowtranct.AddAsync(info); + + itt.DocStatus = "11"; + await _flowtranct.SaveChangesAsync(); + await _doc.SaveChangesAsync(); + + return "已通知!"; + } + + #endregion + + #region 指派 + + /// 查询指派(旧 GetUserZhipai:取最近一次指派记录)。 + public async Task GetUserZhipaiAsync(int userid, string schSerId, int? flowid, int? flowstepid, string type) + { + if ((flowid ?? 0) <= 0) return null; + + var zhipai = (await _zhipai.FindAsync(a => a.UserID == userid && a.SchSerID == schSerId + && a.FlowID == flowid && a.FlowStepID == flowstepid && a.type == type)) + .OrderByDescending(a => a.MSerID) + .FirstOrDefault()?.zhipai; + return zhipai; + } + + /// + /// 指派 upsert(旧 UserFlowZhipai:flowid>0 时按六元组查重,有则更新无则新增;异常按旧实现吞掉不回滚主流程)。 + /// + private async Task UserFlowZhipaiAsync(int uid, int flowid, int? flowstepid, int dataid, string zhipai, string schSerId, string type) + { + try + { + if (flowid <= 0) return; + + var list = await _zhipai.FirstOrDefaultAsync(a => a.UserID == uid && a.MSerID == dataid + && a.SchSerID == schSerId && a.FlowID == flowid && a.FlowStepID == flowstepid && a.type == type); + if (list == null) + { + await _zhipai.AddAsync(new Common_UserFlowZhipai + { + UserID = uid, + MSerID = dataid, + FlowID = flowid, + FlowStepID = flowstepid, + type = type, + zhipai = zhipai, + SchSerID = schSerId, + }); + } + else + { + list.FlowID = flowid; + list.FlowStepID = flowstepid; + list.type = type; + list.zhipai = zhipai; + list.SchSerID = schSerId; + } + await _zhipai.SaveChangesAsync(); + } + catch + { + // 旧实现仅记日志不抛出,保持一致 + } + } + + #endregion + + #region 统计 + + /// 文印数量按月统计(旧 List1:按年月分组 + MonthDiff 补零;起止时间任一为空时不补零)。 + public async Task> List1Async(string starttime, string endtime, string schSerId) + { + var docs = await _doc.FindAsync(m => m.SchSerID == schSerId); + if (DateTime.TryParse(starttime, out var date1)) + docs = docs.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endtime, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + docs = docs.Where(m => m.EditDate <= date2).ToList(); + } + + var grouped = docs.OrderBy(m => m.SerID) + .GroupBy(m => new { m.EditDate.Year, m.EditDate.Month }) + .Select(g => new MonthStatItem + { + dateyear = g.Key.Year, + datemonth = g.Key.Month, + num = g.Count(), + DocNum = g.Sum(a => a.DocNum), + }) + .OrderBy(a => a.dateyear) + .ToList(); + + // 月份区间补零(旧 MonthDiff 跨年补零;参数缺失时跳过,避免旧实现 MinValue 起点的异常输出) + if (DateTime.TryParse(starttime, out var s) && DateTime.TryParse(endtime, out var e)) + { + var months = MonthDiff(s, e.AddDays(1).AddMilliseconds(-1)); + var result = new List(); + foreach (var m in months) + { + var year = int.Parse(m.Split(',')[0]); + var month = int.Parse(m.Split(',')[1]); + var hit = grouped.FirstOrDefault(r => r.dateyear == year && r.datemonth == month); + result.Add(new MonthStatItem { dateyear = year, datemonth = month, num = hit?.num ?? 0, DocNum = hit?.DocNum ?? 0 }); + } + return result; + } + + return grouped; + } + + /// 文印详情统计列表(旧 List2:非待审单据按申请时间倒序分页)。 + public async Task> List2Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex) + { + var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus != "0")).ToList(); + if (DateTime.TryParse(starttime, out var date1)) + docs = docs.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endtime, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + docs = docs.Where(m => m.EditDate <= date2).ToList(); + } + + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + + var full = new List(); + foreach (var m in docs.Where(m => TryGetTea(teaMap, m.UserID, out _)).OrderBy(m => m.SerID)) + { + TryGetTea(teaMap, m.UserID, out var tea); + full.Add(new DocDetailStatItem + { + UserID = m.UserID, + teaname = tea.Name, + DocContent = m.DocContent, + DocType = m.DocType, + DocTypeName = DictName(dictMap, "WYFL", m.DocType), + DocStyle = m.DocStyle, + DocStyleName = DictName(dictMap, "YZBM", m.DocStyle), + DPType = m.DPType, + DPTypeName = DictName(dictMap, "YZFL", m.DPType), + EditDate = m.EditDate.ToString("yyyy-MM-dd"), + UpFile = m.UpFile, + DocTitle = m.DocTitle, + DocNum = m.DocNum, + DocStatus = m.DocStatus, + DocStatusName = await GetStatusByCodeAsync(m.DocStatus, m.DocStep ?? 0, m.zhipai, m.freestepid, teaMap), + }); + } + + return full.OrderByDescending(a => a.EditDate) + .Skip(pageSize * pageIndex).Take(pageSize) + .ToList(); + } + + /// 文印数量按用户排名(旧 List3)。 + public async Task> List3Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex) + { + var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus == "1")).ToList(); + if (DateTime.TryParse(starttime, out var date1)) + docs = docs.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endtime, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + docs = docs.Where(m => m.EditDate <= date2).ToList(); + } + + var teaMap = await GetTeaMapAsync(schSerId); + + var grouped = docs.OrderBy(m => m.SerID) + .Where(m => TryGetTea(teaMap, m.UserID, out _)) // 旧为教师内连接 + .GroupBy(m => new { m.UserID, Name = GetTeaName(teaMap, m.UserID) }) + .Select(g => new UserRankItem + { + UserID = g.Key.UserID, + teaname = g.Key.Name, + num = g.Count(), + DocNum = g.Sum(a => a.DocNum), + }) + .OrderByDescending(a => a.num) + .ToList(); + + return grouped.Skip(pageSize * pageIndex).Take(pageSize).ToList(); + } + + /// 按月份统计明细(旧 List4:指定年月已印好单据,按申请时间倒序分页)。 + public async Task> List4Async(int year, int month, string schSerId, int pageSize, int pageIndex) + { + var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId + && m.DocStatus == "1" && m.EditDate.Year == year && m.EditDate.Month == month)) + .OrderByDescending(a => a.EditDate) + .Skip(pageSize * pageIndex).Take(pageSize) + .ToList(); + + return await BuildStatListAsync(docs, schSerId); + } + + /// 按用户统计明细(旧 List5:指定用户已印好单据,按申请时间倒序分页)。 + public async Task> List5Async(string userid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex) + { + var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.UserID == userid && m.DocStatus == "1")).ToList(); + if (DateTime.TryParse(starttime, out var date1)) + docs = docs.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endtime, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + docs = docs.Where(m => m.EditDate <= date2).ToList(); + } + + docs = docs.OrderByDescending(a => a.EditDate) + .Skip(pageSize * pageIndex).Take(pageSize) + .ToList(); + + return await BuildStatListAsync(docs, schSerId); + } + + #endregion + + #region 内部辅助:查询集合 + + /// + /// 待审单据集合(旧 SPList/ShenheList/GetShnheCount 的三路 Union,HashSet 去重): + /// 固定流程组命中 / 指派包含本人 / 自由流程组命中。 + /// requireFlowIdNonZero:旧 SPList 固定分支带 flowid != 0 条件,ShenheList 仅 flowid != null。 + /// + private async Task> GetPendingShenheDocsAsync(string userId, string schSerId, bool requireFlowIdNonZero) + { + var userid = int.Parse(userId); + var docs = await _doc.FindAsync(a => a.SchSerID == schSerId && a.DocStatus == "0"); + var fixedGroupIds = (await _groupuser.FindAsync(b => b.userid == userid)) + .Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList(); + var freeGroupIds = (await _freegroup.FindAsync(b => b.userid == userid)) + .Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList(); + + var fixedFlowOk = requireFlowIdNonZero + ? new Func(a => a.flowid != null && a.flowid != 0) + : new Func(a => a.flowid != null); + + var result = new HashSet(); + var list = new List(); + foreach (var a in docs) + { + bool match = false; + + // 固定流程:DocStep 命中本人组(旧 zhipai == null 分支) + if (fixedFlowOk(a) && a.zhipai == null && a.DocStep.HasValue && fixedGroupIds.Contains(a.DocStep.Value)) + match = true; + + // 指派:zhipai 含本人 + if (!match && a.DocStatus == "0" && a.zhipai != null && a.flowid != null + && a.zhipai.Contains(userId) && a.SchSerID == schSerId) + match = true; + + // 自由流程:freestepid 命中本人自由组 + if (!match && a.SchSerID == schSerId && a.freestepid > 0 && freeGroupIds.Contains(a.freestepid ?? 0)) + match = true; + + if (match && result.Add(a.SerID)) + { + list.Add(a); + } + } + return list; + } + + #endregion + + #region 内部辅助:教师 / 字典 + + private async Task> GetTeaMapAsync(string schSerId) + { + var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null); + return teas + .Where(t => int.TryParse(t.TeaSerId, out _)) + .GroupBy(t => int.Parse(t.TeaSerId)) + .ToDictionary(g => g.Key, g => (g.First().Name, g.First().Photo)); + } + + private static bool TryGetTea(Dictionary teaMap, string userId, out (string Name, string Photo) tea) + { + tea = default; + return int.TryParse(userId, out var uid) && teaMap.TryGetValue(uid, out tea); + } + + private static string GetTeaName(Dictionary teaMap, string userId) + => TryGetTea(teaMap, userId, out var tea) ? tea.Name : ""; + + /// 字典名缓存(旧 Get_DictionaryOnly:typeID+CodeID 定位,本校或通用,按 Enable 升序取首个)。 + private async Task> GetDictMapAsync(string schSerId) + { + var list = await _dic.FindAsync(a => a.typeID == "WYFL" || a.typeID == "YZBM" || a.typeID == "YZFL"); + return list + .Where(a => a.Schserid == schSerId || string.IsNullOrEmpty(a.Schserid)) + .GroupBy(a => a.typeID + "|" + a.CodeID) + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.Enable).Select(x => x.Name).FirstOrDefault() ?? ""); + } + + private static string DictName(Dictionary dictMap, string type, string code) + => code != null && dictMap.TryGetValue(type + "|" + code, out var name) ? name : ""; + + private static bool DictNameContains(Dictionary dictMap, string type, string code, string name) + { + var dictName = DictName(dictMap, type, code); + return dictName != null && dictName.IndexOf(name) >= 0; + } + + private static List ParseIds(string commaIds) + { + if (string.IsNullOrEmpty(commaIds)) return new List(); + return commaIds.Split(',') + .Where(a => !string.IsNullOrEmpty(a) && int.TryParse(a, out _)) + .Select(int.Parse).ToList(); + } + + #endregion + + #region 内部辅助:状态 / 列表项 + + /// 状态文案(旧 GetStatusByCode:0=待审人名+审核中,1=印刷中,2=审核未通过,3=已撤销,11=已印刷;teaMap 为空时走 SDK 兜底)。 + private async Task GetStatusByCodeAsync(string statusid, int stepid, string zhipai, int? freestepid, Dictionary teaMap) + { + if (statusid == "0") + { + if (stepid > 0) + { + // 固有流程 + var userids = (await _groupuser.FindAsync(a => a.usergroup == stepid)) + .Select(a => a.userid ?? 0).Where(u => u > 0).Distinct().ToList(); + if (!string.IsNullOrEmpty(zhipai)) + { + var ids = zhipai.Split(',').Where(s => int.TryParse(s, out _)).Select(int.Parse).ToList(); + userids = userids.Where(u => ids.Contains(u)).ToList(); + } + var names = await GetTeaNamesAsync(userids, teaMap); + return string.Join(",", names) + " 审核中"; + } + if ((freestepid ?? 0) > 0) + { + // 自由流程 + var userids = (await _freegroup.FindAsync(a => a.usergroup == freestepid)) + .Select(a => a.userid ?? 0).Where(u => u > 0).Distinct().ToList(); + var names = await GetTeaNamesAsync(userids, teaMap); + return string.Join(",", names) + " 审核中"; + } + return ""; + } + if (statusid == "1") return "印刷中"; + if (statusid == "2") return "审核未通过"; + if (statusid == "3") return "已撤销"; + if (statusid == "11") return "已印刷"; + return ""; + } + + /// 按 id 顺序取教师名:校内映射优先(teaMap 可为 null),缺失的批量走 SDK 兜底。 + private async Task> GetTeaNamesAsync(List ids, Dictionary teaMap) + { + var map = teaMap ?? new Dictionary(); + var missing = ids.Where(i => !map.ContainsKey(i)).Distinct().ToList(); + var fallback = new Dictionary(); + if (missing.Count > 0) + { + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", missing)); + fallback = teas.Where(t => int.TryParse(t.TeaSerId, out _)) + .GroupBy(t => int.Parse(t.TeaSerId)) + .ToDictionary(g => g.Key, g => g.First().Name ?? ""); + } + return ids.Select(i => map.TryGetValue(i, out var t) ? t.Name : fallback.TryGetValue(i, out var n) ? n : "").ToList(); + } + + /// 是否禁编辑(旧 GetEditDeleteStatus:待审且无任何审核流水才可编辑)。 + private Task GetEditDeleteStatusAsync(int dateid, string status) + { + if (status == "0") + return _flowtranct.AnyAsync(a => a.dataid == dateid && a.datatype == DocPrintConstants.AppCode.ToString()); + return Task.FromResult(true); + } + + private async Task BuildListItemAsync(WY_DocPrint item, (string Name, string Photo) tea, + Dictionary dictMap, string editDateFormat, string docTypeDictType = "WYFL") + { + return new DocPrintListItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DocType, + DocTypeName = DictName(dictMap, docTypeDictType, item.DocType), + DocStyle = item.DocStyle, + DocStyleName = DictName(dictMap, "YZBM", item.DocStyle), + DPType = item.DPType, + DPTypeName = DictName(dictMap, "YZFL", item.DPType), + EditDate = item.EditDate.ToString(editDateFormat), + TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = tea.Name, + TeaPhoto = tea.Photo, + DocShare = item.DocShare, + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = await GetStatusByCodeAsync(item.DocStatus, item.DocStep ?? 0, item.zhipai, item.freestepid, null), + DocStep = item.DocStep, + flowid = item.flowid, + _disabled = await GetEditDeleteStatusAsync((int)item.SerID, item.DocStatus), + FreeUserID = item.FreeUserID, + SendUserID = item.SendUserID, + freestepid = item.freestepid, + explain = item.explain, + }; + } + + /// 统计明细条目(旧 List4/List5 投影,editstatus 对应旧 Common.GetEditDeleteStatus)。 + private async Task> BuildStatListAsync(List docs, string schSerId) + { + var teaMap = await GetTeaMapAsync(schSerId); + var dictMap = await GetDictMapAsync(schSerId); + var firstStepIds = (await _flowstep.FindAsync(m => m.prestep == 0)) + .Select(m => m.SerID).ToHashSet(); + + var result = new List(); + foreach (var item in docs) + { + TryGetTea(teaMap, item.UserID, out var tea); + bool editstatus = true; + if (item.DocStatus != "1" && item.DocStatus != "2" && item.DocStep.HasValue) + editstatus = !firstStepIds.Contains(item.DocStep.Value); // 一审可编辑 → false + result.Add(new StatListItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DocType, + DocTypeName = DictName(dictMap, "WYFL", item.DocType), + DocStyle = item.DocStyle, + DocStyleName = DictName(dictMap, "YZBM", item.DocStyle), + DPType = item.DPType, + DPTypeName = DictName(dictMap, "YZFL", item.DPType), + EditDate = item.EditDate.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = tea.Name, + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = await GetStatusByCodeAsync(item.DocStatus, item.DocStep ?? 0, item.zhipai, item.freestepid, null), + editstatus = editstatus, + DocStep = item.DocStep, + flowid = item.flowid, + }); + } + return result; + } + + #endregion + + #region 内部辅助:流程挂链 / 抄送 / 附件 + + /// 自由流程挂链(旧 ApproveFlow.SetPrimarySteptID:从 stepid 起沿 laststep 链回写 dataid)。 + private async Task SetPrimarySteptIDAsync(int dataid, int stepid) + { + var node = await _freeflowinfo.FirstOrDefaultAsync(a => a.SerID == stepid); + if (node != null) + { + node.dataid = dataid; + await _freeflowinfo.SaveChangesAsync(); + } + + var nextSer = stepid; + while (true) + { + var next = await _freeflowinfo.FirstOrDefaultAsync(a => a.laststep == nextSer); + if (next == null) break; + next.dataid = dataid; + await _freeflowinfo.SaveChangesAsync(); + nextSer = next.SerID; + } + } + + /// 抄送人落库(旧 ApproveFlow.SetChaoSend:按用户+flowType 全删再插)。 + private async Task SetChaoSendAsync(int uid, string schSerId, string senduserid) + { + var olds = await _senduser.FindAsync(a => a.flowType == DocPrintConstants.AppCode && a.userid == uid); + foreach (var old in olds) + { + await _senduser.RemoveAsync(old); + } + await _senduser.SaveChangesAsync(); + + await _senduser.AddAsync(new Common_SendUserinfo + { + userid = uid, + flowType = DocPrintConstants.AppCode, + Senduserid = senduserid, + schserid = schSerId, + createTime = DateTime.Now, + }); + await _senduser.SaveChangesAsync(); + } + + /// 附件 TopicID 回写(旧 Create 的 Attach + SetModifiedProperty("TopicID"))。 + private async Task BindFilesTopicAsync(string upFile, int topicId) + { + if (string.IsNullOrEmpty(upFile)) return; + + var fids = ParseIds(upFile); + foreach (var fid in fids) + { + var file = await _files.FirstOrDefaultAsync(x => x.SerID == fid); + if (file != null) + { + file.TopicID = topicId; + await _files.UpdateAsync(file); + } + } + await _files.SaveChangesAsync(); + } + + /// 月份间隔(旧 MonthDiff:跨年分段补零)。 + private static List MonthDiff(DateTime start, DateTime end) + { + var months = new List(); + int sMonth = start.Month; + int sYear = start.Year; + int eMonth = end.Month; + int eYear = end.Year; + + if (end.Year > start.Year) + { + var tempStart = sMonth; + while (tempStart <= 12) + { + months.Add(sYear + "," + tempStart); + tempStart++; + } + var tempEnd = 1; + while (tempEnd <= eMonth) + { + months.Add(eYear + "," + tempEnd); + tempEnd++; + } + } + else + { + var tempStart = sMonth; + var tempEnd = eMonth; + while (tempStart <= tempEnd) + { + months.Add(eYear + "," + tempStart); + tempStart++; + } + } + return months; + } + + #endregion + } +} diff --git a/src/YhCore.Service/Impl/WYSHRoleService.cs b/src/YhCore.Service/Impl/WYSHRoleService.cs new file mode 100644 index 0000000..74852a9 --- /dev/null +++ b/src/YhCore.Service/Impl/WYSHRoleService.cs @@ -0,0 +1,2658 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Yh.Platform.Abstractions; +using Yh.SchClass.OpenApi; +using YhCore.Common.Config; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Model.Vo; +using YhCore.Repository; +using YhCore.Service.Interfaces; + +namespace YhCore.Service.Impl +{ + /// + /// 审核角色业务服务(移植旧 BestSchool.DocPrint.WY_SHRoleP,45 接口中可落地部分)。 + /// 降级说明: + /// 1. 旧 SendMessage/SendUnread/Backlog(短信、已读未读、待办中心)新项目缺失,全部降级为纯落库; + /// 2. 教师/头像改走教务 SDK(Yh.SchClass.OpenApi),教师标识用 teaId(TeaSerId)对齐新教务; + /// 3. 签名存文件系统(DES 加密,Config.FilePath 替代旧 Server.MapPath("~/UploadFile/")); + /// 4. 旧 ServiceResult 的 Data/OtherData/Page 三段合并为单个 Result 对象 + /// (getshenheFlow / getStepInfo / getStepByJE / getdocOftable)。 + /// 延后:Excel 导入导出(UpfileExcelSHRole/downMBOfSHRole/downUpfileSHRoleFK/ExceltotalListSHRole/WYRDExceldaochu)、审批单 PDF(DwonWYSQD)。 + /// + public class WYSHRoleService : IWYSHRoleService + { + private readonly IRepository _role; + private readonly IRepository _shTea; + private readonly IRepository _bshTea; + private readonly IRepository _groupSHRole; + private readonly IRepository _docSH; + private readonly IRepository _docNum; + private readonly IRepository _regetJERd; + private readonly IRepository _dic; + private readonly IRepository _dicOrd; + private readonly IRepository _flowinfo; + private readonly IRepository _flowstep; + private readonly IRepository _flowtranct; + private readonly IRepository _doc; + private readonly IRepository _upfiles; + private readonly IRepository _senduser; + private readonly IWYDocPrintService _wyDocPrint; + private readonly SchClassOpenApi _sch; + + public WYSHRoleService( + IRepository role, + IRepository shTea, + IRepository bshTea, + IRepository groupSHRole, + IRepository docSH, + IRepository docNum, + IRepository regetJERd, + IRepository dic, + IRepository dicOrd, + IRepository flowinfo, + IRepository flowstep, + IRepository flowtranct, + IRepository doc, + IRepository upfiles, + IRepository senduser, + IWYDocPrintService wyDocPrint, + SchClassOpenApi sch) + { + _role = role; + _shTea = shTea; + _bshTea = bshTea; + _groupSHRole = groupSHRole; + _docSH = docSH; + _docNum = docNum; + _regetJERd = regetJERd; + _dic = dic; + _dicOrd = dicOrd; + _flowinfo = flowinfo; + _flowstep = flowstep; + _flowtranct = flowtranct; + _doc = doc; + _upfiles = upfiles; + _senduser = senduser; + _wyDocPrint = wyDocPrint; + _sch = sch; + } + + #region 审核角色管理 + + public async Task GetIsSHRoleAsync(string schSerId) + { + var list = await _dic.FindAsync(a => a.Schserid == schSerId && a.typeID == "isUseSHRole"); + return list.Count > 0 ? list[0].CodeID ?? "0" : "0"; + } + + public async Task SaveIsSHRoleAsync(string schSerId, string val) + { + var list = await _dic.FindAsync(a => a.Schserid == schSerId && a.typeID == "isUseSHRole"); + if (list.Count > 0) + { + list[0].CodeID = val; + await _dic.SaveChangesAsync(); + } + else + { + await _dic.AddAsync(new Common_Dictionary + { + Schserid = schSerId, + typeID = "isUseSHRole", + typeName = "是否使用审核角色", + CodeID = val, + Name = "1使用0不使用", + Enable = 1, + }); + await _dic.SaveChangesAsync(); + } + return "ok"; + } + + public async Task> GetSHRoleAsync(string schSerId) + { + var list = (await _role.FindAsync(a => a.SchSerId == schSerId)) + .OrderByDescending(a => a.inserttime).ToList(); + var listSHTea = await _shTea.FindAsync(a => a.SchSerID == schSerId); + var listBSHTea = await _bshTea.FindAsync(a => a.SchSerID == schSerId); + + var rlt = new List(); + foreach (var item in list) + { + var tp = new WYSHRoleExt + { + SerID = item.SerID, + SchSerId = item.SchSerId, + SHRoleName = item.SHRoleName, + inserttime = item.inserttime, + writor = item.writor, + }; + var fdSHTea = listSHTea.Where(a => a.SHRoleID == item.SerID).ToList(); + if (fdSHTea.Count > 0) + tp.SHTea = fdSHTea; + + var fdBSHTea = listBSHTea.Where(a => a.SHRoleID == item.SerID).ToList(); + if (fdBSHTea.Count > 0) + tp.BSHTea = fdBSHTea; + + rlt.Add(tp); + } + return rlt; + } + + public async Task UpdateSHRoleNameAsync(string schSerId, string userId, decimal serId, string roleName) + { + var list = await _role.FindAsync(a => + a.SchSerId == schSerId && a.SerID != serId && a.SHRoleName == roleName); + if (list.Count > 0) + throw new DException("审核角色名称重复了,更新失败!"); + + var list2 = await _role.FindAsync(a => a.SchSerId == schSerId && a.SerID == serId); + if (list2.Count > 0) + { + list2[0].SHRoleName = roleName; + list2[0].writor = userId; + list2[0].inserttime = DateTime.Now; + await _role.SaveChangesAsync(); + } + return "ok"; + } + + public async Task EditSHRoleAsync(string schSerId, string userId, WYSHRoleExt roleInfo) + { + if (roleInfo.SerID == 0) + { + // add + var list = await _role.FindAsync(a => a.SchSerId == schSerId && a.SHRoleName == roleInfo.SHRoleName); + if (list.Count > 0) + throw new DException("审核角色名称已经使用,添加失败!"); + + var tp = new WY_SHRole + { + SchSerId = schSerId, + SHRoleName = roleInfo.SHRoleName, + inserttime = DateTime.Now, + writor = userId, + }; + await _role.AddAsync(tp); + await _role.SaveChangesAsync(); + + await EditSHTeaAsync(schSerId, userId, tp.SerID, roleInfo.SHTea); + await EditBSHTeaAsync(schSerId, userId, tp.SerID, roleInfo.BSHTea); + } + else + { + // update(旧实现取 dup 列表 [0] 属潜在越界 bug,此处改为按 SerID 取实体) + var list = await _role.FindAsync(a => + a.SchSerId == schSerId && a.SerID != roleInfo.SerID && a.SHRoleName == roleInfo.SHRoleName); + if (list.Count > 0) + throw new DException("审核角色名称已经使用,更新失败!"); + + var tp = await _role.FirstOrDefaultAsync(a => a.SerID == roleInfo.SerID); + if (tp != null) + { + tp.SHRoleName = roleInfo.SHRoleName; + await _role.SaveChangesAsync(); + } + await EditSHTeaAsync(schSerId, userId, roleInfo.SerID, roleInfo.SHTea); + await EditBSHTeaAsync(schSerId, userId, roleInfo.SerID, roleInfo.BSHTea); + } + return "ok"; + } + + public async Task DelSHRoleAsync(string schSerId, List listSHRoleId) + { + var ids = listSHRoleId ?? new List(); + var list = await _role.FindAsync(a => a.SchSerId == schSerId && ids.Contains(a.SerID)); + var listSHTea = await _shTea.FindAsync(a => a.SchSerID == schSerId && ids.Contains(a.SHRoleID)); + var listBSHTea = await _bshTea.FindAsync(a => a.SchSerID == schSerId && ids.Contains(a.SHRoleID)); + + if (list.Count > 0) + { + foreach (var item in list) + await _role.RemoveAsync(item); + } + if (listSHTea.Count > 0) + { + foreach (var item in listSHTea) + await _shTea.RemoveAsync(item); + } + if (listBSHTea.Count > 0) + { + foreach (var item in listBSHTea) + await _bshTea.RemoveAsync(item); + } + await _role.SaveChangesAsync(); + return "ok"; + } + + public async Task DelSHTeaRowAsync(string schSerId, decimal serId) + { + var list = await _shTea.FindAsync(a => a.SchSerID == schSerId && a.SerID == serId); + if (list.Count > 0) + { + await _shTea.RemoveAsync(list[0]); + await _shTea.SaveChangesAsync(); + } + return "ok"; + } + + public async Task EditSHTeaRowAsync(string schSerId, string userId, WY_SHRole_SHTea data) + { + var list = await _shTea.FindAsync(a => + a.SchSerID == schSerId && a.SHRoleID == data.SHRoleID && a.SerID != data.SerID && a.SHTeaID == data.SHTeaID); + if (list.Count > 0) + throw new DException("审核教师已经设置!"); + + if (data.SerID == 0) + { + // add + await _shTea.AddAsync(new WY_SHRole_SHTea + { + SchSerID = schSerId, + writor = userId, + inserttime = DateTime.Now, + SHTeaID = data.SHTeaID, + SHTeaName = data.SHTeaName, + SHRoleID = data.SHRoleID, + AndOr = data.AndOr, + }); + } + else + { + // update + var list2 = await _shTea.FindAsync(a => a.SchSerID == schSerId && a.SerID == data.SerID); + if (list2.Count > 0) + { + var tp = list2[0]; + tp.writor = userId; + tp.inserttime = DateTime.Now; + tp.SHTeaID = data.SHTeaID; + tp.SHTeaName = data.SHTeaName; + tp.SHRoleID = data.SHRoleID; + tp.AndOr = data.AndOr; + } + } + await _shTea.SaveChangesAsync(); + return "ok"; + } + + public async Task EditSHTeaAsync(string schSerId, string userId, decimal shRoleId, List listSHTea) + { + var list = await _shTea.FindAsync(a => a.SchSerID == schSerId && a.SHRoleID == shRoleId); + + if (list.Count > 0) + { + if (listSHTea == null || listSHTea.Count == 0) + { + foreach (var item in list) + await _shTea.RemoveAsync(item); + } + else + { + var teaID = listSHTea.Select(a => a.SHTeaID).ToList(); + var fddelrd = list.Where(a => !teaID.Contains(a.SHTeaID)).ToList(); + if (fddelrd.Count > 0) + { + foreach (var item in fddelrd) + await _shTea.RemoveAsync(item); + } + } + } + + if (listSHTea != null) + { + foreach (var item in listSHTea) + { + bool f = false; + if (list == null || list.Count == 0) + f = true; + else + { + var fd = list.Where(a => a.SHTeaID == item.SHTeaID).ToList(); + if (fd.Count == 0) + f = true; + } + + if (f) + { + await _shTea.AddAsync(new WY_SHRole_SHTea + { + SchSerID = schSerId, + inserttime = DateTime.Now, + writor = userId, + SHTeaID = item.SHTeaID, + SHTeaName = item.SHTeaName, + SHRoleID = shRoleId, + AndOr = item.AndOr, + }); + } + } + } + await _shTea.SaveChangesAsync(); + return "ok"; + } + + public async Task EditBSHTeaAsync(string schSerId, string userId, decimal shRoleId, List listBSHTea) + { + var list = await _bshTea.FindAsync(a => a.SchSerID == schSerId && a.SHRoleID == shRoleId); + + if (list.Count > 0) + { + if (listBSHTea == null || listBSHTea.Count == 0) + { + foreach (var item in list) + await _bshTea.RemoveAsync(item); + } + else + { + var teaID = listBSHTea.Select(a => a.BSHTeaID).ToList(); + var fddelrd = list.Where(a => !teaID.Contains(a.BSHTeaID)).ToList(); + if (fddelrd.Count > 0) + { + foreach (var item in fddelrd) + await _bshTea.RemoveAsync(item); + } + } + } + + if (listBSHTea != null) + { + foreach (var item in listBSHTea) + { + bool f = false; + if (list == null || list.Count == 0) + f = true; + else + { + var fd = list.Where(a => a.BSHTeaID == item.BSHTeaID).ToList(); + if (fd.Count == 0) + f = true; + } + + if (f) + { + await _bshTea.AddAsync(new WY_SHRole_BSHTea + { + SchSerID = schSerId, + inserttime = DateTime.Now, + writor = userId, + BSHTeaID = item.BSHTeaID, + BSHTeaName = item.BSHTeaName, + SHRoleID = shRoleId, + }); + } + } + } + await _bshTea.SaveChangesAsync(); + return "ok"; + } + + #endregion + + #region 审核角色流程 + + public async Task> ListFlowAsync(string name, int pageSize, int pageIndex, string schSerId, int flowType, bool edit) + { + var resultList = (await _flowinfo.FindAsync(m => m.flowType == flowType && m.schSerID == schSerId)).ToList(); + + if (pageSize == 100 && pageIndex == 0) + { + if (!edit) + resultList = resultList.Where(a => a.enable == 1).ToList(); + + var stepFlowIds = (await _flowstep.FindAsync(s => s.flowid != null)) + .Select(s => s.flowid.Value).Distinct().ToHashSet(); + resultList = resultList.Where(a => stepFlowIds.Contains(a.SerID)).ToList(); + } + else + { + if (!edit) + resultList = resultList.Where(a => a.enable != 3).ToList(); + } + + if (!string.IsNullOrEmpty(name)) + resultList = resultList.Where(m => m.flowname != null && m.flowname.IndexOf(name) >= 0).ToList(); + + var ordered = resultList.OrderBy(m => m.SerID).ToList(); + var items = ordered.Skip(pageSize * pageIndex).Take(pageSize) + .Select(item => new SHRoleFlowListItem + { + SerID = item.SerID.ToString(), + flowname = item.flowname, + deptid = item.deptid, + typeid = item.typeid, + enable = item.enable, + enablename = item.enable == 1 ? "是" : "否", + miaoshu = item.flowdes, + deitdate = item.deitdate.ToString("yyyy-MM-dd"), + ChaoSend = item.ChaoSend, + StepSend = item.StepSend, + WYPageGG = item.WYPageGG, + }) + .ToList(); + + return PageVO.Of(items, ordered.Count, pageIndex, pageSize); + } + + public async Task EditFlowAsync(Common_flowinfo model) + { + return model.SerID == 0 ? await CreateFlowAsync(model) : await UpdateFlowAsync(model); + } + + private async Task CreateFlowAsync(Common_flowinfo model) + { + var flowname = model.flowname?.Trim(); + var dup = await _flowinfo.AnyAsync(a => + a.schSerID == model.schSerID && a.flowType == model.flowType && + a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3); + if (dup) + throw new DException("流程名不能重名!"); + + await _flowinfo.AddAsync(new Common_flowinfo + { + flowname = model.flowname, + deptid = model.deptid, + typeid = model.typeid, + flowdes = model.flowdes, + enable = model.enable, + flowType = model.flowType, + deitdate = DateTime.Now, + schSerID = model.schSerID, + ChaoSend = model.ChaoSend, + StepSend = model.StepSend, + WYPageGG = model.WYPageGG, + }); + await _flowinfo.SaveChangesAsync(); + return "添加成功!"; + } + + private async Task UpdateFlowAsync(Common_flowinfo model) + { + // 旧实现:更新分支用 AppCode(1003) 做重名判定(与创建分支 flowType 判定不一致,保持旧行为) + var flowname = model.flowname?.Trim(); + var dup = (await _flowinfo.FindAsync(a => + a.SerID != model.SerID && a.schSerID == model.schSerID && + a.flowType == DocPrintConstants.AppCode && + a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3)).ToList(); + if (dup.Count > 1) + throw new DException("流程名不能重名!"); + + var list = await _flowinfo.FindAsync(a => a.SerID == model.SerID); + if (list.Count > 0) + { + var info = list[0]; + info.flowname = model.flowname; + info.deptid = model.deptid; + info.typeid = model.typeid; + info.flowdes = model.flowdes; + info.enable = model.enable; + info.ChaoSend = model.ChaoSend; + info.StepSend = model.StepSend; + info.WYPageGG = model.WYPageGG; + await _flowinfo.SaveChangesAsync(); + } + return "修改成功!"; + } + + public async Task StepCreateOfSHRoleAsync(Common_flowstep model) + { + await _flowstep.AddAsync(new Common_flowstep + { + StepName = model.StepName, + prestep = model.prestep, + flowid = model.flowid, + ordernum = 0, + cond = 0, + isshowJE = model.isshowJE, + isshowBtn = model.isshowBtn, + }); + await _flowstep.SaveChangesAsync(); + + if (string.IsNullOrEmpty(model.SchSerID)) + throw new DException("添加失败!"); + + var stepId = (await _flowstep.FirstOrDefaultAsync(a => + a.StepName == model.StepName && a.flowid == model.flowid && a.ordernum == 0))?.SerID + ?? await GetLastStepIdAsync(model.flowid ?? 0); + + foreach (var item in model.SchSerID.Split(',')) + { + if (!decimal.TryParse(item, out var roleId)) continue; + await _groupSHRole.AddAsync(new Common_Groupuser_SHRole + { + usergroup = stepId, + flowid = model.flowid, + IsOk = 0, + SHRoleid = roleId, + }); + } + await _groupSHRole.SaveChangesAsync(); + return "添加成功!"; + } + + private async Task GetLastStepIdAsync(int flowid) + { + var steps = await _flowstep.FindAsync(a => a.flowid == flowid); + return steps.Count == 0 ? 0 : steps.Max(a => a.SerID); + } + + public async Task StepDeleteAsync(List ids) + { + var list = await _flowstep.FindAsync(a => ids.Contains(a.SerID)); + if (list.Count > 0) + { + foreach (var item in list) + { + var listrole = await _groupSHRole.FindAsync(a => a.usergroup == item.SerID); + if (listrole.Count > 0) + { + foreach (var r in listrole) + await _groupSHRole.RemoveAsync(r); + } + } + foreach (var item in list) + await _flowstep.RemoveAsync(item); + } + await _flowstep.SaveChangesAsync(); + return "删除成功!"; + } + + public async Task UpdateStepNameAsync(string schSerId, int serId, int flowid, string stepName, int cond, string isshowJE, string isshowBtn) + { + var list = await _flowstep.FindAsync(a => a.SerID != serId && a.flowid == flowid && a.StepName == stepName); + if (list.Count > 0) + throw new DException("同一个流程中有相同步骤名称,更新失败!"); + + var list2 = await _flowstep.FindAsync(a => a.SerID == serId); + if (list2.Count > 0) + { + list2[0].StepName = stepName; + list2[0].cond = cond; + list2[0].isshowJE = isshowJE; + list2[0].isshowBtn = isshowBtn; + await _flowstep.SaveChangesAsync(); + } + return "ok"; + } + + public async Task GetStepInfoAsync(string schSerId, decimal flowid) + { + var intFlowId = (int)flowid; + var liststep = await _flowstep.FindAsync(a => a.flowid == intFlowId); + var listSHRole = await _groupSHRole.FindAsync(a => a.flowid == intFlowId); + + return new SHRoleStepInfo + { + Steps = liststep.OrderBy(a => a.SerID).ToList(), + GroupUsers = listSHRole, + }; + } + + public async Task AddStepSHRoleAsync(string schSerId, int flowid, int stepid, decimal shRoleid) + { + var list = await _groupSHRole.FindAsync(a => a.usergroup == stepid && a.SHRoleid == shRoleid); + if (list.Count > 0) + throw new DException("同一个步骤中审核角色重复了,添加失败!"); + + await _groupSHRole.AddAsync(new Common_Groupuser_SHRole + { + IsOk = 0, + usergroup = stepid, + flowid = flowid, + SHRoleid = shRoleid, + }); + await _groupSHRole.SaveChangesAsync(); + return "ok"; + } + + public async Task DelStepSHRoleAsync(string schSerId, int stepid, decimal shRoleid) + { + var list = await _groupSHRole.FindAsync(a => a.usergroup == stepid); + if (list.Count <= 1) + throw new DException("至少要有一个审核角色,删除失败!"); + + var list2 = await _groupSHRole.FindAsync(a => a.usergroup == stepid && a.SHRoleid == shRoleid); + foreach (var item in list2) + await _groupSHRole.RemoveAsync(item); + await _groupSHRole.SaveChangesAsync(); + return "ok"; + } + + public async Task GetStepByJEAsync(string schSerId, string userId, int flowid, int je) + { + var list = (await _flowstep.FindAsync(a => + a.flowid == flowid && (a.cond == 0 || a.cond == null || je > a.cond))).ToList(); + var liststepSHRole = await _groupSHRole.FindAsync(a => a.flowid == flowid); + var listSHRoleInfo = await GetSHRoleAsync(schSerId); + + var stepSHTea = new List(); + decimal userIdDec = Convert.ToDecimal(userId); + foreach (var step in list) + { + var tpStep = new WYStepSHTea + { + SerID = step.SerID, + flowid = step.flowid, + prestep = step.prestep, + ordernum = step.ordernum, + StepName = step.StepName, + cond = step.cond, + }; + var SHTea = new List(); + var fdstepSHRole = liststepSHRole.Where(a => a.usergroup == step.SerID).ToList(); + if (fdstepSHRole.Count > 0) + { + foreach (var shRole in fdstepSHRole) + { + var fd = listSHRoleInfo.Where(a => a.SerID == shRole.SHRoleid).ToList(); + if (fd.Count > 0) + { + bool isok = false; + if (fd[0].BSHTea.Count == 0) + { + // 被审核教师是全部 + isok = true; + foreach (var shTeaItem in fd[0].SHTea) + SHTea.Add(shTeaItem); + } + else + { + var fdBSHTea = fd[0].BSHTea.Where(a => a.BSHTeaID == userIdDec).ToList(); + if (fdBSHTea.Count > 0) + { + isok = true; + foreach (var shTeaItem in fd[0].SHTea) + SHTea.Add(shTeaItem); + } + } + if (isok) + { + tpStep.SHTea = SHTea; + stepSHTea.Add(tpStep); + } + } + } + } + } + + return new SHRoleStepByJEResult + { + StepSHTea = stepSHTea, + Steps = list, + }; + } + + #endregion + + #region 文印申请页面 + + public async Task> GetLCOfUserAsync(string schSerId, string userId, string userName) + { + var listflow = (await _flowinfo.FindAsync(a => + a.schSerID == schSerId && a.enable == 1 && a.flowType == DocPrintConstants.AppCode)).ToList(); + var listflowid = listflow.Select(a => (int?)a.SerID).ToList(); + + var liststep = (await _flowstep.FindAsync(a => a.flowid != null && listflowid.Contains(a.flowid))).ToList(); + var liststepSHRole = (await _groupSHRole.FindAsync(a => a.flowid != null && listflowid.Contains(a.flowid))).ToList(); + var listSHRoleInfo = await GetSHRoleAsync(schSerId); + + var data = new List(); + decimal userIdDec = Convert.ToDecimal(userId); + foreach (var flowitem in listflow) + { + int flowid = flowitem.SerID; + var fdstep = liststep.Where(a => a.flowid == flowid).ToList(); + int n = 0; + var stepSHTea = new List(); + foreach (var step in fdstep) + { + var tpStep = new WYStepSHTea + { + SerID = step.SerID, + flowid = step.flowid, + prestep = step.prestep, + ordernum = step.ordernum, + StepName = step.StepName, + cond = step.cond, + }; + var SHTea = new List(); + var fdstepSHRole = liststepSHRole.Where(a => a.usergroup == step.SerID).ToList(); + if (fdstepSHRole.Count > 0) + { + foreach (var shRole in fdstepSHRole) + { + var fd = listSHRoleInfo.Where(a => a.SerID == shRole.SHRoleid).ToList(); + if (fd.Count > 0) + { + bool isok = false; + if (fd[0].BSHTea.Count == 0) + { + isok = true; + foreach (var shTeaItem in fd[0].SHTea) + SHTea.Add(shTeaItem); + } + else + { + var fdBSHTea = fd[0].BSHTea.Where(a => a.BSHTeaID == userIdDec).ToList(); + if (fdBSHTea.Count > 0) + { + isok = true; + foreach (var shTeaItem in fd[0].SHTea) + SHTea.Add(shTeaItem); + } + } + if (isok) + { + n++; + tpStep.SHTea = SHTea; + stepSHTea.Add(tpStep); + } + } + } + } + } + + if (n == fdstep.Count) + { + data.Add(new WYFlowForUser + { + SerID = flowitem.SerID, + flowname = flowitem.flowname, + StepSHTea = stepSHTea, + UserID = userId, + UserName = userName, + ChaoSend = flowitem.ChaoSend, + ChaoSendName = await GetTeaNameAsync(schSerId, flowitem.ChaoSend), + StepSend = flowitem.StepSend, + WYPageGG = flowitem.WYPageGG, + DateNowStr = DateTime.Now.ToString("yyyy-MM-dd"), + txt = flowitem.flowname + "-" + userName + "-" + DateTime.Now.ToString("yyyy-MM-dd"), + }); + } + } + return data; + } + + public async Task> IsSendUserSHRoleAsync(string schSerId, int userId, int flowid) + { + var senduserlist = await _senduser.FirstOrDefaultAsync(a => a.userid == userId && a.flowType == DocPrintConstants.AppCode); + + // 流程默认抄送人 + var listflow = await _flowinfo.FindAsync(a => a.SerID == flowid); + string defchaosendtxt = listflow.Count > 0 ? listflow[0].ChaoSend : ""; + // 旧实现只使用流程的默认抄送人(senduserlist 分支被注释) + if (defchaosendtxt == "") + return new List(); + + string useridtxt = defchaosendtxt; + var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(useridtxt); + return teas + .Where(a => int.TryParse(a.TeaSerId, out _)) + .Select(a => new SendUserItem + { + uid = int.Parse(a.TeaSerId), + photo = a.Photo, + name = a.Name, + status = false, + }) + .ToList(); + } + + public async Task> GetDicInfoAsync(string schSerId, string typeId) + { + var list = (await _dic.FindAsync(a => + a.typeID == typeId && (a.Schserid == schSerId || a.Schserid == null))) + .OrderBy(a => a.Sort).ToList(); + + var listserid = list.Select(a => a.SerID).ToList(); + var listopen = await _dicOrd.FindAsync(a => listserid.Contains(a.DicSerID) && a.Open == false); + var listdicserid = listopen.Select(a => (decimal)a.DicSerID).ToList(); + + var rlt = list.Where(a => !listdicserid.Contains(a.SerID)).ToList(); + + var listorder = await _dicOrd.FindAsync(a => listserid.Contains(a.DicSerID)); + foreach (var d in rlt) + { + var fd = listorder.Where(a => a.DicSerID == (int)d.SerID).ToList(); + if (fd.Count > 0) + d.Sort = fd[0].Ord; + } + return rlt.OrderBy(a => a.Sort).ToList(); + } + + public async Task EditDocPrintAsync(string schSerId, string userId, WYSHRoleEditDocPrintDto model) + { + decimal docPrintID = 0; + if (model.isedit == 0) + { + model.docdata.UserID = userId; + model.docdata.SchSerID = schSerId; + docPrintID = await _wyDocPrint.CreateAsync(model.docdata); + await AddStepOfUserAsync(userId, docPrintID, model.data); + } + else + { + model.docdata.UserID = userId; + model.docdata.SchSerID = schSerId; + docPrintID = await _wyDocPrint.EditAsync(model.docdata); + await AddStepOfUserAsync(userId, docPrintID, model.data); + } + + // 旧 WY_SHRoleP.Create/Edit 额外落库 rid/reid/grade/Examtype(WYDocPrintService 未覆盖,此处补写) + var doc = await _doc.FirstOrDefaultAsync(a => a.SerID == docPrintID); + if (doc != null) + { + doc.rid = model.docdata.rid; + doc.reid = model.docdata.reid; + doc.grade = model.docdata.grade; + doc.Examtype = model.docdata.Examtype; + await _doc.SaveChangesAsync(); + } + + var flowinfo = await _flowinfo.FirstOrDefaultAsync(a => a.SerID == model.docdata.flowid); + if (flowinfo != null) + { + if (flowinfo.WYPageGG == "2") + await EditWYDocPrintNumAsync(schSerId, docPrintID, model.docnumdata); + if (flowinfo.WYPageGG == "1") + await EditWYDocPrintNumByOneAsync(schSerId, docPrintID, model.docdata); + } + + return (int)docPrintID; + } + + /// 保存单条文印的规格明细(旧 EditWYDocPrintNumByOne)。 + private async Task EditWYDocPrintNumByOneAsync(string schSerId, decimal docPrintID, WYDocPrintSaveDto item) + { + var listYZFL = await GetDicInfoAsync(schSerId, "YZFLSHRole"); + var listYZFLID = listYZFL.Select(a => a.CodeID).ToList(); + var listggall = await _dic.FindAsync(a => + (a.Schserid == schSerId || a.Schserid == null) && listYZFLID.Contains(a.typeID)); + + var list = await _docNum.FindAsync(a => a.DocPrintID == docPrintID); + decimal serid = 0; + if (list.Count > 0) + serid = list[0].SerID; + + string yslxName = ""; + double yslxOrder = 0; + string yslxTypeid = ""; + var fdYZFL = listYZFL.Where(a => a.CodeID == item.DPType).ToList(); + if (fdYZFL.Count > 0) + { + yslxName = fdYZFL[0].Name; + yslxOrder = Convert.ToDouble(fdYZFL[0].Sort); + yslxTypeid = fdYZFL[0].CodeID; + } + + string ysggName = ""; + double ysggOrder = 0; + string ysggTypeid = ""; + double unitPrice = 0; + var fdgg = listggall.Where(a => a.CodeID == item.DocStyle).ToList(); + if (fdgg.Count > 0) + { + ysggName = fdgg[0].Name; + ysggOrder = Convert.ToDouble(fdgg[0].Sort); + ysggTypeid = fdgg[0].CodeID; + if (IsValidNumber(fdgg[0].Code)) + unitPrice = Convert.ToDouble(fdgg[0].Code); + } + + int docPage = 0; + if (IsValidNumber(item.grade)) + docPage = Convert.ToInt32(item.grade); + + var tp = new WY_DocPrint_Num + { + SerID = serid, + DocPrintID = docPrintID, + YSGGName = ysggName, + YSGGOrder = ysggOrder, + YSGGtypeID = ysggTypeid, + YSLXName = yslxName, + YSLXOrder = yslxOrder, + YSLXtypeID = yslxTypeid, + DocFix = item.DocFix, + DocNum = item.DocNum, + DocPage = docPage, + DocShare = item.DocShare, + UnitPrice = unitPrice, + TotalPrice = unitPrice * item.DocNum, + }; + + await EditWYDocPrintNumAsync(schSerId, docPrintID, new List { tp }); + } + + /// 保存多条文印规格明细(旧 EditWYDocPrintNum)。 + private async Task EditWYDocPrintNumAsync(string schSerId, decimal docPrintID, List data) + { + var info = ""; + var listYZFL = await GetDicInfoAsync(schSerId, "YZFLSHRole"); + var listYZFLID = listYZFL.Select(a => a.CodeID).ToList(); + var listggall = await _dic.FindAsync(a => + (a.Schserid == schSerId || a.Schserid == null) && listYZFLID.Contains(a.typeID)); + + var list = await _docNum.FindAsync(a => a.DocPrintID == docPrintID); + + foreach (var item in data) + { + string name = (item.YSLXName ?? "") + (item.YSGGName ?? ""); + if (item.DocShare == 0) + { + if (item.SerID > 0) + { + var fddel = list.Where(a => a.SerID == item.SerID).ToList(); + if (fddel.Count > 0) + { + foreach (var d in fddel) + await _docNum.RemoveAsync(d); + } + info += name + "删除;"; + } + else + { + info += name + "无操作;"; + } + } + else + { + double unitPrice = 0; + var fdgg = listggall.Where(a => a.CodeID == item.YSGGtypeID).ToList(); + if (fdgg.Count > 0 && IsValidNumber(fdgg[0].Code)) + unitPrice = Convert.ToDouble(fdgg[0].Code); + + if (item.SerID == 0) + { + info += name + "添加;"; + await _docNum.AddAsync(new WY_DocPrint_Num + { + DocPrintID = docPrintID, + YSGGName = item.YSGGName, + YSGGOrder = item.YSGGOrder, + YSGGtypeID = item.YSGGtypeID, + YSLXName = item.YSLXName, + YSLXOrder = item.YSLXOrder, + YSLXtypeID = item.YSLXtypeID, + DocFix = item.DocFix, + DocNum = item.DocNum, + DocPage = item.DocPage, + DocShare = item.DocShare, + UnitPrice = unitPrice, + TotalPrice = unitPrice * (item.DocNum ?? 0), + }); + } + else + { + info += name + "更新;"; + var fddocnum = list.Where(a => a.SerID == item.SerID).ToList(); + if (fddocnum.Count > 0) + { + var tp = fddocnum[0]; + tp.DocFix = item.DocFix; + tp.DocNum = item.DocNum; + tp.DocPage = item.DocPage; + tp.DocShare = item.DocShare; + tp.UnitPrice = unitPrice; + tp.TotalPrice = unitPrice * (item.DocNum ?? 0); + } + } + } + } + + await _docNum.SaveChangesAsync(); + return info; + } + + /// 添加申请用户的审核步骤快照(旧 AddStepOfUser)。 + private async Task AddStepOfUserAsync(string userId, decimal docPrintID, WYFlowForUser data) + { + if (data == null) return; + + var list = await _docSH.FindAsync(a => a.DocPrintSerID == docPrintID && a.flowSerID == data.SerID); + if (list.Count > 0) + { + foreach (var item in list) + await _docSH.RemoveAsync(item); + await _docSH.SaveChangesAsync(); + } + + foreach (var item in data.StepSHTea) + { + foreach (var item2 in item.SHTea) + { + await _docSH.AddAsync(new WY_DocPrint_SH + { + DocPrintSerID = docPrintID, + flowSerID = item.flowid, + stepSerID = item.SerID, + stepOrder = item.ordernum, + SHTeaID = item2.SHTeaID, + SHTeaName = item2.SHTeaName, + AndOr = item2.AndOr, + writor = userId, + inserttime = DateTime.Now, + }); + } + } + await _docSH.SaveChangesAsync(); + } + + #endregion + + #region 用户记录查看和审核 + + public async Task GetshenheFlowAsync(string schSerId, decimal docprintId, int flowid) + { + var listStepByDoc = (await _docSH.FindAsync(a => + a.DocPrintSerID == docprintId && a.flowSerID == flowid)).ToList(); + var liststepid = listStepByDoc.Select(a => a.stepSerID).ToList(); + + var liststep = (await _flowstep.FindAsync(a => + a.flowid == flowid && liststepid.Contains(a.SerID))).ToList(); + var listdocprint = await _doc.FindAsync(a => a.SerID == docprintId); + var listSHInfo = await _flowtranct.FindAsync(a => a.dataid == (int)docprintId); + + var rlt = new List(); + var shStep = new List(); + if (listdocprint.Count > 0) + { + int stepid = listdocprint[0].DocStep ?? 0; + shStep = (await _flowstep.FindAsync(a => a.SerID == stepid)).ToList(); + + // 发起 + var fq = new WYStepSHTea + { + SerID = 0, + ordernum = 0, + SHState = "发起申请", + SHtime = listdocprint[0].EditDate.ToString("d"), + }; + fq.SHTea.Add(new WY_SHRole_SHTea + { + SHTeaID = Convert.ToDecimal(listdocprint[0].UserID), + SHTeaName = await GetUserNameAsync(listdocprint[0].UserID), + }); + rlt.Add(fq); + } + + // 用户撤销 + bool isSH = true; + if (listSHInfo.Count > 0 && listSHInfo[0].actionstatus == 3) + { + isSH = false; + var tp = new WYStepSHTea + { + SerID = 9999, + ordernum = 9999, + SHState = "撤销_3", + SHtime = (listSHInfo[0].actiondate ?? DateTime.Now).ToString("d"), + }; + tp.SHTea.Add(new WY_SHRole_SHTea + { + SHTeaID = Convert.ToDecimal(listSHInfo[0].actionuserid ?? 0), + SHTeaName = await GetUserNameAsync((listSHInfo[0].actionuserid ?? 0).ToString()), + inserttime = listSHInfo[0].actiondate, + SchSerID = listSHInfo[0].remark, + SHRoleID = listSHInfo[0].actionstatus, + }); + rlt.Add(tp); + } + + if (isSH) + { + foreach (var item in liststep) + { + var tp = new WYStepSHTea { SerID = item.SerID }; + var fd = listStepByDoc.Where(a => a.stepSerID == item.SerID).ToList(); + int shNum = 0; + int shNum2 = 0; + int andSHTeaNum = 0; + int orSHTeaNum = 0; + int andSHTeaNumNo = 0; + int orSHTeaNumNo = 0; + foreach (var tea in fd) + { + var tptea = new WY_SHRole_SHTea + { + SHTeaID = tea.SHTeaID, + SHTeaName = tea.SHTeaName, + AndOr = tea.AndOr, + }; + if (tea.AndOr == "and") + shNum++; + if (tea.AndOr == "or") + shNum2++; + + var fdSH = listSHInfo.Where(a => a.stepid == item.SerID && a.actionuserid == tea.SHTeaID).ToList(); + if (fdSH.Count > 0) + { + tptea.inserttime = fdSH[0].actiondate; + tptea.SchSerID = "待审核"; + tptea.SHRoleID = 0; + if (fdSH[0].actionstatus == 1) + { + if (tea.AndOr == "and") andSHTeaNum++; + if (tea.AndOr == "or") orSHTeaNum++; + } + else if (fdSH[0].actionstatus == 2) + { + if (tea.AndOr == "and") andSHTeaNumNo++; + if (tea.AndOr == "or") orSHTeaNumNo++; + } + } + else + { + tptea.SchSerID = "待审核"; + tptea.SHRoleID = 0; + } + tp.SHTea.Add(tptea); + } + + tp.SHState = "待审核_0"; + if (shNum > 0 && shNum == andSHTeaNum) + tp.SHState = "同意_1"; + if (shNum > 0 && shNum == orSHTeaNum) + tp.SHState = "不同意_2"; + if (shNum == 0 && shNum2 > 0 && orSHTeaNumNo > 0) + tp.SHState = "不同意_2"; + if (shNum == 0 && shNum2 > 0 && orSHTeaNum > 0) + tp.SHState = "同意_1"; + + rlt.Add(tp); + } + } + + if (listSHInfo.Count > 0) + { + var fd = listSHInfo.Where(a => a.actionstatus == 11).ToList(); + if (fd.Count > 0) + { + var tp = new WYStepSHTea + { + SerID = 9999, + ordernum = 9999, + SHState = "已领取_11", + SHtime = (fd[0].actiondate ?? DateTime.Now).ToString("d"), + }; + tp.SHTea.Add(new WY_SHRole_SHTea + { + SHTeaID = Convert.ToDecimal(fd[0].actionuserid ?? 0), + SHTeaName = await GetUserNameAsync((fd[0].actionuserid ?? 0).ToString()), + inserttime = fd[0].actiondate, + SchSerID = fd[0].remark, + SHRoleID = fd[0].actionstatus, + }); + rlt.Add(tp); + } + } + + var frees = new List(); + foreach (var item in rlt) + { + string teaname = ""; + var listpic = new List(); + foreach (var t in item.SHTea) + { + teaname += teaname == "" ? t.SHTeaName : "," + t.SHTeaName; + var fdsh = listSHInfo.Where(a => a.actionuserid == t.SHTeaID && a.stepid == item.SerID).ToList(); + if (fdsh.Count > 0) + listpic.Add(await GetSignatureValueAsync(schSerId, t.SHTeaID.ToString())); + } + + var fd = item.SHTea.Where(a => a.inserttime.HasValue).Select(a => a.inserttime).DefaultIfEmpty().Max(); + string p = "/static/image/site/moreperpeo.png"; + var arr = (item.SHState ?? "").Split('_'); + var im = new FreeShenheinfoSHRole + { + SignaturePic = listpic, + steptname = teaname, + steptstatus = arr.Length > 0 ? arr[0] : "", + }; + if (item.SHTea.Count > 1) + im.photo = p; + else if (item.SHTea.Count > 0) + im.photo = await GetUserPhotoAsync(item.SHTea[0].SHTeaID.ToString()); + + if (item.SerID == 0) + { + im.actiondate = DateTime.TryParse(item.SHtime, out var fqdate) ? fqdate.ToString("d") : ""; + } + else + { + im.actiondate = fd.HasValue ? fd.Value.ToString("d") : ""; + } + if (arr.Length > 1) + im.actionstatus = arr[1]; + + frees.Add(im); + } + + return new SHRoleShenheFlowResult + { + Flow = frees, + Steps = rlt, + CurrentStep = shStep, + }; + } + + public async Task> MyListtoOneSHRoleAsync(int serId, string schSerId) + { + var result = new List(); + var resultList = await _doc.FindAsync(m => m.SerID == serId); + if (resultList.Count == 0) + return result; + + var teaMap = await GetTeaMapAsync(schSerId); + var item = resultList[0]; + + var rltItem = new SHRoleDocPrintListItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DocType, + DocTypeName = await GetDictNameWithTypeAsync("WYFL", item.DocType, schSerId), + DocStyle = item.DocStyle, + DocStyleName = await GetDictNameByCodeAsync(item.DocStyle, schSerId), + DPType = item.DPType, + DPTypeName = await GetDictNameByCodeAsync(item.DPType, schSerId), + EditDate = item.EditDate.ToString("yyyy-MM-dd HH:mm"), + TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = GetTeaName(teaMap, item.UserID), + TeaPhoto = GetTeaPhoto(teaMap, item.UserID), + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = await GetStatusByCodeSHRoleAsync(schSerId, item.DocStatus, item.DocStep ?? 0, item.SerID, item.flowid), + DocStep = item.DocStep, + explain = item.explain, + flowid = item.flowid, + DocShare = item.DocShare, + DocFix = item.DocFix, + DocNum = item.DocNum, + _disabled = await GetEditDeleteStatusAsync((int)item.SerID, item.DocStatus), + FreeUserID = item.FreeUserID, + SendUserID = item.SendUserID, + freestepid = item.freestepid, + zhipai = item.zhipai, + rid = item.rid, + reid = item.reid, + grade = item.grade, + Examtype = item.Examtype, + }; + + // 附件明细(旧 OtherData.tempresultfile 并入 data) + var fids = ParseIds(item.UpFile); + if (fids.Count > 0) + { + var files = await _upfiles.FindAsync(a => fids.Contains(a.SerID)); + var offices = new[] { ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx" }; + rltItem.tempresultfile = files.Select(a => new DocPrintFileItem + { + SerID = a.SerID, + FileTrueName = a.FileTrueName, + FileSize = a.FileSize, + FileName = Config.FileWebPath + (offices.Contains(a.FileType) + ? (a.FileName ?? "").Replace(a.FileType, ".pdf") + : a.FileName), + }).ToList(); + } + + result.Add(rltItem); + return result; + } + + public async Task> MyListSHRoleAsync(string name, string status, string startDate, string endDate, int pageSize, int pageIndex, string userId, string schSerId) + { + var resultList = (await _doc.FindAsync(m => m.UserID == userId && m.SchSerID == schSerId)).ToList(); + + if (!string.IsNullOrEmpty(name)) + resultList = resultList.Where(m => m.DocContent != null && m.DocContent.Contains(name)).ToList(); + if (!string.IsNullOrEmpty(status)) + resultList = resultList.Where(m => m.DocStatus == status).ToList(); + if (DateTime.TryParse(startDate, out var date1)) + resultList = resultList.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endDate, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + resultList = resultList.Where(m => m.EditDate <= date2).ToList(); + } + + if (resultList.Count == 0) + return new List(); + + var teaMap = await GetTeaMapAsync(schSerId); + var pageList = resultList + .OrderBy(m => m.DocStatus).ThenByDescending(m => m.EditDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var result = new List(); + foreach (var item in pageList) + { + result.Add(new SHRoleDocPrintListItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DocType, + DocTypeName = await GetDictNameWithTypeAsync("YZBM", item.DocType, schSerId), + DocStyle = item.DocStyle, + DocStyleName = await GetDictNameByCodeAsync(item.DocStyle, schSerId), + DPType = item.DPType, + DPTypeName = await GetDictNameByCodeAsync(item.DPType, schSerId), + EditDate = item.EditDate.ToString("yyyy-MM-dd HH:mm"), + TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = GetTeaName(teaMap, item.UserID), + TeaPhoto = GetTeaPhoto(teaMap, item.UserID), + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = await GetStatusByCodeSHRoleAsync(schSerId, item.DocStatus, item.DocStep ?? 0, item.SerID, item.flowid), + _disabled = await GetEditDeleteStatusAsync((int)item.SerID, item.DocStatus), + DocStep = item.DocStep, + flowid = item.flowid, + FreeUserID = item.FreeUserID, + SendUserID = item.SendUserID, + freestepid = item.freestepid, + DocShare = item.DocShare, + explain = item.explain, + rid = item.rid, + reid = item.reid, + DWName = await GetDictNameByIDAsync(item.rid, schSerId), + ZBName = await GetDictNameByIDAsync(item.reid, schSerId), + grade = item.grade, + Examtype = "", + DocPrintNum = await GetDocPrintNumAsync(schSerId, 1, item.flowid, item.DocStep, item.SerID, userId, item.Examtype, item.DocStyle, item.grade, item.DocFix, item.DocShare, item.DocNum), + }); + } + return result; + } + + public async Task> TotalListSHRoleAsync(int flowid, int dwid, string name, string stateid, string starttime, string endtime, int pageSize, int pageIndex, string schSerId) + { + var resultList = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus != "3")).ToList(); + + if (DateTime.TryParse(starttime, out var date1)) + resultList = resultList.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endtime, out var endtimeValue)) + { + var date2 = endtimeValue.AddDays(1).AddMilliseconds(-1); + resultList = resultList.Where(m => m.EditDate <= date2).ToList(); + } + if (!string.IsNullOrEmpty(stateid)) + resultList = resultList.Where(m => m.DocStatus == stateid).ToList(); + if (!string.IsNullOrEmpty(name)) + { + var teaMap = await GetTeaMapAsync(schSerId); + var fdtea = teaMap.Where(a => a.Value.Name == name).ToList(); + if (fdtea.Count > 0) + { + string teaid = fdtea[0].Key.ToString(); + resultList = resultList.Where(m => m.UserID == teaid).ToList(); + } + else + { + resultList = resultList.Where(m => + (m.DocContent != null && m.DocContent.IndexOf(name) >= 0) || + (m.DocTitle != null && m.DocTitle.IndexOf(name) >= 0)).ToList(); + } + } + if (flowid > 0) + resultList = resultList.Where(m => m.flowid == flowid).ToList(); + if (dwid > 0) + resultList = resultList.Where(m => m.rid == dwid).ToList(); + + if (resultList.Count == 0) + return new List(); + + var teaMap2 = await GetTeaMapAsync(schSerId); + var pageList = resultList + .OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var result = new List(); + foreach (var item in pageList) + { + result.Add(new SHRoleDocPrintListItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DocType, + DocTypeName = await GetDictNameWithTypeAsync("WYFL", item.DocType, schSerId), + DocStyle = item.DocStyle, + DocStyleName = await GetDictNameByCodeAsync(item.DocStyle, schSerId), + DPType = item.DPType, + DPTypeName = await GetDictNameByCodeAsync(item.DPType, schSerId), + EditDate = item.EditDate.ToString("yyyy-MM-dd"), + TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = GetTeaName(teaMap2, item.UserID), + TeaPhoto = GetTeaPhoto(teaMap2, item.UserID), + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = await GetStatusByCodeSHRoleAsync(schSerId, item.DocStatus, item.DocStep ?? 0, item.SerID, item.flowid), + _disabled = await GetEditDeleteStatusAsync((int)item.SerID, item.DocStatus), + DocStep = item.DocStep, + flowid = item.flowid, + FreeUserID = item.FreeUserID, + SendUserID = item.SendUserID, + freestepid = item.freestepid, + DocShare = item.DocShare, + explain = item.explain, + DocNum = item.DocNum, + rid = item.rid, + reid = item.reid, + DWName = await GetDictNameByIDAsync(item.rid, schSerId), + ZBName = await GetDictNameByIDAsync(item.reid, schSerId), + grade = item.grade, + Examtype = item.Examtype, + DocFix = item.DocFix, + DocPrintNum = await GetDocPrintNumAsync(schSerId, 4, item.flowid, item.DocStep, item.SerID, item.UserID, item.Examtype, item.DocStyle, item.grade, item.DocFix, item.DocShare, item.DocNum), + }); + } + return result; + } + + public async Task> SendRdListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex) + { + var resultList = (await _doc.FindAsync(m => m.SchSerID == schSerId)).ToList(); + var teaMap0 = await GetTeaMapAsync(schSerId); + + if (!string.IsNullOrEmpty(name)) + { + var fdtea = teaMap0.Where(a => a.Value.Name == name).ToList(); + if (fdtea.Count > 0) + { + string teaid = fdtea[0].Key.ToString(); + resultList = resultList.Where(m => m.UserID == teaid).ToList(); + } + else + { + resultList = resultList.Where(m => + (m.DocContent != null && m.DocContent.IndexOf(name) >= 0) || + (m.DocTitle != null && m.DocTitle.IndexOf(name) >= 0)).ToList(); + } + } + if (!string.IsNullOrEmpty(userId)) + resultList = resultList.Where(a => a.SendUserID != null && a.SendUserID.IndexOf(userId) > -1).ToList(); + if (DateTime.TryParse(startDate, out var date1)) + resultList = resultList.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endDate, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + resultList = resultList.Where(m => m.EditDate <= date2).ToList(); + } + if (flowid > 0) + resultList = resultList.Where(m => m.flowid == flowid).ToList(); + if (dwid > 0) + resultList = resultList.Where(m => m.rid == dwid).ToList(); + if (!string.IsNullOrEmpty(status)) + resultList = resultList.Where(m => m.DocStatus == status).ToList(); + + // 流程中设置了哪步推抄送,而且这步已经审核通过 + var listflow = (await _flowinfo.FindAsync(a => a.schSerID == schSerId)).ToList(); + var listflowidtp = listflow.Select(a => a.SerID).ToList(); + var listflowid = listflowidtp.Select(a => (int?)a).ToList(); + var liststep = (await _flowstep.FindAsync(a => a.flowid != null && listflowid.Contains(a.flowid))).ToList(); + + var listnextstepid = new List(); + foreach (var item in listflow) + { + if (item.StepSend > -1) + { + var fd = liststep.Where(a => a.flowid == item.SerID).OrderBy(a => a.SerID).ToList(); + for (int i = 1; i < fd.Count; i++) + listnextstepid.Add(fd[i].SerID); + } + } + + resultList = resultList.Where(m => + listnextstepid.Contains(m.DocStep) || m.DocStatus == "1" || m.DocStatus == "11" || m.FreeUserID != null).ToList(); + + var chkDates = new Dictionary(); + foreach (var item in resultList) + chkDates[item.SerID] = (await GetFirstSHDateTiketAsync(item.SerID)).ToString(); + + var resultList2 = resultList + .OrderByDescending(m => long.TryParse(chkDates.TryGetValue(m.SerID, out var cd) ? cd : "0", out var l) ? l : 0) + .ToList(); + + if (resultList2.Count == 0) + return new List(); + + var pageList = resultList2.Skip(pageSize * pageIndex).Take(pageSize).ToList(); + var teaMap = await GetTeaMapAsync(schSerId); + var result = new List(); + foreach (var item in pageList) + { + if (!TryGetTea(teaMap, item.UserID, out var tea)) continue; + result.Add(new SHRoleDocPrintListItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DocType, + DocTypeName = await GetDictNameWithTypeAsync("WYFL", item.DocType, schSerId), + DocStyle = item.DocStyle, + DocStyleName = await GetDictNameByCodeAsync(item.DocStyle, schSerId), + DPType = item.DPType, + DPTypeName = await GetDictNameByCodeAsync(item.DPType, schSerId), + EditDate = item.EditDate.ToString("yyyy-MM-dd"), + TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = tea.Name, + TeaPhoto = tea.Photo, + DocShare = item.DocShare, + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = await GetStatusByCodeSHRoleAsync(schSerId, item.DocStatus, item.DocStep ?? 0, item.SerID, item.flowid), + DocStep = item.DocStep, + flowid = item.flowid, + _disabled = await GetEditDeleteStatusAsync((int)item.SerID, item.DocStatus), + FreeUserID = item.FreeUserID, + SendUserID = item.SendUserID, + freestepid = item.freestepid, + explain = item.explain, + DocNum = item.DocNum, + rid = item.rid, + reid = item.reid, + DWName = await GetDictNameByIDAsync(item.rid, schSerId), + ZBName = await GetDictNameByIDAsync(item.reid, schSerId), + grade = item.grade, + Examtype = item.Examtype, + DocFix = item.DocFix, + ChkDate = chkDates.TryGetValue(item.SerID, out var cd2) ? cd2 : "", + firstSHDate = await GetFirstSHDateAsync(item.SerID), + firstSHDateTiket = chkDates.TryGetValue(item.SerID, out var cd3) ? cd3 : "", + DocPrintNum = await GetDocPrintNumAsync(schSerId, 3, item.flowid, item.DocStep, item.SerID, item.UserID, item.Examtype, item.DocStyle, item.grade, item.DocFix, item.DocShare, item.DocNum), + FJDownInfo = await GetFJDownInfoBySendAsync(item.UpFile), + }); + } + return result; + } + + public async Task> SPListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string userName, string schSerId, int pageSize, int pageIndex) + { + var userid = int.Parse(userId); + var statuss = new[] { "0" }; + + var listdoc = (await _doc.FindAsync(a => + a.SchSerID == schSerId && statuss.Contains(a.DocStatus) && a.flowid != 0 && a.flowid != null)).ToList(); + var listdocid = listdoc.Select(a => (decimal?)a.SerID).ToList(); + + // 审核人的对应申请记录的审核步骤 + var liststepuser = (await _docSH.FindAsync(a => a.SHTeaID == userid)).ToList(); + var shrDocPrintID = liststepuser.Select(a => a.DocPrintSerID ?? 0).ToList(); + + // 我的没有审核的记录 id + var dsSerIDArr = new List(); + foreach (var item in liststepuser) + { + var fd = listdoc.Where(a => a.SerID == item.DocPrintSerID && a.flowid == item.flowSerID && a.DocStep == item.stepSerID).ToList(); + if (fd.Count > 0) + dsSerIDArr.Add(fd[0].SerID); + } + // 我已经审核的记录 + var noDsSerIDArr = shrDocPrintID.Where(a => !dsSerIDArr.Contains(a)).ToList(); + + // 没有审核的记录 + var dsDocs = (await _doc.FindAsync(a => dsSerIDArr.Contains(a.SerID))).ToList(); + + // 已审记录:本人有 actionstatus 非 0 的流水(旧 YSList 的 Any 子查询) + var myTrancts = (await _flowtranct.FindAsync(a => + a.actionuserid == userid && a.datatype == DocPrintConstants.AppCode.ToString())).ToList(); + var ysDocs = (await _doc.FindAsync(a => + a.SchSerID == schSerId && a.DocStatus != null && a.DocStatus != "3" && noDsSerIDArr.Contains(a.SerID))).ToList(); + var ysSerIdSet = myTrancts.Where(t => t.actionstatus != 0).Select(t => (decimal)t.dataid).ToHashSet(); + ysDocs = ysDocs.Where(a => ysSerIdSet.Contains(a.SerID)).ToList(); + + // 合并(去重) + var resultList = new List(); + var seen = new HashSet(); + foreach (var d in dsDocs) { if (seen.Add(d.SerID)) resultList.Add(d); } + foreach (var d in ysDocs) { if (seen.Add(d.SerID)) resultList.Add(d); } + + if (DateTime.TryParse(startDate, out var date1)) + resultList = resultList.Where(m => m.EditDate >= date1).ToList(); + if (DateTime.TryParse(endDate, out var endDateValue)) + { + var date2 = endDateValue.AddDays(1).AddMilliseconds(-1); + resultList = resultList.Where(m => m.EditDate <= date2).ToList(); + } + if (!string.IsNullOrEmpty(status)) + resultList = resultList.Where(m => m.DocStatus == status).ToList(); + if (!string.IsNullOrEmpty(name)) + { + var teaMap0 = await GetTeaMapAsync(schSerId); + var fdtea = teaMap0.Where(a => a.Value.Name == name).ToList(); + if (fdtea.Count > 0) + { + string teaid = fdtea[0].Key.ToString(); + resultList = resultList.Where(m => m.UserID == teaid).ToList(); + } + else + { + resultList = resultList.Where(m => + (m.DocContent != null && m.DocContent.IndexOf(name) >= 0) || + (m.DocTitle != null && m.DocTitle.IndexOf(name) >= 0)).ToList(); + } + } + if (flowid > 0) + resultList = resultList.Where(m => m.flowid == flowid).ToList(); + if (dwid > 0) + resultList = resultList.Where(m => m.rid == dwid).ToList(); + + if (resultList.Count == 0) + return new List(); + + var teaMap = await GetTeaMapAsync(schSerId); + var pageList = resultList.OrderByDescending(m => m.EditDate) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var result = new List(); + foreach (var item in pageList) + { + bool isDs = dsSerIDArr.Contains(item.SerID); // 待审 → zt=false;已审 → zt=true + result.Add(new SHRoleDocPrintListItem + { + SerID = (int)item.SerID, + DocContent = item.DocContent, + DocType = item.DocType, + DocTypeName = await GetDictNameWithTypeAsync("WYFL", item.DocType, schSerId), + DocStyle = item.DocStyle, + DocStyleName = await GetDictNameByCodeAsync(item.DocStyle, schSerId), + DPType = item.DPType, + DPTypeName = await GetDictNameByCodeAsync(item.DPType, schSerId), + EditDate = item.EditDate.ToString("yyyy-MM-dd HH:mm"), + TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"), + UserID = item.UserID, + teaname = GetTeaName(teaMap, item.UserID), + TeaPhoto = GetTeaPhoto(teaMap, item.UserID), + DocShare = item.DocShare, + UpFile = item.UpFile, + DocTitle = item.DocTitle, + DocStatus = item.DocStatus, + DocStatusName = await GetStatusByCodeSHRoleAsync(schSerId, item.DocStatus, item.DocStep ?? 0, item.SerID, item.flowid), + DocStep = item.DocStep, + flowid = item.flowid, + _disabled = !isDs, + FreeUserID = item.FreeUserID, + SendUserID = item.SendUserID, + freestepid = item.freestepid, + explain = item.explain, + DocNum = item.DocNum, + rid = item.rid, + reid = item.reid, + DWName = await GetDictNameByIDAsync(item.rid, schSerId), + ZBName = await GetDictNameByIDAsync(item.reid, schSerId), + grade = item.grade, + Examtype = await SJRDisshowJEAsync(item.flowid, userId) ? item.Examtype : "", + isshowje = await SJRDisshowJEAsync(item.flowid, userId), + DocFix = item.DocFix, + SHRName = userName, + DocPrintNum = await GetDocPrintNumAsync(schSerId, 2, item.flowid, item.DocStep, item.SerID, userId, item.Examtype, item.DocStyle, item.grade, item.DocFix, item.DocShare, item.DocNum), + }); + } + return result; + } + + public async Task ShenheOkSHRoleAsync(FlowTranctDto model) + { + int flowid = int.Parse(model.Pid); + decimal dataid = model.dataid; + + var liststepByUser = (await _docSH.FindAsync(a => + a.DocPrintSerID == dataid && a.stepSerID == model.stepid && a.flowSerID == flowid)).ToList(); + + var listSH = (await _flowtranct.FindAsync(a => + a.dataid == model.dataid && a.Pid == model.Pid && a.stepid == model.stepid)).ToList(); + + var listDoc = await _doc.FindAsync(a => a.SerID == dataid); + + var liststepByUserall = (await _docSH.FindAsync(a => + a.DocPrintSerID == dataid && a.flowSerID == flowid)).ToList(); + var liststepid = liststepByUserall.Select(a => a.stepSerID).ToList(); + var liststep = (await _flowstep.FindAsync(a => + a.flowid == flowid && liststepid.Contains(a.SerID))).ToList(); + + if (listDoc.Count > 0) + { + var rdmodel = listDoc[0]; + if (rdmodel.DocStatus != "0") + { + string statusName = rdmodel.DocStatus switch + { + "1" => "已印好", + "2" => "已拒绝", + "3" => "已撤销", + "11" => "已领取", + _ => "", + }; + throw new DException(statusName); + } + } + + // 记录审核内容 + var fd = listSH.Where(a => a.actionuserid == model.actionuserid).ToList(); + if (fd.Count > 0) + { + fd[0].actionstatus = model.actionstatus; + fd[0].remark = model.actionstatus == 1 ? "同意" : "不同意"; + fd[0].actiondate = DateTime.Now; + } + else + { + await _flowtranct.AddAsync(new Common_flowtranctInfo + { + actionuserid = model.actionuserid, + actionstatus = model.actionstatus, + actiondate = DateTime.Now, + remark = model.actionstatus == 1 ? "同意" : "不同意", + dataid = model.dataid, + Pid = model.Pid, + stepid = model.stepid, + datatype = DocPrintConstants.AppCode.ToString(), + }); + } + await _flowtranct.SaveChangesAsync(); + + // 获取全部审核信息 + listSH = (await _flowtranct.FindAsync(a => + a.dataid == model.dataid && a.Pid == model.Pid && a.stepid == model.stepid)).ToList(); + + var maxstepid = liststep.Count == 0 ? 0 : liststep.Max(a => a.SerID); + var fdandSHTea = liststepByUser.Where(a => a.AndOr == "and").ToList(); + var doc = listDoc.Count > 0 ? listDoc[0] : null; + + if (fdandSHTea.Count == 0) + { + // 没有必申审核人:直接设置审核状态 + if (doc == null) return "ok"; + if (maxstepid == model.stepid) + { + doc.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + else + { + if (model.actionstatus == 2) + { + doc.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + else + { + var fdnextstep = liststep.Where(a => a.prestep == model.stepid).ToList(); + if (fdnextstep.Count > 0) + { + doc.DocStep = fdnextstep[0].SerID; + await _doc.SaveChangesAsync(); + } + } + } + } + else + { + // 有必申审核人:判断是否所有必审人都审核了,且审核内容一致 + if (doc == null) return "ok"; + int v1 = 0, v2 = 0, n = 0; + foreach (var item in fdandSHTea) + { + var fdshtea = listSH.Where(a => a.actionuserid == item.SHTeaID).ToList(); + if (fdshtea.Count > 0) + { + if (fdshtea[0].actionstatus == 1) v1++; + if (fdshtea[0].actionstatus == 2) v2++; + n++; + } + } + if (maxstepid == model.stepid) + { + // 最后一步:只有必申全部同意或全部不同意 + if ((n == fdandSHTea.Count && n == v1) || (n == fdandSHTea.Count && n == v2)) + { + doc.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + } + else + { + if (n == fdandSHTea.Count && n == v2) + { + // 全部不同意,结束 + doc.DocStatus = model.actionstatus.ToString(); + await _doc.SaveChangesAsync(); + } + + if (n == fdandSHTea.Count && n == v1) + { + // 全部同意下一步 + var fdnextstep = liststep.Where(a => a.prestep == model.stepid).ToList(); + if (fdnextstep.Count > 0) + { + doc.DocStep = fdnextstep[0].SerID; + await _doc.SaveChangesAsync(); + } + } + } + } + + return "ok"; + } + + #endregion + + #region 规格表 / 签名 / 退回 / 金额 + + public async Task GetDocOftableAsync(string schSerId, decimal docPrintId) + { + var listYZFL = await GetDicInfoAsync(schSerId, "YZFLSHRole"); + // 多条分类 + listYZFL = listYZFL.Where(a => a.Code == "2").ToList(); + var listYZFLID = listYZFL.Select(a => a.CodeID).ToList(); + var listggall = await _dic.FindAsync(a => + (a.Schserid == schSerId || a.Schserid == null) && listYZFLID.Contains(a.typeID)); + + var tbdata = new List(); + var listminpagenum = new List(); + foreach (var item in listYZFL) + { + var listGG = await GetDicInfoAsync(schSerId, item.CodeID); + foreach (var d in listGG) + { + int defpagenum = 1; + if (IsValidNumber(d.Path)) + defpagenum = Convert.ToInt32(d.Path); + listminpagenum.Add(defpagenum); + + double unitPrice = 0; + if (IsValidNumber(d.Code)) + unitPrice = Convert.ToDouble(d.Code); + + tbdata.Add(new WY_DocPrint_Num + { + YSLXName = item.Name, + YSLXtypeID = item.CodeID, + YSLXOrder = item.Sort, + YSGGName = d.Name, + YSGGtypeID = d.CodeID, + YSGGOrder = d.Sort, + UnitPrice = unitPrice, + DocShare = 0, + DocFix = 1, + DocNum = 0, + DocPage = defpagenum, + TotalPrice = 0, + SerID = 0, + DocPrintID = 0, + }); + } + } + + // docPrintID 为 0 表示添加,无需绑定 + if (docPrintId > 0) + { + var listnum = await _docNum.FindAsync(a => a.DocPrintID == docPrintId); + foreach (var item in listnum) + { + var fd = tbdata.Where(a => a.YSGGtypeID == item.YSGGtypeID).ToList(); + if (fd.Count > 0) + { + fd[0].UnitPrice = item.UnitPrice; + fd[0].DocShare = item.DocShare; + fd[0].DocFix = item.DocFix; + fd[0].DocNum = item.DocNum; + fd[0].DocPage = item.DocPage; + fd[0].TotalPrice = 0; + fd[0].SerID = item.SerID; + } + } + } + + // 合并第一列(印刷类型) + var rowspan = new List(); + string front = ""; + int n = 0; + int m = 0; + for (int i = 0; i < tbdata.Count; i++) + { + rowspan.Add(1); + if (i == 0) + { + front = tbdata[i].YSLXtypeID; + n++; + } + else + { + if (front == tbdata[i].YSLXtypeID) + { + n++; + } + else + { + front = tbdata[i].YSLXtypeID; + if (n > 1) + { + for (int j = m; j < rowspan.Count; j++) + rowspan[j] = 0; + rowspan[m] = n; + } + m = i; + n = 1; + rowspan[m] = 1; + } + } + if (i == tbdata.Count - 1 && n > 1) + { + for (int j = m; j < rowspan.Count; j++) + rowspan[j] = 0; + rowspan[m] = n; + } + } + + return new SHRoleDocOfTableResult + { + Table = tbdata, + MinPageNum = listminpagenum, + RowSpan = rowspan, + }; + } + + public async Task SaveSignatureAsync(string schSerId, string userId, string pic) + { + var dir = Path.Combine(Config.FilePath, "Signature"); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, userId + ".txt"); + string content = EncryptDES(pic, "wy@DES.8"); + File.WriteAllText(path, content); + return "ok"; + } + + public async Task GetSignatureAsync(string schSerId, string userId) + { + var dir = Path.Combine(Config.FilePath, "Signature"); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, userId + ".txt"); + if (!File.Exists(path)) + return ""; + + string content = File.ReadAllText(path); + return DecryptDES(content, "wy@DES.8"); + } + + private async Task GetSignatureValueAsync(string schSerId, string userId) + { + try + { + return await GetSignatureAsync(schSerId, userId); + } + catch + { + return ""; + } + } + + public async Task DelSignatureAsync(string schSerId, string userId) + { + var dir = Path.Combine(Config.FilePath, "Signature"); + string path = Path.Combine(dir, userId + ".txt"); + if (File.Exists(path)) + { + string datestr = DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss"); + string newpath = Path.Combine(dir, userId + "_" + datestr + ".txt"); + File.Move(path, newpath); + } + return "ok"; + } + + public async Task TuiHuiAsync(string userId, string cont, decimal docId) + { + var list = await _doc.FindAsync(a => a.SerID == docId); + if (list.Count == 0) + throw new DException("退回失败!"); + + var doc = list[0]; + int len = (cont ?? "").Length > 145 ? 145 : (cont ?? "").Length; + doc.DocType = cont.Substring(0, len); + doc.FreeUserID = doc.DocStatus; // 备份 + doc.DocStatus = "3"; + doc.ChkUserID = userId; + await _doc.SaveChangesAsync(); + return "ok"; + } + + public async Task UnTuiHuiAsync(string userId, decimal docId) + { + var list = await _doc.FindAsync(a => a.SerID == docId); + if (list.Count == 0) + throw new DException("撤销退回失败!"); + + var doc = list[0]; + doc.DocStatus = doc.FreeUserID; + doc.DocType = null; + doc.FreeUserID = null; + doc.ChkUserID = userId; + await _doc.SaveChangesAsync(); + return "ok"; + } + + public async Task RegetJEAsync(string schSerId, string userId, List listDocPrintId, string type) + { + var err = ""; + + // 印刷类型 + var listYZFL = await GetDicInfoAsync(schSerId, "YZFLSHRole"); + var listYZFLID = listYZFL.Select(a => a.CodeID).ToList(); + // 全部规格 + var listggall = (await _dic.FindAsync(a => + (a.Schserid == schSerId || a.Schserid == null) && listYZFLID.Contains(a.typeID))).ToList(); + + var listflowinfo = (await _flowinfo.FindAsync(a => + a.schSerID == schSerId && a.flowType == DocPrintConstants.AppCode && a.enable == 1)).ToList(); + + var listdec = listDocPrintId.Select(d => (decimal?)d).ToList(); + + var listdoc = await _doc.FindAsync(a => listDocPrintId.Contains(a.SerID)); + var listdocnum = await _docNum.FindAsync(a => listdec.Contains(a.DocPrintID)); + + DateTime dtnow = DateTime.Now; + foreach (var item in listdoc) + { + string djstr = ""; + string itemerr = ""; + var fd = listflowinfo.Where(a => a.SerID == item.flowid).ToList(); + if (fd.Count > 0) + { + string wyPageGG = fd[0].WYPageGG ?? "1"; // 1单条2多条 + if (type == "regetje") + { + var fddocnum = listdocnum.Where(a => a.DocPrintID == item.SerID).ToList(); + if (wyPageGG == "2") + { + string errdocnum = ""; + foreach (var docnum in fddocnum) + { + var fdgg = listggall.Where(a => a.CodeID == docnum.YSGGtypeID).ToList(); + if (fdgg.Count > 0) + { + double dj = Convert.ToDouble(fdgg[0].Code); + int num = Convert.ToInt32(docnum.DocNum); + double je = Math.Round(num * dj, 2); + docnum.TotalPrice = je; + djstr += docnum.YSLXName + docnum.YSGGName + ":" + dj.ToString() + ";"; + } + else + { + errdocnum += errdocnum == "" ? docnum.YSGGName : "," + docnum.YSGGName; + } + } + if (errdocnum != "") + itemerr = item.DocTitle + "(" + item.DocContent + "):" + errdocnum + ",无规格单价,计算失败!"; + } + if (wyPageGG == "1") + { + var fdgg = listggall.Where(a => a.CodeID == item.DocStyle).ToList(); + if (fdgg.Count > 0) + { + double dj = Convert.ToDouble(fdgg[0].Code); + // 单条两处金额都要修改;考虑单条一开始程序中没有在 WY_DocPrint_Num 表中做记录,此处做补充 + if (fddocnum.Count == 0) + { + string yslxName = ""; + double yslxOrder = 0; + string yslxTypeid = ""; + var fdYZFL = listYZFL.Where(a => a.CodeID == item.DPType).ToList(); + if (fdYZFL.Count > 0) + { + yslxName = fdYZFL[0].Name; + yslxOrder = Convert.ToDouble(fdYZFL[0].Sort); + yslxTypeid = fdYZFL[0].CodeID; + } + string ysggName = fdgg[0].Name; + double ysggOrder = Convert.ToDouble(fdgg[0].Sort); + string ysggTypeid = fdgg[0].CodeID; + double unitPrice = 0; + if (IsValidNumber(fdgg[0].Code)) + unitPrice = Convert.ToDouble(fdgg[0].Code); + + int docPage = 0; + if (IsValidNumber(item.grade)) + docPage = Convert.ToInt32(item.grade); + + double je = Math.Round(Convert.ToInt32(item.DocNum) * dj, 2); + + await _docNum.AddAsync(new WY_DocPrint_Num + { + DocPrintID = item.SerID, + YSGGName = ysggName, + YSGGOrder = ysggOrder, + YSGGtypeID = ysggTypeid, + YSLXName = yslxName, + YSLXOrder = yslxOrder, + YSLXtypeID = yslxTypeid, + DocFix = item.DocFix, + DocNum = item.DocNum, + DocPage = docPage, + DocShare = item.DocShare, + UnitPrice = unitPrice, + TotalPrice = unitPrice * item.DocNum, + }); + + item.Examtype = je.ToString(); + djstr += ysggName + ysggName + ":" + dj.ToString() + ";"; + } + else + { + foreach (var docnum in fddocnum) + { + int num = Convert.ToInt32(docnum.DocNum); + double je = Math.Round(num * dj, 2); + docnum.TotalPrice = je; + item.Examtype = je.ToString(); + djstr += docnum.YSLXName + docnum.YSGGName + ":" + dj.ToString() + ";"; + } + } + } + else + { + itemerr = item.DocTitle + "(" + item.DocContent + "):无规格单价,计算失败!"; + } + } + } + else + { + // 恢复原来单价计算 + var fddocnum = listdocnum.Where(a => a.DocPrintID == item.SerID).ToList(); + if (wyPageGG == "2") + { + foreach (var docnum in fddocnum) + { + double dj = Convert.ToDouble(docnum.UnitPrice); + int num = Convert.ToInt32(docnum.DocNum); + double je = Math.Round(num * dj, 2); + docnum.TotalPrice = je; + djstr += docnum.YSLXName + docnum.YSGGName + ":" + dj.ToString() + ";"; + } + } + if (wyPageGG == "1") + { + if (fddocnum.Count == 0) + { + itemerr = item.DocTitle + "(" + item.DocContent + "):没有记录原来单价,计算失败!"; + } + else + { + double dj = Convert.ToDouble(fddocnum[0].UnitPrice); + int num = Convert.ToInt32(fddocnum[0].DocNum); + double je = Math.Round(num * dj, 2); + fddocnum[0].TotalPrice = je; + item.Examtype = je.ToString(); + + string yslxName = ""; + var fdYZFL = listYZFL.Where(a => a.CodeID == item.DPType).ToList(); + if (fdYZFL.Count > 0) + yslxName = fdYZFL[0].Name; + + string ysggName = ""; + var fdgg = listggall.Where(a => a.CodeID == item.DocStyle).ToList(); + if (fdgg.Count > 0) + ysggName = fdgg[0].Name; + djstr += yslxName + ysggName + ":" + dj.ToString() + ";"; + } + } + } + } + else + { + itemerr = item.DocTitle + "(" + item.DocContent + "):无流程,计算失败!"; + } + + if (itemerr == "") + { + await _regetJERd.AddAsync(new WY_reGetJERd + { + DocPrintID = item.SerID, + DocTitle = item.DocTitle, + DocContent = item.DocContent, + writor = userId, + type = type, + EditDatetime = dtnow, + SchSerID = schSerId, + UnitPrice = djstr, + }); + } + else + { + err += itemerr; + } + } + await _regetJERd.SaveChangesAsync(); + return err; + } + + public async Task> GetRegetJERdAsync(string schSerId, int pageSize, int pageIndex) + { + var resultList = (await _regetJERd.FindAsync(m => m.SchSerID == schSerId)).ToList(); + if (resultList.Count == 0) + return new List(); + + var teaMap = await GetTeaMapAsync(schSerId); + var pageList = resultList.OrderByDescending(m => m.EditDatetime) + .Skip(pageSize * pageIndex).Take(pageSize).ToList(); + + var result = new List(); + foreach (var item in pageList) + { + string writorName = GetTeaName(teaMap, item.writor); + result.Add(new SHRoleRegetJERdItem + { + teaname = writorName, + writor = writorName, + SerID = (int)item.SerID, + DcoPrintID = (int?)item.DocPrintID, + DocTitle = item.DocTitle, + DocContent = item.DocContent, + DocInfo = item.DocTitle + "(" + item.DocContent + ")", + EditDatetime = item.EditDatetime?.ToString("yyyy-MM-dd HH:mm:ss"), + datestr = item.EditDatetime?.ToString("yyyy-MM-dd HH:mm:ss"), + type = item.type, + typename = item.type == "regetje" ? "按规格单价计算" : "按原单价计算", + SchSerID = item.SchSerID, + UnitPrice = item.UnitPrice, + }); + } + return result; + } + + public async Task SetDownFileTimesAsync(int serId) + { + var list = await _upfiles.FindAsync(a => a.SerID == serId); + if (list.Count == 0) + throw new DException("记录下载次数失败!"); + + int times = list[0].DownTimes ?? 0; + times += 1; + list[0].DownTimes = times; + await _upfiles.SaveChangesAsync(); + return times; + } + + #endregion + + #region 内部辅助 + + /// 获取学校教师映射(TeaSerId → (Name, Photo))。 + private async Task> GetTeaMapAsync(string schSerId) + { + var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null); + return teas + .Where(t => int.TryParse(t.TeaSerId, out _)) + .GroupBy(t => int.Parse(t.TeaSerId)) + .ToDictionary(g => g.Key, g => (g.First().Name ?? "", g.First().Photo ?? "")); + } + + private static bool TryGetTea(Dictionary teaMap, string userId, out (string Name, string Photo) tea) + { + tea = default; + return int.TryParse(userId, out var uid) && teaMap.TryGetValue(uid, out tea); + } + + private static string GetTeaName(Dictionary teaMap, string userId) + => TryGetTea(teaMap, userId, out var tea) ? tea.Name : ""; + + private static string GetTeaPhoto(Dictionary teaMap, string userId) + => TryGetTea(teaMap, userId, out var tea) ? tea.Photo : ""; + + private async Task GetTeaNameAsync(string schSerId, string teaidstr) + { + if (string.IsNullOrEmpty(teaidstr)) return ""; + var teaMap = await GetTeaMapAsync(schSerId); + var ids = teaidstr.Split(',').Where(s => int.TryParse(s, out _)).Select(int.Parse).ToList(); + return string.Join(",", ids.Select(id => teaMap.TryGetValue(id, out var t) ? t.Name : "").Where(n => !string.IsNullOrEmpty(n))); + } + + private async Task GetUserNameAsync(string teaId) + { + if (!int.TryParse(teaId, out var id)) return ""; + var tea = await _sch.TeaInfo.GetTeaInfoAsync(id); + return tea?.Name ?? ""; + } + + private async Task GetUserPhotoAsync(string teaId) + { + if (!int.TryParse(teaId, out var id)) return ""; + var tea = await _sch.TeaInfo.GetTeaInfoAsync(id); + return tea?.Photo ?? ""; + } + + /// 字典名(DocPrintCommon 版:typeID + CodeID 定位)。 + private async Task GetDictNameWithTypeAsync(string typeId, string code, string schSerId) + { + if (string.IsNullOrEmpty(code)) return ""; + var list = await _dic.FindAsync(a => + a.typeID == typeId && a.CodeID == code && (a.Schserid == schSerId || a.Schserid == null)); + return list.OrderBy(a => a.Enable).Select(a => a.Name).FirstOrDefault() ?? ""; + } + + /// 字典名(WY_SHRoleP 版:仅 CodeID 定位,忽略 typeid 参数)。 + private async Task GetDictNameByCodeAsync(string code, string schSerId) + { + if (string.IsNullOrEmpty(code)) return ""; + var list = await _dic.FindAsync(a => + a.CodeID == code && (a.Schserid == schSerId || a.Schserid == null)); + return list.OrderBy(a => a.Enable).Select(a => a.Name).FirstOrDefault() ?? ""; + } + + /// 字典名(按 SerID 定位,旧 Get_DictionaryOnlyByID)。 + private async Task GetDictNameByIDAsync(int? serId, string schSerId) + { + if (serId == null) return ""; + decimal id = Convert.ToDecimal(serId.Value); + var list = await _dic.FindAsync(a => + a.SerID == id && (a.Schserid == schSerId || a.Schserid == null)); + return list.OrderBy(a => a.Enable).Select(a => a.Name).FirstOrDefault() ?? ""; + } + + /// 状态文案(旧 GetStatusByCodeSHRole)。 + private async Task GetStatusByCodeSHRoleAsync(string schSerId, string statusid, int stepid, decimal serId, int? flowid) + { + if (statusid == "0") + { + if (stepid > 0) + { + var flow = await GetshenheFlowAsync(schSerId, serId, flowid ?? 0); + int n = -1; + for (int i = 0; i < flow.Steps.Count; i++) + { + if (flow.Steps[i].SerID == stepid) + { + n = i; + break; + } + } + if (n > -1 && n < flow.Flow.Count) + return flow.Flow[n].steptname + " " + flow.Flow[n].steptstatus; + } + return ""; + } + if (statusid == "1") return "未打印"; + if (statusid == "2") return "审核未通过"; + if (statusid == "3") return "已撤销"; + if (statusid == "11") return "已完成"; + return ""; + } + + /// 是否禁编辑(旧 GetEditDeleteStatus:待审且无任何审核流水才可编辑)。 + private Task GetEditDeleteStatusAsync(int dateid, string status) + { + if (status == "0") + return _flowtranct.AnyAsync(a => a.dataid == dateid && a.datatype == DocPrintConstants.AppCode.ToString()); + return Task.FromResult(true); + } + + /// 审核记录是否显示金额(旧 SJRDisshowJE)。 + private async Task SJRDisshowJEAsync(int? flowid, string userId) + { + bool rlt = false; + if (!IsInteger(userId)) return rlt; + decimal u = Convert.ToDecimal(userId); + + var liststep = await _flowstep.FindAsync(a => a.flowid == flowid); + var liststepbydoc = await _docSH.FindAsync(a => a.flowSerID == flowid && a.SHTeaID == u); + if (liststepbydoc.Count > 0) + { + var liststepid = liststepbydoc.Select(a => a.stepSerID).ToList(); + var fdliststep = liststep.Where(a => liststepid.Contains(a.SerID) && a.isshowJE == "1").ToList(); + rlt = fdliststep.Count > 0; + } + return rlt; + } + + /// 获取规格表(旧 getdocNumOftable)。 + private async Task> GetDocNumOftableAsync(string schSerId, string wyPageGG) + { + var listYZFL = await GetDicInfoAsync(schSerId, "YZFLSHRole"); + if (wyPageGG == "2") + listYZFL = listYZFL.Where(a => a.Code == "2").ToList(); + else if (wyPageGG == "1") + listYZFL = listYZFL.Where(a => a.Code != "2").ToList(); + + var listYZFLID = listYZFL.Select(a => a.CodeID).ToList(); + var listggall = await _dic.FindAsync(a => + (a.Schserid == schSerId || a.Schserid == null) && listYZFLID.Contains(a.typeID)); + + var tbdata = new List(); + foreach (var item in listYZFL) + { + var listGG = await GetDicInfoAsync(schSerId, item.CodeID); + foreach (var d in listGG) + { + double unitPrice = 0; + if (IsValidNumber(d.Code)) + unitPrice = Convert.ToDouble(d.Code); + + tbdata.Add(new WY_DocPrint_Num + { + YSLXName = item.Name, + YSLXtypeID = item.CodeID, + YSLXOrder = item.Sort, + YSGGName = d.Name, + YSGGtypeID = d.CodeID, + YSGGOrder = d.Sort, + UnitPrice = unitPrice, + DocShare = 0, + DocFix = 0, + DocNum = 0, + DocPage = 0, + TotalPrice = 0, + SerID = 0, + DocPrintID = 0, + }); + } + } + return tbdata; + } + + /// 构建列表页规格明细(旧 getDocPrintNum:cls=1申请人/2审核人/3抄送人/4记录浏览)。 + private async Task> GetDocPrintNumAsync(string schSerId, int cls, int? flowid, int? stepid, decimal docPrintID, string userId, string examtype, string docStyle, string grade, int? docFix, int? docShare, int? docNum) + { + int flowidint = flowid ?? 0; + var listflow = await _flowinfo.FindAsync(a => a.SerID == flowidint); + string wyPageGG = listflow.Count > 0 ? listflow[0].WYPageGG : "1"; + + // 是否显示金额 + string isshowJE = "0"; + if (cls == 1) + isshowJE = "0"; + else if (cls == 2) + isshowJE = await SJRDisshowJEAsync(flowid, userId) ? "1" : "0"; + else if (cls == 3) + isshowJE = "1"; + else if (cls == 4) + isshowJE = "1"; + + var tb = await GetDocNumOftableAsync(schSerId, wyPageGG); + if (wyPageGG == "1") + { + // 单条 + var fd = tb.Where(a => a.YSGGtypeID == docStyle).ToList(); + if (fd.Count > 0) + { + int pagenum = 0; + if (IsValidNumber(grade)) + pagenum = Convert.ToInt32(grade); + fd[0].DocPage = pagenum; + fd[0].DocFix = docFix; + fd[0].DocShare = docShare; + fd[0].DocNum = docNum; + + if (isshowJE == "1") + { + double totalprice = 0; + if (IsValidNumber(examtype)) + totalprice = Math.Round(Convert.ToDouble(examtype), 2); + fd[0].TotalPrice = totalprice; + } + else + { + fd[0].TotalPrice = 0; + } + } + } + else + { + // 多条 + var listdocnum = await _docNum.FindAsync(a => a.DocPrintID == docPrintID); + foreach (var d in listdocnum) + { + var fd = tb.Where(a => a.YSGGtypeID == d.YSGGtypeID).ToList(); + if (fd.Count > 0) + { + fd[0].DocPage = d.DocPage; + fd[0].DocFix = d.DocFix; + fd[0].DocShare = d.DocShare; + fd[0].DocNum = d.DocNum; + fd[0].UnitPrice = d.UnitPrice; + if (isshowJE == "1") + fd[0].TotalPrice = Math.Round(Convert.ToDouble(d.TotalPrice), 2); + else + fd[0].TotalPrice = 0; + } + } + } + + return tb; + } + + /// 首次审核时间(旧 getFirstSHDate:datatype=1003 的第一条流水)。 + private async Task GetFirstSHDateAsync(decimal docId) + { + var list = (await _flowtranct.FindAsync(a => + a.datatype == DocPrintConstants.AppCode.ToString() && a.dataid == (int)docId)) + .OrderBy(a => a.SerID).ToList(); + if (list.Count > 0 && list[0].actiondate != null) + return list[0].actiondate.Value.ToString("yyyy-MM-dd HH:mm:ss"); + return ""; + } + + /// 首次审核时间 Ticks(旧 getFirstSHDateTiket,用于排序)。 + private async Task GetFirstSHDateTiketAsync(decimal docId) + { + string datestr = await GetFirstSHDateAsync(docId); + if (datestr != "" && DateTime.TryParse(datestr, out var dt)) + return dt.Ticks; + return 0; + } + + /// 附件下载情况(旧 getFJDownInfoBySend:0 未下载/1 全部下载/2 部分下载)。 + private async Task GetFJDownInfoBySendAsync(string ids) + { + if (string.IsNullOrEmpty(ids)) return 0; + + var listid = ParseIds(ids); + if (listid.Count == 0) return 0; + + var list = await _upfiles.FindAsync(a => listid.Contains(a.SerID)); + if (list.Count == 0) return 0; + + int n = list.Count(a => (a.DownTimes ?? 0) > 0); + if (n == 0) return 0; + if (n == list.Count) return 1; + return 2; + } + + private static List ParseIds(string commaIds) + { + if (string.IsNullOrEmpty(commaIds)) return new List(); + return commaIds.Split(',') + .Where(a => !string.IsNullOrEmpty(a) && int.TryParse(a, out _)) + .Select(int.Parse).ToList(); + } + + private static bool IsValidNumber(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + return double.TryParse(input, NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, out _); + } + + private static bool IsInteger(string input) + => !string.IsNullOrWhiteSpace(input) && int.TryParse(input, out _); + + #endregion + + #region 签名 DES 加解密(旧 BestSchool.Common.DESHelper,等价移植) + + private static readonly byte[] DesKeys = { 0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF }; + + private static string EncryptDES(string encryptString, string encryptKey) + { + try + { + byte[] rgbKey = Encoding.UTF8.GetBytes(encryptKey.Substring(0, 8)); + byte[] rgbIV = DesKeys; + byte[] inputByteArray = Encoding.UTF8.GetBytes(encryptString); + using var dCSP = DES.Create(); + dCSP.Key = rgbKey; + dCSP.IV = rgbIV; + dCSP.Mode = CipherMode.CBC; + dCSP.Padding = PaddingMode.PKCS7; + using var mStream = new MemoryStream(); + using (var cStream = new CryptoStream(mStream, dCSP.CreateEncryptor(), CryptoStreamMode.Write)) + { + cStream.Write(inputByteArray, 0, inputByteArray.Length); + cStream.FlushFinalBlock(); + } + return Convert.ToBase64String(mStream.ToArray()); + } + catch + { + return encryptString; + } + } + + private static string DecryptDES(string decryptString, string decryptKey) + { + try + { + byte[] rgbKey = Encoding.UTF8.GetBytes(decryptKey); + byte[] rgbIV = DesKeys; + byte[] inputByteArray = Convert.FromBase64String(decryptString); + using var dCSP = DES.Create(); + dCSP.Key = rgbKey; + dCSP.IV = rgbIV; + dCSP.Mode = CipherMode.CBC; + dCSP.Padding = PaddingMode.PKCS7; + using var mStream = new MemoryStream(); + using (var cStream = new CryptoStream(mStream, dCSP.CreateDecryptor(), CryptoStreamMode.Write)) + { + cStream.Write(inputByteArray, 0, inputByteArray.Length); + cStream.FlushFinalBlock(); + } + return Encoding.UTF8.GetString(mStream.ToArray()); + } + catch + { + return decryptString; + } + } + + #endregion + } +} diff --git a/src/YhCore.Service/Interfaces/IDictionaryService.cs b/src/YhCore.Service/Interfaces/IDictionaryService.cs new file mode 100644 index 0000000..df2b623 --- /dev/null +++ b/src/YhCore.Service/Interfaces/IDictionaryService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using YhCore.Entity; +using YhCore.Model.Dto; + +namespace YhCore.Service.Interfaces +{ + /// + /// 公共字典服务(对应旧 BestSchool.Service.GetSysDict)。 + /// + public interface IDictionaryService + { + /// 指定字典下拉框列表({k=CodeID, v=Name},Enable=1)。 + Task> GetDictListAsync(string typeId, string schId); + + /// 字典类别列表(排除 DEPT)。 + Task> GetDicTypeListAsync(); + + /// 字典管理列表(含已删除,供超管管理)。 + Task> GetDictMangeListAsync(string typeId, string schId); + + /// 添加字典(typeID/typeName/Name/Schserid 由调用方填好,此处计算 Sort/Des/Enable/CodeID)。 + Task AddDicAsync(Common_Dictionary model); + + /// 更新字典(Name/Des/Sort/Enable)。 + Task UpdateDicAsync(Common_Dictionary model); + + /// 编辑字典名称(Name/Code/Path)。 + Task EditDicAsync(Common_Dictionary model); + } +} diff --git a/src/YhCore.Service/Interfaces/IDocPrintClassifyService.cs b/src/YhCore.Service/Interfaces/IDocPrintClassifyService.cs new file mode 100644 index 0000000..c74db42 --- /dev/null +++ b/src/YhCore.Service/Interfaces/IDocPrintClassifyService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using YhCore.Entity; +using YhCore.Model.Dto; + +namespace YhCore.Service.Interfaces +{ + /// + /// 文印分类 / 印制版面 / 公共类别服务(对应旧 BestSchool.DocPrint.WY_list 分类部分 + WY_Classify)。 + /// + public interface IDocPrintClassifyService + { + /// 添加文印分类(typeName 固定「印制分类」)。 + Task AddDicAsync(Common_Dictionary model); + + /// 文印分类列表。 + Task> CategoryListAsync(string schSerId, string name); + + /// 添加印制版面(typeName 固定「印制版面」)。 + Task AddYZBMAsync(Common_Dictionary model); + + /// 印制版面列表。 + Task> YZBMListAsync(string schSerId, string name); + + /// 删除文印分类/版面(有使用记录则拒绝)。 + Task DeleteDicAsync(decimal[] ids); + + /// 公共类别列表(含排序/显隐)。 + Task> DictionaryTypeListAsync(string schSerId, string name, bool showAll, string typeId); + + /// 删除公共类别(软删除 Enable=3)。 + Task DeleteDictionaryTypeAsync(string[] ids); + + /// 公共类别显隐。 + Task SetDictionaryOrdAsync(string schSerId, int dicSerId, bool show); + + /// 公共类别排序。 + Task OrderDicAsync(string ids, string schSerId); + + /// 结果链接(依赖资源平台 ziyuan,新项目未接入,降级返回 false)。 + Task GetResultUrlAsync(int dataId, int uid, int schId, string schSerId); + + /// 学校文印资源开关。 + Task GetSchZyBitAsync(string schSerId); + + /// 设置学校文印资源开关。 + Task SetSchZyBitAsync(string schSerId, bool zyBit); + + /// 待审数量角标。 + Task GetShnheCountAsync(string userId, string schSerId); + } +} diff --git a/src/YhCore.Service/Interfaces/IDocPrintStatisticsService.cs b/src/YhCore.Service/Interfaces/IDocPrintStatisticsService.cs new file mode 100644 index 0000000..c02e2d8 --- /dev/null +++ b/src/YhCore.Service/Interfaces/IDocPrintStatisticsService.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using YhCore.Model.Dto; + +namespace YhCore.Service.Interfaces +{ + /// + /// 文印统计看板服务(移植旧 BestSchool.DocPrint.DocPrintStatistics,5 接口)。 + /// 旧 [NoAuth] 无用户上下文,schId 由前端传入;新库 SchSerID 已是 int 字符串,直接 schId.ToString() 匹配。 + /// roleId 参数旧实现零引用,已删除。ExportRecord(Excel 导出,NPOI)延后。 + /// + public interface IDocPrintStatisticsService + { + Task> GetDocPrintCountAsync(int schId, DateTime? startDate = null, DateTime? endDate = null); + Task> GetPieChartAsync(int schId, DateTime? startDate = null, DateTime? endDate = null); + Task> GetDocPrintTypeAsync(int schId); + Task GetDocPrintListAsync(int schId, string docType, string likeName, int pageIndex, int pageSize, DateTime? startDate = null, DateTime? endDate = null); + } +} diff --git a/src/YhCore.Service/Interfaces/IFileService.cs b/src/YhCore.Service/Interfaces/IFileService.cs new file mode 100644 index 0000000..3a0c087 --- /dev/null +++ b/src/YhCore.Service/Interfaces/IFileService.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using YhCore.Model.Dto; + +namespace YhCore.Service.Interfaces +{ + /// + /// 公共文件服务(移植旧 BestSchool.Service.Common.GetEnclosure/FileSaveEnclosure + + /// BestSchool.Common.UpLoadFile.FileSave/FileSaveImage/FileChunkSave/FileMergeSave)。 + /// 存储约定:物理根目录 = Config.FilePath(≈旧 ~/UploadFile/),相对路径仍存 /UploadFile/{AppType}/{yyyyMMdd}/{guid}{ext} + /// (与旧库数据格式一致,兼容历史记录);host 取 Config.FileWebPath(≈旧 AuthConfig.weboa_url)。 + /// 旧 ToOfficeHelper.OfficeConvertToPDF(Office COM 转 PDF)新项目缺失,降级:上传不做转 PDF, + /// 预览时本地无 .pdf 则降级 officeapps.live.com 在线预览。 + /// + public interface IFileService + { + /// 按附件 id 取文件信息(旧 GetEnclosure:Common_UpFiles → FileObj)。 + Task GetEnclosureAsync(int id); + + /// 存储附件信息(旧 FileSaveEnclosure:Common_UpFiles 落库,返回 SerID;文件名非法字符替换为 _)。 + Task SaveEnclosureAsync(FileObj file, string appType); + + /// 单文件上传(旧 UpLoadFile.FileSave:扩展名白名单 + 保存 + 返回 FileObj)。 + Task SaveFileAsync(string appType, IFormFile file); + + /// 图片多文件上传(旧 UpLoadFile.FileSaveImage:jpg/gif/png/jpeg)。 + Task> SaveFileImageAsync(string appType, IFormFileCollection files); + + /// 保存分片(旧 FileChunkSave:临时目录 {guid}/ 下按索引存块)。 + Task SaveChunkAsync(string guid, string chunkIndex, IFormFile chunk); + + /// 合并分片(旧 FileMergeSave:按块合并 → 正式目录 + FileObj)。 + Task MergeSaveAsync(string appType, string guid, string fileName); + + /// 相对路径 → 物理路径(fileurl 以 /UploadFile/ 开头 → Config.FilePath + 子路径)。 + string MapPhysicalPath(string fileUrl); + } +} diff --git a/src/YhCore.Service/Interfaces/IFreeApproveService.cs b/src/YhCore.Service/Interfaces/IFreeApproveService.cs new file mode 100644 index 0000000..2d48f13 --- /dev/null +++ b/src/YhCore.Service/Interfaces/IFreeApproveService.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using YhCore.Model.Dto; + +namespace YhCore.Service.Interfaces +{ + /// + /// 自由审批流服务(移植旧 BestSchool.Service.ApproveFlow)。 + /// 教师查询走教务 SDK(Yh.SchClass.OpenApi)。 + /// + public interface IFreeApproveService + { + /// 获取自己的自由流程节点(dataid 为空时取最大 dataid)。 + Task> GetFleeFlowAsync(int userId, int? dataid); + + /// 保存自由流程审核人,返回首步骤 SerID。 + Task EditFleeFlowAsync(int userId, string schSerId, int? dataid, string flowUids, bool isFlowNull); + + /// 添加审核意见(写 flowtranctInfo,actionstatus=-1)。 + Task AddShenheRemarkAsync(int userId, int? dataid, string remark, int freestepid); + + /// 获取 freestepid 之后的自由流程节点。 + Task> GetAfterFleeFlowAsync(int? dataid, int freestepid); + + /// 编辑 freestepid 之后的自由流程,返回末步骤 SerID。 + Task EditAfterFleeFlowAsync(int? dataid, int freestepid, string flowUids); + + /// 查看发起人 + 审核人步骤(自由 / 固定 / 仅记录)。 + Task> ShenheFlowAsync(int uid, string ctime, int dataid, int? freestepid, int? flowid); + + /// 抄送人列表(按 uids 查用户)。 + Task> ChaoSendListAsync(string uids); + } +} diff --git a/src/YhCore.Service/Interfaces/ILogService.cs b/src/YhCore.Service/Interfaces/ILogService.cs new file mode 100644 index 0000000..2430e06 --- /dev/null +++ b/src/YhCore.Service/Interfaces/ILogService.cs @@ -0,0 +1,21 @@ +using YhCore.Entity; +using YhCore.Model.Vo; + +namespace YhCore.Service.Interfaces +{ + /// + /// 日志查询服务(对应 Java 版 ILogService)。 + /// + public interface ILogService + { + /// + /// 分页搜索系统审计日志。 + /// + /// 学校 ID + /// 关键字(可为空) + /// true=系统操作日志(systemSearch);false=普通操作日志(search) + /// 页码,从 0 开始 + /// 每页大小 + PageVO Search(int schId, string keyword, bool systemScope, int page, int size); + } +} diff --git a/src/YhCore.Service/Interfaces/IReviewProcessService.cs b/src/YhCore.Service/Interfaces/IReviewProcessService.cs new file mode 100644 index 0000000..88b306b --- /dev/null +++ b/src/YhCore.Service/Interfaces/IReviewProcessService.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Yh.SchClass.OpenApi.Model; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Model.Vo; + +namespace YhCore.Service.Interfaces +{ + /// + /// 审核流程管理服务(对应旧 BestSchool.Service.Reviewprocess + UserFlowRole)。 + /// + public interface IReviewProcessService + { + /// 审核流程列表(分页)。 + Task> ListAsync(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId); + + /// 审核流程列表【局版】(新框架无 groupId,降级为与 List 同源)。 + Task> List2Async(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId); + + /// 步骤流程列表。 + Task> StepListAsync(string name, int serid); + + /// 添加审核流程。 + Task CreateAsync(Common_flowinfo model); + + /// 修改审核流程。 + Task EditAsync(Common_flowinfo model); + + /// 添加步骤流程。 + Task StepCreateAsync(Common_flowstep model); + + /// 修改步骤流程。 + Task StepEditAsync(Common_flowstep model); + + /// 删除审核流程(软删除 enable=3)。 + Task DeleteAsync(string ids); + + /// 删除步骤。 + Task StepDeleteAsync(decimal[] ids); + + /// 流程绑定的步骤列表。 + Task> BindstepListAsync(int serid); + + /// 绑定步骤的人员。 + Task> BindGroupuserListAsync(int flowid, int prestep); + + /// 绑定流程(流程 + 抄送人)。 + Task> BindflowinfoAsync(string schSerId, int flowType); + + /// 当前学校教师列表。 + Task> GetTeaListAsync(string schSerId); + + /// 学校 + 教育局教师列表(降级为当前学校)。 + Task> GetTeaListSupAsync(string schSerId); + + /// 教师信息分页。 + Task> GetUserinfoAsync(string schSerId, string name, int statetype, int pageSize, int pageIndex); + + /// 非中层领导人员(teaId 数组)。 + Task GetNoLeadingMiddleInfoAsync(string schSerId); + + /// 非中层领导人员【局版】(降级为当前学校)。 + Task GetNoLeadingMiddleInfoSupAsync(string schSerId); + + /// 设置/取消中层领导(教务 SDK 未提供写接口,暂不支持)。 + Task ChangeLeadingMiddleStatusAsync(int state, string userTeaSerIdStr); + + /// 附件数据源。 + Task> FilelistAsync(string ids); + + /// 该校审核流程是固定还是自由。 + Task IsShenheflowAsync(string schSerId, int userId); + + /// 绑定抄送默认人。 + Task> IsSendUserAsync(string schSerId, int userId); + + /// 当前用户基础信息。 + Task GetUserAsync(); + + /// 用户流程角色(1 有固定/自由流程,0 无)。 + Task GetUserFlowRoleAsync(int userId); + } +} diff --git a/src/YhCore.Service/Interfaces/IWYDocPrintService.cs b/src/YhCore.Service/Interfaces/IWYDocPrintService.cs new file mode 100644 index 0000000..0a5920e --- /dev/null +++ b/src/YhCore.Service/Interfaces/IWYDocPrintService.cs @@ -0,0 +1,51 @@ +using System.Threading.Tasks; +using System.Collections.Generic; +using YhCore.Model.Dto; + +namespace YhCore.Service.Interfaces +{ + /// + /// 文印核心业务服务(移植旧 BestSchool.DocPrint.WY_list 除分类/字典外的部分: + /// 抄送、附件、文印 CRUD、各类列表、审核动作、统计、指派)。 + /// + public interface IWYDocPrintService + { + // ---- 抄送 ---- + Task> SendRdListAsync(string name, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex); + Task> SendDetailsListAsync(int serId); + Task EditSendAsync(int serId, string senduserid, string mysenduserid, string schSerId, string uid); + + // ---- 附件 ---- + Task> FileListAsync(string ids); + + // ---- 文印 CRUD ---- + Task CreateAsync(WYDocPrintSaveDto model); + Task EditAsync(WYDocPrintSaveDto model); + Task DeleteAsync(string ids); + Task RevokeAsync(FlowTranctDto model); + + // ---- 列表 ---- + Task> MyListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex); + Task> AllListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex); + Task> ShenheListAsync(string userId, string schSerId, int pageSize, int pageIndex); + Task> SPListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex); + Task> YishenListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex); + Task> TotalListAsync(string name, string stateid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex); + Task> MyListtoOneAsync(int serId, string schSerId); + Task> BindflowinfoAsync(string schSerId, int flowType); + + // ---- 审核动作 ---- + Task ShenheOkAsync(FlowTranctDto model); + Task AffirmApplyforOkAsync(FlowTranctDto model); + + // ---- 指派 ---- + Task GetUserZhipaiAsync(int userid, string schSerId, int? flowid, int? flowstepid, string type); + + // ---- 统计 ---- + Task> List1Async(string starttime, string endtime, string schSerId); + Task> List2Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex); + Task> List3Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex); + Task> List4Async(int year, int month, string schSerId, int pageSize, int pageIndex); + Task> List5Async(string userid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex); + } +} diff --git a/src/YhCore.Service/Interfaces/IWYSHRoleService.cs b/src/YhCore.Service/Interfaces/IWYSHRoleService.cs new file mode 100644 index 0000000..b479ab4 --- /dev/null +++ b/src/YhCore.Service/Interfaces/IWYSHRoleService.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Model.Vo; + +namespace YhCore.Service.Interfaces +{ + /// + /// 审核角色业务服务(移植旧 BestSchool.DocPrint.WY_SHRoleP,45 接口)。 + /// 降级说明: + /// 1. 旧 SendMessage/SendUnread/Backlog(短信、已读未读、待办中心)新项目缺失,全部降级为纯落库; + /// 2. 教师/头像改走教务 SDK(Yh.SchClass.OpenApi),教师标识用 int(TeaSerId 解析), + /// 落库 WY_SHRole_SHTea.SHTeaID / WY_SHRole_BSHTea.BSHTeaID 仍为 decimal 用户 id; + /// 3. 签名存文件系统(DES 加密),非数据库表; + /// 4. 旧 ServiceResult 的 Data/OtherData/Page 三段合并为单个 Result 对象(getshenheFlow / getStepInfo / getStepByJE / getdocOftable)。 + /// 延后:Excel 导入导出(UpfileExcelSHRole/downMBOfSHRole/downUpfileSHRoleFK/ExceltotalListSHRole/WYRDExceldaochu)、审批单 PDF(DwonWYSQD)。 + /// + public interface IWYSHRoleService + { + // ---- 审核角色管理 ---- + Task GetIsSHRoleAsync(string schSerId); + Task SaveIsSHRoleAsync(string schSerId, string val); + Task> GetSHRoleAsync(string schSerId); + Task UpdateSHRoleNameAsync(string schSerId, string userId, decimal serId, string roleName); + Task EditSHRoleAsync(string schSerId, string userId, WYSHRoleExt roleInfo); + Task EditSHTeaRowAsync(string schSerId, string userId, WY_SHRole_SHTea data); + Task DelSHTeaRowAsync(string schSerId, decimal serId); + Task EditSHTeaAsync(string schSerId, string userId, decimal shRoleId, List listSHTea); + Task EditBSHTeaAsync(string schSerId, string userId, decimal shRoleId, List listBSHTea); + Task DelSHRoleAsync(string schSerId, List listSHRoleId); + + // ---- 审核角色流程 ---- + Task> ListFlowAsync(string name, int pageSize, int pageIndex, string schSerId, int flowType, bool edit); + Task EditFlowAsync(Common_flowinfo model); + Task StepCreateOfSHRoleAsync(Common_flowstep model); + Task GetStepInfoAsync(string schSerId, decimal flowid); + Task StepDeleteAsync(List ids); + Task UpdateStepNameAsync(string schSerId, int serId, int flowid, string stepName, int cond, string isshowJE, string isshowBtn); + Task AddStepSHRoleAsync(string schSerId, int flowid, int stepid, decimal shRoleid); + Task DelStepSHRoleAsync(string schSerId, int stepid, decimal shRoleid); + Task GetStepByJEAsync(string schSerId, string userId, int flowid, int je); + + // ---- 文印申请页面 ---- + Task> GetLCOfUserAsync(string schSerId, string userId, string userName); + Task> IsSendUserSHRoleAsync(string schSerId, int userId, int flowid); + Task> GetDicInfoAsync(string schSerId, string typeId); + Task EditDocPrintAsync(string schSerId, string userId, WYSHRoleEditDocPrintDto model); + + // ---- 用户记录查看和审核 ---- + Task GetshenheFlowAsync(string schSerId, decimal docprintId, int flowid); + Task> MyListtoOneSHRoleAsync(int serId, string schSerId); + Task> MyListSHRoleAsync(string name, string status, string startDate, string endDate, int pageSize, int pageIndex, string userId, string schSerId); + Task> TotalListSHRoleAsync(int flowid, int dwid, string name, string stateid, string starttime, string endtime, int pageSize, int pageIndex, string schSerId); + Task> SendRdListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex); + Task> SPListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string userName, string schSerId, int pageSize, int pageIndex); + Task ShenheOkSHRoleAsync(FlowTranctDto model); + + // ---- 规格表 / 签名 / 退回 / 金额 ---- + Task GetDocOftableAsync(string schSerId, decimal docPrintId); + Task SaveSignatureAsync(string schSerId, string userId, string pic); + Task GetSignatureAsync(string schSerId, string userId); + Task DelSignatureAsync(string schSerId, string userId); + Task TuiHuiAsync(string userId, string cont, decimal docId); + Task UnTuiHuiAsync(string userId, decimal docId); + Task RegetJEAsync(string schSerId, string userId, List listDocPrintId, string type); + Task> GetRegetJERdAsync(string schSerId, int pageSize, int pageIndex); + Task SetDownFileTimesAsync(int serId); + } +} diff --git a/src/YhCore.Service/YhCore.Service.csproj b/src/YhCore.Service/YhCore.Service.csproj new file mode 100644 index 0000000..4270285 --- /dev/null +++ b/src/YhCore.Service/YhCore.Service.csproj @@ -0,0 +1,34 @@ + + + + net5.0 + YhCore.Service + YhCore.Service + 9.0 + disable + + + + + + + + + + + + + + + + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.Platform.Abstractions\bin\Debug\net5.0\Yh.Platform.Abstractions.dll + + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.SchClass.OpenApi\bin\Debug\net5.0\Yh.SchClass.OpenApi.dll + + + + diff --git a/src/YhCore.Task/Jobs/CleanupLogJob.cs b/src/YhCore.Task/Jobs/CleanupLogJob.cs new file mode 100644 index 0000000..fef9f92 --- /dev/null +++ b/src/YhCore.Task/Jobs/CleanupLogJob.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; +using YhCore.Entity; +using YhCore.Repository; + +namespace YhCore.Tasks.Jobs +{ + /// + /// 审计日志清理 Job(对应 Java scheduler/SysOperationLogCleanupTask)。 + /// 定时删除 30 天前的系统审计日志(sys_operation_log 表)。 + /// + public class CleanupLogJob : IJob + { + private const int BatchSize = 1000; + + public async Task Execute(IJobExecutionContext context) + { + var services = (IServiceProvider)context.Scheduler.Context.Get("services"); + using var scope = services.CreateScope(); + var provider = scope.ServiceProvider; + + var logger = provider.GetRequiredService>(); + var repo = provider.GetRequiredService>(); + + var cutoff = DateTime.Now.AddDays(-30); + logger.LogInformation("审计日志清理开始:删除 {Cutoff:yyyy-MM-dd HH:mm:ss} 之前的数据", cutoff); + + try + { + var oldList = await repo.FindAsync(l => l.CreateTime < cutoff); + if (oldList.Count == 0) + { + logger.LogInformation("审计日志清理完成:无 30 天前的数据"); + return; + } + + logger.LogInformation("审计日志清理:待删除 {Count} 条,分块提交(每批 {Batch} 条)", oldList.Count, BatchSize); + + var removed = 0; + foreach (var batch in Chunk(oldList, BatchSize)) + { + foreach (var item in batch) + { + await repo.RemoveAsync(item); + } + removed += await repo.SaveChangesAsync(); + } + + logger.LogInformation("审计日志清理完成:共删除 {Count} 条", removed); + } + catch (Exception ex) + { + // 缺表/数据库异常时记录错误日志,不让 Job 崩溃(Quartz 连续失败会标记 Job 错误状态) + logger.LogError(ex, "审计日志清理失败:{Message}", ex.Message); + } + } + + /// 将列表分块(net5.0 无 Enumerable.Chunk,自行实现) + private static IEnumerable> Chunk(List source, int size) + { + for (var i = 0; i < source.Count; i += size) + { + yield return source.GetRange(i, Math.Min(size, source.Count - i)); + } + } + } +} diff --git a/src/YhCore.Task/QuartzHostedService.cs b/src/YhCore.Task/QuartzHostedService.cs new file mode 100644 index 0000000..ba4a4d6 --- /dev/null +++ b/src/YhCore.Task/QuartzHostedService.cs @@ -0,0 +1,75 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Quartz; +using YhCore.Tasks.Jobs; + +namespace YhCore.Tasks +{ + /// + /// Quartz 调度宿主(对应 Java scheduler 包由 Spring 管理的调度器)。 + /// 承载框架内置 Job 模板;业务 Job 在 InitJobs 中追加注册。 + /// + public class QuartzHostedService : IHostedService + { + private const string ServiceProviderKey = "services"; + + private readonly ISchedulerFactory _schedulerFactory; + private readonly IServiceProvider _serviceProvider; + private IScheduler _scheduler; + + public QuartzHostedService(ISchedulerFactory schedulerFactory, IServiceProvider serviceProvider) + { + _schedulerFactory = schedulerFactory; + _serviceProvider = serviceProvider; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _scheduler = await _schedulerFactory.GetScheduler(cancellationToken); + _scheduler.Context.Put(ServiceProviderKey, _serviceProvider); // Job 经此取 DI 服务 + + await InitJobs(); + + await _scheduler.Start(cancellationToken); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_scheduler != null) + { + await _scheduler.Shutdown(waitForJobsToComplete: true, cancellationToken); + } + } + + private async Task InitJobs() + { + // ============ 定时任务注册 ============ + + // 1. 审计日志清理(对应 Java SysOperationLogCleanupTask:每日凌晨 3:30 删除 30 天前 sys_operation_log 数据) + await ScheduleJobAsync("cleanup-log-job", "0 30 3 * * ?"); + + // 2. 其他业务 Job 在此追加注册(示例:每 10 分钟执行一次) + // await ScheduleJobAsync("job-name", "0 */10 * * * ?"); + + await Task.CompletedTask; + } + + /// 注册一个 Cron 定时任务 + protected async Task ScheduleJobAsync(string jobName, string cronExpression) where TJob : IJob + { + var job = JobBuilder.Create() + .WithIdentity(jobName) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(jobName + "-trigger") + .WithCronSchedule(cronExpression) + .Build(); + + await _scheduler.ScheduleJob(job, trigger); + } + } +} diff --git a/src/YhCore.Task/QuartzServiceCollectionExtensions.cs b/src/YhCore.Task/QuartzServiceCollectionExtensions.cs new file mode 100644 index 0000000..6151c7d --- /dev/null +++ b/src/YhCore.Task/QuartzServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using Quartz.Impl; + +namespace YhCore.Tasks +{ + /// + /// 任务调度注册扩展:Quartz 调度器工厂 + 宿主服务(对应 Java scheduler 自动装配)。 + /// + public static class QuartzServiceCollectionExtensions + { + public static IServiceCollection AddQuartzHosted(this IServiceCollection services) + { + services.AddSingleton(); + services.AddHostedService(); + return services; + } + } +} diff --git a/src/YhCore.Task/YhCore.Task.csproj b/src/YhCore.Task/YhCore.Task.csproj new file mode 100644 index 0000000..c2fc0c1 --- /dev/null +++ b/src/YhCore.Task/YhCore.Task.csproj @@ -0,0 +1,25 @@ + + + + net5.0 + YhCore.Task + YhCore.Task + 9.0 + disable + + + + + + + + + + + + + + + + + diff --git a/src/YhCore.WebApi/Controllers/Api/Com/LogController.cs b/src/YhCore.WebApi/Controllers/Api/Com/LogController.cs new file mode 100644 index 0000000..8fc2dc6 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/Com/LogController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; +using YhCore.Common; +using YhCore.Common.UserContext; +using YhCore.Entity; +using YhCore.Model.Vo; +using YhCore.Service.Interfaces; + +namespace YhCore.WebApi.Controllers.Api +{ + /// + /// 日志查询接口(对应 Java 版 controller/api/LogController,路由 /api/log/)。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/log")] + public class LogController : ControllerBase + { + private readonly ILogService _logService; + + public LogController(ILogService logService) + { + _logService = logService; + } + + /// + /// 系统操作日志搜索(对应 Java GET /api/log/systemSearch)。 + /// + /// 关键字(可空) + /// 页码,从 0 开始(对应 Spring Pageable.page) + /// 每页大小(对应 Spring Pageable.size) + [HttpGet("systemSearch")] + public PageVO SystemSearch( + [FromQuery] string keyword, + [FromQuery] int page = 0, + [FromQuery] int size = 20) + { + return _logService.Search(UserContextHolder.GetSchId(), keyword, true, page, size); + } + + /// + /// 操作日志搜索(对应 Java GET /api/log/search)。 + /// + [HttpGet("search")] + public PageVO Search( + [FromQuery] string keyword, + [FromQuery] int page = 0, + [FromQuery] int size = 20) + { + return _logService.Search(UserContextHolder.GetSchId(), keyword, false, page, size); + } + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/Com/SchClassController.cs b/src/YhCore.WebApi/Controllers/Api/Com/SchClassController.cs new file mode 100644 index 0000000..572cb52 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/Com/SchClassController.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Yh.SchClass.OpenApi; +using Yh.SchClass.OpenApi.Model; +using YhCore.Common; + +namespace YhCore.WebApi.Controllers.Api +{ + /// + /// 教务(SchClass)开放接口演示控制器:引用 Yh.SchClass.OpenApi.dll(Library), + /// 对应 Java 教务 bschclassmanage 的 /oapi 端点(配置节 yh:schclass:open)。 + /// SDK 门面 聚合 10 个客户端,本控制器演示其中 5 个。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/com/schClass")] + public class SchClassController : ControllerBase + { + private readonly SchClassOpenApi _sch; + + public SchClassController(SchClassOpenApi sch) + { + _sch = sch ?? throw new ArgumentNullException(nameof(sch)); + } + + /// + /// 全部学校列表(GET /oapi/SchInfo/AllList)。 + /// + [HttpGet("schools")] + public async Task> Schools() + { + return await _sch.SchInfo.AllListAsync(); + } + + /// + /// 按学校外部序列号查询学校(GET /oapi/SchInfo/GetSchFirst)。 + /// + /// 学校外部序列号 + [HttpGet("school")] + public async Task School([FromQuery] string schId) + { + return await _sch.SchInfo.GetSchFirstAsync(schId); + } + + /// + /// 按学校查询教师列表(GET /oapi/OATea/GetSchTeainfo)。 + /// + /// 学校外部序列号 + /// 学校内部 ID(优先使用) + [HttpGet("teachers")] + public async Task> Teachers( + [FromQuery] string schSerId, + [FromQuery] int? schId) + { + return await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, schId); + } + + /// + /// 按学校查询班级列表(GET /oapi/VSchClass/GetVClassList)。 + /// + /// 学校外部序列号 + [HttpGet("classes")] + public async Task>> Classes([FromQuery] string schSerId) + { + var rows = await _sch.VClass.GetVClassListAsync(schSerId); + return rows.Select(ToPlainRow).ToList(); + } + + /// + /// 按学校查询学生列表(GET /oapi/OAStu/GetSchStuList)。 + /// + /// 学校外部序列号 + /// 学校分组 ID(可空) + [HttpGet("students")] + public async Task>> Students( + [FromQuery] string schserid, + [FromQuery] string schgroupid) + { + var rows = await _sch.StuInfo.GetSchStuListAsync(schserid, schgroupid); + return rows.Select(ToPlainRow).ToList(); + } + + // ===================== JsonElement → 普通对象 ===================== + // SDK 行类型 SchClassRow = Dictionary(Java Map 兼容); + // 直接返回会被 Newtonsoft 序列化成 JsonElement 内部结构,这里转成原生对象。 + + private static Dictionary ToPlainRow(Dictionary row) + => row.ToDictionary(kv => kv.Key, kv => ToPlain(kv.Value)); + + private static object ToPlain(JsonElement el) + { + switch (el.ValueKind) + { + case JsonValueKind.String: + return el.GetString(); + case JsonValueKind.Number: + return el.TryGetInt32(out var i) ? (object)i : el.GetDouble(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + return null; + case JsonValueKind.Array: + return el.EnumerateArray().Select(ToPlain).ToList(); + case JsonValueKind.Object: + return el.EnumerateObject().ToDictionary(p => p.Name, p => ToPlain(p.Value)); + default: + return el.ToString(); + } + } + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/Com/UserController.cs b/src/YhCore.WebApi/Controllers/Api/Com/UserController.cs new file mode 100644 index 0000000..0ba0061 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/Com/UserController.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Threading.Tasks; +using Yh.Platform.Abstractions; +using Yh.Platform.Auth; +using Yh.Platform.OpenApi; +using YhCore.Common; + +namespace YhCore.WebApi.Controllers.Api +{ + /// + /// 用户基础接口(对应 Java 版 controller/api/UserController,路由 /api/base/)。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/base")] + public class UserController : ControllerBase + { + private readonly IOpenApiUserClient _openApiUserClient; + + public UserController(IOpenApiUserClient openApiUserClient) + { + _openApiUserClient = openApiUserClient; + } + + /// + /// 获取当前用户可见的应用模块(对应 Java GET /api/base/getUserMod, + /// 内部调用 YschBaseOpenApi.getUserModule)。 + /// + [HttpGet("getUserMod")] + public async Task> GetUserMod() + { + var boa = BoaUserContext.Get(); + return await _openApiUserClient.GetUserModuleAsync(boa?.Id ?? 0, boa?.School?.Id); + } + + /// + /// 获取当前登录用户信息(对应 Java GET /api/base/getUserInfo, + /// Java 版直接返回认证切面注入的 authUserWithDept)。 + /// + [HttpGet("getUserInfo")] + public BoaUser GetUserInfo() + { + return BoaUserContext.Get(); + } + + /// + /// 登出(对应 Java GET /api/base/logout:session.invalidate())。 + /// + [HttpGet("logout")] + public async Task Logout() + { + // 清除本地 Cookie 登录态 + await HttpContext.SignOutAsync(YhAuthDefaults.CookieScheme); + + if (HttpContext.Features.Get() != null) + { + HttpContext.Session.Clear(); + } + + return Ok(); + } + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/Common/CommonController.cs b/src/YhCore.WebApi/Controllers/Api/Common/CommonController.cs new file mode 100644 index 0000000..0e23bfd --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/Common/CommonController.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Yh.Platform.Abstractions; +using YhCore.Common; +using YhCore.Common.Config; +using YhCore.Common.UserContext; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Service.Interfaces; + +namespace YhCore.WebApi.Controllers.Api.Common +{ + /// + /// 公共字典 / 文件(对应旧 Areas/Common/CommonController,路由 api/base)。 + /// 字典部分 8 接口 + 文件部分 4 接口(FileDown / FilePreview / FileUpload / FileUploadImage)已落地; + /// 旧 [NoAuth] 语义:无需用户上下文,schId 由前端传入或取当前用户。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/base")] + public class CommonController : ControllerBase + { + private readonly IDictionaryService _dict; + private readonly IFileService _file; + + public CommonController(IDictionaryService dict, IFileService file) + { + _dict = dict; + _file = file; + } + + /// 获取指定字典数据(Type 1~10)。 + [HttpGet("GetSysDic")] + public async Task> GetSysDic([FromQuery] int type = 0) + { + var typeId = DictionaryTypeHelper.FromSysDicType(type); + if (typeId == null) + { + throw new DException("参数错误"); + } + + return await _dict.GetDictListAsync(typeId, SchSerID()); + } + + /// 字典类别列表([NoAuth],排除部门)。 + [HttpGet("GetDicTypeList")] + public async Task> GetDicTypeList() => await _dict.GetDicTypeListAsync(); + + /// 字典列表管理(Type=0/DEPT 取全校;其他仅超管 bestsch)。 + [HttpGet("GetDictMangeList")] + public async Task> GetDictMangeList([FromQuery] string type) + { + var typeId = DictionaryTypeHelper.ResolveTypeId(type); + if (typeId == null) + { + throw new DException("参数错误"); + } + + if (DictionaryTypeHelper.IsDept(typeId)) + { + return await _dict.GetDictMangeListAsync("DEPT", SchSerID()); + } + + //if (UserContextHolder.Current.() =1 ) + //{ + // throw new DException("非超管不得操作!"); + //} + + return await _dict.GetDictMangeListAsync(typeId, ""); + } + + /// 添加字典。 + [HttpPost("AddDic")] + public async Task AddDic([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + + var info = new Common_Dictionary + { + typeID = model.typeID, + typeName = DictionaryTypeHelper.GetTypeName(model.typeID), + Name = model.Name, + Schserid = SchSerID() + }; + + await _dict.AddDicAsync(info); + } + + /// 更新字典。 + [HttpPost("UpdateDic")] + public async Task UpdateDic([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数有误!"); + model.Schserid = SchSerID(); + await _dict.UpdateDicAsync(model); + } + + /// 编辑字典名称。 + [HttpPost("EditDic")] + public async Task EditDic([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数有误!"); + model.Schserid = SchSerID(); + await _dict.EditDicAsync(model); + } + + /// 添加发文部门。 + [HttpPost("AddDeptDic")] + public async Task AddDeptDic([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数有误!"); + + var info = new Common_Dictionary + { + typeID = "DEPT", + typeName = "部门类别", + Name = model.Name, + Schserid = SchSerID() + }; + + await _dict.AddDicAsync(info); + } + + /// 更新发文部门。 + [HttpPost("UpdateDeptDic")] + public async Task UpdateDeptDic([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数有误!"); + model.Schserid = SchSerID(); + await _dict.UpdateDicAsync(model); + } + + #region 文件:下载 / 预览 / 上传 + + /// + /// 公用下载文件(旧 FileDown,[NoAuth])。yl=true 且为图片/PDF 时重定向到文件 URL(webapp 预览), + /// 否则物理文件下载。 + /// + [HttpGet("FileDown")] + public async Task FileDown([FromQuery] int id, [FromQuery] bool yl = false) + { + if (id == 0) + return Ok(); + + var file = await _file.GetEnclosureAsync(id); + + string[] previewArry = { ".jpg", ".gif", ".png", ".pdf", ".jpeg", ".bmp" }; + if (yl) + { + // 旧 sch_weboa_url.Replace("notice/api", AppType + "/") + fileurl → 新 FileWebPath + fileurl + string fileUrl = (Config.FileWebPath ?? "") + file.fileurl; + string suffName = Path.GetExtension(fileUrl); + if (previewArry.Any(x => x == suffName.ToLower())) + return Redirect(fileUrl); + } + + // WebHelper.DownFile:物理文件下载 + string filePath = _file.MapPhysicalPath(file.fileurl); + if (!System.IO.File.Exists(filePath)) + return Content("请求的文件不存在!"); + + string contentType = GetContentType(filePath); + string outputFileName = file.filename ?? "file"; + return PhysicalFile(filePath, contentType, outputFileName); + } + + /// + /// 公用预览 office 文件(旧 FilePreview / CommonPreview,[NoAuth])。 + /// type=1 → 返回可用预览 url(本地 .pdf 存在优先);type=2 → 重定向本地 pdf 或 officeapps.live.com; + /// 其余 → 重定向替换后缀为 .pdf 的地址。跳转返回 RedirectResult(全局包装过滤器不拦截)。 + /// + [HttpGet("FilePreview")] + public async Task FilePreview([FromQuery] int id, [FromQuery] string type = null) + { + if (id == 0) + throw new DException("文件ID错误!"); + + var file = await _file.GetEnclosureAsync(id); + + // 公文另做处理 + if (file.fileurl != null && file.fileurl.IndexOf("gw.") > -1) + { + string fileUrl = "http://" + file.fileurl; + string suffName = Path.GetExtension(fileUrl); + string newName = fileUrl.Remove(fileUrl.LastIndexOf(suffName, StringComparison.Ordinal), suffName.Length) + ".pdf"; + return Redirect(newName); + } + + string fileUrl2 = (Config.FileWebPath ?? "") + file.fileurl; + string suffName2 = Path.GetExtension(fileUrl2); + if (!string.IsNullOrEmpty(type)) + { + // 单独读取文件是否存在 + string newFile = file.fileurl; + string pdfFile = newFile.Remove(newFile.LastIndexOf(suffName2, StringComparison.Ordinal), suffName2.Length) + ".pdf"; + string outFile = _file.MapPhysicalPath(pdfFile); + + if (type == "1") + { + if (System.IO.File.Exists(outFile)) return (Config.FileWebPath ?? "") + pdfFile; + return fileUrl2; + } + if (type == "2") + { + string pdfUrl = string.Empty; + if (System.IO.File.Exists(outFile)) pdfUrl = (Config.FileWebPath ?? "") + pdfFile; + else pdfUrl = "https://view.officeapps.live.com/op/view.aspx?src=" + fileUrl2; + return Redirect(pdfUrl); + } + return ""; + } + + string newName2 = fileUrl2.Remove(fileUrl2.LastIndexOf(suffName2, StringComparison.Ordinal), suffName2.Length) + ".pdf"; + return Redirect(newName2); + } + + /// + /// 公用附件上传(旧 FileUpload,[NoAuth])。 + /// chunk 参数 → 分片保存返回 {code:1,type:"chunk",msg};fileName 参数 → 合并分片;否则单文件上传。 + /// 落库 Common_UpFiles(TopicType=AppCode),返回 {fileurl,host,fileid,name,type}。 + /// + [HttpPost("FileUpload")] + public async Task Upload() + { + string apptype = "docprint"; // 旧 WebHelper.AppType 固定 docprint + string guid = Request.Form["guid"]; + string chunk = Request.Form["chunk"]; + string fileName = Request.Form["fileName"]; + + if (!string.IsNullOrEmpty(chunk)) + { + var chunkFile = Request.Form.Files.FirstOrDefault(f => f.Name == "file") ?? Request.Form.Files.FirstOrDefault(); + await _file.SaveChunkAsync(guid, chunk, chunkFile); + return new { code = 1, type = "chunk", msg = "分片上传成功!" }; + } + + FileObj url; + string errMsg = "上传失败!"; + if (string.IsNullOrEmpty(fileName)) + { + url = await _file.SaveFileAsync(apptype, Request.Form.Files.FirstOrDefault()); + } + else + { + url = await _file.MergeSaveAsync(apptype, guid, fileName); + } + if (url == null) + throw new DException(errMsg); + + // 旧 WebHelper.AppCode == "1102" ? "1002" : AppCode → docprint 固定 1003 + int serid = await _file.SaveEnclosureAsync(url, DocPrintConstants.AppCode.ToString()); + return new { fileurl = url.fileurl, host = url.host, fileid = serid, name = url.filename, type = url.filetype }; + } + + /// 公用图片上传(旧 FileUploadImage,[NoAuth];多图,返回数组)。 + [HttpPost("FileUploadImage")] + public async Task UploadImg() + { + string apptype = "docprint"; + var urlList = await _file.SaveFileImageAsync(apptype, Request.Form.Files); + if (urlList.Count == 0) + throw new DException("上传失败!"); + + var result = new List(); + foreach (var item in urlList) + { + int serid = await _file.SaveEnclosureAsync(item, DocPrintConstants.AppCode.ToString()); + result.Add(new { fileurl = item.fileurl, host = item.host, fileid = serid, name = item.filename, type = item.filetype }); + } + return result; + } + + /// 按扩展名取 ContentType(替代旧 MimeMapping.GetMimeMapping)。 + private static string GetContentType(string filePath) + { + string ext = Path.GetExtension(filePath).ToLower(); + return ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".png" => "image/png", + ".bmp" => "image/bmp", + ".pdf" => "application/pdf", + ".txt" => "text/plain", + ".doc" => "application/msword", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls" => "application/vnd.ms-excel", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt" => "application/vnd.ms-powerpoint", + ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".zip" => "application/zip", + ".rar" => "application/x-rar-compressed", + ".mp4" => "video/mp4", + _ => "application/octet-stream", + }; + } + + #endregion + + private static string SchSerID() => UserContextHolder.Current.SchSerID; + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/Common/FreeApproveController.cs b/src/YhCore.WebApi/Controllers/Api/Common/FreeApproveController.cs new file mode 100644 index 0000000..b0b231e --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/Common/FreeApproveController.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using YhCore.Common; +using YhCore.Common.UserContext; +using YhCore.Model.Dto; +using YhCore.Service.Interfaces; + +namespace YhCore.WebApi.Controllers.Api.Common +{ + /// + /// 自由审批流控制器(对应旧 Areas/Common/FreeApproveController,路由 api/common/freeapprove)。 + /// 7 接口:自由流程节点查询/保存、审核意见、审核回显、抄送人。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/common/freeapprove")] + public class FreeApproveController : ControllerBase + { + private readonly IFreeApproveService _svc; + + public FreeApproveController(IFreeApproveService svc) + { + _svc = svc; + } + + /// 获取当前自由流程节点(dataid 为空时取最大 dataid)。 + [HttpGet("GetFleeFlow")] + public Task> GetFleeFlow([FromQuery] int? dataid = null) + => _svc.GetFleeFlowAsync(UserContextHolder.Current?.Id ?? 0, dataid); + + /// 保存自由流程审核人,返回首步骤 SerID。 + [HttpGet("EditFleeFlow")] + public Task EditFleeFlow([FromQuery] string FlowUids, [FromQuery] int? dataid = null, [FromQuery] bool IsFlowNull = false) + => _svc.EditFleeFlowAsync(UserContextHolder.Current?.Id ?? 0, SchSerID(), dataid, FlowUids, IsFlowNull); + + /// 添加审核意见。 + [HttpGet("AddShenheRemark")] + public Task AddShenheRemark([FromQuery] int? dataid, [FromQuery] string remark, [FromQuery] int freestepid) + => _svc.AddShenheRemarkAsync(UserContextHolder.Current?.Id ?? 0, dataid, remark, freestepid); + + /// 获取后续自由流程节点。 + [HttpGet("GetAfterFleeFlow")] + public Task> GetAfterFleeFlow([FromQuery] int? dataid, [FromQuery] int freestepid) + => _svc.GetAfterFleeFlowAsync(dataid, freestepid); + + /// 编辑后续自由流程,返回末步骤 SerID。 + [HttpGet("EditAfterFleeFlow")] + public Task EditAfterFleeFlow([FromQuery] int? dataid, [FromQuery] int freestepid, [FromQuery] string FlowUids) + => _svc.EditAfterFleeFlowAsync(dataid, freestepid, FlowUids); + + /// 自由流程审核回显(发起人 + 审核人步骤)。 + [HttpGet("ShenheFlow")] + public Task> ShenheFlow([FromQuery] int uid, [FromQuery] string ctime, [FromQuery] int dataid, + [FromQuery] int? freestepid = null, [FromQuery] int? flowid = null) + => _svc.ShenheFlowAsync(uid, ctime, dataid, freestepid, flowid); + + /// 抄送人列表(按 uids 查用户)。 + [HttpGet("ChaoSendList")] + public Task> ChaoSendList([FromQuery] string uids) + => _svc.ChaoSendListAsync(uids); + + private static string SchSerID() => UserContextHolder.Current.SchSerID; + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/Common/ReviewprocessController.cs b/src/YhCore.WebApi/Controllers/Api/Common/ReviewprocessController.cs new file mode 100644 index 0000000..67c4587 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/Common/ReviewprocessController.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Yh.SchClass.OpenApi.Model; +using YhCore.Common; +using YhCore.Common.UserContext; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Model.Vo; +using YhCore.Service.Interfaces; + +namespace YhCore.WebApi.Controllers.Api.Common +{ + /// + /// 审核流程管理控制器(对应旧 Areas/Common/reviewprocessController,路由 api/common/reviewprocess)。 + /// 23 接口:流程/步骤 CRUD、教师查询、流程判断、用户角色。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/common/reviewprocess")] + public class ReviewprocessController : ControllerBase + { + private readonly IReviewProcessService _svc; + + public ReviewprocessController(IReviewProcessService svc) + { + _svc = svc; + } + + #region 流程与步骤 + + /// 审核流程列表(分页)。 + [HttpGet("List")] + public Task> List([FromQuery] string Name, [FromQuery] int PageSize, + [FromQuery] int PageIndex, [FromQuery] int flowType, [FromQuery] bool Edit = false) + => _svc.ListAsync(Name, PageSize, PageIndex, flowType, Edit, SchSerID()); + + /// 审核流程列表【局版】(降级为当前学校)。 + [HttpGet("List2")] + public Task> List2([FromQuery] string Name, [FromQuery] int PageSize, + [FromQuery] int PageIndex, [FromQuery] int flowType, [FromQuery] bool Edit = false) + => _svc.List2Async(Name, PageSize, PageIndex, flowType, Edit, SchSerID()); + + /// 步骤流程列表。 + [HttpGet("StepList")] + public Task> StepList([FromQuery] string Name, [FromQuery] int Serid) + => _svc.StepListAsync(Name, Serid); + + /// 添加审核流程。 + [HttpPost("Create")] + public async Task Create([FromBody] Common_flowinfo model) + { + model.schSerID = SchSerID(); + await _svc.CreateAsync(model); + } + + /// 修改审核流程。 + [HttpPost("Edit")] + public async Task Edit([FromBody] Common_flowinfo model) + { + model.schSerID = SchSerID(); + await _svc.EditAsync(model); + } + + /// 添加步骤流程。 + [HttpPost("StepCreate")] + public Task StepCreate([FromBody] Common_flowstep model) + => _svc.StepCreateAsync(model); + + /// 修改步骤流程。 + [HttpPost("StepEdit")] + public Task StepEdit([FromBody] Common_flowstep model) + => _svc.StepEditAsync(model); + + /// 删除审核流程(ids 逗号分隔)。 + [HttpGet("Delete")] + public Task Delete([FromQuery] string ids) + => _svc.DeleteAsync(ids); + + /// 删除步骤。 + [HttpPost("StepDelete")] + public Task StepDelete([FromBody] decimal[] ids) + => _svc.StepDeleteAsync(ids); + + /// 流程绑定的步骤列表。 + [HttpGet("BindstepList")] + public Task> BindstepList([FromQuery] int Serid) + => _svc.BindstepListAsync(Serid); + + /// 绑定步骤的人员。 + [HttpGet("BindGroupuserList")] + public Task> BindGroupuserList([FromQuery] int flowid, [FromQuery] int prestep) + => _svc.BindGroupuserListAsync(flowid, prestep); + + /// 绑定流程(流程 + 抄送人)。 + [HttpGet("Bindflowinfo")] + public Task> Bindflowinfo([FromQuery] int flowType) + => _svc.BindflowinfoAsync(SchSerID(), flowType); + + #endregion + + #region 教师 / 人员 + + /// 当前学校教师列表。 + [HttpGet("GetTeaList")] + public Task> GetTeaList() + => _svc.GetTeaListAsync(SchSerID()); + + /// 学校 + 教育局教师列表(降级为当前学校)。 + [HttpGet("GetTeaListSup")] + public Task> GetTeaListSup() + => _svc.GetTeaListSupAsync(SchSerID()); + + /// 教师信息分页。 + [HttpGet("GetUserinfo")] + public Task> GetUserinfo([FromQuery] string Name, [FromQuery] int statetype, + [FromQuery] int PageSize, [FromQuery] int PageIndex) + => _svc.GetUserinfoAsync(SchSerID(), Name, statetype, PageSize, PageIndex); + + /// 非中层领导人员。 + [HttpGet("GetNoLeadingMiddleInfo")] + public Task GetNoLeadingMiddleInfo() + => _svc.GetNoLeadingMiddleInfoAsync(SchSerID()); + + /// 非中层领导人员【局版】(降级为当前学校)。 + [HttpGet("GetNoLeadingMiddleInfoSup")] + public Task GetNoLeadingMiddleInfoSup() + => _svc.GetNoLeadingMiddleInfoSupAsync(SchSerID()); + + /// 设置/取消中层领导(教务 SDK 未提供,暂不支持)。 + [HttpGet("changeLeadingMiddleStatus")] + public Task ChangeLeadingMiddleStatus([FromQuery] int state, [FromQuery] string UserTeaSerIDstr) + => _svc.ChangeLeadingMiddleStatusAsync(state, UserTeaSerIDstr); + + /// 附件数据源。 + [HttpGet("filelist")] + public Task> Filelist([FromQuery] string ids) + => _svc.FilelistAsync(ids); + + #endregion + + #region 流程判断 / 用户 + + /// 该校审核流程是固定还是自由。 + [HttpGet("IsShenheflow")] + public Task IsShenheflow() + => _svc.IsShenheflowAsync(SchSerID(), UserContextHolder.Current?.Id ?? 0); + + /// 绑定抄送默认人。 + [HttpGet("IsSendUser")] + public Task> IsSendUser() + => _svc.IsSendUserAsync(SchSerID(), UserContextHolder.Current?.Id ?? 0); + + /// 当前用户基础信息。 + [HttpGet("GetUser")] + public Task GetUser() + => _svc.GetUserAsync(); + + /// 用户流程角色(1 有流程,0 无)。 + [HttpGet("GetUserFlowRole")] + public Task GetUserFlowRole() + => _svc.GetUserFlowRoleAsync(UserContextHolder.Current?.Id ?? 0); + + #endregion + + private static string SchSerID() => UserContextHolder.Current.SchSerID; + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/DocPrint/DocPrintStatisticsController.cs b/src/YhCore.WebApi/Controllers/Api/DocPrint/DocPrintStatisticsController.cs new file mode 100644 index 0000000..a268372 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/DocPrint/DocPrintStatisticsController.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Yh.Platform.Abstractions; +using YhCore.Common; +using YhCore.Model.Dto; +using YhCore.Service.Interfaces; + +namespace YhCore.WebApi.Controllers.Api.DocPrint +{ + /// + /// 文印统计看板控制器(对应旧 Areas/DocPrint/DocPrintStatisticsController,路由 api/docprint/statistics)。 + /// 旧类级 [NoAuth]:schId 由前端传入(非当前用户),新项目 SSO 关闭时匿名放行、开启时按配置保护, + /// 语义一致。ExportRecord(Excel 导出,NPOI)延后迁移。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/docprint/statistics")] + public class DocPrintStatisticsController : ControllerBase + { + private readonly IDocPrintStatisticsService _statistics; + + public DocPrintStatisticsController(IDocPrintStatisticsService statistics) + { + _statistics = statistics; + } + + /// 打印总数/消耗/最多类型统计(旧 GetDocPrintCount)。 + [HttpGet("GetDocPrintCount")] + public async Task> GetDocPrintCount( + [FromQuery] int schId, + [FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null) + => await _statistics.GetDocPrintCountAsync(schId, startDate, endDate); + + /// 饼图(按类型,消耗/数量,旧 GetPieChart)。 + [HttpGet("GetPieChart")] + public async Task> GetPieChart( + [FromQuery] int schId, + [FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null) + => await _statistics.GetPieChartAsync(schId, startDate, endDate); + + /// 打印类型列表(旧 GetDocPrintType)。 + [HttpGet("GetDocPrintType")] + public async Task> GetDocPrintType([FromQuery] int schId) + => await _statistics.GetDocPrintTypeAsync(schId); + + /// 分页明细(旧 GetDocPrintList;旧 1 基 pageIndex 内部转 0 基)。 + [HttpGet("GetDocPrintList")] + public async Task GetDocPrintList( + [FromQuery] int schId, + [FromQuery] string docType = "ALL", + [FromQuery] string likeName = null, + [FromQuery] int pageIndex = 1, + [FromQuery] int pageSize = 10, + [FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null) + { + if (pageSize <= 0) throw new DException("pageSize 必须大于 0"); + return await _statistics.GetDocPrintListAsync(schId, docType, likeName, pageIndex, pageSize, startDate, endDate); + } + + // 延后:ExportRecord(Excel 导出,NPOI) + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/DocPrint/LoadingController.cs b/src/YhCore.WebApi/Controllers/Api/DocPrint/LoadingController.cs new file mode 100644 index 0000000..b269f13 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/DocPrint/LoadingController.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using YhCore.Common; +using YhCore.Entity; +using YhCore.Repository; + +namespace YhCore.WebApi.Controllers.Api.DocPrint +{ + /// + /// 启动预热控制器(对应旧 Areas/DocPrint/LoadingController,路由 api/docprint/loading)。 + /// 无业务逻辑:执行一次 Base_Log 查询触发数据库连接建立,防止首请求慢。 + /// 旧接口 void 无返回体;新项目由 GlobalWrapResultFilter 统一包装为 {code:0,msg:"",data:null}。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/docprint/loading")] + public class LoadingController : ControllerBase + { + private readonly IRepository _log; + + public LoadingController(IRepository log) + { + _log = log; + } + + /// 预热(执行一次 Base_Log 查询)。 + [HttpGet("List")] + public async Task List() + { + await _log.FirstOrDefaultAsync(a => a.Id > 0); + } + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/DocPrint/WYSHRoleController.cs b/src/YhCore.WebApi/Controllers/Api/DocPrint/WYSHRoleController.cs new file mode 100644 index 0000000..4ce3093 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/DocPrint/WYSHRoleController.cs @@ -0,0 +1,334 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Yh.Platform.Abstractions; +using YhCore.Common; +using YhCore.Common.UserContext; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Model.Vo; +using YhCore.Service.Interfaces; + +namespace YhCore.WebApi.Controllers.Api.DocPrint +{ + /// + /// 审核角色 + 流程配置控制器(对应旧 Areas/DocPrint/WYSHRoleController,路由 api/docprint/wyshrole)。 + /// 45 action 中本批落地 39 个;Excel 导入导出(ExceltotalListSHRole/WYRDExceldaochu/UpfileExcelSHRole/ + /// downMBOfSHRole/downUpfileSHRoleFK)与审批单 PDF(DwonWYSQD)依赖 NPOI/PdfHelper,延后迁移。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/docprint/wyshrole")] + public class WYSHRoleController : ControllerBase + { + private readonly IWYSHRoleService _role; + + public WYSHRoleController(IWYSHRoleService role) + { + _role = role; + } + + #region 审核角色管理 + + /// 学校是否启用审核角色(旧 getIsSHRole)。 + [HttpGet("getIsSHRole")] + public async Task GetIsSHRole() => await _role.GetIsSHRoleAsync(SchSerID()); + + /// 保存启用开关(旧 saveIsSHRole)。 + [HttpGet("saveIsSHRole")] + public async Task SaveIsSHRole([FromQuery] string val) => await _role.SaveIsSHRoleAsync(SchSerID(), val); + + /// 审核角色列表(旧 getSHRole)。 + [HttpGet("getSHRole")] + public async Task> GetSHRole() => await _role.GetSHRoleAsync(SchSerID()); + + /// 角色改名(旧 UpdateSHRoleName)。 + [HttpGet("UpdateSHRoleName")] + public async Task UpdateSHRoleName([FromQuery] decimal serID, [FromQuery] string sHRoleName) + => await _role.UpdateSHRoleNameAsync(SchSerID(), UserId(), serID, sHRoleName); + + /// 新增/编辑角色(旧 EditSHRole,form: WYSHRole=JSON → [FromBody])。 + [HttpPost("EditSHRole")] + public async Task EditSHRole([FromBody] WYSHRoleExt model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.EditSHRoleAsync(SchSerID(), UserId(), model); + } + + /// 编辑成员行(旧 editSHTeaRow,form: SHTeaTxt → [FromBody])。 + [HttpPost("editSHTeaRow")] + public async Task EditSHTeaRow([FromBody] WY_SHRole_SHTea model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.EditSHTeaRowAsync(SchSerID(), UserId(), model); + } + + /// 删除成员行(旧 delSHTeaRow)。 + [HttpGet("delSHTeaRow")] + public async Task DelSHTeaRow([FromQuery] decimal serID) + => await _role.DelSHTeaRowAsync(SchSerID(), serID); + + /// 批量保存审核人(旧 editSHTea,form: SHRoleID + listSHTeaTxt → [FromBody])。 + [HttpPost("editSHTea")] + public async Task EditSHTea([FromBody] EditSHTeaDto model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.EditSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listSHTea); + } + + /// 批量保存被审核人(旧 editBSHTea,form: SHRoleID + listBSHTeaTxt → [FromBody])。 + [HttpPost("editBSHTea")] + public async Task EditBSHTea([FromBody] EditBSHTeaDto model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.EditBSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listBSHTea); + } + + /// 删除角色(旧 delSHRole,form: listSHRoleIDTxt → [FromBody])。 + [HttpPost("delSHRole")] + public async Task DelSHRole([FromBody] DelSHRoleDto model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.DelSHRoleAsync(SchSerID(), model.listSHRoleID); + } + + #endregion + + #region 审核角色流程 + + /// 审核流程列表(旧 ListFlow)。 + [HttpGet("ListFlow")] + public async Task> ListFlow( + [FromQuery] string name = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0, + [FromQuery] int flowType = 0, + [FromQuery] bool edit = false) + => await _role.ListFlowAsync(name, pageSize, pageIndex, SchSerID(), flowType, edit); + + /// 新增/编辑流程(旧 EditFlow;schSerID 取当前用户)。 + [HttpPost("EditFlow")] + public async Task EditFlow([FromBody] Common_flowinfo model) + { + if (model == null) throw new DException("参数错误!"); + model.schSerID = SchSerID(); + return await _role.EditFlowAsync(model); + } + + /// 新增步骤(旧 StepCreateOfSHRole)。 + [HttpPost("StepCreateOfSHRole")] + public async Task StepCreateOfSHRole([FromBody] Common_flowstep model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.StepCreateOfSHRoleAsync(model); + } + + /// 步骤详情(旧 getStepInfo)。 + [HttpGet("getStepInfo")] + public async Task GetStepInfo([FromQuery] decimal flowid) + => await _role.GetStepInfoAsync(SchSerID(), flowid); + + /// 删除步骤(旧 StepDelete)。 + [HttpGet("StepDelete")] + public async Task StepDelete([FromQuery] int id) + => await _role.StepDeleteAsync(new List { id }); + + /// 改步骤名/条件/金额/按钮显隐(旧 updateStepName)。 + [HttpGet("updateStepName")] + public async Task UpdateStepName( + [FromQuery] int serID, + [FromQuery] int flowid, + [FromQuery] string stepName, + [FromQuery] int cond = 0, + [FromQuery] string isshowJE = "0", + [FromQuery] string isshowBtn = "1") + => await _role.UpdateStepNameAsync(SchSerID(), serID, flowid, stepName, cond, isshowJE, isshowBtn); + + /// 步骤绑定角色(旧 AddStepSHRole)。 + [HttpGet("AddStepSHRole")] + public async Task AddStepSHRole([FromQuery] int flowid, [FromQuery] int stepid, [FromQuery] decimal shRoleid) + => await _role.AddStepSHRoleAsync(SchSerID(), flowid, stepid, shRoleid); + + /// 步骤解绑角色(旧 delStepSHRole)。 + [HttpGet("delStepSHRole")] + public async Task DelStepSHRole([FromQuery] int stepid, [FromQuery] decimal shRoleid) + => await _role.DelStepSHRoleAsync(SchSerID(), stepid, shRoleid); + + /// 按金额取步骤(旧 getStepByJE)。 + [HttpGet("getStepByJE")] + public async Task GetStepByJE([FromQuery] int flowid, [FromQuery] int je) + => await _role.GetStepByJEAsync(SchSerID(), UserId(), flowid, je); + + #endregion + + #region 文印申请页面 + + /// 流程发起人列表(旧 getLCOfUser)。 + [HttpGet("getLCOfUser")] + public async Task> GetLCOfUser() + => await _role.GetLCOfUserAsync(SchSerID(), UserId(), UserName()); + + /// 当前用户是否发起人(旧 IsSendUserSHRole)。 + [HttpGet("IsSendUserSHRole")] + public async Task> IsSendUserSHRole([FromQuery] int flowid) + => await _role.IsSendUserSHRoleAsync(SchSerID(), UserContextHolder.Current?.Id ?? 0, flowid); + + /// 字典信息(旧 getDicInfo)。 + [HttpGet("getDicInfo")] + public async Task> GetDicInfo([FromQuery] string typeID) + => await _role.GetDicInfoAsync(SchSerID(), typeID); + + /// 编辑文印(审核角色视角;旧 editDocPrint,form 三段 JSON → [FromBody])。 + [HttpPost("editDocPrint")] + public async Task EditDocPrint([FromBody] WYSHRoleEditDocPrintDto model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.EditDocPrintAsync(SchSerID(), UserId(), model); + } + + /// 审核流程回显(旧 getshenheFlow)。 + [HttpGet("getshenheFlow")] + public async Task GetshenheFlow([FromQuery] decimal docprintID, [FromQuery] int flowid) + => await _role.GetshenheFlowAsync(SchSerID(), docprintID, flowid); + + /// 文印表格数据(旧 getdocOftable)。 + [HttpGet("getdocOftable")] + public async Task GetDocOftable([FromQuery] decimal docPrintID) + => await _role.GetDocOftableAsync(SchSerID(), docPrintID); + + #endregion + + #region 审核角色列表与审批 + + /// 单条详情(旧 MyListtoOneSHRole)。 + [HttpGet("MyListtoOneSHRole")] + public async Task> MyListtoOneSHRole([FromQuery] int serID) + => await _role.MyListtoOneSHRoleAsync(serID, SchSerID()); + + /// 我的文印(旧 MyListSHRole)。 + [HttpGet("MyListSHRole")] + public async Task> MyListSHRole( + [FromQuery] string name = null, + [FromQuery] string status = null, + [FromQuery] string startDate = null, + [FromQuery] string endDate = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _role.MyListSHRoleAsync(name, status, startDate, endDate, pageSize, pageIndex, UserId(), SchSerID()); + + /// 全部文印(旧 totalListSHRole)。 + [HttpGet("totalListSHRole")] + public async Task> TotalListSHRole( + [FromQuery] int flowid = 0, + [FromQuery] int dwid = 0, + [FromQuery] string name = null, + [FromQuery] string stateid = null, + [FromQuery] string starttime = null, + [FromQuery] string endtime = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _role.TotalListSHRoleAsync(flowid, dwid, name, stateid, starttime, endtime, pageSize, pageIndex, SchSerID()); + + /// 抄送列表(旧 SendRdListSHRole)。 + [HttpGet("SendRdListSHRole")] + public async Task> SendRdListSHRole( + [FromQuery] int flowid = 0, + [FromQuery] int dwid = 0, + [FromQuery] string name = null, + [FromQuery] string status = null, + [FromQuery] string startDate = null, + [FromQuery] string endDate = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _role.SendRdListSHRoleAsync(flowid, dwid, name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex); + + /// 审批列表(旧 SPListSHRole)。 + [HttpGet("SPListSHRole")] + public async Task> SPListSHRole( + [FromQuery] int flowid = 0, + [FromQuery] int dwid = 0, + [FromQuery] string name = null, + [FromQuery] string status = null, + [FromQuery] string startDate = null, + [FromQuery] string endDate = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _role.SPListSHRoleAsync(flowid, dwid, name, status, startDate, endDate, UserId(), UserName(), SchSerID(), pageSize, pageIndex); + + /// 审核确认(旧 shenheokSHRole)。 + [HttpPost("shenheokSHRole")] + public async Task ShenheOkSHRole([FromBody] FlowTranctDto model) + { + if (model == null) throw new DException("参数错误!"); + model.actionuserid = UserContextHolder.Current?.Id ?? 0; + return await _role.ShenheOkSHRoleAsync(model); + } + + // 延后:ExceltotalListSHRole / WYRDExceldaochu / UpfileExcelSHRole / downMBOfSHRole / downUpfileSHRoleFK(Excel,NPOI) + // 延后:DwonWYSQD(审批单 PDF,PdfHelper + Server.MapPath) + + #endregion + + #region 签名 / 退回 / 金额重提 + + /// 保存签名(旧 SaveSignature,form: pic → [FromBody])。 + [HttpPost("SaveSignature")] + public async Task SaveSignature([FromBody] SaveSignatureDto model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.SaveSignatureAsync(SchSerID(), UserId(), model.pic); + } + + /// 获取签名(旧 GetSignature)。 + [HttpGet("GetSignature")] + public async Task GetSignature() => await _role.GetSignatureAsync(SchSerID(), UserId()); + + /// 删除签名(旧 DelSignature)。 + [HttpGet("DelSignature")] + public async Task DelSignature() => await _role.DelSignatureAsync(SchSerID(), UserId()); + + /// 退回(旧 TuiHui,form: DocID + cont → [FromBody])。 + [HttpPost("TuiHui")] + public async Task TuiHui([FromBody] TuiHuiDto model) + { + if (model == null) throw new DException("参数错误!"); + return await _role.TuiHuiAsync(UserId(), model.cont, model.DocID); + } + + /// 撤销退回(旧 unTuiHui)。 + [HttpGet("unTuiHui")] + public async Task UnTuiHui([FromQuery] decimal docID) + => await _role.UnTuiHuiAsync(UserId(), docID); + + /// 金额重提(旧 regetJE,form: DocID 逗号分隔 + type → [FromBody])。 + [HttpPost("regetJE")] + public async Task RegetJE([FromBody] RegetJEDto model) + { + if (model == null) throw new DException("参数错误!"); + var ids = (model.DocID ?? "").Split(',') + .Where(a => !string.IsNullOrEmpty(a) && decimal.TryParse(a, out _)) + .Select(decimal.Parse).ToList(); + return await _role.RegetJEAsync(SchSerID(), UserId(), ids, model.type); + } + + /// 重提记录(旧 getRegetJERd)。 + [HttpGet("getRegetJERd")] + public async Task> GetRegetJERd( + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _role.GetRegetJERdAsync(SchSerID(), pageSize, pageIndex); + + /// 记录下载次数(旧 setDownFileTimes)。 + [HttpGet("setDownFileTimes")] + public async Task SetDownFileTimes([FromQuery] int serID) + => await _role.SetDownFileTimesAsync(serID); + + #endregion + + private static string SchSerID() => UserContextHolder.Current.SchSerID; + + private static string UserId() => UserContextHolder.Current?.Id.ToString() ?? "0"; + + private static string UserName() => UserContextHolder.Current?.UserName ?? ""; + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/DocPrint/WY_listController.cs b/src/YhCore.WebApi/Controllers/Api/DocPrint/WY_listController.cs new file mode 100644 index 0000000..8d2b787 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/DocPrint/WY_listController.cs @@ -0,0 +1,335 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Yh.Platform.Abstractions; +using YhCore.Common; +using YhCore.Common.UserContext; +using YhCore.Entity; +using YhCore.Model.Dto; +using YhCore.Service.Interfaces; + +namespace YhCore.WebApi.Controllers.Api.DocPrint +{ + /// + /// 文印业务主控制器(对应旧 Areas/DocPrint/WY_listController,路由 api/docprint/wy_list)。 + /// 第一批:分类 / 版面 / 字典 14 接口;第三批 D1:文印 CRUD / 列表 / 审核 / 统计 / 指派 24 接口; + /// Excel 导出 4 接口延后批次迁移。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath + "/docprint/wy_list")] + public class WY_listController : ControllerBase + { + private readonly IDocPrintClassifyService _classify; + private readonly IDictionaryService _dict; + private readonly IWYDocPrintService _wy; + + public WY_listController(IDocPrintClassifyService classify, IDictionaryService dict, IWYDocPrintService wy) + { + _classify = classify; + _dict = dict; + _wy = wy; + } + + #region 分类 / 版面 / 字典 + + /// 添加文印分类。 + [HttpPost("AddDic")] + public async Task AddDic([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + model.Schserid = SchSerID(); + await _classify.AddDicAsync(model); + } + + /// 文印分类列表。 + [HttpGet("CategoryList")] + public async Task> CategoryList([FromQuery] string name = null) + => await _classify.CategoryListAsync(SchSerID(), name); + + /// 添加印制版面。 + [HttpPost("AddYZBM")] + public async Task AddYZBM([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + model.Schserid = SchSerID(); + await _classify.AddYZBMAsync(model); + } + + /// 印制版面列表。 + [HttpGet("YZBMList")] + public async Task> YZBMList([FromQuery] string name = null) + => await _classify.YZBMListAsync(SchSerID(), name); + + /// 删除文印分类/版面(ids 逗号分隔)。 + [HttpGet("DeleteDic")] + public async Task DeleteDic([FromQuery] string ids) + { + if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null"); + decimal[] idArr = ids.Split(',').Select(a => decimal.Parse(a)).ToArray(); + await _classify.DeleteDicAsync(idArr); + } + + /// 公共类别列表。 + [HttpGet("DictionaryTypeList")] + public async Task> DictionaryTypeList( + [FromQuery] string typeID, + [FromQuery] string name = "", + [FromQuery] bool showAll = false) + => await _classify.DictionaryTypeListAsync(SchSerID(), name, showAll, typeID); + + /// 添加公共类别(复用字典添加逻辑)。 + [HttpPost("AddDictionaryType")] + public async Task AddDictionaryType([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数错误!"); + model.Schserid = SchSerID(); + await _dict.AddDicAsync(model); + } + + /// 编辑公共类别(旧调 GetSysDict.EditDic)。 + [HttpPost("EditDictionaryType")] + public async Task EditDictionaryType([FromBody] Common_Dictionary model) + { + if (model == null) throw new DException("参数有误!"); + model.Schserid = SchSerID(); + await _dict.EditDicAsync(model); + } + + /// 删除公共类别(软删除)。 + [HttpGet("DeleteDictionaryType")] + public async Task DeleteDictionaryType([FromQuery] string ids) + { + if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null"); + string[] idArr = ids.Split(','); + await _classify.DeleteDictionaryTypeAsync(idArr); + } + + /// 公共类别显隐。 + [HttpGet("SetDictionaryOrd")] + public async Task SetDictionaryOrd([FromQuery] int dicSerID, [FromQuery] bool show) + => await _classify.SetDictionaryOrdAsync(SchSerID(), dicSerID, show); + + /// 公共类别排序。 + [HttpGet("OrderDic")] + public async Task OrderDic([FromQuery] string ids) + => await _classify.OrderDicAsync(ids, SchSerID()); + + /// 结果链接(依赖资源平台,已降级)。 + [HttpGet("GetResultUrl")] + public async Task GetResultUrl([FromQuery] int dataId) + { + var user = UserContextHolder.Current; + return await _classify.GetResultUrlAsync(dataId, user?.Id ?? 0, 0, SchSerID()); + } + + /// 学校文印资源开关。 + [HttpGet("GetSchZyBit")] + public async Task GetSchZyBit() => await _classify.GetSchZyBitAsync(SchSerID()); + + /// 设置学校文印资源开关。 + [HttpGet("SetSchZyBit")] + public async Task SetSchZyBit([FromQuery] bool zyBit) => await _classify.SetSchZyBitAsync(SchSerID(), zyBit); + + /// 待审数量角标。 + [HttpGet("GetShnheCount")] + public async Task GetShnheCount() + => await _classify.GetShnheCountAsync(UserContextHolder.Current?.Id.ToString(), SchSerID()); + + #endregion + + #region 文印核心(第三批 D1:抄送 / 附件 / CRUD / 列表 / 审核 / 统计 / 指派) + + /// 抄送列表(旧 SendRdList)。 + [HttpGet("SendRdList")] + public async Task> SendRdList( + [FromQuery] string name = null, + [FromQuery] string startDate = null, + [FromQuery] string endDate = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _wy.SendRdListAsync(name, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex); + + /// 查看抄送人员详情(旧 SendDetailsList)。 + [HttpGet("SendDetailsList")] + public async Task> SendDetailsList([FromQuery] int serID) + => await _wy.SendDetailsListAsync(serID); + + /// 修改抄送人(旧 EditSend)。 + [HttpGet("EditSend")] + public async Task EditSend([FromQuery] int serID, [FromQuery] string senduserid, [FromQuery] string mysenduserid) + => await _wy.EditSendAsync(serID, senduserid, mysenduserid, SchSerID(), UserId()); + + /// 获取附件数据源(旧 filelist)。 + [HttpGet("filelist")] + public async Task> FileList([FromQuery] string ids) + => await _wy.FileListAsync(ids); + + /// 添加文印(旧 Create;短信/待办推送已降级为纯落库)。 + [HttpPost("Create")] + public async Task Create([FromBody] WYDocPrintSaveDto model) + { + if (model == null) throw new DException("参数错误!"); + model.UserID = UserId(); + model.SchSerID = SchSerID(); + return await _wy.CreateAsync(model); + } + + /// 修改文印(旧 Edit)。 + [HttpPost("Edit")] + public async Task Edit([FromBody] WYDocPrintSaveDto model) + { + if (model == null) throw new DException("参数错误!"); + model.UserID = UserId(); + model.SchSerID = SchSerID(); + return await _wy.EditAsync(model); + } + + /// 撤销(旧 Revoke)。 + [HttpPost("Revoke")] + public async Task Revoke([FromBody] FlowTranctDto model) + { + if (model == null) throw new DException("参数错误!"); + model.actionuserid = UserContextHolder.Current?.Id; + return await _wy.RevokeAsync(model); + } + + /// 我的文印列表(旧 MyList)。 + [HttpGet("MyList")] + public async Task> MyList( + [FromQuery] string name = null, + [FromQuery] string status = null, + [FromQuery] string startDate = null, + [FromQuery] string endDate = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _wy.MyListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex); + + /// 全部文印列表(旧 AllList 四分类,返回 ctype)。 + [HttpGet("AllList")] + public async Task> AllList( + [FromQuery] string name = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _wy.AllListAsync(name, UserId(), SchSerID(), pageSize, pageIndex); + + /// 文印审核列表(旧 ShenheList;旧 Name 过滤为死代码,保持不生效)。 + [HttpGet("ShenheList")] + public async Task> ShenheList( + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _wy.ShenheListAsync(UserId(), SchSerID(), pageSize, pageIndex); + + /// 审批列表(旧 SPList)。 + [HttpGet("SPList")] + public async Task> SPList( + [FromQuery] string name = null, + [FromQuery] string status = null, + [FromQuery] string startDate = null, + [FromQuery] string endDate = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _wy.SPListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex); + + /// 文印已审列表(旧 YishenList)。 + [HttpGet("YishenList")] + public async Task> YishenList( + [FromQuery] string name = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _wy.YishenListAsync(name, UserId(), SchSerID(), pageSize, pageIndex); + + /// 全部文印列表(旧 totalList)。 + [HttpGet("totalList")] + public async Task> TotalList( + [FromQuery] string name = null, + [FromQuery] string stateid = null, + [FromQuery] string starttime = null, + [FromQuery] string endtime = null, + [FromQuery] int pageSize = 10, + [FromQuery] int pageIndex = 0) + => await _wy.TotalListAsync(name, stateid, starttime, endtime, SchSerID(), pageSize, pageIndex); + + /// 获取当前一条文印详情(旧 MyListtoOne;附件明细并入条目 tempresultfile)。 + [HttpGet("MyListtoOne")] + public async Task> MyListtoOne([FromQuery] int serID) + => await _wy.MyListtoOneAsync(serID, SchSerID()); + + /// 审核确认(旧 shenheok)。 + [HttpPost("shenheok")] + public async Task ShenheOk([FromBody] FlowTranctDto model) + { + if (model == null) throw new DException("参数错误!"); + model.actionuserid = UserContextHolder.Current?.Id ?? 0; + return await _wy.ShenheOkAsync(model); + } + + /// 领取确认(旧 AffirmApplyforOk)。 + [HttpPost("AffirmApplyforOk")] + public async Task AffirmApplyforOk([FromBody] FlowTranctDto model) + { + if (model == null) throw new DException("参数错误!"); + model.actionuserid = UserContextHolder.Current?.Id ?? 0; + return await _wy.AffirmApplyforOkAsync(model); + } + + /// 删除文印(旧 Delete)。 + [HttpGet("Delete")] + public async Task Delete([FromQuery] string ids) + { + if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null"); + await _wy.DeleteAsync(ids); + } + + /// 绑定流程(旧 Bindflowinfo)。 + [HttpGet("Bindflowinfo")] + public async Task> Bindflowinfo([FromQuery] int flowType) + => await _wy.BindflowinfoAsync(SchSerID(), flowType); + + /// 查询指派(旧 GetUserZhipai)。 + [HttpGet("GetUserZhipai")] + public async Task GetUserZhipai([FromQuery] int? flowid, [FromQuery] int? flowstepid, [FromQuery] string type) + => await _wy.GetUserZhipaiAsync(UserContextHolder.Current?.Id ?? 0, SchSerID(), flowid, flowstepid, type); + + /// 文印数量按月统计(旧 List1)。 + [HttpGet("List1")] + public async Task> List1([FromQuery] string starttime = null, [FromQuery] string endtime = null) + => await _wy.List1Async(starttime, endtime, SchSerID()); + + /// 文印详情统计列表(旧 List2)。 + [HttpGet("List2")] + public async Task> List2( + [FromQuery] string starttime = null, [FromQuery] string endtime = null, + [FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0) + => await _wy.List2Async(starttime, endtime, SchSerID(), pageSize, pageIndex); + + /// 文印数量按用户排名(旧 List3)。 + [HttpGet("List3")] + public async Task> List3( + [FromQuery] string starttime = null, [FromQuery] string endtime = null, + [FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0) + => await _wy.List3Async(starttime, endtime, SchSerID(), pageSize, pageIndex); + + /// 按月份统计明细(旧 List4)。 + [HttpGet("List4")] + public async Task> List4( + [FromQuery] int year, [FromQuery] int month, + [FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0) + => await _wy.List4Async(year, month, SchSerID(), pageSize, pageIndex); + + /// 按用户统计明细(旧 List5)。 + [HttpGet("List5")] + public async Task> List5( + [FromQuery] string userid = null, [FromQuery] string starttime = null, [FromQuery] string endtime = null, + [FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0) + => await _wy.List5Async(userid, starttime, endtime, SchSerID(), pageSize, pageIndex); + + // 延后批次:ExceltotalList / Exceldaochu1 / Exceldaochu2 / Exceldaochu3(Excel 导出,方案待定) + + #endregion + + private static string SchSerID() => UserContextHolder.Current.SchSerID; + + private static string UserId() => UserContextHolder.Current?.Id.ToString() ?? "0"; + } +} diff --git a/src/YhCore.WebApi/Controllers/Api/LoginUserController.cs b/src/YhCore.WebApi/Controllers/Api/LoginUserController.cs new file mode 100644 index 0000000..4ca95d5 --- /dev/null +++ b/src/YhCore.WebApi/Controllers/Api/LoginUserController.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Threading.Tasks; +using Yh.Platform.Abstractions; +using Yh.Platform.Auth; +using Yh.Platform.OpenApi; +using YhCore.Common; + +namespace YhCore.WebApi.Controllers.Api +{ + /// + /// 登录用户接口(对应 Java 版 controller/api/LoginUserController,路由 /api/GetUserMod 等)。 + /// 注意:Java 版路由以大写开头(GetUserMod / GetUserInfo / Logout),此处保持 URL 大小写一致。 + /// + [ApiController] + [Route(ApiRoutes.ApiPath)] + public class LoginUserController : ControllerBase + { + private readonly IOpenApiUserClient _openApiUserClient; + + public LoginUserController(IOpenApiUserClient openApiUserClient) + { + _openApiUserClient = openApiUserClient; + } + + /// + /// 获取当前用户可见的应用模块(对应 Java GET /api/GetUserMod, + /// 内部调用 YschBaseOpenApi.getUserModule(id, schoolId))。 + /// + [HttpGet("GetUserMod")] + public async Task> GetUserMod() + { + var boa = BoaUserContext.Get(); + return await _openApiUserClient.GetUserModuleAsync(boa?.Id ?? 0, boa?.School?.Id); + } + + /// + /// 获取当前登录用户信息(对应 Java GET /api/GetUserInfo, + /// Java 版直接返回认证切面注入的 authUserWithDept;未登录时为 null)。 + /// 返回新框架用户上下文解析的完整 BoaUser(含 School/CurrentRole/RoleIds),与 Java 返回结构一致。 + /// + [HttpGet("GetUserInfo")] + public BoaUser GetUserInfo() + { + return BoaUserContext.Get(); + } + + /// + /// 判断是否登录(对应 Java GET /api/checkLogin): + /// 已登录 → 重定向到运维工作台 /work-view.html(兼容 context-path,.NET 用相对路径); + /// 未登录 → 401 + {"code":401,"msg":"未登录"}(通常 CAS 拦截器会先行 302 到 CAS 登录页)。 + /// + [HttpGet("checkLogin")] + public IActionResult CheckLogin() + { + if (BoaUserContext.Get()?.Id > 0) + { + // 对应 Java request.getContextPath() + "/work-view.html"(.NET 无 contextPath,用根相对路径) + return Redirect("/work-view.html"); + } + + return new ContentResult + { + Content = "{\"code\":401,\"msg\":\"未登录\"}", + ContentType = "application/json;charset=UTF-8", + StatusCode = StatusCodes.Status401Unauthorized + }; + } + + /// + /// 登出(对应 Java GET /api/Logout:session.invalidate())。 + /// 清除本地 Cookie 认证(新框架 YhCookie 方案)+ 可选跳转到 CAS 单点登出(CasServerUrlPrefix + logout)。 + /// TODO:按需启用单点登出(去 yh:sso:cas-server-url-prefix + logout 退出所有 SSO 应用)。 + /// + [HttpGet("Logout")] + public async Task Logout() + { + // 清除本地 Cookie 登录态(对应 session.invalidate()) + await HttpContext.SignOutAsync(YhAuthDefaults.CookieScheme); + + // 仅在已启用 Session 中间件时再清一份 + if (HttpContext.Features.Get() != null) + { + HttpContext.Session.Clear(); + } + + return Ok(); + } + } +} diff --git a/src/YhCore.WebApi/Filters/GlobalWrapResultFilter.cs b/src/YhCore.WebApi/Filters/GlobalWrapResultFilter.cs new file mode 100644 index 0000000..5b753cf --- /dev/null +++ b/src/YhCore.WebApi/Filters/GlobalWrapResultFilter.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using System; +using System.Linq; +using System.Threading.Tasks; +using YhCore.Common.Result; + +namespace YhCore.WebApi.Filters +{ + /// + /// 排除统一包装特性:标注在 Controller 或 Action 上,该处返回值不参与全局包装。 + /// 适用:CAS 协议端点(XML/跳转)、文件下载、已手工构造响应体的接口。 + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class NoWrapResultAttribute : Attribute, IFilterMetadata + { + } + + /// + /// 全局统一响应包装过滤器:所有 Controller 的 ObjectResult 正常返回 + /// 自动包装为 ApiResult{code=0, msg="", data=...}。 + /// 规则: + /// - [NoWrapResult] 标注的 Controller/Action 跳过; + /// - 非 2xx 状态码(参数校验 400 等)不包装,保持错误结构 {code,msg,data,timeNow}; + /// - 已是 ApiResult<T> 的返回值不重复包装(防双层嵌套); + /// - ContentResult / Redirect / File 等非对象结果不包装(checkLogin 401、OAuth/CAS 协议端点等); + /// - null 返回值包装为 {"code":0,"msg":"","data":null}(原为 204)。 + /// + public class GlobalWrapResultFilter : IAsyncResultFilter + { + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + // 1) 显式排除 + if (context.Filters.Any(f => f is NoWrapResultAttribute)) + { + await next(); + return; + } + + // 2) 只包装对象结果 + if (context.Result is ObjectResult objectResult) + { + var statusCode = objectResult.StatusCode ?? StatusCodes.Status200OK; + var isErrorStatus = statusCode < 200 || statusCode >= 300; + + if (!isErrorStatus && !IsApiResult(objectResult.Value)) + { + context.Result = new ObjectResult(ApiResult.Ok(objectResult.Value)) + { + StatusCode = StatusCodes.Status200OK + }; + } + } + else if (context.Result is OkResult || context.Result is EmptyResult) + { + // Ok()(200 空 body)/ EmptyResult(204)→ 统一包装为 {"code":0,"msg":"","data":null} + context.Result = new ObjectResult(ApiResult.Ok()) + { + StatusCode = StatusCodes.Status200OK + }; + } + + await next(); + } + + /// 值是否已是 ApiResult<T>(防双层包装) + private static bool IsApiResult(object value) + { + if (value == null) + { + return false; + } + + var type = value.GetType(); + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ApiResult<>); + } + } +} diff --git a/src/YhCore.WebApi/Filters/SysOperationLogFilter.cs b/src/YhCore.WebApi/Filters/SysOperationLogFilter.cs new file mode 100644 index 0000000..ea5146f --- /dev/null +++ b/src/YhCore.WebApi/Filters/SysOperationLogFilter.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Newtonsoft.Json; +using Yh.Platform.Abstractions; +using YhCore.Common.UserContext; +using YhCore.Entity; +using YhCore.Repository; + +namespace YhCore.WebApi.Filters +{ + /// + /// 系统审计日志过滤器(对应 Java SysOperationLogAspect)。 + /// 全量拦截所有 Controller Action(不依赖标注),每次请求后异步写入 sys_operation_log 表。 + /// 与 Java 对齐的行为: + /// - 排除日志查询控制器自身(防递归刷屏); + /// - 成功路径记录响应 GZIP + 业务状态码(0 成功 / 非 0 失败); + /// - 异常路径记录错误码 + 异常堆栈摘要(截断 8000),异常继续向外传播; + /// - 落库失败不影响主流程(审计不阻断业务)。 + /// 注:请求/响应脱敏(Java SensitiveDataUtil)未实现,仅做长度截断,后续可接入。 + /// + public class SysOperationLogFilter : IAsyncActionFilter, IAsyncResultFilter + { + private const string AuditEntityKey = "__sys_operation_log_entity"; + private const string AuditStopwatchKey = "__sys_operation_log_stopwatch"; + + private const int MaxResponseSize = 10240; // 响应 JSON 截断长度(对齐 Java DEFAULT_MAX_RESPONSE_SIZE) + private const int MaxErrorMsg = 8000; // 异常信息截断长度(对齐 Java MAX_ERROR_MSG) + private const int MaxParamsSize = 4096; // 请求参数截断长度(Java 无此限制,此处防超大请求体) + + /// 排除的控制器(日志查询自身,防递归) + private static readonly string[] ExcludeControllers = + { + "YhCore.WebApi.Controllers.Api.LogController", + }; + + private static readonly string[] DeleteKeywords = { "delete", "del", "remove" }; + private static readonly string[] CreateKeywords = { "add", "create", "insert", "save", "import", "upload" }; + + private readonly IRepository _repo; + + public SysOperationLogFilter(IRepository repo) + { + _repo = repo; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (IsExcluded(context)) + { + await next(); + return; + } + + var entity = BuildEntity(context); + var stopwatch = Stopwatch.StartNew(); + context.HttpContext.Items[AuditEntityKey] = entity; + context.HttpContext.Items[AuditStopwatchKey] = stopwatch; + + var executed = await next(); + + stopwatch.Stop(); + entity.CostTime = stopwatch.ElapsedMilliseconds; + + if (executed.Exception != null) + { + // 异常路径:Result 过滤器不会执行,在此记录错误码与摘要后放行(异常继续传播,由 GlobalExceptionMiddleware 兜底) + entity.StatusCode = ResolveErrorCode(executed.Exception); + entity.ErrorMsg = BuildErrorMsg(executed.Exception); + await SaveQuietlyAsync(entity); + } + // 成功路径:StatusCode / ResponseData 由 OnResultExecutionAsync 填充并落库 + } + + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + if (!context.HttpContext.Items.TryGetValue(AuditEntityKey, out var raw) || raw is not SysOperationLog entity) + { + await next(); + return; + } + + if (context.HttpContext.Items.TryGetValue(AuditStopwatchKey, out var sw) && sw is Stopwatch stopwatch) + { + entity.CostTime = stopwatch.ElapsedMilliseconds; + } + + // 从结果对象提取业务状态码与响应体 + switch (context.Result) + { + case ObjectResult objectResult: + entity.StatusCode = ResolveStatusCode(objectResult.Value); + entity.ResponseData = BuildResponseData(objectResult.Value); + break; + case OkResult: + case EmptyResult: + case StatusCodeResult statusCodeResult when statusCodeResult.StatusCode >= 200 && statusCodeResult.StatusCode < 300: + entity.StatusCode = ErrorCode.Success.Code; + break; + default: + // 非对象结果(重定向/文件等)仅记 200 默认成功,不序列化响应体 + entity.StatusCode = ErrorCode.Success.Code; + break; + } + + await next(); + + await SaveQuietlyAsync(entity); + } + + // ==================== 构建 ==================== + + private static bool IsExcluded(ActionExecutingContext context) + { + if (context.ActionDescriptor is not ControllerActionDescriptor descriptor) + { + return true; + } + + var fullName = descriptor.ControllerTypeInfo.FullName; + return ExcludeControllers.Any(c => string.Equals(fullName, c, StringComparison.Ordinal)); + } + + private static SysOperationLog BuildEntity(ActionExecutingContext context) + { + var descriptor = (ControllerActionDescriptor)context.ActionDescriptor; + var http = context.HttpContext; + var request = http.Request; + var user = UserContextHolder.Current; + + var uri = request.Path.HasValue ? request.Path.Value : ""; + var httpMethod = request.Method ?? ""; + var actionName = descriptor.ActionName ?? ""; + + var (level, operationType) = ResolveLevelAndType(httpMethod, uri, actionName); + + return new SysOperationLog + { + CreateTime = DateTime.Now, + UserId = user?.Id ?? 0, + UserName = string.IsNullOrEmpty(user?.UserName) ? "anonymous" : user.UserName, + SchId = user?.SchoolId, + RequestIp = http.Connection.RemoteIpAddress?.ToString() ?? "", + RequestUri = uri, + RequestMethod = httpMethod, + ClassMethod = $"{descriptor.ControllerTypeInfo.FullName}.{actionName}", + LogLevel = level, + OperationType = operationType, + UserAgent = request.Headers["User-Agent"].ToString(), + RequestParams = BuildRequestParams(context), + StatusCode = ErrorCode.Success.Code + }; + } + + /// 请求参数序列化(过滤基础设施类型,对齐 Java buildRequestParams) + private static string BuildRequestParams(ActionExecutingContext context) + { + var args = context.ActionArguments.Values + .Where(v => v != null && !IsInfrastructureType(v)) + .ToList(); + + if (args.Count == 0) + { + return ""; + } + + try + { + var json = JsonConvert.SerializeObject(args); + return json.Length > MaxParamsSize + ? json.Substring(0, MaxParamsSize) + "...[截断]" + : json; + } + catch + { + return ""; + } + } + + private static bool IsInfrastructureType(object value) + { + var type = value.GetType(); + return typeof(IFormFile).IsAssignableFrom(type) + || type == typeof(CancellationToken) + || typeof(HttpRequest).IsAssignableFrom(type) + || typeof(HttpResponse).IsAssignableFrom(type); + } + + /// 响应 JSON → GZIP 压缩(对齐 Java fillResponse + GzipUtil.compress) + private static byte[] BuildResponseData(object value) + { + if (value == null) + { + return null; + } + + try + { + var json = JsonConvert.SerializeObject(value); + if (json.Length > MaxResponseSize) + { + json = json.Substring(0, MaxResponseSize) + "...[截断]"; + } + + var bytes = Encoding.UTF8.GetBytes(json); + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Fastest, true)) + { + gzip.Write(bytes, 0, bytes.Length); + } + return output.ToArray(); + } + catch + { + return null; + } + } + + /// 业务状态码:返回值带 int Code 属性则取之(ApiResult 等),否则默认 0 成功 + private static int ResolveStatusCode(object value) + { + if (value == null) + { + return ErrorCode.Success.Code; + } + + var codeProperty = value.GetType().GetProperty("Code", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (codeProperty != null && codeProperty.PropertyType == typeof(int)) + { + try + { + return (int)codeProperty.GetValue(value); + } + catch + { + // 忽略取值异常,回落默认成功 + } + } + + return ErrorCode.Success.Code; + } + + /// 异常错误码:DException 取业务码,其余取平台兜底码 6(对齐 Java resolveErrorCode) + private static int ResolveErrorCode(Exception exception) + { + if (exception is DException dex && dex.Code != null) + { + return dex.Code.Code; + } + + return ErrorCode.InternalServerError.Code; + } + + /// 异常信息摘要:message + 堆栈(截断 8000,对齐 Java buildErrorMsg) + private static string BuildErrorMsg(Exception exception) + { + var msg = exception.Message; + var head = string.IsNullOrWhiteSpace(msg) ? exception.GetType().Name : msg; + var text = head + Environment.NewLine + exception.StackTrace; + return text.Length > MaxErrorMsg ? text.Substring(0, MaxErrorMsg) : text; + } + + /// 日志级别/类型推断(复刻 Java OperationLogResolver):GET=查询、DELETE=删除、关键字兜底、其余更新 + private static (int level, string type) ResolveLevelAndType(string httpMethod, string uri, string actionName) + { + var method = string.IsNullOrEmpty(httpMethod) ? "" : httpMethod.ToUpperInvariant(); + if (method == "") + { + return (2, "UPDATE"); + } + + if (method == "GET") + { + return (3, "QUERY"); + } + + if (method == "DELETE") + { + return (1, "DELETE"); + } + + var text = (uri ?? "").ToLowerInvariant() + " " + (actionName ?? "").ToLowerInvariant(); + if (ContainsAny(text, DeleteKeywords)) + { + return (1, "DELETE"); + } + + if (ContainsAny(text, CreateKeywords)) + { + return (2, "CREATE"); + } + + return (2, "UPDATE"); + } + + private static bool ContainsAny(string text, IEnumerable keywords) + { + foreach (var keyword in keywords) + { + if (text.Contains(keyword, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// 异步落库,失败不影响主流程(对齐 Java 操作日志不阻断业务) + private async Task SaveQuietlyAsync(SysOperationLog entity) + { + try + { + await _repo.AddAsync(entity); + await _repo.SaveChangesAsync(); + } + catch + { + // 忽略:落库失败仅影响审计,不影响接口结果 + } + } + } +} diff --git a/src/YhCore.WebApi/Middleware/ApiAccessLogMiddleware.cs b/src/YhCore.WebApi/Middleware/ApiAccessLogMiddleware.cs new file mode 100644 index 0000000..4493a49 --- /dev/null +++ b/src/YhCore.WebApi/Middleware/ApiAccessLogMiddleware.cs @@ -0,0 +1,141 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using log4net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using YhCore.Common.UserContext; + +namespace YhCore.WebApi.Middleware +{ + /// + /// 接口访问日志中间件(唯一日志出口,方案 §6.2)。 + /// 记录:请求时间 / 渠道 / 路径与方法 / Query / Body(敏感字段脱敏)/ + /// 客户端 IP / 当前用户 / 响应状态码 / 耗时 / 异常摘要。 + /// 其余一切日志(SQL、框架、业务 Debug)均已关闭。 + /// + public class ApiAccessLogMiddleware + { + private static readonly ILog Log = LogManager.GetLogger(typeof(ApiAccessLogMiddleware)); + + /// 需要脱敏的入参 key(对应 Java SensitiveDataUtil) + private static readonly string[] SensitiveKeys = { "password", "pwd", "sign", "token", "secret" }; + + private readonly RequestDelegate _next; + private readonly ILogger _fallbackLogger; + + public ApiAccessLogMiddleware(RequestDelegate next, ILogger fallbackLogger) + { + _next = next; + _fallbackLogger = fallbackLogger; + } + + public async Task InvokeAsync(HttpContext context) + { + var sw = Stopwatch.StartNew(); + var request = context.Request; + + // 请求体读取(可重复读) + var body = await ReadBodyAsync(request); + + string exceptionSummary = null; + try + { + await _next(context); + } + catch (Exception ex) + { + exceptionSummary = ex.Message; + throw; // 异常继续向上,由 GlobalExceptionMiddleware 处理响应 + } + finally + { + sw.Stop(); + WriteLog(context, body, sw.ElapsedMilliseconds, exceptionSummary); + } + } + + private void WriteLog(HttpContext context, string body, long elapsedMs, string exceptionSummary) + { + var request = context.Request; + var user = UserContextHolder.Current; + + var sb = new StringBuilder(); + sb.Append("time=").Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")); + sb.Append(" | channel=").Append(ResolveChannel(request.Path.Value)); + sb.Append(" | method=").Append(request.Method); + sb.Append(" | path=").Append(request.Path.Value); + sb.Append(" | query=").Append(MaskSensitive(request.QueryString.Value)); + if (!string.IsNullOrEmpty(body)) + { + sb.Append(" | body=").Append(MaskSensitive(body)); + } + sb.Append(" | ip=").Append(context.Connection.RemoteIpAddress?.ToString() ?? "-"); + sb.Append(" | user=").Append(user?.Id.ToString() ?? "-"); + sb.Append(" | status=").Append(context.Response.StatusCode); + sb.Append(" | cost=").Append(elapsedMs).Append("ms"); + if (!string.IsNullOrEmpty(exceptionSummary)) + { + sb.Append(" | exception=").Append(exceptionSummary); + } + + Log.Info(sb.ToString()); + } + + /// 渠道解析:/api → api、/mobiapi → mobiapi、/oapi → oapi、其余 - + private static string ResolveChannel(string path) + { + if (string.IsNullOrEmpty(path)) return "-"; + if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) return "api"; + if (path.StartsWith("/mobiapi/", StringComparison.OrdinalIgnoreCase)) return "mobiapi"; + if (path.StartsWith("/oapi/", StringComparison.OrdinalIgnoreCase)) return "oapi"; + return "-"; + } + + /// 对查询串/请求体做敏感字段脱敏(key=value 中的 value 替换为 ***) + private static string MaskSensitive(string raw) + { + if (string.IsNullOrEmpty(raw)) return raw; + + var parts = raw.Split('&'); + for (var i = 0; i < parts.Length; i++) + { + var eq = parts[i].IndexOf('='); + if (eq <= 0) continue; + var key = parts[i].Substring(0, eq); + if (SensitiveKeys.Any(k => key.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + parts[i] = key + "=***"; + } + } + + return string.Join("&", parts); + } + + private static async Task ReadBodyAsync(HttpRequest request) + { + if (!request.Body.CanRead + || !request.ContentLength.HasValue + || request.ContentLength <= 0) + { + return null; + } + + request.EnableBuffering(); + try + { + using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + request.Body.Position = 0; // 复位,供后续中间件/模型绑定读取 + return body.Length > 2048 ? body.Substring(0, 2048) + "...(truncated)" : body; + } + catch + { + return null; + } + } + } +} diff --git a/src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs b/src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs new file mode 100644 index 0000000..4948a34 --- /dev/null +++ b/src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs @@ -0,0 +1,90 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Yh.Platform.AppAuth; +using Yh.Platform.Auth; + +namespace YhCore.WebApi.Middleware +{ + /// + /// 应用间签名校验中间件(对应 Java yh-app-auth-spring-boot-starter,仅对 /oapi 渠道生效)。 + /// 基于新框架 完成请求级签名校验(X-App-Key / X-Timestamp / X-Signature + /// 三件套 + 时间戳过期 + 防重放);Enabled=false(yh:app-auth)时跳过。 + /// 签名协议见 yh-platform-net contract/sign-protocol.md。 + /// + public class AppAuthMiddleware + { + private readonly RequestDelegate _next; + private readonly AppAuthVerifier _verifier; + private readonly AppAuthOptions _options; + + public AppAuthMiddleware(RequestDelegate next, AppAuthVerifier verifier, IOptions options) + { + _next = next; + _verifier = verifier; + _options = options.Value; + } + + public async Task InvokeAsync(HttpContext context) + { + if (_options.Enabled) + { + var path = context.Request.Path.Value ?? string.Empty; + if (path.StartsWith("/oapi", StringComparison.OrdinalIgnoreCase)) + { + var appKey = GetHeader(context, "X-App-Key"); + var timestamp = GetHeader(context, "X-Timestamp"); + var signature = GetHeader(context, "X-Signature"); + var nonce = GetHeader(context, "X-Nonce"); + + if (string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature)) + { + await RejectAsync(context, "缺少签名头(X-App-Key / X-Timestamp / X-Signature)"); + return; + } + + // canonical target:path + 排序 query(对齐 Java AppSignInterceptor,挂根路径无 basePath) + var canonicalTarget = SignCanonical.BuildTarget( + new Uri(context.Request.Scheme + "://" + context.Request.Host + context.Request.Path + context.Request.QueryString), + basePath: null); + + var ok = await _verifier.VerifyAsync( + appKey, timestamp, signature, + context.Request.Method, canonicalTarget, + string.IsNullOrEmpty(nonce) ? null : nonce, + context.RequestAborted); + + if (!ok) + { + await RejectAsync(context, "签名校验失败(签名错误 / 时间戳过期 / 重复请求)"); + return; + } + + // 验签通过:写入调用方应用身份(对齐 LoginUser.AppKey,业务可经 UserContextHolder 读取) + UserContextHolder.Set(new LoginUser { AppKey = appKey }); + } + } + + await _next(context); + } + + private static async Task RejectAsync(HttpContext context, string message) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsync(JsonSerializer.Serialize(new + { + code = 4, + msg = message, + data = (object)null, + })); + } + + private static string GetHeader(HttpContext context, string name) + { + return context.Request.Headers.TryGetValue(name, out var value) ? value.ToString() : null; + } + } +} diff --git a/src/YhCore.WebApi/Middleware/GlobalExceptionMiddleware.cs b/src/YhCore.WebApi/Middleware/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..0ae34f6 --- /dev/null +++ b/src/YhCore.WebApi/Middleware/GlobalExceptionMiddleware.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using Yh.Platform.Abstractions; + +namespace YhCore.WebApi.Middleware +{ + /// + /// 全局异常中间件(对应 Java @ControllerAdvice)。 + /// 捕获未处理异常 → 返回统一错误响应 {code, msg, data, timeNow}(对齐 bschclassmanage ValidationExceptionHandler + /// 与平台 GlobalExceptionHandler);异常摘要由接口访问日志(ApiAccessLogMiddleware)统一记录,本中间件不单独输出日志(方案 §6.2 / §6.4)。 + /// 规则(对齐 Java): + /// DException(业务异常,含 /oapi 业务失败信封)→ HTTP 200 + {code, msg, data, timeNow} + /// JSON 解析失败 / 请求体异常 → HTTP 400 + {code:1, msg:"请求体(JSON)解析失败: ...", data:null, timeNow} + /// 未捕获异常(平台兜底) → HTTP 200 + {code:6(INTERNAL_SERVER_ERROR), msg:"内部服务错误", data:null, timeNow} + /// + public class GlobalExceptionMiddleware + { + private readonly RequestDelegate _next; + + public GlobalExceptionMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (DException dex) + { + // 业务异常(含 /oapi 信封失败)→ 200 + {code, msg, data, timeNow}(code 原样透传,对齐 Java handleDException) + await WriteJsonAsync(context, StatusCodes.Status200OK, new + { + code = dex.Code?.Code ?? ErrorCode.Error.Code, + msg = dex.Message, + data = dex.EnvelopeData, + timeNow = TimeNow() + }); + } + catch (BadHttpRequestException ex) + { + // @RequestBody 反序列化失败(JSON 格式错 / 字段类型不符)→ 400 + 真实原因透出(对齐 Java handleNotReadable) + await WriteJsonAsync(context, StatusCodes.Status400BadRequest, new + { + code = ErrorCode.Error.Code, + msg = $"请求体(JSON)解析失败: {ex.Message}", + data = (object)null, + timeNow = TimeNow() + }); + } + catch (Exception) + { + // 未捕获异常 → 平台兜底:200 + code:6 内部服务错误(对齐 Java GlobalExceptionHandler.handleException) + await WriteJsonAsync(context, StatusCodes.Status200OK, new + { + code = ErrorCode.InternalServerError.Code, + msg = ErrorCode.InternalServerError.Msg, + data = (object)null, + timeNow = TimeNow() + }); + } + } + + private static async Task WriteJsonAsync(HttpContext context, int statusCode, object body) + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsync(JsonConvert.SerializeObject(body)); + } + + /// 时间戳(对齐 Java timeNow 格式 yyyy-MM-dd HH:mm:ss) + private static string TimeNow() => DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + } +} diff --git a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs new file mode 100644 index 0000000..a895175 --- /dev/null +++ b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs @@ -0,0 +1,128 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Yh.Platform.Auth; + +namespace YhCore.WebApi.Middleware +{ + /// + /// SSO 路径保护中间件(对应 Java yh-auth-spring-boot-starter 的 /api/* 保护行为)。 + /// 规则(对齐 yh:sso 配置节): + /// - yh:sso:enabled=false(默认):所有路径匿名放行,不影响开发联调; + /// - yh:sso:enabled=true:匹配 /api/* / /mobiapi/* / 根路径(/, 浏览器 SSO 入口) 且未认证的请求 + /// → Challenge → Yh.Platform.Auth CasAuthenticationHandler 302 重定向到 CAS 登录页; + /// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /signin-cas)未认证时同样 Challenge: + /// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页, + /// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现); + /// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302; + /// - Swagger 路径始终放行,方便本地接口调试; + /// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp / + /// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配 + /// /oapi/* 也不拦截),保证应用间调用不受浏览器登录流程影响。 + /// 调用方:Startup.Configure 中 UseAuthentication + UseYhUserContext 之后、UseAuthorization 之前。 + /// + public class SsoProtectionMiddleware + { + private readonly RequestDelegate _next; + private readonly IConfiguration _configuration; + + public SsoProtectionMiddleware(RequestDelegate next, IConfiguration configuration) + { + _next = next; + _configuration = configuration; + } + + public async Task InvokeAsync(HttpContext context) + { + var sso = _configuration.GetSection("yh:sso"); + var isSwaggerPath = context.Request.Path.StartsWithSegments("/swagger"); + var isOapiPath = context.Request.Path.StartsWithSegments("/oapi"); + var isRootEntry = context.Request.Path == "/"; + var isCasCallback = context.Request.Path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas"); + + // 已认证(含 Cookie 恢复登录态)直接放行 + if (context.User?.Identity?.IsAuthenticated == true) + { + // 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location), + // 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC 端点匹配,/signin-cas 无对应 action, + // 会返回 404 覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。 + if (isCasCallback && context.Response.StatusCode == 302 && + !string.IsNullOrEmpty(context.Response.Headers["Location"])) + { + return; + } + await _next(context); + return; + } + + // oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头), + // 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。 + if (isOapiPath) + { + await _next(context); + return; + } + + var enabled = sso["enabled"] != null + ? bool.TryParse(sso["enabled"], out var en) && en + : _configuration.GetValue("Yh:Sso:Enabled", false); + + if (enabled && !isSwaggerPath && + (isRootEntry || isCasCallback || IsProtectedPath(context.Request.Path.Value, sso))) + { + // 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期, + // 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发 + // 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。 + if (context.Request.Query.ContainsKey("ticket")) + { + context.Response.StatusCode = 401; + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync( + "登录失效" + + "" + + "

登录票据校验失败或已过期

" + + "

CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。

" + + "

重新登录

"); + return; + } + // 未认证 → Challenge → 302 重定向 CAS 登录页(ticket 校验与回跳由新框架 CasAuthenticationHandler 处理) + await context.ChallengeAsync(YhAuthDefaults.CasScheme); + return; + } + + await _next(context); + } + + /// 路径是否命中保护模式(yh:sso:urlPattern / yh:sso:mobiApiPattern,默认 /api/*、/mobiapi/*,通配后缀匹配,忽略大小写) + private static bool IsProtectedPath(string path, IConfigurationSection sso) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + var urlPattern = sso["urlPattern"] ?? "/api/*"; + var mobiPattern = sso["mobiApiPattern"] ?? "/mobiapi/*"; + return MatchesPattern(path, urlPattern) || MatchesPattern(path, mobiPattern); + } + + private static bool MatchesPattern(string path, string pattern) + { + if (string.IsNullOrEmpty(pattern) || string.IsNullOrEmpty(path)) + { + return false; + } + + // 形如 /api/* → 前缀匹配;否则精确匹配(忽略大小写) + if (pattern.EndsWith("/*", StringComparison.Ordinal)) + { + var prefix = pattern.Substring(0, pattern.Length - 1); // 去掉 * + return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + return path.Equals(pattern, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/YhCore.WebApi/Program.cs b/src/YhCore.WebApi/Program.cs new file mode 100644 index 0000000..af0a83c --- /dev/null +++ b/src/YhCore.WebApi/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace YhCore.WebApi +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/YhCore.WebApi/Properties/launchSettings.json b/src/YhCore.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..b14e551 --- /dev/null +++ b/src/YhCore.WebApi/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "profiles": { + "YhCore.WebApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:51835" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + }, + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:58057/", + "sslPort": 44399 + } + } +} \ No newline at end of file diff --git a/src/YhCore.WebApi/Startup.cs b/src/YhCore.WebApi/Startup.cs new file mode 100644 index 0000000..6b7f9b5 --- /dev/null +++ b/src/YhCore.WebApi/Startup.cs @@ -0,0 +1,367 @@ +using System; +using System.IO; +using System.Linq; +using FluentValidation.AspNetCore; +using log4net.Config; +using log4net; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; +using Newtonsoft.Json.Serialization; +using YhCore.Common.Cache; +using YhCore.Repository; +using YhCore.Repository.Extensions; +using YhCore.Service.Impl; +using YhCore.Service.Interfaces; +using YhCore.Tasks; +using YhCore.WebApi.Filters; +using YhCore.WebApi.Middleware; + +using YhCore.Common.Config; +using YhCore.Common.Exceptions; +using YhCore.Common.UserContext; +using Yh.Platform.Abstractions; +using Yh.SchClass.OpenApi; +using Yh.Platform.Auth; + +namespace YhCore.WebApi +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + + // log4net 初始化(唯一日志出口:接口访问日志) + var log4netConfig = Path.Combine(AppContext.BaseDirectory, "log4net.config"); + if (File.Exists(log4netConfig)) + { + XmlConfigurator.Configure(new FileInfo(log4netConfig)); + } + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(options => + { + // 系统审计日志:全量拦截 Controller(对齐 Java SysOperationLogAspect,内部排除日志查询控制器) + options.Filters.Add(new TypeFilterAttribute(typeof(SysOperationLogFilter))); + // 全局统一响应包装:Controller 正常返回自动包 ApiResult{code,msg,data} + options.Filters.Add(); + }) + .ConfigureApiBehaviorOptions(options => + { + // 模型校验失败 → 400 + {code, msg, data, timeNow}(对齐 Java ValidationExceptionHandler 参数校验返回) + options.InvalidModelStateResponseFactory = context => + { + var msgs = context.ModelState.Values + .SelectMany(v => v.Errors) + .Select(e => e.ErrorMessage) + .Where(m => !string.IsNullOrEmpty(m)) + .ToList(); + return new BadRequestObjectResult(new + { + code = ErrorCode.Error.Code, + msg = string.Join(";", msgs), + data = (object)null, + timeNow = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + }); + }; + }) + // 对齐 Java Jackson:日期格式 yyyy-MM-dd HH:mm:ss + 字段 camelCase(code/msg/data) + .AddNewtonsoftJson(o => + { + o.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss"; + o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + }) + // 对应 Java Hibernate Validator + .AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining()); + + // 对象映射:对应 Java ModelMapper + services.AddAutoMapper(typeof(Startup)); + + // 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true"); + // AllowAnyOrigin 与 AllowCredentials 不能同时生效,接入 CAS 后按 yh.sso.allow-origins 收紧 + services.AddCors(options => options.AddPolicy("default", builder => builder + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader())); + + // 数据层:默认 SQL Server 主库,DatabaseProvider=MySql 可切换 + services.AddDataLayer(Configuration); + + // 仓储与 Dapper 通道(对应 Java Repository 自动装配) + services.AddRepositories(); + + // 系统审计日志过滤器(全量拦截,TypeFilterAttribute 从 DI 解析依赖) + services.AddScoped(); + + // Redis 缓存(RedisUtil 单例,连接串取 Redis:ConnectionString) + services.AddRedisCache(Configuration); + + // 框架期占位实现;业务实现就绪后在 Extensions 中统一替换注册 + services.AddScoped(); + + // ===== DocPrint 业务服务(对应旧 BestSchool.Service / BestSchool.DocPrint) ===== + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // ===== 基础平台(yh-platform-net 新框架,对应 Java yh-platform) ===== + // 聚合注册:/oapi 客户端(Yh:Open)+ 应用签名鉴权(Yh:AppAuth)+ Cookie/CAS 认证(Yh:Sso/Yh:Auth)。 + // AddYhAuth 内部含 AddYhOpenApi;AddYhPlatform = AddYhAppAuth + AddYhAuth。 + // 静态门面 YschBaseOpenApi 由宿主启动时自动装配(YschFacadeWiring: IHostedService)。 + services.AddYhPlatform(Configuration); + + // 教务(SchClass)开放接口客户端 SDK(Yh.SchClass.OpenApi.dll,配置节 yh:schclass:open) + services.AddSchClassOpenApi(Configuration); + + // 统一配置管理(YhCore.Common.Config):数据库 / Redis / Swagger / 文件路径 + services.AddOptions() + .Bind(Configuration); + services.AddOptions() + .Bind(Configuration.GetSection("Redis")); + services.AddOptions() + .Bind(Configuration.GetSection("Swagger")); + services.AddOptions() + .Bind(Configuration); + // 静态门面(对齐 Java Config):文件路径由配置注入 + YhCore.Common.Config.Config.FilePath = Configuration["FILE_PATH"]; + YhCore.Common.Config.Config.FileWebPath = Configuration["FILE_WEB_PATH"]; + + // 接口文档:对应 Java SpringDoc,按渠道分三组(api / mobiapi / oapi) + services.AddSwaggerGen(c => + { + c.SwaggerDoc("api", new OpenApiInfo { Title = "YhCore API", Version = "v1" }); + c.SwaggerDoc("mobiapi", new OpenApiInfo { Title = "YhCore MobiApi", Version = "v1" }); + c.SwaggerDoc("oapi", new OpenApiInfo { Title = "YhCore OApi", Version = "v1" }); + c.AddSecurityDefinition("Token", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Description = "登录后携带的令牌(对接 CAS SSO 后使用)" + }); + }); + + // 任务调度:Quartz 宿主 + Job 模板(对应 Java scheduler 包) + services.AddQuartzHosted(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILogger logger) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + // 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true") + app.UseCors("default"); + + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/api/swagger.json", "API"); + c.SwaggerEndpoint("/swagger/mobiapi/swagger.json", "MobiApi"); + c.SwaggerEndpoint("/swagger/oapi/swagger.json", "OApi"); + }); + + // 管道顺序:先路由,再认证;CAS 回调必须在认证阶段消费 ticket 并写入 Cookie。 + app.UseRouting(); + + app.UseMiddleware(); // 0. 接口访问日志(唯一日志出口) + app.UseMiddleware(); // 1. 异常兜底(异常摘要并入接口日志) + app.UseAuthentication(); // 2. CAS ticket 校验 + Cookie 恢复(Yh.Platform.Auth) + app.UseMiddleware(); // 3. SSO 路径保护(未认证 → 302 CAS;认证通过后才有用户身份) + app.UseYhUserContext(); // 4. 新框架用户上下文:BoaUserContext + UserContextHolder(LoginUser) + // 5. 应用间签名(oapi 渠道)。必须排在 UseYhUserContext 之后: + // UserContextMiddleware 的 finally 会无条件 Clear UserContextHolder, + // 若 AppAuth 在其之前写入 LoginUser{AppKey} 会被清空,业务层读不到应用身份。 + app.UseMiddleware(); + app.Use(LegacyUserContextAdapter); // 6. 兼容旧 YhCore.Common.UserContextHolder(业务依赖 UserContext) + app.UseAuthorization(); + + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + + // 根路径登录状态页:SSO 开启时未登录会先被中间件 302 到 CAS;登录后在此显示当前用户。 + endpoints.MapGet("/", async context => + { + var user = context.User; + if (user?.Identity?.IsAuthenticated != true) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("未登录(SSO 未开启或 Cookie 失效)"); + return; + } + + var name = user.Identity?.Name ?? "-"; + var id = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "-"; + var school = user.FindFirst("schoolname")?.Value ?? "-"; + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync( + "" + + "

YhCore SSO 登录成功

" + + $"

用户:{System.Net.WebUtility.HtmlEncode(name)}

" + + $"

用户Id:{System.Net.WebUtility.HtmlEncode(id)}

" + + $"

学校:{System.Net.WebUtility.HtmlEncode(school)}

" + + "

打开接口文档 | 退出登录

" + + ""); + }); + }); + + // 数据库自动补表:实体模型里有而数据库没有的表,启动时自动创建。 + // 开关 Database:AutoCreateMissingTables(默认 true);失败只记录不影响启动。 + // 放在 Configure 末尾而非 Main:此处 Web 宿主配置/DI 已完整就绪,DbContext 连接串有效。 + if (Configuration.GetValue("Database:AutoCreateMissingTables", true)) + { + using (var scope = app.ApplicationServices.CreateScope()) + { + try + { + var created = DatabaseInitializer.EnsureMissingTables( + scope.ServiceProvider.GetRequiredService()); + if (created.Count > 0) + { + Console.WriteLine( + "[DatabaseInitializer] auto-created missing table(s): " + + string.Join(", ", created)); + logger.LogInformation( + "[DatabaseInitializer] auto-created missing table(s): {Tables}", + string.Join(", ", created)); + } + } + catch (Exception ex) + { + Console.WriteLine("[DatabaseInitializer] ERROR: " + ex); + logger.LogError(ex, + "[DatabaseInitializer] auto-create skipped due to error (app continues)"); + } + } + } + } + + /// + /// 兼容适配:把新框架 的当前用户转写为旧 + /// UserContextHolder(YhCore.Common.UserContext),保证 LogController / + /// SysOperationLogFilter / ApiAccessLogMiddleware 等既有业务代码零改动继续工作。 + /// SchSerID(学校 UUID)通过教务 SDK SchInfo.GetSchFirstAsync 反查填充,进程内缓存避免每请求调用。 + /// + private static async System.Threading.Tasks.Task LegacyUserContextAdapter(HttpContext context, Func next) + { + var boa = BoaUserContext.Get(); + if (boa != null) + { + var userContext = new YhCore.Common.UserContext.UserContext + { + Id = boa.Id ?? 0, + UserName = boa.Name, + Account = boa.Account, + SchoolId = boa.School?.Id, + SchoolName = boa.School?.Name, + UserType = boa.Type, + Role = boa.RoleIds?.FirstOrDefault() ?? boa.CurrentRole?.RoleId, + FullBoaUser = boa, + }; + + if ((userContext.SchoolId ?? 0) > 0) + { + userContext.SchSerID = await ResolveSchSerIDAsync(context, userContext.SchoolId.Value); + } + + YhCore.Common.UserContext.UserContextHolder.Current = userContext; + } + + try + { + await next(); + } + finally + { + // 请求结束清理,避免 AsyncLocal 泄漏到连接池复用线程 + YhCore.Common.UserContext.UserContextHolder.Current = null; + } + } + + /// 学校 UUID 反查缓存(SchoolId → (值, 缓存时间戳))。成功永久缓存;失败负缓存 60s 后重试。 + private static readonly System.Collections.Concurrent.ConcurrentDictionary SchSerIdCache = + new System.Collections.Concurrent.ConcurrentDictionary(); + + /// 失败负缓存有效期(毫秒):教务服务不可达时避免每请求触发,又能在服务恢复后自动重试。 + private const long SchSerIdNegativeCacheMs = 60_000; + + /// + /// 通过教务 SDK 反查学校 UUID(SchInfoVo.SchSerId);失败或未命中返回 null。 + /// 注意:SDK HttpClient 配置 ConnectTimeout=1500ms,教务地址不可达时(如本地联调 BaseHost=localhost:8086 + /// 未起服务)会抛 TaskCanceledException——属预期路径,捕获后走负缓存,不阻断请求。 + /// + private static async System.Threading.Tasks.Task ResolveSchSerIDAsync(HttpContext context, int schoolId) + { + var now = System.DateTime.UtcNow.Ticks; + if (SchSerIdCache.TryGetValue(schoolId, out var cached)) + { + if (!string.IsNullOrEmpty(cached.Value)) + return cached.Value; + // 负缓存:有效期内不再重试;过期后放行重新反查(教务服务可能已恢复) + if (now - cached.Ticks < SchSerIdNegativeCacheMs * System.TimeSpan.TicksPerMillisecond) + return null; + } + + string result = null; + try + { + var sch = context.RequestServices.GetService(typeof(Yh.SchClass.OpenApi.SchClassOpenApi)) + as Yh.SchClass.OpenApi.SchClassOpenApi; + if (sch != null) + { + var info = await sch.SchInfo.GetUserSchFirstAsync(schoolId, System.Threading.CancellationToken.None); + result = info?.SchSerId; + } + } + catch (System.OperationCanceledException) + { + // 连接超时(ConnectTimeout)/ 请求取消:教务不可达,走负缓存,SchSerID 保持 null + LogSchSerIdResolve(context, schoolId, "connect timeout"); + } + catch (System.Exception ex) + { + LogSchSerIdResolve(context, schoolId, ex.GetType().Name + ": " + ex.Message); + } + + SchSerIdCache[schoolId] = (result ?? "", now); + return result; + } + + /// 反查失败诊断日志(Debug 级,避免干扰正常请求日志)。 + private static void LogSchSerIdResolve(HttpContext context, int schoolId, string reason) + { + try + { + var logger = context.RequestServices.GetService(typeof(Microsoft.Extensions.Logging.ILoggerFactory)) + as Microsoft.Extensions.Logging.ILoggerFactory; + logger?.CreateLogger("YhCore.WebApi.Startup").LogDebug( + "Resolve SchSerID for schoolId={SchoolId} failed: {Reason}", schoolId, reason); + } + catch + { + // 日志失败不影响请求 + } + } + } +} diff --git a/src/YhCore.WebApi/YhCore.WebApi.csproj b/src/YhCore.WebApi/YhCore.WebApi.csproj new file mode 100644 index 0000000..aa2ed62 --- /dev/null +++ b/src/YhCore.WebApi/YhCore.WebApi.csproj @@ -0,0 +1,52 @@ + + + + net5.0 + YhCore.WebApi + YhCore.WebApi + 9.0 + disable + + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.Platform.Abstractions\bin\Debug\net5.0\Yh.Platform.Abstractions.dll + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.Platform.AppAuth\bin\Debug\net5.0\Yh.Platform.AppAuth.dll + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.Platform.Auth\bin\Debug\net5.0\Yh.Platform.Auth.dll + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.Platform.OpenApi\bin\Debug\net5.0\Yh.Platform.OpenApi.dll + + + ..\..\..\yunhai.netcore.tool\BasePlatformDotnet\src\Yh.SchClass.OpenApi\bin\Debug\net5.0\Yh.SchClass.OpenApi.dll + + + + diff --git a/src/YhCore.WebApi/appsettings.json b/src/YhCore.WebApi/appsettings.json new file mode 100644 index 0000000..2ca35e4 --- /dev/null +++ b/src/YhCore.WebApi/appsettings.json @@ -0,0 +1,52 @@ +{ + "DatabaseProvider": "SqlServer", + "Database": { + "AutoCreateMissingTables": true + }, + "ConnectionStrings": { + "SqlServer": "Server=localhost;Database=EduCloudDocPrint;User Id=sa;Password=Password01!;MultipleActiveResultSets=true", + "Mysql": "Server=localhost;Port=3306;Database=EduCloudDocPrint;User Id=root;Password=123123;CharSet=utf8mb4" + }, + "Redis": { + "ConnectionString": "localhost:6379,defaultDatabase=0" + }, + "yh": { + "auth": { + "Enabled": true, + "CookieExpireMinutes": 1, + "cookieName": ".yh.auth" + }, + "sso": { + "Enabled": true, + "CasServerUrlPrefix": "http://sso.yunhaixingtong.com/cas", + "AppBaseUrl": "http://localhost:51835", + "SignInCallbackPath": "/signin-cas", + "TicketValidateTimeoutMs": 10000, + "UrlPattern": "/api/*", + "MobiApiPattern": "/mobiapi/*" + }, + "app-auth": { + "Enabled": true, + "AppKey": "100002", + "AppSecret": "2314a3b158b24a838b2b9b69fd3db118", + "ExpireMs": 300000 + }, + "open": { + "Enabled": true, + "BaseHost": "http://base.yunhaixingtong.com", + "BasePath": "/userbase", + "SchClassPath": "/bschclass", + "AppKey": "100002", + "AppSecret": "2314a3b158b24a838b2b9b69fd3db118" + } + }, + "Logging": { + "LogLevel": { + "Default": "None", + "Microsoft": "None", + "Microsoft.Hosting.Lifetime": "None", + "Microsoft.AspNetCore.Authentication": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/YhCore.WebApi/log4net.config b/src/YhCore.WebApi/log4net.config new file mode 100644 index 0000000..0f2cc19 --- /dev/null +++ b/src/YhCore.WebApi/log4net.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/yh-platform-dotnet建设方案.md b/yh-platform-dotnet建设方案.md deleted file mode 100644 index ba2784b..0000000 --- a/yh-platform-dotnet建设方案.md +++ /dev/null @@ -1,370 +0,0 @@ -# yh-platform-dotnet 建设方案 v3 —— 从空文件夹到可发布 NuGet - -> 版本:v3.0 | 日期:2026-08-28 | 状态:方案评审 -> 定位:**平台侧工件建设** —— 建设与 Java `yh-platform` 同构的 .NET SDK(`yh-platform-net`),使平台基础能力(用户信息、/oapi 开放接口、应用签名、CAS SSO)具备跨语言辐射能力 -> v2 增量:基于 GrowthHealth 既有 .NET 代码实证,给出可直接从空文件夹落地的工程实施方案(框架选型、仓库脚手架、命令序列、分阶段实施) -> v3 增量:把 v2 的多阶段门禁简化为**做完后一次性自检 + 功能逻辑审计核对**——建完后照单跑一遍,有问题修,没问题即收 - -*** - -## 一、消费方现状实证(决定 SDK 设计约束) - -### 1.1 GrowthHealth 技术栈考古(2026-08-28 代码核查) - -| 项 | 实证 | 对 SDK 设计的约束 | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| 目标框架 | **net5.0**(五项目:Bsch.Web/Entity/Common/Repository/Service,[Bsch.Web.csproj:4](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Web/Bsch.Web.csproj)) | SDK 最低必须可被 net5.0 消费 | -| JSON | **Newtonsoft.Json 13.0.1** + `AddNewtonsoftJson`(非 System.Text.Json) | 模型须 Newtonsoft 可反序列化(纯 POCO 即可,Newtonsoft 属性匹配大小写不敏感) | -| ORM | EF Core 5.0.9 + SqlServer + Dapper(本地 dll) | 与 SDK 无关,仅确认不冲突 | -| 旧平台 SDK | **BschOpenApi.dll**(本地 Library 引用,无 NuGet):`new BaseOpenApi(host, appKey, appSecret)`,命名空间 `BschBaseOpenApi`,类型 `BoaUser` / `CurrentRole`([ControllerAbstract.cs:18](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Web/Controllers/ControllerAbstract.cs)、[SchClassService.cs:22](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Service/SchClassService.cs)) | **yh-platform-net = BschOpenApi 的重生**。需提供同构构造函数形态 `(host, appKey, appSecret)` 降低迁移摩擦;`BoaUser` 类型名保留 | -| CAS 认证 | 本地 dll `AspNetCore.Security.CAS.dll` + Cookie scheme(`AddCasSso()`,[ServiceCollectionExtensions.cs:158-184](file:///d:/SoftProject/yunhai/GrowthHealth/service/Bsch.Web/Code/Extensions/ServiceCollectionExtensions.cs)) | Auth 包需内置等价 CAS handler,摆脱本地 dll | -| 配置 | 静态 `SiteConfig.AppSetting`(appsettings.json `AppTicket.AppKey=100476`、`UserCenterHost`) | SDK 用 Options 模式,同时提供静态兼容入口 | -| Redis | `Microsoft.Extensions.Caching.Redis 2.2.0`(旧 IDistributedCache 风格) | SDK 自带 StackExchange.Redis,不依赖消费方缓存栈 | -| 部署 | Docker `sdk:5.0`/`aspnet:5.0` | SDK 发布无运行时假定 | - -### 1.2 核心判断(承 v1) - -* yh-platform 的本质是"HTTP 契约 + 可移植协议 + 客户端封装",服务本体是 UserBase/CasSso/Redis,C# 侧做**同构 NuGet SDK**即可,无需 Java sidecar。 - -* **原则:不要让 .NET 去"依赖 Java",而是让两个 SDK 依赖同一份契约。** - -* v2 新增判断:**BschOpenApi.dll 已证明".NET 版平台 SDK"这条路的可行性**(GrowthHealth 全面使用其形态);yh-platform-net 不是发明新事物,而是把它升级为:有源码、有版本、有 CI、对齐新 UserBase /oapi 契约(含 BoaRoleInfo 多角色体系)的正规工件。 - -### 1.3 建设目标 - -1. 产出 `yh-platform-net` 独立仓库与 NuGet 包族,能力与 `yh-platform` 同构,且 **net5.0 旧栈可直接消费**; -2. 产出跨语言契约工件(/oapi OpenAPI spec、签名协议规范、共享测试向量),确立 UserBase 为契约权威源; -3. 建立双 SDK 版本治理机制(集中版本管理 + 兼容矩阵 + 契约测试),防双端漂移。 - -## 二、目标框架与兼容策略(v2 新增,先拍板) - -| 决策点 | 结论 | 理由 | -| -------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| SDK 目标框架 | **多目标** **`net5.0; net8.0`**(`netstandard2.0` 不选:需要 `IHttpClientFactory`/`AsyncLocal`/中间件等运行时能力,且 net5 消费方天然兼容) | net5.0 覆盖 GrowthHealth 现状;net8.0(LTS)覆盖新项目与未来升级 | -| 构建工具链 | .NET SDK **8.0**(可构建 net5.0 目标,targeting pack 经 NuGet 自动还原) | 单一工具链构建双目标 | -| 测试运行 | 单测跑 **net8.0**;net5.0 仅做编译 + 契约 fixture 反序列化冒烟(net5 runtime 已 EOL,不为测试单独装机) | 平衡成本与置信度 | -| JSON 序列化 | 模型**纯 POCO 零注解**:net5 消费方 Newtonsoft(大小写不敏感)天然兼容 camelCase;net8 内部用 `JsonSerializerOptions.Web` | 双序列化器兼容且不引入注解漂移 | -| 依赖策略 | SDK 最小依赖:`Microsoft.Extensions.Http` / `StackExchange.Redis` /(Auth 包)`Microsoft.AspNetCore.Authentication`——net5.0 版本线对齐消费方可用版本 | 避免 SDK 拖动消费方依赖升级 | - -> 未决事项升级:若决定 GrowthHealth 先升级 .NET 8 再接入,可砍掉 net5.0 目标线(见 9.3 裁决 #1)。 - -## 三、仓库脚手架(从空文件夹开始) - -### 3.1 目录结构 - -``` -yh-platform-net/ # 独立仓库(与 Java 不混仓) -├── Directory.Build.props # 共享编译属性(多目标 TFM、LangVersion、确定性构建) -├── Directory.Packages.props # 集中包版本管理(= Java BOM 等价物) -├── yh-platform-net.sln -├── src/ -│ ├── Yh.Platform.Abstractions/ # 模型与信封(对应 yh-utils-core) -│ │ └── Yh.Platform.Abstractions.csproj -│ ├── Yh.Platform.OpenApi/ # /oapi 客户端(对应 yh-openapi-client) -│ ├── Yh.Platform.AppAuth/ # HMAC 签名 + 防重放(对应 yh-app-auth-starter) -│ └── Yh.Platform.Auth/ # CAS SSO + 用户上下文(对应 yh-auth-starter) -├── tests/ -│ ├── Yh.Platform.Abstractions.Tests/ -│ ├── Yh.Platform.OpenApi.Tests/ # 含契约 fixture 对拍 -│ ├── Yh.Platform.AppAuth.Tests/ # 含共享测试向量对拍 -│ └── Yh.Platform.Auth.Tests/ -├── samples/ -│ └── Yh.Platform.Sample.Web/ # net8.0 示例应用(平台验收载体) -├── contract/ # 契约工件(单一事实源派生物) -│ ├── openapi-oapi.yaml # /oapi OpenAPI 3 spec -│ ├── sign-protocol.md # 签名协议规范 -│ └── fixtures/ -│ ├── sign-vectors.json # 共享签名测试向量 -│ └── envelope-*.json # 信封解析 fixture(与 Java 对拍) -├── .gitignore # dotnet 标准模板 -├── .gitea/workflows/ci.yml # 对齐 UserBase/Notice 的 Gitea CI 惯例 -└── README.md -``` - -### 3.2 初始化命令序列 - -```powershell -# 前置:已安装 .NET SDK 8.0(dotnet --version >= 8.0) -mkdir yh-platform-net; cd yh-platform-net -git init -dotnet new gitignore -dotnet new sln -n yh-platform-net - -# 四包 + 测试 + 示例(Abstractions 为 classlib,其余按依赖逐个建) -dotnet new classlib -n Yh.Platform.Abstractions -o src/Yh.Platform.Abstractions -f net8.0 -dotnet new classlib -n Yh.Platform.OpenApi -o src/Yh.Platform.OpenApi -f net8.0 -dotnet new classlib -n Yh.Platform.AppAuth -o src/Yh.Platform.AppAuth -f net8.0 -dotnet new classlib -n Yh.Platform.Auth -o src/Yh.Platform.Auth -f net8.0 -dotnet new xunit -n Yh.Platform.OpenApi.Tests -o tests/Yh.Platform.OpenApi.Tests -f net8.0 -dotnet new web -n Yh.Platform.Sample.Web -o samples/Yh.Platform.Sample.Web -f net8.0 - -# 引用关系(镜像 Java 模块依赖图) -dotnet add src/Yh.Platform.OpenApi/src reference src/Yh.Platform.Abstractions/src # openapi → abstractions -dotnet add src/Yh.Platform.AppAuth/src reference src/Yh.Platform.Abstractions/src # appauth → abstractions -dotnet add src/Yh.Platform.Auth/src reference src/Yh.Platform.Abstractions/src # auth → abstractions -dotnet add src/Yh.Platform.OpenApi.Tests/src reference src/Yh.Platform.OpenApi/src -# ... 其余同理 - -# 多目标改写:手工把各 csproj 的 改为 -# net5.0;net8.0 -# 并在 Directory.Build.props 固化公共属性 - -foreach f in (sln 里所有项目) { dotnet sln add $f } -dotnet build # 双目标全绿即脚手架完成 -``` - -### 3.3 集中版本管理(BOM 等价物) - -`Directory.Packages.props`(Central Package Management): - -```xml - - - true - 1.0.0 - - - - - - - - - - - -``` - -> net5.0 目标线的扩展包版本(如 `Microsoft.Extensions.Http 5.0.x`)通过条件 ItemGroup 区分;`#if NET5_0_OR_GREATER` 符号处理 API 差异(预计极少——`IHttpClientFactory`/`DelegatingHandler` 自 2.1 起稳定)。 - -## 四、四包详细设计(对齐 Java 模块) - -### 4.1 Yh.Platform.Abstractions(对应 yh-utils-core) - -| C# 类型 | Java 对齐 | 说明 | -| -------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `BoaUser` | `utils.model.BoaUser` | 字段一比一;`CurrentRole` 属性类型 `BoaRoleInfo`(对齐 Java `BoaUser.currentRole`,**替代** GrowthHealth 本地 `Bsch.Entity.Custom.CurrentRole` 散落实现) | -| `BoaRoleInfo` | `utils.model.BoaRoleInfo` | roleId/roleName/schId/schName/childId/childName,`int?`/`string` | -| `BoaGroup` / `BoaModule` / `BoaApp` / `BoaAppType` | 同名 | 纯 POCO 零注解(双序列化器兼容,见 2) | -| `ApiEnvelope` | `ApiResult` | `{code,msg,data}` 信封 + `IsSuccess` 判别(对齐 `AbstractOpenApiClient.unwrap()` 语义) | -| `DException` | `utils.exception.DException` | 携带 code/msg/data;`code≠0` 时由 OpenApi 包抛出 | -| `YhErrorCode` | `ErrorCode`/`IErrorCode` | 错误码抽象 | - -### 4.2 Yh.Platform.OpenApi(对应 yh-openapi-client + starter) - -| C# 类型 | Java 对齐 | 说明 | -| -------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `YschBaseOpenApi` | 门面同名 | **静态门面 + 实例构造** **`(baseUrl, appKey, appSecret)`** **双形态**:前者对齐 Java `YschBaseOpenApi` 静态入口;后者复刻 BschOpenApi `BaseOpenApi(host, appKey, appSecret)` 构造习惯,GrowthHealth 迁移时改 using 即可 | -| `IOpenApiUserClient` / `OpenApiUserClient` | `OpenApiUserClient`/`Default*` | 用户域:byId/byLoginName/byPhone/roles/identity current+switch/password/groupState/transferToSchool 等,端点清单从 Java 接口 javadoc 反推 | -| `IOpenApiGroupClient` / `OpenApiGroupClient` | 同名 | 组域:tree/byId/list/add/edit/remove | -| `IOpenApiAppClient` / `OpenApiAppClient` | 同名 | 应用域:byId/list | -| `OpenApiClientBase` | `AbstractOpenApiClient` | **信封感知解析单点**:成功裸数据 → 反序列化;HTTP 200 + `code≠0` → 抛 `DException`(携带 code/msg/data);`PREFIX="/oapi"` | -| `OpenApiOptions` | `OpenApiProperties` | `yh:open:` 配置节(base-host/base-path/app-key/app-secret/超时,默认值对齐 Java:connect 1500ms / read 5000ms) | -| `ServiceCollectionExtensions.AddYhOpenApi()` | `OpenApiAutoConfiguration` | `IHttpClientFactory` 注册 + typed client + `DelegatingHandler` 挂签名 | - -### 4.3 Yh.Platform.AppAuth(对应 yh-app-auth-starter) - -| C# 类型 | Java 对齐 | 说明 | -| ----------------- | ----------------------- | --------------------------------------------------------------------------- | -| `SignUtil` | `utils.crypto.SignUtil` | `HmacSHA256` 静态方法集,输出格式逐一对齐(见契约 `sign-protocol.md` 裁决) | -| `AppSignHandler` | `AppSignInterceptor` | `DelegatingHandler`:自动附加 `X-App-Key/X-Timestamp/X-Signature` 头(对齐 Java 头契约) | -| `SignKeyResolver` | `SignKeyResolver` | 三层:Redis → IMemoryCache → UserBase `/oapi/internal/sign-key/{appKey}` 兜底 | -| `ReplayGuard` | 防重放 | nonce + 5min 窗口,Redis 键空间与 Java 约定一致 | -| `AppAuthOptions` | `yh.app-auth.*` | 配置节 | - -### 4.4 Yh.Platform.Auth(对应 yh-auth-starter) - -| C# 类型 | Java 对齐 | 说明 | -| -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `CasAuthenticationHandler` | `AddCasSso()` 用到的本地 dll | 自研 CAS 远程认证 handler(AuthenticationHandler 基类),替换 `AspNetCore.Security.CAS.dll`:CasServerUrlBase、service ticket 校验 `/serviceValidate` | -| `BoaUserContext` | `UserContextHolder` | `AsyncLocal`(替代 Java ThreadLocal 语义) | -| `IUserDetailsProvider` | `UserDetailsProvider` SPI | 默认实现走 `IOpenApiUserClient` 换取 `BoaUser` | -| `AddYhAuth()` | `YhAuthAutoConfiguration` | Cookie + CAS 认证注册 + 用户上下文中间件 | - -**聚合入口**:`AddYhPlatform()` 一次带齐(等价 Java `yh-spring-boot-starter`),也可按包拆引。 - -## 五、契约工件(MVP 前置门禁,承 v1 第六章) - -1. `contract/sign-protocol.md`:权威签名方案(数据级 `data|ts` 小写 hex vs 请求级 `METHOD\nurl\nts` Base64——需裁决)、验签大小写、时间戳毫秒、5min 窗口; -2. `contract/openapi-oapi.yaml`:UserBase `controller.oapi` 四域全端点(springdoc 导出 + 人工补 `oneOf` 错误信封),排除 `/oapi/internal/**`; -3. `contract/fixtures/sign-vectors.json`:≥10 组签名向量(**由 Java** **`SignUtil`** **实际运行产出基线**,含空 body/超长参数/时间戳边界); -4. `contract/fixtures/envelope-*.json`:信封样例(成功裸数据/失败 code=4/5),双端解析对拍。 - -## 六、构建、CI 与发布 - -| 环节 | 方案 | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| 本地构建 | `dotnet build`(双目标);`dotnet test`(net8.0 全量) | -| 契约对拍 | 测试内加载 `contract/fixtures/*.json`(`CopyToOutputDirectory`),断言 C# 输出与 fixture 逐字节一致 | -| CI(Gitea,对齐 UserBase/Notice 惯例) | `dotnet build -c Release` → `dotnet test` → `dotnet pack -c Release -o artifacts`;Java 侧 CI 增加一步"重算签名向量 fixture"防漂移 | -| 发布 | `dotnet nuget push` 到 Nexus 3 NuGet 仓库(`nexus.yunhaixingtong.com`),与 Maven 同私服 | -| 版本策略 | SemVer;包版本统一随 `YhPlatformNetVersion` 走(对齐 BOM 集中钉版本);兼容矩阵登记 `yh-platform ↔ yh-platform-net ↔ /oapi 契约` | - -## 七、分阶段实施步骤(每步有完成标志) - -| 阶段 | 内容 | 完成标志 | -| ------------------- | -------------------------------------------------------------------------------- | ------------------------------------------- | -| **P0 环境与裁决** | 装 .NET SDK 8;裁决 9.3 的 #1(net5.0 目标线去留)与 #2(签名方案);Java 侧跑出 `sign-vectors.json` 基线 | `dotnet --version ≥ 8`;裁决记录进本文档 9.3 | -| **P1 契约提纯** | 产出第五节四件契约工件 | 自检清单 A 全勾 | -| **P2 脚手架** | 第三节命令序列跑通,空壳四包 + 测试 + sample 编译绿 | `dotnet build` 双目标零 warning(新增代码零 warning) | -| **P3 Abstractions** | 全模型 + 信封 + 异常;用 `envelope-*.json` 做反序列化对拍测试 | Abstractions.Tests 绿 | -| **P4 AppAuth** | `SignUtil` + `AppSignHandler` + `SignKeyResolver`;`sign-vectors.json` 100% 对拍 | AppAuth.Tests 绿,含向量逐条断言 | -| **P5 OpenApi** | 三域 client + 门面双形态 + `AddYhOpenApi()`;对接 UserBase 测试环境端到端 | sample 应用成功取到 `BoaUser`(含 currentRole)与角色列表 | -| **P6 Auth** | CAS handler + `BoaUserContext` + `AddYhAuth()` | sample 应用 CAS 登录 → 上下文注入 `BoaUser` | -| **P7 打包发布** | `dotnet pack` + Nexus push + CI 固化 + 兼容矩阵登记 | Nexus 可 `dotnet add package` 拉到 | -| **P8 消费方就绪文档** | 《.NET 消费方接入指南》(appKey 注册、配置样例、从 BschOpenApi 迁移对照表) | 指南成文并评审 | - -## 八、完成后自检清单(Self-Check) - -> 用法:**SDK 建完(P0\~P6 收尾)后,一次性照单跑一遍**。全部打勾即合格;任何一项不勾,修完再勾,不留尾巴。 - -### 8.1 工程自检(有没有问题) - -* [ ] `git clone` 空机器 + `dotnet build` 一次通过(含 net5.0;net8.0 双目标,前提是裁决保留了 net5.0 线) - -* [ ] `dotnet test` 全绿(含契约 fixture 对拍) - -* [ ] 无 `#if` 之外的 TFM 差异残留;无 TODO/FIXME 遗留 - -* [ ] 包引用关系正确:OpenApi/AppAuth/Auth → Abstractions,无反向依赖 - -* [ ] `Directory.Packages.props` 全部包版本集中管理,csproj 内无散落版本号 - -* [ ] NuGet 包元数据完整(作者、描述、仓库地址、license) - -* [ ] `dotnet pack` 产物可被空控制台项目 `dotnet add package` 正常引用还原 - -### 8.2 契约自检(和 Java 侧对不对得上) - -* [ ] `sign-vectors.json` 全部向量 C# 输出逐字节一致(100% 对拍) - -* [ ] `envelope-*.json` 全部样例:成功裸数据反序列化正确;失败信封抛 `DException` 且携带 code/msg/data - -* [ ] 模型字段与 Java `BoaUser/BoaRoleInfo` 等一比一(含 `BoaUser.CurrentRole: BoaRoleInfo` 嵌套) - -* [ ] 同一份 fixture JSON 用 Newtonsoft(net5 形态)与 STJ(net8 形态)都能正确反序列化 - -* [ ] `X-App-Key/X-Timestamp/X-Signature` 头名与 Java 逐字一致 - -### 8.3 运行自检(跑起来对不对) - -* [ ] sample 应用签名调 /oapi 成功取到 `BoaUser`(含 currentRole) - -* [ ] CAS 登录 → `BoaUserContext` 注入正确;请求结束上下文清空 - -* [ ] 超时默认 connect 1500ms / read 5000ms 生效 - -* [ ] 防重放:同一签名 5 分钟内二次请求被拒 - -## 九、审计核对(功能逻辑逐项验证) - -> 用法:**自检全勾后**,按功能逐项核对"输入 → 预期行为",确认逻辑与 Java 侧等价。核对方式:跑 sample/单测实际验证,不靠读代码。每项给结论:通过 / 不通过(说明原因)。 - -### 9.1 核对表 - -| # | 功能 | 核对内容(输入 → 预期) | 方式 | 结果 | -| -- | --------- | ----------------------------------------------------------------------------- | -------------- | ------------------------------------------------- | -| 1 | HMAC 签名生成 | 固定 `(data, ts, secret)` → 输出与 Java `SignUtil` 完全一致 | 测试向量对拍 | ✅ 通过(向量 100% 对拍 + 独立实现抽检 6/6) | -| 2 | 签名请求头附加 | 任意经 SDK 发出的 HTTP 请求 → 自动带三头,值与手算一致 | 抓包/单测 | ✅ 通过(P1 修复后回归:handler 对 fullUrl 签名与 Java 基线逐字节一致) | -| 3 | 信封成功分支 | /oapi 返回裸数据/裸数组 → 正确反序列化为模型;数组含单元素与多元素 | fixture 单测 | ✅ 通过 | -| 4 | 信封失败分支 | HTTP 200 + `{code:4/5, msg, data}` → 抛 `DException`,code/msg/data 原样携带 | fixture 单测 | ✅ 通过(data 载体更名 `EnvelopeData`,P2 修复) | -| 5 | 业务异常等价性 | Java 侧 `catch(DException)` 的代码,C# 侧 `catch(DException)` 能拿到同样的 code/msg | 对照 | ✅ 通过 | -| 6 | 用户查询三入口 | byId / byLoginName / byPhone → 同一测试用户返回字段一致的 `BoaUser` | sample 实调 | ⏳ 待环境(UserBase 测试环境 + appKey 注册) | -| 7 | 多角色读取 | `getUserRoleList` → 角色列表字段完整(roleId/roleName/schId/schName/childId/childName) | sample 实调 | ⏳ 待环境 | -| 8 | 当前身份 | `getCurrentIdentity` → 与 UserBase `sys_user_current_role` 状态一致;未切换时回退主身份 | sample 实调 | ⏳ 待环境 | -| 9 | 身份切换(若需) | `switchIdentity` → 白名单 appKey 通过、非白名单拒绝、三元组非法拒绝 | sample 实调 | ⏳ 待环境 | -| 10 | 防重放 | 同 timestamp+nonce 二次请求 → 拒绝;5 分钟前时间戳 → 拒绝 | 实调 | ✅ 通过(逻辑单测:MemoryReplayGuard + 双向偏差窗口) | -| 11 | 密钥解析链 | Redis 有值 → 不请求 UserBase;Redis 空 → 兜底取到并回填缓存 | 断点/日志 | ✅ 通过(代码级核对,兜底实调待环境) | -| 12 | CAS 登录 | sample 走完 CAS 重定向 + ticket 校验 → 登录态建立、`BoaUser` 注入 | 浏览器实走 | ⏳ 待环境(CAS server 联通) | -| 13 | 用户上下文生命周期 | 请求内任何层可取 `BoaUserContext.Current`;请求结束不串到下一请求(并发压一下) | 单测/实调 | ✅ 通过(单测) | -| 14 | 错误传播 | UserBase 不可达/超时 → 抛超时类异常而非挂死;HTTP 非 2xx → 异常带状态码 | 实调(断 UserBase) | ✅ 通过(FakeHandler 模拟 500/异常路径) | -| 15 | 双形态门面 | `YschBaseOpenApi` 静态门面与 `new (baseUrl, appKey, appSecret)` 实例,行为一致 | 单测 | ✅ 通过 | - -### 9.2 审计结论记录 - -> 全部核对完成后填写;"不通过"项须附原因与修复决定。 - -| 日期 | 核对人 | 通过 / 不通过项 | 遗留问题与决定 | -| ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------- | -| 2026-08-28 | 架构验收(工具辅助) | **通过 11 项**(#1 签名向量、#3/#4 信封双分支、#5 异常等价、#10 防重放逻辑、#11 密钥链代码、#14 错误传播、#15 双形态门面等);**不通过 1 项(#2)**;待环境 3 项(#6\~#9、#12 实调类) | 见下方 2026-08-28 验收详情 | - -**2026-08-28 验收详情(BasePlatformDotnet,测试 67/67 绿)**: - -* **不通过 #2(P1 阻断)**:`AppSignHandler` 签名 target 未剥离 basePath。实证:Java 客户端 `AppSignInterceptor` 签名前 `stripBasePath`(配置 base-path=/userbase),服务端 `AppAuthFilter.buildSignedTarget` 剥 `request.getContextPath()`——两端验签口径为 `/oapi/**`;而 C# `AppSignHandler` 用 `uri.AbsolutePath`(含 `/userbase` 前缀)。UserBase 挂 context-path `/userbase` 时 C# 出站签名必被服务端拒绝。**根因连带**:`sign-vectors.json` 请求级向量的 `canonicalTarget` 含 `/userbase` 前缀(生成时 Java 侧 basePath 未配置),向量与 C# 实现"自洽"但均偏离服务端口径。**修复决定**:AppSignHandler 增加 basePath 剥离参数;同步修正请求级向量(canonicalTarget 去 `/userbase` 前缀)并在 `sign-protocol.md` 写明剥离规则;补一个"剥离后与 Java 拦截器同 URL 签名一致"的对拍测试。 - -* **P2**:`DException.Data` 遮蔽 `Exception.Data`(编译警告 CS0114,违反"零 warning"自检项)。`Exception.Data` 是 IDictionary 语义,被 `object?` 遮蔽后框架代码按基类契约访问会异常。决定:改名 `EnvelopeData`(或至少显式 `new` + 文档化),消除警告。 - -* **P3 观察项**:path 解码差异——Java 拦截器 `URLDecoder`(`+`→空格)vs C# `AbsolutePath`(保留 `+`);pack/Nexus push 未实际执行(P7 范畴);8.3 运行自检整组待 UserBase 测试环境(appKey 注册、网络可达)后补验 #6\~#9、#12。 - -* **独立验证**:签名向量经第三方实现(独立 HMACSHA256 脚本)抽样 6/6 MATCH,确认拼接顺序/编码/大小写跨实现一致。 - -**2026-08-28 修复执行记录(P1/P2 已闭环,回归 83/83 绿、双目标构建零 warning)**: - -* **P1 basePath 剥离**:`SignCanonical` 新增 `StripBasePath`(逐行对齐 Java `stripBasePath`,含尾斜杠归一化与前缀不完整匹配不剥离边界)与 `BuildTarget(Uri, basePath)` 重载;`AppSignHandler` 增加 basePath 参数(`NormalizeBasePath` 归一化,空=挂根不剥离),DI 扩展与 `BaseOpenApi` 均已接线传入 `OpenApiOptions.BasePath`;时钟源统一走 `SignUtil.NowMillis`(可注入,测试可固定 ts 与基线逐字节对拍)。 - -* **P1 向量修正**:`sign-vectors.json` 六个请求级用例 canonicalTarget 去 `/userbase` 前缀并以独立 HMAC 实现重算期望值;新增 `fullUrl`/`basePath` 字段复现剥离前 URL;`requestLevel.note` 写明剥离口径。 - -* **P1 对拍测试**:新增 `AppSignHandlerTests`(6 个向量驱动的 handler 对 fullUrl 签名与 Java 基线逐字节一致、剥离+query 排序、未配置 basePath 不剥离、7 组 StripBasePath 边界、BuildTarget 双形态)。 - -* **P2**:`DException.Data` → `EnvelopeData`(消除 CS0114 对 `Exception.Data` 的遮蔽),`sign-protocol.md` 2.2 节补 basePath 剥离规则。 - -* **协议文档**:`sign-protocol.md` 2.2 新增"basePath 剥离(客户端与服务端共同口径)"条款。 - -* 遗留:P3 观察项(path `+` 解码差异)与待环境项(#6\~#9、#12、Nexus push)不变。 - -**2026-08-28 双端同步闭环(契约快照对拍机制建成)**: - -* **流程补课**:UserBase 加 `/oapi` 文档分组(`SwaggerConfig.oapiGroup`:pathsToMatch /oapi/\*\* + pathsToExclude /oapi/internal/\*\*;prod 用 `SPRINGDOC_API_DOCS_ENABLED=false` 关闭)→ 真实导出 `contract/openapi-oapi.exported.json` 入库作为权威基线 → C# 侧 `ContractSnapshotTests`(15 个用例)对拍,双端同步机制正式生效。此前"手写 yaml 自称权威源"的流程偏差就此纠正。 - -* **对拍机制**:①端点对拍——SDK 路由表 ⊆ 快照(严格失败,UserBase 删改端点立刻红);快照多出的端点须显式登记白名单否则失败(防无声失联);②模型对拍——13 个 C# POCO 属性集(camelCase)⇔ 快照 schema properties 键集双向差额为零(反射驱动,UserBase 改字段立刻红)。 - -* **首战战果(快照暴露 4 个 SDK 真漂移,全部已修)**:① `SysUserRequest.Roles` → `RoleIds`(序列化键 `roles` 服务端收不到,会实际跑坏);② `SysUserRequest` 补 `SchoolId`;③ `SysGroupRequest` 补 `Id`;④ 新增 `GetAvailableIdentitiesAsync`(/oapi/identity/available,多角色配套端点此前缺失)。另修正手写 yaml 4 处文档偏差(byId id / appId 类型、clientType required、roles→roleIds+schoolId)。 - -* **基线更新流程**(写入测试 xml doc):UserBase 契约变更 → 起服务 `GET /v3/api-docs/oapi` 覆盖 exported.json → 跑测试,红了即漂移,同步修 SDK 或登记白名单。 - -* 回归:测试 98/98 绿(+15)。 - -### 9.3 方案合规核对(建设前依据,承 v2 方案审计) - -| 审计项 | 结论 | 依据 | -| ------------------------ | -- | ----------------------------------------------------- | -| 是否避免为 .NET 引入额外常驻运行单元 | 通过 | 无 sidecar、无网关耦合 | -| 是否与既有 yh-platform 架构风格一致 | 通过 | 四包 1:1 镜像;"core 纯契约 + 装配扩展"分层同源 | -| 是否与既有 BOM/私服治理一致 | 通过 | CPM = BOM 等价物;Nexus 3 同私服承载 NuGet | -| 是否复用既有基础设施 | 通过 | springdoc 已在 UserBase;Redis/CasSso 复用;模型编译期共享(Java 侧) | -| 影响面是否最小化 | 通过 | yh-platform 零改动;UserBase 仅配置级(spec 分组) | -| **兼容旧栈**(v2 新增) | 通过 | 多目标 net5.0;Newtonsoft 兼容模型;`BaseOpenApi` 构造形态复刻 | -| **可从空文件夹直接落地**(v2 新增) | 通过 | 第三节命令序列 + 第七节分阶段步骤,每步有完成标志 | -| 淘汰路径 | 通过 | 阶段化交付可独立回退;最坏降级方案 C(sidecar)过渡 | - -### 9.4 消费方代码实证(2026-08-28,建设依据) - -| 核查对象 | 核查点 | 结果 | -| ----------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `GrowthHealth/service/Bsch.Web/Bsch.Web.csproj` | 目标框架/依赖 | 实证:net5.0;Newtonsoft 5.0.2 适配器;本地 dll 引用 BschOpenApi/AspNetCore.Security.CAS/log4net | -| `Bsch.Common.csproj` | 基础设施栈 | 实证:Redis 缓存 2.2.0(旧式)、Newtonsoft 13.0.1、Serilog 4.1.0 | -| `Bsch.Repository/Repository/UserInfo.cs` | 旧 SDK 用法 | 实证:`new BaseOpenApi(UserCenterHost, appKey, appSecret)`;`BoaUser` 来自 `BschBaseOpenApi` 命名空间;CurrentRole 为本地实体+请求头拼装 | -| `ControllerAbstract.cs` | 控制器基类消费模式 | 实证:每个控制器 new 一个 BaseOpenApi(无 DI/无连接复用)——新 SDK 需以 IHttpClientFactory 修正此反模式 | -| `ServiceCollectionExtensions.cs`(AddCasSso) | CAS 认证形态 | 实证:Cookie + `AddCAS()`(本地 dll);CasServerUrlBase 取自配置 | -| `appsettings.json` | 配置契约 | 实证:`AppTicket.AppKey=100476`、UserCenterHost/SsoHost/SchClassHost——新 SDK Options 节需给出从旧键迁移的映射 | -| `Dockerfile` | 部署形态 | 实证:sdk:5.0/aspnet:5.0,aspnet:5.0 基础镜像已 EOL(升级建议列入消费方指南) | -| Java 侧核查(承 v1) | SignUtil 双规则/信封/springdoc/模型共享 | 结论不变:内容合格、形式待提纯;详见 v1 审计表 | - -### 9.5 未决事项(待裁决) - -| # | 事项 | 裁决人 | 截止 | v2 备注 | -| - | ------------------------------------------------------------ | ------------------- | ---- | -------------------------------------------------------- | -| 1 | 保留 `net5.0` 目标线,还是要求消费方先升 .NET 8 | 平台负责人 | P0 | 保留=SDK 复杂度+双目标;砍掉=GrowthHealth 必须先升级(含 EF Core/基础镜像连带升级) | -| 2 | 权威签名方案:数据级(`data\|ts` 小写 hex)还是请求级(`METHOD\nurl\nts` Base64) | 平台负责人 | P1 前 | 旧 BschOpenApi 实际用哪套可作为裁决输入(反编译或抓包确认) | -| 3 | 请求级验签大小写敏感是否设计意图 | yh-platform 维护者 | P1 前 | — | -| 4 | C# 模型手写 vs NSwag/Kiota 生成 | yh-platform-net 负责人 | P3 前 | v2 倾向手写(模型量小、需 Newtonsoft 零注解兼容) | -| 5 | 双签名规则长期收敛 | 平台负责人 | 长期 | 不阻塞 | - -### 9.6 变更记录 - -| 日期 | 版本 | 说明 | -| ---------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-08-28 | v1.0 | 初版:平台工件建设定位,方案选型、架构、影响面实证、自检清单、审计 | -| 2026-08-28 | v2.0 | 基于消费方实证(GrowthHealth net5.0 + BschOpenApi.dll 考古):新增目标框架兼容策略(net5.0;net8.0 多目标)、仓库脚手架命令序列(Directory.Build.props/CPM)、四包详细设计(含 `YschBaseOpenApi` 双形态复刻 BaseOpenApi 构造习惯)、分阶段实施步骤、自检清单 B 工程门禁、审计新增兼容旧栈与空文件夹可落地两项 | -| 2026-08-28 | v3.0 | 自检/审计简化落地:多阶段门禁改为"完成后一次性自检"(工程/契约/运行三组)+ "功能逻辑审计核对表"(15 项输入→预期核对,含审计结论记录表);原方案审计/代码实证降级为建设依据附录 | - diff --git a/yh-platform-dotnet建设方案v4.md b/yh-platform-dotnet建设方案v4.md deleted file mode 100644 index 9d319dd..0000000 --- a/yh-platform-dotnet建设方案v4.md +++ /dev/null @@ -1,257 +0,0 @@ -# yh-platform-dotnet 建设方案 v4 —— 移交文档(已做 / 未做全记录) - -> 版本:v4.0 | 日期:2026-08-28 | 状态:**移交文档**(接手人从此文档开始) -> 定位:平台侧工件建设——与 Java `yh-platform` 同构的 .NET SDK(`yh-platform-net`,代码仓库名 `BasePlatformDotnet`),使平台基础能力(用户信息、/oapi 开放接口、应用签名、CAS SSO)具备跨语言辐射能力 -> v4 增量:**建设阶段基本完成**(四包 98 测试全绿、契约快照对拍机制建成、pack 产物验证通过)。本文档为移交版:完整记录已完成工作、待办事项、关键机制与快速上手路径 -> 前置阅读:v1(架构决策)→ v2(工程落地)→ v3(自检与审计),见 [yh-platform-dotnet建设方案.md](file:///d:/SoftProject/yunhai/yh-platform-dotnet建设方案.md) - -*** - -## 一、项目一句话说明 - -Java 侧有 `yh-platform`(Maven 聚合 SDK:取用户、调 /oapi 开放接口、应用签名、CAS SSO)。.NET 侧项目(如 GrowthHealth,由 dotnet core 迁入、部署于 projman 平台)原先用无源码的本地 `BschOpenApi.dll` 调旧接口。本项目产出 **`yh-platform-net` 四个 NuGet 包**,与 Java SDK 依赖**同一份契约**(UserBase /oapi + HMAC 签名协议),让 .NET 项目以正规 NuGet 依赖方式获得同等能力。 - -**核心原则:不要让 .NET 去"依赖 Java",而是让两个 SDK 依赖同一份契约。** - -## 二、仓库与关联工程地图 - -| 路径 | 角色 | 状态 | -|---|---|---| -| `d:\SoftProject\yunhai\BasePlatformDotnet\` | **本 SDK 仓库**(本移交的主对象) | 建设完成,待发布 | -| `d:\SoftProject\yunhai\BasePlatform\` | Java 侧 yh-platform(yh-utils-core / yh-openapi-client / yh-app-auth-starter / yh-auth-starter) | 零改动(契约对齐的参照系) | -| `d:\SoftProject\yunhai\UserBase\` | 用户中心服务(/oapi 契约权威源) | 本项目改了 2 个文件(见 3.3) | -| `d:\SoftProject\yunhai\GrowthHealth\` | 未来首个 .NET 消费方(net5.0 + Newtonsoft + BschOpenApi.dll) | 未接入(SDK 未发布的依赖项) | - -### BasePlatformDotnet 仓库结构(实物核对 2026-08-28) - -``` -BasePlatformDotnet/ -├── Directory.Build.props # 共享编译属性:双目标 net5.0;net8.0、LangVersion、NuGet 元数据、YhPlatformNetVersion -├── Directory.Packages.props # CPM 集中包版本(= Java BOM 等价物) -├── yh-platform-net.sln -├── .gitea/workflows/ci.yml # CI:build → test → pack -├── src/ -│ ├── Yh.Platform.Abstractions/ # 模型与信封(对应 yh-utils-core) -│ │ ├── BoaUser.cs / BoaRoleInfo.cs / BoaGroup.cs / BoaModule.cs / BoaApp.cs / BoaAppType.cs -│ │ ├── ApiEnvelope.cs / DException.cs / ErrorCode.cs / IErrorCode.cs / ClientEnum.cs -│ ├── Yh.Platform.AppAuth/ # HMAC 签名 + 防重放(对应 yh-app-auth-starter) -│ │ ├── SignUtil.cs / SignCanonical.cs / AppSignHandler.cs -│ │ ├── SignKeyResolver.cs / SignKeyFallback.cs / ReplayGuard.cs / SignAppKeyPayload.cs -│ │ └── AppAuthOptions.cs / AppAuthServiceCollectionExtensions.cs -│ ├── Yh.Platform.OpenApi/ # /oapi 客户端(对应 yh-openapi-client) -│ │ ├── YschBaseOpenApi.cs # 静态门面(对齐 Java 同名) -│ │ ├── BaseOpenApi.cs # 实例形态 (baseUrl, appKey, appSecret)——复刻旧 BschOpenApi 构造习惯 -│ │ ├── IOpenApiUserClient.cs / OpenApiUserClient.cs # 用户域 + 身份域 -│ │ ├── IOpenApiGroupClient.cs / OpenApiGroupClient.cs # 组域 -│ │ ├── OpenApiClientBase.cs # 信封感知解析单点(unwrap 语义) -│ │ ├── SysUserRequests.cs / SysGroupRequests.cs # 写操作请求模型 -│ │ └── OpenApiOptions.cs / OpenApiServiceCollectionExtensions.cs / OpenApiErrorCode.cs / OpenApiException.cs -│ └── Yh.Platform.Auth/ # CAS SSO + 用户上下文(对应 yh-auth-starter) -│ ├── CasAuthenticationHandler.cs / CasServiceTicketValidator.cs -│ ├── BoaUserContext.cs / UserContextMiddleware.cs / UserDetailsProvider.cs -│ └── YhAuthOptions.cs / AuthServiceCollectionExtensions.cs -├── tests/ # 4 个测试工程,共 98 个测试 -│ ├── Yh.Platform.Abstractions.Tests/ # 8(模型序列化双序列化器对拍) -│ ├── Yh.Platform.AppAuth.Tests/ # 42(签名向量对拍、basePath 剥离、防重放、密钥链) -│ ├── Yh.Platform.OpenApi.Tests/ # 37(信封解析、客户端行为、契约快照对拍 15 个) -│ └── Yh.Platform.Auth.Tests/ # 11(CAS handler、上下文生命周期) -├── samples/Yh.Platform.Sample.Web/ # net8.0 示例应用(平台验收载体,配置样例在 appsettings.json) -├── contract/ # 契约工件(单一事实源派生物) -│ ├── openapi-oapi.exported.json # ★ 权威契约快照(UserBase springdoc 真实导出,对拍基线) -│ ├── openapi-oapi.yaml # 手写契约(已与快照对齐,供人读) -│ ├── sign-protocol.md # 签名协议规范(含 basePath 剥离条款) -│ └── fixtures/ -│ ├── sign-vectors.json # 共享签名测试向量(含 fullUrl/basePath 字段) -│ └── envelope-success.json / envelope-error.json # 信封解析 fixture -└── README.md -``` - -## 三、已完成的工作(按时间序,全部可查证) - -### 3.1 建设主体(P0~P6 全部完成) - -| 阶段 | 内容 | 完成证据 | -|---|---|---| -| P0 框架裁决 | 多目标 `net5.0;net8.0`(net5 覆盖 GrowthHealth 旧栈,net8 覆盖新项目);SDK 8.0 工具链构建;模型纯 POCO 零注解(Newtonsoft 大小写不敏感天然兼容 camelCase) | [Directory.Build.props](file:///d:/SoftProject/yunhai/BasePlatformDotnet/Directory.Build.props) | -| P1 契约提纯 | sign-protocol.md(签名协议规范)+ openapi-oapi.yaml + sign-vectors.json(Java SignUtil 实际运行产出基线)+ envelope fixtures | contract/ 目录 | -| P2 脚手架 | 四包 + 四测试工程 + sample + CPM + CI | 仓库根 | -| P3 Abstractions | 全模型(BoaUser 含 CurrentRole:BoaRoleInfo 多角色嵌套)+ 信封 + DException + 错误码 | 8 测试绿 | -| P4 AppAuth | SignUtil(HMAC-SHA256)+ AppSignHandler(DelegatingHandler 三头)+ SignKeyResolver(Redis→Memory→UserBase 兜底)+ ReplayGuard(5min 窗口) | 42 测试绿 | -| P5 OpenApi | 用户/组/应用三域 client + 身份域(current/available/switch)+ 双形态门面 + AddYhOpenApi() | 37 测试绿 | -| P6 Auth | CasAuthenticationHandler(自研,替换旧 dll)+ BoaUserContext(AsyncLocal)+ AddYhAuth() | 11 测试绿 | - -**当前基线(2026-08-28 实测):双目标构建零 warning,测试 98/98 绿。** - -### 3.2 验收与缺陷修复记录(重要:都是真实踩过的坑,接手人必读) - -**第一轮验收(测试 67 绿时)发现:** - -| 级别 | 缺陷 | 修复 | -|---|---|---| -| P1 阻断 | `AppSignHandler` 签名未剥离 basePath:C# 用 `uri.AbsolutePath`(含 `/userbase` 前缀)签出 `/userbase/oapi/...`,而 Java 客户端(AppSignInterceptor.stripBasePath)与服务端(AppAuthFilter 剥 contextPath)口径都是 `/oapi/...`——UserBase 挂 context-path 时 C# 签名必被拒 | `SignCanonical.StripBasePath`(逐行对齐 Java,含尾斜杠归一化/前缀不完整匹配不剥离边界);`AppSignHandler` 增加 basePath 参数并接线 `OpenApiOptions.BasePath`;sign-vectors.json 六个请求级向量重算;新增 6 个逐字节对拍测试。回归 83 绿 | -| P2 | `DException.Data` 遮蔽 `Exception.Data`(CS0114 警告,违反零 warning 门禁) | 改名 `EnvelopeData` | -| P3 观察项 | path `+` 解码差异:Java 拦截器 URLDecoder(`+`→空格)vs C# AbsolutePath(保留 `+`)——仅当路径参数含 `+` 时有影响 | **未修**,列入待办(低概率场景) | - -**第二轮(契约快照对拍上线)发现 4 个 SDK 真漂移,全部已修:** - -| # | 缺陷 | 危害 | 修复 | -|---|---|---|---| -| 1 | `SysUserRequest.Roles` 序列化键 `roles`,服务端字段 `roleIds` | **新增/编辑用户带角色时服务端静默丢弃**(实现没错,是契约认知错误——67 个测试全绿也没发现,因为它只能靠权威快照暴露) | 改名 `RoleIds` | -| 2 | `SysUserRequest` 缺 `SchoolId` | 无法传参 | 补字段 | -| 3 | `SysGroupRequest` 缺 `Id` | 契约键不完整 | 补字段 | -| 4 | 缺 `/oapi/identity/available` 端点 | 多角色"可切换身份列表"能力缺失 | 新增 `GetAvailableIdentitiesAsync` | - -> 教训(写给接手人):**手写翻译代码无法发现契约认知错误,只有权威快照对拍能**。这就是 3.4 节机制存在的理由,请务必维持它。 - -### 3.3 UserBase 侧改动(共 2 个文件,已编译验证) - -| 文件 | 改动 | -|---|---| -| [SwaggerConfig.java](file:///d:/SoftProject/yunhai/UserBase/service/src/main/java/com/yunhai/userbase/config/SwaggerConfig.java) | 新增两个 `GroupedOpenApi`:**oapi 组**(pathsToMatch `/oapi/**` + pathsToExclude `/oapi/internal/**`,契约权威源导出入口)+ api 组(`/api/**` 业务接口隔离) | -| [application.yml](file:///d:/SoftProject/yunhai/UserBase/service/src/main/resources/application.yml) | 新增 springdoc 开关(默认开;生产用环境变量 `SPRINGDOC_API_DOCS_ENABLED=false` + `SPRINGDOC_SWAGGER_UI_ENABLED=false` 关闭) | - -> **部署注意**:UserBase 生产环境部署时记得设置上述两个环境变量,否则文档端点外泄。 - -### 3.4 契约快照对拍机制(本项目最重要的长效机制,移交后必须维护) - -**双端同步流程:** - -``` -UserBase 改 /oapi 契约 - → 起服务 GET /userbase/v3/api-docs/oapi - → 覆盖保存 BasePlatformDotnet/contract/openapi-oapi.exported.json(入库) - → cd BasePlatformDotnet && dotnet test - → ContractSnapshotTests 红了 = 契约漂移 - → 二选一:修 SDK(同步四包实现) / 在测试的 acknowledged 白名单显式登记(有意不跟) -``` - -**机制构成**([ContractSnapshotTests.cs](file:///d:/SoftProject/yunhai/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs),15 个用例): - -- **端点对拍**:`SdkRoutes` 路由表 ⊆ 快照(UserBase 删/改端点立刻红);快照多出的端点须显式登记 `acknowledged` 白名单否则红(防无声失联) -- **模型对拍**:`ModelMappings` 13 个 POCO 反射取属性集(camelCase 化)⇔ 快照 schema properties 键集,双向差额为零 -- **维护纪律**:SDK 加端点 → 必须同步登记 `SdkRoutes`;加模型 → 必须登记 `ModelMappings`;UserBase 加端点 → 登记白名单并排期跟进 - -### 3.5 发布产物验证(pack 已跑通,未推送) - -2026-08-28 实测 `dotnet pack -c Release`: - -- 4 包生成:`Yh.Platform.Abstractions/AppAuth/OpenApi/Auth.1.0.0.nupkg`(版本统一随 `YhPlatformNetVersion`) -- 包内 `lib/net5.0` + `lib/net8.0` 双 dll 齐全 -- 依赖元数据按 TFM 正确分组:net5.0 → 内部包 1.0.0 + Microsoft.Extensions.* 5.0.0;net8.0 → 8.0.x。**GrowthHealth(net5.0)引用时自动命中 net5.0 组,老项目零升级** -- 模型纯 POCO,与 GrowthHealth 的 Newtonsoft 13.0.1 无冲突(有双序列化器对拍测试锁定) - -### 3.6 配置契约(消费方视角,sample 已验证写法) - -```json -{ - "Yh": { - "OpenApi": { - "BaseHost": "http://userbase-host:port", - "BasePath": "/userbase", - "AppKey": "100xxx", - "AppSecret": "xxx" - } - } -} -``` - -- `BasePath`:UserBase context-path,**签名剥离与 URL 拼接都依赖它**(P1 缺陷修复后成为关键配置,挂根则留空) -- 聚合入口 `AddYhPlatform()`(等价 Java yh-spring-boot-starter),也可按包拆引 `AddYhOpenApi()` / `AddYhAppAuth()` / `AddYhAuth()` -- 超时默认对齐 Java:connect 1500ms / read 5000ms - -## 四、未完成的工作(移交待办,按优先级) - -### 4.1 🔴 发布链(阻塞所有消费方,预计半天) - -- [ ] **Nexus 建仓**(需管理员):登录 `nexus.yunhaixingtong.com` → Repositories → Create → `nuget-hosted`(Version policy: Release;Deployment policy: **Disable redeploy**)→ 拿 API Key。可选:`nuget-proxy`(代理 nuget.org)+ `nuget-public`(group) -- [ ] **版本号裁决**:当前 `YhPlatformNetVersion=1.0.0`。因 9.1 核对表 #6~#9/#12 尚未真实环境实调,**建议先发 `1.0.0-preview.1`**(改 Directory.Build.props 后重 pack),实调通过再定 1.0.0;或直接 1.0.0 接受风险 -- [ ] **推送**: - ```powershell - dotnet nuget add source "http://nexus.yunhaixingtong.com/repository/nuget-hosted/index.json" ` - --name nexus-nuget --username <账号> --password <密码> --store-password-in-clear-text - dotnet nuget push "d:\SoftProject\yunhai\BasePlatformDotnet\artifacts\*.nupkg" ` - --source nexus-nuget --api-key --skip-duplicate - ``` -- [ ] **推送后验证**:空项目 `dotnet add package Yh.Platform.OpenApi` 能还原;注意 http 源在新 SDK 需 `nuget.config` 加 `allowInsecureConnections="true"` -- [ ] **CI push 固化**:Gitea 仓库 Secrets 加 `NEXUS_NUGET_APIKEY`/`NEXUS_NUGET_URL`,ci.yml 的 pack 步骤后追加 push 步骤(仅 main 分支) - -### 4.2 🟠 真实环境实调(依赖 UserBase 测试环境 + appKey 注册) - -- [ ] 核对表 #6:用户查询三入口(byId/byLoginName/byPhone)实调,返回字段一致 -- [ ] 核对表 #7:`getUserRoleList` 多角色字段完整 -- [ ] 核对表 #8:`getCurrentIdentity` 与 UserBase 状态一致 -- [ ] 核对表 #9:`switchIdentity` 白名单通过/拒绝行为 -- [ ] 核对表 #12:CAS 登录全流程(sample 浏览器实走,需 CAS server 联通 + 回调域名注册) -- [ ] 8.3 运行自检整组(超时生效、防重放真实拒绝、SignKeyResolver 兜底实调) -- [ ] **appKey 注册**:消费方(含 GrowthHealth 既有 100476 或新申请)须在 UserBase 侧注册 appKey+secret;身份切换端点另有白名单(现仅 100001/100002) - -### 4.3 🟡 文档与治理 - -- [ ] **P8 消费方接入指南**(原方案承诺未做):appKey 注册流程、配置样例、**从 BschOpenApi.dll 迁移对照表**(`new BaseOpenApi(host,appKey,appSecret)` → 新 SDK 双形态对照、`BschBaseOpenApi.BoaUser` → `Yh.Platform.Abstractions.BoaUser`、本地 CurrentRole → 服务端 currentRole)、EF Core/基础镜像升级建议 -- [ ] **兼容矩阵登记**:`yh-platform Java 版本 ↔ yh-platform-net 版本 ↔ /oapi 契约快照日期` 三方对应关系表(首次发布时建立) -- [ ] **Java 侧 CI 补一步**:重算 sign-vectors.json 防漂移(原方案第六节设想,未实施;当前防线只在 C# 侧) - -### 4.4 ⚪ 长期/低优先 - -- [ ] P3 观察项:path 参数含 `+` 时的解码差异(Java `+`→空格 vs C# 保留)——出现实际案例再修 -- [ ] 未决裁决 #1:net5.0 目标线去留(若 GrowthHealth 升级 .NET 8,可砍掉简化 SDK) -- [ ] 未决裁决 #5:Java 双签名规则(数据级/请求级)长期收敛——不阻塞,属 Java 侧治理 -- [ ] GrowthHealth 实际接入(SDK 发布 + 接入指南就绪后启动,属消费方项目不属于本项目) - -## 五、接手人快速上手(30 分钟路径) - -```powershell -# 前置:.NET SDK 8.0+(dotnet --version) -cd d:\SoftProject\yunhai\BasePlatformDotnet -dotnet build # 双目标编译,预期零 warning -dotnet test # 预期 98/98 绿 -dotnet pack -c Release -o artifacts # 预期 4 包生成 -``` - -**理解项目的阅读顺序:** - -1. 本文档第三节(做了什么、踩过什么坑) -2. [contract/sign-protocol.md](file:///d:/SoftProject/yunhai/BasePlatformDotnet/contract/sign-protocol.md)——签名协议(含 basePath 剥离条款,P1 缺陷的教训沉淀) -3. [ContractSnapshotTests.cs](file:///d:/SoftProject/yunhai/BasePlatformDotnet/tests/Yh.Platform.OpenApi.Tests/ContractSnapshotTests.cs)——双端同步机制(日常维护最主要触点) -4. [samples/Yh.Platform.Sample.Web/Program.cs](file:///d:/SoftProject/yunhai/BasePlatformDotnet/samples/Yh.Platform.Sample.Web/Program.cs) + appsettings.json——SDK 用法样例 -5. Java 参照系:`BasePlatform/yh-app-auth-spring-boot-starter`(签名口径)、`UserBase/controller/oapi`(契约源头) - -**日常最可能的变更场景与操作:** - -| 场景 | 操作 | -|---|---| -| UserBase 改了 /oapi 契约 | 重新导出快照 → dotnet test → 按红项修 SDK 或登记白名单 → 升版本 → push | -| SDK 加新端点 | Client 实现 + `SdkRoutes` 登记 + (新模型则)`ModelMappings` 登记 + 测试 | -| 发新版本 | 改 `Directory.Build.props` 的 `YhPlatformNetVersion` → pack → push(**Disable redeploy,同版本不可覆盖**) | - -## 六、验证状态总览 - -| 验证维度 | 状态 | -|---|---| -| 双目标编译(net5.0;net8.0) | ✅ 零 warning | -| 单元测试 | ✅ 98/98(Abstractions 8 / AppAuth 42 / OpenApi 37 / Auth 11) | -| 签名正确性 | ✅ 向量逐字节对拍 + 独立 HMAC 实现抽检 6/6 + handler 全链路对拍(P1 修复后) | -| 信封解析 | ✅ 成功裸数据/code≠0 抛 DException(EnvelopeData 携带),fixture 锁定 | -| 模型契约 | ✅ 13 POCO ⇔ 权威快照双向对拍 | -| 双序列化器 | ✅ Newtonsoft(大小写不敏感)+ STJ Web 逐节点对拍 | -| pack 产物 | ✅ 4 包、双 TFM lib、依赖分组正确(本地验证) | -| Nexus 发布 | ❌ 未推送(待 4.1) | -| 真实环境实调 | ❌ 待 UserBase 测试环境(待 4.2,核对表 15 项中 11 过 4 待) | -| 消费方实际接入 | ❌ 未开始(待 4.3 指南 + 4.1 发布) | - -## 七、历史文档索引 - -| 文档 | 内容 | 位置 | -|---|---|---| -| 方案 v1~v3(含全部演进过程、9.1 十五项核对表、9.5 未决裁决) | [yh-platform-dotnet建设方案.md](file:///d:/SoftProject/yunhai/yh-platform-dotnet建设方案.md) | 仓库根 | -| 签名协议规范 | contract/sign-protocol.md | SDK 仓库 | -| 项目 README | [README.md](file:///d:/SoftProject/yunhai/BasePlatformDotnet/README.md) | SDK 仓库 | - -### 变更记录 - -| 日期 | 版本 | 说明 | -|---|---|---| -| 2026-08-28 | v1.0 | 平台工件定位、方案选型、架构、影响面实证 | -| 2026-08-28 | v2.0 | 消费方实证(GrowthHealth net5.0 + BschOpenApi 考古)、脚手架命令、四包设计、分阶段实施 | -| 2026-08-28 | v3.0 | 自检清单(工程/契约/运行)+ 15 项审计核对表 | -| 2026-08-28 | v4.0 | **移交版**:已完成(P0~P6 + 两轮缺陷修复 + 快照对拍机制 + pack 验证)与待办(发布链/实调/指南/长期项)全记录,含接手人快速上手路径 |