From ce436cbb32c2ba4489226a1ce4750e5f8049c4e7 Mon Sep 17 00:00:00 2001 From: wanghu Date: Wed, 2 Sep 2026 10:23:24 +0800 Subject: [PATCH] bag --- .NET5底层框架搭建方案.md | 324 ++ .dockerignore | 9 + .gitignore | 47 + Dockerfile | 32 + YHDocPrintCore.slnx | 11 + docs/底层框架代码审查报告.md | 285 ++ docs/开发规范.md | 86 + scripts/oapi-sign.js | 72 + sqlscript/create-a-datalog-table.sql | 26 + sqlscript/create-sys-operation-log-table.sql | 55 + src/YhCore.Common/ApiRoutes.cs | 20 + src/YhCore.Common/Cache/RedisConstants.cs | 42 + .../Cache/RedisServiceCollectionExtensions.cs | 29 + src/YhCore.Common/Cache/RedisUtil.cs | 287 ++ src/YhCore.Common/Config/ApiPathConfig.cs | 19 + src/YhCore.Common/Config/Config.cs | 25 + src/YhCore.Common/Config/DatabaseConfig.cs | 17 + src/YhCore.Common/Config/FileConfig.cs | 14 + src/YhCore.Common/Config/RedisConfig.cs | 11 + src/YhCore.Common/Config/SwaggerConfig.cs | 17 + src/YhCore.Common/Exceptions/ErrorCodeUtil.cs | 30 + src/YhCore.Common/Result/ApiResult.cs | 47 + src/YhCore.Common/UserContext/UserContext.cs | 46 + .../UserContext/UserContextHolder.cs | 23 + src/YhCore.Common/Utils/PageInfo.cs | 42 + src/YhCore.Common/Utils/PageUtil.cs | 66 + src/YhCore.Common/YhCore.Common.csproj | 24 + src/YhCore.Entity/A_DataLog.cs | 25 + src/YhCore.Entity/AppMsgRecord.cs | 33 + src/YhCore.Entity/Base_Log.cs | 28 + src/YhCore.Entity/Common_Dictionary.cs | 30 + src/YhCore.Entity/Common_DictionaryOrd.cs | 23 + src/YhCore.Entity/Common_Groupuser.cs | 24 + src/YhCore.Entity/Common_Groupuser_SHRole.cs | 24 + src/YhCore.Entity/Common_SendUserinfo.cs | 24 + src/YhCore.Entity/Common_UpFiles.cs | 27 + src/YhCore.Entity/Common_UserFlowZhipai.cs | 26 + src/YhCore.Entity/Common_flowinfo.cs | 30 + src/YhCore.Entity/Common_flowstep.cs | 28 + src/YhCore.Entity/Common_flowtranctInfo.cs | 30 + src/YhCore.Entity/Common_flowuser.cs | 23 + src/YhCore.Entity/Common_freeGroupuser.cs | 24 + src/YhCore.Entity/Common_freeflowinfo.cs | 27 + src/YhCore.Entity/SysOperationLog.cs | 73 + src/YhCore.Entity/WY_DocPrint.cs | 47 + src/YhCore.Entity/WY_DocPrint_Num.cs | 32 + src/YhCore.Entity/WY_DocPrint_SH.cs | 29 + src/YhCore.Entity/WY_SHRole.cs | 23 + src/YhCore.Entity/WY_SHRole_BSHTea.cs | 25 + src/YhCore.Entity/WY_SHRole_SHTea.cs | 27 + src/YhCore.Entity/WY_SchZyBit.cs | 21 + src/YhCore.Entity/WY_reGetJERd.cs | 27 + src/YhCore.Entity/YhCore.Entity.csproj | 12 + src/YhCore.Model/Dto/DictionaryDtos.cs | 129 + .../Dto/DocPrintStatisticsDtos.cs | 73 + src/YhCore.Model/Dto/FileDtos.cs | 28 + src/YhCore.Model/Dto/FreeApproveDtos.cs | 38 + src/YhCore.Model/Dto/ReviewProcessDtos.cs | 120 + src/YhCore.Model/Dto/WYDocPrintDtos.cs | 228 ++ src/YhCore.Model/Dto/WYSHRoleDtos.cs | 236 ++ src/YhCore.Model/Vo/PageVO.cs | 40 + src/YhCore.Model/YhCore.Model.csproj | 16 + src/YhCore.Repository/Dapper/DapperChannel.cs | 72 + src/YhCore.Repository/DatabaseInitializer.cs | 177 ++ .../DataLayerServiceCollectionExtensions.cs | 48 + src/YhCore.Repository/IRepository.cs | 34 + src/YhCore.Repository/RepositoryBase.cs | 128 + .../Specification/Specification.cs | 84 + src/YhCore.Repository/YHDbContext.cs | 102 + .../YhCore.Repository.csproj | 25 + src/YhCore.Service/Impl/DictionaryService.cs | 171 ++ .../Impl/DocPrintClassifyService.cs | 338 +++ .../Impl/DocPrintStatisticsService.cs | 213 ++ src/YhCore.Service/Impl/FileService.cs | 235 ++ src/YhCore.Service/Impl/FreeApproveService.cs | 595 ++++ src/YhCore.Service/Impl/LogServiceStub.cs | 18 + .../Impl/ReviewProcessService.cs | 530 ++++ src/YhCore.Service/Impl/WYDocPrintService.cs | 1339 +++++++++ src/YhCore.Service/Impl/WYSHRoleService.cs | 2658 +++++++++++++++++ .../Interfaces/IDictionaryService.cs | 31 + .../Interfaces/IDocPrintClassifyService.cs | 52 + .../Interfaces/IDocPrintStatisticsService.cs | 20 + src/YhCore.Service/Interfaces/IFileService.cs | 39 + .../Interfaces/IFreeApproveService.cs | 34 + src/YhCore.Service/Interfaces/ILogService.cs | 21 + .../Interfaces/IReviewProcessService.cs | 84 + .../Interfaces/IWYDocPrintService.cs | 51 + .../Interfaces/IWYSHRoleService.cs | 70 + src/YhCore.Service/YhCore.Service.csproj | 34 + src/YhCore.Task/Jobs/CleanupLogJob.cs | 71 + src/YhCore.Task/QuartzHostedService.cs | 75 + .../QuartzServiceCollectionExtensions.cs | 19 + src/YhCore.Task/YhCore.Task.csproj | 25 + .../Controllers/Api/Com/LogController.cs | 51 + .../Controllers/Api/Com/SchClassController.cs | 116 + .../Controllers/Api/Com/UserController.cs | 67 + .../Api/Common/CommonController.cs | 306 ++ .../Api/Common/FreeApproveController.cs | 64 + .../Api/Common/ReviewprocessController.cs | 167 ++ .../DocPrint/DocPrintStatisticsController.cs | 65 + .../Api/DocPrint/LoadingController.cs | 32 + .../Api/DocPrint/WYSHRoleController.cs | 334 +++ .../Api/DocPrint/WY_listController.cs | 335 +++ .../Controllers/Api/LoginUserController.cs | 94 + .../Filters/GlobalWrapResultFilter.cs | 79 + .../Filters/SysOperationLogFilter.cs | 334 +++ .../Middleware/ApiAccessLogMiddleware.cs | 141 + .../Middleware/AppAuthMiddleware.cs | 90 + .../Middleware/GlobalExceptionMiddleware.cs | 79 + .../Middleware/SsoProtectionMiddleware.cs | 128 + src/YhCore.WebApi/Program.cs | 20 + .../Properties/launchSettings.json | 27 + src/YhCore.WebApi/Startup.cs | 367 +++ src/YhCore.WebApi/YhCore.WebApi.csproj | 52 + src/YhCore.WebApi/appsettings.json | 52 + src/YhCore.WebApi/log4net.config | 19 + src/com.yunhai.platform.zip | Bin 0 -> 81544 bytes 117 files changed, 13768 insertions(+) create mode 100644 .NET5底层框架搭建方案.md create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 YHDocPrintCore.slnx create mode 100644 docs/底层框架代码审查报告.md create mode 100644 docs/开发规范.md create mode 100644 scripts/oapi-sign.js create mode 100644 sqlscript/create-a-datalog-table.sql create mode 100644 sqlscript/create-sys-operation-log-table.sql create mode 100644 src/YhCore.Common/ApiRoutes.cs create mode 100644 src/YhCore.Common/Cache/RedisConstants.cs create mode 100644 src/YhCore.Common/Cache/RedisServiceCollectionExtensions.cs create mode 100644 src/YhCore.Common/Cache/RedisUtil.cs create mode 100644 src/YhCore.Common/Config/ApiPathConfig.cs create mode 100644 src/YhCore.Common/Config/Config.cs create mode 100644 src/YhCore.Common/Config/DatabaseConfig.cs create mode 100644 src/YhCore.Common/Config/FileConfig.cs create mode 100644 src/YhCore.Common/Config/RedisConfig.cs create mode 100644 src/YhCore.Common/Config/SwaggerConfig.cs create mode 100644 src/YhCore.Common/Exceptions/ErrorCodeUtil.cs create mode 100644 src/YhCore.Common/Result/ApiResult.cs create mode 100644 src/YhCore.Common/UserContext/UserContext.cs create mode 100644 src/YhCore.Common/UserContext/UserContextHolder.cs create mode 100644 src/YhCore.Common/Utils/PageInfo.cs create mode 100644 src/YhCore.Common/Utils/PageUtil.cs create mode 100644 src/YhCore.Common/YhCore.Common.csproj create mode 100644 src/YhCore.Entity/A_DataLog.cs create mode 100644 src/YhCore.Entity/AppMsgRecord.cs create mode 100644 src/YhCore.Entity/Base_Log.cs create mode 100644 src/YhCore.Entity/Common_Dictionary.cs create mode 100644 src/YhCore.Entity/Common_DictionaryOrd.cs create mode 100644 src/YhCore.Entity/Common_Groupuser.cs create mode 100644 src/YhCore.Entity/Common_Groupuser_SHRole.cs create mode 100644 src/YhCore.Entity/Common_SendUserinfo.cs create mode 100644 src/YhCore.Entity/Common_UpFiles.cs create mode 100644 src/YhCore.Entity/Common_UserFlowZhipai.cs create mode 100644 src/YhCore.Entity/Common_flowinfo.cs create mode 100644 src/YhCore.Entity/Common_flowstep.cs create mode 100644 src/YhCore.Entity/Common_flowtranctInfo.cs create mode 100644 src/YhCore.Entity/Common_flowuser.cs create mode 100644 src/YhCore.Entity/Common_freeGroupuser.cs create mode 100644 src/YhCore.Entity/Common_freeflowinfo.cs create mode 100644 src/YhCore.Entity/SysOperationLog.cs create mode 100644 src/YhCore.Entity/WY_DocPrint.cs create mode 100644 src/YhCore.Entity/WY_DocPrint_Num.cs create mode 100644 src/YhCore.Entity/WY_DocPrint_SH.cs create mode 100644 src/YhCore.Entity/WY_SHRole.cs create mode 100644 src/YhCore.Entity/WY_SHRole_BSHTea.cs create mode 100644 src/YhCore.Entity/WY_SHRole_SHTea.cs create mode 100644 src/YhCore.Entity/WY_SchZyBit.cs create mode 100644 src/YhCore.Entity/WY_reGetJERd.cs create mode 100644 src/YhCore.Entity/YhCore.Entity.csproj create mode 100644 src/YhCore.Model/Dto/DictionaryDtos.cs create mode 100644 src/YhCore.Model/Dto/DocPrintStatisticsDtos.cs create mode 100644 src/YhCore.Model/Dto/FileDtos.cs create mode 100644 src/YhCore.Model/Dto/FreeApproveDtos.cs create mode 100644 src/YhCore.Model/Dto/ReviewProcessDtos.cs create mode 100644 src/YhCore.Model/Dto/WYDocPrintDtos.cs create mode 100644 src/YhCore.Model/Dto/WYSHRoleDtos.cs create mode 100644 src/YhCore.Model/Vo/PageVO.cs create mode 100644 src/YhCore.Model/YhCore.Model.csproj create mode 100644 src/YhCore.Repository/Dapper/DapperChannel.cs create mode 100644 src/YhCore.Repository/DatabaseInitializer.cs create mode 100644 src/YhCore.Repository/Extensions/DataLayerServiceCollectionExtensions.cs create mode 100644 src/YhCore.Repository/IRepository.cs create mode 100644 src/YhCore.Repository/RepositoryBase.cs create mode 100644 src/YhCore.Repository/Specification/Specification.cs create mode 100644 src/YhCore.Repository/YHDbContext.cs create mode 100644 src/YhCore.Repository/YhCore.Repository.csproj create mode 100644 src/YhCore.Service/Impl/DictionaryService.cs create mode 100644 src/YhCore.Service/Impl/DocPrintClassifyService.cs create mode 100644 src/YhCore.Service/Impl/DocPrintStatisticsService.cs create mode 100644 src/YhCore.Service/Impl/FileService.cs create mode 100644 src/YhCore.Service/Impl/FreeApproveService.cs create mode 100644 src/YhCore.Service/Impl/LogServiceStub.cs create mode 100644 src/YhCore.Service/Impl/ReviewProcessService.cs create mode 100644 src/YhCore.Service/Impl/WYDocPrintService.cs create mode 100644 src/YhCore.Service/Impl/WYSHRoleService.cs create mode 100644 src/YhCore.Service/Interfaces/IDictionaryService.cs create mode 100644 src/YhCore.Service/Interfaces/IDocPrintClassifyService.cs create mode 100644 src/YhCore.Service/Interfaces/IDocPrintStatisticsService.cs create mode 100644 src/YhCore.Service/Interfaces/IFileService.cs create mode 100644 src/YhCore.Service/Interfaces/IFreeApproveService.cs create mode 100644 src/YhCore.Service/Interfaces/ILogService.cs create mode 100644 src/YhCore.Service/Interfaces/IReviewProcessService.cs create mode 100644 src/YhCore.Service/Interfaces/IWYDocPrintService.cs create mode 100644 src/YhCore.Service/Interfaces/IWYSHRoleService.cs create mode 100644 src/YhCore.Service/YhCore.Service.csproj create mode 100644 src/YhCore.Task/Jobs/CleanupLogJob.cs create mode 100644 src/YhCore.Task/QuartzHostedService.cs create mode 100644 src/YhCore.Task/QuartzServiceCollectionExtensions.cs create mode 100644 src/YhCore.Task/YhCore.Task.csproj create mode 100644 src/YhCore.WebApi/Controllers/Api/Com/LogController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/Com/SchClassController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/Com/UserController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/Common/CommonController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/Common/FreeApproveController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/Common/ReviewprocessController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/DocPrint/DocPrintStatisticsController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/DocPrint/LoadingController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/DocPrint/WYSHRoleController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/DocPrint/WY_listController.cs create mode 100644 src/YhCore.WebApi/Controllers/Api/LoginUserController.cs create mode 100644 src/YhCore.WebApi/Filters/GlobalWrapResultFilter.cs create mode 100644 src/YhCore.WebApi/Filters/SysOperationLogFilter.cs create mode 100644 src/YhCore.WebApi/Middleware/ApiAccessLogMiddleware.cs create mode 100644 src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs create mode 100644 src/YhCore.WebApi/Middleware/GlobalExceptionMiddleware.cs create mode 100644 src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs create mode 100644 src/YhCore.WebApi/Program.cs create mode 100644 src/YhCore.WebApi/Properties/launchSettings.json create mode 100644 src/YhCore.WebApi/Startup.cs create mode 100644 src/YhCore.WebApi/YhCore.WebApi.csproj create mode 100644 src/YhCore.WebApi/appsettings.json create mode 100644 src/YhCore.WebApi/log4net.config create mode 100644 src/com.yunhai.platform.zip 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/.gitignore b/.gitignore new file mode 100644 index 0000000..223eddc --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +artifacts/ + +# Visual Studio cache/options +.vs/ +.idea/ +*.DotSettings.user + +# NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ +*.nuget.props +*.nuget.targets + +# Rider +*.sln.iml + +# OS +.DS_Store +Thumbs.db + +# 业务侧特有 +# 临时验证目录(Mock CAS 脚本、curl 日志、清理清单等) +Temp/ +# 项目记忆与本地技能(含会话数据,不入库) +.workbuddy/ +# 日志 +*.log 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/YHDocPrintCore.slnx b/YHDocPrintCore.slnx new file mode 100644 index 0000000..14e6d79 --- /dev/null +++ b/YHDocPrintCore.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + 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..6280cff --- /dev/null +++ b/src/YhCore.Common/YhCore.Common.csproj @@ -0,0 +1,24 @@ + + + + net5.0 + YhCore.Common + YhCore.Common + 9.0 + disable + + + + + + + + + + + + ..\..\..\Library\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..edc7305 --- /dev/null +++ b/src/YhCore.Service/YhCore.Service.csproj @@ -0,0 +1,34 @@ + + + + net5.0 + YhCore.Service + YhCore.Service + 9.0 + disable + + + + + + + + + + + + + + + + + + ..\..\..\Library\Yh.Platform.Abstractions.dll + + + + ..\..\..\Library\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..48e6c0f --- /dev/null +++ b/src/YhCore.WebApi/YhCore.WebApi.csproj @@ -0,0 +1,52 @@ + + + + net5.0 + YhCore.WebApi + YhCore.WebApi + 9.0 + disable + + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\..\Library\Yh.Platform.Abstractions.dll + + + ..\..\..\Library\Yh.Platform.AppAuth.dll + + + ..\..\..\Library\Yh.Platform.Auth.dll + + + ..\..\..\Library\Yh.Platform.OpenApi.dll + + + ..\..\..\Library\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/src/com.yunhai.platform.zip b/src/com.yunhai.platform.zip new file mode 100644 index 0000000000000000000000000000000000000000..775a0d1d993f44d8f4518694c026a2fda39bf31e GIT binary patch literal 81544 zcmcHg1CS;`*YAtAZQHhO+n%;2q&b)gc#CPJ`_ns3sZbju< z8Btlke`RK@im1w!D)QhE7$E;S?AwS9{zv71Hy9u!AZAVuOgVPWcF_5Xq$07?2^#(?twE8Ee+os)^>zm-s``@cUv3QRGm8 z_wwlP_Utkb^I`)j(MXAc)Y#!`U{Mcd=cUs6hQP443LGmDVpDKaLeY0m`W=4}zPpMx zg!UD#Af9mvK$nD+5^UY>(Bu9r$&OM;$I>9ILaJ%*5U@T&Y^|5*?GmR$0!bi56G90~b3cMsjJbi~!o8B6SeX09$K_lkEg2EkRW*pK9_6|J zA?-;;Xt`72t9QG2D^h-@jz}4G?_+@$-u3zCNciu&0js8?13SMVubvr>J6y3BNY}J% zI+`2!ZDvEb1aW^ok&L)Ns>1_o3`+n9pM#Y+Jh2}) zvHiCikc|!Hj-PpJZUPD`f3Z>bjUTJs0w|5L{QXDicU;w9=Vjx@Utof2%~{xLL!Opq zHNW?WLVhpr#Y-F}DqUCO!TYxAbYE0`SM*-np7vKPm%rqFcN^QawT1ioqv*&=y%Z80 zPau=Ch(RCiDIf9XKvr;m3de4JyYA1mDKb4A<`U%*01K95qypHr=oh85kuC@aRo&DSpmrza#~zoBz47aKk0l3j@7l$Zk0iifqPD_*RPS-jUb!e(Fc z0~$hs;7&>nXm8%!m&a?pkVhN*taS7Bix{Sw?ko-)soxLXPYIlXeLDir2$8$QR|OU} zIUKFN5xY)|F7UAs`mjeF6$QkyHN53|Kf zWNz+4)2JABoWbWokWBHu>j}nTK!v8L1|U+}n8}ZSLP6drs?NwT5L`tb8YaS2$W;v- z1SA&$1mu6dp#HlE7x@1v!tL$6j`biQryfb1a@?ih&dJ2s=|<%IxP6qjSRiV6Ni>IWb2v@t&5hL zr$sG*b7@OW>&E%X^UC|;uAbcuJ3DFU^YgdM&)(Ua_!+Odmgfz-y6)!>0=Bpu3Fhv5AY)p)>e0Y2V7F<6*)^+lX@%`0Ujd)aW}7*$n)IK1K`yNo@Wt@U)KY!u+~ZmAnN*-<3B;pD9#q_H zt080=la))mAfr&H=zqnE(oNQQ!?U(ru-9~WF{rV(bg>yE=m(p7;_ArHw?d&yy3Mza zKy~4G3$n$-b@cy`h?wY8%brg-qZP*cYZrJr_dV}0rP*~jekrU2KCBu}Q0T(A5mV{N z3~m6-^2W)M$d`o}kP6Nu0cV|ufmqj#?yy{nDqL=F*Ukwy~z9 zd=qa;y`)6s2%l#{kM%;sV!-S%0kP@mI50xtqE4A^8L(`sU#GFJ{XHbGM4?b;vS=4> z6Q_uNm?u|8T@lJLEid2DzvykA=BTBZtkLr7{wIucqH^7^$d%0jr&uz@HfD-M5`F$y zhIZbP&ivJZv{xCvcao+}{|uJerFm18s3mKyFI>fp(nuPl-7*ZLtIUCe=tMiOnX&1#64T&)F$8a(LGA zJ@8Tm+|bZ5s1Ga`uh}?g#+mq8ew+Ef%$Ye?Y}oCOF!ADk zS?6qi3=~r?SqAT!aY>jIoNg9GRo9K)E<2~JmxUFR6CC{bmFP&>BleG(`c#_^{eZy^ zFUq6pVuJXz85fr0t#&v_X9`iQ7I=IuWe1*eW$@*|c+32Ooam4mNWzmcF_*!2Yr+wp zA+T`~6Kp>hs&h7Dj5;}Sr>z?HhSMdmC*Trm0|^!{Z;J{)uSa!B9ZTLLS3NAdqNyOJ z+x`!v_1oI?MSsN&-*QkT3g@w>;>^ewhyH=-yOus8D{Nq@V*N(Ew}P=tj%k2SUR%;Y zF#3`ZqP-c{@me)bsWw9r6$5a;CP&31LW^X#!&@BU(vHlNQ1zu0jRLj@Yk2n`r@KDJu#frO60c zk1%e<(7*|Sqs=sfIo8Lk1W3*3SMtrGOr?apMHu&BXyAp&(Y7z3=+i>NBlL&JSN)*j z#8T~G^TAP56whtq^LSOA7F+VDM6<*x<;*Yj+1Y#@(&EFGr_M_8?m}B!*C>M@2D;dV zV=!<+$d?*tk?MHbbp46TwXM`M zgRw0!29W9q727BGbsI&#c}qyIlp-yF;v|UJX8G&~>IE!`@{zcKG}f@!BxXFF6YDCB zTDgE)YbIj{eaXb29emayEdcmDK*wC{b{u1l;989Ty71X$RivkBy7vG_Hf*{b zNuqRp-AMAN9Wv9R4kTeXj=sN)p1jX{%MD1$m9LTgadwl7zYhdt~HeK)0if#fakP!M<6!nM@g(ok?@H>j&;?-|Y$1uUsM zY~hDXIXRm&uS$2%)A@!b7h$1tVj;GcJW1(1M^F+Ki96(v#YxbTG(FldO!J*Qonm}& z4ovta)hl|=mil?QA}fB=F#h7E@&zv}lew(q=_w=t*N8UC9VdCO5%|W;-5R_irsqX_P^a=>_$=YFG^;q`x4yzt>u&oPGDm_6y)rL4uW&C*+maM@Cf9PAg21&>`# zO48@kKu~k))Vwh~&(*sopsUe~jG*ZU)FZ2N#|83xO)qY7re@V#mjXjSVWnoBWjVMM z1e*$QJQ98=2L3DB7y=0JNyyM_j1+?PlH zgjn0Ez5fuXJJL2We==a{^Q7e?oBTSHy6~7sOQ;GGS|XRC+4hA#t0mwSrCt*iPFNr^ z>&`hk#xG1&;h&uHk2>ffI>(e+fWu6_BDv!r^f(_>PCE8P8{XN=ba^I`m-uoQPS}#H z#Z?ZqFYAk4QB|`DPVC;Y*LfFv0PREB;*J~iXP@R3-$wm_}nJhzBFybb8yZq zCg)d#J_eDv(lZLQjccAhFKt|-+xac&Y<9-B2$Co*=e?Xz7YLxqNbk8Yim2J=!38uGVnKjbw9<0!ve{{hzO)}T74_aaR zq4c9Dt+QM3k4^v|!!((|ly~^oj6O1um-fZ*BiWwN(@K-yJ#*|eChxW`_J{65-(#IY z!tv>?gE#kT{I@{ic|EwyJukiq^(tFbx(5%4UmoA5-HdtjXHgvR0ay5pWcnlMR9bZr zi5;`?;ZK}g+V3II$So(Hty=Qx>fVJ z@>{%5F8ybg`2B8^6ZHD5_#mbAJJ`pczn5bGW|S$m<6z^*yERX?kUmSKd& zwHyI7JC9RSPCG*%p9>DnipBLTd0W&26+bOAGA~^@aObRgNYIDXEwW}PF4Xtj`ZnkS0TM?KAt!fcIP*&$ScM28>Y_2)$4pJm}H0v(#>b(-;+;4 zFv@M85Xc7_vYZSgByaKLaxzl`GN!z%bEgO#>Fdn4+GLotKdO8f1p2p7osR(Sfbj#F z^5-~Z*QFj<+4v1^)^XL3n8o(c<75iys~m{%&&F5B*Bp6v#bDoY%p=W;#X|2t6@t5Z zMM{y!m1Isahn&OkeiYh=vt?@JL)dc47hLJmK*~?~1#pF&={_46&rtxhDzmYAlP0APDO_OfO zgXEkmpOG!N(BvPdywz<3pyOY(CqmtbNV3XZ|a~ zW%u((qogJ9)uQY19dC?Q(I)^Am{Hc%14Wfu*0lq+D5q}d6uh47HyVIY8xEQLF?S_V ztLM7gkL3{VJN||8qZxBWzIN6VfzLPv=((9Y<{|4y*V>BSD(}ek&mAQH$o+_2hyK@* zEI*x>e$YYX=5@$OikDgICiwXDviEJ|Q91Rm>quzI7sgfgV@GcZ*VzuFi|t#FaSI=! z1@K$K@gpB1$!pzlhZ!H4U8uig0FjyBV_#yfehvD_`iWbLIQJn0NSm$7Fv`Zeaj56E6Vi4RrR9j-E%ButNzIcI3j~Qgj0v$){ElPk@!m|zaja2a|}TlRYO-O!tRyu0!z*kcydi&X%Qfb4bMJ9QD= zIBhz9p|tZ#O15GrE?Kgl#AM?Zf*-{T=PYYGWCW$x%Av%y<+!f%F+mu4^5kfi8_IRY zmYdEsN^a^FbHf(tYYdQR@GX%3lX+Y)?%_-8wG7zEa>L~<=c;YI<8bRgVb@L=n1B3q z<;*mFvAORt&S`oTxz9N6FtUL>_*2Fa=j0eQNH?St{GYF%mRoj)ZkKkJoH9`H6&MPi zfNY!KLIODZ$UP{nspl_rp_gftaNLC1Ohtd~GiClrqVvLLu)Ox|669s{5_QtsQI*js zu_p?*I{A+!o{_cLK@`qOS?I!7NTXHk@H+%-iMFeg6IhW8*mr7(HOHK2le!$F-8g7> zY@IEYn3tyA!qJaIb|>5P4pzvAGctAb7EDr&tFKe|Y-BaE=-oFz2%qdf*!Sm3KO&4^ zug|Ea0gc>Mu2Qxsdt-wrVt@&#RinIk30CU% zb$jg=y3th8MOpbdh7)rfz^wE4pM=7WOQmFHvD_1i87MX5GM2iC98xwhN~^LNnLwQH z(a+wE^Z|Z>Gqm?MD!S?*e*q(my*89yvpYNR>|Q1b_Irs8k_{82xhqRlGek4+L*Eg_ zSfLT}s5fWgjDXZT*+R`d*~0a|Wb%QLI$)w@O_YJG&A}M7@}?bpz1-M zziCBIZn78(X78US7`^rCOvmBUtHTR61H*ZC+)(d+TK$FH&{-Hs=1R}CKyFKngquH1 zb$mUkhPYk}gI!Us4-9#FX>@|t>B(>!^n3g6$hc|odo%3(A8y4jXp(5JF>=rA+T@m2 z`{+vLT8!UFC1fI}HWIUdZSYQg2;g&q^7GtR@eN`1KphL9xm^B9kt1g?H6ivsV^3`s z{Eq7_GO7Jc@56hTSbLvZA)UItj!CE6E|*tC@Ki1K>FM~JPr9h56Zx)>;Pl4bWE-VJ+quWlbnXY6I|@K^%32pjM_Gp$b1KtT+YQ0 z{f~bqvNuFeMb}}rHC?}ql4RE)=cK!+k4TQyd)|FX$C$ZV#fW{lt%5V-I@~gwme2gQ z!+XlTv_~gxKNq_FV(`PQPzt_fpOu1Eg>f!2wTjyKqnI~J16)st4q`c~cAqfm${;t& z>VTKZVq~_XfK_sjd{LtrW^$~y3|ksy8iP;%9X3O6kSqNsXahlG&>VP8x+@O!4;1P_ z5>s3PkzD9qNqvM~vFh+$lG+(zH_C&KB`Z2i8X{j2jJ-Dsh4=jZ35%Zn$TqL1Q7D3XUpn-k8y6mG2EAr`4Kg(e?$%a~aLf^CL}JhGygOgh5O@XG8m zpINWWe1#W3vV{5{VasOO0@Bam6^xAaSepXarPI#Pq6w7$11m|fDUe7;VDB~}lZ|H& z5F?YFVF}P8`{;XwEW^wTm;=NqWUv?vx+5+Ml?D9M{D=If+5b=TA2RqKk{+woX`f5a zS|GK)B#e?%VU=Zjyjhv7E&l8a4d9_rkT_Oj_FgTKoNM(SB0)5+^(;!}H{c#E5zqB+ z1lEbmB5@Pg^GzV|O<2u1&8QDq4qZmrV^47VXWnC%WAkE1ac5cbt-=^q%&RRg%?T_8 zX1pWr1jx;sg#Jr&(#R*=DqMct|49>=g+W@e`oC^Kb=6I2$Zkk+$-Lt^#2tBv-7^mu zu^mzRPtDZ53F*I}`Un4uvcP|o1yZ~cDc-_PaI4^9$`7OWAOv8T`}|ajH0Zf$d$le9 zq}jG~7;e{ItKFz3RNee(YF5vlF4;euf_lV;wRGN-%l&1=O059K?d>ATG~9%0(4V{F zf;BK6Ro+xiPNX%7B3zSHvL=^9i_h#_+G-u z3-lXAmsx5FyL*cBvv1&iSr zKYJQ5Rl_~EyfD}PwO(eyJd2B>%Wo|w@5WUf{_=$CZV=VDdo+xA)RMsDtG9r2C;Q_~ zmKo=cO}3+QOHn7nT?_}hHHoE+JBxZHU+L=0H)G8b2pbrRAV107De!+9q+7@MeNbv z6n(+#AH5w~HQbL?3~ooER`NYCHW92qN@E{KN+uXOXb1Nv7I{gZiv;G0J;+%~)LXq? zR#dwdZU3ek`vT-#h&!phXa`CwnIKk2A`$$yjCPT-sQuNI&fgU8+Amyx7{CODMTA-< z2hH>Z>52HZcMDE;8&~<=hl2W9 z0lhw%-z={l)lO#lq$Ar=yVS)Og}AQYwe`B~%A{-xAs68RpY+fL zM)o+vjoI-(BE?ifD9_AS*QXVix!@P?;-p5}G(qO#2JOAfC@K0m%sruDElDagd&&^x zCct)@BMvCE?xgFZuYMIT5~`*5;vSsERHLR%Y=x-YG8)Wy^)u`G@BnFF2Noq+aUNDD zCXJ_2G2rpm;|OUJ2UBvOoYr&XSDu@RqOox(^AfT3Go;xVS2awdThAJZt0t^bHFe+c ziKwP=0>#*jK*i0&+Yi40H~KpL>tE2dxyoteT9gMRTm@$2-zG|IVl=VPegmmSuupNE z{}vQEb@I1{)_pp?@)QQY4JK*qTm<@n&hGd@jJX!(eYUfV%^lY(_ z&iiZ1e5U`|V#fM9E{r{D6RJ2X)hx-OU!@RNJK?Eu<)xXrBd-(J8?K z?SSRaFb||DZWAvB&geYv6OSTTgO*;r)Jv+c>pGf4zmR^Vf57}a9FK$QzN0CjvB2&VSXz~9y0~8GT#Yt$P@AlG)ns2Eb~?@!cDH) zRAOn^GzmvNm?C`8Lb6X8V|k6NEq6pj;QE5qyo(mMSSYKk)nkLPK}Ub?4lyZ_IXFdx zCo#KcAb}&$XNVf>K53FK<2PUoGE=5@T-GIqedWkpD>=!|jcu?{`UVF@PR$kHw!a~S zoB+@JSK%F&_2ZjgsL4{H14UdyIs2ub1X`DS6lOuC`EW?wGhR${W<3$QzWb0EnSCrq zbS88m7PKMQx`_5qWn{brNl!QB`h@nTnX^-4~zM#{FQ82m&((Wr)s@B4bly^MF()Vwot?{uA}G^8lCr=3yvj1=#Ea z9q-pmq)VF(sBamgH<6$1tJn+)#fwYVuJbH<#v@H#(Y$neoON@pt{^*TG3bh`Usc@$&)B46eMWZSi(w@u=}a4ud>9nMd(wa9&=S( zg51l7wsvk9^;+C-X;h3|454A|K(ZBT9;wU$&$WN|iy#?s5RwT-k$;F|kRD=AOZx); z_f4Qt)UYC}J?R3y3qF=K%SPfbXe=$=LcF}*NQGyl*oa(cPG-X#$~g6NCW`8W|GN;Q ztCY-pX)|ajqZ!iDZMKRs1(w3&v&DVzs%f9p8#pPe*d`f2*#1&uw7jG3a_o5S(#))n z&E>g_O2r(gNV|9#D+Jgk5Oy0!z{Bqov?pGaCDu+pX1J7Xm;u=^!*m7GliK-RqLp$& zdo(FSo_t!R5OhUO`WFXx%4i&ikvbcossv(T*&|qj*TH0NUOoyRsemT-vZeo4$vWsAJUW0l20G^ljvrd2!V;h3i8GuPVGbTw<{%hGK~HDGhRO0dUjTM^@mNE^L2Wp=KxnUJaP@^XkfK~c zU~n4^FZ5d{Y}Ck=J33=B60Q;3#~i0vA)hMJjev~pD0rY;i&X`}XWW?q`;a@ItLIQ4 zbHT>vQcfFl)DRU>^q}$Tg|Z&oB@r^9XwM_KtoCY6BG+e0jn@&qFI4Zci9wzybzO&V z6qacj6wgL+v(=<0lMI)fgs7{8sDBP6Ny9Gr5B%s|pBg^g0S0F}f^AW1HH`g3QTEi{ zk&1b945z3o;TP=&Gn&i5aDO}perj{#i@aR^r=&dP^ek!p&5#=m8B1XU#GGE3mgjyD zHtMKdP!u<6GtY$MzWyO!AD(O(Ya@Mqe4yea&Vxh~-!5ucBZ=1~T8`L2FHOu zMhFXjJt;~Mj+#MG_8g)y5}HzXmlpdT5|T-(h!%&W-QsO0La+R7Cw8xAb4%oRKEqC! zCs>8%;PXaliyjQA^gRTIxXtWqI>_eWG_qJB~u{)}05n)3<9z#dYF*m~~%Uzw8^W z5u|I|o7aaH=1afh=V(FT;DfXhv`iUrtNyz!(#=v3uzCOI^sV_`sme^pI_PlS zREK}{im4LvgiD=7oKQ>(HHTihS?U4N7+Q_vYgFK=xDEF`Dew^F?XJeDO=#D1b!F92 zXp7xyap_0hKoniNrpjn9v(=5@!`j8q?N#^4(#@B{3EjXsmQ{^v8ZGt^`T`l~q-(wr zJKZk!+2W(la+>Qu{;On?D%W&^yW8b_1nH)lcR|uc1yFX}>#URcH-^kD)x#o^0>;W| z9GIcd?xJQ-TAh-m3O4^fH(?(#&scP6#I1py88p}7U>1ZS3dc#F_~}S0%Qy%W0>+gp z7{k1vV{KfnF!H`Z8r&PQ;#k95amW7JaWIg+QCF_*0jCRz2aZ_ddUVSUWok%ta}F)W zcS;suEsXUEqdT=>i z69J-W2+vV}Ffa}GH8e4VD-~2Ez^XYwsc1a?)K-zAa&K6Ii}~E=RL-4sFPBsz&v9<* z4}KyoPj$8E5Z=t`5AOa;!?4v!8nl&MFyRDmkDT3L#j?KqSb~B7;ci@DjA&Uh!teg; z98bu$yucSL|l|q2%b^j1hD-N8L_IIGi}R2{}v!3h&1#CKQ%=r*g|5rd-z4$(f}!sO8U;Af@9% z4bl!5tjg{V#ztFty{Q0^P99!vxD$g&?}XFN3iZDdKfvQ88C0t8^(Bw?$!k4-nKLYi zrB|doxHR-`#9(?ZEjy+#?O*bCS0AF+W?X9P5(l~(@5gErpEYX&kgTJ zrX+5@6&`D+hI+~6#L&CL+2;!wb51pRIq|F*TP;9(r(X@KF3=;D+aH{>r<>0k|1y%b zKCu4$m-2~Osr5vrsmwpzv@$O#cUK9xs~-NPWb>;!lpQhCir0$xx^N$JC=ehI3eu zWQDQ89w0$#h;9Al(vncA!eN$s5G6C=Vv8FKsnG1q!*cCOX#KqKIQ$kBCB()@z%rnI zN#sRT(`8B)9cgq-=D^@c48V zLR)qo#-x3lraq?AsOa?6*4A*L!SA^^m-%BgnQL44K;k(ADTzzx*#{Dxf{5((DkWHL zG3fTj9}z$=;WFR&U=rgrr)ld7|AzU;ljLCNte&3TDdWL!cd%0nVz2C|UIk*|PpX*bVw?riX!%@A{K3-{~qyv~48uUB;1PL{pLIs6z1hCrhy z3+(cVMCYJu9Wwi7#MNt}Txbb^e__QO=uecb6xi5?ypLOgxi_B1AGT=m2@|#UG4Ln z%YX851$rQ!ZQz(~eBR|CxnMW~v$_TJMvt`_9hjpDzBAvtulVcYSj{2xLfnSnA)uWJ z;_aDUs9X{H)k#NBmFV!pKp9FtuUb9n(@AItoxZ)>ho(+4PpIokl@Pr0gEa6nS3Q0q zaOz^1paWF8l-Qg<;w^}KQQ$knyLboFU-+>}cpmlpyZf2WT3C5wv+rfrJ3Eh8=MULi z={x+J(cgK-T=zk1=G2ws-nt#vuI-4|7Z+zINk6*=d*mO*Fp>=%Efvs{iyI0T%Tl#% zvJ;_=hJ!b^!uq|Z8J4Lr_-({3-${X$AOWd)^1peh8ROx3uOHhW+)r)>RnZ3cVuXuh zXY3v$|=JdgSLH?1BEQ4T$j^0t|N~ zpccf{_bMS=m1GqAd7Xmf6oLyP<}ld9Lh2c0X|d%OF?9k#?sa&aWYBCwURquSU15X~ z78;IW;+{c;B*+Fw{4MLvI+?yXdj?cj>06g~74+N>;aXXzY`T*~p(VUILB_CXpsl&* z*aqms(!th2zC=r*L*` zSfO{qQ{}=Fk)NOeP-5MWNkS@-hEX`=E8S~jMAiUod=`5k!i}Ry+HDVE6#NaS2zW4A zkN_z&)`EGVstB!!X`y`~tjJ=JC0H5=Bh()dJ?Li03aARAQ<6E>LUxgiU`N%St=JNQmGFs`*A~JBlD){zS4T$CAM~)@WddOaE<%`26T4tp zu{OMs=35>7Ay}n1UUdeTOO8!qNyz6^?jnYG91b0#*fzY$O|LU6e7XWNZEqVS2zyVa zIbZul@<^nI51%HrLWLFY+QS9*4MgvJ`v#L^8iOu_xCGVEt$=#|vR@M4}t;YYqUk;=bo4M(`Y?dQ z8@m{ppqIX`4Ju*sq5KY%qX)UUBIX8S3ESH(X7unk@w}tM^M~Cn4zWxMa^X1i0#KC0 zheeS{EG2RQXu{#cqevv0f?PcQ#^HmZNTirDItOgv@F7qnQcj_s1BTk%OJoZ~QxS`$ zAeW9u&I1Q+?om13WU~D+6os;>{zg;%&7vX}Pe~{phve{qQ)DU`SGRFRRm_)4DPIKQ zasV=^h^14k7Jz8k0>7v-7JzKo0y$K(MdL>tfDkI}1t5Uq%{$wlOfg?NMY(vKdJ(8@ zeP7A(#*yvMrvh8YF$8FD z-+FNqW&7y+FIXF^0~-+m7X;A}KL&rQ->zeB*tn>sZwT59i$(XiG&70`e~5GT9JXDm zgAbX_LUxg@unGK%D!${kwdOIscm5saoe|v(sE8}A!0iswF7^|+d-Vyc0>nO z?{SJMAqgO=3MH8Rjw}w~;etA4@4_d8CoXowq@^L3Cy{{Dt<|Ry(~WUYB~>x+8&Eo? zpgp4bL6T0Ns9!RcMiZPwZ-R?it|YM}y!fkj*l(Iq+;TQ&&gA(1imN~k3R$4&=WrJT zEa$M3aV>x0qh8ljTeQgoxq?p)zFpmE&#RtSgSpgF<0dMt$~vCftSl8hH0NnZXj!i_dBHuDa2O z2fg^tubi!~N`pDNw%I$vXMVSs#OA=gY47rKPu~J++y@pD z>NhR}IJ#fFlkm)A%Ak`+&H}GvU(@gRg6GT?;`}zxB#(NFmZv*iyBpUMgXx1N$%Q?8 z7r#pgrH9=P&+pNNt#XQ^)aCqycRQuA)e64rk{-sm!&+RU?3`U*g;c7bl`;hc)hJ*Q zT7%4=C6*MU$*i^`%^?5poHj@^=(On+4NsxJj#q121=CtTW!VAY_HM_x5y}`bjcKM? z8YXyT`6b7FF-a2>8DIu8cLt1`U3fD)?X-voc}Jj6G( zQki@zlo*T^{{dXo85y+k(O=qkPW$1DbuAz2VV!^U$O5*HCq@d!mb)VpO0z5 z;gD_B2`9X=Po@@?WYGgO5>!#CldesicNUP7y!qFTix9X`EE1)aU-f*kG3PB|WN;j5QW@_&dq z@Ku%(!%1^Hd)**+J9=&>eOMW&d>#6yC$x(OciTtw-OjlgS^B-U-RR+jWGbf|SL4DV zbM4ZsrjW}VIxh`>?yF+_HEKJQeoB*9NY@bNQDb_o=o)N$uQO3uo5JOtXLq2Vv{rHc zE}?d6%FgGA<8^bGKB`>)`r~>(<;Cbno&3fRX>6m% zzfkw!Y5}2_N_T!F2%VgL$7)CJ--s5Btf1#*FJYSlZW&MrEsHXGv9t)RQh^Q&jsnkdHgn*OztD6cIe9RNI7eCvXLIE$ z&yU*cJmO-Xsp%x!;e;cunss1}RPN5JzB_k}IRtZ)Y+!9tFdw6YsS@{XY^&kAAP+u} z*SZ@hwGiDMfklon5Q`ElCee~j_0DM(Z#CY8xqIKit*_|4b|6r`*b$zQ;^;ng{n2Ha zCOY!_I*zX~)uqFvICaZiP>1oetE;=~U?AmA4OBjOR3%9tbeoum2tEW8PE{+0f?20B z^_RM98W|*pg&d|PPDNDH1UlBM~jJ>E|-Fnsh)kI~AD_&RUw@sO6 zB|4+0MciW3!=bpcb80*D%qjo5fF928_FO%-nMrEr{n)V=_;PrMHG7DMI0CqB!lgnW zk8m_8zbolQffJ!!H;tCLuAI1kI6pkP8h<)?dcM0k96ovnxCJb#Er6+gd4KDh6oAww zBp5l>(hScg;O}LmXCGW;48Wh}9iI>fQGnU_){+>6iRD!q46q~G`@vZN)F=<2NOWu2 z__fcnXw%Dz>jf%F;Jtb%n57t|F-k|G)PZ0=M9KXM6KKk4dv9TCF2DRNwS(0X zCuGo_Ud=w-fNP?~c!Syq33y;GX%(^dMRPQ0ge9YgiVq4742VZa_=@bB-Qyp|rOdq! z-R^}!&{-+?A7sjm$sj2&p}78J*t5mH4Gi1+!s%aRx_wKcEx1uA^3XOL76T}d%B%Z^ zQ(3?O*9|Mtcsg0Js()%kBU2{j`57>sbL1I)(p;J>_S@!hJ>5pjC1G%94bUqojb(PE?Az)c*_A}Lv%5{W71r{#Hg`h;^ZZL#_pEN2ThZ^X{7gb9iM z)*?j$Kho6N%Z>L0eezCqRCK5-aZfW9BE(>BN{B9t*TJ?^dU zV|>{-++Q5R#ubg@5+oQD)AQJ585~k0HuPgo>BPMm?(0%ZfIaCDgjpvBI7QaKq6^ED zrjAn{V8}6!ubVPIk4tH58=n`T7`1(lpQEB`YxU8Us=$?0g89&u0^I%PX_tmXdf%MV zwhGd*JKHJ)I~~$WNR&84VXwjrqSZvSlQ#M-5>EElzFxbRIjve_5e%GN-$nd7cLtO1 zG2~FICDzwb`e-E4(^7k$qJ-`0JGGTKiBX81jx%};t4?;_+qg@KlXKFc)MM0yOpNc4 z=r;p|JQgzUNF9xOZ|y@>PI6LRD-_63Rm~vfP>&~$yxqtf&=n1K4j(j&RUPg^`K9LN z(<0rmp5t~(Amzd0>7Vrnw!ci2(!0)}*sEQqZu>lX_ywCmUUhW=a-uO3%lpV-Zp1|- zP8v)BL;0&dX zdiApFWJNk8sLE)S?{rnX4_Ht}x-Q-~Rou#E>Gk@8_-q+8OMBJ!Sr{8(Fj-w8rD|Df zg}PRu8>Pb)utmM>F9Dsb=cfK~i?7%aw*0zbycX7R<0kc$Qp1_E zzP&U(*{W1J5z(5y%c*~wPMJ~h-}u}D#`rR(ZU<|azFN!%MhEG4lGQ^oebh7u39pSm zp*CObPUlXT1QR?(DQ%APL$}CZb2w;u;)Mu8j)} zD0;p06I=N_7Z($fq}sx99Zil9#|eKr%l80Jfzm9WB9)PARG8%p<;dfF-4 zWK%#7B95tVJc|vWBW!+8wiYqvwVDwxD zqbz2G<9hyPe#7xxy?Csiv0y|!lKdiK$PqTq;>vs%jE)kef$vQddJhV4JY6vNt8$TE z`%Hy*6#c-)w`o8D6@|x~M-9fZA>sV<&g5yb%716~5pkJ`s7xH!fyHYdjs*_uic1i! zp=tTVT`}U8G@9BYte3c7ol}xJSWgly6h0tWk*gT|KrzMO{HceACWmp3 zx{%Ylb`R5UlgujEkUnheF6`XET2FO*e$B^7vK>oDu6FwPZsgNdW3O%57L(h0pcT>b zLtx6yv~;S+)uG7uN@>TH>s@eNta`bMk?z$dG}kG|&FeTox1B2?3-=e4WUmyUcyTr+ zgH41KL27p41=?IB)bIc&sJ^l-lasIr(@SKpBTS8 zM~tQpP!P4ALvlyC?qO&#ngScx*sZq0TPU&8hu(;PF_42yJY>#XzI4*2K4~zzYo2+$ zLYV6wMch=#g@DiprN#G0mZuJ4WuLo2s=ipHQ-Bs8{2@X;aT9TGvDN5}L?BNf(YprR zwi^ifg`l&h!t+|w)cZ4Pi=xe1$C8a_LBt{28zP#MWsfrLCo?_8X5A6R7l&*sEK}Ot{0xd}hRm>J+SjG z#a{T@-aVZ4O&wp)9xVaG;@i@9uiKr=V5uu_wM%cu8OtpRn~n#tV{_0J%y}iB{qwI! z?#`tuGjVft4HNz}&Zi#FGF3-|OTQdmXq2y`v=pipmN#b0MKGzA6NwTI2O;Ylgvwf7 zlNzcfQK4+Zf{|rT`%HAgE3uv)qY8JnH<=CnSv9J-95#XVEwtKa@Tj{hxqqU*wEJPR zSF+C(a|xys#rhkR8TyHdw~6K+C!~XKCGXa=YdN*^G}4n;TP>036be5nd4OapIFL^A zggEiUEP)OlZQ#XH3gYsOEeReFa@u38d?2cm>K3N4JZuE$77qa0BQ@YA^D1v9%086}>o%bRlGi zNf#~~YRlxnC$lSeVUNIh?Hl%!!PV28F@t>wKdF*F%1gX8g#bz*F z>?yQ25BU{rr3O)i!HKJLC|=gEIy>D(_ko;WDKSD=>0s({r7q)9nLs*rYpq~0irAUx zkJ+%CGRf8f)s-`eWO`pAv>3O252h*N*c7Hp^H}~-P-U7N?3{@wkx1&#mswG#&CGE| zXgpDU;J^6#nihBC!Q$8ipN&-&hDb&-^8U4c{O?wk$EXI}}?gN>X^Z)}S8 zyyrQ0BeM%)7|DE65|u((5xvap<_9#oz&EEmf4i~L`nJco;0R?mGrV{~+fxJzr9DIK z^)UU;HTEvdH*=ev@Km*42H`~?wc2|Q5WBMsJj9A=^9m|zo#CdHWUZD*2i3z|AsTsY z*fuNO(y;hX3_|9^uPZ#d;5g9<9Zqgde#n?ht_#Mpur0p57`}S>NXPXF;fpLZJ-#9U zK0^Xp)7(OFFlo3b($1+JcOD~nad=8!Y&aa{wqy6Cp=tv(oIZ1&s%|2AZZBe4U+Yu6 zo%g~`ODQr9*tk>~>kp(1Wj1m*xBnt)aS3$6sbjnH2rO6La$`Ro2!KUXhwH#+=l&_V zp3R;kap!M9Vi6)wr`X?5((3%=^mLwio`$gYm34xhqxGg0G9e=NBAw9LOj<1#mbchM4Ms0=)vE5gBD~4g>SFsFHAvrQ1B&Uu5?P zB3Fs1&eY97z~pC6A~0Tr(U}&q)W23zR}+A~gwHBgVG%1E?!gE)fOd4*5cP$`9_2U6 z9l-LpjCf0lqRWk%#}w<=9z2-Tb*r-3PUvh{Kzn#o7-TSZ?spdq`gUoJLbsv8eBhqvu6Crsba}-6B0IBJ;p; zpsSPeqr<(5Z59WELLO3nP1+d0ISDkhU$93<^LwpL?>(OcP3Ea_yF~IT&ayS~D3g0V za7-h>oE=TLa;%EUJdkF!iDQs=%+bwyg0Eohr|8E$dcJSEISAg6EZmI^>)N<1V@W zQA|D1&Yd*TyyCT#^C8mS5S6*vV(M^>TaogU@W2R_rrePhRsAdFUKY*px0GduU~l!4 z$8)sTi4mjku;Pm%O#unugpf=|JEioI7(UlSJ=2B^1;)P#J`1c%ZWGeGokFY7?g^^p6!7^EJ2S)nQ}TB*roh5V4qD~bDE^hsUfk$7ctyW zw@R58w0XHTJU>pcSz%nq^J8qH`l-&KE{+AS{bF#Pt-z7k)4DB>nlvn`rQU{S^FllfLHa8E-6mbK}9^yQ7`*BMHj+wj?39lHIqe+#z9xm-L!k-9ntrVORFMdnLEE?3V*-3 z-a=(7zPfsGsU$bHc74cE=0`5~T`Gz0$!IMd^{SYNI>C|qHGK8F*>PU-O2Vm0o>3|ew8*xlD zp|;>YDpk3}ZCTIc&4L z9C<(7WMlZH{^IFJY!9@KFrDWU8j{b(k)cbF#npk=XJHQKZox9VVTGKP$CEX)pkeq< z>iB2L&uS(-J%dMCwh+6b2fp{-v@ z?Cr+C4(E@5zI$dT?cJ0Io$^g!TS-R;DpM@%PVn@cF7QL64$AQqM+66B($-Gav$R6~ z_F1HyrTXm$DfPjgY$@?AAtg&W^9R=A)+E+UnfuWw*8&Snp85nO{8Xz%{@cQtcf=dufmDR zMpKVn9Hz_|^;@$i&)as`z6=-6u+}4Bsa6lkwPtPf{nL0S<)9M779U5kqfUiG{&^nm zwpj{HmCyQ@*ZrgAz4y%+^{3L~rz_>^ZVzdwbk_n|KM|?LZMy!bcDlopbL{@ZU@>E= z)DHg#U`2n)V)7U@df(P`jwD4y51Tl(namCe@h>*n*H$rsQUXmpPiYs;y0Lut9jPi7 z{nK0hFLS)-a~jB^)V{Q59XtTuTyx@BE1$bIA&4wv;qX1~K+TGXw=(b4C*Yhm1- zUEpZep>AOHNS?pY(evaqRld!}kdUs+`h8JJ%*?3jc2Q&Wy!FtOo~c}e3TUs4$SgW} z3n_fiZa*fL;u<%vI!7`XoPAM1WdTQqT^+j#Gi$0qHY44w`238S(!9W=9`_6rKtJqR zeT2rEszce-?Okr$d@RTyNhy^iC9A|{=P@o_shLLSGMx)~vhA-@lAeQElOj82#bKRb zb)}^_d}qINuu(tD_oU%n9nOCH1Op=!WlqA8F8 zeclt%!eaSpdN>D;jb?Lq-D$NccXccu3fYBx(=4}fP$&RqnsKKuP#fwEy6Ua3MnC0j z`+S{E3YeKwk~ol6(yK39y~&3=+u^PQOUmu#L1F-5jfI&_f>VGAUkw4n)SVA@7mm(o zzsAo;$xQ^g&cu5rCTjhGOZ0nQyE0<7&r`UKAO;G~7_6ERQumRmv6$Dlg}n4y5I_0= zdE8kCNo?xD^XKqB_!Wx&J1eOlQj=0_B0Qo`}%XxOKvClj>+k{ei$fQmK~D$t5DmmLhDq z4|k@=uq(#bZl(oZ=yh|q3PhK~p3$p1!^>kPmQqc6yi%vo zXpzt3lFhi6`gzgI8d||b^?Ip0=K>vQv@@i{Yd9t+yu^OCy`d-R5Cm(8COmrePh}00 zj(G3i7C4&J!`Oi#PSoSo!0x{c7;?lGNjZ$}6jB*n~ZVZ@)*KNNe753gtpX zC)9Wb2s{XCAUk_iurNsQiEn*OZcjQ-ljDrZ4KoH04FRh2R<0IiG@VP@VxGiQ?fED>3)x&?i$W!qnRoyVjW+CRoH z2xFLgYuky>rWxB%OpnIGFYWH;E{fZbJTeJF!)y6v5f~I5SZ%MnSLZ^n4iFKm#pF9W zz727`uvh8`ircQXe>~cK%jHC+_2bCO0g1u>z(CFl-PI}j14G^T;{ylur3>wL4-jBM zZlav6U)o!D2K@a1Xbu`B^Xo*am9L|k;WT0pUtJ}Z1!;~Ruz0p)^QYrc!2w;x4GoK^ zS*3oU5G6qH2g~bWUku*Q|z2uNc+2FbR-|Dp zOh&%OVh$K)6aWyvy7BxZp){bg)a%>HIm^I+d-+O?La2|tjhHY8dAX5#`uTXpTw<|; zsz0uJ2O7OQ9i0omUm!_Oni`}PIrN)>F(5kUNG$zIv)~U`X-pSl#Lsc5?6G1h1avJ# zh$5NLwfj4P7H58l(j{;NKRAJcaZhZM4d{)}+=F)uBTl9TcTvI+QSad0X&GI+iP>ef zi2K3mVbr*wM~H$b`)1;<{lzoG3W0wpg!~lLH zBVwo*M=6}}PgILbY9WB4C-9;pP?8j#Bc?WtV2u-FnME5-i_2s)o5&|gb43Uv`jJcY z9nWkoeEw84c`2?k75hh;7A%1s9vgW4UMw?Pp_EBIr-=%I9X`bwzw&C*gyRiCytZMH zN8C_nSzzNRk&z~Sf(ZKvVZ{#t<%tl=krc=2Jz*1+8p%6AOBarB^$n?Y->xq>qWNI`P_1`N;lTQTAoe3CLzGO-nW5h`r z3pN{olmEIH&bd6=Yny`b|FICIYPhy&<0b4JrmYR|`Uva1mwgX~a}gcYGbOxF;&IPV z`z-$a3+@+kVjNe0dN1QyIuEA5IYpCIXUYKGidJ57X%&DAaR+9bJ`v)Oktr*pkHm&T zLo5uhh#kI0s7)&52u*S5d@89W)^Yll_^S^kLc)YW)hK@+`V)WNORA;GGZo<}5b^Ow zz;f!v>QHe0(>Bv^c#?_en5SK;f5qsSoSSb?T0CWoe>!C5m`hPEhOZ}T#kbhO&)xg2l-p2f|j){RpC7CoxDEg?m4y3b_LtE6@KKRA@#(9YLL?FwaFuyQA8uDsHjx^-0``RGRd<@gf!dta5*I zj|5HM#;9XT2V;*;SEzFt%T2ItjDE-v>1Nx^^OE(NJbom4b%{4d+r?IRiyXXh7mCdK zp^zI-9$xOK3DBr^Z*QC1vEQSjjDJhajw;O(7N6Ip-eA|KqZ%pUhg!!Ywx{V#*;C^z zmSntst#S@!Y5gZN@m~`1I(7=IbyR#T=RHfs3rtwKK(JE)YwQLg2nQLmfJO4 zMQIJ=1Sg!@5j6J1uagwplHbySTmUyHcdgMGl2f3`j46zHuxF>sPcc8lzUq0R{`7uS zZy)gQV*jX2+*Ub5zTf{@+w;!@Q~c+F`D|_ZoE%O6 z_84jdhrd$h$AkFamHBZ`O3;z$lf?=?za#T@MQ1vv0t&-Bp0#f0oZ4ev<#C?!$LA+4 z3ib~yLO`q0bU07IURF4mWCMrpH|@zZE$ z9$xyQRMOT=QzNB4`sZcjC}F)Q)$(vjj~9>OAB(|F8a10Z+|ZfkgA`305uGlI=(o7T zUHNmzwE%eic&YMgh@Vcy>RR;%sK|SdzOejEQX4u^(Zj^Xt0l@dQi0?6o-))m`=m!* zUzS(suMmeOJJ&ZpNBs@g2%4g71PB6>0E9SD%;$~O_Wt|(H`3BKLuJjW1DW9-Zr_>4 z9TL&y<(H6C69b?_Q>%e~*{E3qo;-8&MnX=?-9=Z$w7fcsHvD|;=WbpBLAmXPMnO0m6QMG`FkB_m@<|WBX zs^y+OK(}3cZ-qi}eHAZK{o;G@<%h(!O^VypH*V>PYm67PDMH@PQK{|J+jUG=lr$W`b0m4|?wFQ^DLSPG~yRX)z8 zlyq!mEiLVMr1EpQ91Abac)hNcAfIpF>d>_i(WKsUxx38qF}yf92Q7PtBsi?vtnx*Nd zKvgIZPW#Yve~DPQ*cNI#AE2!9t!)S45IP_YBsLIS0qke(vOa*8<_??2WfzRyn9Vsn+CLZ8$d*Gqd>RP-`tu{o z=0%X=jUX(w9sfsT zN)AT$0+wb*){ejN*^heXA3n?R?z5B18WxCrNZ!*mXqSn+%lDQR{50}}>Vv{M;UkU= zbO9MFPA1I=;(X-0L7O;8O`v=hO0kRnxm3ls!^>8yC9j~<6PC?wr;9V>jyUOA4Gdck zQ!Z003NZ@Ks8I{8cnjTuOZ5!Fmg5A)xy*Y*5PAbe#=~LygG8+$!K@u}1J5`%-ArfY za#yOL3jKApq-)j-t>!7APN!i&hyzpx317=V`jz^ymj$t78DBI>CLuX^DD>>VH&tJ6Y_#C1{T%vHOC&h7Zj# zPfI19N*gSO?vdOIwg?!qxj%p{PxJX?+M6fy6BjP0=IWN4zrR6BpzSr?zKyqK<;x}N z;VlaeAeYZcMo+CDST*YBlkwNx)6I(W^p&3n@o&!?o6qGvwf>M}uyOg3@GkM`Afe~4 z>cRrR2Bp6aN_t-G-{!_P*MzWb!Bbu>Giak(^yCoJP!6yLg94@ss5OZFL-^;(WS6P4 z2K@2VAekG?;epo6zA_jxb>{yVfERNj6!mpTfA6MzO6 z5)RHT2YrFT@B}=gNuJh}N7{fu#sN4p`d)oZGm?|HU-5>1zgy>N3hyODK*5w#Ok#9x zTkwi+4xKSDO^lM%t5vb_9Nsu`HL?)d4?LBBQ^DLp;Fmm}WbCVr<#PtC{{!zxx|VT_!OO z`zzSF>aQ}%ZbCvtI$T*B>!_+L(sDc$8I(o(g$p_#k8u`Sy6D;ojEdwTkjA{BJSWi*{}PCX7+k}3ClpB4Y})^d>7r^ z*z^-yzbgGokMrft3etkCDMT=1*5t9A2wvc zTd$Z_Cf3ye;Z>J)@T!^)Lm>Adq6r(laY7#ogh34nR%HIx=&zNIqL7 ztT7sWyof(sz=*k1>y%z-e>R`Y zb^Etaby%5%NM3L=o^^2@K2M!LPnp3`mZ`OJL^%~g6G5MgO^`h|=53;W;iiFl?8+>@ zK!m`o=S~zY_4l{chLbzBFo+RMTq?>8M`ZYvOOYkrPs@xXG*ettn`K$_>niAw)FejQ!1OZLF*iz_f(3xmcvE z<9xwjJG?1KjvOpZm_uBd(QqjGHuawxRHw(Yj)%UyTHGB3E-!?fFSQ>LaF6w-TA zC)-?+Qj+UwXl=Y~KY-wY2+G)9_a}K#ehAQJgu-I3z+A0?5|)ym+w=Fv##|Me$M1$1 zAfzy>b9DUdCc(;?Gq&2QiQQQKQdIxahEeWKQXXFXwXCvmC-PF3 zbG|p$y=esj=45CViyh((w*@KA1X|`*iOZg~$y@P*>}N3SxxSU%#^A4xwcS)|LFOuV z?XxkoA?&2`B+_xAQofwE#p}m+L|FHG{sP`uKU!>=({_284B4XZv=zuQX|hJD2D$8I z&2W+)C8JM7_bk=y;gHoe7bOD@GsrjC^hk3E9NrL}yB|c>D5~#X?jA7jnT__MnTz*t z%ocrYnS=@FFCAZgOyU}E*_7N8XaDkg_MqeKp%FU#VSUdN3hzTc%LWGuoYQbiuzrWL za;TleJ$%ojINCEs{I~l4h9oC!%I|P-1NpCTLHehh?ssq@X6(ov^w)547Wr3j z@#n!Ae}hH;GPu|ubmg6_7}+Zr**lvV82tt`KA!k@&vj+sr+%sr5gz}8fhm!?J#Ri@;6WQ zDHvjyujC1d&c}Z~>oh|@R$vHmlFO4d)m8R~prMjbz|?TF8%tz?gp-(KgfocN-G5cWMidu?1}KT0W|w748*@H~A4`sdC-vwT5cFl~Cv zqT18=*|2;6AH5yhO=gFg=g`k%L^UqPd#g27EmzKEf?&3H9Xw{grt*B82R3#r*CDDO zUI=UI30F%TRvC7r#&h${j(3B$G{9f?yId}*QZJ76S=gCHJ4JzgTt+s!Oe<1vV^-p? z)JY&>;h;pf&_;AE0`NSwP`|2wrc~?_rb^{HdUQz@TUz@`Gouk$h*C?{Wau_Y2}f=v z3?W1&#iM;O-mGGE`G~ri7!2rt>PJW&frHWfV$2!(=U%u4b^0Zusu9w=E~yJ&`}$D-yi5*{We5L;$QwDlJ1 zLuZBo60XbB>#ydKSY47N?G&HQ!v(LNwGO@Z*uL1}dm=0^^pUmaYItLbx!%G1b(dOL z%7{V$F^(*axH32BWhbofx6WQ7Z2nVZCjN~#{LjGrHhLoVHcqy`v46Jz37Q{w3aY6I2^ENHN@}sm1&68W zQ86j%(TQn_MF*Kl392#4=7Zfai3uqxYEemPCDEzrg$l~aanXr+iV-nNv2mKQg{w&! zu}QLn@&kDQATKX3(2U*L_4R>#agNrJs|~7YmzJI*bN9{0tC#HP5{ncrjau0^qpR?g zIh7-x4E8Ibx#AK`%+2#3ZtdQO<6VeIoe1Ly$eEXj8xfZhQ1AbHuFnM0YT*vv`_C3; zP8bI5XAbP@x1!WS5Acba(_e`raVj8{BhM;GR3eA-2~wf9=kLZ7$hzJGc1iLQ&9Hg| z`3~gIcOv)~`!575qQDs$+j`4=0(9a|8zf?;-C8AMH8DIW1i zC=D1M5CCBB{rYFt`)4JP{J$#UT^&xAzeAya)k0LQ#}wizH!p(WrfMSU z0kGsrAyk9U@(PS#AS4K(R);EF{1@O+j)+`Y7SB{?aawCq1eT9u?bDVAG%t3H zIQ383SdFTO_|He7hug%TS1swk17PJkT=%zR_&~Nt3zt?xsj*oQRG%YEaD1`XB+7J? z?UfGG&p6a;x={1Mo7Xt6_ckzfCnqe*)k ztPuV4{2MMj`N3x!JQoxLSDenWdh;sVK}3 zs1Jr~#l0CQ{jNU!e@lb^OhSLx*Xo~s^bu2j<3=B@`VmGx?oo;x)~mE=U58}PAUlBJ z>`jjRl52`lXMmmCC=y~=gHvA=@%fg@4T~CA9}BGK7_FI$%Z;o^nxJQJidp?AwDmq0 zBP|0Q64b>fICNjVv>|Dn;3XA7r>SS6fz6^5qbwkd@PA%JC4h%Y4_%cN;Uy_fcB+CN zNVno`7RFNW5S|^$jf;YSQ(?par8`b1cC3%+XX*5wyz>DIQR;D>vp{jH+O)#)sJCe`e?#i2ja` zm_?MY^h5AD?p->O_UZw+$#ecb?o0f)_3uy{Eek)zl(6m}Sl%pzu-Lb@TskXmbHa5> zqsoW!4W=ybn*5w{F=ORvLmS@siuFPKslejRY5^DFH2@epzZ26XvgzSxM`%(2rZ{G` z`m2CuNS@5d&FM_SNl^I?6Auho7GAtSgmQ}$ib$>FTP*WdsG3wWkc4yJjaOCyNzjdG zvIAlf47)GhT|Qq%oQ8$D$JBVmE$5Ty=MXh{086q!JzP5h)ZemiPxa2uq# zg4`b`Xq!-eFk08ksXUf#46T3rX$jsA2>un=3c6O(gp);y`3Ps_2 z%TvZ@%ff2|G9-0i6a=R9M;+iDZN%WQ@!H4W>Z`6eL3 zCoATxAHHYKC7&{4)*K@5%B3LFnX5pQJT^S@7c}#1c%|1V(x&DY)Q3OJJb|78-|^kV z?ZN+!R3CBZ&%M=t*Kp28-~2B#xW9L7L;6R!xSC5VH<+aFBAzSG5$$^fH<~JTDbb92}4UpNS-tO zlp?`9$sYsLRlSSHJRSRLnva~Abu}}2%`rpTYj4$zPk++9bMMpz+KZjx)upVy8>&6H zA+tURwlw=W<+zlk$UxSj(_x@PVOf=bRSzN)E@jvNxGVZgVJzPMXEh`bZ5OeX7 zs9c7jVRYHsX)YOrgPK&MI&A`ubeyq`QK|Dp?a?zMu687xg;LBV5AfI%JP7mq@C7jamYaGiRp6zveaO-OqbVF%vx zlk9(95tVOyHVw?96d_R;Ij;Pc3opU656Nad0gD;=bg|D}Zs=4b!T6$qOqz8*)=))3 zZcor*n!>?G}9kr@hrbyYG{}VI`>r<3e1h2U>B6PD3S z@{99$tOy%Y!po%kP`IUk9v&nQuOTKGRHI#<5WZ#ra&Qa!QtoFL=^Sl_cRUsPWvD2( zEyb;1BwxT7TT7*UQu3@mkD2h$s=;O*ELBPi_EI$+U`%>1C~;|gx`^+OtNs=0l=?Pe zk^ui^%3FN+`k}R{AZOrt)SSCcCpFb!~E9p5K-+Kxd2=#ZM_;Bfg%75P<;{D&a zvc0#6!q_%)|Mc)2VDc@Jtu^ZCC5d05-XT$~nu0q6ORDEg zUXQ*NbhdPxSj>kAQx%1XT&m%d2V#AgG8l1Xu$pE9Mj9h%K7I{uQ{W7O=FONq&?YQw zZ1t1Rg=Q)6lXW#Ot||3$e!5sTykGIS=2tjY@gv=s;-zmYEZqytHTtM+^=+lM4fWJl z3FT8gB#D5yb?T@|J;tdmq3)*oXyZaYKQVz7kXz3=_#c29YWKi z%w*W#uY^tu$+!`L_C+7Y2ML;+naRmgPm-1$U7pQQbXqb-e%jNA>BcduMcXw>AsO1K zBs~7agn9uxjuUq0@otynANJ&cQw^vm0k4uL=_uKAE@o>aF=GD}J!azba~ji;q@bH& zlcVu*OeC$a|zte!u>iKmNH!AD8aqMGC(A(k-wjj@m4=3^5=`hk;5g97!D+0VI%C zh&n5cy5RuUqP1O7XnJA+GLtf_ru||UTRq0VrYb3 zDL@Inn?E>$EGtOB*wBj6sj#**GJ2wMGb; zr~CQUEajOV*H5xlj?wzcf)lez`v^QcZZpn>sy-a*McZm4oTRf2lL9T;aVrirhIDkz z_GW6_P+#AugFO!ptL9^229wRR?S?s=Gpe-S2l4l#Da3NCG$ca?zY;Xr@Kg)jj5ZZ} zD8W;8xH(QGMKsePiLxPokOSrS_(;_FrUDQ^jKy!oS=*LF4%Fs8c|#thO*iCH60?fs z@QM2A!T8mE3#FWGCmv)IB?0+7Xefk*y@iEFGKg0b2O@fh>G(O2$=h~<6X`34?7Vkq zh9rPmfB_28u-7mY$#XAeW^?6;9F>1C@&dUa5E{zqTP-_q`vp^)qH2jOmaIgc8VsbI zxt1RU$am6I6;^E!gnR-Zw*=>(UVk7)hjGDh{yUJO{$s`Iuk1$d|CZf|S=%}}zI&UV zfvM47o!0m;p5FI}{$W6%y`%5|_z)mp-;}MWkD)_$0K{MMm%S{mS{GGe;E}CtgXI=m zKOqSR_ca4Bj7_elIVHNWt~~nuQDllonAVW(-52=(%^xQIlxqn6Z|0@ZSfz+*v4OXhtSTPxQyfK=(3e=Rf>ErH6Pi8o(NAV?9e0uR<$ySM`tEi-H5bb{b! zR7c;F_E*h){wvg*A9r~JZKFV%d;=InMy4SHhsVz}kLh&{ZB0^W)#RI_UoC@m2(M`w zKklzz@780EdTY*cxw}&zFAr~5A2*hCD7uu-#Kb(UGctr~SOc+;NkM}xQLKD<0i8Fu zVYWvIrH>qZw3cfLeeHG`tUKTU9Q@dn`kKGO+kYEErsnT_nv&bht|fsZLLT6KKPDL= z;37}WRq>6hY!2DNu?Rzpub@FooFnmwIm|iivy-p%Cz?_8=dXB7Tavq1+p+Q0U_=`T z=kcVU_;=y?n)CP~_ddg^ zUzM$wf&WzSds5m49Dg3zx(xG#d*gCfblZhVLum`YgJ#0&m!Gx47Rp|ul0I$WQ+jbA zpAR5TBvBGu1KTYBbzDoeP#=yE+vZu$<^7@ z-reMJ=YC&ugL8$ck5ICKg1|m3Mw!_Tf?7Qqg*Op-W#t%(1ILQxItcL;JRPVDH&YpR zX8*`*`~GJSifqrD$+&QRm=n5XBagQ57itO3xK?sIsolrXRY?U?3=ZO;f)Y~!scJK< zwEaz1)Z-7wusKzz z>w03aGVT-$JsTj>VKkzaI(N%E1|{8QsG@+|ENU8Epvh348lvJpiT0cB|ukJ;3=tc4Aif-+0sC zj^q6!G|L+q8`&FKe{8n=H&|g{V5q07tE)M6WQmNZo~f>gwU?TZkd9iAqLQAij-U{m znwgcFt&WwDk*1lblbNHOpQ0`opRSUbmZMUc60aVco)Vv)qM4SKl9a9qgfuT9Nlr~B z4$=@|AbA6Q4XtlapP+v!FA*+*a3zlrAs^|>-)+062|v{KHR>u5^DxvfQ(Ij6Ga=zj z8#L${%r7DqR8Omx3}!5V&o z>gg~U%WXcj{^gYL4j0j-&Sh-EYN@?3BM$hexQT;;it)T!Lbnocyn!83@9Q!^y=AXo zsY!0Zwr0A5yn3Q}Mv6M5Vsb$NoNRhxLfUDTI(mFgR=P<>YP_MDwV9=!VOo5As<|;h zlxsCTVy?FVuz`RC*pQ~ZJ)bvV@_=n*;u1O`!|5FuBQq_ovhswvv|ZX&*%P;9 zmTfn8mCk}s(o49l7`QtvaEHMJhS0&;zLel(=C+Y=+1iI~9k&fub0i}Rb;9?MI$EFq zl#0OMGN0~xyr=ST|JaTFD<<%N!^b~3xqyw8t(m3K zf5QiC-Z_C+M3Ono&?vkw?>ispQ99_W^9=+J*vIw1t?1%+%Q6DoX+V51|F*B=GEZ2z zrcXyy+YnfMIw!s;T>A9*$Mou_rO4U;AB5n~>5Jn31qc&VWgCn25gN@~TazX9r z>iAb&=Of$snA^v_E1?b&hz>f~rB{WFn%deO=NK=Poz$_d`Nv8@N@63+{cW~CUMU2) zlLX$w>+>fvlgmzSY$E>S0HPn*9Fm}PBO~hO=7UZB65jLxUClCA ztr$%|LuYhViEqtdh(ZqEnuowT(OkLcqaoXSH3WmlzRIV6i}^vugYjnJeKhm>&FZ~? z_TK0zk1Ft>To6{S9o1@hnKcct)_XMov^2qNF_1oCzf?S6x^EC5D{5iw#`94*1cdFV zOx`6iXvfVrbI*!0!@jhL@4BIT@X+xk-PN(%-Y*`b>Nsu5+6OpJ?s-ddKF9A!Z6Q$7;4f7&BuE!rTm}_=La}*(h##p&<=fz7@k7 z@lXez*&4_RU6Y;9^n^Si6kW+SaFHZ>K)?vt6N;V7bd*(JUdhij-)N~UCgW9Ztiod0 z>yX-_UlpdNpB17}JE|r$k}}D@UX>5j*0`mF)|r7ZRZyx#X-Oyyb~pxhfAR*vvcC?w zX`p0i2*O-}qN|6~5f0#NE=1oe^Rc3nrtD}B)>|P#ZNKoLG(ll1%UH3N$N(>R zM;mANWrX=xfnpyP^V{`5nqm*J4gihv>Y;kr5g*g*BGrfs*sNDE)E&(O-R#fP)#0JA zu{FtZusKy0*3f2MGuM7%kkaQucG%JQymC>UJm)F3>RtjU{l#|VCO@>kPW z923<2M5)@#0Xs<~2G#87YagL{KUjn-LDCBUQ+Iz7ef*?wFXVr&YV7ek&%NCmuCI z%ev8~$hxs+3jG{##CQt_1NmKZEk%T)36p@E-cnCy9H}euKuR;4X;*692XDkZ1_bT1NFsyRflcrT*>trTQ2rvDH5Z6*+HvQ#|?V^O{`e9|O; zdI4EtPzIP@NwQQU2xBq6Hhj_)|CM0fW+X*4e@L=a1Be4njIuW2Da|tWX}F05|A%1R zZX`tve{r(bSvXBHP$=kLe~AhWQYI9+c6v87c`1R+26m@=xk>(cr!oX%v?8YLBDc-8 z`vGNkR0)!_1lLZ4xfR7?sKI?Zi@+?J7-h#VS!iY9(K<$qXXIq6mJlcsd`bAF@%}`? zx?xC^Odt|Px@q{T&SstEQJLmt>IM+zg}QC{s@(aO@n5@iLLOWNMHY)b&jO;w@S3N& z?Rcwu&GWJ2IRMmGF;vB-+#KVoNe-LX0h%hsqx!yxh9m4g*L zq+6aPvsP|p%0z^dKt8pRzP3O3k`JxT)r%}=9o?Q14|{1RfKhUQDfA6PizFB$R1TaB zy_|$yCKT+%ZHrEdg0$h%2^dzp;bJI`$o_sc&Z&C9K$*lFkMbbw@-%GxbVE3bRV9&b zZTD;kAiNrc-LxIx6y#WGF=W2HL%mJ+7wDP~e$RV44eWgjH{Y ziE#1+tg=U<1j+CkuFWVX*$;1yke-=l zbFqLG!*nu7@gA!4W&2LGYrXSq?JGQ*8tt%9 zs(wH{k_tzRACLR=B9JXPqvopQFz5qDeAYpVm0gyvDG$pvKCwUnHHT6(Xy^v73=VAj z&t2n;Em;=bXppql3cw5RC&T?5NRLq0&|svqy#W|m?8zKJUba+sfLmkWYTu-TnJq)y z^mi8rwxddW+!u^fUf@~CM|nRe=FS*zn~M$-EN2ftb6d4XC7y~Mbra(U9fl5PN-mON z)S38S*Ll>qDu4bZQgNy+FQ~IxJT4*1H6ekx1p?#ltfOPx{A5L*$tj=k{T_mIbnyjP z^u$Y|TULD6^Ff7#V>i^{?cH?8Cn6cqPZxnWpja)s(k;HuhZ!QxGIDPSU9e^zlT&oRs>%mGYcOKOFo$t~_qWjZO@h^P(?%>S+stBOZh6pyKamqHJ#X8%+Ue8M#??=2 z?ztU)})tsGJv<|10IiwPB8U#hg!?wpIs;Dny^;g1N1;b5(kmGF{rN}Y2Q zsXEC(l68hed=i+5;LSdU-E&t=g@}}YVj>vVN?e``t8M&v?=T;=QnmI!CVy)%Y8j! z;vQvg<$pG1R zK|183e%5AA5<+xv7i(t+mkiP^TMre0pQ+!Lb_q#hZU#4<>oXg=zZ65$l!InVf^a2S6iw<6(8(Qh(KT-^rs>DV`j zad$Xb`&S3i!{5=DK}t{TAN7^I8ZU9-zifxN_p|H?vekVL?$I#g{uv-bc3A+JJkj$H zrzJ)^UP>;Z*#chtQCi0+GLBflj>%8+er7>+y-?FDX*?(vPWz^;cDg=pM=Yup)2mt1 zw(xrj9%@D;E}F8#?X&73K2K)nu$rNmAg|;7Yz@%MDg50oKz-P!&-fjx-3;6wcH|1lXIu$B2?LYUN4tdGO9=4Lvq9^A+b8QzMv1=ds*q@AOwI14bx}yvr$SYM+m-; za+(L3(E_QWgTEY+1~=5b^5mO6x}x3S z2C#_UlZ{g zQFMi#Nw`)%|9sO&Cgkw-_h6JMb=XvERUF>NV9ptS_^W9QGg<~tT9k=?%94R{fDw!C z?HYfUB74FNn}d`354Ps)rq&j@fp^tv#L<#;e6w~doVn~L+o%XZp%}5v@SDn;Ua`BC z=(CkVhIpzr#68MR5X}%pN#x{8R$VYhO3X1Rp33iq3!R`BRrclgW|nMqZzL~G*uyfh zJ#{KVE0YrldNsGO*E>^Mz{O^C)S58ifzZfLY54B9%0}GV)oAt<35W}^je@Rxwnb~~ z_J*4gAgf}7g40lNZ75Ka~I;G_h2*9ne*UDw)*_BCx*UxUB;LSbMV>%g#O9XOevoTTv z(xyDBv#0RvXzPrDZITQc?^T|3y!}8Fha-RsV0>S)>?uaUX{iTBDt3c|d0go|a z@pm<&e;c`bSgWQ!^vqdc(+0-hsC;3k_k>Q^Omg?$7IA&}`Ln_vzAH0d7$DEqX@j9A z=qUL!AqI<+ej>2p!q`LEL)9s7){W#DVO+P4!T{Sx!zpaZ#}xZRa^+d?X?4l!#W>M= z)w=H(UB_|8*$M%T63}EtPbcZbSmj#3Y#j}gnZc(wIHB%~E}EFh=f!s+<;3&2TrV(= zaBjU9e@HOXqofa2mcgU!LG>KJ4c=r9T?f}6wwXQVDrHC0+WNCq#*Y0v>?i)teve*<`d&zex8`#{$e=>=Iz$-R^Q=V^Tx@FT z+ZNKOw94mI7!>(4{VL0`orkE?YzN}stsB>IQ%`~gup9jGBToXcE3I+c8BfU_$bTdN z!I`45PhltT1|1}wc%YnSEtDT4z;4yoi}6Ft31wC4@QVTf<=k5fB}DrZXbjj9V)*T( z^tb8FZ7c~`|LA!*B6Edu;`@e}P6M##R3OpNOFchBri=HKX($t^mvutG*|PfHCg+PY zev4F~_1dCuy_AzJXVt)N5>P!`&2WtY(AJ;>Zj|UvI4NeJ=(Jd`gI1dc)S<%+5ed!H zw4(%)1I!zM@|>y1uJA)AmMoW6)Uj?Y;Jlh3uwlvQ*rYp99EJtWWFTV$koYC?kYMoDkM@Ic zoG<47Q~jm%u#xGS-9y?*!(iL?#%IF19p5ka`0>(#Vfydpp6fW9(Ph{k{kZMO2GZak zNjt2QV-$a_z)rC5SU)Yj>;Tm+;UG4ptLVi$6fyzPHo=YvaPXAAmtRxKUFiB*s#?r? z9c(!j@wvy4{yTxj9h1)N%Bzcyi{4$xUVB?fQnlEIAjJISKRw}$l;t*pKvvR17p`my zwOohy0RSk}u0o1uPReW3sVdwYd7?q=xSw)utI+{GTPijwNx6Zg9f#;nwCo)$mkFU~ z=;+Oxq!?FOC-dCMXk^m9YrYpa*}J#t&z5+AAHiIoQA#<~cTv1d+9LBD5KdI#A|nP! zP5ha>i!1$eI&(N^frL;uW2zf@7>80d%7qhWu3}TS+it2AK@m}qk(;GAF~@qCasKuP zU%+mugv2GMi2N_E8FS1fgL59P#X`3ioaVCwcyEh23_9l-QxtkhjyLOIBaTl zNZzJj2TknE)}nYdGr+nC$eh8(DQx`-l_}% zJWr7SoO{W=!msYDU;xyX%Rb1mq;;nzgx_XtD2)T&ussAPHJ)faxenrM?~=VBpwX&+PAso0J<3e9rTB)k&48VgEOm+b zs(O;V*S{6t8E1i(y+jOC`xQDz=AC96?Z|^{C4aYw@8(Ca|9e)%r^qwho(t*L_t^18 zY6Ul3_1y6Rfbo7S2E<(*;AqAhN;l>Gn4wRaNWAI`L`enopea|XT( z7+cTYVp$l&+1l)(dkiJ{lTNTl57JtkG;N4og+u`*FadE4PK3IL{9Z}co@v&Au~`}h zr@Wq7ww_5gy~7i1%-8i1t)sNsHpQ@9Tg7a)#dn?e?*qvLyr;bDAk%`TUs7JIYmjZy zMaWYy%ls|puDD~&M7ey#Cd5kCfqor!`Ioxq{Fd!o(w&5BCv`vjPn*Sn`y2iwT(drN zS@m**Y$QrWDVcg4l9Ogmol#2%S~Mq~8EXbs6< zDdZ`1p1HS}biMvgv>zZ1c#ZyZU^S^uSWv%_DF=y+u<-=5p?1V{;CqFuLw1O21Kg2+ zYxyAq6BjmE&+2+6Ofx%JnC>ByViYHAMJ^TEY zl9bJhAFDBbs}@bnHh&8gB^XzK5+d;)aETC&W&eHx>x89Y*l{emhTyn{%*JfSlm|=) zjw3A5C)oWnZ_&%qInkrovrM_>!E`Go)n*qacxJpa9-+6qq$W-L|6@9F*duloHV^jy zlZ|&48ga$^f8T)Oq?Oc=*^uOze#?1)J#rttYvMa%H6s6?k&#Oi;{Qn1Klnc?^ZutY zZ<2dF*=z6#b`>0S*+IlEI4?9~pSNOxDlG?fuZCG=id9R8-d63E^0iW2)pcc4vr6W4 z@!r`Kctc-pB2!2scJXnVtP-WB~E8TQ^mRHaF0h5_MO zk|6`cJ)zPAhRSDafD8(frUr@Mrc|PM?UCzKK~y(Hs~h>^tX3K2riZ3(&`bJC@Qd%0 zd3Dwe-lBlXEF}|cP7Pfz?q_kEKhB6VEZN(e1Izf&?SPkUjHYIE^TiFsvww+FArkp}EXte5`RW8ZX}} z(N-LavH@lxtoQe2c%n)N{Ayt@WTjyhSFi<PSm;gG(QO(3MYhZv2_chZh?*2yIN;A8Z2!TmdXJCst0H?uJ8wrs7~n_qN1 zXr8$09+tRd0F?hW_8$z=;yy=F^b?yOXG!6&_1YQX?dsJ1o60N;5OaYp#5O`5$gL!N z7#;Bha9ffZ1qwnomzSFVl00fZv3;Nc6J(~L$`!0s(-Xu;{71e=_ve<6Y%Oy4)*KdL zE#A%cRy&J+`JXyf9Bf&#gK1waSlw;xWw-CLDrb4LIwan+T-p@d>1C64zmD3)|9+B* zXn9{*tXr*2N+sd5-kfmj!A92)&vp1UEOp&YC zJPY!ju=7xVMM1L~t`~oY7-~kkMKn_S(xp{m-+yHyj#i`BU7B|^m4Hxf5!3eRTz!Tz z0+Z=1rosq+4nvip<>5XSRMRN-7EHXAq(eEy=n!sXiP77b9j_EDq~J$>V!XUMEx*VH z`}-zBtgk`kZz7`G-ph!bq?5(i6BOK%ph&f=08VOn*iLoC3W?gCaCP+Aujo!hvGi8h zgSD8f-}J4o7oJ^8g&wPNW>FvFE8%6!Brhex$!yP{`Zy|lcno|PA#P%2Nc59de+v7| zaW<4QFbHB?BD8pdFdk#ChHiB3S^MFn2BTk1**AP5q-GFDHa5dsetrM?+uPTfwod0N z3aU0+A%#?(e7~4I&zSVfQ2v)NRWy|MK(ao}V+`B(4@EZ3T;R~!2!P~xU&+SEe+|MP z(WSYqMrRCi^B`dDE}$hL*T5sEjxoNuOVLAYys+5FxVTdwc|nWJg$V^)rl6;$22j*c zThn7HHri3z8k^Z-omE@=8|3n@)rQjALOpiI#?wk`skr9)Qe&6laAb$;oX(>1;+oRx z!om_L!rzT^?Um)~ON$!ob;VVZlA6nw7T1~@k4KCu%U0`L2p9Ow6 zC8+OFMF0w;J~}j%kri%9MZ|!h2rtabR(53YRY7$|H*X5;wmPXC43XIFeBMG z!M0y9A6p~tOEdIi#6uxe^6@pWAevy035VW3&J=Z@sdAVT!U(&GiwtXYp6iiQ4zxjC zJ68NTS-@!>)wZ8sr^3f~ejb+7R%y>kgw_v`YBr{c9`zu>f-d=j`iXtWHg93wM>tsG z<&wHXc!p#?ril`67T>yG%b_hz>*Gt7OAXbzxtcdm3e;x69Fo=21U-Oyj1X0|37ext zZIGP@Cdi;CAynkrBMiAge1e2aycwt82!}dLR~v~g4I3q3sRWRP44R7dDWEN{k+fxx z2=ZS2Wj5)e#xCT~C~bA!pl{IBnY%?uh-VB)66B1}>=}q-_46E}#JEeCd(D{}WQn6Ih$Qo2po$0|lw~CHq0Y@Po$q2J-VO$d- z*j$5Y?y6u<(-fniL2HclwUw|IJ701hw6zuBAg$L%i2^-(8_u**lCm~{5TJQH6?)Su zSv@-n)j4O{Vnw=V+n@V|^|z!^>_u}875epw37t?Eud||aCvVIIbGC6WKs6N4m`(w_ zGqAwG$iT!mnSoF;oVfo)rS#m_vA=m3(m@s`Gfvav9QvX%(Px~q+U0mVf z(v{OZleU4?+t2Pq>w#rRg}KC}BEnASRZ5*ihtz`&!J#ovC59piE?X4G8+3Ep@~C&; z`5!r=#rb@A?W#){38L12G;|b~7=ZYm#VV1Pg~dp{zfjlC^}=5Y`_1$VF$=*}Eo_N^ zK_+1etZ?jmw^4YB2!jv|&~iLOtb?@RbLtv@;fk*P^uvecm~Ds`XdQ7eB$zhhhe4vL zX%=E-v`5O_!h}bpgR;^a=8(rJpVE<)CVbxb>7B$S-%6T6g6NGAmu@l@7057TAD&F_ z0#=Rs#9zUPnT0n=ctH1-8Y5)vtd^t4vzKOOJuNTJB^Ar(Km^-Gf|@5eht$h>7^O1K)6vlyot7wPiTxLq{)y@E9Qf&NJ~VqawLt$u~~dNJ`M{;OL@KzrLfG;&y!<2iQ-we z`$45 zlME-EJW$e@)`Cr1d$>tm0C6JVZevZ5Rj9WS?Gm5LJ{ICVFk+WssY;u>w}^BeH#$yR zbHb9r>?xNQW_a;fmDg5zI3SOIw`Fkknc0u5OqO?W3l%5mOEY*>--#n4Z88kD5!2HI zt57zVBFq_&P3}<`hGh^OOS#Qf!=7{!Y*HeEu401z zIphRY>%>a9(c3;{T-bdywp4hlg5+vwoBM*ysc+>)ljcY^At(G#>J3Iz$ARJgSXSKR z=J;nB>D&)78S?2_;`-|$XJ`_p{08tj?O=7cJ%3D;QEUHj4wPoj3A;U=1MWT?sZ!=f z+WJ^Oxk;>h(I)O4l;B1p_Y2f4;elR;MxIM4WnTj%^WD$BQwEX)>R)V5(a~tnN=={( z1Sdl0)I^H!iLz5fIQtJob=2ysm~!wxWU~=aZiLVRnQ-fgk^Qlhb^SBv5DXAe<-5Do zS#}W-4U+}cS;eduZ#v<7Wo|k#d)=B_!oG6pwu9Y3%hd*-HcDEwp@}8#z|ll3XJ1kY zZD}l~zit?z{;pbwb$t%k!?1m74mv#qPeeD1Tm!*0*^~IhKz>`!*15;=y{!n5r``0` z_TuRfyxCc^=6kem-z%TKUeU{~6Xrs%ds%sBUaOBFURhng+_x}ZcppDS@Es1`OUOe> zmL3A@zuLl_z2uwg=@s~z_x?=ZnCzA)&UCDU4A)I{_*AbL$s)9PMQ^zvjDN z`}=2W)g`QC^Z8&x%WsZpRkfN*oh6VqPZBEOihIObtBYl}@Tjwl>Z;Obl|)SOiiU4z ztBe~j)ky8uU!oun(wbwPc{2A}kFlkCSWt}DKp}+{JqXH0$oNscQ>;YR^80lY=05$5 zNsCIv;;=J~>M8`(6hBb*IKd4!6;W{+3!aSEph6LCm-!oV?#;=V;C57 z?4uC_4dE4j>D2Cfx)8r_hcT{Avus;%*V|K7LldZwRTKrS zW(A?3a`RSRMGVirW(p|ec9~N=chI_6QVcuCx~|{BZ;w$jg%Nd5z|X$@4Lu6pE5)m}=+dIZnbcn3W>w$_JOyD%5Oa%n ze4N{nfLkz>OH`Lg73whKrel?59uBP}aPUgzjk0sqsIU|POkaxZsCkD}E(kixE8;z& zyz*lD9MEG_Imoo&00Q0ZUEbSSA{?;>cMK#dOX+Y;FR8^bBgEV&mR4HZ7&e#)! zh;R7Q4zl%8@$X03>I^iIF%R{i}MrqF)xkU8Ca-uRE6r1hTpPtOc1dWHHUiJAtz)4X8BGRbNw z9UmKb^!QW`DP|8H-#7aIZA@N_=QrM4I9K&wqxCsv1MoI&S8NgjU3pRWl%V;Ed2$|g zJMC0>H*|5vI!zgRUlQCwg-NmDE^u#`dG{G(PqVu#u_zZRuEm$NluSODRp<;#JN zeL^g-9Wt~dx(gT-U&Hv3DLMKub;M>E7cQ~T)5%Uy!{VG&>7s=~Be!iZbQ z*(vE(OVk5(R%(ZgP4f8y19mEKwIfLz9g>`$o%v8^od=OAUq;CfsZ@%ZJ+-wp?5J?N zjt-?h7)>S`rk)Tu4u|A~CG#u;@%H`%Hrf@U%vNYLyW2G)b@~5- zePW5S&@^+-a2x=nKI;;_Rl&=!OP?Z7;@u0urm#9G<4fi5;#wn^jrE!VwEw>DNcG%; zTn~FWpb0%*%ko*-7fD2WE3(>fd$W{Zi{*NgB>z0UP*1%tm7)wwd=N1CYHMpW&vb5Y zaGuQ&vTq6W>ukTwfKslPcUn!BzQx%79tZ$Or6uv}@(laQO4B-I{6&wg-9*07;tTi8 zj6TpGFIB<2u?2A#(_H=3q%meyVL6$X_jR`V%y)AZ%JJnoI@zgW_Z4w!H1^_Y>&_jP z*0dI>SU!&lFQN~OLxg%^#`(ja9VyO$(ErcptM`M77a2B2e_cLl@uhBzs0``T?LmAh zBk}2dU4=FKpdRGf7eCx}E>IP08+Mg=$G=_qysgH^l}`+a1q#4h+v{T;^Z0FfvmG~o$-_2*dR{YvvF z+(!2icxV!sNBR2dexk7uP}tb)d!F^k%;D0kBzY})gL~EgH&37KGHAh=ypq^kx9!xm z75eh*=wL7AZQWpl^t%vRtbw(q9BOiLL-y~ocx{{1L{OvN;Ps7wPVZ@&S#l(98)3^= zf?vfC-{c&bA}&h$SU9e$hc92=Rd=gG=A4(%{RT93IAR{+G=RD`9G9>SX9*Y9gm1>}qLaB4%RgV(;|-s=S*1 z*NO3e=?ed!z`y^^9}7tovfdO#4!I8b3j6RU@;WP-3z9+`-EAjv%EACQE5YYltTszs zPxyJX>VCNL5YGmnpL{mk>1s=Nd+)1(!_;R~6@Pgtz-NBZMT;fkM*mIeJ%{TE#@ob; zEuNnjrEncUi&VTu8ZH$n2k15m5R1M(^YbS&`8B`yL96Dl(FE@<`R|qE;yJjvcTk?w zvCgdENdal@cux9-!=)pKgihjtdbIj<7aafW4hyS?oA3T+d>c|69TS7Pe=d|dEY-vS z>L?1-^9Ihz&MKdx|jni-{~#A$n6n}G%8R)OUqc9bXD?T@@Tii{_zMb z@#N$|+-J5L*P2vpHNcXaAkwl}B24Uq<5I_Om9tIP&(d$}3&{SYbm1_qOMdKr)ho5Y zmfdfz(x^VFS&OnB>;*PEBsX76^)!?S6g)&(aO32R6MIq)w!0Chl^ z^8lc?Eeg3HqOw~7?j$cM*Ux3|FD)C87dnT=5*%1hCq<1Zvxu(g_v22J(_RwQD)71G zh0h6E0Dht27&_(&gkO|oaKy)~-nf(Di>+rsX_dBhc}G^;XhtNJ1E*{$&hT1_PZ}Tr zo8^=we*(M39-7rwuF1}CM&A?5=;GnI6mTg>IQ z$f!cBgy4p7!8`?vFThnJwusxdgQP;HLZKR*MwU~agjWjXdF$^*EC+UjaU-%045G<_ zal<|QC`YH7T11O^U=>LUzyPxauLR=;aREDpnKM@~HaRtoo(kXw;R5-_e1X1#K7zr5 z?1PbeBpldrcEy26gH7E15|YjrX5?dqDu=h=nzPE6&c76F5sVR>7A*5;_@PT_a|R|( z$n@qv4;9V-hJUJ%e9CJR4U|N8k zQV%fbc~r2@e;e!*toUGBJXG*lFvmaJU&o)fo9#U|#HU-UoKO*ZPBZ^j5H4T^Y#B7? zIWz(00dfo&6~Hh2hAx=~_p=Za>>P{~ahpEi!oR~|{BELWm*rmM9pW{4*_O4T|JTS*?5P+@H$gg4Qh^uXUh zhST^i7-ozO55)OaTOV*{iH#S{0mkBE!)PMXImO%1Ax_%^+wfl-9;8NB>E)hXe(6>> z4WjtHC(~>%{en3}62k|Nlj=bNa<}avynDJrw_bgNiII)|7ycai5A*@tq}FWqP$o!S z#KZ3(TtDoK3$DxiM-{tZZgQx*e%|0y0nq15R}>`@pNV~1L%3U8G&B$hQvD8P4?5>x zu2{E2MxN((>BHIW4lV<9BG~Qz3UDA0T!US)0-)rsq>{Yn54-clRDCpv>=6jskTilu z4}v5Pd|5p(3z6`8Y3te`<0kLRZb4XkkebUQufdlvJ)9#)4~huq?QEakt#7ahC6kcy z$DtMug;+hAmF z6bi>F7Y|h|?kZScSu=gO<#H90Fy{_KSYP2Yec0vnWDidIU~Beo-}t^4|M!^}fuK&G zob~e+hnGJq+3LpQk8XbdK}%1x`j>EKZo@`<8;dj_^Vw-gG_`MNVVRnAj*rukadliv@SsR5NmM zm4sQlwp)(X0SAo6fjda%n0Vd^7+9zIPz2Vg=XcV)w|YR71Y^WBJBim2V`wN6`$o)I z4#%nRgZf(0ZRl$|)ytdhtso19@Y4c@WyJ*sss<>JmyBgFEBgvU@RprDZj9Vfo%@LA zmLX@br9-|dnC-`{D-`9SIgy+#WD!DF$Adm^B#(1adk$g|sFubnA$k9IzA-5m30Xc? z*xWbC)0u#0RFB4aVR!RV#J4eFZc#+ucW>}BgLmUS&jr#vkJ{yaQ?~xaoqK|WAWbie zE_39lIkMc>A!qtq8Yh1ec~%&V3aX{4Hy+Lm8}&U=>O>JAAl1iy}mt-&5} zwh`tv?0z0r@3INWBl03B@y8h#g%$d8vO}J-bm5Y~5f(b5Q&W-35Q)NS)#^|QYel*w z6DykZ4agspQ6EwLCQ7A^*C`%Lp$bT#HN-|QlNVhQSd6M2_MWB}F`LbrGd#Y#WY1HE zMB**@Gu%Z7!!~ShP|K75pxyP@7GZc#D(jhrYh8ER^P=tEU?RTMxQRk7_FiP)NNQXr zg&;nvy2Ee0!YOarNbV?mbsG{k@O*JZH|?43!E^u|@}$=jCq5E?4#T{x*+32w=73-} z{g`IysvEt(*N*M{%mRK^=+4o!&EDcqALik4*iLi5N^zYkN{<8-Y`Z%nvve_959RNT zb1Nq_4u%UE{xS^Ip;HT;I3&Rj(XpUTHuXF@l4b!nsCw%m7V+P%2g z)iH$}_k=-*EW)M(`{^BPFEI0vG-&UdwZLW9*HrYD_msXunA_%-;976m@_4IdeeG1N zJH6i|wyshMcew`4n^ylL#R3SiaX!Wl zRX~euOfkw(HN+vwEk5pxOqiHR1J#|m)urF;!kO7_r$*S%X)*BtAdgrqDFCgIrNMwD!?Bx9A9&@-H(E$c984h0 zk`Tw*T)E8grgT3K{kzA|bdqU%!WvV}Jg`PAed}J|ojt}Fh`vcOur|q;i&o4~f&DrL zY`DtHf%D_C=spx*i0BT*AVnL9Mh*}bZONo~V>6F6A8$h6x$EH2k@HyF=Pg_82uVw_ zbD6q&?=njf8Y#Mp;ciTJ>@X}$2D{8T&1kC=wpjMC%B^glTFpGM$$TvQ4B>Hpsvg@+C${!@=-BmpKDfo0J-|U2 z0o*iUQ^1pk+8LJJ7WX2<3R15dMMz#%Ox)d{9~@ndKkh$1-CiFIAH4ybeHWD%K$Sl| zzH|)pe$>Xr>D$**4bR5m?xv+??q8-2z@6qCpAhC1H_V_*A;z9y@0tdg0(?4IPqtY$ROXqfJmsQm*9@^ z9qvI)(%j3?&2BI_jk&DPe!AqCB%;C+veO^BT`SBRzu?_Zto}uYo7V*DyldG4R}JG~ zVSp^LjEYwXg(>vmx?Tk;XD2g8^&iy;B=UqDZ(W9S)*Ss0std!#eybez$D0W01T>C} z0a{sb7CJRD^Z`zMN>o%hV~0p#YE0y7*oos=M0wLw0%5t_lpI$#&k$AyAhWmba;8A@ zM)d4TFuzcdIx!;Hk(&A@ha3TF+rjI)&;6F0cZ;y0$}B4F@PQbO%SW^&i(~&Jc)x(0 z>`EM?kKax?gV)s;`-5Twi3U-?urS@jfO9rzD5fbv|FGHBHHs?7iS5_|)ZKWUhHh2? zzNC@Sac^}W{qx4b-r^7@wonW^AKsv_w(BO-;E*z*o;PDsC-(JlUzc(m%t;3y^g1EH zKCJ%br+^G`@;LcEnl%0Rx)I~kxVVOf!Fe9Ce%r_RISPt~dLK=RB5ZL5s3%Pcz{Pu> zdTB_o_ticHn3sy#*;e7#X`50^B+n`Ya~Z4~p)9D8u+eWCce1zk`O>}2X5JbNuj}CS zCg|O{J(zfhCXG@py1tIwMSX(^jiZDxF)I~P zB~qE+(BKx4cGH*Nbs_DR*iOIq#wJMdBrDmeT$Thy$rwT!<#^)A!X(Nr5I2iUMlI8%-70JqDz{mZQgY6^DXxYP}90?yoeerQK?q47814 z=!~ww66Fl#d@b{!jgsMVn1WuGXWvfdb0eRa#TSjDcQxLHSfrm+5SO;BK<{p7_l0%r zm`NS^|gu03x6W87&|H-j|{pDo4%ql2{Di7G+p zp2}*2_*VuWkee^or*kKayvBn_r@Babwu7AITjHIbP3G%^ut1mP*KK;Jyj4gJ@oPqS zd`^dCHxDPhR_IVHx@Y8eWehA13Oq4uLs#sdbB*~26E|#evqv`ix7Hdh5P0n;6A>fI zF%7m!R|a{yWWDY>@vYo$i;Hmy;%y<=w#Xr}e3f?Cow}n0GkPi74v(Kjb2&NS4J~C^ zj8!SiaPs?k1kVk}XSd=#5Pu(n(ndF)GmjRlLKlWULur#so{WY54s$2Zikf4~X}nRi z4W)XCKW-On{*wLaPbfuFG6E}QgsJ=XsG$?q4FK7L0-BBBR|8t-UvI^8DN>i3xir6U zI$dXdwBz_VIKozX*3WU3THlMLRp=zsCeWlC;?sMEI_hYKzAiF^b;j!S-5@u{GHxfM zV)9PN=-rk<$qVUW*`K}`U$NX)|2|aDn9`#hiG32#WeFH$u&2NAMT84c!S$x_zxn&x zoi3PoS2;?oeI&!#3B6G-$@+)HEIpW&b&QNoAY4eY%YzLmcQvK}t^BBy1bH78y4x1-1*p__lH8|~Y zRzBVP{zT%aTHa;-VIRr>uTiOLtY@PI%VWl6`ym0ubMIcv+UpY|)i(_^QJRa*cpEiM zl!04(85hvN6L;O&@UltOV(ruqju2Hf9edz~=9q+!XbV8`D<}84n0SbTVLft-8c!dh zC2zlm<&W_=z|m#305i6C+-OI!R^$8?{wPgmtOT2U!k)c$=c>o>x5@Omb?)sRb+Kps z=e9~dG_(-}6LBEAGD8R__u?a3?ad0a3cUF6nFPbsW7LcFPO~2xi86^y|0WnICcGPb^oPUUeoIOGDLGgE7@l=x*0;H*wqeW6iRx z9{NRC%QnTE-+1TDUX6y%haLLIqIQ60-n}ldT{YP0I7$pHuBjjJNC}h4sLT*s=q@La zp*=O!T1(fiejd}~Vd??%Zr#lyQd1X!JVUv^&Z8Y>R^(PmD_4&YwsXvMit&ReuiX!V zr+J@I2BC7e*v|H17m1zuZEo~>T~=K#ZxZ%xYyt*)WSohPef<8mK09 zow6EC^fC-mpBeBnTnL_lje02uU6m25S}4V{U9>()y-|5E1|HG!bA_)y-XiI87k_p1 zjONU16B{~VCE-}luO9h6?cIgS-TP_X`MJ#5?8w-6zCxZ`LA2p6sr@;;?mP8zE7zDy zSY&RR3S@M<@cvM!IW+_=VG`+%hM(qp!V&p1vDJ1Gv=v$ZY78skrK8DIjW7`wBcG2} zLP2fn#3`xN9^}W7-@>6{t%<9QmeAIwNNR6%b=c2o^&z{+t?&l(e9DbR5OhU z=MWK!u5dA6t{+*A_x=`Bytli}ZW74(yN1ts8_dXBw_}cop{JVv3iGog0H3>>d#;pE zG?P3&(73`RKuQ`QnSY*?3AK~D-^i`&+9A;Vi_*?^l~TW0{9Vl(JX^(?YFZ%7RUmE^ zV)%52Kwr%VPIu|;i2o$OJ*d>|MRd7b*>m-F|K>MmwhqB0Z^Ow~S-vYz9M3H7{%UoE zH1gP=O!Ap9N+`Vg7?xCS1J)?!q-fg^gePxL-v$z6I3wfdpzVq=}Y&Qog9jzk) zMLX#sv=PYC+B~Xvb)0Ul&#?pG*Y|3y(6;)xhJ5L3MD*scEy*X>1mdPb4ay@D zVOYehM!(ht44?>NlR8~JTLRE=S3Q=^6p`Be`*HjY3(?LSlOk6*7<>ICe*75`(VgWN zOF+mV#E^GO?|k+iBT67rBeUmmQQD0^kcX=e`t6Fyd!f0F=CiwkXZK)4`|r9Rc~(x9 zZOGoe#>{9aZ6v#yzqR9*yv;q>ouGm1-aEKbeaDmgVkig+OB=b9n4AAf7MRPOC-WR= zOlcja%&a;%NZIc8?)rY6eVu{2*~dA>&C~wa4x5w}5|>SL885U)>i{D|CKaW;ZcU>8 z@3+c=0mG%(DgkT^kqQ*DDN9`KP?2Gg*qDO4ACIwBMod}c@71RQ#arqZREhg!OgDyB zP%xTHR|y1P(%5WkMTXn;^o=A?vd9J18axVhlLG|NCWy{%d-8#>_|w8>rDG)FwlP0B zNo=KYtGH65`r}vgh8|5WhbjFn>q4C048Zs9udUaow>>6Cs=3UjDuKO#p_(paf}(f! zR(_-G#jU%t9)TaX#^HmV$6Yz^8DcsXoF7j69~Z`Og-_EtR(ng{H*18N;V5P1RFvU2 zNxvLY^c`?<(+|65tk;^n4PR3?5i=NTs#c8r2@HLiCUgS}vy2St1Jb&l%o`(K1)7_%?TI%HBe2-fV-$Jx9Z!Qz_v)tTSD z{xy+)DPsN>8)Z0A-VnLX0OU+|>eu*0>e{vQdYCTO6n(5OH=yS=nRP1-j^i3abnRtG z7L;zLU5`-phiNP>lrlvAev+vC8yOs>(UL#brfGDq-p`>E`7dpaCDc#*@7p!j*VLHl z5t8(ZL`zT-6e%p5>0bG-Xq-O}BfYaGEJbE+K(CpZ3F9VMF^KzurDe{RT52K}Md$_9 ztZD$mH5YrML-4OHekQuw@Y(s!Z$$pazhA=J$f3&WI_Km=M4c{;9=lbqO+jXksEPj~ z_?4uagxN3-+KkJkkS-6vp;zH2V=Q=Gj2+XijJX$*H(jTh^6DrZi6u<7v~AOtMD0JH zOs>zx3Euonhn_w<4yxRNbk_C? z455`(t0L=pK@3F{6#Jw484uQNZ>IwsgMTY&fm@dCSQ#VnE?)DJ7mr^v^5i?IRKjlZ zI66;0+phLFp{;j4-A;wjvjnSnr9$Id*<;ID@S)hf84s8U5Nha)NX7^JTN}}EHedVo zgWkZibbQzSz=(G+%rDAhUQwFOT2+y{ExrK!`qiT?29HL*^7LEVPsN=9iGP-2q#x&V zyOP-?)-~*Zvj5eJyf89C=4|_&lKm67y~IfH9KBpIq8)C#s{dUb1~qFLFSqvcAvN|# z8#}jTLo@YR#y5^5wZ_+M)`RIq3pmmn2QCj-FG>xpGEP*C5X>&2xqm1_d1fAy^?W&+ zJ-7;+J)ondpYvw#!I#WUt4S`IgjmP+)ay@l!9FxAuZfOl@9M)9KYD!F!Q?8vzyEZv zrZKbgc+FE6Mz8c=EsO2VYA+x6t(uDYOQ3W+djGNAd0qBJ$*V(?T+%x17xWKHaIN0M z%#0w_iM#YP;5v7i?O*FhS&W-569@2!&}LXk|M_^2=e@{W&MDoT_vo?ZU-K{EY^OrG zWGz}*LoO5StzCTZ@cD?M%uJyF6Ufpl6UcHpj=$(8PLiw+o zYyx<%_Y%j#D7Y|Nwxp#qO>Y$z)^poC`W}cBGL!Jqw77!4zGK7EU?kcp9QLAAY@Gga zQ(n$*P{B}C`(n1&SIC@gp$8qiAp`)P>%nQNd7Z%7_z z+0j_#G{-8h#3 zY{gRivcgHp7E3CEeR_-g%AM1ofe)bqpJr5{m;tgPaCa+D#D?9i@v>9`rgfNRhxHGJ z#%W_O9$tlG<5Y&eMoBYy#rPBl`No$RHOgYl)@3Oi0p11tu~p~A|d|XI^(-#q`#CvJ+?@s&65s4Jnh*|j=ihkeplL9)+8&rg);p-67`chO=vaM41N9@Fg zStj-9M>s$F0gs9U4Avwas`_@X5}Sr2K?X@Gsdy<_B{p045$Q6`WIE@GEa>A6UzNg? zOzg@;*GeIR@kIKnK>m%{8=SEyED}5y}2{&Z(H6;xjx-V^uAqYVP=!y6ksAy zdy8e_#)q(lNN2cH>Eo^BDuP;V>@^t~zH-MUx|{t)89BrIA=FwB3k`P|LCp}Q{lLUX z%yVisJEaQDhu%*fZ`xiGhq~|VF|-?Lk+SF3QtEIV+dFkxI$gUkx^=u4C)DMvE$!6r zO)omEi&mNQLAn2t&Evj;_?7a_NTq76?MZb>G3pzE5`P2_WY`J3YGK4Nb&P`^e^76! zB#Xy{LXiXu5w@(mTazP%MWgSoCb^!N)iXB=#Fqme5le!W9awcAE$;dkN(L5|0;=N& z7KV*2r0R8frA}Zm!X8H?8}KgmvLhBWv;v81bdxsE1X?g?C&@^bagC38NqlU&f{#-m z373(Ld30+YifhLl@BxQU9E@w=Y$1?GYw#-(cAk0-IHK~U><2T;VbCs(t1%P89*yDe z%E2Dcp92_1CUIXrE%q)1!7XLLu2iwhqOMGm82PZyB>03STj*QBzG$y?vCjdmA?zc4 z`GG!~+_3H#%!Q0er11#kzZXzTe)_Cnt{>|i)9_NMGoCcZ7DZcsoOaGwm3@H@ z`o1b_$RZss%TYhe(N(em^SPN-&YY4Yb!>3GJaIq(RvGuRsaenDjB(bZL|BFDI_!5X zo1UWY*-UD|J;Mxra0Xd7Hmw9~no+d{^cXDs(r&J9qIk6lgJWPcycX~0K*2FV)wbKa zbk23Ffsx-e8t-BfSd-KUdnOH{xo&Cu#$fztyc{jFx*uHJBh}yO?af@IyE?(VW2hdv zzvFJASFQ<3!UzT_RoD)b-IXiX`<#_vvZ@8gULQ_+X+EovX6%i z9E<7}J6(dp2B6ilENFMpg_4OD`0gajF(o`Obfg?d1m+D8 z9+V(j=q^OUM_b1y$HJbYuzL+sbAd>nU3tEdQt8uK=yq>ro~B|UK6R3y5$U0BAji%? zU#=#dz#lD|Nz9dg=!ve_gvD%6!Q>+75s257rUq+54W80B0>t(({HMPK^t8kT`&TpdMqivAy9^Kzs>|ZxTY^X^eE5bfVRC*|&JQ_qbnCLK{=#T?CtEv@36V5@r zf#jjeMt5cW6D)KRmJKlU$&>wOFS_MIeQy5T&31ZA@ z*K=OSKuAx^+PhwdY&tEB93~V3Ds@^H3~3EF2#pzqN>8G(5?7W;u0~^Cvs*Tla4cx% zM98rCK26F9D_Y7(utEO~#l`tR=Hi7J%ZDA#t#_W z^N8?{ap4_O_a6*ZkK&IPh!?1F(Of+#U5uwGJlMWwl=YUaiM@1-TGwgei%)=AKVGzGz5lgKRI9i^;`KYLTXjUCl5AL7r(eBjT1 zO0qD1{6KU9LUOd~w~%zc)E}7hu)#DC8gDE*>|vYaTRJo>=lZFmu208&Pi5OWEjY4K z@g;)ba+_G-L`;RF_g!F&rRbZoAxBQ%p$0xX5@-?7B*gJfC&LiJmXIU*LMpqq)c77xfKC` z4*HnAFT>j}zx8I#jYaG&B?ba&75rDx7@)%Ne_dWc{_j>5+8G-C<^5By4cY&^Htnx2 ztNH1AiC36k-IMSaeMw*2z5dphRKwlj-K^`&w-YmGCvbDIhA=G)RU!cwYI^2=2>oMI zs@%l<_;A}WM)mOgu7@aRr*K1YLR*b0NJTb+$c7*u(bzg0 z{zypI^a40b8GC74v6c1Pv6IoV_iwxLc5UGG_gL1?TGrccTPm%;X~A*LuSpOPLT_`s z#vG{GRiNEJ%`<-MP1cc^RMDd;cT;xksH5Q0T^t(H(`_}`iNPewq=}2kHL6h^CoT7= z$+f0UrVL9iR*(s=bQ(E0dw-;?>$bb@eU~iWTQN(@MWU0EY1rV~YF)`9!((GxtRW+Js}X!?`7J#xPD@ zKSg_!Qn+na*%k`%sw0RVXSC9PEBN9s1u!l6?VaDP0D`!d<;d(B7eUra--Cd z7E8w(PhK&eHr|T)ekOJc3-0Rd&E47a!RaN9__|BS)7|;~OIyl^{h?~HTr6?@3p}nw z(YArhEeUvq<&7KW-Golty^Sx5_GXq>OH9~m3Ib{y1%`rBctrOSoDb#%2nnIeUMohQ zUrg<~Rc0pf+g5ja2MbCAzpyuPwz3vPFMLh-5cb89(7#yA7#F6Gl{!P#+_ZR`CunxJ zX`=gyc*QvynH}4PB?g&9zlo;HG77npO-Hr$y`Ai}6_k(Nb}4d)!sw>m>a$C3Un|Q>I;?T+U2?<9037@@a~`Zi=K49L)QicJe5}oxo`z4 z&rTKT&51^et7}e6=MMO<5?By>MiWLm)dx9ATPw~3&qHUBxRh*Y^kN`2i=8`~5Plcg zpQu2hG`vz3m~;Y5yR(37O)oOY*pX;oV`dKEXt32{}`fObTC@bz{rx~x?|Tk#W@ z5Z8_AHMH`_fYmZD@%s3s>xVkzB+qslMd(5Y?Hm_6DgRJwwNo0Zx7MKN}Oy7W}R*!`s6L_K}G zy0Z7S=!&yJbfV}pXF^Ein}w07G;#2xa_XA}qp^PJ=I)z7i~4?j8Xv8#YLg@_TSAms-v938aslf!?{YCxfYhXAzzdVT*yHBz2h^7(eQ#8rVvmLoCFZNR*r9U| zl1pm}yN)vP^IS^Wx5}=kwGUBq=oriFmx~=AEgraT7{baY3UWne;UiZucYmBV9>6mp zn#jSyDY|m0?p}Y4pZ8PJE-Y=mSP%RAzT#?uM>q!f2I3OV{X}JOzqU~S3-eZW<-if>3EI=UvGavhH;T^o ze650Tx05$Z1wa~+^bEM>0?D|s(YROB&!|MFwL1EFo=(7SliwF0!a^q?OU*I|wp5k4 zj|JOpBTsp`Gd*R}v!J|5N@ItOKSxbmE{N~cmbA8Kq#Z9aJ&s>scG%Y_$yFl;J6fs} z$PUj@%$4InR@Es(S8*-D0UMbFfmrZ!y!#ltB&F@ST;Jo;;?5xdp!%sTVAl5YKzF_4 zPVGxDj#8uBNnnRArm*#yERsUlq5whEv|qHE;Oxz;|@W zxsF1oepgE=!C6V|5>r5~cDyMDfn;Hp?wO2HrKcwFDJ{3SbfyXv=IHo{zhcj|+Ew{` z#AEBAH%|H2{Ha7e4{bW4vPD!~DeF-h-S)lJ2WCQ7nj*ty0h>y_F>|?!i#S$u;l6f0 z#xjJKd(evj@eCd7{ED;rQK5>`=^H-N)sOxUM{3pH{QAjEs3RZEXjS@Za&(F+5ZIsF z)pl+r4WDhyWP9uePlrhBpp}i%Y2H}+rc`l|uBX1}z>kU40W&#lJl3NZrnS|<-rB9u zZN;l1lmQ#NqS8v63$BTJXq%v(yj>w^(wIc71A75m5JeELC)^xGqc9^= zC{uVSQzQ(oO4&=`))#G%0vi%NP00yUa`tp>Fy<-jP8h+CglD)*R0l6Iq0_JMttoCk zeszF;P~S1}i=XNcOo&w8UaAO)MQeNw@6i6&W3Kjvn*j_1(ZuR-f(W; zHRS`1Z&!{7)&Z=_Fs@v}yi` zG!7XDEqUSS%-GJ}f5LJk%phNa{)#B3!oLRb6|r{0zZIv5Af?=Q1+Et!LYp$pDD_7o znM*mM{X`%qP~{!$J<_15piV&Twri9#p|IX9=vSM|7RXDhtiYL-zNlLurnLFFb8gV0 zDaXr)ZCcq}q&f`(HyvnKlvCy4oJ2lcqGz}nFf-OZO<>n|MVkh8#0D@$n;zBRmtKGo zz+bGALCxYsj!{uRVd3xs9zIx$CT%%yWpnA(D-ZBxbFnAggn~X{No{_wJOGT2J@qE^ z8KY>5@t!v~b;=EnRJ@$V5UF|Di@eVo|GA?O|4nCdo$dn{L!b@um%Bb;nk=_ z1)iN7JBb+EtOElzh;ywX5LvhviNq#YGiL<&W@YG4ob^X`joN8uy(yFG)oId zIgkOfkDRt*_3kQYzlU88e$lAfvZRI9YS%qH?J(c!;{?~|jBZu7V^YGdDT@NjLx&d| z8DlpModFwb9k-_XmKJLj<2P{E4e)$yla3!mA0|J3sSIkkfva|J@`5AkoM9KSJ}rIu54fXCq_p%EC4{wJSy4<{ z^^`SEv<#aRTI39JWCjFrn*ruC)+l2hLrhn}`^Dz7$@@^-7)m>7qp0&kVRB`x6nf~! z)*`w_wv$x2b@T<>-nyd>X~x;Ls#GXK^jzdUQ4E&L7|ykEpHEO&MuspqEWvvY*N@f` zJXYFb_`oW!IO``Ua1MxFdrt*vvI!^fM2FpCI7lIe?_$80i1pc2>BWsAqm!#Ht2Qp+ zZ8$^?WJ#c13x!24kL(Zf@UaUDNpLuj-kVofO$?_zsrv2aXb9Jv&FnGt^vDy}qua%b zk`Jc^=#|y`yRxba%LlQkNbI`lKY-+KGf^8WJRssgF|y_odXS?F)&8yBPZs&G$xU z;+ARBy03}`a1)1mWF&dKc5KkY#7fu6q$c0{ZGIJLK~h_SU3M_fYky)i3Ey81=Y$@` zP+KxnRU?hsQLW($84DCgL^GRJW!6yh4c3T99V9dl3XKTO>~0I*&Q~-YZc1mSQN(oI z8)5Suz+^ZMbNW)XKKkGTDsVUuo|9Sw=NYmXE-b44NnCXlhu)rMUxFzNKDy^@}@B)7>F{J z-Gu3aVPtR!k?hMr#h^&OLd)p7BhRuF3KS@meU7UAF6|!^%ffJ6Xcm3YW7{@FoiFfD z8R!eRoYj^2;~vneS2sSHhN>bQwF$VTIS0b6&%+@kl2Ey?#mQWt8SB1jsw%@tvUK|R zWziwa0x4}b7lv)i;_z`y%TW3<&16D7iT>c+6GDCkOie+$ zJBxuxE5R0bON2zB^PEk)sm3B_(#ROO&zG@W)`5}myc?5tYQ7iDZ!Siz2OeZfT?XC3 zWYBt#Bg_4Nn zrQ{WS7X(W(sXB@!n2j>GV9mn12X8q}YYp+==6{-;Hrh>SP*3N}z#4FH!68uX$|I?(4#kTs--jm3)2`(T^Vo6h@gC)jqp&o|?|*I!!$ z>6k|HK)M2zQZK?zh`&-)I|H@K4?W}_#_{X>>*E7BCu6NJCc>`xK*#Bc;~eRdik1-e z!g^74m{#pB&!oxR1J3W+xSNIT{3KrkR;r64X3kZSBm#FAKN4j2*GhfPIEEEG(A?rc z+FK9*Zm;FtDcjNGK=PqV%||0v$Lo&Wk0(mpT*4VOd}xh-4Mwt6qoqr^>ho~%rMS?D zd|UeA4gXYc%9qJiYE9SkX+;KTQL2j@TbzXObNW7a-E{l+x`6&bTLIc%P*fC&kkSuO z6zfT5M_=AOVTqh5)Olu4UQ62^`mTF^>BY=oUsrIw8qcW{n%adyWughzuG*kWs*tUF zUfr)`@11Bn+B=_3ps}v-&}r|SwE)K)H|&eBXc(xvT=-^4J6CYh{e#_=c{oX9?9GAv z)+7D?%pe7q%=sSpcaMw4+@zN~>U zAKrHvqDM=ArP)O>fHw~VjC=xpyku`6R)F*X^Ii zo4yNZe5we0%)e!%9z#i18DCk`L_QdRn?5TSt=En3p&H7G*f|@FO?l`rYW1EEz6y7G z>Azi$MyR~qzUO&8A{$3!{^kxDkl?mGmk}se=GpA8lkUqS|ZpQEMef+D8%OA29y2%z|h=#zX54}4sK^FR{ z{iANAGx;MN(QrK1+0?x}9;3wb3!~@-ZSEzG4mwe7cpV3m5g%=y(ci+l$_V-;uF=D^0)>_JidkmzjTrlm&+X11&cC?=n&SF?oaC7fD#l%I#Zv= z(MRsVR{QtMgnIEyo--=MC?F#g@5k(S?{0~o8v9UUpP4=Ox`Vba(vZ@|K9l8jKg5j$ zSMn%8-eULC=dK~#MxrNgr-EQnNIe{VG-Zb9d(1}GDtvMA;Gy2XHY=u2laaa}E<5iU zNh`go@sP3zq#rwOU!mGY^KBa#Cr`h%WM;*|Gv+mqp-FF`RIYq-pw5*z{bArTWudb} zayegF$|SArNwBHE|G7C0O;gHTEq&jF?L!rJd3=5Jr|8%+YoCDlDoZJcA^&$KpZpLn z-v>ItHc4e0;M7hQ7Eq+AsUkpWjG47@6-o3g>xyz)Fh0~zXf^Y+KCFC$?JMe90`z|q z$$!LVKh%a}FAAe;%FKA74$MV5^T!$z3P*s+gs^4_wKU>mG8i;M?ou=s0 zT!{M-76ftcuBOtC=zTlV)m7<|*>nmu+2TfjssbN0dzIrk{k2ZK;X)pJauFxVz>Z~+ z--tWcvTcPyf|MjZ>~lG6gM`s0gl{75yVBdfLXU>L*`4jHt@Jr}K_hA%iuQwtESBnL zZnQTnY%K@GHg=^_v(#=abD!Y68t2wSr?_RE2*2^)MxI}(q)q|}%^Ka@L#T{j5_x(^ zEHh=TE1%;4k*Z0O#JZ=MuMBP6!G6`?GK))f0n!n!n(oT|<|)DDO~LELk$Sw{?Q-c* zgqAVrs4JU#d&4J3$?ntPmmht49#I0d;<~H-Xo=>ud$xzKt*=LkR#{bt91VJOHnuxP zngZmCU8m03JR7E2u~^>Pc`W&@-9z!)s)M0?e!SIYwsOb(sgZDI-Pc%{W(I*;D(hLp zX1Otd4o|;*hkR#tS2K0k({K3ZKr71C^@HmL#nu3C?^*X8IGxmo_uuAsmv&RWzM0W| zEH-V4hQgphX{^U&pzqq7nzQW>ldNfWOrs-0wn%szk*xv0D$8`atnm^*EKCzH4^YWs z4oLg7M4I?l34fayk*1v(G#osIZVxA#RNXpuaB2uD@ic+KV*Kl)Q3v4=%f2;l8nKrV z1;L~B%3_n)$hd@EWGa$_C_>Dz!Kgx#IzgIp9;g>x znC)d9Oxct9f`y%uZ1x7l-bTCAq(W+enO}rQA%x93E#Z)aF3dV|>P{I3TiTW8_uS?{ zqRVlSrtdZYtu5)PQ*4TM9*$HnxE8D6R$6i#wEW%Xn{X(gok$$!i;$EUg-1*Iwd63A zP=$yK7#lAz@Q3Ju@Q}>@&TJb6-#QOhgjGhJt})a*g7wB1UnBhFW9RRhePUGlZ4 z925VR^J^>1{Y;#VTBbg_eYA&|F%TG6*l5Vv+8KVd5DISl)SeovWHbuzK#uS+mKZfXgNPl4K<=^b%f#L)W1y>aEr4qecT26V;GIA7$f}<&# zv8%Yu*QB%2dgki)(aALQA;~xZ*E|2{qMx>CYZkPQ^XV!dyl}F)T?Tu{_34;+YK1?C z;K(5&Nfcs95REmRp*Nx6a{O?Qtg&;qrm~Ob=y@x+xEaR# zP38NsRiSrz+yU#H-BqC{RBIqcyAJP>~nu+l>`>%cvMe8_R8#d)`jNGaS3rjXPJ6>KseB)uIkxF4d1W(%JQ0&hr) zi|$64GT&Oo1pb~4i$HXOtg62YPLQ9^a7j=OE$yu*?OcQkTOt_Tw`3l#A9r}KyNFkv zD-??mKtPOy|Jp_TRVU_u$D>*~8XG!L>+9;982-wmN(0U;bYmr$4Tb{lFpH6r0Hs3= zd~K;*V+{3_g%6HUqkLp}0g6%>l$8FhU{#8rL_?$R<(okxeomHZYrt)~^IkG-<4SO$v_ki>lQY(g z2qsK`XiT4RQQGw!=RzH5Unu~BH>n^PwgRR~&V2X7q7q^gjL8;4)ZkS1&{Rk*ru<_93P}yUM23o>Uao2)Q$jc%7Z%yy~dSW_+Cz7zhaFr)J1@B|k?! zq<<3i$k-TK@!6OPx#%0(IG9>nQS00PNi4#W zB}Sz_P>V`RD~XOz%$8D(jfjqZr5qHa5*wiz-Ywaa2Lkc*^aM}cnqFDy%@OBl8l2&R znQ&7m^)Ve-%;qWw`=uS%JLYzRX$Dl;hfzL!!jjsSGxZ*%F z_@ey25G~GNZ@+DQ=B_ClN;jW7G#xx&j4gQxdWbEguMhe_9kmQ)!=lP8cqIC};a>a# zCF_1+9)%)hUSWbNfm4_U}0)#1+bA{D!?lI9}3_E7)YOrh77g{ir2nMm@<-j zsgahF8J;hl9k8uw6N+SfqlqfQP2BZ~kp zlvQR!i8^ZDJQv~IFtxyQyY_r|iklt6uM6+$&1r3cqx0CyJ(+)kVn!?FDJ*Foo66kSPwokGQV#KC}jQ=wtW{W(3ld5$ek9r zo-p{jWOvPCvX;v~bTt-TZcnxhwzqard}{eF=cHOhr%~m^(W`r}n{*Z>2{j^G4CIiL z(a=B~!B0Meh1>+NmFtN`9U-TxF`-C|`PHnOatR5XWZ6%G1_>lI=LxQ4fnp8|)DYpk zqsnGb$MU(=@;as)h|@7;#JtgG-V}jf>~fOC*JTXi&n-?Eb7KD*iN}n^KXmXE>lAE* zyF}OCogl#uCV-5O>g6atyNu*~>t43$tK8yoj}ab;DtmR_bYa9$;u(40*nl%?3tmjg zlD9bdWUrb~uQ}-)o(^h#D|Gc^lEWJ|{G@G35R*987JM@~e~GI_a1pkMzr%=udm8Ql zL95%`-b}!xxxdzLZ`0n|x%OfkiB~6$%ksqIEd86CJ4cnPZ4I(krqxE(57nd)7zAwot#%@*NIYn7>xnK9?2lHEpGZ1XG^>J{-bLeGoto26 zH>BbR7Y^3T7P6Y1&&4>#vQfflq9Zrb97);LM-`d-o@7kU7{&;s`>u@Q6!>)J-LBhDk)dkI45K;W58SC#t~x+Jv%}LI*<@vS0JFKu(JC)G=6Dh0-KN@{`67?i`3l@Fq5)j@az1L z`XCFRZFF}&3$+&B{&G!``m=;r024pkb<(f-vHmy^4={#l(0#r_u4eyX;Z z2gJI^L%1=mP8{!Csce9d_*9EZx|4D^qabQp7Ph1^>Z_RCb+=^=FL{? zE!r2#aaLchTWvDeu??$0oHu&kCWnwQR+k`$6_r;=G#hMvh$u6mnJ!0PySd@H@S*XT zStL1Sf z>FJm^_x5VK7mpmhGRKx;c%YVliz?tSr8NckGc3rAjrQhbiw9EvQdv_tW3E8l;pbv4 zsqYoDWiD*D&MCsN7Sh@)-lwS{SH`!(P&qqJ-|V#O2`k1)ZMKq@37}##M+wL{H?DU@ zjbWazG{H1w7=+!z?+a_imG6FY%d3aQlz{-Z-26Xb(Q5*zvFc~HjQuBWS>Djz(c;(j zZtMUx2LnPOaKQ8b9q|3*z<(BR{c~VI$n}rE{>0tCg1p9Cug`KNEtw@*jP`w%v=ivJ zV9p$UhPoEELyW+QS{O7G%7&D2b%uc$Z&e*~kO+V~c#s`qK7<;L6DU5>ZKowrK6kI( zhyreoIR*1K^KGY=!zvxbKHb>-&|239 zegBDU@Qq*t;lxY07gOiO1qIuk)-a76FA$ARzC#t=+&6U66aBDWpkp8o;x0QcO@3*_ zUN+2Vp{A(b3#Hbk9PPpjP2fzDi^gjbkV{GlqnJ;s)D^{~Z}L6((;s}$Yg}xVS2KmA zx$&Vm2dY8jt?6Ct--sAyQLb8)HYZIb(BrAxUe)oE7YvQVvgVAZtLzAIX3Bc)4X2{N zw}-0g)Q)C>ySY?We8SDu9PWozWA7dEB#ss8!2>4|9m50uA@P=r(0pE1T8}MMq1g?* z?IuvvH(PFeUr0LwNk}jpJZg>LJ^^C!tx=<2uY!v%Iu^C{S(eosZdQc}1k44NrvL=Wbx5$-;OX<^L=r?4361=k<7Z? z5+p5H?k4z-#XQ6%l+!DEu40XW^=N_1zXVZ>FM_XEUS; z)U>+ITE2KRr7&yLkW-Q~hi314L2?#h?U@x6#*Yz#la7WNR)!^2fvs2ca174KS;!Kw zxUS&lM|=xut0`3mtjQr$SLy}GI$?ZwPS_x}>=3YkO@ILVUGnsMU&HipVlhlCGO!DH8u-A9w zBHLp2Xa1<5h#E=FQFVM%Hb+IS{AR%MYRjBNt+DijL=I ze-IS}AO7}YtBjbMC<|B_t{{K!8U8~f7P7N@y)($b@ZT`v@VMWMc;M^~g;ZP2x37)# z%20Qb4INmalemRIi>rquAf~-3IzZ3?o{nBB(1`FyjESBm;ZYcW@15=(?nY>36=*={ zbj@{v=>uoS)pzgb7oyJ=@4-Ci?!^e>^)U>jN`S-()QdC1|M-Iz zDVB(VQ9_VS{k{`M*Vi{^R4icv?GsJQDtw?2+q;5*g`($zgvBy6==fEzrE!YB_3O(R zb+Xxt`A%%N1c>TnU^P89+fSR|sx-#oXr^e0=g6SB;fIj&h%Q}2GTZlGGn-vK^ShAh z>I$gOzK&4VKQenM-pZ8jzq!6d^ZR_NO9vLW#bUWSJmKrsnnR6C(bsHt`L2}sMshgF_d>4ZD=(d89raL)G_&2r?CB~h`&0KJz$VjtY%5*N-?GW6JHoxn zbI(k7*@N;0jNQo+`R@Xj_ELbq$0xe6eY13ms|2=5@$!gyJL)sez29#G4Vn~v+GwphrHVN(iKb99v@I_=Bmq*O?9Wo!Dm{fEe^`!#Ck$H#rTg$kr zNpZSxdhog!+D_&YU#qv~8_;g8&sq9<-46}LeYHkXE7R#xTru^fc(dD{4%Pl|1Khj5 zevtCd+CdX|nzM3LM?xf4?`8?i6}o7cfD?*_=ey8;p98`nAyKvZp4TlU(uQtBFI5D= z2;mqKyW;IR6ch9k?V|?LkLvZ3ZL6-a!iZ{(;u@cJZp6mq1g1t{bVrghV*wFCn^fH@ zx0_>A6Nl9OBGTNMYm6(N#lxW)s%M&FSe&rbFQ@Cs`dg~7^fsI%hgm-+E#<$d%hW<) z%%o9_RpD@hh4-vO3k#pYeq~14MAvJw4BazWm|)}@7=OlG0_W$!A|RIa@hx zE1}~xR7-Z0+J6TyNai=cH%R+GWHH|ViMv)XHMSDawX(J{)z`K76Kgd4XA0x>8Lv1Y zgUpB0ex^frn7s;T@L9!_0J@(SE97fNSdgx>3F*l=HA8JcKO>)zKcpzDC?OQH+)QkW z4K0Pz3%cR5-BHKJxRtkMV;_5vlUM7R`kxU4U1mJ+j=x1XDpb<+OfbU9#qzKd);td-H6; zb;|w`ZFj_GyWP*ESjipv&9`q6Eza;>XYc%0>>wCR&#>Z2pR2uGPQ1K#t+%u8j?27$ zWGiu;(wDO?OycpTh^!FR5YT*rLQD;J?dXP@UgmZ>CPl5HzRB{s+`ZglLu>%m&5t1< zw=nMO#QONo?o5UOUmrg%-WnAW6NzDxQntqSzF6CJGh_k&V$(v$A`+rin2SML$kF;% zK7Lun*P`ZXZJ^j8*=;H$K!p#xgu2Q-LYHcaqhL(Jjz<}auNzEHt`|q#V|^xjpoX#A zLRHLdvW8KU@AkZ0!*h{5WgTMd$jSaCx#eYvvgqM%c{VaP3!&$!j-?`Jky}+neu?{~ z(O+vL>Aqgf<2)-OTOUbYMo1D<6x5fQ7lI)JdXk~$ZJ--+aF#NV9Z=4@kJD0OV@nWw zc7z!}jKc^efK?cRciKP-WdTA z0;~{bn`uH3Nd6=ACZ3V&1LZQrC>d&Eet5IN_IS9LDXZGXtZTN^viA51mj1ZHXYif10(fSt)2A#RE z>Ex+fggYi#*X~G8?YOyE88g!^aOt_KV@SP>Cb_PIx=~T16{x&^nv`K|w7D+6T51#$ z|9I~iq_Fc}2Bf1{{?eH6{=%4)98CWmJ8DzbdbK0Zv&tk!ZVT{4{~|hIsVpx=A~UM@ zBwIZW995|&;}s0GlMpG0kPyg-IT$%4+@_3l&=5pf_%A|>Xn;mVYm4ct)9NU6wlfYb zGwu^@`zs7H_bWL)WIB7ktXkwRov|6DffH16bG2nBP|iuZ6I3jH-(zDA#Pi9VAaASq zqBhyx77Ixl947kp1`Vanr~)>Q4i0!%SDfFC4x`7!Z;a?!UYxE?7cMeU7nv?^aWI*_<)XU|fH2 z?BqbmMPg5}t?#QcolHxunv8(UDL_Kb)U{4>>)9Q!hp79d&uNE218w9HjT^cGcA5?{ zvC%DCdvs`VMl5j;1F+l}7&R=)W9dK#RzC&ixFPUkH9QF{b{)_kh;8#m$Y$eM5?0tvefu~0pBhxBn+fR zCtc%kW&HfM&ULijR(_M>Ox)p5?c7igA$~(gy=(D~UvdYLzT<6ZOI)CACXr6j8(p^d zG*#Qgl*3d|+UXsw!k@^TJ&kx1@~FCGmw{22j-s^Sl7WsGLhaz0FG(OO*yN|*7WL*` z=MBr{t!QbNdZmTsrvWd6VV8D2ik~EynAgd(UT)!olfqA{9u5k>+e3ftI`l{a~ z@vP=H*|uvG3QT#(BZ@brc=QtRy=yLZV^h=dkRi=(HdzU=^~+<$%^qFRqe!>~I);&F zKP2kYBza=GSx~gn2Oa+>5QUn2cb64@g^{@Ww(^o2qAha0>& zB`#wvcK)PK{kcmePqJmXvSe}K&F+=jml)|H-eld+e2`4$u0M)u_un$D5c(g0J|#{Z zsstYdq-f%Zjrqydy^pC#83s-i;OiDOMMh>{t`?^U!9;(hR?76MZMMpTyGX%wv1Lm#kz)` zkh72sl4{rLeAS~8I2g<#N+XF=v=zE&2>pA6zBkGZ{QmL=0OQ8wtXt!J;JPW z=4$s!pk`_y*9c4^h4$8LllK_rQvH$$0T)DRH$NC;E_u+N9E--E^D-zv82_r*0eazk{qq+G0{{1ML~tPBv4q#O%>Nr%rS#{KF@73Z{!d2M zGqw8V=>O>I_UEJH{xo{gUyS~bE^q&9uMNP#A2I%TuPO0Q7+}D^mdz^%KXsD(71Zmt z>%WBcubtgMJ#IUBz`~LS3k1afJE#U9<9`?G7rLT0FtquTf+_&ZzoO&!w*YhgM28q4 zrTYsAn*YV}FMdS*tHybMLc#Al2L4O|KHYyo!LP2C{RIV#fHd`z$FceYU=r;BBv1X5 z43t!UO~J2N*0+DHYx|Y{!2qf2f9W3p1Jv}t(xbmO!%@{=WB%jXvrY80A459 zD*!83<8c6hp(4N}qx^{}Vzs{n;Ipyeb96BI=Pk{@*e3-@@mGL3Zxy@P0D$O#h398L zB8|TU{HJ&Pj{sj+GMjIV$*HR%6r`rH3fcduUm_quxpF(5r=QwDH$4gJRnsiOOTg7BNV{SV}?A`Sp! zzsG!?kYs<@tNEERa>HL^idi`r+8OEU8~&QHQ$YFV?-_dqVGu3$X8_C(Trfbj%kQMu z;1l?NgNXg6Fu>-$p`C)Eos+5lFEfGw_I{82%HALKhW$I_7d{S?U$Z9#NI6>knzh&c zg5R^&0MOZ>VKOKZVES(ZJ|w>bXz+>qYXE+0T@gELN1H#<727YcEVVH&Q85rB! z80!DJiWx8&U&?=;E4^iSTFg+rtw#X;p#fYh?@yGR*!>rj7*hj0kj?)tYynWw>Gv}g z%7A!nrCah%5J2(?fX~k%h=JpOMTLW|9iR;Dmvp?=a{tS6UbX(mxbf%6&fV$H>G<~} zy9jWCpGwnzKZ#%X=KoI&D?d=!3CF1z#p#m+yW zsHKGz1+4810j`)z+0f1&P%*~k1fYSM4^T00sb}HJ=ip#xs^{onX#XeH_50}dUm<6U zQSLwvVAi1n7GKey2y=A#HwpWdG5m`rF)JhMKcVK2*7$1cJi@dFYyc7Q13q#;QB&^r z{}VN0RyK}+^98>yhXfeKAITa8ED~rPHtQ{b)y@eJmka$wR)G6|LDoNyI{YaqKo$Sb zF8-yjd3{_g9>98q2#5kyej;VY>%So7A93pcOeJ|kBSSkwt5;|G%d*j5$mvymH4{=4 z?Eh5X&%`zQ{`bVaPJRJvOB+)Q!~ax?uh!1I%78EiAnph-(-nV`T!PfEZwA>HQ>|m8$=O z0ue(iLpxmuLjx%Ven(Ra10e%bhu3XeDPWRSCaapA-+oL&tD6lYW@p4 z|Bcr|z{L6gIyr+Bgkd0xwuqaeD-{tG6sjn=(gTQi7Z2gCTW{b+y73Zjy@XfL`AFao zPNu2!0I!pzX=d`0pVi2QTIzk=Jrm!+yuLp2Me$uydQos|gR`dSgyCTYy;VWaU+F=c zw^${_eIjCAU0V|W3?1_=j<2zXuM^l|CpSwWSbe4(W|PQqm@O=P!UWGwxdxt#W}xT# z8%+&Woh53WHxxY^m?EwV48l4`2QQnoz_sqzz6RRD;G zfrhWd&n6cmn-){uBpl9!Ff;&Qb4K^n#Rs^`TLi{dfQB6=ZZxi%W56I9@UCg8hQ(uQ z!y2^%Awf3?orE?7iT7@}qET0~d7Kp6lQkgdC@!_@v_CIxNW!L&u)Zalt`zXIY%kA! LEF*6v|LyDp1AIW7 literal 0 HcmV?d00001