Compare commits
66
Commits
5fc313b707
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695ba1530e | ||
|
|
c2f6f42a3c | ||
|
|
2b9389fcb8 | ||
|
|
44d3cd0326 | ||
|
|
3ff9b48a19 | ||
|
|
20325bed00 | ||
|
|
0efde94a74 | ||
|
|
4211b72041 | ||
|
|
e9e1c1722d | ||
|
|
583d06f3d0 | ||
|
|
83182cb538 | ||
|
|
bbcb865055 | ||
|
|
cf8d6da5b5 | ||
|
|
ae1bd4cdb0 | ||
|
|
f1059fd92b | ||
|
|
5785a2c10e | ||
|
|
10c791d034 | ||
|
|
04e708c4fe | ||
|
|
d658ab5b6a | ||
|
|
ae8327807e | ||
|
|
74ba80ddf9 | ||
|
|
6ff1d07f31 | ||
|
|
7e945d310d | ||
|
|
3e9be2bd54 | ||
|
|
7112b7ed40 | ||
|
|
f711f1c5e5 | ||
|
|
6d5df72d2f | ||
|
|
0225df144c | ||
|
|
461847e4ed | ||
|
|
951ed3f00a | ||
|
|
7471dbe66f | ||
|
|
5972f3b7e1 | ||
|
|
8abba50e0e | ||
|
|
3ac70fb699 | ||
|
|
28b3ab80a2 | ||
|
|
18e10a004f | ||
|
|
a73fb4a214 | ||
|
|
b91fd1b514 | ||
|
|
e9c4b64240 | ||
|
|
ff31293df3 | ||
|
|
6920ed4c0d | ||
|
|
70ccd1cb8e | ||
|
|
986b24180a | ||
|
|
a697b85643 | ||
|
|
2ea71d90e1 | ||
|
|
9fe45c810e | ||
|
|
b7789a1914 | ||
|
|
77da929895 | ||
|
|
37f50b7e61 | ||
|
|
12a3f240eb | ||
|
|
f43a0450ac | ||
|
|
07b817ccca | ||
|
|
acff097027 | ||
|
|
3c34db6a65 | ||
|
|
4d6c07c34c | ||
|
|
a8e02ab176 | ||
|
|
ce5e806adb | ||
|
|
c8632e25c2 | ||
|
|
02a752ad87 | ||
|
|
262f0da9c2 | ||
|
|
f21601fc68 | ||
|
|
063ff2ac6a | ||
|
|
8db5870084 | ||
|
|
2728235dc7 | ||
|
|
58392d95d0 | ||
|
|
bf5c2e1b4d |
@@ -1,324 +0,0 @@
|
|||||||
# YhCore — .NET 5.0 底层框架搭建方案
|
|
||||||
|
|
||||||
> 参考项目:`E:\YHXT\java\bschclassmanage`(Spring Boot 2.7 + JPA + MySQL + Redis + Nacos)
|
|
||||||
> 本地类库支撑:`E:\YHXT\net\Library`
|
|
||||||
> 范围:**仅底层框架**(分层结构、基础设施、横切组件),不含任何业务逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景与目标
|
|
||||||
|
|
||||||
将现有 Java(Spring Boot 2.7)项目 `bschclassmanage` 的底层框架能力平移到 .NET 5.0,形成一套可复用的后端基础框架,供 `YhCore` 及后续 .NET 项目使用。
|
|
||||||
|
|
||||||
**目标:**
|
|
||||||
|
|
||||||
1. 保持与 Java 版一致的分层模型与开发习惯(Controller → Service → Repository → Entity),降低团队切换成本。
|
|
||||||
2. 复用 `E:\YHXT\net\Library` 下已有的 DLL(日志、调度、文档处理、JSON 等),减少重复采购/引入。
|
|
||||||
3. 提供统一的基础设施:配置管理、日志、异常处理、统一响应、用户上下文、操作日志 AOP、数据访问、缓存、任务调度、接口文档、鉴权。
|
|
||||||
4. 只搭骨架,不写业务——框架落地后业务模块以"填空"方式接入。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Java 参考项目结构分析
|
|
||||||
|
|
||||||
`bschclassmanage` 的包结构即框架的职责划分,逐项映射如下:
|
|
||||||
|
|
||||||
| Java 包 / 组件 | 职责 | 关键实现 |
|
|
||||||
| -------------------------------------------- | ------------------------------------------------ | ------------------------------- |
|
|
||||||
| `controller` (api / mobiapi / oapi) | 接口入口,按渠道分三套 | Spring MVC |
|
|
||||||
| `service` / `service.impl` | 业务服务层 | 接口 + 实现 |
|
|
||||||
| `Repository` / `specification` | 数据访问层 + 动态查询 | Spring Data JPA + Specification |
|
|
||||||
| `entity` | 数据库实体 | Hibernate 注解实体 |
|
|
||||||
| `model` (dto / vo / excel / constant / tool) | 传输与视图模型 | POJO + ModelMapper 转换 |
|
|
||||||
| `config` | 框架配置(Async / Redis / Swagger / WebMvc / File) | `@Configuration` |
|
|
||||||
| `interceptor` | 用户上下文拦截器 | `UserContextInterceptor` |
|
|
||||||
| `annotation` + `aspect` | 操作日志、同步日志 AOP | `@OperationLog` + AspectJ |
|
|
||||||
| `scheduler` | 定时任务(升级、日志清理) | Spring `@Scheduled` |
|
|
||||||
| `validation` | 自定义校验(密码等) | Hibernate Validator |
|
|
||||||
| `utils` | 30+ 工具类(日期/分页/签名/加解压/Redis/HTTP…) | 静态工具类 |
|
|
||||||
| 配置体系 | `bootstrap.yml`(Nacos) + `application-{env}.yml` | Nacos 配置中心,多 Profile |
|
|
||||||
| 日志 | `logback-spring.xml` | Logback + Slf4j |
|
|
||||||
|
|
||||||
**中间件清单**(来自 pom.xml / application.yml):MySQL 8、Redis、RabbitMQ(已预留配置)、Nacos、CAS SSO(yh-auth)、应用间签名鉴权(yh-app-auth)、Swagger(SpringDoc)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 技术选型对照表(Java → .NET 5.0)
|
|
||||||
|
|
||||||
| 能力 | Java 实现 | .NET 5.0 方案 | 来源 |
|
|
||||||
| -------- | -------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| Web 框架 | Spring MVC | **ASP.NET Core 5.0 Web API** | 内置 |
|
|
||||||
| 依赖注入 | Spring IoC | **内置 DI**(Autofac 可选,AOP 需要时引入) | 内置 / NuGet |
|
|
||||||
| ORM | Spring Data JPA (Hibernate) | **EF Core 5.0** 为主;高频 SQL 用 **Dapper** | NuGet `Microsoft.EntityFrameworkCore.SqlServer 5.x` + Library `Dapper.Common.dll` |
|
|
||||||
| 数据库 | mysql-connector-java 8.0.33(MySQL 8) | **主库 SQL Server**(`Microsoft.EntityFrameworkCore.SqlServer 5.x`);**MySQL 可切换**(`Pomelo.EntityFrameworkCore.MySql 5.x`,按 `DatabaseProvider` 配置切换) | NuGet |
|
|
||||||
| 动态条件查询 | JPA Specification | EF Core `IQueryable` 链式拼装 + 自定义 Specification 模式 | 框架自研(轻量) |
|
|
||||||
| 缓存 | spring-data-redis | **StackExchange.Redis**(封装 `RedisUtil` 对应 Java 版) | NuGet |
|
|
||||||
| 配置中心 | Nacos + 多 Profile | **appsettings.{env}.json** 为基础;预留 **nacos-sdk-csharp** 接入点 | NuGet(可选) |
|
|
||||||
| 日志 | Logback + Slf4j接口日志 | **log4net**(Library 已有),**仅用于接口访问日志**:单一中间件记录接口调用详情,其余日志(SQL/框架/业务 Debug)全部关闭 | Library |
|
|
||||||
| JSON | fastjson / Jackson | **Newtonsoft.Json**(Library 已有,兼容旧接口格式) | Library |
|
|
||||||
| 对象映射 | ModelMapper | **AutoMapper** | NuGet |
|
|
||||||
| 参数校验 | Hibernate Validator | **FluentValidation**(或 DataAnnotations) | NuGet |
|
|
||||||
| 接口文档 | SpringDoc OpenAPI | **Swashbuckle.AspNetCore 5.x** | NuGet |
|
|
||||||
| 定时任务 | `@Scheduled` / scheduler 包 | **Quartz.NET 3.x**(Library 已有 `Quartz.dll`,需核版本)或 `BackgroundService` | Library |
|
|
||||||
| AOP | AspectJ(`aspect` + `annotation`) | **ActionFilter + 自定义 Attribute**(操作日志);全局逻辑用 **Middleware** | 内置 |
|
|
||||||
| 拦截器 | HandlerInterceptor | **Middleware / ActionFilter** | 内置 |
|
|
||||||
| HTTP 客户端 | HttpClient / OkHttp | **IHttpClientFactory** | NuGet `Microsoft.Extensions.Http` |
|
|
||||||
| Excel/文档 | easypoi (POI) | **Aspose.Cells / Aspose.Words**(Library 已有,打印场景核心) | Library |
|
|
||||||
| 压缩 | GzipUtil | `ICSharpCode.SharpZipLib`(Library 已有)或 BCL `System.IO.Compression` | Library |
|
|
||||||
| HTML 解析 | CommonMark / jsoup 类 | **HtmlAgilityPack**(Library 已有) | Library |
|
|
||||||
| SSO / 鉴权 | CAS(yh-auth)+ yh-app-auth 签名 | 自定义 `AuthenticationHandler` + 签名中间件(对接 `BschOpenApi.dll`) | Library + 自研 |
|
|
||||||
| 短信/消息 | — | `HyCloud.Sms.dll` / `BestschSendMsg.dll`(按需接入) | Library |
|
|
||||||
| Lombok | `@Getter/@Setter` | C# 9 `record` / 自动属性,无需替代 | 语言内置 |
|
|
||||||
| 异步线程池 | AsyncConfig | `async/await` + `Task.Run`,无需独立配置 | 语言内置 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Library DLL 适配性评估(.NET 5.0 兼容性)
|
|
||||||
|
|
||||||
> .NET 5.0 可引用面向 **.NET Standard 2.0/2.1** 或 **.NET Core/.NET 5+** 的程序集;纯 .NET Framework 4.x 程序集多数可运行但需逐一验证。
|
|
||||||
|
|
||||||
| DLL | 用途 | .NET 5 兼容策略 |
|
|
||||||
| ------------------------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
||||||
| `Aspose.Cells/Words/Slides.dll` | Excel/Word/PPT 文档生成(打印核心) | 需确认版本 ≥ 20.x(支持 .NET Standard 2.0)。旧 .NET Framework 版建议升级 NuGet 官方包 |
|
|
||||||
| `EntityFramework.dll` + `EntityFramework.SqlServer.dll` | EF6(SQL Server) | ⚠️ **不建议使用**:EF6 仅 6.4+ 支持 .NET Core;本方案选 EF Core 5(SqlServer 提供方) |
|
|
||||||
| `Dapper.Common.dll` | Dapper 扩展 | 验证目标框架;不兼容则改用 NuGet `Dapper` + `Dapper.Contrib` |
|
|
||||||
| `log4net.dll` + `Common.Logging*.dll` | 日志 | log4net 2.0.8+ 支持 .NET Standard 2.0,可直接用;建议只保留 log4net,弃用 Common.Logging(.NET 5 有官方 `ILogger` 抽象) |
|
|
||||||
| `Quartz.dll` | 任务调度 | 需 ≥ 3.1(.NET Standard 2.0);旧 2.x 不兼容,则 NuGet 升级 `Quartz` 3.x |
|
|
||||||
| `Newtonsoft.Json.dll` | JSON | 直接用(或 NuGet 对齐版本) |
|
|
||||||
| `ICSharpCode.SharpZipLib.dll` | 压缩/解压 | 1.2+ 支持 .NET Standard 2.0,可用 |
|
|
||||||
| `HtmlAgilityPack.dll` | HTML 解析 | 1.11+ 支持 .NET Standard 2.0,可用 |
|
|
||||||
| `C5.dll` | 扩展集合 | 2.5+ 支持 .NET Standard,可用(优先级低) |
|
|
||||||
| `BschOpenApi.dll` | 开放平台签名 | ⚠️ 需反编译确认目标框架;若为 .NET Framework 4.x 且无 Core 版,封装为独立 Facade 隔离风险 |
|
|
||||||
| `BestschSendMsg.dll` / `HyCloud.Sms.dll` / `BestSchool.Unread.dll` | 消息/短信 | 同上,逐个验证;不兼容走 HTTP API 调用替代 |
|
|
||||||
| `HTTPSSSL.dll` / `DecompressFile.dll` | HTTPS / 解压 | .NET 5 原生支持,建议**弃用**,用 BCL 替代 |
|
|
||||||
|
|
||||||
**结论**:核心三件(Aspose、log4net、Quartz)+ Newtonsoft.Json + SharpZipLib + HtmlAgilityPack 构成 Library 复用主力;EntityFramework、HTTPSSSL、DecompressFile 弃用;业务 DLL(BschOpenApi/Sms 等)做兼容性验证后封装接入。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 解决方案结构设计
|
|
||||||
|
|
||||||
```
|
|
||||||
E:\YHXT\net\YhCore\
|
|
||||||
├─ YhCore.sln
|
|
||||||
├─ docs\ # 方案与设计文档
|
|
||||||
├─ lib\ # 本地 DLL 引用(软链或复制自 E:\YHXT\net\Library)
|
|
||||||
├─ sqlscript\ # 数据库脚本(沿用 Java 项目惯例)
|
|
||||||
├─ src\
|
|
||||||
│ ├─ YhCore.WebApi\ # ★ 启动项目(对应 Java 的 controller + config)
|
|
||||||
│ │ ├─ Controllers\
|
|
||||||
│ │ │ ├─ Api\ # 对应 controller/api
|
|
||||||
│ │ │ ├─ MobiApi\ # 对应 controller/mobiapi
|
|
||||||
│ │ │ └─ OApi\ # 对应 controller/oapi
|
|
||||||
│ │ ├─ Middleware\ # 对应 interceptor:接口访问日志、用户上下文、异常、签名
|
|
||||||
│ │ ├─ Filters\ # 对应 aspect+annotation:操作日志、校验
|
|
||||||
│ │ ├─ Attributes\ # 对应 annotation:[OperationLog] 等
|
|
||||||
│ │ ├─ Extensions\ # ServiceCollection / ApplicationBuilder 扩展
|
|
||||||
│ │ ├─ appsettings.json
|
|
||||||
│ │ ├─ appsettings.Development.json
|
|
||||||
│ │ ├─ appsettings.Production.json
|
|
||||||
│ │ ├─ log4net.config
|
|
||||||
│ │ ├─ Program.cs
|
|
||||||
│ │ └─ Startup.cs
|
|
||||||
│ ├─ YhCore.Service\ # 业务服务层(对应 service + service.impl)
|
|
||||||
│ │ ├─ Interfaces\
|
|
||||||
│ │ └─ Impl\
|
|
||||||
│ ├─ YhCore.Repository\ # 数据访问层(对应 Repository + specification)
|
|
||||||
│ │ ├─ Base\ # Repository 基类、IRepository<T>、UnitOfWork
|
|
||||||
│ │ ├─ Specification\ # 动态查询条件构造
|
|
||||||
│ │ └─ YHDbContext.cs # EF Core 上下文
|
|
||||||
│ ├─ YhCore.Entity\ # 数据库实体(对应 entity,DB First 生成)
|
|
||||||
│ ├─ YhCore.Model\ # 对应 model:DTO / VO / Excel / 常量
|
|
||||||
│ │ ├─ Dto\
|
|
||||||
│ │ ├─ Vo\
|
|
||||||
│ │ ├─ Excel\
|
|
||||||
│ │ └─ Constant\
|
|
||||||
│ ├─ YhCore.Common\ # 对应 utils:30+ 工具类的 .NET 版
|
|
||||||
│ │ ├─ Result\ # ServiceResult / ApiResult 统一响应
|
|
||||||
│ │ ├─ UserContext\ # 当前用户上下文(AsyncLocal 实现)
|
|
||||||
│ │ ├─ Cache\ # RedisUtil 封装
|
|
||||||
│ │ ├─ Http\ # HttpClient 封装
|
|
||||||
│ │ ├─ Security\ # Signature 签名、加密
|
|
||||||
│ │ └─ Utils\ # 日期/分页/枚举/校验/Gzip…
|
|
||||||
│ └─ YhCore.Task\ # 对应 scheduler:Quartz 任务宿主
|
|
||||||
│ ├─ Jobs\
|
|
||||||
│ └─ QuartzHostedService.cs
|
|
||||||
├─ tests\
|
|
||||||
│ └─ YhCore.Tests\ # xUnit 单元测试
|
|
||||||
└─ docker\ # Dockerfile / compose(沿用 Java 项目惯例)
|
|
||||||
```
|
|
||||||
|
|
||||||
**分层依赖方向**(单向,禁止反向引用):
|
|
||||||
|
|
||||||
```
|
|
||||||
WebApi → Service → Repository → Entity
|
|
||||||
└──────→ Model ←──────┘
|
|
||||||
└──────────→ Common ←──────────┘(Common 不依赖任何上层)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 底层框架核心组件设计
|
|
||||||
|
|
||||||
### 6.1 配置体系(对应 bootstrap.yml + application-{env}.yml)
|
|
||||||
|
|
||||||
- 本地开发:`appsettings.json` + `appsettings.Development.json`(默认不连 Nacos,对应 Java 的 `profile=local`)。
|
|
||||||
- 生产:环境变量注入(对应 CI 注入 `SPRING_PROFILES_ACTIVE=prod`)+ 可选 Nacos(`nacos-sdk-csharp`),加载顺序沿用"共享配置 → 应用配置 → 环境变量覆盖"的三级优先级约定。
|
|
||||||
- 强类型配置:`services.Configure<YhSsoOptions>(configuration.GetSection("Yh:Sso"))`,Options 类按 Java yml 的 key 结构一一对应(`yh.sso.*`、`yh.app-auth.*`、datasource、redis 等)。
|
|
||||||
|
|
||||||
### 6.2 日志(对应 logback-spring.xml)
|
|
||||||
|
|
||||||
**只保留接口访问日志,其余日志一律去除。**
|
|
||||||
|
|
||||||
- 通过 `ApiAccessLogMiddleware`(请求管道最外层)统一记录每次接口调用详情,框架不再输出任何其他日志(SQL 日志、框架运行日志、业务 Debug/Info 日志全部关闭)。
|
|
||||||
- **记录字段**:请求时间、渠道(api / mobiapi / oapi)、请求路径与方法、Query 与 Body(敏感字段脱敏,对应 `SensitiveDataUtil`)、客户端 IP、当前用户(取 `UserContext`)、响应状态码、响应耗时、异常摘要(若本次请求出错)。
|
|
||||||
- **落地**:log4net(Library 复用)配置单个 `RollingFileAppender`,按天滚动一个接口日志文件,不做级别分文件。
|
|
||||||
- **屏蔽其他输出**:`appsettings.json` 中将 `Microsoft.*`、`System.*`、`Pomelo.*`(EF Core SQL 日志)等内置日志类别全部设为 `None`;代码规范上禁止在业务代码中调用 `ILogger` 写日志。
|
|
||||||
|
|
||||||
### 6.3 统一响应(对应 yh-api-std / BestSchool.Common 的 ServiceResult)
|
|
||||||
|
|
||||||
复用 `Library\新建文件夹\BestSchool.Common\ServiceResult.cs` 的思路,定义:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class ApiResult<T>
|
|
||||||
{
|
|
||||||
public int Code { get; set; } // 0=成功
|
|
||||||
public string Message { get; set; }
|
|
||||||
public T Data { get; set; }
|
|
||||||
public static ApiResult<T> Ok(T data) => new() { Code = 0, Data = data };
|
|
||||||
public static ApiResult<T> Fail(string msg, int code = -1) => new() { Code = code, Message = msg };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
配合 `ActionFilter` 自动包装 Controller 返回值,业务代码只返回 `T`。
|
|
||||||
|
|
||||||
### 6.4 全局异常处理中间件
|
|
||||||
|
|
||||||
- 捕获未处理异常 → 返回统一 `ApiResult.Fail`(对应 Java 的 `@ControllerAdvice`);异常详情由 6.2 的接口访问日志统一记录,不单独输出错误日志。
|
|
||||||
- 自定义 `BizException`:Service 层抛出业务异常,中间件翻译为友好错误码。
|
|
||||||
|
|
||||||
### 6.5 用户上下文(对应 UserContextInterceptor + UserContext + ControllerUserContextAspect)
|
|
||||||
|
|
||||||
- `UserContextMiddleware`:从 CAS Ticket / Header 解析当前用户,写入 `AsyncLocal<UserContext>`。
|
|
||||||
- 全链路任意位置通过 `UserContextHolder.Current` 获取(对应 Java 的 ThreadLocal 方案,AsyncLocal 是 .NET 的等价物且对 async 安全)。
|
|
||||||
- **Java `DWebMvcConfig` 不迁移、直接移除**:它仅负责把 `UserContextInterceptor` 注册到 `/**`,中间件方式天然作用于全管道,无需等价的注册配置类。
|
|
||||||
|
|
||||||
### 6.6 操作日志 AOP(对应 @OperationLog + OperationLogAspect)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[AttributeUsage(AttributeTargets.Method)]
|
|
||||||
public class OperationLogAttribute : Attribute
|
|
||||||
{
|
|
||||||
public string Module { get; }
|
|
||||||
public string Action { get; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `[OperationLog("班级", "升级")]` 标注在 Controller 方法上;
|
|
||||||
- `OperationLogFilter : IAsyncActionFilter` 在方法执行后解析 SpEL 等价表达式(用路由参数/参数名占位符),异步落库到操作日志表(对应 `OperationLogResolver` + `OperationLogRecordRepository`)。
|
|
||||||
|
|
||||||
### 6.7 数据访问(对应 JPA Repository + Specification)
|
|
||||||
|
|
||||||
- **数据库方案**:主库 **SQL Server**(`Server=localhost;Database=EduCloudDocPrint`),同时**支持切换 MySQL**——`appsettings.json` 中 `DatabaseProvider` 取 `SqlServer`(默认)或 `MySql`,`AddDataLayer` 扩展按此选择 EF Core 提供方(`Microsoft.EntityFrameworkCore.SqlServer 5.x` / `Pomelo.EntityFrameworkCore.MySql 5.x`)。连接串生产环境用环境变量覆盖(`ConnectionStrings__SqlServer`),不入库密码。
|
|
||||||
- **EF Core 5**:`YHDbContext` 集中管理实体;旧 EF6 实体无 `[Key]` 标注,统一按约定推断主键(优先 `Id`,其次 `SerID`,皆无则 Keyless);`IRepository<T>` / `RepositoryBase<T>` 提供 CRUD、分页(对应 `PageUtil`)、批量操作。
|
|
||||||
- **Specification 轻量实现**:`Expression<Func<T, bool>>` 组合器(And/Or/Not),对应 Java `specification` 包的动态查询。
|
|
||||||
- **Dapper 通道**:复杂报表/批量快照插入(对应 Java 用 JdbcTemplate 的 `backupAllSnapshot` 场景)走 Dapper,与 EF 共存。
|
|
||||||
- 事务:`IDbContextTransaction` 显式事务 + UnitOfWork 模式;生产 `ddl-auto=none` 对应 EF **禁用自动迁移**,结构变更走 `sqlscript` 脚本。
|
|
||||||
|
|
||||||
### 6.8 Redis 缓存(对应 RedisConfig + RedisUtil)
|
|
||||||
|
|
||||||
- `StackExchange.Redis` 单例 `ConnectionMultiplexer` 注入;
|
|
||||||
- `RedisUtil` 封装 String/Hash/Set/分布式锁,Key 规范常量集中在 `RedisConstants`(对应 Java 同名类)。
|
|
||||||
|
|
||||||
### 6.9 任务调度(对应 scheduler 包)
|
|
||||||
|
|
||||||
- Quartz.NET 3.x + `QuartzHostedService` 承载;Job 通过 DI 构造(可访问 Service 层)。
|
|
||||||
- 框架内置两个示例 Job 骨架:定时任务模板、日志清理任务模板(对应 `SysOperationLogCleanupTask`),业务 Job 后续按模板添加。
|
|
||||||
- 轻量场景可直接用 `BackgroundService` + `PeriodicTimer`。
|
|
||||||
|
|
||||||
### 6.10 接口文档(对应 SwaggerConfig)
|
|
||||||
|
|
||||||
- Swashbuckle:按渠道分组(api / mobiapi / oapi 三个 Swagger Doc),Header 鉴权参数预置,对应 Java 的三套 controller 分组。
|
|
||||||
|
|
||||||
### 6.11 鉴权(对应 yh-auth CAS + yh-app-auth 签名)
|
|
||||||
|
|
||||||
- `CasAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>`:复刻 CAS ticket 校验与 SSO 重定向逻辑(配置项对齐 `yh.sso.*`)。
|
|
||||||
- `AppAuthMiddleware`:应用间调用签名校验(app-key / timestamp / sign),对应 `yh.app-auth.*`;优先尝试复用 `BschOpenApi.dll` 的签名算法,不兼容则按 Signature.java 逻辑重写。
|
|
||||||
|
|
||||||
### 6.12 参数校验(对应 validation 包)
|
|
||||||
|
|
||||||
- FluentValidation:每个 DTO 一个 `AbstractValidator<T>`,自动注册;
|
|
||||||
- 自定义校验规则(如密码强度,对应 `PasswordValidation`)实现为可复用扩展方法。
|
|
||||||
|
|
||||||
### 6.13 文件上传(对应 FileConfig + multipart 50MB)
|
|
||||||
|
|
||||||
- `FormOptions` 配置 `MultipartBodyLengthLimit = 50MB`;
|
|
||||||
- 上传路径走强类型配置 `FileOptions`,对应 Java `FileConfig`。
|
|
||||||
|
|
||||||
### 6.14 路由常量(对应 Java `Config` 枚举的 API_PATH / MOBILEAPI_PATH / OAPI)
|
|
||||||
|
|
||||||
- Java `Config` 枚举中的三个渠道路由前缀,集中在 `Common` 层 `ApiRoutes` 常量类:
|
|
||||||
`ApiPath = "api"`、`MobiApiPath = "mobiapi"`、`OApiPath = "oapi"`。
|
|
||||||
- Controller 一律用 `[Route(ApiRoutes.ApiPath + "/log")]` 引用常量,**禁止硬编码 `"api/..."` 字符串**,与 Java 版 `Config.API_PATH` 常量化做法保持一致。
|
|
||||||
- `Config` 枚举的 `FILE_PATH` / `FILE_WEB_PATH` 已由 6.13 `FileOptions` 覆盖;`getString/getInt`(ResourceBundle 读 config.properties)无对应需求,不迁移。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 关键骨架代码示例
|
|
||||||
|
|
||||||
**Program.cs / Startup.cs 装配顺序:**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public void ConfigureServices(IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddControllers()
|
|
||||||
.AddNewtonsoftJson(o => { // 对齐 Java Jackson 配置
|
|
||||||
o.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss";
|
|
||||||
});
|
|
||||||
services.AddDataLayer(Configuration); // SQL Server 主库;DatabaseProvider=MySql 时切 Pomelo
|
|
||||||
services.AddRedisCache(Configuration); // RedisUtil 单例(StackExchange.Redis,连接串取 Redis:ConnectionString)
|
|
||||||
services.AddRepositories(); // 扩展方法:批量注册 IRepository<T>
|
|
||||||
services.AddServices(); // 扩展方法:批量注册 Service
|
|
||||||
services.AddAutoMapper(typeof(Startup));
|
|
||||||
services.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Startup>());
|
|
||||||
services.AddSwaggerGen(/* 三组文档 + 鉴权 Header */);
|
|
||||||
services.AddQuartzHosted(); // 调度
|
|
||||||
services.AddLog4Net(); // 日志
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Configure(IApplicationBuilder app)
|
|
||||||
{
|
|
||||||
app.UseMiddleware<ApiAccessLogMiddleware>(); // 0. 接口访问日志(唯一日志出口)
|
|
||||||
app.UseMiddleware<GlobalExceptionMiddleware>(); // 1. 异常兜底(异常摘要并入接口日志)
|
|
||||||
app.UseMiddleware<UserContextMiddleware>(); // 2. 用户上下文
|
|
||||||
app.UseMiddleware<AppAuthMiddleware>(); // 3. 应用签名(oapi 渠道)
|
|
||||||
app.UseAuthentication(); // 4. CAS
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.UseEndpoints(e => e.MapControllers());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 实施步骤
|
|
||||||
|
|
||||||
| 阶段 | 内容 | 产出 |
|
|
||||||
| -------- | ------------------------------------------------------------------- | ----------------- |
|
|
||||||
| 1. 环境准备 | 安装 .NET 5 SDK、创建解决方案与 7 个类库项目、配置 `lib` 本地引用 | sln 骨架可编译 |
|
|
||||||
| 2. 基础件接入 | log4net、Newtonsoft、Swagger、AutoMapper、FluentValidation | 启动即出 Swagger 页 |
|
|
||||||
| 3. 数据层 | 建 YHDbContext + Repository 基类 + Specification + Dapper 通道(暂不生成业务实体) | 连通 SQL Server 冒烟通过 |
|
|
||||||
| 4. 横切组件 | 统一响应、异常中间件、**接口访问日志中间件(唯一日志出口)**、用户上下文、操作日志 Filter | Postman 验证全链路 |
|
|
||||||
| 5. 缓存与调度 | RedisUtil + Quartz 宿主 + Job 模板 | Redis 读写、Job 触发验证 |
|
|
||||||
| 6. 鉴权 | CAS Handler + 签名中间件(对接 yh 平台配置) | SSO 联调通过 |
|
|
||||||
| 7. 收尾 | Dockerfile、CI 配置、开发规范文档 | 框架交付 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 风险与注意事项
|
|
||||||
|
|
||||||
1. **.NET 5 已 EOS**(2022-05 停止支持):按用户要求采用 5.0,但建议方案预留升级路径(代码只使用与 .NET 6/8 兼容的 API,将来改 `TargetFramework` 即可)。
|
|
||||||
2. **Library DLL 多为 .NET Framework 时代产物**:开工第一件事是对 `lib` 全部 DLL 做目标框架核验(可用 `dotnet` 反编译或 `Assembly.LoadFrom` 试加载),不兼容的立即走 NuGet 替代,避免后期返工。
|
|
||||||
3. **EF6 → EF Core 语义差异**:Java JPA 的 `ddl-auto: update` 在 EF Core 中对应 Migrate,生产环境一律禁用自动迁移,结构变更走 SQL 脚本(与 Java 侧生产约定一致)。
|
|
||||||
4. **AsyncLocal vs ThreadLocal**:用户上下文必须用 `AsyncLocal`,否则 async 方法链中上下文丢失。
|
|
||||||
5. **Nacos 为可选项**:本地/单机部署先用 `appsettings.{env}.json` + 环境变量;需接入配置中心时再启用 `nacos-sdk-csharp`,配置 key 结构保持与 Java yml 一致,降低运维心智成本。
|
|
||||||
6. **命名习惯迁移**:Java 的 `getClassHistoryTree` 等接口路径与返回结构保持原样(URL 与 JSON 字段不变),保证前端/移动端零改动切换。
|
|
||||||
@@ -26,7 +26,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl(alpine)
|
- name: 安装 git / curl(alpine)
|
||||||
run: apk add --no-cache git curl
|
run: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -53,14 +56,20 @@ jobs:
|
|||||||
- name: 构建 publish 产物(dotnet sdk 容器,复用宿主 nuget 缓存)
|
- name: 构建 publish 产物(dotnet sdk 容器,复用宿主 nuget 缓存)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f dotnetbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name dotnetbuild \
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||||
|
BUILD_CONTAINER="dotnetbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" \
|
||||||
--network infra-infra-net \
|
--network infra-infra-net \
|
||||||
-v /opt/docker/projman-dotnet-nuget:/root/.nuget/packages \
|
-v /opt/docker/projman-dotnet-nuget:/root/.nuget/packages \
|
||||||
-w /build mcr.microsoft.com/dotnet/sdk:5.0 sleep 3600
|
-w /build mcr.microsoft.com/dotnet/sdk:5.0 sleep 3600
|
||||||
docker cp "${PWD}/." dotnetbuild:/build/
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
docker exec dotnetbuild sh -c "find /build -maxdepth 2 -iname nuget.config -exec sed -i 's|//nexus.yunhaixingtong.com|//nexus:8081|g' {} +"
|
docker exec "$BUILD_CONTAINER" sh -c "find /build -maxdepth 2 -iname nuget.config -exec sed -i 's|//nexus.yunhaixingtong.com|//nexus:8081|g' {} +"
|
||||||
docker exec dotnetbuild sh -c '
|
docker exec "$BUILD_CONTAINER" sh -c '
|
||||||
set -e
|
set -e
|
||||||
root_sln=$(ls /build/*.sln 2>/dev/null | head -1 || true)
|
root_sln=$(ls /build/*.sln 2>/dev/null | head -1 || true)
|
||||||
root_csproj=$(ls /build/*.csproj 2>/dev/null | head -1 || true)
|
root_csproj=$(ls /build/*.csproj 2>/dev/null | head -1 || true)
|
||||||
@@ -88,8 +97,8 @@ jobs:
|
|||||||
echo "publish target: $target"
|
echo "publish target: $target"
|
||||||
'
|
'
|
||||||
mkdir -p "${PWD}/publish"
|
mkdir -p "${PWD}/publish"
|
||||||
docker cp dotnetbuild:/app/. "${PWD}/publish/"
|
docker cp "$BUILD_CONTAINER":/app/. "${PWD}/publish/"
|
||||||
docker rm -f dotnetbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
# 入口脚本:runtimeconfig.json 是可执行项目唯一标志(X.runtimeconfig.json <-> X.dll),
|
# 入口脚本:runtimeconfig.json 是可执行项目唯一标志(X.runtimeconfig.json <-> X.dll),
|
||||||
# 类库 deps.json 无 runtimeconfig 兄弟文件,天然不会误选
|
# 类库 deps.json 无 runtimeconfig 兄弟文件,天然不会误选
|
||||||
entry=$(ls "${PWD}"/publish/*.runtimeconfig.json | head -1)
|
entry=$(ls "${PWD}"/publish/*.runtimeconfig.json | head -1)
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
# ============ 多阶段构建:.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"]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# DocprintService
|
|
||||||
|
|
||||||
> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。
|
|
||||||
> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。
|
|
||||||
|
|
||||||
## 首次推送本地代码到本仓库
|
|
||||||
|
|
||||||
> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 进入本地代码目录
|
|
||||||
cd /your/local/code/dir
|
|
||||||
|
|
||||||
# 2. 初始化 git(目录已有 .git 也安全,git init 幂等无副作用)
|
|
||||||
git init
|
|
||||||
git branch -M main
|
|
||||||
|
|
||||||
# 3. 先把本地代码提交成一个 commit(关键!不提交直接 merge 会丢失本地代码)
|
|
||||||
git add -A
|
|
||||||
git commit -m "init local code"
|
|
||||||
|
|
||||||
# 4. 关联远程仓库(幂等:已存在 origin 则改地址,不报错)
|
|
||||||
git remote add origin http://gitea.yunhaixingtong.com/docprint/DocprintService.git 2>/dev/null \
|
|
||||||
|| git remote set-url origin http://gitea.yunhaixingtong.com/docprint/DocprintService.git
|
|
||||||
|
|
||||||
# 5. 拉取远程信息(只下载,不合并)
|
|
||||||
git fetch origin
|
|
||||||
|
|
||||||
# 6. 合并远程 main(关键步骤)
|
|
||||||
# 本地与远程是两条独立历史,需允许合并不相关历史。
|
|
||||||
# 此步会把 projman 注入的 build.yml 与 Dockerfile 合并进你的本地工作区。
|
|
||||||
git merge origin/main --allow-unrelated-histories -m "merge projman injected files"
|
|
||||||
|
|
||||||
# 7. 查看状态(确认 .gitea/workflows/build.yml、docker/Dockerfile 已在本地)
|
|
||||||
git status
|
|
||||||
|
|
||||||
# 8. 提交并推送
|
|
||||||
git add -A
|
|
||||||
git commit -m "sync local code" 2>/dev/null || true
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
## 推送后验证
|
|
||||||
|
|
||||||
push 触发 CI 构建,前往 Gitea 仓库 → **Actions** 查看日志;构建成功后镜像推送到 Nexus,projman 建档待部署,在 projman 应用详情「后端发布」手动点部署。
|
|
||||||
|
|
||||||
## 禁止写法(会导致注入文件被删)
|
|
||||||
|
|
||||||
`git reset origin/main` + `git add -A` 会把远程已注入、但你本地没有的文件(`build.yml`、`Dockerfile`)当作"删除"提交并推送,导致这些文件被删、CI 失败。务必用 `git merge` 而非 `git reset`,且 merge 前先提交本地代码。
|
|
||||||
|
|
||||||
## 关于 Dockerfile
|
|
||||||
|
|
||||||
projman 注入的 Dockerfile 仅在仓库不存在时写入;你自备的 Dockerfile 优先,不会被覆盖。
|
|
||||||
+3
-1
@@ -8,10 +8,12 @@ FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
|
|||||||
|
|
||||||
# .NET 5 基础镜像基于 Debian 10(已 EOL),apt 源须切 archive.debian.org 才能装包
|
# .NET 5 基础镜像基于 Debian 10(已 EOL),apt 源须切 archive.debian.org 才能装包
|
||||||
# curl 供 projman compose healthcheck(test: CMD curl -f ...)使用;tzdata 设上海时区
|
# curl 供 projman compose healthcheck(test: CMD curl -f ...)使用;tzdata 设上海时区
|
||||||
|
# wkhtmltopdf 供审批单 PDF 接口(DwonWYSQD)本地转换 HTML(Debian 源为 0.12.5,无需 --enable-local-file-access);
|
||||||
|
# fonts-wqy-zenhei 提供中文字体(缺失则 PDF 中文显示方块)
|
||||||
RUN sed -i 's|deb.debian.org|archive.debian.org|g; s|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \
|
RUN sed -i 's|deb.debian.org|archive.debian.org|g; s|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \
|
||||||
&& echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-valid-until \
|
&& echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-valid-until \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl tzdata \
|
&& apt-get install -y --no-install-recommends curl tzdata wkhtmltopdf fonts-wqy-zenhei \
|
||||||
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -21,5 +21,8 @@ namespace YhCore.Common.Config
|
|||||||
|
|
||||||
/// <summary>文件访问 URL 前缀(对应 Java FILE_WEB_PATH,由 Startup 注入)</summary>
|
/// <summary>文件访问 URL 前缀(对应 Java FILE_WEB_PATH,由 Startup 注入)</summary>
|
||||||
public static string FileWebPath { get; set; }
|
public static string FileWebPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>wkhtmltopdf 可执行文件路径(审批单 PDF 用;空则用 PATH 中的 wkhtmltopdf,由 Startup 注入)</summary>
|
||||||
|
public static string WkHtmlToPdfPath { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
using Yh.Platform.Abstractions;
|
using Yh.Platform.Abstractions;
|
||||||
|
|
||||||
namespace YhCore.Common.Result
|
namespace YhCore.Common.Result
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 统一响应(对应 Java yh-api-std:com.yunhai.platform.apistd.ApiResponse)。
|
/// 统一响应(对应 Java yh-api-std:com.yunhai.platform.apistd.ApiResponse)。
|
||||||
/// 字段与 Java 对齐:code/msg/data(Newtonsoft CamelCase 序列化输出小写)。
|
/// 信封字段 code/msg/data 以 [JsonProperty] 显式钉死小写——全局序列化已改为
|
||||||
|
/// DefaultContractResolver 原样输出(data 内容对齐旧项目混合大小写字段),信封不受影响。
|
||||||
/// 注意:Controller 返回值由全局过滤器 GlobalWrapResultFilter 自动包装;
|
/// 注意:Controller 返回值由全局过滤器 GlobalWrapResultFilter 自动包装;
|
||||||
/// 不需要包装的接口用 [NoWrapResult] 特性显式排除(CAS 协议端点、文件下载等)。
|
/// 不需要包装的接口用 [NoWrapResult] 特性显式排除(CAS 协议端点、文件下载等)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ApiResult<T>
|
public class ApiResult<T>
|
||||||
{
|
{
|
||||||
/// <summary>0=成功,非 0=失败(对齐 Java ErrorCode )</summary>
|
/// <summary>0=成功,非 0=失败(对齐 Java ErrorCode )</summary>
|
||||||
|
[JsonProperty("code")]
|
||||||
public int Code { get; set; }
|
public int Code { get; set; }
|
||||||
|
|
||||||
/// <summary>提示消息(对齐 Java msg)</summary>
|
/// <summary>提示消息(对齐 Java msg)</summary>
|
||||||
|
[JsonProperty("msg")]
|
||||||
public string Msg { get; set; }
|
public string Msg { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("data")]
|
||||||
public T Data { get; set; }
|
public T Data { get; set; }
|
||||||
|
|
||||||
public static ApiResult<T> Ok(T data) => new() { Code = ErrorCode.Success.Code, Msg = "", Data = data };
|
public static ApiResult<T> Ok(T data) => new() { Code = ErrorCode.Success.Code, Msg = "", Data = data };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 平台模型与信封(DException/ErrorCode/BoaUser 等):从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
<!-- 平台模型与信封(DException/ErrorCode/BoaUser 等):从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
||||||
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -68,22 +68,10 @@ namespace YhCore.Model.Dto
|
|||||||
/// <summary>全部字典类别(typeID, typeName),对齐旧 EnumConfig.Dictionary 的 Description。</summary>
|
/// <summary>全部字典类别(typeID, typeName),对齐旧 EnumConfig.Dictionary 的 Description。</summary>
|
||||||
public static readonly (string Id, string Name)[] AllTypes =
|
public static readonly (string Id, string Name)[] AllTypes =
|
||||||
{
|
{
|
||||||
("DEPT", "部门类别"),
|
|
||||||
("BXBM", "报修部门"),
|
|
||||||
("WYFL", "文印分类"),
|
("WYFL", "文印分类"),
|
||||||
("YZBM", "印制版面"),
|
("YZBM", "印制版面"),
|
||||||
("YZFL", "印制分类"),
|
("YZFL", "印制分类"),
|
||||||
("LeaveType", "请假类别"),
|
("filetype", "文件类型")
|
||||||
("FoodType", "物品类别"),
|
|
||||||
("Unit", "单位"),
|
|
||||||
("VenueType", "场馆类别"),
|
|
||||||
("Vefacilities", "场馆设施"),
|
|
||||||
("Time", "场馆时间"),
|
|
||||||
("Repeat", "场馆重复"),
|
|
||||||
("LevelOrder", "奖次"),
|
|
||||||
("filetype", "文件类型"),
|
|
||||||
("doclevel", "公文级别"),
|
|
||||||
("XKCategory", "选课类型"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>GetSysDic 的 Type(int) → typeID;越界返回 null。</summary>
|
/// <summary>GetSysDic 的 Type(int) → typeID;越界返回 null。</summary>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace YhCore.Model.Dto
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文件信息(旧 BestSchool.Entity.FileObj,GetEnclosure/FileSave/FileSaveImage/FileMergeSave 共用)。
|
/// 文件信息(旧 BestSchool.Entity.FileObj,GetEnclosure/FileSave/FileSaveImage/FileMergeSave 共用)。
|
||||||
/// fileurl 为相对路径(如 /UploadFile/docprint/20260101/{guid}.jpg,与旧库数据格式一致);
|
/// fileurl 为相对路径(如 /upload/docprint/20260101/{guid}.jpg,与旧库数据格式一致);
|
||||||
/// host 为文件访问主机前缀(旧 AuthConfig.weboa_url,新 Config.FileWebPath)。
|
/// host 为文件访问主机前缀(旧 AuthConfig.weboa_url,新 Config.FileWebPath)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class FileObj
|
public class FileObj
|
||||||
@@ -19,7 +19,7 @@ namespace YhCore.Model.Dto
|
|||||||
/// <summary>文件真实名称(含扩展名)。</summary>
|
/// <summary>文件真实名称(含扩展名)。</summary>
|
||||||
public string filename { get; set; }
|
public string filename { get; set; }
|
||||||
|
|
||||||
/// <summary>相对访问路径(如 /UploadFile/docprint/20260101/{guid}.jpg)。</summary>
|
/// <summary>相对访问路径(如 /upload/docprint/20260101/{guid}.jpg)。</summary>
|
||||||
public string fileurl { get; set; }
|
public string fileurl { get; set; }
|
||||||
|
|
||||||
/// <summary>文件主机前缀(Config.FileWebPath)。</summary>
|
/// <summary>文件主机前缀(Config.FileWebPath)。</summary>
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ namespace YhCore.Repository
|
|||||||
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
|
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
|
||||||
Task<long> CountAsync(Expression<Func<T, bool>> predicate = null);
|
Task<long> CountAsync(Expression<Func<T, bool>> predicate = null);
|
||||||
|
|
||||||
|
// ---- 读取(追踪版)----
|
||||||
|
// 查出的实体受变更追踪器管理,修改属性后直接 SaveChangesAsync 即可落库。
|
||||||
|
// 仅供写场景使用,纯查询请用上面的 NoTracking 版本。
|
||||||
|
Task<List<T>> FindTrackedAsync(Expression<Func<T, bool>> predicate);
|
||||||
|
Task<List<T>> GetAllTrackedAsync();
|
||||||
|
Task<T> FirstOrDefaultTrackedAsync(Expression<Func<T, bool>> predicate);
|
||||||
|
|
||||||
/// <summary>分页查询(页码从 0 开始),对应 PageUtil + PageVO</summary>
|
/// <summary>分页查询(页码从 0 开始),对应 PageUtil + PageVO</summary>
|
||||||
Task<PageVO<T>> PageAsync(Expression<Func<T, bool>> predicate, int page, int size,
|
Task<PageVO<T>> PageAsync(Expression<Func<T, bool>> predicate, int page, int size,
|
||||||
Expression<Func<T, object>> orderBy = null, bool asc = true);
|
Expression<Func<T, object>> orderBy = null, bool asc = true);
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ namespace YhCore.Repository
|
|||||||
return await DbSet.AsNoTracking().FirstOrDefaultAsync(predicate);
|
return await DbSet.AsNoTracking().FirstOrDefaultAsync(predicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public virtual async Task<List<T>> FindTrackedAsync(Expression<Func<T, bool>> predicate)
|
||||||
|
{
|
||||||
|
return await DbSet.Where(predicate).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<List<T>> GetAllTrackedAsync()
|
||||||
|
{
|
||||||
|
return await DbSet.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<T> FirstOrDefaultTrackedAsync(Expression<Func<T, bool>> predicate)
|
||||||
|
{
|
||||||
|
return await DbSet.FirstOrDefaultAsync(predicate);
|
||||||
|
}
|
||||||
|
|
||||||
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate)
|
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate)
|
||||||
{
|
{
|
||||||
return await DbSet.AnyAsync(predicate);
|
return await DbSet.AnyAsync(predicate);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.IO;
|
||||||
|
using OfficeOpenXml;
|
||||||
|
using OfficeOpenXml.Style;
|
||||||
|
|
||||||
|
namespace YhCore.Service.Excel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// EPPlus 多 Sheet 导出(移植自旧 Bsch.Common/DocPrint/ExcelHelper,
|
||||||
|
/// 保留 RenderToExcelBytes + ExportDynamicSheet:动态列、大标题、自定义列宽、打印优化)。
|
||||||
|
/// 注意:EPPlus 实际产出 xlsx 字节流,旧系统文件名沿用 .xls,Excel 可正常打开,保持原契约。
|
||||||
|
/// </summary>
|
||||||
|
public class ExcelHelper
|
||||||
|
{
|
||||||
|
/// <summary>导出多 Sheet Excel 字节流(listdt/listsheetname/listtitle 按下标一一对应)</summary>
|
||||||
|
public static byte[] RenderToExcelBytes(string fileName, List<DataTable> listdt, List<string> listsheetname, List<string> listtitle, List<double> listcolwidth = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var package = new ExcelPackage();
|
||||||
|
int n = 0;
|
||||||
|
foreach (var dt in listdt)
|
||||||
|
{
|
||||||
|
string sheetname = "Sheet" + (n + 1).ToString();
|
||||||
|
if (n < listsheetname.Count) sheetname = listsheetname[n];
|
||||||
|
string title = "";
|
||||||
|
if (n < listtitle.Count) title = listtitle[n];
|
||||||
|
var ws1 = package.Workbook.Worksheets.Add(sheetname);
|
||||||
|
ExportDynamicSheet(ws1, dt, title, "A", listcolwidth);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return package.GetAsByteArray();
|
||||||
|
}
|
||||||
|
catch (Exception) { return Array.Empty<byte>(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 动态列 Sheet 导出(通用方法)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导出 动态列 + 自定义表头 + 自定义页码 + 打印优化
|
||||||
|
/// </summary>
|
||||||
|
public static void ExportDynamicSheet(ExcelWorksheet ws, DataTable dt, string sheetTitle, string printMark, List<double> listcolwidth = null)
|
||||||
|
{
|
||||||
|
// -------------------------- 行定义 --------------------------
|
||||||
|
int titleRow = 1; // 大标题行
|
||||||
|
int headerRow = 2; // 列标题行
|
||||||
|
int dataStartRow = 3; // 数据开始行
|
||||||
|
|
||||||
|
int colCount = dt.Columns.Count;
|
||||||
|
int rowCount = dt.Rows.Count;
|
||||||
|
|
||||||
|
// -------------------------- 1. 自定义大标题(合并行) --------------------------
|
||||||
|
if (sheetTitle != "")
|
||||||
|
{
|
||||||
|
ws.Cells[titleRow, 1].Value = sheetTitle;
|
||||||
|
ws.Cells[titleRow, 1, titleRow, colCount].Merge = true; // 合并单元格
|
||||||
|
|
||||||
|
// 标题样式
|
||||||
|
var titleStyle = ws.Cells[titleRow, 1].Style;
|
||||||
|
titleStyle.Font.Size = 12;
|
||||||
|
titleStyle.Font.Bold = true;
|
||||||
|
titleStyle.HorizontalAlignment = ExcelHorizontalAlignment.Center;
|
||||||
|
titleStyle.VerticalAlignment = ExcelVerticalAlignment.Center;
|
||||||
|
ws.Row(titleRow).Height = 32;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
headerRow = 1; // 列标题行
|
||||||
|
dataStartRow = 2; // 数据开始行
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------- 2. 动态列 自动填充表头 --------------------------
|
||||||
|
for (int c = 0; c < colCount; c++)
|
||||||
|
{
|
||||||
|
ws.Cells[headerRow, c + 1].Value = dt.Columns[c].ColumnName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------- 3. 动态列 自动填充数据 --------------------------
|
||||||
|
for (int r = 0; r < rowCount; r++)
|
||||||
|
{
|
||||||
|
for (int c = 0; c < colCount; c++)
|
||||||
|
{
|
||||||
|
ws.Cells[dataStartRow + r, c + 1].Value = dt.Rows[r][c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------- 4. 自动换行 + 行高自适应(关键) --------------------------
|
||||||
|
var dataRange = ws.Cells[headerRow, 1, dataStartRow + rowCount - 1, colCount];
|
||||||
|
dataRange.Style.Font.Size = 10;
|
||||||
|
dataRange.Style.WrapText = true;
|
||||||
|
dataRange.Style.VerticalAlignment = ExcelVerticalAlignment.Center;
|
||||||
|
dataRange.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center;
|
||||||
|
// 加细边框
|
||||||
|
dataRange.Style.Border.Top.Style = ExcelBorderStyle.Thin;
|
||||||
|
dataRange.Style.Border.Bottom.Style = ExcelBorderStyle.Thin;
|
||||||
|
dataRange.Style.Border.Left.Style = ExcelBorderStyle.Thin;
|
||||||
|
dataRange.Style.Border.Right.Style = ExcelBorderStyle.Thin;
|
||||||
|
|
||||||
|
// 自动计算:所有列平均撑满 A4 宽度
|
||||||
|
if (listcolwidth != null)
|
||||||
|
{
|
||||||
|
int colnum = listcolwidth.Count > colCount ? colCount : listcolwidth.Count;
|
||||||
|
for (int i = 1; i <= colnum; i++)
|
||||||
|
{
|
||||||
|
ws.Column(i).Width = listcolwidth[i - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
double totalWidth = 58;
|
||||||
|
double perColWidth = (double)totalWidth / colCount;
|
||||||
|
for (int i = 1; i <= colCount; i++)
|
||||||
|
{
|
||||||
|
ws.Column(i).Width = perColWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行高自适应(EPPlus 4.x 兼容)
|
||||||
|
for (int r = headerRow; r <= headerRow + rowCount - 1; r++)
|
||||||
|
{
|
||||||
|
ws.Row(r).CustomHeight = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------- 5. 表头样式 --------------------------
|
||||||
|
var headerRange = ws.Cells[headerRow, 1, headerRow, colCount];
|
||||||
|
headerRange.Style.Font.Bold = true;
|
||||||
|
ws.Row(headerRow).Height = 25;
|
||||||
|
|
||||||
|
// -------------------------- 6. 打印:每页重复标题行 --------------------------
|
||||||
|
ws.PrinterSettings.RepeatRows = new ExcelAddress($"{headerRow}:{headerRow}");
|
||||||
|
|
||||||
|
// -------------------------- 7. 自定义页码(中文样式 + 字体大小) --------------------------
|
||||||
|
// &P=当前页 &N=总页 12号字体 居中
|
||||||
|
ws.HeaderFooter.OddFooter.CenteredText = "&14 第 &P 页 / 共 &N 页";
|
||||||
|
|
||||||
|
// -------------------------- 8. 打印格式优化 --------------------------
|
||||||
|
ws.PrinterSettings.PaperSize = ePaperSize.A4;
|
||||||
|
ws.PrinterSettings.Orientation = eOrientation.Portrait;
|
||||||
|
|
||||||
|
ws.PrinterSettings.LeftMargin = 0.4m;
|
||||||
|
ws.PrinterSettings.RightMargin = 0.4m;
|
||||||
|
ws.PrinterSettings.TopMargin = 0.8m;
|
||||||
|
ws.PrinterSettings.BottomMargin = 1.2m;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
using System;
|
||||||
|
using System.Data;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using NPOI.HSSF.UserModel;
|
||||||
|
using NPOI.SS.Formula.Eval;
|
||||||
|
using NPOI.SS.UserModel;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
|
|
||||||
|
namespace YhCore.Service.Excel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 使用 NPOI 操作 Excel,无需 Office COM 组件(移植自旧 Bsch.Common/DocPrint/ExcelRender,
|
||||||
|
/// 仅保留 6 个 WYSHRole Excel 接口用到的导出/导入成员;控制器用 File() 包装下载)。
|
||||||
|
/// </summary>
|
||||||
|
public class ExcelRender
|
||||||
|
{
|
||||||
|
static ExcelRender()
|
||||||
|
{
|
||||||
|
// ExportDT 列宽按 GB2312 字节长计算:net5 需注册 CodePages 提供程序后才能 GetEncoding(936)
|
||||||
|
Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region DataTable 导出为 .xls 字节流
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DataTable 导出为 Excel 的 MemoryStream(HSSF .xls;65535 行自动分 sheet;列宽按 GB2312 字节长,上限 50)
|
||||||
|
/// </summary>
|
||||||
|
static MemoryStream ExportDT(DataTable dtSource, string strHeaderText)
|
||||||
|
{
|
||||||
|
HSSFWorkbook workbook = new HSSFWorkbook();
|
||||||
|
HSSFSheet sheet = workbook.CreateSheet() as HSSFSheet;
|
||||||
|
|
||||||
|
HSSFCellStyle dateStyle = workbook.CreateCellStyle() as HSSFCellStyle;
|
||||||
|
HSSFDataFormat format = workbook.CreateDataFormat() as HSSFDataFormat;
|
||||||
|
dateStyle.DataFormat = format.GetFormat("yyyy-mm-dd");
|
||||||
|
|
||||||
|
//取得列宽
|
||||||
|
int[] arrColWidth = new int[dtSource.Columns.Count];
|
||||||
|
foreach (DataColumn item in dtSource.Columns)
|
||||||
|
{
|
||||||
|
arrColWidth[item.Ordinal] = Encoding.GetEncoding(936).GetBytes(item.ColumnName.ToString()).Length;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < dtSource.Rows.Count; i++)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < dtSource.Columns.Count; j++)
|
||||||
|
{
|
||||||
|
int intTemp = Encoding.GetEncoding(936).GetBytes(dtSource.Rows[i][j].ToString()).Length;
|
||||||
|
if (intTemp > arrColWidth[j])
|
||||||
|
{
|
||||||
|
arrColWidth[j] = intTemp;
|
||||||
|
if (intTemp >= 50)
|
||||||
|
arrColWidth[j] = 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int rowIndex = 0;
|
||||||
|
|
||||||
|
foreach (DataRow row in dtSource.Rows)
|
||||||
|
{
|
||||||
|
#region 新建表,填充表头,填充列头,样式
|
||||||
|
|
||||||
|
if (rowIndex == 65535 || rowIndex == 0)
|
||||||
|
{
|
||||||
|
if (rowIndex != 0)
|
||||||
|
{
|
||||||
|
sheet = workbook.CreateSheet() as HSSFSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 列头及样式
|
||||||
|
|
||||||
|
{
|
||||||
|
HSSFRow headerRow = sheet.CreateRow(0) as HSSFRow;
|
||||||
|
|
||||||
|
HSSFCellStyle headStyle = workbook.CreateCellStyle() as HSSFCellStyle;
|
||||||
|
headStyle.Alignment = NPOI.SS.UserModel.HorizontalAlignment.Center;
|
||||||
|
HSSFFont font = workbook.CreateFont() as HSSFFont;
|
||||||
|
font.FontHeightInPoints = 10;
|
||||||
|
font.Boldweight = 700;
|
||||||
|
headStyle.SetFont(font);
|
||||||
|
|
||||||
|
foreach (DataColumn column in dtSource.Columns)
|
||||||
|
{
|
||||||
|
headerRow.CreateCell(column.Ordinal).SetCellValue(column.ColumnName);
|
||||||
|
headerRow.GetCell(column.Ordinal).CellStyle = headStyle;
|
||||||
|
|
||||||
|
//设置列宽
|
||||||
|
sheet.SetColumnWidth(column.Ordinal, (arrColWidth[column.Ordinal] + 1) * 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
rowIndex = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 填充内容
|
||||||
|
|
||||||
|
HSSFRow dataRow = sheet.CreateRow(rowIndex) as HSSFRow;
|
||||||
|
foreach (DataColumn column in dtSource.Columns)
|
||||||
|
{
|
||||||
|
HSSFCell newCell = dataRow.CreateCell(column.Ordinal) as HSSFCell;
|
||||||
|
|
||||||
|
string drValue = row[column].ToString();
|
||||||
|
|
||||||
|
switch (column.DataType.ToString())
|
||||||
|
{
|
||||||
|
case "System.String": //字符串类型
|
||||||
|
newCell.SetCellValue(drValue);
|
||||||
|
break;
|
||||||
|
case "System.DateTime": //日期类型
|
||||||
|
DateTime dateV;
|
||||||
|
DateTime.TryParse(drValue, out dateV);
|
||||||
|
newCell.SetCellValue(dateV);
|
||||||
|
newCell.CellStyle = dateStyle; //格式化显示
|
||||||
|
break;
|
||||||
|
case "System.Boolean": //布尔型
|
||||||
|
bool boolV = false;
|
||||||
|
bool.TryParse(drValue, out boolV);
|
||||||
|
newCell.SetCellValue(boolV);
|
||||||
|
break;
|
||||||
|
case "System.Int16": //整型
|
||||||
|
case "System.Int32":
|
||||||
|
case "System.Int64":
|
||||||
|
case "System.Byte":
|
||||||
|
int intV = 0;
|
||||||
|
int.TryParse(drValue, out intV);
|
||||||
|
newCell.SetCellValue(intV);
|
||||||
|
break;
|
||||||
|
case "System.Decimal": //浮点型
|
||||||
|
case "System.Double":
|
||||||
|
double doubV = 0;
|
||||||
|
double.TryParse(drValue, out doubV);
|
||||||
|
newCell.SetCellValue(doubV);
|
||||||
|
break;
|
||||||
|
case "System.DBNull": //空值处理
|
||||||
|
newCell.SetCellValue("");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newCell.SetCellValue("");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
rowIndex++;
|
||||||
|
}
|
||||||
|
using (MemoryStream ms = new MemoryStream())
|
||||||
|
{
|
||||||
|
workbook.Write(ms);
|
||||||
|
ms.Flush();
|
||||||
|
ms.Position = 0;
|
||||||
|
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>导出 Excel 字节流(供 Service 层返回 byte[],控制器包 File 下载)</summary>
|
||||||
|
public static byte[] RenderToExcelBytes(DataTable table, string fileName)
|
||||||
|
{
|
||||||
|
try { return ExportDT(table, null).ToArray(); }
|
||||||
|
catch (Exception) { return Array.Empty<byte>(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 读取 excel 到 DataTable
|
||||||
|
|
||||||
|
/// <summary>Excel 文档流是否有数据(默认第一个 sheet)</summary>
|
||||||
|
public static bool HasData(Stream excelFileStream, string fileExt)
|
||||||
|
{
|
||||||
|
return HasData(excelFileStream, 0, fileExt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasData(Stream excelFileStream, int sheetIndex, string fileExt)
|
||||||
|
{
|
||||||
|
using (excelFileStream)
|
||||||
|
{
|
||||||
|
IWorkbook workbook = null;
|
||||||
|
if (fileExt == ".xls")
|
||||||
|
{
|
||||||
|
workbook = new HSSFWorkbook(excelFileStream);
|
||||||
|
}
|
||||||
|
else if (fileExt == ".xlsx")
|
||||||
|
{
|
||||||
|
workbook = new XSSFWorkbook(excelFileStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
if (workbook.NumberOfSheets > 0)
|
||||||
|
{
|
||||||
|
if (sheetIndex < workbook.NumberOfSheets)
|
||||||
|
{
|
||||||
|
ISheet sheet = workbook.GetSheetAt(sheetIndex);
|
||||||
|
{
|
||||||
|
return sheet.PhysicalNumberOfRows > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Excel 文档流转换成 DataTable
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="excelFileStream">Excel 文档流</param>
|
||||||
|
/// <param name="sheetIndex">表索引号,如第一个表为 0</param>
|
||||||
|
/// <param name="headerRowIndex">标题行索引号,如第一行为 0</param>
|
||||||
|
public static DataTable RenderFromExcel(Stream excelFileStream, int sheetIndex, int headerRowIndex, string fileExt)
|
||||||
|
{
|
||||||
|
DataTable table = null;
|
||||||
|
|
||||||
|
using (excelFileStream)
|
||||||
|
{
|
||||||
|
IWorkbook workbook = null;
|
||||||
|
if (fileExt == ".xls")
|
||||||
|
{
|
||||||
|
workbook = new HSSFWorkbook(excelFileStream);
|
||||||
|
}
|
||||||
|
else if (fileExt == ".xlsx")
|
||||||
|
{
|
||||||
|
workbook = new XSSFWorkbook(excelFileStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
ISheet sheet = workbook.GetSheetAt(sheetIndex);
|
||||||
|
{
|
||||||
|
table = ImportDt(sheet, headerRowIndex, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>将指定 sheet 中的数据导出到 datatable 中(第一行为列头)</summary>
|
||||||
|
static DataTable ImportDt(ISheet sheet, int HeaderRowIndex, bool needHeader)
|
||||||
|
{
|
||||||
|
DataTable table = new DataTable();
|
||||||
|
IRow headerRow;
|
||||||
|
int cellCount;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (HeaderRowIndex < 0 || !needHeader)
|
||||||
|
{
|
||||||
|
headerRow = sheet.GetRow(0);
|
||||||
|
cellCount = headerRow.LastCellNum;
|
||||||
|
|
||||||
|
for (int i = headerRow.FirstCellNum; i <= cellCount; i++)
|
||||||
|
{
|
||||||
|
DataColumn column = new DataColumn(Convert.ToString(i));
|
||||||
|
table.Columns.Add(column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
headerRow = sheet.GetRow(HeaderRowIndex);
|
||||||
|
cellCount = headerRow.LastCellNum;
|
||||||
|
|
||||||
|
for (int i = headerRow.FirstCellNum; i < cellCount; i++)
|
||||||
|
{
|
||||||
|
if (headerRow.GetCell(i) == null)
|
||||||
|
{
|
||||||
|
if (table.Columns.IndexOf(Convert.ToString(i)) > 0)
|
||||||
|
{
|
||||||
|
DataColumn column = new DataColumn(Convert.ToString("重复列名" + i));
|
||||||
|
table.Columns.Add(column);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DataColumn column = new DataColumn(Convert.ToString(i));
|
||||||
|
table.Columns.Add(column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (table.Columns.IndexOf(headerRow.GetCell(i).ToString()) > 0)
|
||||||
|
{
|
||||||
|
DataColumn column = new DataColumn(Convert.ToString("重复列名" + i));
|
||||||
|
table.Columns.Add(column);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DataColumn column = new DataColumn(headerRow.GetCell(i).ToString());
|
||||||
|
table.Columns.Add(column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int rowCount = sheet.LastRowNum;
|
||||||
|
for (int i = (HeaderRowIndex + 1); i <= sheet.LastRowNum; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IRow row;
|
||||||
|
if (sheet.GetRow(i) == null)
|
||||||
|
{
|
||||||
|
row = sheet.CreateRow(i);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
row = sheet.GetRow(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataRow dataRow = table.NewRow();
|
||||||
|
|
||||||
|
for (int j = row.FirstCellNum; j < cellCount; j++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (row.GetCell(j) != null)
|
||||||
|
{
|
||||||
|
switch (row.GetCell(j).CellType)
|
||||||
|
{
|
||||||
|
case CellType.String:
|
||||||
|
string str = row.GetCell(j).StringCellValue;
|
||||||
|
if (str != null && str.Length > 0)
|
||||||
|
{
|
||||||
|
dataRow[j] = str.ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataRow[j] = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CellType.Numeric:
|
||||||
|
if (DateUtil.IsCellDateFormatted(row.GetCell(j)))
|
||||||
|
{
|
||||||
|
dataRow[j] = DateTime.FromOADate(row.GetCell(j).NumericCellValue);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataRow[j] = Convert.ToDouble(row.GetCell(j).NumericCellValue);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CellType.Boolean:
|
||||||
|
dataRow[j] = Convert.ToString(row.GetCell(j).BooleanCellValue);
|
||||||
|
break;
|
||||||
|
case CellType.Error:
|
||||||
|
dataRow[j] = ErrorEval.GetText(row.GetCell(j).ErrorCellValue);
|
||||||
|
break;
|
||||||
|
case CellType.Formula:
|
||||||
|
switch (row.GetCell(j).CachedFormulaResultType)
|
||||||
|
{
|
||||||
|
case CellType.String:
|
||||||
|
string strFORMULA = row.GetCell(j).StringCellValue;
|
||||||
|
if (strFORMULA != null && strFORMULA.Length > 0)
|
||||||
|
{
|
||||||
|
dataRow[j] = strFORMULA.ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataRow[j] = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CellType.Numeric:
|
||||||
|
dataRow[j] = Convert.ToString(row.GetCell(j).NumericCellValue);
|
||||||
|
break;
|
||||||
|
case CellType.Boolean:
|
||||||
|
dataRow[j] = Convert.ToString(row.GetCell(j).BooleanCellValue);
|
||||||
|
break;
|
||||||
|
case CellType.Error:
|
||||||
|
dataRow[j] = ErrorEval.GetText(row.GetCell(j).ErrorCellValue);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dataRow[j] = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dataRow[j] = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.Rows.Add(dataRow);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public static byte[] StreamToBytes(Stream stream)
|
||||||
|
{
|
||||||
|
byte[] bytes = new byte[stream.Length];
|
||||||
|
stream.Read(bytes, 0, bytes.Length);
|
||||||
|
// 设置当前流的位置为流的开始
|
||||||
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ namespace YhCore.Service.Impl
|
|||||||
throw new DException("已存在该分类,请重新填写!");
|
throw new DException("已存在该分类,请重新填写!");
|
||||||
}
|
}
|
||||||
|
|
||||||
var obj = await _dic.FirstOrDefaultAsync(x => x.SerID == model.SerID);
|
var obj = await _dic.FirstOrDefaultTrackedAsync(x => x.SerID == model.SerID);
|
||||||
if (obj == null) throw new DException("未找到要更新的分类!");
|
if (obj == null) throw new DException("未找到要更新的分类!");
|
||||||
|
|
||||||
obj.Name = model.Name;
|
obj.Name = model.Name;
|
||||||
@@ -141,7 +141,7 @@ namespace YhCore.Service.Impl
|
|||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
|
|
||||||
var current = await _dic.FirstOrDefaultAsync(m => m.SerID == model.SerID);
|
var current = await _dic.FirstOrDefaultTrackedAsync(m => m.SerID == model.SerID);
|
||||||
if (current == null) throw new DException("未找到要编辑的分类!");
|
if (current == null) throw new DException("未找到要编辑的分类!");
|
||||||
|
|
||||||
var dup = await _dic.AnyAsync(m =>
|
var dup = await _dic.AnyAsync(m =>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ namespace YhCore.Service.Impl
|
|||||||
{
|
{
|
||||||
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
|
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
|
||||||
|
|
||||||
var items = await _dic.FindAsync(m => ids.Contains(m.SerID));
|
var items = await _dic.FindTrackedAsync(m => ids.Contains(m.SerID));
|
||||||
if (items.Count == 0) throw new DException("未找到要删除的分类!");
|
if (items.Count == 0) throw new DException("未找到要删除的分类!");
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
@@ -189,7 +189,7 @@ namespace YhCore.Service.Impl
|
|||||||
{
|
{
|
||||||
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
|
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
|
||||||
|
|
||||||
var items = await _dic.FindAsync(m => ids.Contains(m.SerID.ToString()));
|
var items = await _dic.FindTrackedAsync(m => ids.Contains(m.SerID.ToString()));
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
item.Enable = 3;
|
item.Enable = 3;
|
||||||
@@ -200,7 +200,7 @@ namespace YhCore.Service.Impl
|
|||||||
|
|
||||||
public async Task SetDictionaryOrdAsync(string schSerId, int dicSerId, bool show)
|
public async Task SetDictionaryOrdAsync(string schSerId, int dicSerId, bool show)
|
||||||
{
|
{
|
||||||
var obj = await _ord.FirstOrDefaultAsync(m => m.DicSerID == dicSerId && m.SchSerID == schSerId);
|
var obj = await _ord.FirstOrDefaultTrackedAsync(m => m.DicSerID == dicSerId && m.SchSerID == schSerId);
|
||||||
if (obj == null)
|
if (obj == null)
|
||||||
{
|
{
|
||||||
await _ord.AddAsync(new Common_DictionaryOrd
|
await _ord.AddAsync(new Common_DictionaryOrd
|
||||||
@@ -224,7 +224,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (string.IsNullOrEmpty(ids)) throw new DException("ids is not null");
|
if (string.IsNullOrEmpty(ids)) throw new DException("ids is not null");
|
||||||
|
|
||||||
var ssids = ids.Split(',').Select(a => int.Parse(a)).ToArray();
|
var ssids = ids.Split(',').Select(a => int.Parse(a)).ToArray();
|
||||||
var list = await _ord.FindAsync(m => m.SchSerID == schSerId);
|
var list = await _ord.FindTrackedAsync(m => m.SchSerID == schSerId);
|
||||||
|
|
||||||
var i = 0;
|
var i = 0;
|
||||||
foreach (var id in ssids)
|
foreach (var id in ssids)
|
||||||
@@ -274,7 +274,7 @@ namespace YhCore.Service.Impl
|
|||||||
|
|
||||||
public async Task SetSchZyBitAsync(string schSerId, bool zyBit)
|
public async Task SetSchZyBitAsync(string schSerId, bool zyBit)
|
||||||
{
|
{
|
||||||
var obj = await _zybit.FirstOrDefaultAsync(a => a.SchSerID == schSerId);
|
var obj = await _zybit.FirstOrDefaultTrackedAsync(a => a.SchSerID == schSerId);
|
||||||
if (obj == null)
|
if (obj == null)
|
||||||
{
|
{
|
||||||
await _zybit.AddAsync(new WY_SchZyBit { SchSerID = schSerId, ziyuanbit = zyBit });
|
await _zybit.AddAsync(new WY_SchZyBit { SchSerID = schSerId, ziyuanbit = zyBit });
|
||||||
|
|||||||
@@ -68,10 +68,9 @@ namespace YhCore.Service.Impl
|
|||||||
if (file == null || file.Length == 0)
|
if (file == null || file.Length == 0)
|
||||||
throw new DException("上传失败!");
|
throw new DException("上传失败!");
|
||||||
|
|
||||||
if (appType.ToLower() == "noticemsg") appType = "notice";
|
|
||||||
string dtStr = DateTime.Now.ToString("yyyyMMdd");
|
string dtStr = DateTime.Now.ToString("yyyyMMdd");
|
||||||
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
|
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
|
||||||
string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/";
|
string outFilePath = appType + "/" + dtStr + "/";
|
||||||
|
|
||||||
if (!Directory.Exists(physicalDir))
|
if (!Directory.Exists(physicalDir))
|
||||||
Directory.CreateDirectory(physicalDir);
|
Directory.CreateDirectory(physicalDir);
|
||||||
@@ -109,7 +108,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (appType.ToLower() == "noticemsg") appType = "notice";
|
if (appType.ToLower() == "noticemsg") appType = "notice";
|
||||||
string dtStr = DateTime.Now.ToString("yyyyMMdd");
|
string dtStr = DateTime.Now.ToString("yyyyMMdd");
|
||||||
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
|
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
|
||||||
string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/";
|
string outFilePath = appType + "/" + dtStr + "/";
|
||||||
|
|
||||||
if (!Directory.Exists(physicalDir))
|
if (!Directory.Exists(physicalDir))
|
||||||
Directory.CreateDirectory(physicalDir);
|
Directory.CreateDirectory(physicalDir);
|
||||||
@@ -169,7 +168,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (appType.ToLower() == "noticemsg") appType = "notice";
|
if (appType.ToLower() == "noticemsg") appType = "notice";
|
||||||
string dtStr = DateTime.Now.ToString("yyyyMMdd");
|
string dtStr = DateTime.Now.ToString("yyyyMMdd");
|
||||||
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
|
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
|
||||||
string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/";
|
string outFilePath = appType + "/" + dtStr + "/";
|
||||||
if (!Directory.Exists(physicalDir))
|
if (!Directory.Exists(physicalDir))
|
||||||
Directory.CreateDirectory(physicalDir);
|
Directory.CreateDirectory(physicalDir);
|
||||||
|
|
||||||
@@ -214,22 +213,29 @@ namespace YhCore.Service.Impl
|
|||||||
if (string.IsNullOrEmpty(fileUrl))
|
if (string.IsNullOrEmpty(fileUrl))
|
||||||
return "";
|
return "";
|
||||||
|
|
||||||
// 相对路径如 /UploadFile/docprint/20260101/{guid}.jpg → Config.FilePath + 子路径
|
// 新数据:fileurl 以 FILE_PATH 开头(如 /data/upload/docprint/20260101/{guid}.jpg)→ 剥前缀映射回 FILE_PATH。
|
||||||
|
// 注意必须用未 TrimStart 的原始 fileUrl 比较(FILE_PATH 本身以 / 开头)
|
||||||
|
string fp = Config.FilePath;
|
||||||
|
if (!string.IsNullOrEmpty(fp) && fileUrl.StartsWith(fp, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Path.Combine(fp, fileUrl.Substring(fp.Length).TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
|
||||||
|
// 旧数据:相对路径如 /upload/docprint/20260101/{guid}.jpg(不含 FILE_PATH 前缀)→ StorageRoot + 子路径
|
||||||
var relative = fileUrl.TrimStart('/');
|
var relative = fileUrl.TrimStart('/');
|
||||||
const string prefix = "UploadFile/";
|
string prefix = Config.FilePath;
|
||||||
if (relative.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
if (relative.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
relative = relative.Substring(prefix.Length);
|
relative = relative.Substring(prefix.Length);
|
||||||
|
|
||||||
return Path.Combine(StorageRoot(), relative.Replace('/', Path.DirectorySeparatorChar));
|
return Path.Combine(StorageRoot(), relative.Replace('/', Path.DirectorySeparatorChar));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>存储根目录(旧 Server.MapPath("~/UploadFile/");Config.FilePath 缺省时退回进程目录/UploadFile)。</summary>
|
/// <summary>存储根目录(旧 Server.MapPath("~/upload/");Config.FilePath 缺省时退回进程目录/upload)。</summary>
|
||||||
private static string StorageRoot()
|
public static string StorageRoot()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(Config.FilePath))
|
if (!string.IsNullOrEmpty(Config.FilePath))
|
||||||
return Config.FilePath;
|
return Config.FilePath;
|
||||||
|
|
||||||
return Path.Combine(AppContext.BaseDirectory, "UploadFile");
|
// 此分支仅在 FILE_PATH 未配置时执行(此时 Config.FilePath 为 null,不可再参与拼接)
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, Config.FilePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using YhCore.Entity;
|
|
||||||
using YhCore.Model.Vo;
|
|
||||||
using YhCore.Service.Interfaces;
|
|
||||||
|
|
||||||
namespace YhCore.Service.Impl
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 日志查询服务占位实现(框架期保证接口可运行)。
|
|
||||||
/// TODO:接入 Repository 层后实现真实查询(按 schId + systemScope 过滤、keyword 模糊匹配、分页)。
|
|
||||||
/// </summary>
|
|
||||||
public class LogServiceStub : ILogService
|
|
||||||
{
|
|
||||||
public PageVO<SysOperationLog> Search(int schId, string keyword, bool systemScope, int page, int size)
|
|
||||||
{
|
|
||||||
return PageVO<SysOperationLog>.Empty(page, size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -159,7 +159,7 @@ namespace YhCore.Service.Impl
|
|||||||
a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3)).Count;
|
a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3)).Count;
|
||||||
if (dup > 1) throw new DException("流程名不能重名!");
|
if (dup > 1) throw new DException("流程名不能重名!");
|
||||||
|
|
||||||
var info = await _flowinfo.FirstOrDefaultAsync(a => a.SerID == model.SerID);
|
var info = await _flowinfo.FirstOrDefaultTrackedAsync(a => a.SerID == model.SerID);
|
||||||
if (info == null) throw new DException("未找到要修改的流程!");
|
if (info == null) throw new DException("未找到要修改的流程!");
|
||||||
|
|
||||||
info.flowname = model.flowname;
|
info.flowname = model.flowname;
|
||||||
@@ -198,7 +198,7 @@ namespace YhCore.Service.Impl
|
|||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
|
|
||||||
var info = await _flowstep.FirstOrDefaultAsync(a => a.SerID == model.SerID);
|
var info = await _flowstep.FirstOrDefaultTrackedAsync(a => a.SerID == model.SerID);
|
||||||
if (info == null) throw new DException("未找到要修改的步骤!");
|
if (info == null) throw new DException("未找到要修改的步骤!");
|
||||||
|
|
||||||
info.flowid = model.flowid;
|
info.flowid = model.flowid;
|
||||||
@@ -207,7 +207,7 @@ namespace YhCore.Service.Impl
|
|||||||
await _flowstep.UpdateAsync(info);
|
await _flowstep.UpdateAsync(info);
|
||||||
|
|
||||||
// 重写该步骤的人员绑定(先清后加)
|
// 重写该步骤的人员绑定(先清后加)
|
||||||
var existing = await _groupuser.FindAsync(a => a.usergroup == info.SerID);
|
var existing = await _groupuser.FindTrackedAsync(a => a.usergroup == info.SerID);
|
||||||
foreach (var itt in existing)
|
foreach (var itt in existing)
|
||||||
{
|
{
|
||||||
await _groupuser.RemoveAsync(itt);
|
await _groupuser.RemoveAsync(itt);
|
||||||
@@ -243,7 +243,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null");
|
if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null");
|
||||||
|
|
||||||
var lids = ids.Split(',').Select(a => int.Parse(a)).ToArray();
|
var lids = ids.Split(',').Select(a => int.Parse(a)).ToArray();
|
||||||
var flows = await _flowinfo.FindAsync(a => lids.Contains(a.SerID));
|
var flows = await _flowinfo.FindTrackedAsync(a => lids.Contains(a.SerID));
|
||||||
foreach (var item in flows)
|
foreach (var item in flows)
|
||||||
{
|
{
|
||||||
item.enable = 3;
|
item.enable = 3;
|
||||||
@@ -256,7 +256,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
|
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
|
||||||
|
|
||||||
var intIds = ids.Select(d => (int)d).ToArray();
|
var intIds = ids.Select(d => (int)d).ToArray();
|
||||||
var objlist = await _flowstep.FindAsync(m => intIds.Contains(m.SerID));
|
var objlist = await _flowstep.FindTrackedAsync(m => intIds.Contains(m.SerID));
|
||||||
foreach (var item in objlist)
|
foreach (var item in objlist)
|
||||||
{
|
{
|
||||||
await _flowstep.RemoveAsync(item);
|
await _flowstep.RemoveAsync(item);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Yh.Platform.Abstractions;
|
|||||||
using Yh.SchClass.OpenApi;
|
using Yh.SchClass.OpenApi;
|
||||||
using YhCore.Entity;
|
using YhCore.Entity;
|
||||||
using YhCore.Model.Dto;
|
using YhCore.Model.Dto;
|
||||||
|
using YhCore.Model.Vo;
|
||||||
using YhCore.Repository;
|
using YhCore.Repository;
|
||||||
using YhCore.Service.Interfaces;
|
using YhCore.Service.Interfaces;
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ namespace YhCore.Service.Impl
|
|||||||
#region 抄送
|
#region 抄送
|
||||||
|
|
||||||
/// <summary>抄送列表(旧 SendRdList:DocStatus 1/11,SendUserID 含本人;旧为教师内连接)。</summary>
|
/// <summary>抄送列表(旧 SendRdList:DocStatus 1/11,SendUserID 含本人;旧为教师内连接)。</summary>
|
||||||
public async Task<List<DocPrintListItem>> SendRdListAsync(string name, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocPrintListItem>> 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();
|
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId)).ToList();
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ namespace YhCore.Service.Impl
|
|||||||
docs = docs.Where(m => m.TakeDate <= date2).ToList();
|
docs = docs.Where(m => m.TakeDate <= date2).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var total = docs.Count(m => m.DocStatus == "1" || m.DocStatus == "11");
|
||||||
docs = docs.Where(m => m.DocStatus == "1" || m.DocStatus == "11")
|
docs = docs.Where(m => m.DocStatus == "1" || m.DocStatus == "11")
|
||||||
.OrderByDescending(m => m.SerID)
|
.OrderByDescending(m => m.SerID)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
||||||
@@ -101,7 +103,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (!TryGetTea(teaMap, item.UserID, out var tea)) continue;
|
if (!TryGetTea(teaMap, item.UserID, out var tea)) continue;
|
||||||
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd"));
|
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd"));
|
||||||
}
|
}
|
||||||
return result;
|
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>查看抄送人员详情(旧 SendDetailsList)。</summary>
|
/// <summary>查看抄送人员详情(旧 SendDetailsList)。</summary>
|
||||||
@@ -138,7 +140,7 @@ namespace YhCore.Service.Impl
|
|||||||
{
|
{
|
||||||
await SetChaoSendAsync(int.Parse(uid), schSerId, mysenduserid);
|
await SetChaoSendAsync(int.Parse(uid), schSerId, mysenduserid);
|
||||||
|
|
||||||
var model = await _doc.FirstOrDefaultAsync(m => m.SerID == serId);
|
var model = await _doc.FirstOrDefaultTrackedAsync(m => m.SerID == serId);
|
||||||
if (model != null)
|
if (model != null)
|
||||||
{
|
{
|
||||||
model.SendUserID = senduserid;
|
model.SendUserID = senduserid;
|
||||||
@@ -249,7 +251,7 @@ namespace YhCore.Service.Impl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<int> EditAsync(WYDocPrintSaveDto model)
|
public async Task<int> EditAsync(WYDocPrintSaveDto model)
|
||||||
{
|
{
|
||||||
var info = await _doc.FirstOrDefaultAsync(x => x.SerID == model.SerID);
|
var info = await _doc.FirstOrDefaultTrackedAsync(x => x.SerID == model.SerID);
|
||||||
if (info == null) throw new DException("未找到该文印记录!");
|
if (info == null) throw new DException("未找到该文印记录!");
|
||||||
|
|
||||||
info.DocType = model.DocType;
|
info.DocType = model.DocType;
|
||||||
@@ -303,7 +305,7 @@ namespace YhCore.Service.Impl
|
|||||||
var idArr = ids.Split(',')
|
var idArr = ids.Split(',')
|
||||||
.Where(a => !string.IsNullOrEmpty(a) && decimal.TryParse(a, out _))
|
.Where(a => !string.IsNullOrEmpty(a) && decimal.TryParse(a, out _))
|
||||||
.Select(decimal.Parse).ToArray();
|
.Select(decimal.Parse).ToArray();
|
||||||
var objlist = await _doc.FindAsync(m => idArr.Contains(m.SerID));
|
var objlist = await _doc.FindTrackedAsync(m => idArr.Contains(m.SerID));
|
||||||
foreach (var item in objlist)
|
foreach (var item in objlist)
|
||||||
{
|
{
|
||||||
await _doc.RemoveAsync(item);
|
await _doc.RemoveAsync(item);
|
||||||
@@ -334,7 +336,7 @@ namespace YhCore.Service.Impl
|
|||||||
await _flowtranct.AddAsync(tranct);
|
await _flowtranct.AddAsync(tranct);
|
||||||
await _flowtranct.SaveChangesAsync();
|
await _flowtranct.SaveChangesAsync();
|
||||||
|
|
||||||
var docModel = await _doc.FirstOrDefaultAsync(m => m.SerID == model.dataid);
|
var docModel = await _doc.FirstOrDefaultTrackedAsync(m => m.SerID == model.dataid);
|
||||||
if (docModel != null)
|
if (docModel != null)
|
||||||
{
|
{
|
||||||
docModel.DocStatus = model.actionstatus.ToString();
|
docModel.DocStatus = model.actionstatus.ToString();
|
||||||
@@ -349,7 +351,7 @@ namespace YhCore.Service.Impl
|
|||||||
#region 列表
|
#region 列表
|
||||||
|
|
||||||
/// <summary>我的文印列表(旧 MyList;旧 DocTypeName 误用 YZBM 字典,此处保持旧行为)。</summary>
|
/// <summary>我的文印列表(旧 MyList;旧 DocTypeName 误用 YZBM 字典,此处保持旧行为)。</summary>
|
||||||
public async Task<List<DocPrintListItem>> MyListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocPrintListItem>> 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();
|
var docs = (await _doc.FindAsync(m => m.UserID == userId && m.SchSerID == schSerId)).ToList();
|
||||||
|
|
||||||
@@ -365,6 +367,7 @@ namespace YhCore.Service.Impl
|
|||||||
docs = docs.Where(m => m.TakeDate <= date2).ToList();
|
docs = docs.Where(m => m.TakeDate <= date2).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var total = docs.Count;
|
||||||
docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate).ThenByDescending(m => m.EditDate)
|
docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate).ThenByDescending(m => m.EditDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
||||||
|
|
||||||
@@ -376,14 +379,14 @@ namespace YhCore.Service.Impl
|
|||||||
TryGetTea(teaMap, item.UserID, out var tea); // 旧为左连接,缺失补空
|
TryGetTea(teaMap, item.UserID, out var tea); // 旧为左连接,缺失补空
|
||||||
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm", "YZBM"));
|
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm", "YZBM"));
|
||||||
}
|
}
|
||||||
return result;
|
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 全部文印列表(旧 AllList 原生 SQL 四分类:x=待我审(0) y=我已审(1) z=抄送我(2) m=我发起(3),按优先级去重)。
|
/// 全部文印列表(旧 AllList 原生 SQL 四分类:x=待我审(0) y=我已审(1) z=抄送我(2) m=我发起(3),按优先级去重)。
|
||||||
/// 旧 Name 过滤的 SQL 拼接存在运算符优先级缺陷,此处按语义实现:内容/教师名/字典名任一命中。
|
/// 旧 Name 过滤的 SQL 拼接存在运算符优先级缺陷,此处按语义实现:内容/教师名/字典名任一命中。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<DocPrintListItem>> AllListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocPrintListItem>> AllListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex)
|
||||||
{
|
{
|
||||||
int uid = int.Parse(userId);
|
int uid = int.Parse(userId);
|
||||||
|
|
||||||
@@ -423,6 +426,7 @@ namespace YhCore.Service.Impl
|
|||||||
|| DictNameContains(dictMap, "YZFL", t.doc.DPType, name));
|
|| DictNameContains(dictMap, "YZFL", t.doc.DPType, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var total = query.Count();
|
||||||
var pageItems = query.OrderByDescending(t => t.doc.SerID).ThenByDescending(t => t.doc.TakeDate)
|
var pageItems = query.OrderByDescending(t => t.doc.SerID).ThenByDescending(t => t.doc.TakeDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
||||||
|
|
||||||
@@ -434,16 +438,17 @@ namespace YhCore.Service.Impl
|
|||||||
item.ctype = ctype;
|
item.ctype = ctype;
|
||||||
result.Add(item);
|
result.Add(item);
|
||||||
}
|
}
|
||||||
return result;
|
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文印审核列表(旧 ShenheList:固定流程 + 指派 + 自由流程三路并集,HashSet 去重)。
|
/// 文印审核列表(旧 ShenheList:固定流程 + 指派 + 自由流程三路并集,HashSet 去重)。
|
||||||
/// 旧实现的 Name 过滤作用在未被使用的查询上(死代码),此处保持 Name 不生效的旧行为。
|
/// 旧实现的 Name 过滤作用在未被使用的查询上(死代码),此处保持 Name 不生效的旧行为。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<DocPrintListItem>> ShenheListAsync(string userId, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocPrintListItem>> ShenheListAsync(string userId, string schSerId, int pageSize, int pageIndex)
|
||||||
{
|
{
|
||||||
var docs = await GetPendingShenheDocsAsync(userId, schSerId, requireFlowIdNonZero: false);
|
var docs = await GetPendingShenheDocsAsync(userId, schSerId, requireFlowIdNonZero: false);
|
||||||
|
var total = docs.Count;
|
||||||
docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate)
|
docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
||||||
|
|
||||||
@@ -455,13 +460,13 @@ namespace YhCore.Service.Impl
|
|||||||
TryGetTea(teaMap, item.UserID, out var tea);
|
TryGetTea(teaMap, item.UserID, out var tea);
|
||||||
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm"));
|
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm"));
|
||||||
}
|
}
|
||||||
return result;
|
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 审批列表(旧 SPList:待审(DS,zt=false)∪ 我已审(YS,zt=true,排除 DS),_disabled 即 zt)。
|
/// 审批列表(旧 SPList:待审(DS,zt=false)∪ 我已审(YS,zt=true,排除 DS),_disabled 即 zt)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<DocPrintListItem>> SPListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocPrintListItem>> SPListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex)
|
||||||
{
|
{
|
||||||
var userid = int.Parse(userId);
|
var userid = int.Parse(userId);
|
||||||
|
|
||||||
@@ -493,6 +498,7 @@ namespace YhCore.Service.Impl
|
|||||||
query = query.Where(m => m.doc.DocContent != null && m.doc.DocContent.IndexOf(name) >= 0);
|
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 ordered = query.OrderBy(m => m.doc.DocStatus).ThenByDescending(m => m.doc.EditDate).ToList();
|
||||||
|
var total = ordered.Count;
|
||||||
|
|
||||||
var teaMap = await GetTeaMapAsync(schSerId);
|
var teaMap = await GetTeaMapAsync(schSerId);
|
||||||
var dictMap = await GetDictMapAsync(schSerId);
|
var dictMap = await GetDictMapAsync(schSerId);
|
||||||
@@ -504,11 +510,11 @@ namespace YhCore.Service.Impl
|
|||||||
item._disabled = zt; // 我已审的记录不可再编辑(覆盖通用 _disabled 语义,与旧一致)
|
item._disabled = zt; // 我已审的记录不可再编辑(覆盖通用 _disabled 语义,与旧一致)
|
||||||
result.Add(item);
|
result.Add(item);
|
||||||
}
|
}
|
||||||
return result;
|
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>文印已审列表(旧 YishenList:本人审核流水关联,排除撤销与已领取)。</summary>
|
/// <summary>文印已审列表(旧 YishenList:本人审核流水关联,排除撤销与已领取)。</summary>
|
||||||
public async Task<List<DocPrintListItem>> YishenListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocPrintListItem>> YishenListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex)
|
||||||
{
|
{
|
||||||
int uid = int.Parse(userId);
|
int uid = int.Parse(userId);
|
||||||
var tranctDataIds = (await _flowtranct.FindAsync(b => b.actionuserid == uid && b.datatype == DocPrintConstants.AppCode.ToString() && b.actionstatus != 11))
|
var tranctDataIds = (await _flowtranct.FindAsync(b => b.actionuserid == uid && b.datatype == DocPrintConstants.AppCode.ToString() && b.actionstatus != 11))
|
||||||
@@ -521,6 +527,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
docs = docs.Where(m => m.DocContent != null && m.DocContent.Contains(name)).ToList();
|
docs = docs.Where(m => m.DocContent != null && m.DocContent.Contains(name)).ToList();
|
||||||
|
|
||||||
|
var total = docs.Count;
|
||||||
docs = docs.OrderByDescending(m => m.SerID).ThenByDescending(m => m.TakeDate)
|
docs = docs.OrderByDescending(m => m.SerID).ThenByDescending(m => m.TakeDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
||||||
|
|
||||||
@@ -532,11 +539,11 @@ namespace YhCore.Service.Impl
|
|||||||
TryGetTea(teaMap, item.UserID, out var tea);
|
TryGetTea(teaMap, item.UserID, out var tea);
|
||||||
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm"));
|
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm"));
|
||||||
}
|
}
|
||||||
return result;
|
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>全部文印列表(旧 totalList,管理员视角,按状态/时间过滤)。</summary>
|
/// <summary>全部文印列表(旧 totalList,管理员视角,按状态/时间过滤)。</summary>
|
||||||
public async Task<List<DocPrintListItem>> TotalListAsync(string name, string stateid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocPrintListItem>> 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();
|
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId)).ToList();
|
||||||
|
|
||||||
@@ -552,6 +559,7 @@ namespace YhCore.Service.Impl
|
|||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
docs = docs.Where(m => m.DocContent != null && m.DocContent.IndexOf(name) >= 0).ToList();
|
docs = docs.Where(m => m.DocContent != null && m.DocContent.IndexOf(name) >= 0).ToList();
|
||||||
|
|
||||||
|
var total = docs.Count;
|
||||||
docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate)
|
docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
||||||
|
|
||||||
@@ -563,7 +571,7 @@ namespace YhCore.Service.Impl
|
|||||||
TryGetTea(teaMap, item.UserID, out var tea);
|
TryGetTea(teaMap, item.UserID, out var tea);
|
||||||
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd"));
|
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd"));
|
||||||
}
|
}
|
||||||
return result;
|
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -665,7 +673,7 @@ namespace YhCore.Service.Impl
|
|||||||
using var tx = await _db.Database.BeginTransactionAsync();
|
using var tx = await _db.Database.BeginTransactionAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var rdmodel = await _doc.FirstOrDefaultAsync(m => m.SerID == model.dataid);
|
var rdmodel = await _doc.FirstOrDefaultTrackedAsync(m => m.SerID == model.dataid);
|
||||||
if (rdmodel == null) throw new DException("未找到该文印记录!");
|
if (rdmodel == null) throw new DException("未找到该文印记录!");
|
||||||
|
|
||||||
if (rdmodel.DocStatus != "0")
|
if (rdmodel.DocStatus != "0")
|
||||||
@@ -766,7 +774,7 @@ namespace YhCore.Service.Impl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> AffirmApplyforOkAsync(FlowTranctDto model)
|
public async Task<string> AffirmApplyforOkAsync(FlowTranctDto model)
|
||||||
{
|
{
|
||||||
var itt = await _doc.FirstOrDefaultAsync(m => m.SerID == model.dataid);
|
var itt = await _doc.FirstOrDefaultTrackedAsync(m => m.SerID == model.dataid);
|
||||||
if (itt == null) throw new DException("未找到该文印记录!");
|
if (itt == null) throw new DException("未找到该文印记录!");
|
||||||
|
|
||||||
var claimed = await _flowtranct.AnyAsync(a => a.dataid == model.dataid
|
var claimed = await _flowtranct.AnyAsync(a => a.dataid == model.dataid
|
||||||
@@ -817,7 +825,7 @@ namespace YhCore.Service.Impl
|
|||||||
{
|
{
|
||||||
if (flowid <= 0) return;
|
if (flowid <= 0) return;
|
||||||
|
|
||||||
var list = await _zhipai.FirstOrDefaultAsync(a => a.UserID == uid && a.MSerID == dataid
|
var list = await _zhipai.FirstOrDefaultTrackedAsync(a => a.UserID == uid && a.MSerID == dataid
|
||||||
&& a.SchSerID == schSerId && a.FlowID == flowid && a.FlowStepID == flowstepid && a.type == type);
|
&& a.SchSerID == schSerId && a.FlowID == flowid && a.FlowStepID == flowstepid && a.type == type);
|
||||||
if (list == null)
|
if (list == null)
|
||||||
{
|
{
|
||||||
@@ -895,7 +903,7 @@ namespace YhCore.Service.Impl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>文印详情统计列表(旧 List2:非待审单据按申请时间倒序分页)。</summary>
|
/// <summary>文印详情统计列表(旧 List2:非待审单据按申请时间倒序分页)。</summary>
|
||||||
public async Task<List<DocDetailStatItem>> List2Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<DocDetailStatItem>> List2Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex)
|
||||||
{
|
{
|
||||||
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus != "0")).ToList();
|
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus != "0")).ToList();
|
||||||
if (DateTime.TryParse(starttime, out var date1))
|
if (DateTime.TryParse(starttime, out var date1))
|
||||||
@@ -933,13 +941,16 @@ namespace YhCore.Service.Impl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return full.OrderByDescending(a => a.EditDate)
|
var total = full.Count;
|
||||||
|
return PageVO<DocDetailStatItem>.Of(
|
||||||
|
full.OrderByDescending(a => a.EditDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize)
|
.Skip(pageSize * pageIndex).Take(pageSize)
|
||||||
.ToList();
|
.ToList(),
|
||||||
|
total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>文印数量按用户排名(旧 List3)。</summary>
|
/// <summary>文印数量按用户排名(旧 List3)。</summary>
|
||||||
public async Task<List<UserRankItem>> List3Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<UserRankItem>> List3Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex)
|
||||||
{
|
{
|
||||||
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus == "1")).ToList();
|
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.DocStatus == "1")).ToList();
|
||||||
if (DateTime.TryParse(starttime, out var date1))
|
if (DateTime.TryParse(starttime, out var date1))
|
||||||
@@ -965,23 +976,27 @@ namespace YhCore.Service.Impl
|
|||||||
.OrderByDescending(a => a.num)
|
.OrderByDescending(a => a.num)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return grouped.Skip(pageSize * pageIndex).Take(pageSize).ToList();
|
var total = grouped.Count;
|
||||||
|
return PageVO<UserRankItem>.Of(
|
||||||
|
grouped.Skip(pageSize * pageIndex).Take(pageSize).ToList(),
|
||||||
|
total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>按月份统计明细(旧 List4:指定年月已印好单据,按申请时间倒序分页)。</summary>
|
/// <summary>按月份统计明细(旧 List4:指定年月已印好单据,按申请时间倒序分页)。</summary>
|
||||||
public async Task<List<StatListItem>> List4Async(int year, int month, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<StatListItem>> List4Async(int year, int month, string schSerId, int pageSize, int pageIndex)
|
||||||
{
|
{
|
||||||
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId
|
var all = (await _doc.FindAsync(m => m.SchSerID == schSerId
|
||||||
&& m.DocStatus == "1" && m.EditDate.Year == year && m.EditDate.Month == month))
|
&& m.DocStatus == "1" && m.EditDate.Year == year && m.EditDate.Month == month)).ToList();
|
||||||
.OrderByDescending(a => a.EditDate)
|
var total = all.Count;
|
||||||
|
var docs = all.OrderByDescending(a => a.EditDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize)
|
.Skip(pageSize * pageIndex).Take(pageSize)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return await BuildStatListAsync(docs, schSerId);
|
return PageVO<StatListItem>.Of(await BuildStatListAsync(docs, schSerId), total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>按用户统计明细(旧 List5:指定用户已印好单据,按申请时间倒序分页)。</summary>
|
/// <summary>按用户统计明细(旧 List5:指定用户已印好单据,按申请时间倒序分页)。</summary>
|
||||||
public async Task<List<StatListItem>> List5Async(string userid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex)
|
public async Task<PageVO<StatListItem>> 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();
|
var docs = (await _doc.FindAsync(m => m.SchSerID == schSerId && m.UserID == userid && m.DocStatus == "1")).ToList();
|
||||||
if (DateTime.TryParse(starttime, out var date1))
|
if (DateTime.TryParse(starttime, out var date1))
|
||||||
@@ -992,11 +1007,12 @@ namespace YhCore.Service.Impl
|
|||||||
docs = docs.Where(m => m.EditDate <= date2).ToList();
|
docs = docs.Where(m => m.EditDate <= date2).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var total = docs.Count;
|
||||||
docs = docs.OrderByDescending(a => a.EditDate)
|
docs = docs.OrderByDescending(a => a.EditDate)
|
||||||
.Skip(pageSize * pageIndex).Take(pageSize)
|
.Skip(pageSize * pageIndex).Take(pageSize)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return await BuildStatListAsync(docs, schSerId);
|
return PageVO<StatListItem>.Of(await BuildStatListAsync(docs, schSerId), total, pageIndex, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -1240,7 +1256,7 @@ namespace YhCore.Service.Impl
|
|||||||
/// <summary>自由流程挂链(旧 ApproveFlow.SetPrimarySteptID:从 stepid 起沿 laststep 链回写 dataid)。</summary>
|
/// <summary>自由流程挂链(旧 ApproveFlow.SetPrimarySteptID:从 stepid 起沿 laststep 链回写 dataid)。</summary>
|
||||||
private async Task SetPrimarySteptIDAsync(int dataid, int stepid)
|
private async Task SetPrimarySteptIDAsync(int dataid, int stepid)
|
||||||
{
|
{
|
||||||
var node = await _freeflowinfo.FirstOrDefaultAsync(a => a.SerID == stepid);
|
var node = await _freeflowinfo.FirstOrDefaultTrackedAsync(a => a.SerID == stepid);
|
||||||
if (node != null)
|
if (node != null)
|
||||||
{
|
{
|
||||||
node.dataid = dataid;
|
node.dataid = dataid;
|
||||||
@@ -1250,7 +1266,7 @@ namespace YhCore.Service.Impl
|
|||||||
var nextSer = stepid;
|
var nextSer = stepid;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var next = await _freeflowinfo.FirstOrDefaultAsync(a => a.laststep == nextSer);
|
var next = await _freeflowinfo.FirstOrDefaultTrackedAsync(a => a.laststep == nextSer);
|
||||||
if (next == null) break;
|
if (next == null) break;
|
||||||
next.dataid = dataid;
|
next.dataid = dataid;
|
||||||
await _freeflowinfo.SaveChangesAsync();
|
await _freeflowinfo.SaveChangesAsync();
|
||||||
@@ -1261,7 +1277,7 @@ namespace YhCore.Service.Impl
|
|||||||
/// <summary>抄送人落库(旧 ApproveFlow.SetChaoSend:按用户+flowType 全删再插)。</summary>
|
/// <summary>抄送人落库(旧 ApproveFlow.SetChaoSend:按用户+flowType 全删再插)。</summary>
|
||||||
private async Task SetChaoSendAsync(int uid, string schSerId, string senduserid)
|
private async Task SetChaoSendAsync(int uid, string schSerId, string senduserid)
|
||||||
{
|
{
|
||||||
var olds = await _senduser.FindAsync(a => a.flowType == DocPrintConstants.AppCode && a.userid == uid);
|
var olds = await _senduser.FindTrackedAsync(a => a.flowType == DocPrintConstants.AppCode && a.userid == uid);
|
||||||
foreach (var old in olds)
|
foreach (var old in olds)
|
||||||
{
|
{
|
||||||
await _senduser.RemoveAsync(old);
|
await _senduser.RemoveAsync(old);
|
||||||
@@ -1287,7 +1303,7 @@ namespace YhCore.Service.Impl
|
|||||||
var fids = ParseIds(upFile);
|
var fids = ParseIds(upFile);
|
||||||
foreach (var fid in fids)
|
foreach (var fid in fids)
|
||||||
{
|
{
|
||||||
var file = await _files.FirstOrDefaultAsync(x => x.SerID == fid);
|
var file = await _files.FirstOrDefaultTrackedAsync(x => x.SerID == fid);
|
||||||
if (file != null)
|
if (file != null)
|
||||||
{
|
{
|
||||||
file.TopicID = topicId;
|
file.TopicID = topicId;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ namespace YhCore.Service.Interfaces
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 公共文件服务(移植旧 BestSchool.Service.Common.GetEnclosure/FileSaveEnclosure +
|
/// 公共文件服务(移植旧 BestSchool.Service.Common.GetEnclosure/FileSaveEnclosure +
|
||||||
/// BestSchool.Common.UpLoadFile.FileSave/FileSaveImage/FileChunkSave/FileMergeSave)。
|
/// BestSchool.Common.upload.FileSave/FileSaveImage/FileChunkSave/FileMergeSave)。
|
||||||
/// 存储约定:物理根目录 = Config.FilePath(≈旧 ~/UploadFile/),相对路径仍存 /UploadFile/{AppType}/{yyyyMMdd}/{guid}{ext}
|
/// 存储约定:物理根目录 = Config.FilePath(≈旧 ~/upload/),相对路径仍存 /upload/{AppType}/{yyyyMMdd}/{guid}{ext}
|
||||||
/// (与旧库数据格式一致,兼容历史记录);host 取 Config.FileWebPath(≈旧 AuthConfig.weboa_url)。
|
/// (与旧库数据格式一致,兼容历史记录);host 取 Config.FileWebPath(≈旧 AuthConfig.weboa_url)。
|
||||||
/// 旧 ToOfficeHelper.OfficeConvertToPDF(Office COM 转 PDF)新项目缺失,降级:上传不做转 PDF,
|
/// 旧 ToOfficeHelper.OfficeConvertToPDF(Office COM 转 PDF)新项目缺失,降级:上传不做转 PDF,
|
||||||
/// 预览时本地无 .pdf 则降级 officeapps.live.com 在线预览。
|
/// 预览时本地无 .pdf 则降级 officeapps.live.com 在线预览。
|
||||||
@@ -21,10 +21,10 @@ namespace YhCore.Service.Interfaces
|
|||||||
/// <summary>存储附件信息(旧 FileSaveEnclosure:Common_UpFiles 落库,返回 SerID;文件名非法字符替换为 _)。</summary>
|
/// <summary>存储附件信息(旧 FileSaveEnclosure:Common_UpFiles 落库,返回 SerID;文件名非法字符替换为 _)。</summary>
|
||||||
Task<int> SaveEnclosureAsync(FileObj file, string appType);
|
Task<int> SaveEnclosureAsync(FileObj file, string appType);
|
||||||
|
|
||||||
/// <summary>单文件上传(旧 UpLoadFile.FileSave:扩展名白名单 + 保存 + 返回 FileObj)。</summary>
|
/// <summary>单文件上传(旧 upload.FileSave:扩展名白名单 + 保存 + 返回 FileObj)。</summary>
|
||||||
Task<FileObj> SaveFileAsync(string appType, IFormFile file);
|
Task<FileObj> SaveFileAsync(string appType, IFormFile file);
|
||||||
|
|
||||||
/// <summary>图片多文件上传(旧 UpLoadFile.FileSaveImage:jpg/gif/png/jpeg)。</summary>
|
/// <summary>图片多文件上传(旧 upload.FileSaveImage:jpg/gif/png/jpeg)。</summary>
|
||||||
Task<List<FileObj>> SaveFileImageAsync(string appType, IFormFileCollection files);
|
Task<List<FileObj>> SaveFileImageAsync(string appType, IFormFileCollection files);
|
||||||
|
|
||||||
/// <summary>保存分片(旧 FileChunkSave:临时目录 {guid}/ 下按索引存块)。</summary>
|
/// <summary>保存分片(旧 FileChunkSave:临时目录 {guid}/ 下按索引存块)。</summary>
|
||||||
@@ -33,7 +33,7 @@ namespace YhCore.Service.Interfaces
|
|||||||
/// <summary>合并分片(旧 FileMergeSave:按块合并 → 正式目录 + FileObj)。</summary>
|
/// <summary>合并分片(旧 FileMergeSave:按块合并 → 正式目录 + FileObj)。</summary>
|
||||||
Task<FileObj> MergeSaveAsync(string appType, string guid, string fileName);
|
Task<FileObj> MergeSaveAsync(string appType, string guid, string fileName);
|
||||||
|
|
||||||
/// <summary>相对路径 → 物理路径(fileurl 以 /UploadFile/ 开头 → Config.FilePath + 子路径)。</summary>
|
/// <summary>相对路径 → 物理路径(fileurl 以 /upload/ 开头 → Config.FilePath + 子路径)。</summary>
|
||||||
string MapPhysicalPath(string fileUrl);
|
string MapPhysicalPath(string fileUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
using YhCore.Entity;
|
using YhCore.Entity;
|
||||||
using YhCore.Model.Vo;
|
using YhCore.Model.Vo;
|
||||||
|
|
||||||
@@ -9,13 +10,13 @@ namespace YhCore.Service.Interfaces
|
|||||||
public interface ILogService
|
public interface ILogService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 分页搜索系统审计日志。
|
/// 分页搜索系统审计日志(sys_operation_log,按 CreateTime 倒序)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="schId">学校 ID</param>
|
/// <param name="schId">学校 ID(多校隔离)</param>
|
||||||
/// <param name="keyword">关键字(可为空)</param>
|
/// <param name="keyword">关键字(可为空;模糊匹配用户名/URI/类方法/参数/异常/操作类型)</param>
|
||||||
/// <param name="systemScope">true=系统操作日志(systemSearch);false=普通操作日志(search)</param>
|
/// <param name="systemScope">true=系统操作日志(systemSearch,全量);false=普通操作日志(search,排除查询类)</param>
|
||||||
/// <param name="page">页码,从 0 开始</param>
|
/// <param name="page">页码,从 0 开始</param>
|
||||||
/// <param name="size">每页大小</param>
|
/// <param name="size">每页大小</param>
|
||||||
PageVO<SysOperationLog> Search(int schId, string keyword, bool systemScope, int page, int size);
|
Task<PageVO<SysOperationLog>> SearchAsync(int schId, string keyword, bool systemScope, int page, int size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using YhCore.Model.Dto;
|
using YhCore.Model.Dto;
|
||||||
|
using YhCore.Model.Vo;
|
||||||
|
|
||||||
namespace YhCore.Service.Interfaces
|
namespace YhCore.Service.Interfaces
|
||||||
{
|
{
|
||||||
@@ -11,7 +12,7 @@ namespace YhCore.Service.Interfaces
|
|||||||
public interface IWYDocPrintService
|
public interface IWYDocPrintService
|
||||||
{
|
{
|
||||||
// ---- 抄送 ----
|
// ---- 抄送 ----
|
||||||
Task<List<DocPrintListItem>> SendRdListAsync(string name, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocPrintListItem>> SendRdListAsync(string name, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<SendDetailItem>> SendDetailsListAsync(int serId);
|
Task<List<SendDetailItem>> SendDetailsListAsync(int serId);
|
||||||
Task<string> EditSendAsync(int serId, string senduserid, string mysenduserid, string schSerId, string uid);
|
Task<string> EditSendAsync(int serId, string senduserid, string mysenduserid, string schSerId, string uid);
|
||||||
|
|
||||||
@@ -25,12 +26,12 @@ namespace YhCore.Service.Interfaces
|
|||||||
Task<string> RevokeAsync(FlowTranctDto model);
|
Task<string> RevokeAsync(FlowTranctDto model);
|
||||||
|
|
||||||
// ---- 列表 ----
|
// ---- 列表 ----
|
||||||
Task<List<DocPrintListItem>> MyListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocPrintListItem>> MyListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<DocPrintListItem>> AllListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocPrintListItem>> AllListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<DocPrintListItem>> ShenheListAsync(string userId, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocPrintListItem>> ShenheListAsync(string userId, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<DocPrintListItem>> SPListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocPrintListItem>> SPListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<DocPrintListItem>> YishenListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocPrintListItem>> YishenListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<DocPrintListItem>> TotalListAsync(string name, string stateid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocPrintListItem>> TotalListAsync(string name, string stateid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<DocPrintDetailItem>> MyListtoOneAsync(int serId, string schSerId);
|
Task<List<DocPrintDetailItem>> MyListtoOneAsync(int serId, string schSerId);
|
||||||
Task<List<BindFlowOption>> BindflowinfoAsync(string schSerId, int flowType);
|
Task<List<BindFlowOption>> BindflowinfoAsync(string schSerId, int flowType);
|
||||||
|
|
||||||
@@ -43,9 +44,9 @@ namespace YhCore.Service.Interfaces
|
|||||||
|
|
||||||
// ---- 统计 ----
|
// ---- 统计 ----
|
||||||
Task<List<MonthStatItem>> List1Async(string starttime, string endtime, string schSerId);
|
Task<List<MonthStatItem>> List1Async(string starttime, string endtime, string schSerId);
|
||||||
Task<List<DocDetailStatItem>> List2Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<DocDetailStatItem>> List2Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<UserRankItem>> List3Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<UserRankItem>> List3Async(string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<StatListItem>> List4Async(int year, int month, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<StatListItem>> List4Async(int year, int month, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<StatListItem>> List5Async(string userid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<StatListItem>> List5Async(string userid, string starttime, string endtime, string schSerId, int pageSize, int pageIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using YhCore.Entity;
|
using YhCore.Entity;
|
||||||
using YhCore.Model.Dto;
|
using YhCore.Model.Dto;
|
||||||
using YhCore.Model.Vo;
|
using YhCore.Model.Vo;
|
||||||
@@ -14,7 +16,9 @@ namespace YhCore.Service.Interfaces
|
|||||||
/// 落库 WY_SHRole_SHTea.SHTeaID / WY_SHRole_BSHTea.BSHTeaID 仍为 decimal 用户 id;
|
/// 落库 WY_SHRole_SHTea.SHTeaID / WY_SHRole_BSHTea.BSHTeaID 仍为 decimal 用户 id;
|
||||||
/// 3. 签名存文件系统(DES 加密),非数据库表;
|
/// 3. 签名存文件系统(DES 加密),非数据库表;
|
||||||
/// 4. 旧 ServiceResult 的 Data/OtherData/Page 三段合并为单个 Result 对象(getshenheFlow / getStepInfo / getStepByJE / getdocOftable)。
|
/// 4. 旧 ServiceResult 的 Data/OtherData/Page 三段合并为单个 Result 对象(getshenheFlow / getStepInfo / getStepByJE / getdocOftable)。
|
||||||
/// 延后:Excel 导入导出(UpfileExcelSHRole/downMBOfSHRole/downUpfileSHRoleFK/ExceltotalListSHRole/WYRDExceldaochu)、审批单 PDF(DwonWYSQD)。
|
/// Excel 导入导出(UpfileExcelSHRole/downMBOfSHRole/downUpfileSHRoleFK/ExceltotalListSHRole/WYRDExceldaochu)
|
||||||
|
/// 与审批单 PDF(DwonWYSQD):下载类接口返回 (byte[] Content, string FileName),
|
||||||
|
/// 控制器按扩展名包装 File();导入反馈表存进程内 IMemoryCache(单实例)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IWYSHRoleService
|
public interface IWYSHRoleService
|
||||||
{
|
{
|
||||||
@@ -50,10 +54,10 @@ namespace YhCore.Service.Interfaces
|
|||||||
// ---- 用户记录查看和审核 ----
|
// ---- 用户记录查看和审核 ----
|
||||||
Task<SHRoleShenheFlowResult> GetshenheFlowAsync(string schSerId, decimal docprintId, int flowid);
|
Task<SHRoleShenheFlowResult> GetshenheFlowAsync(string schSerId, decimal docprintId, int flowid);
|
||||||
Task<List<SHRoleDocPrintListItem>> MyListtoOneSHRoleAsync(int serId, string schSerId);
|
Task<List<SHRoleDocPrintListItem>> MyListtoOneSHRoleAsync(int serId, string schSerId);
|
||||||
Task<List<SHRoleDocPrintListItem>> MyListSHRoleAsync(string name, string status, string startDate, string endDate, int pageSize, int pageIndex, string userId, string schSerId);
|
Task<PageVO<SHRoleDocPrintListItem>> MyListSHRoleAsync(string name, string status, string startDate, string endDate, int pageSize, int pageIndex, string userId, string schSerId);
|
||||||
Task<List<SHRoleDocPrintListItem>> TotalListSHRoleAsync(int flowid, int dwid, string name, string stateid, string starttime, string endtime, int pageSize, int pageIndex, string schSerId);
|
Task<PageVO<SHRoleDocPrintListItem>> TotalListSHRoleAsync(int flowid, int dwid, string name, string stateid, string starttime, string endtime, int pageSize, int pageIndex, string schSerId);
|
||||||
Task<List<SHRoleDocPrintListItem>> SendRdListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<SHRoleDocPrintListItem>> SendRdListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<List<SHRoleDocPrintListItem>> SPListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string userName, string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<SHRoleDocPrintListItem>> SPListSHRoleAsync(int flowid, int dwid, string name, string status, string startDate, string endDate, string userId, string userName, string schSerId, int pageSize, int pageIndex);
|
||||||
Task<string> ShenheOkSHRoleAsync(FlowTranctDto model);
|
Task<string> ShenheOkSHRoleAsync(FlowTranctDto model);
|
||||||
|
|
||||||
// ---- 规格表 / 签名 / 退回 / 金额 ----
|
// ---- 规格表 / 签名 / 退回 / 金额 ----
|
||||||
@@ -64,7 +68,26 @@ namespace YhCore.Service.Interfaces
|
|||||||
Task<string> TuiHuiAsync(string userId, string cont, decimal docId);
|
Task<string> TuiHuiAsync(string userId, string cont, decimal docId);
|
||||||
Task<string> UnTuiHuiAsync(string userId, decimal docId);
|
Task<string> UnTuiHuiAsync(string userId, decimal docId);
|
||||||
Task<string> RegetJEAsync(string schSerId, string userId, List<decimal> listDocPrintId, string type);
|
Task<string> RegetJEAsync(string schSerId, string userId, List<decimal> listDocPrintId, string type);
|
||||||
Task<List<SHRoleRegetJERdItem>> GetRegetJERdAsync(string schSerId, int pageSize, int pageIndex);
|
Task<PageVO<SHRoleRegetJERdItem>> GetRegetJERdAsync(string schSerId, int pageSize, int pageIndex);
|
||||||
Task<int> SetDownFileTimesAsync(int serId);
|
Task<int> SetDownFileTimesAsync(int serId);
|
||||||
|
|
||||||
|
// ---- Excel 导入导出 / 审批单 PDF(延后批次补齐)----
|
||||||
|
/// <summary>下载导入模板(旧 downMBOfSHRole,代码生成)。</summary>
|
||||||
|
Task<byte[]> DownMBOfSHRoleAsync();
|
||||||
|
|
||||||
|
/// <summary>Excel 批量导入审核角色(旧 UpfileExcelSHRole,multipart 第一个文件),返回反馈消息。</summary>
|
||||||
|
Task<string> UpfileExcelSHRoleAsync(string schSerId, string userId, IFormFile file);
|
||||||
|
|
||||||
|
/// <summary>下载最近一次导入的反馈 Excel(旧 downUpfileSHRoleFK)。</summary>
|
||||||
|
Task<byte[]> DownUpfileSHRoleFKAsync(string userId);
|
||||||
|
|
||||||
|
/// <summary>全部文印导出(旧 ExceltotalListSHRole)。</summary>
|
||||||
|
Task<(byte[] Content, string FileName)> ExceltotalListSHRoleAsync(string schSerId, string name, string stateid, string starttime, string endtime);
|
||||||
|
|
||||||
|
/// <summary>文印记录导出(旧 WYRDExceldaochu,flag=page 简版 / 其余完整版)。</summary>
|
||||||
|
Task<(byte[] Content, string FileName)> WYRDExceldaochuAsync(string schSerId, int flowid, int dwid, string name, string stateid, string starttime, string endtime, string flag);
|
||||||
|
|
||||||
|
/// <summary>审批单 PDF 下载(旧 DwonWYSQD,IDS 逗号分隔的 WY_DocPrint.SerID)。</summary>
|
||||||
|
Task<(byte[] Content, string FileName)> DwonWYSQDAsync(string schSerId, string userId, string ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace YhCore.Service.Pdf
|
||||||
|
{
|
||||||
|
/// <summary>数字转中文大写金额(移植自旧 Bsch.Common/DocPrint/NumberToChineseConverter,审批单合计金额用)。</summary>
|
||||||
|
public class NumberToChineseConverter
|
||||||
|
{
|
||||||
|
// 中文大写数字
|
||||||
|
private static readonly char[] ChineseNumbers = { '零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖' };
|
||||||
|
// 中文大写单位
|
||||||
|
private static readonly string[] ChineseUnits = { "分", "角", "元", "拾", "佰", "仟", "万", "拾", "佰", "仟", "亿", "拾", "佰", "仟", "兆" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将数字转换为中文大写金额
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="number">需要转换的数字(支持整数和小数,建议保留两位小数)</param>
|
||||||
|
/// <returns>中文大写金额字符串</returns>
|
||||||
|
public static string ToChineseUppercase(decimal number)
|
||||||
|
{
|
||||||
|
// 处理负数
|
||||||
|
bool isNegative = number < 0;
|
||||||
|
if (isNegative)
|
||||||
|
{
|
||||||
|
number = Math.Abs(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四舍五入保留两位小数
|
||||||
|
number = Math.Round(number, 2);
|
||||||
|
long integerPart = (long)number; // 整数部分
|
||||||
|
int decimalPart = (int)((number - integerPart) * 100); // 小数部分(角分)
|
||||||
|
|
||||||
|
// 处理零值
|
||||||
|
if (integerPart == 0 && decimalPart == 0)
|
||||||
|
{
|
||||||
|
return "零元整";
|
||||||
|
}
|
||||||
|
|
||||||
|
string result = string.Empty;
|
||||||
|
|
||||||
|
// 处理整数部分
|
||||||
|
if (integerPart > 0)
|
||||||
|
{
|
||||||
|
string integerStr = integerPart.ToString();
|
||||||
|
int length = integerStr.Length;
|
||||||
|
|
||||||
|
// 标记是否需要加零
|
||||||
|
bool needZero = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
int digit = int.Parse(integerStr[i].ToString());
|
||||||
|
int unitIndex = length - i - 1 + 2; // 对应到ChineseUnits的索引(从元开始)
|
||||||
|
|
||||||
|
if (digit == 0)
|
||||||
|
{
|
||||||
|
needZero = true;
|
||||||
|
// 处理万、亿等单位,避免漏单位
|
||||||
|
if (unitIndex == 6 || unitIndex == 10)
|
||||||
|
{
|
||||||
|
result += ChineseUnits[unitIndex];
|
||||||
|
needZero = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (needZero)
|
||||||
|
{
|
||||||
|
result += ChineseNumbers[0];
|
||||||
|
needZero = false;
|
||||||
|
}
|
||||||
|
result += ChineseNumbers[digit] + ChineseUnits[unitIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理小数部分(角分)
|
||||||
|
if (decimalPart > 0)
|
||||||
|
{
|
||||||
|
int jiao = decimalPart / 10; // 角
|
||||||
|
int fen = decimalPart % 10; // 分
|
||||||
|
|
||||||
|
if (jiao > 0)
|
||||||
|
{
|
||||||
|
result += ChineseNumbers[jiao] + ChineseUnits[1];
|
||||||
|
}
|
||||||
|
else if (integerPart > 0) // 有整数部分但角为0,需要加零
|
||||||
|
{
|
||||||
|
result += ChineseNumbers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fen > 0)
|
||||||
|
{
|
||||||
|
result += ChineseNumbers[fen] + ChineseUnits[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 无小数部分,加"整"
|
||||||
|
result += "整";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理负数
|
||||||
|
if (isNegative)
|
||||||
|
{
|
||||||
|
result = "负" + result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重载方法,支持double类型
|
||||||
|
public static string ToChineseUppercase(double number)
|
||||||
|
{
|
||||||
|
return ToChineseUppercase((decimal)number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重载方法,支持int类型
|
||||||
|
public static string ToChineseUppercase(int number)
|
||||||
|
{
|
||||||
|
return ToChineseUppercase((decimal)number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// PDF helper(移植自旧 Bsch.Common/DocPrint/PdfHelper):
|
||||||
|
// HTML→PDF 由 wkhtmltopdf 外部进程完成(原 iTextSharp XMLWorker 无 Core 版),
|
||||||
|
// 多页合并用 iTextSharp.LGPLv2.Core;Base64→PNG 直接落盘。
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using iTextSharp.text;
|
||||||
|
using iTextSharp.text.pdf;
|
||||||
|
using Yh.Platform.Abstractions;
|
||||||
|
using YhCore.Common.Config;
|
||||||
|
|
||||||
|
namespace YhCore.Service.Pdf
|
||||||
|
{
|
||||||
|
public class PdfHelper
|
||||||
|
{
|
||||||
|
private static string WkHtmlToPdfPath =>
|
||||||
|
string.IsNullOrEmpty(Config.WkHtmlToPdfPath) ? "wkhtmltopdf" : Config.WkHtmlToPdfPath;
|
||||||
|
|
||||||
|
// --enable-local-file-access 为 wkhtmltopdf 0.12.6 新增开关(签名图片用本地绝对路径 <img> 必需);
|
||||||
|
// 0.12.5 及以下(如 Debian 10 源)不认识该参数会报 Unknown long argument,按版本探测决定是否追加
|
||||||
|
private static readonly Lazy<bool> NeedsLocalFileAccessFlag = new Lazy<bool>(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var probe = Process.Start(new ProcessStartInfo(WkHtmlToPdfPath, "--version")
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
});
|
||||||
|
if (probe == null) return true;
|
||||||
|
string output = probe.StandardOutput.ReadToEnd();
|
||||||
|
probe.WaitForExit(10000);
|
||||||
|
var m = Regex.Match(output, @"(\d+)\.(\d+)\.(\d+)");
|
||||||
|
if (!m.Success) return true;
|
||||||
|
var version = new Version(int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value), int.Parse(m.Groups[3].Value));
|
||||||
|
return version >= new Version(0, 12, 6);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>逐页 HTML→PDF 后合并为单个 PDF 字节流(DwonWYSQD 用)</summary>
|
||||||
|
public static byte[] ConvertHtmlTextToPdfByList(List<string> htmlText, string HorV = "V")
|
||||||
|
=> MergePdf(htmlText.Select(HtmlToPdf).ToList());
|
||||||
|
|
||||||
|
private static byte[] HtmlToPdf(string html)
|
||||||
|
{
|
||||||
|
var tmpIn = Path.GetTempFileName() + ".html";
|
||||||
|
var tmpOut = Path.GetTempFileName() + ".pdf";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(tmpIn, html, new UTF8Encoding(false));
|
||||||
|
string localFlag = NeedsLocalFileAccessFlag.Value ? " --enable-local-file-access" : "";
|
||||||
|
var psi = new ProcessStartInfo(WkHtmlToPdfPath,
|
||||||
|
$" --encoding utf-8{localFlag} --disable-smart-shrinking \"{tmpIn}\" \"{tmpOut}\"")
|
||||||
|
{ CreateNoWindow = true, UseShellExecute = false };
|
||||||
|
using var proc = Process.Start(psi);
|
||||||
|
if (proc == null || !proc.WaitForExit(60000) || !File.Exists(tmpOut))
|
||||||
|
throw new DException("审批单 PDF 生成失败,请检查服务器是否已安装 wkhtmltopdf(或配置 wkhtmltopdf_path 指向其可执行文件)!");
|
||||||
|
return File.ReadAllBytes(tmpOut);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tmpIn)) File.Delete(tmpIn);
|
||||||
|
if (File.Exists(tmpOut)) File.Delete(tmpOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] MergePdf(List<byte[]> pdfBytes)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
var doc = new Document();
|
||||||
|
var copy = new PdfCopy(doc, ms);
|
||||||
|
doc.Open();
|
||||||
|
foreach (var b in pdfBytes)
|
||||||
|
{
|
||||||
|
if (b == null || b.Length == 0) continue;
|
||||||
|
var reader = new PdfReader(b);
|
||||||
|
for (int i = 1; i <= reader.NumberOfPages; i++)
|
||||||
|
copy.AddPage(copy.GetImportedPage(reader, i));
|
||||||
|
copy.FreeReader(reader);
|
||||||
|
reader.Close();
|
||||||
|
}
|
||||||
|
doc.Close();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>签名 base64 → PNG 落盘(审批单 HTML 内嵌本地路径图片用)</summary>
|
||||||
|
public static void ConvertBase64ToPng(string base64String, string outputFilePath)
|
||||||
|
{
|
||||||
|
var bytes = Convert.FromBase64String(base64String);
|
||||||
|
File.WriteAllBytes(outputFilePath, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,16 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 业务异常 DException / ErrorCode / IErrorCode:从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
<!-- 业务异常 DException / ErrorCode / IErrorCode:从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
||||||
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.2" />
|
||||||
<!-- 教务(SchClass)开放接口 SDK:教师/学校查询(C1 reviewprocess 教师类接口):从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
<!-- 教务(SchClass)开放接口 SDK:教师/学校查询(C1 reviewprocess 教师类接口):从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
||||||
<PackageReference Include="Yh.SchClass.OpenApi" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.SchClass.OpenApi" Version="1.0.0-preview.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Excel 导入导出(NPOI .xls 读写 / EPPlus 多 Sheet 导出)与 PDF 合并:版本对齐旧 DocPrintCore/Bsch.Common -->
|
||||||
|
<PackageReference Include="NPOI" Version="2.6.2" />
|
||||||
|
<PackageReference Include="EPPlus" Version="4.5.3.3" />
|
||||||
|
<PackageReference Include="iTextSharp.LGPLv2.Core" Version="3.4.20" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ namespace YhCore.Tasks.Jobs
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var oldList = await repo.FindAsync(l => l.CreateTime < cutoff);
|
var oldList = await repo.FindTrackedAsync(l => l.CreateTime < cutoff);
|
||||||
if (oldList.Count == 0)
|
if (oldList.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("审计日志清理完成:无 30 天前的数据");
|
logger.LogInformation("审计日志清理完成:无 30 天前的数据");
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Auth
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Challenge:302 到 CAS 登录页,service = {AppBaseUrl}{当前 path}[?除 ticket 外 query],即原访问 URL,
|
||||||
|
/// 与 Java notice 的 service 形态一致(service=http://host/module/api/xxx);AppBaseUrl 含模块前缀
|
||||||
|
/// (如 /docprint)时拼接结果天然带模块名。显式回跳语境(前端 yschreferer/Referer 携带页面地址)时
|
||||||
|
/// 把回跳地址以 Java notice 同款分号矩阵段插入 path 与 query 之间:
|
||||||
|
/// {path};redirectToReferer=<hex(回跳地址)>[?{原 query}]——不再以 {SignInCallbackPath} 回调端点作
|
||||||
|
/// service(signin-cas 形态已弃用);hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils),
|
||||||
|
/// 分号段不参与 query 结构,含中文/特殊字符的页面地址也可无歧义承载。ASP.NET Core 对回调中分号段
|
||||||
|
/// 的处理:带 ticket 校验成功后由中间件短路(不落 MVC 路由),路径分号段仅用于解析回跳目标。</item>
|
||||||
|
/// <item>Authenticate:任意受保护路径带 ticket 的回调均按「path + 除 ticket 外的 query」重建 service
|
||||||
|
/// 调 /serviceValidate 校验(service 即登录时的原访问 URL,含分号段则一并重建,CAS 在末尾追加
|
||||||
|
/// ?ticket=/&ticket= 回跳)。成功后写入 Cookie 登录态并 302 到分号段 redirectToReferer(hex 解码,
|
||||||
|
/// 当前形态)/ query redirectToReferer(旧 query 形态兼容)/ returnUrl(更早中继形态兼容)/
|
||||||
|
/// 原 URL 去 ticket 版(无回跳目标);回跳目标 302 为纯解码地址,不追加任何参数。</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
|
||||||
|
{
|
||||||
|
/// <summary>旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。</summary>
|
||||||
|
private const string ReturnUrlParam = "returnUrl";
|
||||||
|
|
||||||
|
/// <summary>旧 query 形态(service=原访问 URL?redirectToReferer=<hex>,986b241 曾用)的回跳参数——
|
||||||
|
/// 已改分号矩阵段承载,此处仅兼容读取历史 URL/缓存。</summary>
|
||||||
|
private const string RedirectToRefererQueryParam = "redirectToReferer";
|
||||||
|
|
||||||
|
private readonly CasServiceTicketValidator _validator;
|
||||||
|
|
||||||
|
public CasAuthenticationHandler(
|
||||||
|
IOptionsMonitor<CasAuthenticationOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
ISystemClock clock,
|
||||||
|
CasServiceTicketValidator validator)
|
||||||
|
: base(options, logger, encoder, clock)
|
||||||
|
{
|
||||||
|
_validator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}],即原访问 URL(notice 形态)。
|
||||||
|
/// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &ticket=),校验时必须还原为登录时的
|
||||||
|
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
|
||||||
|
/// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后,
|
||||||
|
/// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=<hex> 段),与登录 service 逐字节一致;
|
||||||
|
/// {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),拼接结果天然带模块名,不受反向代理剥离前缀影响。
|
||||||
|
/// Challenge 侧构造新 service 用当前(已剥离的)路由路径,目标以分号段另行追加,见 HandleChallengeAsync。
|
||||||
|
/// </summary>
|
||||||
|
private string BuildServiceUrlFromOriginalPath()
|
||||||
|
{
|
||||||
|
return BuildServiceUrl(ResolveOriginalPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Challenge 侧 service 基准:当前(已剥离分号段的)路由路径 + 除 ticket 外 query。</summary>
|
||||||
|
private string BuildServiceUrlFromCurrentPath()
|
||||||
|
{
|
||||||
|
return BuildServiceUrl(Context.Request.Path.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildServiceUrl(string path)
|
||||||
|
{
|
||||||
|
var query = Context.Request.Query
|
||||||
|
.Where(kv => !string.Equals(kv.Key, "ticket", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString()))
|
||||||
|
.ToList();
|
||||||
|
var service = BuildCallbackRoot() + path;
|
||||||
|
return query.Count > 0 ? service + "?" + string.Join("&", query) : service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>原始完整路径(含 ;redirectToReferer= 分号段):优先 PathMatrixSegmentMiddleware 存入
|
||||||
|
/// Items 的值,未经过该中间件时回退当前请求路径。</summary>
|
||||||
|
private string ResolveOriginalPath()
|
||||||
|
{
|
||||||
|
if (Context.Items.TryGetValue(YhCasDefaults.OriginalPathItemKey, out var v)
|
||||||
|
&& v is string original && !string.IsNullOrEmpty(original))
|
||||||
|
{
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
return Context.Request.Path.Value ?? "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取回调 query 中的 returnUrl(旧中继形态兼容:service={回调端点}?returnUrl=… 的历史 URL/缓存;
|
||||||
|
/// 本地相对路径或同源完整 http(s) URL,非法返回 null)。</summary>
|
||||||
|
private string ResolveReturnUrl()
|
||||||
|
{
|
||||||
|
if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var url = value.ToString();
|
||||||
|
return CasHex.IsAllowedRedirectTarget(url, AllowedHost()) ? url : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的
|
||||||
|
/// hex 回跳地址)并解码;缺失或 hex 非法返回 null。合法性由调用方统一 IsAllowedReturnTarget 把关。</summary>
|
||||||
|
private string ResolveQueryRedirectToReferer()
|
||||||
|
{
|
||||||
|
if (!Context.Request.Query.TryGetValue(RedirectToRefererQueryParam, out var value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return CasHex.Decode(value.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary>
|
||||||
|
private string BuildCallbackRoot()
|
||||||
|
{
|
||||||
|
var root = Options.AppBaseUrl?.TrimEnd('/');
|
||||||
|
if (string.IsNullOrEmpty(root))
|
||||||
|
{
|
||||||
|
root = Context.Request.Scheme + "://" + Context.Request.Host.Value;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
var casBase = Options.CasServerUrlPrefix;
|
||||||
|
if (string.IsNullOrWhiteSpace(casBase))
|
||||||
|
{
|
||||||
|
Response.StatusCode = 500;
|
||||||
|
return Response.WriteAsync("未配置 CAS 服务端地址(yh:sso:cas-server-url-prefix)");
|
||||||
|
}
|
||||||
|
properties.Items.TryGetValue("returnUrl", out var returnUrl);
|
||||||
|
var target = returnUrl ?? properties.RedirectUri;
|
||||||
|
string service;
|
||||||
|
if (string.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
// 无显式回跳语境(浏览器/工具直访受保护接口,未带 yschreferer / Referer):
|
||||||
|
// service = 原访问 URL(AppBaseUrl 前缀 + 当前 path + 除 ticket 外 query),
|
||||||
|
// 与 Java notice 的 service 形态一致(service=http://host/module/api/xxx,无附加参数)。
|
||||||
|
// AppBaseUrl 带模块前缀(如 http://host/docprint)时,拼接结果天然含模块名,
|
||||||
|
// 不受反向代理剥离前缀影响;登录成功后 CAS 回跳该 URL + ticket,由本 handler 校验并
|
||||||
|
// 302 去掉 ticket 落回原地址。
|
||||||
|
service = BuildServiceUrlFromCurrentPath();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 显式回跳语境(前端 yschreferer 携带页面地址等):service 仍 = 原访问 URL(notice 形态,
|
||||||
|
// 不再以 {SignInCallbackPath} 回调端点作 service),把回跳地址以 Java notice 同款的分号矩阵段
|
||||||
|
// 形式插入 path 与 query 之间:{path};redirectToReferer=<hex>[?{原 query}]——CAS 会把它作为
|
||||||
|
// service 一部分在回调时原样带回,回调侧 PathMatrixSegmentMiddleware 剥离分号段后由
|
||||||
|
// ResolveRedirectToReferer 从 Items 的 hex 解码即得回跳目标(query 形态 ?redirectToReferer=<hex>
|
||||||
|
// 的旧 URL/缓存仍兼容读取)。hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils);
|
||||||
|
// 分号段内不参与 query 结构,含中文/特殊字符的页面地址也可无歧义承载。
|
||||||
|
service = BuildServiceUrlFromCurrentPath();
|
||||||
|
var q = service.IndexOf('?');
|
||||||
|
var segment = YhCasDefaults.RedirectToRefererMarker + CasHex.Encode(target);
|
||||||
|
service = q < 0
|
||||||
|
? service + segment
|
||||||
|
: service.Substring(0, q) + segment + service.Substring(q);
|
||||||
|
}
|
||||||
|
// Uri.EscapeDataString 会把 ; / : = 全部编码(%3B/%2F/%3A/%3D),与平台旧版系统生成的
|
||||||
|
// CAS 登录 URL 逐字节一致(UrlEncoder.Encode 会保留 ; 字面量)
|
||||||
|
var loginUrl = (casBase.EndsWith('/') ? casBase : casBase + "/") + "login?service=" + Uri.EscapeDataString(service);
|
||||||
|
Response.Redirect(loginUrl);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
// 1. 已有 Cookie 登录态直接放行。
|
||||||
|
// 例外:已登录状态下又收到带 ticket 的 CAS 回调(刷新/复用回调地址、自动登录型 CAS 对同一
|
||||||
|
// service 重复签发)——票据对该会话已无意义(且常已被消费),不重走验票;若带回跳分号段则
|
||||||
|
// 直接 302 到解码地址,避免落入 MVC 路由 404。Context.User 手动同步:
|
||||||
|
// NoResult 返回时 AuthenticationMiddleware 不会赋值,SsoProtectionMiddleware 需据此识别已认证。
|
||||||
|
var cookieResult = await Context.AuthenticateAsync(YhCasDefaults.CookieScheme);
|
||||||
|
if (cookieResult.Succeeded && cookieResult.Principal != null)
|
||||||
|
{
|
||||||
|
var revisitTicket = Context.Request.Query["ticket"].ToString();
|
||||||
|
var revisitReferer = ResolveRedirectToReferer();
|
||||||
|
if (!string.IsNullOrEmpty(revisitTicket) && CasHex.IsAllowedRedirectTarget(revisitReferer, AllowedHost()))
|
||||||
|
{
|
||||||
|
Context.User = cookieResult.Principal;
|
||||||
|
Response.Redirect(StripTicketParams(revisitReferer));
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
return AuthenticateResult.Success(cookieResult.Ticket!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CAS 回调:校验 ticket
|
||||||
|
var ticketValue = Context.Request.Query["ticket"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(ticketValue))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// service 必须与登录时完全一致:CAS 是在登录 service 后追加 ?ticket=(已有 query 时 &ticket=)回调。
|
||||||
|
// 分号段已被 PathMatrixSegmentMiddleware 从 Request.Path 剥离,须以 Items 保存的原始路径重建。
|
||||||
|
var service = BuildServiceUrlFromOriginalPath();
|
||||||
|
string loginName;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loginName = await _validator.ValidateAsync(ticketValue, service, Context.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (Yh.Platform.Auth.AuthException e)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail(e);
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(loginName))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("CAS ticket 校验失败或已失效");
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.Name, loginName),
|
||||||
|
new(ClaimTypes.NameIdentifier, loginName),
|
||||||
|
new("loginName", loginName),
|
||||||
|
};
|
||||||
|
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
// 写入 Cookie 登录态(会话级:IsPersistent=false,关闭浏览器即失效;票据过期由 SessionStore + ExpireTimeSpan 控制)
|
||||||
|
var props = new AuthenticationProperties { IsPersistent = false };
|
||||||
|
await Context.SignInAsync(YhCasDefaults.CookieScheme, principal, props);
|
||||||
|
// 同步当前请求上下文身份:SignInAsync 只写 Cookie 不改 context.User,
|
||||||
|
// 后续中间件(SsoProtectionMiddleware)需据此识别“刚登录成功”,避免被误判未认证再次 Challenge
|
||||||
|
Context.User = principal;
|
||||||
|
|
||||||
|
// 回跳原地址:
|
||||||
|
// 1. 路径分号段 ;redirectToReferer=<hex>(当前形态:service=原 URL;redirectToReferer=<hex>,
|
||||||
|
// CAS 原样回带)→ 解码跳该目标;
|
||||||
|
// 2. query redirectToReferer=<hex>(ff31293 前的 query 形态 URL/缓存,兼容);
|
||||||
|
// 3. query returnUrl(更早中继形态 service={回调端点}?returnUrl=…,兼容);
|
||||||
|
// 4. 都没有(service=纯净原访问 URL 的 notice 形态:CAS 在 service 后追加 ?ticket= 回跳,
|
||||||
|
// 本请求 Path + 除 ticket 外 query 即登录时原 URL)→ 302 去掉一次性 ticket 落回原地址。
|
||||||
|
// 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。
|
||||||
|
// 回跳目标 302 即为纯解码地址(不追加 ticket 等任何参数)——票据已在本次 /serviceValidate
|
||||||
|
// 消费(一次性),登录态由 Cookie 承载,前端页自行调用户接口感知登录即可。
|
||||||
|
var referer = ResolveRedirectToReferer()
|
||||||
|
?? ResolveQueryRedirectToReferer()
|
||||||
|
?? ResolveReturnUrl();
|
||||||
|
if (referer != null)
|
||||||
|
{
|
||||||
|
// 有显式回跳目标:302 到纯解码地址(不追加任何参数),并剥离目标上历史残留的
|
||||||
|
// ticket 参数(SPA 的 yschreferer 携带 window.location.href,曾在死循环中累积
|
||||||
|
// 多个 ticket 进 fragment;回跳时清掉,避免污染的地址在页面间继续传播)。
|
||||||
|
var target = CasHex.IsAllowedRedirectTarget(referer, AllowedHost()) ? StripTicketParams(referer) : "/";
|
||||||
|
Response.Redirect(target);
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无显式回跳目标(无 referer 挑战:service=原访问 URL,CAS 原样带 ticket 回调):
|
||||||
|
// - 根路径回调(如 /?ticket=ST-xxx):Cookie 已写入、Context.User 已同步,直接返回放行管道,
|
||||||
|
// 由根端点当场渲染登录态页面(200,零重定向)——即"验票登录且不再重定向";
|
||||||
|
// 刷新/重放该地址时走 Cookie 恢复(票据不再参与),不会重复验票失败 401,杜绝重定向循环。
|
||||||
|
// - 其余路径回调:仍 302 去掉 ticket 的干净原地址(清理地址栏,防刷新重复验票)。
|
||||||
|
var cleanUrl = BuildServiceUrlFromOriginalPath();
|
||||||
|
if (IsRootPath(ResolveOriginalPath()) && CasHex.IsAllowedRedirectTarget(cleanUrl, AllowedHost()))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
Response.Redirect(CasHex.IsAllowedRedirectTarget(cleanUrl, AllowedHost()) ? cleanUrl : "/");
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>是否根路径(无 referer 挑战的 service=根地址形态,回调形如 /?ticket=ST-xxx)。</summary>
|
||||||
|
private static bool IsRootPath(string path)
|
||||||
|
=> string.IsNullOrEmpty(path) || path == "/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 剥离回跳目标 URL 上的 ticket 参数(query 与 #fragment 内均处理)。
|
||||||
|
/// SPA 地址的死循环残留形如 #/?ticket=ST-x&ticket=ST-y;按 (?|&)ticket=[^&#]* 正则全局删除,
|
||||||
|
/// 再清理删除后悬空的连接符,保证 URL 结构完好。
|
||||||
|
/// </summary>
|
||||||
|
private static string StripTicketParams(string url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(url) || url.IndexOf("ticket=", StringComparison.OrdinalIgnoreCase) < 0)
|
||||||
|
{
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
var stripped = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
url, "[?&]ticket=[^&#]*", string.Empty, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
stripped = stripped.Replace("?&", "?").Replace("&&", "&");
|
||||||
|
if (stripped.EndsWith('&') || stripped.EndsWith('?'))
|
||||||
|
{
|
||||||
|
stripped = stripped.Remove(stripped.Length - 1);
|
||||||
|
}
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析 ;redirectToReferer= 分号段的回跳目标:优先 PathMatrixSegmentMiddleware 存入 Items 的
|
||||||
|
/// hex(分号段已从路由路径剥离,回调重建 service 仍用原始路径);未经过该中间件时回退解析当前
|
||||||
|
/// 路径。无该段或 hex 非法返回 null。
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveRedirectToReferer()
|
||||||
|
{
|
||||||
|
if (Context.Items.TryGetValue(YhCasDefaults.RefererHexItemKey, out var v)
|
||||||
|
&& v is string hex && hex.Length > 0)
|
||||||
|
{
|
||||||
|
return CasHex.Decode(hex);
|
||||||
|
}
|
||||||
|
return ParseRedirectToReferer(Context.Request.Path.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从回调路径中解析 ;redirectToReferer= 后的 hex 并解码;无该段或编码非法返回 null。</summary>
|
||||||
|
private static string ParseRedirectToReferer(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var marker = path.IndexOf(YhCasDefaults.RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (marker < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var hex = path.Substring(marker + YhCasDefaults.RedirectToRefererMarker.Length);
|
||||||
|
var term = hex.IndexOfAny(new[] { ';', '?', '#' });
|
||||||
|
if (term >= 0)
|
||||||
|
{
|
||||||
|
hex = hex.Substring(0, term);
|
||||||
|
}
|
||||||
|
return CasHex.Decode(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。</summary>
|
||||||
|
private string AllowedHost()
|
||||||
|
{
|
||||||
|
var root = Options.AppBaseUrl;
|
||||||
|
if (string.IsNullOrEmpty(root))
|
||||||
|
{
|
||||||
|
root = Context.Request.Scheme + "://" + Context.Request.Host.Value;
|
||||||
|
}
|
||||||
|
return Uri.TryCreate(root, UriKind.Absolute, out var uri) ? uri.Host : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Auth
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// redirectToReferer 的十六进制编解码与回跳目标白名单(收敛 handler / PathMatrixSegmentMiddleware /
|
||||||
|
/// SsoProtectionMiddleware 三处同口径逻辑的公共实现)。
|
||||||
|
/// 编解码对齐 Java 平台 HexUtils 与前端 strToHexCharCode:按 char(Unicode 码点)维度,
|
||||||
|
/// 每字符至少两位小写十六进制(ASCII 范围内与 UTF-8 字节编码结果相同)。
|
||||||
|
/// </summary>
|
||||||
|
public static class CasHex
|
||||||
|
{
|
||||||
|
/// <summary>字符串 → 十六进制(char 维度、小写)。</summary>
|
||||||
|
public static string Encode(string value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var sb = new StringBuilder(value.Length * 2);
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
sb.Append(((int)c).ToString("x2", CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>十六进制 → 字符串(每两位还原一个 char;奇数长度或含非十六进制字符返回 null)。
|
||||||
|
/// 逐字符校验 Uri.IsHexDigit,比 int.TryParse(NumberStyles.HexNumber) 严格——后者会放行
|
||||||
|
/// 前导空白/符号,与 Java Integer.parseInt(hex, 16) 的非法输入语义不一致。</summary>
|
||||||
|
public static string Decode(string hex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var sb = new StringBuilder(hex.Length / 2);
|
||||||
|
for (var i = 0; i < hex.Length; i += 2)
|
||||||
|
{
|
||||||
|
if (!Uri.IsHexDigit(hex[i]) || !Uri.IsHexDigit(hex[i + 1]))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sb.Append((char)Convert.ToInt32(hex.Substring(i, 2), 16));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 回跳目标是否允许(统一白名单口径):
|
||||||
|
/// - 拒绝含控制字符(0x00-0x1F/0x7F)与空白字符(对齐 Java isValidHttpUrl 的 ^https?://[^\s]*$,
|
||||||
|
/// 防 \n 等注入 302 Location;平台 char 维度 hex 对非 ASCII 码点有损,可能还原出控制字符);
|
||||||
|
/// - 本地相对路径(/xxx):拒绝协议相对地址(//host)与反斜杠变体(/\host);
|
||||||
|
/// - 完整 http(s) URL:host 须与 allowedHost 一致或为本机 loopback(localhost/127.0.0.1/::1,
|
||||||
|
/// 支持"本地前端页调线上 API"的联调登录链);allowedHost 为空(未配置 AppBaseUrl)则不做 host 限制。
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAllowedRedirectTarget(string url, string allowedHost)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(url))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (var c in url)
|
||||||
|
{
|
||||||
|
if (c < 0x20 || c == 0x7f || char.IsWhiteSpace(c))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url[0] == '/')
|
||||||
|
{
|
||||||
|
return !url.StartsWith("//", StringComparison.Ordinal)
|
||||||
|
&& !url.StartsWith("/\\", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return string.IsNullOrEmpty(allowedHost)
|
||||||
|
|| string.Equals(uri.Host, allowedHost, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| uri.IsLoopback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从 AppBaseUrl(如 http://host/docprint)提取 host 作为回跳白名单基准;非法返回 null。</summary>
|
||||||
|
public static string HostOf(string appBaseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(appBaseUrl) || !Uri.TryCreate(appBaseUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return uri.Host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Yh.Platform.Auth;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Auth
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 轻量 CAS service ticket 校验器(对齐 Java CasServiceTicketValidator,不依赖 CAS 客户端库)。
|
||||||
|
/// 调用 CAS 的 /serviceValidate 端点,从响应 XML 中提取 <cas:user>。
|
||||||
|
/// </summary>
|
||||||
|
public class CasServiceTicketValidator
|
||||||
|
{
|
||||||
|
private readonly string _casServerUrl;
|
||||||
|
private readonly int _timeoutMs;
|
||||||
|
|
||||||
|
public CasServiceTicketValidator(string casServerUrl, int timeoutMs = 5000)
|
||||||
|
{
|
||||||
|
_casServerUrl = casServerUrl;
|
||||||
|
_timeoutMs = timeoutMs > 0 ? timeoutMs : 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>校验 ticket,成功返回登录账号,失败/无效返回 null。</summary>
|
||||||
|
public async Task<string> ValidateAsync(string ticket, string service, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_casServerUrl))
|
||||||
|
{
|
||||||
|
throw new AuthException("未配置 CasServerUrlPrefix(yh:sso:cas-server-url-prefix),无法进行 ticket 校验");
|
||||||
|
}
|
||||||
|
var base_ = _casServerUrl.EndsWith('/') ? _casServerUrl : _casServerUrl + "/";
|
||||||
|
var endpoint = base_ + "serviceValidate"
|
||||||
|
+ "?ticket=" + Uri.EscapeDataString(ticket ?? "")
|
||||||
|
+ "&service=" + Uri.EscapeDataString(service ?? "");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromMilliseconds(_timeoutMs) };
|
||||||
|
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(_timeoutMs) };
|
||||||
|
using var resp = await http.GetAsync(endpoint, ct).ConfigureAwait(false);
|
||||||
|
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode || body == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ExtractUser(body);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
throw new AuthException("CAS serviceValidate 请求失败: " + e.Message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从 CAS 响应 XML 提取用户名。
|
||||||
|
/// 成功响应形如 <cas:serviceResponse><cas:authenticationSuccess><cas:user>NAME</cas:user>...
|
||||||
|
/// </summary>
|
||||||
|
public static string ExtractUser(string xml)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(xml))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var start = xml.IndexOf("<cas:user>", StringComparison.Ordinal);
|
||||||
|
if (start < 0)
|
||||||
|
{
|
||||||
|
start = xml.IndexOf("<cas:user ", StringComparison.Ordinal); // 容错带属性
|
||||||
|
}
|
||||||
|
if (start < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var tagEnd = xml.IndexOf('>', start);
|
||||||
|
var end = xml.IndexOf("</cas:user>", tagEnd, StringComparison.Ordinal);
|
||||||
|
if (tagEnd < 0 || end < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return xml.Substring(tagEnd + 1, end - tagEnd - 1).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Auth
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 会话票据数据格式(自定义 ISecureDataFormat<AuthenticationTicket>):
|
||||||
|
/// 登录票据(AuthenticationTicket)保存在服务端内存,Cookie 只写 32 位 key(Guid N 格式),
|
||||||
|
/// 实现「Cookie 短值 + 服务端可控过期」。
|
||||||
|
/// 语义:
|
||||||
|
/// - 关闭浏览器失效:AuthenticationProperties.IsPersistent=false(会话 Cookie,无持久化头);
|
||||||
|
/// - 固定过期:服务端票据按 Properties.ExpiresUtc(由 ExpireTimeSpan 决定)过期,Unprotect 过期返回 null;
|
||||||
|
/// - 显式退出立即失效:Revoke 删除指定 key(由 Cookie 注册的 OnSigningOut 事件触发);
|
||||||
|
/// - 安全:key 为 128 位随机熵(Guid),不可枚举猜测;查表失败/过期即无效。
|
||||||
|
/// </summary>
|
||||||
|
public class SessionTicketDataFormat : ISecureDataFormat<AuthenticationTicket>
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, AuthenticationTicket> _tickets = new(StringComparer.Ordinal);
|
||||||
|
private long _lastCleanupUtc;
|
||||||
|
|
||||||
|
public string Protect(AuthenticationTicket data) => Protect(data, null);
|
||||||
|
|
||||||
|
public string Protect(AuthenticationTicket data, string purpose)
|
||||||
|
{
|
||||||
|
var key = Guid.NewGuid().ToString("N"); // 32 个十六进制字符
|
||||||
|
_tickets[key] = data;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationTicket Unprotect(string protectedText) => Unprotect(protectedText, null);
|
||||||
|
|
||||||
|
public AuthenticationTicket Unprotect(string protectedText, string purpose)
|
||||||
|
{
|
||||||
|
if (protectedText is null || !_tickets.TryGetValue(protectedText, out var ticket))
|
||||||
|
{
|
||||||
|
CleanupExpiredIfDue();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端票据过期:视为无效并清理(配合 SlidingExpiration=false 实现固定过期)
|
||||||
|
if (ticket.Properties.ExpiresUtc.HasValue && ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
_tickets.TryRemove(protectedText, out _);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 吊销指定票据:显式退出(SignOut,如 /api/Logout)时服务端票据立即失效,无需等待过期兜底。
|
||||||
|
/// </summary>
|
||||||
|
public void Revoke(string key)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
_tickets.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
CleanupExpiredIfDue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>分钟级摊还清理过期票据,防内存膨胀(Interlocked 门闩,最多每分钟一次全量扫描)。</summary>
|
||||||
|
private void CleanupExpiredIfDue()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
var last = Volatile.Read(ref _lastCleanupUtc);
|
||||||
|
if (now - last < 60)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Interlocked.CompareExchange(ref _lastCleanupUtc, now, last) != last)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (var kvp in _tickets)
|
||||||
|
{
|
||||||
|
if (kvp.Value.Properties.ExpiresUtc is { } exp && exp < DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
_tickets.TryRemove(kvp.Key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Auth
|
||||||
|
{
|
||||||
|
/// <summary>本地化 CAS 认证方案名常量(值与 Yh.Platform.Auth.YhAuthDefaults 保持一致,管线行为不变)。</summary>
|
||||||
|
public static class YhCasDefaults
|
||||||
|
{
|
||||||
|
/// <summary>Cookie 认证方案(登录态载体)</summary>
|
||||||
|
public const string CookieScheme = "YhCookie";
|
||||||
|
|
||||||
|
/// <summary>CAS 认证方案(challenge 时重定向 CAS 登录页)</summary>
|
||||||
|
public const string CasScheme = "YhCas";
|
||||||
|
|
||||||
|
/// <summary>;redirectToReferer= 段标记(service 路径与 query 之间携带 hex 回跳地址,对齐 Java notice)</summary>
|
||||||
|
public const string RedirectToRefererMarker = ";redirectToReferer=";
|
||||||
|
|
||||||
|
/// <summary>HttpContext.Items 键:从路径分号段提取的回跳地址 hex(PathMatrixSegmentMiddleware 写入,handler/保护中间件消费)</summary>
|
||||||
|
public const string RefererHexItemKey = "CasRedirectToRefererHex";
|
||||||
|
|
||||||
|
/// <summary>HttpContext.Items 键:剥离分号段前的原始完整路径(serviceValidate 重建 service 必须与登录时逐字节一致)</summary>
|
||||||
|
public const string OriginalPathItemKey = "CasOriginalPath";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CAS 认证配置(service 指向本应用回调接口 /api/base/GetUserInfo,回跳地址以 query 参数
|
||||||
|
/// returnUrl 携带;兼容平台旧版 ;redirectToReferer=<hex> 形态的已发出链接)。
|
||||||
|
/// </summary>
|
||||||
|
public class CasAuthenticationOptions : AuthenticationSchemeOptions
|
||||||
|
{
|
||||||
|
/// <summary>CAS 服务端地址前缀,如 http://sso.yunhaixingtong.com(无 /cas 上下文,拼接规则为前缀 + /login)</summary>
|
||||||
|
public string CasServerUrlPrefix { get; set; }
|
||||||
|
|
||||||
|
/// <summary>本应用根地址(用于拼 service 回调地址),为空时从当前请求推断</summary>
|
||||||
|
public string AppBaseUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ticket 回调路径(service = AppBaseUrl + 本路径[?returnUrl=<urlencoded>])</summary>
|
||||||
|
public string SignInCallbackPath { get; set; } = "/api/base/GetUserInfo";
|
||||||
|
|
||||||
|
/// <summary>ticket 校验超时(毫秒)</summary>
|
||||||
|
public int TicketValidateTimeoutMs { get; set; } = 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>通用鉴权配置(配置节 yh:auth)。</summary>
|
||||||
|
public class YhAuthOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "yh:auth";
|
||||||
|
|
||||||
|
/// <summary>是否启用(默认 true)</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>登录态有效期(分钟),固定过期,不滑动续期</summary>
|
||||||
|
public int CookieExpireMinutes { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>Cookie 名</summary>
|
||||||
|
public string CookieName { get; set; } = ".yh.auth";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Auth
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 本地化 CAS 认证装配(对齐 Yh.Platform.Auth 的 AddYhAuth 认证部分;service 构造为
|
||||||
|
/// {root}{SignInCallbackPath}[?returnUrl=<urlencoded>]——回跳走 query 而非平台旧版
|
||||||
|
/// ;redirectToReferer=<hex> 矩阵参数(ASP.NET Core 不支持矩阵参数,见 CasAuthenticationHandler)。
|
||||||
|
/// 不含 AddYhOpenApi,OpenApi/AppAuth 由宿主另行注册)。
|
||||||
|
/// 配置节:<c>yh:auth</c>(通用)与 <c>yh:sso</c>(CAS 服务端)。
|
||||||
|
/// </summary>
|
||||||
|
public static class YhCasAuthServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddYhCasAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// 会话票据表单例:Cookie TicketDataFormat 与 OnSigningOut 退出吊销必须共享同一实例(同一票据表)
|
||||||
|
var sessionTicketFormat = new SessionTicketDataFormat();
|
||||||
|
services.AddSingleton(sessionTicketFormat);
|
||||||
|
|
||||||
|
services.AddOptions<YhAuthOptions>().Bind(configuration.GetSection(YhAuthOptions.SectionName));
|
||||||
|
// 注:AddScheme 的 configure 才是 handler 实际读取的命名 options(AuthenticationHandler<TOptions>.Options);
|
||||||
|
// 必须在此绑定 yh:sso,否则 handler 拿到的 CasAuthenticationOptions 为空(Challenge 时 CasServerUrlPrefix 缺失)。
|
||||||
|
services.AddOptions<CasAuthenticationOptions>().Bind(configuration.GetSection("yh:sso"));
|
||||||
|
|
||||||
|
services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
// DefaultScheme 必须为 CasScheme(而非 CookieScheme):
|
||||||
|
// UseAuthentication 每请求调用 AuthenticateAsync(默认 scheme),若默认是 CookieScheme,
|
||||||
|
// CAS 票据校验(CasAuthenticationHandler.HandleAuthenticateAsync 的 ticket 分支)永远没有触发点——
|
||||||
|
// 回调带 ticket 时认证不校验、SsoProtectionMiddleware 见未认证直接 401,CAS 登录永远失败。
|
||||||
|
// CasAuthenticationHandler.HandleAuthenticateAsync 第一步就是 Context.AuthenticateAsync(CookieScheme)
|
||||||
|
// 恢复 Cookie 登录态,因此 CasScheme 作为默认认证 scheme 语义等价"先 Cookie 恢复、后 ticket 校验"。
|
||||||
|
options.DefaultScheme = YhCasDefaults.CasScheme;
|
||||||
|
options.DefaultChallengeScheme = YhCasDefaults.CasScheme;
|
||||||
|
})
|
||||||
|
.AddCookie(YhCasDefaults.CookieScheme, options =>
|
||||||
|
{
|
||||||
|
var auth = configuration.GetSection(YhAuthOptions.SectionName).Get<YhAuthOptions>() ?? new YhAuthOptions();
|
||||||
|
options.Cookie.Name = auth.CookieName;
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
// SameSite=Lax:登录/回调全部是顶层导航,Lax 足够。
|
||||||
|
// 【勿改 None】HTTP 部署下无 Secure 属性的 SameSite=None Cookie 会被浏览器直接丢弃
|
||||||
|
// (Chrome/Edge 强制 None 必须 Secure),导致登录 Cookie 永远种不上 → 无限重定向循环
|
||||||
|
// (2026-09-07 线上复现:验票成功但下一请求无 Cookie → ERR_TOO_MANY_REDIRECTS)。
|
||||||
|
// 跨 host 登录态需求由 DebugUser 头方案解决(见 docs/本地前端联调线上后端SSO方案.md)。
|
||||||
|
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
|
||||||
|
// 会话票据:TicketDataFormat 把票据存服务端,Cookie 只写 32 位 key;
|
||||||
|
// IsPersistent=false(CasAuthenticationHandler)会话 Cookie,关闭浏览器即失效;
|
||||||
|
// SlidingExpiration=false 固定过期,不滑动续期。
|
||||||
|
options.TicketDataFormat = sessionTicketFormat;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromMinutes(auth.CookieExpireMinutes > 0 ? auth.CookieExpireMinutes : 1);
|
||||||
|
options.SlidingExpiration = false;
|
||||||
|
options.LoginPath = "/api/base/GetUserInfo";
|
||||||
|
// 仅 Cookie 自身 Challenge(未登录访问需授权页 → 跳 LoginPath)时使用;实际登录走 CasScheme,
|
||||||
|
// 参数名须与 CasAuthenticationHandler.ReturnUrlParam("returnUrl") 一致,避免双参数名并存误导。
|
||||||
|
options.ReturnUrlParameter = "returnUrl";
|
||||||
|
// 退出即吊销:SignOutAsync(如业务 GET /api/Logout)清除 Cookie 时,
|
||||||
|
// 从请求 Cookie 取 32 位 key 立即删除服务端票据——否则票据残留到 ExpireTimeSpan 过期才失效。
|
||||||
|
options.Events = new CookieAuthenticationEvents
|
||||||
|
{
|
||||||
|
OnSigningOut = context =>
|
||||||
|
{
|
||||||
|
if (context.HttpContext.Request.Cookies.TryGetValue(auth.CookieName, out var rawKey))
|
||||||
|
{
|
||||||
|
sessionTicketFormat.Revoke(rawKey);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddScheme<CasAuthenticationOptions, CasAuthenticationHandler>(YhCasDefaults.CasScheme,
|
||||||
|
o => configuration.GetSection("yh:sso").Bind(o));
|
||||||
|
|
||||||
|
services.AddSingleton<CasServiceTicketValidator>(sp =>
|
||||||
|
{
|
||||||
|
var opts = sp.GetRequiredService<IOptionsMonitor<CasAuthenticationOptions>>().CurrentValue;
|
||||||
|
return new CasServiceTicketValidator(opts.CasServerUrlPrefix, opts.TicketValidateTimeoutMs);
|
||||||
|
});
|
||||||
|
// 用户明细 SPI 默认实现(调 UserBase /oapi/user/byLoginName),UserContextMiddleware 依赖
|
||||||
|
services.AddScoped<Yh.Platform.Auth.IUserDetailsProvider, Yh.Platform.Auth.OpenApiUserDetailsProvider>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
using YhCore.Common.UserContext;
|
using YhCore.Common.UserContext;
|
||||||
@@ -12,6 +14,7 @@ namespace YhCore.WebApi.Controllers.Api
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/log")]
|
[Route(ApiRoutes.ApiPath + "/log")]
|
||||||
|
[EnableCors("default")]
|
||||||
public class LogController : ControllerBase
|
public class LogController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogService _logService;
|
private readonly ILogService _logService;
|
||||||
@@ -22,30 +25,30 @@ namespace YhCore.WebApi.Controllers.Api
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 系统操作日志搜索(对应 Java GET /api/log/systemSearch)。
|
/// 系统操作日志搜索(对应 Java GET /api/log/systemSearch,全量日志)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="keyword">关键字(可空)</param>
|
/// <param name="keyword">关键字(可空)</param>
|
||||||
/// <param name="page">页码,从 0 开始(对应 Spring Pageable.page)</param>
|
/// <param name="page">页码,从 0 开始(对应 Spring Pageable.page)</param>
|
||||||
/// <param name="size">每页大小(对应 Spring Pageable.size)</param>
|
/// <param name="size">每页大小(对应 Spring Pageable.size)</param>
|
||||||
[HttpGet("systemSearch")]
|
[HttpGet("systemSearch")]
|
||||||
public PageVO<SysOperationLog> SystemSearch(
|
public Task<PageVO<SysOperationLog>> SystemSearch(
|
||||||
[FromQuery] string keyword,
|
[FromQuery] string keyword,
|
||||||
[FromQuery] int page = 0,
|
[FromQuery] int page = 0,
|
||||||
[FromQuery] int size = 20)
|
[FromQuery] int size = 20)
|
||||||
{
|
{
|
||||||
return _logService.Search(UserContextHolder.GetSchId(), keyword, true, page, size);
|
return _logService.SearchAsync(UserContextHolder.GetSchId(), keyword, true, page, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 操作日志搜索(对应 Java GET /api/log/search)。
|
/// 操作日志搜索(对应 Java GET /api/log/search,排除查询类)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public PageVO<SysOperationLog> Search(
|
public Task<PageVO<SysOperationLog>> Search(
|
||||||
[FromQuery] string keyword,
|
[FromQuery] string keyword,
|
||||||
[FromQuery] int page = 0,
|
[FromQuery] int page = 0,
|
||||||
[FromQuery] int size = 20)
|
[FromQuery] int size = 20)
|
||||||
{
|
{
|
||||||
return _logService.Search(UserContextHolder.GetSchId(), keyword, false, page, size);
|
return _logService.SearchAsync(UserContextHolder.GetSchId(), keyword, false, page, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Yh.SchClass.OpenApi;
|
using Yh.SchClass.OpenApi;
|
||||||
using Yh.SchClass.OpenApi.Model;
|
using Yh.SchClass.OpenApi.Model;
|
||||||
@@ -15,6 +16,7 @@ namespace YhCore.WebApi.Controllers.Api
|
|||||||
/// 对应 Java 教务 bschclassmanage 的 /oapi 端点(配置节 yh:schclass:open)。
|
/// 对应 Java 教务 bschclassmanage 的 /oapi 端点(配置节 yh:schclass:open)。
|
||||||
/// <para>SDK 门面 <see cref="SchClassOpenApi"/> 聚合 10 个客户端,本控制器演示其中 5 个。</para>
|
/// <para>SDK 门面 <see cref="SchClassOpenApi"/> 聚合 10 个客户端,本控制器演示其中 5 个。</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/com/schClass")]
|
[Route(ApiRoutes.ApiPath + "/com/schClass")]
|
||||||
public class SchClassController : ControllerBase
|
public class SchClassController : ControllerBase
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Yh.Platform.Abstractions;
|
using Yh.Platform.Abstractions;
|
||||||
@@ -15,6 +17,7 @@ namespace YhCore.WebApi.Controllers.Api
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户基础接口(对应 Java 版 controller/api/UserController,路由 /api/base/)。
|
/// 用户基础接口(对应 Java 版 controller/api/UserController,路由 /api/base/)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/base")]
|
[Route(ApiRoutes.ApiPath + "/base")]
|
||||||
public class UserController : ControllerBase
|
public class UserController : ControllerBase
|
||||||
@@ -47,6 +50,20 @@ namespace YhCore.WebApi.Controllers.Api
|
|||||||
return BoaUserContext.Get();
|
return BoaUserContext.Get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取初始化地址(对齐老 UserCenterApiController.GetInitUrl:读 Session["AppInitUrl"])。
|
||||||
|
/// 前端 main.js 首次进入时调用:resp.Data 有值则 router.replace(initUrl),否则回 /docPrint/。
|
||||||
|
/// 响应形状为混排兼容体 {"code":0,"msg":"","State":true,"Data":...}:
|
||||||
|
/// code 小写保证前端 api/index.js 的 success 回调触发(response.code==0),
|
||||||
|
/// Data/State 大写保证 main.js 的 resp.Data 取到值——故用 ContentResult 手写 JSON
|
||||||
|
/// (ContentResult 不参与全局包装,也不经 camelCase ContractResolver)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("GetInitUrl")]
|
||||||
|
public string GetInitUrl()
|
||||||
|
{
|
||||||
|
return HttpContext.Session.GetString("AppInitUrl");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 登出(对应 Java GET /api/base/logout:session.invalidate())。
|
/// 登出(对应 Java GET /api/base/logout:session.invalidate())。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Yh.Platform.Abstractions;
|
using Yh.Platform.Abstractions;
|
||||||
|
using Yh.SchClass.OpenApi;
|
||||||
|
using Yh.SchClass.OpenApi.Model;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
using YhCore.Common.Config;
|
using YhCore.Common.Config;
|
||||||
using YhCore.Common.UserContext;
|
using YhCore.Common.UserContext;
|
||||||
@@ -20,19 +23,31 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
/// 字典部分 8 接口 + 文件部分 4 接口(FileDown / FilePreview / FileUpload / FileUploadImage)已落地;
|
/// 字典部分 8 接口 + 文件部分 4 接口(FileDown / FilePreview / FileUpload / FileUploadImage)已落地;
|
||||||
/// 旧 [NoAuth] 语义:无需用户上下文,schId 由前端传入或取当前用户。
|
/// 旧 [NoAuth] 语义:无需用户上下文,schId 由前端传入或取当前用户。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/base")]
|
[Route(ApiRoutes.ApiPath + "/base")]
|
||||||
public class CommonController : ControllerBase
|
public class CommonController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDictionaryService _dict;
|
private readonly IDictionaryService _dict;
|
||||||
private readonly IFileService _file;
|
private readonly IFileService _file;
|
||||||
|
private readonly SchClassOpenApi _sch;
|
||||||
|
|
||||||
public CommonController(IDictionaryService dict, IFileService file)
|
public CommonController(IDictionaryService dict, IFileService file, SchClassOpenApi sch)
|
||||||
{
|
{
|
||||||
_dict = dict;
|
_dict = dict;
|
||||||
_file = file;
|
_file = file;
|
||||||
|
_sch = sch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("GetSchoolAll")]
|
||||||
|
public async Task<List<TeaTreeVo>> GetSchoolAll([FromQuery] int type = 0)
|
||||||
|
{
|
||||||
|
return await _sch.SchTree.GetTeaTreeAsync(SchSerID(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>获取指定字典数据(Type 1~10)。</summary>
|
/// <summary>获取指定字典数据(Type 1~10)。</summary>
|
||||||
[HttpGet("GetSysDic")]
|
[HttpGet("GetSysDic")]
|
||||||
public async Task<List<DicOption>> GetSysDic([FromQuery] int type = 0)
|
public async Task<List<DicOption>> GetSysDic([FromQuery] int type = 0)
|
||||||
@@ -220,42 +235,58 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 公用附件上传(旧 FileUpload,[NoAuth])。
|
/// 公用附件上传(旧 FileUpload,[NoAuth])。
|
||||||
/// chunk 参数 → 分片保存返回 {code:1,type:"chunk",msg};fileName 参数 → 合并分片;否则单文件上传。
|
/// 分片(chunk/chunkIndex + totalChunks>1)→ 分片保存返回 {code:1,type:"chunk",msg};
|
||||||
|
/// 只带 fileName 不带文件 → 合并分片;带文件 → 单文件直传
|
||||||
|
/// (前端单文件直传也会带 guid/chunkIndex=0/totalChunks=0/fileName,不得误入合并分支)。
|
||||||
|
/// guid/chunk/fileName 兼容 query 与 form 两种形态(旧版读 Query)。
|
||||||
/// 落库 Common_UpFiles(TopicType=AppCode),返回 {fileurl,host,fileid,name,type}。
|
/// 落库 Common_UpFiles(TopicType=AppCode),返回 {fileurl,host,fileid,name,type}。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("FileUpload")]
|
[HttpPost("FileUpload")]
|
||||||
public async Task<object> Upload()
|
public async Task<object> Upload()
|
||||||
{
|
{
|
||||||
string apptype = "docprint"; // 旧 WebHelper.AppType 固定 docprint
|
string apptype = "docprint"; // 旧 WebHelper.AppType 固定 docprint
|
||||||
string guid = Request.Form["guid"];
|
string guid = FormOrQuery("guid");
|
||||||
string chunk = Request.Form["chunk"];
|
string chunk = FormOrQuery("chunk"); // 旧契约分片序号
|
||||||
string fileName = Request.Form["fileName"];
|
string chunkIndex = FormOrQuery("chunkIndex"); // 前端现用分片序号
|
||||||
|
string totalChunks = FormOrQuery("totalChunks");
|
||||||
|
string fileName = FormOrQuery("fileName");
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(chunk))
|
var file = Request.Form.Files.FirstOrDefault(f => f.Name == "file") ?? Request.Form.Files.FirstOrDefault();
|
||||||
|
bool multiChunk = int.TryParse(totalChunks, out var total) && total > 1;
|
||||||
|
bool isChunkReq = file != null
|
||||||
|
&& (!string.IsNullOrEmpty(chunk) || (!string.IsNullOrEmpty(chunkIndex) && multiChunk));
|
||||||
|
|
||||||
|
if (isChunkReq)
|
||||||
{
|
{
|
||||||
var chunkFile = Request.Form.Files.FirstOrDefault(f => f.Name == "file") ?? Request.Form.Files.FirstOrDefault();
|
await _file.SaveChunkAsync(guid, string.IsNullOrEmpty(chunk) ? chunkIndex : chunk, file);
|
||||||
await _file.SaveChunkAsync(guid, chunk, chunkFile);
|
|
||||||
return new { code = 1, type = "chunk", msg = "分片上传成功!" };
|
return new { code = 1, type = "chunk", msg = "分片上传成功!" };
|
||||||
}
|
}
|
||||||
|
|
||||||
FileObj url;
|
FileObj url;
|
||||||
string errMsg = "上传失败!";
|
if (!string.IsNullOrEmpty(fileName) && file == null)
|
||||||
if (string.IsNullOrEmpty(fileName))
|
|
||||||
{
|
{
|
||||||
url = await _file.SaveFileAsync(apptype, Request.Form.Files.FirstOrDefault());
|
url = await _file.MergeSaveAsync(apptype, guid, fileName); // 合并分片
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
url = await _file.MergeSaveAsync(apptype, guid, fileName);
|
url = await _file.SaveFileAsync(apptype, file); // 单文件直传
|
||||||
}
|
}
|
||||||
if (url == null)
|
if (url == null)
|
||||||
throw new DException(errMsg);
|
throw new DException("上传失败!");
|
||||||
|
|
||||||
// 旧 WebHelper.AppCode == "1102" ? "1002" : AppCode → docprint 固定 1003
|
// 旧 WebHelper.AppCode == "1102" ? "1002" : AppCode → docprint 固定 1003
|
||||||
int serid = await _file.SaveEnclosureAsync(url, DocPrintConstants.AppCode.ToString());
|
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 };
|
return new { fileurl = url.fileurl, host = url.host, fileid = serid, name = url.filename, type = url.filetype };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>取表单/URL 参数(旧版读 Request["x"] 同时覆盖 Query+Form)。</summary>
|
||||||
|
private string FormOrQuery(string name)
|
||||||
|
{
|
||||||
|
var v = Request.Form[name].ToString();
|
||||||
|
if (!string.IsNullOrEmpty(v)) return v;
|
||||||
|
return Request.Query[name].ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>公用图片上传(旧 FileUploadImage,[NoAuth];多图,返回数组)。</summary>
|
/// <summary>公用图片上传(旧 FileUploadImage,[NoAuth];多图,返回数组)。</summary>
|
||||||
[HttpPost("FileUploadImage")]
|
[HttpPost("FileUploadImage")]
|
||||||
public async Task<object> UploadImg()
|
public async Task<object> UploadImg()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
using YhCore.Common.UserContext;
|
using YhCore.Common.UserContext;
|
||||||
@@ -12,6 +13,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
/// 自由审批流控制器(对应旧 Areas/Common/FreeApproveController,路由 api/common/freeapprove)。
|
/// 自由审批流控制器(对应旧 Areas/Common/FreeApproveController,路由 api/common/freeapprove)。
|
||||||
/// 7 接口:自由流程节点查询/保存、审核意见、审核回显、抄送人。
|
/// 7 接口:自由流程节点查询/保存、审核意见、审核回显、抄送人。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/common/freeapprove")]
|
[Route(ApiRoutes.ApiPath + "/common/freeapprove")]
|
||||||
public class FreeApproveController : ControllerBase
|
public class FreeApproveController : ControllerBase
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Yh.SchClass.OpenApi.Model;
|
using Yh.SchClass.OpenApi.Model;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
@@ -15,6 +16,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
/// 审核流程管理控制器(对应旧 Areas/Common/reviewprocessController,路由 api/common/reviewprocess)。
|
/// 审核流程管理控制器(对应旧 Areas/Common/reviewprocessController,路由 api/common/reviewprocess)。
|
||||||
/// 23 接口:流程/步骤 CRUD、教师查询、流程判断、用户角色。
|
/// 23 接口:流程/步骤 CRUD、教师查询、流程判断、用户角色。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/common/reviewprocess")]
|
[Route(ApiRoutes.ApiPath + "/common/reviewprocess")]
|
||||||
public class ReviewprocessController : ControllerBase
|
public class ReviewprocessController : ControllerBase
|
||||||
@@ -47,7 +49,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
|
|
||||||
/// <summary>添加审核流程。</summary>
|
/// <summary>添加审核流程。</summary>
|
||||||
[HttpPost("Create")]
|
[HttpPost("Create")]
|
||||||
public async Task Create([FromBody] Common_flowinfo model)
|
public async Task Create([FromForm] Common_flowinfo model)
|
||||||
{
|
{
|
||||||
model.schSerID = SchSerID();
|
model.schSerID = SchSerID();
|
||||||
await _svc.CreateAsync(model);
|
await _svc.CreateAsync(model);
|
||||||
@@ -55,7 +57,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
|
|
||||||
/// <summary>修改审核流程。</summary>
|
/// <summary>修改审核流程。</summary>
|
||||||
[HttpPost("Edit")]
|
[HttpPost("Edit")]
|
||||||
public async Task Edit([FromBody] Common_flowinfo model)
|
public async Task Edit([FromForm] Common_flowinfo model)
|
||||||
{
|
{
|
||||||
model.schSerID = SchSerID();
|
model.schSerID = SchSerID();
|
||||||
await _svc.EditAsync(model);
|
await _svc.EditAsync(model);
|
||||||
@@ -63,12 +65,12 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
|
|
||||||
/// <summary>添加步骤流程。</summary>
|
/// <summary>添加步骤流程。</summary>
|
||||||
[HttpPost("StepCreate")]
|
[HttpPost("StepCreate")]
|
||||||
public Task StepCreate([FromBody] Common_flowstep model)
|
public Task StepCreate([FromForm] Common_flowstep model)
|
||||||
=> _svc.StepCreateAsync(model);
|
=> _svc.StepCreateAsync(model);
|
||||||
|
|
||||||
/// <summary>修改步骤流程。</summary>
|
/// <summary>修改步骤流程。</summary>
|
||||||
[HttpPost("StepEdit")]
|
[HttpPost("StepEdit")]
|
||||||
public Task StepEdit([FromBody] Common_flowstep model)
|
public Task StepEdit([FromForm] Common_flowstep model)
|
||||||
=> _svc.StepEditAsync(model);
|
=> _svc.StepEditAsync(model);
|
||||||
|
|
||||||
/// <summary>删除审核流程(ids 逗号分隔)。</summary>
|
/// <summary>删除审核流程(ids 逗号分隔)。</summary>
|
||||||
@@ -78,7 +80,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
|
|||||||
|
|
||||||
/// <summary>删除步骤。</summary>
|
/// <summary>删除步骤。</summary>
|
||||||
[HttpPost("StepDelete")]
|
[HttpPost("StepDelete")]
|
||||||
public Task StepDelete([FromBody] decimal[] ids)
|
public Task StepDelete([FromForm] decimal[] ids)
|
||||||
=> _svc.StepDeleteAsync(ids);
|
=> _svc.StepDeleteAsync(ids);
|
||||||
|
|
||||||
/// <summary>流程绑定的步骤列表。</summary>
|
/// <summary>流程绑定的步骤列表。</summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Yh.Platform.Abstractions;
|
using Yh.Platform.Abstractions;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
@@ -13,6 +14,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
/// 旧类级 [NoAuth]:schId 由前端传入(非当前用户),新项目 SSO 关闭时匿名放行、开启时按配置保护,
|
/// 旧类级 [NoAuth]:schId 由前端传入(非当前用户),新项目 SSO 关闭时匿名放行、开启时按配置保护,
|
||||||
/// 语义一致。ExportRecord(Excel 导出,NPOI)延后迁移。
|
/// 语义一致。ExportRecord(Excel 导出,NPOI)延后迁移。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/docprint/statistics")]
|
[Route(ApiRoutes.ApiPath + "/docprint/statistics")]
|
||||||
public class DocPrintStatisticsController : ControllerBase
|
public class DocPrintStatisticsController : ControllerBase
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
using YhCore.Entity;
|
using YhCore.Entity;
|
||||||
@@ -11,6 +12,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
/// 无业务逻辑:执行一次 Base_Log 查询触发数据库连接建立,防止首请求慢。
|
/// 无业务逻辑:执行一次 Base_Log 查询触发数据库连接建立,防止首请求慢。
|
||||||
/// 旧接口 void 无返回体;新项目由 GlobalWrapResultFilter 统一包装为 {code:0,msg:"",data:null}。
|
/// 旧接口 void 无返回体;新项目由 GlobalWrapResultFilter 统一包装为 {code:0,msg:"",data:null}。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/docprint/loading")]
|
[Route(ApiRoutes.ApiPath + "/docprint/loading")]
|
||||||
public class LoadingController : ControllerBase
|
public class LoadingController : ControllerBase
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using Yh.Platform.Abstractions;
|
using Yh.Platform.Abstractions;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
using YhCore.Common.UserContext;
|
using YhCore.Common.UserContext;
|
||||||
@@ -14,9 +17,12 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 审核角色 + 流程配置控制器(对应旧 Areas/DocPrint/WYSHRoleController,路由 api/docprint/wyshrole)。
|
/// 审核角色 + 流程配置控制器(对应旧 Areas/DocPrint/WYSHRoleController,路由 api/docprint/wyshrole)。
|
||||||
/// 45 action 中本批落地 39 个;Excel 导入导出(ExceltotalListSHRole/WYRDExceldaochu/UpfileExcelSHRole/
|
/// 45 action 全部落地(Excel 导入导出 5 个走 NPOI/EPPlus,审批单 PDF 走 wkhtmltopdf)。
|
||||||
/// downMBOfSHRole/downUpfileSHRoleFK)与审批单 PDF(DwonWYSQD)依赖 NPOI/PdfHelper,延后迁移。
|
/// POST 动作一律 [FromForm]:前端 api/index.js 固定 x-www-form-urlencoded 提交([FromForm] 会 415);
|
||||||
|
/// 老契约"表单字段塞 JSON"的动作(WYSHRole/SHTeaTxt/listXxxTxt/data/docdata/docnumdata)
|
||||||
|
/// 以 [FromForm] string 接收后手动反序列化。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/docprint/wyshrole")]
|
[Route(ApiRoutes.ApiPath + "/docprint/wyshrole")]
|
||||||
public class WYSHRoleController : ControllerBase
|
public class WYSHRoleController : ControllerBase
|
||||||
@@ -28,6 +34,14 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
_role = role;
|
_role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>解析老契约"表单字段塞 JSON"的值;空串安全,非法 JSON 返回 null 由调用方判参。</summary>
|
||||||
|
private static T ParseFormJson<T>(string txt) where T : class
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(txt)) return null;
|
||||||
|
try { return JsonConvert.DeserializeObject<T>(txt); }
|
||||||
|
catch (JsonException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
#region 审核角色管理
|
#region 审核角色管理
|
||||||
|
|
||||||
/// <summary>学校是否启用审核角色(旧 getIsSHRole)。</summary>
|
/// <summary>学校是否启用审核角色(旧 getIsSHRole)。</summary>
|
||||||
@@ -47,18 +61,20 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
public async Task<string> UpdateSHRoleName([FromQuery] decimal serID, [FromQuery] string sHRoleName)
|
public async Task<string> UpdateSHRoleName([FromQuery] decimal serID, [FromQuery] string sHRoleName)
|
||||||
=> await _role.UpdateSHRoleNameAsync(SchSerID(), UserId(), serID, sHRoleName);
|
=> await _role.UpdateSHRoleNameAsync(SchSerID(), UserId(), serID, sHRoleName);
|
||||||
|
|
||||||
/// <summary>新增/编辑角色(旧 EditSHRole,form: WYSHRole=JSON → [FromBody])。</summary>
|
/// <summary>新增/编辑角色(旧 EditSHRole,form: WYSHRole=JSON 字符串)。</summary>
|
||||||
[HttpPost("EditSHRole")]
|
[HttpPost("EditSHRole")]
|
||||||
public async Task<string> EditSHRole([FromBody] WYSHRoleExt model)
|
public async Task<string> EditSHRole([FromForm] string WYSHRole)
|
||||||
{
|
{
|
||||||
|
var model = ParseFormJson<WYSHRoleExt>(WYSHRole);
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
return await _role.EditSHRoleAsync(SchSerID(), UserId(), model);
|
return await _role.EditSHRoleAsync(SchSerID(), UserId(), model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>编辑成员行(旧 editSHTeaRow,form: SHTeaTxt → [FromBody])。</summary>
|
/// <summary>编辑成员行(旧 editSHTeaRow,form: SHTeaTxt=JSON 字符串)。</summary>
|
||||||
[HttpPost("editSHTeaRow")]
|
[HttpPost("editSHTeaRow")]
|
||||||
public async Task<string> EditSHTeaRow([FromBody] WY_SHRole_SHTea model)
|
public async Task<string> EditSHTeaRow([FromForm] string SHTeaTxt)
|
||||||
{
|
{
|
||||||
|
var model = ParseFormJson<WY_SHRole_SHTea>(SHTeaTxt);
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
return await _role.EditSHTeaRowAsync(SchSerID(), UserId(), model);
|
return await _role.EditSHTeaRowAsync(SchSerID(), UserId(), model);
|
||||||
}
|
}
|
||||||
@@ -68,35 +84,41 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
public async Task<string> DelSHTeaRow([FromQuery] decimal serID)
|
public async Task<string> DelSHTeaRow([FromQuery] decimal serID)
|
||||||
=> await _role.DelSHTeaRowAsync(SchSerID(), serID);
|
=> await _role.DelSHTeaRowAsync(SchSerID(), serID);
|
||||||
|
|
||||||
/// <summary>批量保存审核人(旧 editSHTea,form: SHRoleID + listSHTeaTxt → [FromBody])。</summary>
|
/// <summary>批量保存审核人(旧 editSHTea,form: SHRoleID + listSHTeaTxt=JSON 数组字符串)。</summary>
|
||||||
[HttpPost("editSHTea")]
|
[HttpPost("editSHTea")]
|
||||||
public async Task<string> EditSHTea([FromBody] EditSHTeaDto model)
|
public async Task<string> EditSHTea([FromForm] decimal SHRoleID, [FromForm] string listSHTeaTxt)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
var list = ParseFormJson<List<WY_SHRole_SHTea>>(listSHTeaTxt) ?? new List<WY_SHRole_SHTea>();
|
||||||
return await _role.EditSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listSHTea);
|
return await _role.EditSHTeaAsync(SchSerID(), UserId(), SHRoleID, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>批量保存被审核人(旧 editBSHTea,form: SHRoleID + listBSHTeaTxt → [FromBody])。</summary>
|
/// <summary>批量保存被审核人(旧 editBSHTea,form: SHRoleID + listBSHTeaTxt=JSON 数组字符串)。</summary>
|
||||||
[HttpPost("editBSHTea")]
|
[HttpPost("editBSHTea")]
|
||||||
public async Task<string> EditBSHTea([FromBody] EditBSHTeaDto model)
|
public async Task<string> EditBSHTea([FromForm] decimal SHRoleID, [FromForm] string listBSHTeaTxt)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
var list = ParseFormJson<List<WY_SHRole_BSHTea>>(listBSHTeaTxt) ?? new List<WY_SHRole_BSHTea>();
|
||||||
return await _role.EditBSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listBSHTea);
|
return await _role.EditBSHTeaAsync(SchSerID(), UserId(), SHRoleID, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>删除角色(旧 delSHRole,form: listSHRoleIDTxt → [FromBody])。</summary>
|
/// <summary>删除角色(旧 delSHRole,form: listSHRoleIDTxt=JSON 数组字符串,兼容逗号分隔)。</summary>
|
||||||
[HttpPost("delSHRole")]
|
[HttpPost("delSHRole")]
|
||||||
public async Task<string> DelSHRole([FromBody] DelSHRoleDto model)
|
public async Task<string> DelSHRole([FromForm] string listSHRoleIDTxt)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
var list = ParseFormJson<List<decimal?>>(listSHRoleIDTxt);
|
||||||
return await _role.DelSHRoleAsync(SchSerID(), model.listSHRoleID);
|
if (list == null)
|
||||||
|
{
|
||||||
|
list = (listSHRoleIDTxt ?? "").Split(',')
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a) && decimal.TryParse(a.Trim(), out _))
|
||||||
|
.Select(a => (decimal?)decimal.Parse(a.Trim())).ToList();
|
||||||
|
}
|
||||||
|
return await _role.DelSHRoleAsync(SchSerID(), list);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region 审核角色流程
|
#region 审核角色流程
|
||||||
|
|
||||||
/// <summary>审核流程列表(旧 ListFlow)。</summary>
|
/// <summary>审核流程列表(旧 ListFlow;分页返回 PageVO,Content 内对象字段与旧 Data 对齐)。</summary>
|
||||||
[HttpGet("ListFlow")]
|
[HttpGet("ListFlow")]
|
||||||
public async Task<PageVO<SHRoleFlowListItem>> ListFlow(
|
public async Task<PageVO<SHRoleFlowListItem>> ListFlow(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
@@ -108,7 +130,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>新增/编辑流程(旧 EditFlow;schSerID 取当前用户)。</summary>
|
/// <summary>新增/编辑流程(旧 EditFlow;schSerID 取当前用户)。</summary>
|
||||||
[HttpPost("EditFlow")]
|
[HttpPost("EditFlow")]
|
||||||
public async Task<string> EditFlow([FromBody] Common_flowinfo model)
|
public async Task<string> EditFlow([FromForm] Common_flowinfo model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.schSerID = SchSerID();
|
model.schSerID = SchSerID();
|
||||||
@@ -117,7 +139,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>新增步骤(旧 StepCreateOfSHRole)。</summary>
|
/// <summary>新增步骤(旧 StepCreateOfSHRole)。</summary>
|
||||||
[HttpPost("StepCreateOfSHRole")]
|
[HttpPost("StepCreateOfSHRole")]
|
||||||
public async Task<string> StepCreateOfSHRole([FromBody] Common_flowstep model)
|
public async Task<string> StepCreateOfSHRole([FromForm] Common_flowstep model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
return await _role.StepCreateOfSHRoleAsync(model);
|
return await _role.StepCreateOfSHRoleAsync(model);
|
||||||
@@ -178,11 +200,22 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
public async Task<List<Common_Dictionary>> GetDicInfo([FromQuery] string typeID)
|
public async Task<List<Common_Dictionary>> GetDicInfo([FromQuery] string typeID)
|
||||||
=> await _role.GetDicInfoAsync(SchSerID(), typeID);
|
=> await _role.GetDicInfoAsync(SchSerID(), typeID);
|
||||||
|
|
||||||
/// <summary>编辑文印(审核角色视角;旧 editDocPrint,form 三段 JSON → [FromBody])。</summary>
|
/// <summary>编辑文印(审核角色视角;旧 editDocPrint,form: isedit + data/docdata/docnumdata 三段 JSON 字符串)。</summary>
|
||||||
[HttpPost("editDocPrint")]
|
[HttpPost("editDocPrint")]
|
||||||
public async Task<int> EditDocPrint([FromBody] WYSHRoleEditDocPrintDto model)
|
public async Task<int> EditDocPrint(
|
||||||
|
[FromForm] int isedit,
|
||||||
|
[FromForm] string data,
|
||||||
|
[FromForm] string docdata,
|
||||||
|
[FromForm] string docnumdata)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
var model = new WYSHRoleEditDocPrintDto
|
||||||
|
{
|
||||||
|
isedit = isedit,
|
||||||
|
data = ParseFormJson<WYFlowForUser>(data),
|
||||||
|
docdata = ParseFormJson<WYDocPrintSaveDto>(docdata),
|
||||||
|
docnumdata = ParseFormJson<List<WY_DocPrint_Num>>(docnumdata) ?? new List<WY_DocPrint_Num>()
|
||||||
|
};
|
||||||
|
if (model.data == null || model.docdata == null) throw new DException("参数错误!");
|
||||||
return await _role.EditDocPrintAsync(SchSerID(), UserId(), model);
|
return await _role.EditDocPrintAsync(SchSerID(), UserId(), model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +238,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
public async Task<List<SHRoleDocPrintListItem>> MyListtoOneSHRole([FromQuery] int serID)
|
public async Task<List<SHRoleDocPrintListItem>> MyListtoOneSHRole([FromQuery] int serID)
|
||||||
=> await _role.MyListtoOneSHRoleAsync(serID, SchSerID());
|
=> await _role.MyListtoOneSHRoleAsync(serID, SchSerID());
|
||||||
|
|
||||||
/// <summary>我的文印(旧 MyListSHRole)。</summary>
|
/// <summary>我的文印(旧 MyListSHRole,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("MyListSHRole")]
|
[HttpGet("MyListSHRole")]
|
||||||
public async Task<List<SHRoleDocPrintListItem>> MyListSHRole(
|
public async Task<PageVO<SHRoleDocPrintListItem>> MyListSHRole(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
[FromQuery] string status = null,
|
[FromQuery] string status = null,
|
||||||
[FromQuery] string startDate = null,
|
[FromQuery] string startDate = null,
|
||||||
@@ -216,9 +249,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _role.MyListSHRoleAsync(name, status, startDate, endDate, pageSize, pageIndex, UserId(), SchSerID());
|
=> await _role.MyListSHRoleAsync(name, status, startDate, endDate, pageSize, pageIndex, UserId(), SchSerID());
|
||||||
|
|
||||||
/// <summary>全部文印(旧 totalListSHRole)。</summary>
|
/// <summary>全部文印(旧 totalListSHRole,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("totalListSHRole")]
|
[HttpGet("totalListSHRole")]
|
||||||
public async Task<List<SHRoleDocPrintListItem>> TotalListSHRole(
|
public async Task<PageVO<SHRoleDocPrintListItem>> TotalListSHRole(
|
||||||
[FromQuery] int flowid = 0,
|
[FromQuery] int flowid = 0,
|
||||||
[FromQuery] int dwid = 0,
|
[FromQuery] int dwid = 0,
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
@@ -229,9 +262,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _role.TotalListSHRoleAsync(flowid, dwid, name, stateid, starttime, endtime, pageSize, pageIndex, SchSerID());
|
=> await _role.TotalListSHRoleAsync(flowid, dwid, name, stateid, starttime, endtime, pageSize, pageIndex, SchSerID());
|
||||||
|
|
||||||
/// <summary>抄送列表(旧 SendRdListSHRole)。</summary>
|
/// <summary>抄送列表(旧 SendRdListSHRole,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("SendRdListSHRole")]
|
[HttpGet("SendRdListSHRole")]
|
||||||
public async Task<List<SHRoleDocPrintListItem>> SendRdListSHRole(
|
public async Task<PageVO<SHRoleDocPrintListItem>> SendRdListSHRole(
|
||||||
[FromQuery] int flowid = 0,
|
[FromQuery] int flowid = 0,
|
||||||
[FromQuery] int dwid = 0,
|
[FromQuery] int dwid = 0,
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
@@ -242,9 +275,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _role.SendRdListSHRoleAsync(flowid, dwid, name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
|
=> await _role.SendRdListSHRoleAsync(flowid, dwid, name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>审批列表(旧 SPListSHRole)。</summary>
|
/// <summary>审批列表(旧 SPListSHRole,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("SPListSHRole")]
|
[HttpGet("SPListSHRole")]
|
||||||
public async Task<List<SHRoleDocPrintListItem>> SPListSHRole(
|
public async Task<PageVO<SHRoleDocPrintListItem>> SPListSHRole(
|
||||||
[FromQuery] int flowid = 0,
|
[FromQuery] int flowid = 0,
|
||||||
[FromQuery] int dwid = 0,
|
[FromQuery] int dwid = 0,
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
@@ -257,23 +290,89 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>审核确认(旧 shenheokSHRole)。</summary>
|
/// <summary>审核确认(旧 shenheokSHRole)。</summary>
|
||||||
[HttpPost("shenheokSHRole")]
|
[HttpPost("shenheokSHRole")]
|
||||||
public async Task<string> ShenheOkSHRole([FromBody] FlowTranctDto model)
|
public async Task<string> ShenheOkSHRole([FromForm] FlowTranctDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
|
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
|
||||||
return await _role.ShenheOkSHRoleAsync(model);
|
return await _role.ShenheOkSHRoleAsync(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延后:ExceltotalListSHRole / WYRDExceldaochu / UpfileExcelSHRole / downMBOfSHRole / downUpfileSHRoleFK(Excel,NPOI)
|
#region Excel 导入导出 / 审批单 PDF
|
||||||
// 延后:DwonWYSQD(审批单 PDF,PdfHelper + Server.MapPath)
|
|
||||||
|
/// <summary>全部文印导出(旧 ExceltotalListSHRole,NPOI .xls)。</summary>
|
||||||
|
[HttpGet("ExceltotalListSHRole")]
|
||||||
|
public async Task<IActionResult> ExceltotalListSHRole(
|
||||||
|
[FromQuery] string Name,
|
||||||
|
[FromQuery] string stateid,
|
||||||
|
[FromQuery] string starttime,
|
||||||
|
[FromQuery] string endtime)
|
||||||
|
{
|
||||||
|
var (content, fileName) = await _role.ExceltotalListSHRoleAsync(SchSerID(), Name, stateid, starttime, endtime);
|
||||||
|
return File(content, ContentType(fileName), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>文印记录导出(旧 WYRDExceldaochu,flag=page 简版 / 完整版,EPPlus)。</summary>
|
||||||
|
[HttpGet("WYRDExceldaochu")]
|
||||||
|
public async Task<IActionResult> WYRDExceldaochu(
|
||||||
|
[FromQuery] int flowid,
|
||||||
|
[FromQuery] int DWid,
|
||||||
|
[FromQuery] string Name,
|
||||||
|
[FromQuery] string stateid,
|
||||||
|
[FromQuery] string starttime,
|
||||||
|
[FromQuery] string endtime,
|
||||||
|
[FromQuery] string flag)
|
||||||
|
{
|
||||||
|
var (content, fileName) = await _role.WYRDExceldaochuAsync(SchSerID(), flowid, DWid, Name, stateid, starttime, endtime, flag);
|
||||||
|
return File(content, ContentType(fileName), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Excel 批量导入审核角色(旧 UpfileExcelSHRole,multipart 文件字段名不敏感取第一个)。</summary>
|
||||||
|
[HttpPost("UpfileExcelSHRole")]
|
||||||
|
public async Task<string> UpfileExcelSHRole()
|
||||||
|
{
|
||||||
|
var file = Request.Form.Files.Count > 0 ? Request.Form.Files[0] : null;
|
||||||
|
return await _role.UpfileExcelSHRoleAsync(SchSerID(), UserId(), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>下载导入模板(旧 downMBOfSHRole,代码生成)。</summary>
|
||||||
|
[HttpGet("downMBOfSHRole")]
|
||||||
|
public async Task<IActionResult> DownMBOfSHRole()
|
||||||
|
{
|
||||||
|
var content = await _role.DownMBOfSHRoleAsync();
|
||||||
|
return File(content, "application/vnd.ms-excel", "批量导入审核角色.xls");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>下载最近一次导入的反馈 Excel(旧 downUpfileSHRoleFK)。</summary>
|
||||||
|
[HttpGet("downUpfileSHRoleFK")]
|
||||||
|
public async Task<IActionResult> DownUpfileSHRoleFK()
|
||||||
|
{
|
||||||
|
var content = await _role.DownUpfileSHRoleFKAsync(UserId());
|
||||||
|
return File(content, "application/vnd.ms-excel", "批量导入审核角色反馈.xls");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>审批单 PDF 下载(旧 DwonWYSQD,IDS 逗号分隔的 WY_DocPrint.SerID;依赖服务器安装 wkhtmltopdf)。</summary>
|
||||||
|
[HttpGet("DwonWYSQD")]
|
||||||
|
public async Task<IActionResult> DwonWYSQD([FromQuery] string IDS)
|
||||||
|
{
|
||||||
|
var (content, fileName) = await _role.DwonWYSQDAsync(SchSerID(), UserId(), IDS);
|
||||||
|
return File(content, "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按扩展名取下载 Content-Type(对齐 CommonController.GetContentType 约定)。</summary>
|
||||||
|
private static string ContentType(string fileName)
|
||||||
|
=> fileName != null && fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "application/pdf"
|
||||||
|
: "application/vnd.ms-excel";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region 签名 / 退回 / 金额重提
|
#region 签名 / 退回 / 金额重提
|
||||||
|
|
||||||
/// <summary>保存签名(旧 SaveSignature,form: pic → [FromBody])。</summary>
|
/// <summary>保存签名(旧 SaveSignature,form: pic → [FromForm])。</summary>
|
||||||
[HttpPost("SaveSignature")]
|
[HttpPost("SaveSignature")]
|
||||||
public async Task<string> SaveSignature([FromBody] SaveSignatureDto model)
|
public async Task<string> SaveSignature([FromForm] SaveSignatureDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
return await _role.SaveSignatureAsync(SchSerID(), UserId(), model.pic);
|
return await _role.SaveSignatureAsync(SchSerID(), UserId(), model.pic);
|
||||||
@@ -287,9 +386,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
[HttpGet("DelSignature")]
|
[HttpGet("DelSignature")]
|
||||||
public async Task<string> DelSignature() => await _role.DelSignatureAsync(SchSerID(), UserId());
|
public async Task<string> DelSignature() => await _role.DelSignatureAsync(SchSerID(), UserId());
|
||||||
|
|
||||||
/// <summary>退回(旧 TuiHui,form: DocID + cont → [FromBody])。</summary>
|
/// <summary>退回(旧 TuiHui,form: DocID + cont → [FromForm])。</summary>
|
||||||
[HttpPost("TuiHui")]
|
[HttpPost("TuiHui")]
|
||||||
public async Task<string> TuiHui([FromBody] TuiHuiDto model)
|
public async Task<string> TuiHui([FromForm] TuiHuiDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
return await _role.TuiHuiAsync(UserId(), model.cont, model.DocID);
|
return await _role.TuiHuiAsync(UserId(), model.cont, model.DocID);
|
||||||
@@ -300,9 +399,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
public async Task<string> UnTuiHui([FromQuery] decimal docID)
|
public async Task<string> UnTuiHui([FromQuery] decimal docID)
|
||||||
=> await _role.UnTuiHuiAsync(UserId(), docID);
|
=> await _role.UnTuiHuiAsync(UserId(), docID);
|
||||||
|
|
||||||
/// <summary>金额重提(旧 regetJE,form: DocID 逗号分隔 + type → [FromBody])。</summary>
|
/// <summary>金额重提(旧 regetJE,form: DocID 逗号分隔 + type → [FromForm])。</summary>
|
||||||
[HttpPost("regetJE")]
|
[HttpPost("regetJE")]
|
||||||
public async Task<string> RegetJE([FromBody] RegetJEDto model)
|
public async Task<string> RegetJE([FromForm] RegetJEDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
var ids = (model.DocID ?? "").Split(',')
|
var ids = (model.DocID ?? "").Split(',')
|
||||||
@@ -311,9 +410,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
return await _role.RegetJEAsync(SchSerID(), UserId(), ids, model.type);
|
return await _role.RegetJEAsync(SchSerID(), UserId(), ids, model.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>重提记录(旧 getRegetJERd)。</summary>
|
/// <summary>重提记录(旧 getRegetJERd,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("getRegetJERd")]
|
[HttpGet("getRegetJERd")]
|
||||||
public async Task<List<SHRoleRegetJERdItem>> GetRegetJERd(
|
public async Task<PageVO<SHRoleRegetJERdItem>> GetRegetJERd(
|
||||||
[FromQuery] int pageSize = 10,
|
[FromQuery] int pageSize = 10,
|
||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _role.GetRegetJERdAsync(SchSerID(), pageSize, pageIndex);
|
=> await _role.GetRegetJERdAsync(SchSerID(), pageSize, pageIndex);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Yh.Platform.Abstractions;
|
using Yh.Platform.Abstractions;
|
||||||
using YhCore.Common;
|
using YhCore.Common;
|
||||||
using YhCore.Common.UserContext;
|
using YhCore.Common.UserContext;
|
||||||
using YhCore.Entity;
|
using YhCore.Entity;
|
||||||
using YhCore.Model.Dto;
|
using YhCore.Model.Dto;
|
||||||
|
using YhCore.Model.Vo;
|
||||||
using YhCore.Service.Interfaces;
|
using YhCore.Service.Interfaces;
|
||||||
|
|
||||||
namespace YhCore.WebApi.Controllers.Api.DocPrint
|
namespace YhCore.WebApi.Controllers.Api.DocPrint
|
||||||
@@ -16,6 +18,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
/// 第一批:分类 / 版面 / 字典 14 接口;第三批 D1:文印 CRUD / 列表 / 审核 / 统计 / 指派 24 接口;
|
/// 第一批:分类 / 版面 / 字典 14 接口;第三批 D1:文印 CRUD / 列表 / 审核 / 统计 / 指派 24 接口;
|
||||||
/// Excel 导出 4 接口延后批次迁移。
|
/// Excel 导出 4 接口延后批次迁移。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath + "/docprint/wy_list")]
|
[Route(ApiRoutes.ApiPath + "/docprint/wy_list")]
|
||||||
public class WY_listController : ControllerBase
|
public class WY_listController : ControllerBase
|
||||||
@@ -35,7 +38,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>添加文印分类。</summary>
|
/// <summary>添加文印分类。</summary>
|
||||||
[HttpPost("AddDic")]
|
[HttpPost("AddDic")]
|
||||||
public async Task AddDic([FromBody] Common_Dictionary model)
|
public async Task AddDic([FromForm] Common_Dictionary model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.Schserid = SchSerID();
|
model.Schserid = SchSerID();
|
||||||
@@ -49,7 +52,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>添加印制版面。</summary>
|
/// <summary>添加印制版面。</summary>
|
||||||
[HttpPost("AddYZBM")]
|
[HttpPost("AddYZBM")]
|
||||||
public async Task AddYZBM([FromBody] Common_Dictionary model)
|
public async Task AddYZBM([FromForm] Common_Dictionary model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.Schserid = SchSerID();
|
model.Schserid = SchSerID();
|
||||||
@@ -78,20 +81,27 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
[FromQuery] bool showAll = false)
|
[FromQuery] bool showAll = false)
|
||||||
=> await _classify.DictionaryTypeListAsync(SchSerID(), name, showAll, typeID);
|
=> await _classify.DictionaryTypeListAsync(SchSerID(), name, showAll, typeID);
|
||||||
|
|
||||||
/// <summary>添加公共类别(复用字典添加逻辑)。</summary>
|
/// <summary>添加公共类别(复用字典添加逻辑)。前端 api/index.js 固定以 x-www-form-urlencoded 提交
|
||||||
|
/// (TypeId/Name/typeName 表单字段,大小写不敏感绑定到 typeID/Name/typeName),故用 [FromForm];
|
||||||
|
/// 若误用 [FromBody],Content-Type 不符会返回 415。</summary>
|
||||||
[HttpPost("AddDictionaryType")]
|
[HttpPost("AddDictionaryType")]
|
||||||
public async Task AddDictionaryType([FromBody] Common_Dictionary model)
|
public async Task AddDictionaryType([FromForm] Common_Dictionary model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
// 表单绑定实例不会为 null,但字段名拼错(如 TypelD≠TypeId)时必填列会为 null,
|
||||||
|
// SaveChanges 才抛"不能将 NULL 插入列"(code:6 无从排查)——在入口显式校验给出可读错误。
|
||||||
|
if (model == null || string.IsNullOrWhiteSpace(model.typeID)
|
||||||
|
|| string.IsNullOrWhiteSpace(model.typeName) || string.IsNullOrWhiteSpace(model.Name))
|
||||||
|
throw new DException("参数错误:typeID、typeName、Name 均不能为空(注意表单字段名拼写,TypeId≠TypelD)");
|
||||||
model.Schserid = SchSerID();
|
model.Schserid = SchSerID();
|
||||||
await _dict.AddDicAsync(model);
|
await _dict.AddDicAsync(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>编辑公共类别(旧调 GetSysDict.EditDic)。</summary>
|
/// <summary>编辑公共类别(旧调 GetSysDict.EditDic)。同 AddDictionaryType:前端表单编码提交,[FromForm] 绑定。</summary>
|
||||||
[HttpPost("EditDictionaryType")]
|
[HttpPost("EditDictionaryType")]
|
||||||
public async Task EditDictionaryType([FromBody] Common_Dictionary model)
|
public async Task EditDictionaryType([FromForm] Common_Dictionary model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数有误!");
|
if (model == null || model.SerID <= 0 || string.IsNullOrWhiteSpace(model.Name))
|
||||||
|
throw new DException("参数错误:SerID、Name 均不能为空(注意表单字段名拼写,TypeId≠TypelD)");
|
||||||
model.Schserid = SchSerID();
|
model.Schserid = SchSerID();
|
||||||
await _dict.EditDicAsync(model);
|
await _dict.EditDicAsync(model);
|
||||||
}
|
}
|
||||||
@@ -140,9 +150,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
#region 文印核心(第三批 D1:抄送 / 附件 / CRUD / 列表 / 审核 / 统计 / 指派)
|
#region 文印核心(第三批 D1:抄送 / 附件 / CRUD / 列表 / 审核 / 统计 / 指派)
|
||||||
|
|
||||||
/// <summary>抄送列表(旧 SendRdList)。</summary>
|
/// <summary>抄送列表(旧 SendRdList,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("SendRdList")]
|
[HttpGet("SendRdList")]
|
||||||
public async Task<System.Collections.Generic.List<DocPrintListItem>> SendRdList(
|
public async Task<PageVO<DocPrintListItem>> SendRdList(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
[FromQuery] string startDate = null,
|
[FromQuery] string startDate = null,
|
||||||
[FromQuery] string endDate = null,
|
[FromQuery] string endDate = null,
|
||||||
@@ -167,7 +177,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>添加文印(旧 Create;短信/待办推送已降级为纯落库)。</summary>
|
/// <summary>添加文印(旧 Create;短信/待办推送已降级为纯落库)。</summary>
|
||||||
[HttpPost("Create")]
|
[HttpPost("Create")]
|
||||||
public async Task<int> Create([FromBody] WYDocPrintSaveDto model)
|
public async Task<int> Create([FromForm] WYDocPrintSaveDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.UserID = UserId();
|
model.UserID = UserId();
|
||||||
@@ -177,7 +187,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>修改文印(旧 Edit)。</summary>
|
/// <summary>修改文印(旧 Edit)。</summary>
|
||||||
[HttpPost("Edit")]
|
[HttpPost("Edit")]
|
||||||
public async Task<int> Edit([FromBody] WYDocPrintSaveDto model)
|
public async Task<int> Edit([FromForm] WYDocPrintSaveDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.UserID = UserId();
|
model.UserID = UserId();
|
||||||
@@ -187,16 +197,16 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>撤销(旧 Revoke)。</summary>
|
/// <summary>撤销(旧 Revoke)。</summary>
|
||||||
[HttpPost("Revoke")]
|
[HttpPost("Revoke")]
|
||||||
public async Task<string> Revoke([FromBody] FlowTranctDto model)
|
public async Task<string> Revoke([FromForm] FlowTranctDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.actionuserid = UserContextHolder.Current?.Id;
|
model.actionuserid = UserContextHolder.Current?.Id;
|
||||||
return await _wy.RevokeAsync(model);
|
return await _wy.RevokeAsync(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>我的文印列表(旧 MyList)。</summary>
|
/// <summary>我的文印列表(旧 MyList,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("MyList")]
|
[HttpGet("MyList")]
|
||||||
public async Task<System.Collections.Generic.List<DocPrintListItem>> MyList(
|
public async Task<PageVO<DocPrintListItem>> MyList(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
[FromQuery] string status = null,
|
[FromQuery] string status = null,
|
||||||
[FromQuery] string startDate = null,
|
[FromQuery] string startDate = null,
|
||||||
@@ -205,24 +215,24 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.MyListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
|
=> await _wy.MyListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>全部文印列表(旧 AllList 四分类,返回 ctype)。</summary>
|
/// <summary>全部文印列表(旧 AllList 四分类,返回 ctype;分页返回 PageVO)。</summary>
|
||||||
[HttpGet("AllList")]
|
[HttpGet("AllList")]
|
||||||
public async Task<System.Collections.Generic.List<DocPrintListItem>> AllList(
|
public async Task<PageVO<DocPrintListItem>> AllList(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
[FromQuery] int pageSize = 10,
|
[FromQuery] int pageSize = 10,
|
||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.AllListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
|
=> await _wy.AllListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>文印审核列表(旧 ShenheList;旧 Name 过滤为死代码,保持不生效)。</summary>
|
/// <summary>文印审核列表(旧 ShenheList;旧 Name 过滤为死代码,保持不生效;分页返回 PageVO)。</summary>
|
||||||
[HttpGet("ShenheList")]
|
[HttpGet("ShenheList")]
|
||||||
public async Task<System.Collections.Generic.List<DocPrintListItem>> ShenheList(
|
public async Task<PageVO<DocPrintListItem>> ShenheList(
|
||||||
[FromQuery] int pageSize = 10,
|
[FromQuery] int pageSize = 10,
|
||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.ShenheListAsync(UserId(), SchSerID(), pageSize, pageIndex);
|
=> await _wy.ShenheListAsync(UserId(), SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>审批列表(旧 SPList)。</summary>
|
/// <summary>审批列表(旧 SPList,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("SPList")]
|
[HttpGet("SPList")]
|
||||||
public async Task<System.Collections.Generic.List<DocPrintListItem>> SPList(
|
public async Task<PageVO<DocPrintListItem>> SPList(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
[FromQuery] string status = null,
|
[FromQuery] string status = null,
|
||||||
[FromQuery] string startDate = null,
|
[FromQuery] string startDate = null,
|
||||||
@@ -231,17 +241,17 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.SPListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
|
=> await _wy.SPListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>文印已审列表(旧 YishenList)。</summary>
|
/// <summary>文印已审列表(旧 YishenList,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("YishenList")]
|
[HttpGet("YishenList")]
|
||||||
public async Task<System.Collections.Generic.List<DocPrintListItem>> YishenList(
|
public async Task<PageVO<DocPrintListItem>> YishenList(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
[FromQuery] int pageSize = 10,
|
[FromQuery] int pageSize = 10,
|
||||||
[FromQuery] int pageIndex = 0)
|
[FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.YishenListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
|
=> await _wy.YishenListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>全部文印列表(旧 totalList)。</summary>
|
/// <summary>全部文印列表(旧 totalList,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("totalList")]
|
[HttpGet("totalList")]
|
||||||
public async Task<System.Collections.Generic.List<DocPrintListItem>> TotalList(
|
public async Task<PageVO<DocPrintListItem>> TotalList(
|
||||||
[FromQuery] string name = null,
|
[FromQuery] string name = null,
|
||||||
[FromQuery] string stateid = null,
|
[FromQuery] string stateid = null,
|
||||||
[FromQuery] string starttime = null,
|
[FromQuery] string starttime = null,
|
||||||
@@ -257,7 +267,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>审核确认(旧 shenheok)。</summary>
|
/// <summary>审核确认(旧 shenheok)。</summary>
|
||||||
[HttpPost("shenheok")]
|
[HttpPost("shenheok")]
|
||||||
public async Task<string> ShenheOk([FromBody] FlowTranctDto model)
|
public async Task<string> ShenheOk([FromForm] FlowTranctDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
|
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
|
||||||
@@ -266,7 +276,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
|
|
||||||
/// <summary>领取确认(旧 AffirmApplyforOk)。</summary>
|
/// <summary>领取确认(旧 AffirmApplyforOk)。</summary>
|
||||||
[HttpPost("AffirmApplyforOk")]
|
[HttpPost("AffirmApplyforOk")]
|
||||||
public async Task<string> AffirmApplyforOk([FromBody] FlowTranctDto model)
|
public async Task<string> AffirmApplyforOk([FromForm] FlowTranctDto model)
|
||||||
{
|
{
|
||||||
if (model == null) throw new DException("参数错误!");
|
if (model == null) throw new DException("参数错误!");
|
||||||
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
|
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
|
||||||
@@ -296,30 +306,30 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
|
|||||||
public async Task<System.Collections.Generic.List<MonthStatItem>> List1([FromQuery] string starttime = null, [FromQuery] string endtime = null)
|
public async Task<System.Collections.Generic.List<MonthStatItem>> List1([FromQuery] string starttime = null, [FromQuery] string endtime = null)
|
||||||
=> await _wy.List1Async(starttime, endtime, SchSerID());
|
=> await _wy.List1Async(starttime, endtime, SchSerID());
|
||||||
|
|
||||||
/// <summary>文印详情统计列表(旧 List2)。</summary>
|
/// <summary>文印详情统计列表(旧 List2,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("List2")]
|
[HttpGet("List2")]
|
||||||
public async Task<System.Collections.Generic.List<DocDetailStatItem>> List2(
|
public async Task<PageVO<DocDetailStatItem>> List2(
|
||||||
[FromQuery] string starttime = null, [FromQuery] string endtime = null,
|
[FromQuery] string starttime = null, [FromQuery] string endtime = null,
|
||||||
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.List2Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
|
=> await _wy.List2Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>文印数量按用户排名(旧 List3)。</summary>
|
/// <summary>文印数量按用户排名(旧 List3,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("List3")]
|
[HttpGet("List3")]
|
||||||
public async Task<System.Collections.Generic.List<UserRankItem>> List3(
|
public async Task<PageVO<UserRankItem>> List3(
|
||||||
[FromQuery] string starttime = null, [FromQuery] string endtime = null,
|
[FromQuery] string starttime = null, [FromQuery] string endtime = null,
|
||||||
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.List3Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
|
=> await _wy.List3Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>按月份统计明细(旧 List4)。</summary>
|
/// <summary>按月份统计明细(旧 List4,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("List4")]
|
[HttpGet("List4")]
|
||||||
public async Task<System.Collections.Generic.List<StatListItem>> List4(
|
public async Task<PageVO<StatListItem>> List4(
|
||||||
[FromQuery] int year, [FromQuery] int month,
|
[FromQuery] int year, [FromQuery] int month,
|
||||||
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.List4Async(year, month, SchSerID(), pageSize, pageIndex);
|
=> await _wy.List4Async(year, month, SchSerID(), pageSize, pageIndex);
|
||||||
|
|
||||||
/// <summary>按用户统计明细(旧 List5)。</summary>
|
/// <summary>按用户统计明细(旧 List5,分页返回 PageVO)。</summary>
|
||||||
[HttpGet("List5")]
|
[HttpGet("List5")]
|
||||||
public async Task<System.Collections.Generic.List<StatListItem>> List5(
|
public async Task<PageVO<StatListItem>> List5(
|
||||||
[FromQuery] string userid = null, [FromQuery] string starttime = null, [FromQuery] string endtime = null,
|
[FromQuery] string userid = null, [FromQuery] string starttime = null, [FromQuery] string endtime = null,
|
||||||
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
|
||||||
=> await _wy.List5Async(userid, starttime, endtime, SchSerID(), pageSize, pageIndex);
|
=> await _wy.List5Async(userid, starttime, endtime, SchSerID(), pageSize, pageIndex);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -16,6 +17,7 @@ namespace YhCore.WebApi.Controllers.Api
|
|||||||
/// 登录用户接口(对应 Java 版 controller/api/LoginUserController,路由 /api/GetUserMod 等)。
|
/// 登录用户接口(对应 Java 版 controller/api/LoginUserController,路由 /api/GetUserMod 等)。
|
||||||
/// 注意:Java 版路由以大写开头(GetUserMod / GetUserInfo / Logout),此处保持 URL 大小写一致。
|
/// 注意:Java 版路由以大写开头(GetUserMod / GetUserInfo / Logout),此处保持 URL 大小写一致。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[EnableCors("default")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route(ApiRoutes.ApiPath)]
|
[Route(ApiRoutes.ApiPath)]
|
||||||
public class LoginUserController : ControllerBase
|
public class LoginUserController : ControllerBase
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using YhCore.Repository;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.HealthChecks
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 数据库连通性检查:复用已注册的 YHDbContext(随 DatabaseProvider 自动适配 SqlServer/MySql)。
|
||||||
|
/// 每个检查自带 2 秒超时,防止连接池耗尽时探针自身挂死、反而探不出来。
|
||||||
|
/// </summary>
|
||||||
|
public class DbContextHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly YHDbContext _dbContext;
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
public DbContextHealthCheck(YHDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var probe = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await _dbContext.Database.OpenConnectionAsync(cancellationToken);
|
||||||
|
using var command = _dbContext.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT 1";
|
||||||
|
await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var finished = await Task.WhenAny(probe, Task.Delay(Timeout, CancellationToken.None));
|
||||||
|
if (finished != probe)
|
||||||
|
return HealthCheckResult.Unhealthy("database probe timed out after 2s");
|
||||||
|
|
||||||
|
await probe; // 传播异常
|
||||||
|
return HealthCheckResult.Healthy();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("database probe failed: " + ex.Message, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Redis 连通性检查:复用全局单例 IConnectionMultiplexer(YhCore.Common.Cache 注册)。
|
||||||
|
/// </summary>
|
||||||
|
public class RedisHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IConnectionMultiplexer _multiplexer;
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
public RedisHealthCheck(IConnectionMultiplexer multiplexer)
|
||||||
|
{
|
||||||
|
_multiplexer = multiplexer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_multiplexer.IsConnected)
|
||||||
|
return HealthCheckResult.Unhealthy("redis connection is not established");
|
||||||
|
|
||||||
|
var probe = _multiplexer.GetDatabase().PingAsync();
|
||||||
|
var finished = await Task.WhenAny(probe, Task.Delay(Timeout, CancellationToken.None));
|
||||||
|
if (finished != probe)
|
||||||
|
return HealthCheckResult.Unhealthy("redis ping timed out after 2s");
|
||||||
|
|
||||||
|
var latency = await probe;
|
||||||
|
return HealthCheckResult.Healthy("ping " + latency);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("redis probe failed: " + ex.Message, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,12 @@ namespace YhCore.WebApi.Middleware
|
|||||||
{
|
{
|
||||||
sb.Append(" | exception=").Append(exceptionSummary);
|
sb.Append(" | exception=").Append(exceptionSummary);
|
||||||
}
|
}
|
||||||
|
else if (context.Items.TryGetValue("ExceptionSummary", out var inner)
|
||||||
|
&& !string.IsNullOrEmpty(inner as string))
|
||||||
|
{
|
||||||
|
// GlobalExceptionMiddleware 在内侧把异常转成了 200 响应,摘要经 HttpContext.Items 递出
|
||||||
|
sb.Append(" | exception=").Append(inner);
|
||||||
|
}
|
||||||
|
|
||||||
Log.Info(sb.ToString());
|
Log.Info(sb.ToString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DebugUser 模拟登录中间件(对齐老 BestSchool/Java 系统的 DebugUser 调试机制;
|
||||||
|
/// 前端 api/index.js 每次请求固定携带 DebugAppType=true 与 DebugUser=<loginName> 头)。
|
||||||
|
///
|
||||||
|
/// 用途:解决「本地前端(localhost:8103) 直连部署后端」的登录态跨域问题——
|
||||||
|
/// Cookie 按 host 隔离且 SameSite=Lax 不随跨站 XHR 携带,CAS 在线上域名种下的登录态
|
||||||
|
/// 永远无法回流 localhost 页面的请求;本中间件以请求头承载身份,绕开该物理限制。
|
||||||
|
///
|
||||||
|
/// 生效条件(全部满足):
|
||||||
|
/// 1. 配置开关 yh:auth:DebugUserEnabled = true(默认 false,生产环境勿开——伪造该头即可冒充任意用户);
|
||||||
|
/// 2. 当前请求未认证(正常 CAS 登录态优先,互不干扰);
|
||||||
|
/// 3. DebugAppType=true 且 DebugUser 非空。
|
||||||
|
///
|
||||||
|
/// 动作:按 DebugUser 值构造 loginName 身份(Claims:Name/NameIdentifier/loginName,与 CAS handler 一致),
|
||||||
|
/// 写入 Context.User。后续 UseYhUserContext 据此经 IUserDetailsProvider(UserBase /oapi/user/byLoginName)
|
||||||
|
/// 解析完整 BoaUser;loginName 无对应用户时接口返回空数据,可据此发现账号填错。
|
||||||
|
///
|
||||||
|
/// 注册位置:UseAuthentication 之后、SsoProtectionMiddleware 之前。
|
||||||
|
/// </summary>
|
||||||
|
public class DebugUserMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly bool _enabled;
|
||||||
|
|
||||||
|
public DebugUserMiddleware(RequestDelegate next, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_enabled = configuration.GetValue("yh:auth:DebugUserEnabled", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (_enabled
|
||||||
|
&& context.User?.Identity?.IsAuthenticated != true
|
||||||
|
&& string.Equals(context.Request.Headers["DebugAppType"].ToString(), "true", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var loginName = context.Request.Headers["DebugUser"].ToString().Trim();
|
||||||
|
if (!string.IsNullOrEmpty(loginName))
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, loginName),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, loginName),
|
||||||
|
new Claim("loginName", loginName),
|
||||||
|
};
|
||||||
|
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "DebugUser"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using log4net;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -18,6 +19,8 @@ namespace YhCore.WebApi.Middleware
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class GlobalExceptionMiddleware
|
public class GlobalExceptionMiddleware
|
||||||
{
|
{
|
||||||
|
private static readonly ILog Log = LogManager.GetLogger(typeof(GlobalExceptionMiddleware));
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
public GlobalExceptionMiddleware(RequestDelegate next)
|
public GlobalExceptionMiddleware(RequestDelegate next)
|
||||||
@@ -53,9 +56,13 @@ namespace YhCore.WebApi.Middleware
|
|||||||
timeNow = TimeNow()
|
timeNow = TimeNow()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// 未捕获异常 → 平台兜底:200 + code:6 内部服务错误(对齐 Java GlobalExceptionHandler.handleException)
|
// 未捕获异常 → 平台兜底:200 + code:6 内部服务错误(对齐 Java GlobalExceptionHandler.handleException)
|
||||||
|
// 本中间件位于 ApiAccessLogMiddleware 内侧,异常不会穿透到它的 catch,
|
||||||
|
// 必须在此落日志,否则 code:6 无迹可寻(摘要随接口访问日志一并输出)
|
||||||
|
context.Items["ExceptionSummary"] = ex.Message;
|
||||||
|
Log.Error($"未捕获异常 {context.Request.Method} {context.Request.Path}{context.Request.QueryString}", ex);
|
||||||
await WriteJsonAsync(context, StatusCodes.Status200OK, new
|
await WriteJsonAsync(context, StatusCodes.Status200OK, new
|
||||||
{
|
{
|
||||||
code = ErrorCode.InternalServerError.Code,
|
code = ErrorCode.InternalServerError.Code,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using YhCore.WebApi.Auth;
|
||||||
|
|
||||||
|
namespace YhCore.WebApi.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 路径矩阵分号段前置中间件(须注册在 UseRouting 之前)。
|
||||||
|
/// 对齐 Java notice 行为:Spring 路由前默认 UrlPathHelper.removeSemicolonContent=true 会剥离
|
||||||
|
/// 分号内容(矩阵参数),ASP.NET Core 不做——带 ';' 的路径进入 UseRouting 会匹配不到任何 action
|
||||||
|
/// 而 404。本中间件在路由前模拟该语义:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>路径含 ;redirectToReferer=<hex>:提取 hex 与原始路径存入 HttpContext.Items
|
||||||
|
/// (键 <see cref="YhCasDefaults.RefererHexItemKey"/> / <see cref="YhCasDefaults.OriginalPathItemKey"/>),
|
||||||
|
/// 再从 Request.Path 剥离该段。提取对带 ticket 的 CAS 回调同样生效(不再放行原始路径):
|
||||||
|
/// serviceValidate 重建 service 由 CasAuthenticationHandler 改用 Items 中的原始路径,
|
||||||
|
/// 与登录 service 逐字节一致;回跳目标(解码地址 + ?ticket=)由 handler / SsoProtectionMiddleware
|
||||||
|
/// 在认证完成后统一 302——已登录(Cookie 恢复)收到回调也跳目标页,不再落入 MVC 路由 404。</item>
|
||||||
|
/// <item>其余含 ';' 的路径:剥离首个 ';' 起的分号段后继续,正常路由/保护判定不再 404。</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class PathMatrixSegmentMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public PathMatrixSegmentMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var rawPath = context.Request.Path.Value ?? string.Empty;
|
||||||
|
var marker = rawPath.IndexOf(YhCasDefaults.RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (marker >= 0)
|
||||||
|
{
|
||||||
|
// hex 终止于 ';'(后续矩阵段)、'?'、'#'(hash 路由防御)或串尾
|
||||||
|
var start = marker + YhCasDefaults.RedirectToRefererMarker.Length;
|
||||||
|
var end = start;
|
||||||
|
while (end < rawPath.Length && rawPath[end] != ';' && rawPath[end] != '?' && rawPath[end] != '#')
|
||||||
|
{
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
context.Items[YhCasDefaults.RefererHexItemKey] = rawPath.Substring(start, end - start);
|
||||||
|
context.Items[YhCasDefaults.OriginalPathItemKey] = rawPath;
|
||||||
|
var stripped = rawPath.Substring(0, marker) + rawPath.Substring(end);
|
||||||
|
context.Request.Path = stripped.Length > 0 ? new PathString(stripped) : new PathString("/");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var semi = rawPath.IndexOf(';');
|
||||||
|
if (semi >= 0)
|
||||||
|
{
|
||||||
|
context.Request.Path = new PathString(rawPath.Substring(0, semi));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Yh.Platform.Abstractions;
|
||||||
using Yh.Platform.Auth;
|
using Yh.Platform.Auth;
|
||||||
|
using YhCore.WebApi.Auth;
|
||||||
|
|
||||||
namespace YhCore.WebApi.Middleware
|
namespace YhCore.WebApi.Middleware
|
||||||
{
|
{
|
||||||
@@ -12,11 +14,15 @@ namespace YhCore.WebApi.Middleware
|
|||||||
/// 规则(对齐 yh:sso 配置节):
|
/// 规则(对齐 yh:sso 配置节):
|
||||||
/// - yh:sso:enabled=false(默认):所有路径匿名放行,不影响开发联调;
|
/// - yh:sso:enabled=false(默认):所有路径匿名放行,不影响开发联调;
|
||||||
/// - yh:sso:enabled=true:匹配 /api/* / /mobiapi/* / 根路径(/, 浏览器 SSO 入口) 且未认证的请求
|
/// - yh:sso:enabled=true:匹配 /api/* / /mobiapi/* / 根路径(/, 浏览器 SSO 入口) 且未认证的请求
|
||||||
/// → Challenge → Yh.Platform.Auth CasAuthenticationHandler 302 重定向到 CAS 登录页;
|
/// → Challenge → 本地化 CasAuthenticationHandler(Auth/Cas/)302 重定向到 CAS 登录页;
|
||||||
/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /signin-cas)未认证时同样 Challenge:
|
/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /api/base/GetUserInfo;仅用于识别"回调直访防呆"
|
||||||
|
/// <see cref="IsCasCallback"/> 与旧 URL 兼容。service=原 URL 形态下回调落在任意受保护 API 路径 +
|
||||||
|
/// ticket;历史 URL/缓存中的旧中继形态 {path}?returnUrl=…[&ticket] 仍可校验)未认证时同样 Challenge:
|
||||||
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
|
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
|
||||||
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
|
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
|
||||||
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
|
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
|
||||||
|
/// - 受保护 API 的 AJAX/XHR 请求:复用电框架 Challenge 生成 CAS 登录 URL 后改写为 200 + JSON
|
||||||
|
/// (code:302 + urlToRedirectTo),避免 XHR 跟随跨域 302 被 CORS 拦截;文档导航仍走 302;
|
||||||
/// - Swagger 路径始终放行,方便本地接口调试;
|
/// - Swagger 路径始终放行,方便本地接口调试;
|
||||||
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
|
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
|
||||||
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
|
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
|
||||||
@@ -37,62 +43,219 @@ namespace YhCore.WebApi.Middleware
|
|||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var sso = _configuration.GetSection("yh:sso");
|
var sso = _configuration.GetSection("yh:sso");
|
||||||
var isSwaggerPath = context.Request.Path.StartsWithSegments("/swagger");
|
var path = context.Request.Path;
|
||||||
var isOapiPath = context.Request.Path.StartsWithSegments("/oapi");
|
|
||||||
var isRootEntry = context.Request.Path == "/";
|
|
||||||
var isCasCallback = context.Request.Path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas");
|
|
||||||
|
|
||||||
// 已认证(含 Cookie 恢复登录态)直接放行
|
// 1. 已认证(含 Cookie 恢复登录态)直接放行。
|
||||||
|
// 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location),
|
||||||
|
// 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC,signin-cas 或原 URL 回调端点可能无
|
||||||
|
// 对应 action 或已响应,404 会覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路:
|
||||||
|
// - IsCasCallback:中继形态回调端点({SignInCallbackPath}?returnUrl=…&ticket=…);
|
||||||
|
// - 带 ticket:原 URL 形态回调(service=原访问 URL,CAS 追加 ?ticket= 回跳,落在任意受保护 API 路径),
|
||||||
|
// ticket 校验成功后本中间件无法用路径识别,只能按「携带 ticket」短路。
|
||||||
|
// 已认证且未 302(如 Cookie 有效时刷新带已消费 ticket 的地址)不短路,正常走 MVC 业务响应。
|
||||||
if (context.User?.Identity?.IsAuthenticated == true)
|
if (context.User?.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
// 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location),
|
var hasTicket = context.Request.Query.ContainsKey("ticket");
|
||||||
// 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC 端点匹配,/signin-cas 无对应 action,
|
if (IsRedirect(context.Response) && (IsCasCallback(path, sso) || hasTicket))
|
||||||
// 会返回 404 覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。
|
|
||||||
if (isCasCallback && context.Response.StatusCode == 302 &&
|
|
||||||
!string.IsNullOrEmpty(context.Response.Headers["Location"]))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 已登录直访带 ;redirectToReferer=<hex> 分号段的地址(无 ticket,认证 handler 未接管,
|
||||||
|
// PathMatrixSegmentMiddleware 已把 hex 存入 Items 并剥离路由路径):302 到解码地址,
|
||||||
|
// 对齐 Java CasSsoRefererRedirectFilter「识别分号段即跳回前端页」语义,覆盖刷新/收藏场景。
|
||||||
|
if (!hasTicket
|
||||||
|
&& context.Items.TryGetValue(YhCasDefaults.RefererHexItemKey, out var hexObj)
|
||||||
|
&& hexObj is string hex && hex.Length > 0)
|
||||||
|
{
|
||||||
|
var target = CasHex.Decode(hex);
|
||||||
|
if (CasHex.IsAllowedRedirectTarget(target, CasHex.HostOf(sso["AppBaseUrl"])))
|
||||||
|
{
|
||||||
|
context.Response.Redirect(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
|
// 2. oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
|
||||||
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
|
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
|
||||||
if (isOapiPath)
|
if (path.StartsWithSegments("/oapi"))
|
||||||
{
|
{
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var enabled = sso["enabled"] != null
|
// 3. Swagger 路径始终放行,方便本地接口调试。
|
||||||
? bool.TryParse(sso["enabled"], out var en) && en
|
if (path.StartsWithSegments("/swagger"))
|
||||||
: _configuration.GetValue("Yh:Sso:Enabled", false);
|
|
||||||
|
|
||||||
if (enabled && !isSwaggerPath &&
|
|
||||||
(isRootEntry || isCasCallback || IsProtectedPath(context.Request.Path.Value, sso)))
|
|
||||||
{
|
{
|
||||||
// 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期,
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. SSO 未启用 → 所有路径匿名放行。
|
||||||
|
if (!_configuration.GetValue("yh:sso:enabled", false))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 仅保护根路径(浏览器 SSO 入口)、CAS 回调路径、以及匹配 urlPattern/mobiApiPattern 的 API 路径。
|
||||||
|
var isRootEntry = path == "/";
|
||||||
|
var isCasCallback = IsCasCallback(path, sso);
|
||||||
|
if (!isRootEntry && !isCasCallback && !IsProtectedPath(path.Value, sso))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期,
|
||||||
// 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发
|
// 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发
|
||||||
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
|
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
|
||||||
if (context.Request.Query.ContainsKey("ticket"))
|
if (context.Request.Query.ContainsKey("ticket"))
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = 401;
|
await WriteTicketErrorAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 未认证 → Challenge → 交框架 CasAuthenticationHandler 处理(302 到 CAS 或改写 JSON)。
|
||||||
|
await ChallengeAsync(context, sso, isCasCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触发 Challenge 生成 CAS 登录地址(service 构造由本地化 CasAuthenticationHandler 完成:
|
||||||
|
/// service 恒为原访问 URL(notice 形态);显式回跳(yschreferer/Referer)时以 Java notice 同款
|
||||||
|
/// 分号矩阵段 ;redirectToReferer=<hex> 插入 path 与 query 之间,不再以 SignInCallbackPath
|
||||||
|
/// 回调端点作 service)。
|
||||||
|
/// 受保护 API 的 AJAX/XHR 请求:拦截框架写入的 302 + Location,改写为 200 + JSON,避免 XHR
|
||||||
|
/// 跟随跨域 302 跳转 CAS 登录页时被 CORS 拦截;文档导航 / 根路径 / 回调仍保持 302。
|
||||||
|
/// </summary>
|
||||||
|
private static async Task ChallengeAsync(HttpContext context, IConfigurationSection sso, bool isCasCallback)
|
||||||
|
{
|
||||||
|
var request = context.Request;
|
||||||
|
|
||||||
|
// 只有受保护 API 路径的 AJAX 请求需要改写为 JSON;根路径 / 回调是文档导航语义,始终 302。
|
||||||
|
var isApiAjax = IsProtectedPath(request.Path.Value, sso) && IsAjaxRequest(request);
|
||||||
|
// 回跳目标决策(对齐 Java notice 的 service 语义差异):
|
||||||
|
// - 无显式回跳语境(浏览器/工具直访受保护接口,无 yschreferer 无 Referer):不携带回跳目标
|
||||||
|
// (null)→ handler 以纯净"原访问 URL"作 service(notice 形态 service=http://host/module/api/xxx),
|
||||||
|
// 登录后 CAS 回跳原 URL+ticket,由 handler 校验并 302 去掉 ticket 落回原地址;
|
||||||
|
// - 显式回跳语境(前端 yschreferer 打标携带页面地址 / Referer):回跳目标 = 页面地址,
|
||||||
|
// handler 仍以原访问 URL 作 service 主体,并把目标以分号矩阵段 ;redirectToReferer=<hex>
|
||||||
|
// 插入 path 与 query 之间(与 Java notice 同款;分号段不参与 query 结构,目标含中文/特殊
|
||||||
|
// 字符也无歧义)——前端登录后回到所在页面而非接口本身;
|
||||||
|
// - 回调路径直访防呆(访问的恰是回调端点自身且无 ticket 未认证、无打标):回 "/",
|
||||||
|
// 避免登录后回跳回调自身再触发 Challenge(前端打标场景仍回前端页——回调路径本身常是前端
|
||||||
|
// 登录检测接口 /api/base/GetUserInfo,必须回到用户所在页面)。
|
||||||
|
// 回跳目标决策(对齐 Java notice 的 service 语义差异),优先级:
|
||||||
|
// 1. Items 中的分号段 hex(直访/重登录带 ;redirectToReferer 的地址):解码后作回跳目标,
|
||||||
|
// 重新登录后回到原意页面而非接口本身;
|
||||||
|
// 2. yschreferer 头(前端打标的页面地址,hex 解码);
|
||||||
|
// 3. Referer 头明文;
|
||||||
|
// 4. 无显式回跳语境:回调路径直访防呆回 "/",其余 null(handler 以纯净原访问 URL 作 service,
|
||||||
|
// 登录后落回原地址)。
|
||||||
|
var hasYschReferer = !string.IsNullOrWhiteSpace(request.Headers["yschreferer"].ToString());
|
||||||
|
var hasReferer = !string.IsNullOrWhiteSpace(request.Headers["Referer"].ToString());
|
||||||
|
var appBasePath = ExtractBasePath(sso["AppBaseUrl"]);
|
||||||
|
string returnTarget;
|
||||||
|
string itemsHex = context.Items.TryGetValue(YhCasDefaults.RefererHexItemKey, out var ih) && ih is string ihs
|
||||||
|
? CasHex.Decode(ihs)
|
||||||
|
: null;
|
||||||
|
if (!string.IsNullOrEmpty(itemsHex))
|
||||||
|
{
|
||||||
|
returnTarget = itemsHex;
|
||||||
|
}
|
||||||
|
else if (!hasYschReferer && !hasReferer)
|
||||||
|
{
|
||||||
|
returnTarget = isCasCallback ? "/" : null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
returnTarget = BuildReturnUrl(request, appBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var properties = new AuthenticationProperties
|
||||||
|
{
|
||||||
|
RedirectUri = returnTarget
|
||||||
|
};
|
||||||
|
|
||||||
|
await context.ChallengeAsync(YhAuthDefaults.CasScheme, properties);
|
||||||
|
|
||||||
|
if (!isApiAjax)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅当框架成功写入 302 + Location 时才改写 JSON;若未配置 CAS(框架返回 500)保留原始错误响应,不误改写。
|
||||||
|
if (context.Response.StatusCode != StatusCodes.Status302Found)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = context.Response.Headers["Location"].ToString();
|
||||||
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||||
|
context.Response.Headers.Remove("Location");
|
||||||
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await context.Response.WriteAsync("{\"code\":302,\"urlToRedirectTo\":\"" + location + "\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>票据校验失败 401 错误页(带重新登录入口)。</summary>
|
||||||
|
private static Task WriteTicketErrorAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
context.Response.ContentType = "text/html; charset=utf-8";
|
context.Response.ContentType = "text/html; charset=utf-8";
|
||||||
await context.Response.WriteAsync(
|
return context.Response.WriteAsync(
|
||||||
"<!DOCTYPE html><html lang=\"zh\"><head><meta charset=\"utf-8\"><title>登录失效</title></head>" +
|
"<!DOCTYPE html><html lang=\"zh\"><head><meta charset=\"utf-8\"><title>登录失效</title></head>" +
|
||||||
"<body style=\"font-family:system-ui;text-align:center;padding-top:80px\">" +
|
"<body style=\"font-family:system-ui;text-align:center;padding-top:80px\">" +
|
||||||
"<h2>登录票据校验失败或已过期</h2>" +
|
"<h2>登录票据校验失败或已过期</h2>" +
|
||||||
"<p>CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。</p>" +
|
"<p>CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。</p>" +
|
||||||
"<p><a href=\"/\">重新登录</a></p></body></html>");
|
"<p><a href=\"/\">重新登录</a></p></body></html>");
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 未认证 → Challenge → 302 重定向 CAS 登录页(ticket 校验与回跳由新框架 CasAuthenticationHandler 处理)
|
|
||||||
await context.ChallengeAsync(YhAuthDefaults.CasScheme);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _next(context);
|
/// <summary>
|
||||||
|
/// 是否为 CAS 回调路径。先剥离路径矩阵参数(;xxx=yyy)再比较:
|
||||||
|
/// StartsWithSegments 按段比较,要求匹配后下一个字符是 '/' 或结束,回调若带
|
||||||
|
/// ;redirectToReferer=<hex> 后缀(旧形态)会失配,导致"已认证 + 已 302"的短路判断失效,
|
||||||
|
/// MVC 匹配不到的 404 将覆盖掉 handler 写好的回跳响应。
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCasCallback(PathString path, IConfigurationSection sso)
|
||||||
|
{
|
||||||
|
var value = path.Value ?? string.Empty;
|
||||||
|
var semicolon = value.IndexOf(';');
|
||||||
|
if (semicolon >= 0)
|
||||||
|
{
|
||||||
|
value = value.Substring(0, semicolon);
|
||||||
|
}
|
||||||
|
return new PathString(value).StartsWithSegments(sso["signInCallbackPath"] ?? "/api/base/GetUserInfo");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>响应是否已经是框架写入的 302 重定向(用于回调校验成功后短路检查)。</summary>
|
||||||
|
private static bool IsRedirect(HttpResponse response)
|
||||||
|
=> response.StatusCode == StatusCodes.Status302Found
|
||||||
|
&& !string.IsNullOrEmpty(response.Headers["Location"]);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断请求是否为 AJAX/XHR(而非浏览器整页文档导航)。任一命中即视为 AJAX:
|
||||||
|
/// 1. X-Requested-With: XMLHttpRequest(jQuery/axios/原生 XHR 常见);
|
||||||
|
/// 2. Sec-Fetch-Dest: empty(现代浏览器 fetch/XHR 必带);
|
||||||
|
/// 3. Accept 不含 text/html(fetch 默认 */*、axios 默认 application/json)。
|
||||||
|
/// 文档导航(浏览器地址栏、location.href、<a>)Accept 含 text/html 且无前两者 → false,保持 302。
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsAjaxRequest(HttpRequest request)
|
||||||
|
{
|
||||||
|
if (string.Equals(request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(request.Headers["Sec-Fetch-Dest"].ToString(), "empty", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var accept = request.Headers["Accept"].ToString();
|
||||||
|
return !string.IsNullOrEmpty(accept) &&
|
||||||
|
accept.IndexOf("text/html", StringComparison.OrdinalIgnoreCase) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>路径是否命中保护模式(yh:sso:urlPattern / yh:sso:mobiApiPattern,默认 /api/*、/mobiapi/*,通配后缀匹配,忽略大小写)</summary>
|
/// <summary>路径是否命中保护模式(yh:sso:urlPattern / yh:sso:mobiApiPattern,默认 /api/*、/mobiapi/*,通配后缀匹配,忽略大小写)</summary>
|
||||||
@@ -124,5 +287,59 @@ namespace YhCore.WebApi.Middleware
|
|||||||
|
|
||||||
return path.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
return path.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构造登录后回跳目标(消费前端 yschreferer 头,对齐 Java notice 语义):
|
||||||
|
/// 1. yschreferer 头 —— 前端 strToHexCharCode(当前页完整 URL,含 #/ 路由片段) 的 hex,
|
||||||
|
/// char 维度解码即前端页地址,登录成功后应回到该页(参照 notice 行为,而非接口本身);
|
||||||
|
/// 2. Referer 头明文(完整 http(s) URL;注意浏览器发送 Referer 不含 fragment);
|
||||||
|
/// 3. 兜底当前请求的本地相对路径(含 query,不含 ticket)。
|
||||||
|
/// 返回的可能是完整 URL 或相对路径;合法性(本地路径 / 与 service 同 host 的 http(s) URL)
|
||||||
|
/// 由 CasAuthenticationHandler.IsAllowedReturnTarget 在构造 service 与登录后回跳时统一把关。
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildReturnUrl(HttpRequest request, string appBasePath)
|
||||||
|
{
|
||||||
|
var ysch = request.Headers["yschreferer"].ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(ysch))
|
||||||
|
{
|
||||||
|
var decoded = CasHex.Decode(ysch.Trim());
|
||||||
|
if (!string.IsNullOrEmpty(decoded))
|
||||||
|
{
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var referer = request.Headers["Referer"].ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(referer))
|
||||||
|
{
|
||||||
|
return referer.Trim();
|
||||||
|
}
|
||||||
|
// 兜底:回跳当前请求路径。部署在子应用(如 /docprint)且反向代理剥离前缀时,
|
||||||
|
// Kestrel 收到的 request.Path 不含应用基路径,需补上 AppBaseUrl 的 path 段,
|
||||||
|
// 否则登录后回跳会丢失模块名(/api/GetUserInfo → 应为 /docprint/api/GetUserInfo)。
|
||||||
|
var path = request.Path.Value ?? "/";
|
||||||
|
if (!string.IsNullOrEmpty(appBasePath)
|
||||||
|
&& path.StartsWith("/")
|
||||||
|
&& !path.StartsWith(appBasePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
path = appBasePath.TrimEnd('/') + path;
|
||||||
|
}
|
||||||
|
var query = request.QueryString.Value;
|
||||||
|
return string.IsNullOrEmpty(query) ? path : path + query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从 AppBaseUrl 配置提取应用基路径(host 之后的 path 段,如 http://x/docprint → /docprint)。</summary>
|
||||||
|
private static string ExtractBasePath(string appBaseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(appBaseUrl))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
if (!Uri.TryCreate(appBaseUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
var path = uri.AbsolutePath;
|
||||||
|
return path.Length == 0 ? "/" : path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,11 @@ using YhCore.Common.Config;
|
|||||||
using YhCore.Common.Exceptions;
|
using YhCore.Common.Exceptions;
|
||||||
using YhCore.Common.UserContext;
|
using YhCore.Common.UserContext;
|
||||||
using Yh.Platform.Abstractions;
|
using Yh.Platform.Abstractions;
|
||||||
|
using Yh.Platform.AppAuth;
|
||||||
|
using Yh.Platform.OpenApi;
|
||||||
using Yh.SchClass.OpenApi;
|
using Yh.SchClass.OpenApi;
|
||||||
using Yh.Platform.Auth;
|
using Yh.Platform.Auth;
|
||||||
|
using YhCore.WebApi.Auth;
|
||||||
|
|
||||||
namespace YhCore.WebApi
|
namespace YhCore.WebApi
|
||||||
{
|
{
|
||||||
@@ -77,11 +80,14 @@ namespace YhCore.WebApi
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// 对齐 Java Jackson:日期格式 yyyy-MM-dd HH:mm:ss + 字段 camelCase(code/msg/data)
|
// 对齐旧项目 DocPrint WebApiConfig:属性名原样输出(DefaultContractResolver),
|
||||||
|
// data 内容字段与旧接口逐字段一致(SerID/DocContent/teaname 等混合大小写 as-is);
|
||||||
|
// 日期对齐旧 IsoDateTimeConverter "yyyy/MM/dd HH:mm:ss"。
|
||||||
|
// 信封 code/msg/data 由 ApiResult 的 [JsonProperty] 显式固定小写,不受 as-is 影响。
|
||||||
.AddNewtonsoftJson(o =>
|
.AddNewtonsoftJson(o =>
|
||||||
{
|
{
|
||||||
o.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss";
|
o.SerializerSettings.DateFormatString = "yyyy/MM/dd HH:mm:ss";
|
||||||
o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
|
o.SerializerSettings.ContractResolver = new DefaultContractResolver();
|
||||||
})
|
})
|
||||||
// 对应 Java Hibernate Validator
|
// 对应 Java Hibernate Validator
|
||||||
.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Startup>());
|
.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Startup>());
|
||||||
@@ -89,16 +95,24 @@ namespace YhCore.WebApi
|
|||||||
// 对象映射:对应 Java ModelMapper
|
// 对象映射:对应 Java ModelMapper
|
||||||
services.AddAutoMapper(typeof(Startup));
|
services.AddAutoMapper(typeof(Startup));
|
||||||
|
|
||||||
// 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true");
|
// 跨域策略:允许任意来源 + 允许凭据(等价 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true"))。
|
||||||
// AllowAnyOrigin 与 AllowCredentials 不能同时生效,接入 CAS 后按 yh.sso.allow-origins 收紧
|
// SetIsOriginAllowed(_ => true) 会回显任意请求 Origin(而非下发 *),
|
||||||
|
// 并附 Access-Control-Allow-Credentials: true,使任意来源的前端都能以 credentials:'include' 跨域携带 CAS 登录 Cookie。
|
||||||
|
// 注意:Cors 规范禁止 * 与凭据同用,故不能直接用 AllowAnyOrigin。
|
||||||
services.AddCors(options => options.AddPolicy("default", builder => builder
|
services.AddCors(options => options.AddPolicy("default", builder => builder
|
||||||
.AllowAnyOrigin()
|
.SetIsOriginAllowed(_ => true)
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader()));
|
.AllowAnyHeader()
|
||||||
|
.AllowCredentials()));
|
||||||
|
|
||||||
// 数据层:默认 SQL Server 主库,DatabaseProvider=MySql 可切换
|
// 数据层:默认 SQL Server 主库,DatabaseProvider=MySql 可切换
|
||||||
services.AddDataLayer(Configuration);
|
services.AddDataLayer(Configuration);
|
||||||
|
|
||||||
|
// 健康探针:/health(免认证,路径不在 SSO 保护段内),数据库/Redis 各 2 秒超时
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddCheck<HealthChecks.DbContextHealthCheck>("database", timeout: TimeSpan.FromSeconds(2))
|
||||||
|
.AddCheck<HealthChecks.RedisHealthCheck>("redis", timeout: TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
// 仓储与 Dapper 通道(对应 Java Repository 自动装配)
|
// 仓储与 Dapper 通道(对应 Java Repository 自动装配)
|
||||||
services.AddRepositories();
|
services.AddRepositories();
|
||||||
|
|
||||||
@@ -108,8 +122,26 @@ namespace YhCore.WebApi
|
|||||||
// Redis 缓存(RedisUtil 单例,连接串取 Redis:ConnectionString)
|
// Redis 缓存(RedisUtil 单例,连接串取 Redis:ConnectionString)
|
||||||
services.AddRedisCache(Configuration);
|
services.AddRedisCache(Configuration);
|
||||||
|
|
||||||
// 框架期占位实现;业务实现就绪后在 Extensions 中统一替换注册
|
// 进程内内存缓存:WYSHRole Excel 导入的反馈表(updateSHRole_{userId},单实例;
|
||||||
services.AddScoped<ILogService, LogServiceStub>();
|
// 多实例部署需改 Redis 支撑的分布式缓存)
|
||||||
|
services.AddMemoryCache();
|
||||||
|
|
||||||
|
// ===== 会话状态(Session)=====
|
||||||
|
// 对应 Java HttpSession / session.invalidate();旧 DocPrintCore 同款配置。
|
||||||
|
// 注意:YhAuth 的登录态走自定义短 Cookie(SessionTicketDataFormat,票据存进程内存),
|
||||||
|
// 这里的 ASP.NET Core Session 仅用于 Logout 时 HttpContext.Session.Clear() 释放会话级数据,
|
||||||
|
// 与认证 Cookie 相互独立(各自独立 Cookie)。
|
||||||
|
// 使用进程内分布式缓存(单实例);多实例部署请改用 Redis 支撑的 IDistributedCache。
|
||||||
|
services.AddDistributedMemoryCache();
|
||||||
|
services.AddSession(options =>
|
||||||
|
{
|
||||||
|
options.IdleTimeout = TimeSpan.FromMinutes(20);
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.IsEssential = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 审计日志查询(sys_operation_log 分页搜索,LogServiceStub 已被真实实现替换)
|
||||||
|
services.AddScoped<ILogService, LogService>();
|
||||||
|
|
||||||
// ===== DocPrint 业务服务(对应旧 BestSchool.Service / BestSchool.DocPrint) =====
|
// ===== DocPrint 业务服务(对应旧 BestSchool.Service / BestSchool.DocPrint) =====
|
||||||
services.AddScoped<IDictionaryService, DictionaryService>();
|
services.AddScoped<IDictionaryService, DictionaryService>();
|
||||||
@@ -122,10 +154,14 @@ namespace YhCore.WebApi
|
|||||||
services.AddScoped<IFileService, FileService>();
|
services.AddScoped<IFileService, FileService>();
|
||||||
|
|
||||||
// ===== 基础平台(yh-platform-net 新框架,对应 Java yh-platform) =====
|
// ===== 基础平台(yh-platform-net 新框架,对应 Java yh-platform) =====
|
||||||
// 聚合注册:/oapi 客户端(Yh:Open)+ 应用签名鉴权(Yh:AppAuth)+ Cookie/CAS 认证(Yh:Sso/Yh:Auth)。
|
// OpenApi 客户端(Yh:Open)+ 应用签名鉴权(Yh:AppAuth)+ 本地化 CAS 认证(Yh:Sso/Yh:Auth)。
|
||||||
// AddYhAuth 内部含 AddYhOpenApi;AddYhPlatform = AddYhAppAuth + AddYhAuth。
|
// 认证部分本地化(Auth/Cas/):service 构造为
|
||||||
|
// "{AppBaseUrl}{SignInCallbackPath}[?returnUrl=<urlencoded 回跳地址>]"(query 形态,兼容旧矩阵参数链接),
|
||||||
|
// 其余(Cookie 会话票据 / ticket 校验 / 用户上下文)与 Yh.Platform.Auth.AddYhAuth 一致。
|
||||||
// 静态门面 YschBaseOpenApi 由宿主启动时自动装配(YschFacadeWiring: IHostedService)。
|
// 静态门面 YschBaseOpenApi 由宿主启动时自动装配(YschFacadeWiring: IHostedService)。
|
||||||
services.AddYhPlatform(Configuration);
|
services.AddYhAppAuth(Configuration);
|
||||||
|
services.AddYhOpenApi(Configuration);
|
||||||
|
services.AddYhCasAuth(Configuration);
|
||||||
|
|
||||||
// 教务(SchClass)开放接口客户端 SDK(Yh.SchClass.OpenApi.dll,配置节 yh:schclass:open)
|
// 教务(SchClass)开放接口客户端 SDK(Yh.SchClass.OpenApi.dll,配置节 yh:schclass:open)
|
||||||
services.AddSchClassOpenApi(Configuration);
|
services.AddSchClassOpenApi(Configuration);
|
||||||
@@ -139,9 +175,19 @@ namespace YhCore.WebApi
|
|||||||
.Bind(Configuration.GetSection("Swagger"));
|
.Bind(Configuration.GetSection("Swagger"));
|
||||||
services.AddOptions<YhCore.Common.Config.FileConfig>()
|
services.AddOptions<YhCore.Common.Config.FileConfig>()
|
||||||
.Bind(Configuration);
|
.Bind(Configuration);
|
||||||
// 静态门面(对齐 Java Config):文件路径由配置注入
|
// 静态门面(对齐 Java Config):文件路径由配置注入。
|
||||||
YhCore.Common.Config.Config.FilePath = Configuration["FILE_PATH"];
|
// FILE_PATH 必须以 / 结尾(旧约定:FileService 多处 FilePath + "{appType}/..." 直接拼接,缺斜杠会拼出坏路径);
|
||||||
YhCore.Common.Config.Config.FileWebPath = Configuration["FILE_WEB_PATH"];
|
// FILE_WEB_PATH 不带尾 /(拼接目标 fileurl 均以 / 开头,带尾斜杠会产生 //)
|
||||||
|
var configFilePath = Configuration["FILE_PATH"];
|
||||||
|
YhCore.Common.Config.Config.FilePath = string.IsNullOrEmpty(configFilePath)
|
||||||
|
? configFilePath
|
||||||
|
: configFilePath.TrimEnd('/').Replace('\\', '/') + "/";
|
||||||
|
var configFileWebPath = Configuration["FILE_WEB_PATH"];
|
||||||
|
YhCore.Common.Config.Config.FileWebPath = string.IsNullOrEmpty(configFileWebPath)
|
||||||
|
? configFileWebPath
|
||||||
|
: configFileWebPath.TrimEnd('/');
|
||||||
|
// 审批单 PDF:wkhtmltopdf 可执行文件路径(空则用 PATH)
|
||||||
|
YhCore.Common.Config.Config.WkHtmlToPdfPath = Configuration["wkhtmltopdf_path"];
|
||||||
|
|
||||||
// 接口文档:对应 Java SpringDoc,按渠道分三组(api / mobiapi / oapi)
|
// 接口文档:对应 Java SpringDoc,按渠道分三组(api / mobiapi / oapi)
|
||||||
services.AddSwaggerGen(c =>
|
services.AddSwaggerGen(c =>
|
||||||
@@ -156,6 +202,12 @@ namespace YhCore.WebApi
|
|||||||
In = ParameterLocation.Header,
|
In = ParameterLocation.Header,
|
||||||
Description = "登录后携带的令牌(对接 CAS SSO 后使用)"
|
Description = "登录后携带的令牌(对接 CAS SSO 后使用)"
|
||||||
});
|
});
|
||||||
|
// 加载 XML 注释:控制器/动作的 summary 中文描述展示到 Swagger(csproj GenerateDocumentationFile)
|
||||||
|
var xml = $"{System.AppContext.BaseDirectory}{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||||
|
if (System.IO.File.Exists(xml))
|
||||||
|
{
|
||||||
|
c.IncludeXmlComments(xml, includeControllerXmlComments: true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 任务调度:Quartz 宿主 + Job 模板(对应 Java scheduler 包)
|
// 任务调度:Quartz 宿主 + Job 模板(对应 Java scheduler 包)
|
||||||
@@ -169,9 +221,6 @@ namespace YhCore.WebApi
|
|||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true")
|
|
||||||
app.UseCors("default");
|
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI(c =>
|
app.UseSwaggerUI(c =>
|
||||||
{
|
{
|
||||||
@@ -180,12 +229,34 @@ namespace YhCore.WebApi
|
|||||||
c.SwaggerEndpoint("/swagger/oapi/swagger.json", "OApi");
|
c.SwaggerEndpoint("/swagger/oapi/swagger.json", "OApi");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 静态文件(wwwroot):承载同源调试页面(如 cookie-test.html)。
|
||||||
|
// 放在 UseRouting 之前:静态文件请求直接短路,不进入 SSO/认证管道。
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// 路径矩阵分号段前置处理(;redirectToReferer=<hex> 解析回跳 + 分号段剥离对齐 Spring
|
||||||
|
// removeSemicolonContent)。必须在 UseRouting 之前:路由按含分号路径匹配不到 action 会 404,
|
||||||
|
// 且此阶段改 Request.Path 才来得及影响路由选择。带 ticket 的登录回跳原样放行给认证链路。
|
||||||
|
app.UseMiddleware<PathMatrixSegmentMiddleware>();
|
||||||
|
|
||||||
// 管道顺序:先路由,再认证;CAS 回调必须在认证阶段消费 ticket 并写入 Cookie。
|
// 管道顺序:先路由,再认证;CAS 回调必须在认证阶段消费 ticket 并写入 Cookie。
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
|
// CORS 中间件必须位于 UseRouting 之后、UseEndpoints 之前(endpoint-aware 模式):
|
||||||
|
// 否则带 [EnableCors] 元数据的控制器端点执行时抛 InvalidOperationException
|
||||||
|
// "contains CORS metadata, but a middleware was not found that supports CORS"(曾导致 GetUserInfo code6)。
|
||||||
|
app.UseCors("default");
|
||||||
|
|
||||||
|
// 启用 ASP.NET Core Session(需在 UseEndpoints 之前、且在使用 Session 的组件之前注册)。
|
||||||
|
// 仅 Logout 真正读写;其余请求不触碰 Session,不会额外下发 .AspNetCore.Session Cookie。
|
||||||
|
app.UseSession();
|
||||||
|
|
||||||
app.UseMiddleware<ApiAccessLogMiddleware>(); // 0. 接口访问日志(唯一日志出口)
|
app.UseMiddleware<ApiAccessLogMiddleware>(); // 0. 接口访问日志(唯一日志出口)
|
||||||
app.UseMiddleware<GlobalExceptionMiddleware>(); // 1. 异常兜底(异常摘要并入接口日志)
|
app.UseMiddleware<GlobalExceptionMiddleware>(); // 1. 异常兜底(异常摘要并入接口日志)
|
||||||
app.UseAuthentication(); // 2. CAS ticket 校验 + Cookie 恢复(Yh.Platform.Auth)
|
app.UseAuthentication(); // 2. CAS ticket 校验 + Cookie 恢复(Yh.Platform.Auth)
|
||||||
|
// 2.5 DebugUser 模拟登录(开关 yh:auth:DebugUserEnabled,默认关):本地前端直连部署后端的
|
||||||
|
// 跨域联调身份方案——Cookie 按 host 隔离导致线上登录态无法回流 localhost 页面请求,
|
||||||
|
// 以 DebugUser 请求头承载身份绕开。必须位于认证之后、SSO 保护之前。
|
||||||
|
app.UseMiddleware<DebugUserMiddleware>();
|
||||||
app.UseMiddleware<SsoProtectionMiddleware>(); // 3. SSO 路径保护(未认证 → 302 CAS;认证通过后才有用户身份)
|
app.UseMiddleware<SsoProtectionMiddleware>(); // 3. SSO 路径保护(未认证 → 302 CAS;认证通过后才有用户身份)
|
||||||
app.UseYhUserContext(); // 4. 新框架用户上下文:BoaUserContext + UserContextHolder(LoginUser)
|
app.UseYhUserContext(); // 4. 新框架用户上下文:BoaUserContext + UserContextHolder(LoginUser)
|
||||||
// 5. 应用间签名(oapi 渠道)。必须排在 UseYhUserContext 之后:
|
// 5. 应用间签名(oapi 渠道)。必须排在 UseYhUserContext 之后:
|
||||||
@@ -200,15 +271,16 @@ namespace YhCore.WebApi
|
|||||||
{
|
{
|
||||||
endpoints.MapControllers();
|
endpoints.MapControllers();
|
||||||
|
|
||||||
|
// 免认证健康探针:健康 200 / 不健康 503(容器与负载均衡用)
|
||||||
|
endpoints.MapHealthChecks("/health");
|
||||||
|
|
||||||
// 根路径登录状态页:SSO 开启时未登录会先被中间件 302 到 CAS;登录后在此显示当前用户。
|
// 根路径登录状态页:SSO 开启时未登录会先被中间件 302 到 CAS;登录后在此显示当前用户。
|
||||||
endpoints.MapGet("/", async context =>
|
endpoints.MapGet("/", async context =>
|
||||||
{
|
{
|
||||||
var user = context.User;
|
var user = context.User;
|
||||||
if (user?.Identity?.IsAuthenticated != true)
|
if (user?.Identity?.IsAuthenticated != true)
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = 401;
|
throw new DException("未登录(SSO 未开启或 Cookie 失效)");
|
||||||
await context.Response.WriteAsync("未登录(SSO 未开启或 Cookie 失效)");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
var name = user.Identity?.Name ?? "-";
|
var name = user.Identity?.Name ?? "-";
|
||||||
var id = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "-";
|
var id = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "-";
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<AssemblyName>YhCore.WebApi</AssemblyName>
|
<AssemblyName>YhCore.WebApi</AssemblyName>
|
||||||
<LangVersion>9.0</LangVersion>
|
<LangVersion>9.0</LangVersion>
|
||||||
<Nullable>disable</Nullable>
|
<Nullable>disable</Nullable>
|
||||||
|
<!-- 生成 XML 注释文件供 Swagger 展示接口描述;1591=无 XML 注释的公共成员告警(大量 DTO 属性无注释,忽略) -->
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -33,11 +36,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 平台基础(Abstractions/AppAuth/Auth/OpenApi + 教务 SDK):从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
<!-- 平台基础(Abstractions/AppAuth/Auth/OpenApi + 教务 SDK):从 Nexus nuget-group 引用(原为本地源项目引用) -->
|
||||||
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.2" />
|
||||||
<PackageReference Include="Yh.Platform.AppAuth" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.Platform.AppAuth" Version="1.0.0-preview.2" />
|
||||||
<PackageReference Include="Yh.Platform.Auth" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.Platform.Auth" Version="1.0.0-preview.2" />
|
||||||
<PackageReference Include="Yh.Platform.OpenApi" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.Platform.OpenApi" Version="1.0.0-preview.2" />
|
||||||
<PackageReference Include="Yh.SchClass.OpenApi" Version="1.0.0-preview.1" />
|
<PackageReference Include="Yh.SchClass.OpenApi" Version="1.0.0-preview.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,23 +4,27 @@
|
|||||||
"AutoCreateMissingTables": true
|
"AutoCreateMissingTables": true
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"SqlServer": "Server=localhost;Database=EduCloudDocPrint;User Id=sa;Password=Password01!;MultipleActiveResultSets=true",
|
"SqlServer": "Server=localhost;Database=EduCloudDocPrint2;User Id=sa;Password=Password01!;MultipleActiveResultSets=true",
|
||||||
"Mysql": "Server=localhost;Port=3306;Database=EduCloudDocPrint;User Id=root;Password=123123;CharSet=utf8mb4"
|
"Mysql": "Server=localhost;Port=3306;Database=EduCloudDocPrint;User Id=root;Password=123123;CharSet=utf8mb4"
|
||||||
},
|
},
|
||||||
"Redis": {
|
"Redis": {
|
||||||
"ConnectionString": "localhost:6379,defaultDatabase=0"
|
"ConnectionString": "localhost:6379,defaultDatabase=0"
|
||||||
},
|
},
|
||||||
|
"FILE_PATH": "/data/upload/",
|
||||||
|
"FILE_WEB_PATH": "",
|
||||||
|
"wkhtmltopdf_path": "",
|
||||||
"yh": {
|
"yh": {
|
||||||
"auth": {
|
"auth": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"CookieExpireMinutes": 120,
|
"CookieExpireMinutes": 120,
|
||||||
"cookieName": ".yh.auth"
|
"cookieName": ".yh.auth",
|
||||||
|
"DebugUserEnabled": true
|
||||||
},
|
},
|
||||||
"sso": {
|
"sso": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com/cas",
|
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com",
|
||||||
"AppBaseUrl": "http://localhost:51835",
|
"AppBaseUrl": "http://localhost:51835",
|
||||||
"SignInCallbackPath": "/signin-cas",
|
"SignInCallbackPath": "/api/base/GetUserInfo",
|
||||||
"TicketValidateTimeoutMs": 10000,
|
"TicketValidateTimeoutMs": 10000,
|
||||||
"UrlPattern": "/api/*",
|
"UrlPattern": "/api/*",
|
||||||
"MobiApiPattern": "/mobiapi/*"
|
"MobiApiPattern": "/mobiapi/*"
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"BaseHost": "http://base.yunhaixingtong.com",
|
"BaseHost": "http://base.yunhaixingtong.com",
|
||||||
"BasePath": "/userbase",
|
"BasePath": "/userbase",
|
||||||
"SchClassPath": "/bschclass",
|
"SchClassPath": "/userclass",
|
||||||
"AppKey": "100002",
|
"AppKey": "100002",
|
||||||
"AppSecret": "2314a3b158b24a838b2b9b69fd3db118"
|
"AppSecret": "2314a3b158b24a838b2b9b69fd3db118"
|
||||||
}
|
}
|
||||||
@@ -48,5 +52,11 @@
|
|||||||
"Microsoft.AspNetCore.Authentication": "Information"
|
"Microsoft.AspNetCore.Authentication": "Information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"Cors": {
|
||||||
|
"Origins": [
|
||||||
|
"http://localhost:51835",
|
||||||
|
"http://localhost:8103"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user