Compare commits

..
66 Commits
Author SHA1 Message Date
ops 695ba1530e fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:25 +00:00
ops c2f6f42a3c ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:56 +00:00
wanghu 2b9389fcb8 附件
build / build-push (push) Successful in 1m55s
2026-09-09 20:30:15 +08:00
wanghu 44d3cd0326 去掉小数点
build / build-push (push) Successful in 40s
2026-09-09 19:01:08 +08:00
wanghu 3ff9b48a19 修改地址
build / build-push (push) Successful in 40s
2026-09-09 17:39:30 +08:00
wanghu 20325bed00 签名
build / build-push (push) Successful in 39s
2026-09-09 16:49:55 +08:00
wanghu 0efde94a74 附件
build / build-push (push) Successful in 1m21s
2026-09-09 16:27:17 +08:00
wanghu 4211b72041 Config.FilePath
build / build-push (push) Successful in 1m24s
2026-09-09 16:04:46 +08:00
wanghu e9e1c1722d uploud
build / build-push (push) Successful in 44s
2026-09-09 15:54:50 +08:00
wanghu 583d06f3d0 pdf
build / build-push (push) Successful in 1m55s
2026-09-09 12:07:17 +08:00
wanghu 83182cb538 插件
build / build-push (push) Failing after 58s
2026-09-09 12:02:05 +08:00
wanghu bbcb865055 插件
build / build-push (push) Failing after 21s
2026-09-09 12:01:45 +08:00
wanghu cf8d6da5b5 提交
build / build-push (push) Successful in 28s
2026-09-09 08:57:41 +08:00
wanghu ae1bd4cdb0 注入
build / build-push (push) Successful in 28s
2026-09-08 21:18:29 +08:00
wanghu f1059fd92b 大小写
build / build-push (push) Successful in 33s
2026-09-08 17:42:25 +08:00
wanghu 5785a2c10e 更新大小写
build / build-push (push) Successful in 28s
2026-09-08 17:29:56 +08:00
wanghu 10c791d034 编辑
build / build-push (push) Successful in 28s
2026-09-08 16:55:39 +08:00
wanghu 04e708c4fe Merge branch 'main' of http://gitea.yunhaixingtong.com/docprint/DocprintService
build / build-push (push) Successful in 1m49s
2026-09-08 15:06:44 +08:00
wanghu d658ab5b6a 服务层列表接口返回 PageVO(配合控制器分页改造) 2026-09-08 15:05:20 +08:00
wanghu ae8327807e 1 2026-09-08 15:05:00 +08:00
wanghu 74ba80ddf9 1
build / build-push (push) Failing after 1m23s
2026-09-08 15:00:25 +08:00
wanghu 6ff1d07f31 组
build / build-push (push) Failing after 1m36s
2026-09-08 14:48:44 +08:00
wanghu 7e945d310d 1
build / build-push (push) Successful in 57s
2026-09-07 19:31:54 +08:00
wanghu 3e9be2bd54 11
build / build-push (push) Successful in 31s
2026-09-07 18:13:51 +08:00
wanghu 7112b7ed40 缓存schserid
build / build-push (push) Successful in 1m20s
2026-09-07 16:38:59 +08:00
wanghu f711f1c5e5 s
build / build-push (push) Successful in 1m23s
2026-09-07 15:47:20 +08:00
wanghu 6d5df72d2f code
build / build-push (push) Successful in 31s
2026-09-07 15:02:14 +08:00
wanghu 0225df144c 大小写
build / build-push (push) Successful in 1m29s
2026-09-07 14:39:18 +08:00
wanghu 461847e4ed sw
build / build-push (push) Successful in 28s
2026-09-07 10:15:23 +08:00
wanghu 951ed3f00a FromForm
build / build-push (push) Successful in 27s
2026-09-07 09:29:47 +08:00
wanghu 7471dbe66f GetInitUrl
build / build-push (push) Successful in 29s
2026-09-07 08:54:43 +08:00
wanghu 5972f3b7e1 debug
build / build-push (push) Successful in 27s
2026-09-07 08:19:37 +08:00
wanghu 8abba50e0e 添加deugUser 2026-09-05 16:17:42 +08:00
wanghu 3ac70fb699 1
build / build-push (push) Successful in 1m29s
2026-09-04 14:59:20 +08:00
wanghu 28b3ab80a2 1
build / build-push (push) Successful in 27s
2026-09-04 14:08:50 +08:00
wanghu 18e10a004f 去除ticket
build / build-push (push) Successful in 1m17s
2026-09-04 13:49:11 +08:00
wanghu a73fb4a214 1
build / build-push (push) Successful in 2m3s
2026-09-04 13:36:57 +08:00
wanghu b91fd1b514 1
build / build-push (push) Successful in 1m49s
2026-09-04 13:02:23 +08:00
wanghu e9c4b64240 矩阵分号段前置处理(对齐 Java removeSemicolonContent + notice 回跳语义):UseRouting 前剥离分号段杜绝 404;;redirectToReferer=<hex> 解析并 302 解码地址;带 ticket 登录回跳放行认证链路
build / build-push (push) Successful in 27s
2026-09-04 12:52:04 +08:00
wanghu ff31293df3 service 回跳信息对齐 Java notice 分号矩阵段形态:{path};redirectToReferer=<hex>[?query](弃 query 形态,回调经路径分号段解析;query 旧形态仍兼容读取)
build / build-push (push) Successful in 1m0s
2026-09-04 12:23:02 +08:00
wanghu 6920ed4c0d 回跳白名单放行本机联调地址:完整 http(s) 回跳目标允许 Uri.IsLoopback(localhost/127.0.0.1/::1),本地页面调线上 docprint API 的登录链可回到本地页;外站 host 仍拒绝
build / build-push (push) Successful in 1m30s
2026-09-04 12:04:04 +08:00
wanghu 70ccd1cb8e 回跳目标健壮性:IsAllowedReturnTarget 拒绝含控制字符(0x00-0x1F/0x7F)的畸形地址,防 Uri 解析/302 序列化异常导致 500
build / build-push (push) Successful in 27s
2026-09-04 11:53:57 +08:00
wanghu 986b24180a CAS service 全面直签原访问 URL:弃用 signin-cas 回调端点中继,回页面信息改走 ?redirectToReferer=<hex> 附加
build / build-push (push) Successful in 28s
2026-09-04 11:39:24 +08:00
wanghu a697b85643 登录回跳对齐 Java notice:消费 yschreferer 回前端页、service 直签原访问 URL(含子应用前缀)、回调 302 落回原地址
build / build-push (push) Successful in 28s
2026-09-04 11:06:06 +08:00
wanghu 2ea71d90e1 1
build / build-push (push) Successful in 1m15s
2026-09-04 10:38:41 +08:00
wanghu 9fe45c810e 1
build / build-push (push) Successful in 1m14s
2026-09-04 09:58:52 +08:00
wanghu b7789a1914 1
build / build-push (push) Failing after 18s
2026-09-04 09:55:26 +08:00
wanghu 77da929895 4
build / build-push (push) Successful in 2m7s
2026-09-03 21:57:21 +08:00
wanghu 37f50b7e61 3
build / build-push (push) Successful in 1m55s
2026-09-03 21:41:34 +08:00
wanghu 12a3f240eb 2
build / build-push (push) Successful in 2m15s
2026-09-03 21:33:33 +08:00
wanghu f43a0450ac 1
build / build-push (push) Successful in 27s
2026-09-03 20:55:10 +08:00
wanghu 07b817ccca 1
build / build-push (push) Successful in 27s
2026-09-03 20:14:17 +08:00
wanghu acff097027 2
build / build-push (push) Successful in 30s
2026-09-03 19:31:00 +08:00
wanghu 3c34db6a65 st
build / build-push (push) Successful in 28s
2026-09-03 18:50:24 +08:00
wanghu 4d6c07c34c 1
build / build-push (push) Successful in 31s
2026-09-03 17:32:05 +08:00
wanghu a8e02ab176 1
build / build-push (push) Successful in 33s
2026-09-03 16:57:56 +08:00
wanghu ce5e806adb 1
build / build-push (push) Successful in 35s
2026-09-03 16:51:51 +08:00
wanghu c8632e25c2 ky
build / build-push (push) Successful in 1m59s
2026-09-03 14:43:12 +08:00
wanghu 02a752ad87 kuayu
build / build-push (push) Successful in 31s
2026-09-03 12:06:08 +08:00
wanghu 262f0da9c2 1
build / build-push (push) Successful in 52s
2026-09-03 11:21:52 +08:00
wanghu f21601fc68 控制器加跨域
build / build-push (push) Successful in 41s
2026-09-03 10:37:25 +08:00
wanghu 063ff2ac6a kuayu
build / build-push (push) Successful in 32s
2026-09-03 10:23:03 +08:00
wanghu 8db5870084 1
build / build-push (push) Successful in 27s
2026-09-03 08:39:36 +08:00
wanghu 2728235dc7 dotnet 探针
build / build-push (push) Failing after 14s
2026-09-03 08:29:05 +08:00
wanghu 58392d95d0 1
build / build-push (push) Failing after 12s
2026-09-02 18:30:50 +08:00
wanghu bf5c2e1b4d 1
build / build-push (push) Failing after 16s
2026-09-02 18:16:40 +08:00
56 changed files with 3781 additions and 775 deletions
-324
View File
@@ -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 字段不变),保证前端/移动端零改动切换。
+17 -8
View File
@@ -26,7 +26,10 @@ jobs:
options: --network infra-infra-net
steps:
- 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 本仓库)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -53,14 +56,20 @@ jobs:
- name: 构建 publish 产物(dotnet sdk 容器,复用宿主 nuget 缓存)
run: |
set -e
docker rm -f dotnetbuild 2>/dev/null || true
docker run -d --name dotnetbuild \
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 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 \
-v /opt/docker/projman-dotnet-nuget:/root/.nuget/packages \
-w /build mcr.microsoft.com/dotnet/sdk:5.0 sleep 3600
docker cp "${PWD}/." dotnetbuild:/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 dotnetbuild sh -c '
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
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 "$BUILD_CONTAINER" sh -c '
set -e
root_sln=$(ls /build/*.sln 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"
'
mkdir -p "${PWD}/publish"
docker cp dotnetbuild:/app/. "${PWD}/publish/"
docker rm -f dotnetbuild
docker cp "$BUILD_CONTAINER":/app/. "${PWD}/publish/"
docker rm -f "$BUILD_CONTAINER"
# 入口脚本:runtimeconfig.json 是可执行项目唯一标志(X.runtimeconfig.json <-> X.dll),
# 类库 deps.json 无 runtimeconfig 兄弟文件,天然不会误选
entry=$(ls "${PWD}"/publish/*.runtimeconfig.json | head -1)
-32
View File
@@ -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"]
-53
View File
@@ -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
View File
@@ -8,10 +8,12 @@ FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
# .NET 5 基础镜像基于 Debian 10(已 EOL),apt 源须切 archive.debian.org 才能装包
# 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 \
&& echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-valid-until \
&& apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& apt-get install -y --no-install-recommends curl tzdata wkhtmltopdf fonts-wqy-zenhei \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
+3
View File
@@ -21,5 +21,8 @@ namespace YhCore.Common.Config
/// <summary>文件访问 URL 前缀(对应 Java FILE_WEB_PATH,由 Startup 注入)</summary>
public static string FileWebPath { get; set; }
/// <summary>wkhtmltopdf 可执行文件路径(审批单 PDF 用;空则用 PATH 中的 wkhtmltopdf,由 Startup 注入)</summary>
public static string WkHtmlToPdfPath { get; set; }
}
}
+7 -2
View File
@@ -1,21 +1,26 @@
using Newtonsoft.Json;
using Yh.Platform.Abstractions;
namespace YhCore.Common.Result
{
/// <summary>
/// 统一响应(对应 Java yh-api-std:com.yunhai.platform.apistd.ApiResponse)。
/// 字段与 Java 对齐:code/msg/data(Newtonsoft CamelCase 序列化输出小写)。
/// 信封字段 code/msg/data 以 [JsonProperty] 显式钉死小写——全局序列化已改为
/// DefaultContractResolver 原样输出(data 内容对齐旧项目混合大小写字段),信封不受影响。
/// 注意:Controller 返回值由全局过滤器 GlobalWrapResultFilter 自动包装;
/// 不需要包装的接口用 [NoWrapResult] 特性显式排除(CAS 协议端点、文件下载等)。
/// </summary>
public class ApiResult<T>
{
/// <summary>0=成功,非 0=失败(对齐 Java ErrorCode)</summary>
/// <summary>0=成功,非 0=失败(对齐 Java ErrorCode )</summary>
[JsonProperty("code")]
public int Code { get; set; }
/// <summary>提示消息(对齐 Java msg)</summary>
[JsonProperty("msg")]
public string Msg { get; set; }
[JsonProperty("data")]
public T Data { get; set; }
public static ApiResult<T> Ok(T data) => new() { Code = ErrorCode.Success.Code, Msg = "", Data = data };
+1 -1
View File
@@ -17,7 +17,7 @@
<ItemGroup>
<!-- 平台模型与信封(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>
</Project>
+1 -13
View File
@@ -68,22 +68,10 @@ namespace YhCore.Model.Dto
/// <summary>全部字典类别(typeID, typeName),对齐旧 EnumConfig.Dictionary 的 Description。</summary>
public static readonly (string Id, string Name)[] AllTypes =
{
("DEPT", "部门类别"),
("BXBM", "报修部门"),
("WYFL", "文印分类"),
("YZBM", "印制版面"),
("YZFL", "印制分类"),
("LeaveType", "请假类别"),
("FoodType", "物品类别"),
("Unit", "单位"),
("VenueType", "场馆类别"),
("Vefacilities", "场馆设施"),
("Time", "场馆时间"),
("Repeat", "场馆重复"),
("LevelOrder", "奖次"),
("filetype", "文件类型"),
("doclevel", "公文级别"),
("XKCategory", "选课类型"),
("filetype", "文件类型")
};
/// <summary>GetSysDic 的 Type(int) → typeID;越界返回 null。</summary>
+2 -2
View File
@@ -2,7 +2,7 @@ namespace YhCore.Model.Dto
{
/// <summary>
/// 文件信息(旧 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)。
/// </summary>
public class FileObj
@@ -19,7 +19,7 @@ namespace YhCore.Model.Dto
/// <summary>文件真实名称(含扩展名)。</summary>
public string filename { get; set; }
/// <summary>相对访问路径(如 /UploadFile/docprint/20260101/{guid}.jpg)。</summary>
/// <summary>相对访问路径(如 /upload/docprint/20260101/{guid}.jpg)。</summary>
public string fileurl { get; set; }
/// <summary>文件主机前缀(Config.FileWebPath)。</summary>
+7
View File
@@ -19,6 +19,13 @@ namespace YhCore.Repository
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
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>
Task<PageVO<T>> PageAsync(Expression<Func<T, bool>> predicate, int page, int size,
Expression<Func<T, object>> orderBy = null, bool asc = true);
+15
View File
@@ -43,6 +43,21 @@ namespace YhCore.Repository
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)
{
return await DbSet.AnyAsync(predicate);
+149
View File
@@ -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
}
}
+404
View File
@@ -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;
}
}
}
+2 -2
View File
@@ -125,7 +125,7 @@ namespace YhCore.Service.Impl
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("未找到要更新的分类!");
obj.Name = model.Name;
@@ -141,7 +141,7 @@ namespace YhCore.Service.Impl
{
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("未找到要编辑的分类!");
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");
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("未找到要删除的分类!");
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");
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)
{
item.Enable = 3;
@@ -200,7 +200,7 @@ namespace YhCore.Service.Impl
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)
{
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");
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;
foreach (var id in ssids)
@@ -274,7 +274,7 @@ namespace YhCore.Service.Impl
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)
{
await _zybit.AddAsync(new WY_SchZyBit { SchSerID = schSerId, ziyuanbit = zyBit });
+15 -9
View File
@@ -68,10 +68,9 @@ namespace YhCore.Service.Impl
if (file == null || file.Length == 0)
throw new DException("上传失败!");
if (appType.ToLower() == "noticemsg") appType = "notice";
string dtStr = DateTime.Now.ToString("yyyyMMdd");
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/";
string outFilePath = appType + "/" + dtStr + "/";
if (!Directory.Exists(physicalDir))
Directory.CreateDirectory(physicalDir);
@@ -109,7 +108,7 @@ namespace YhCore.Service.Impl
if (appType.ToLower() == "noticemsg") appType = "notice";
string dtStr = DateTime.Now.ToString("yyyyMMdd");
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/";
string outFilePath = appType + "/" + dtStr + "/";
if (!Directory.Exists(physicalDir))
Directory.CreateDirectory(physicalDir);
@@ -169,7 +168,7 @@ namespace YhCore.Service.Impl
if (appType.ToLower() == "noticemsg") appType = "notice";
string dtStr = DateTime.Now.ToString("yyyyMMdd");
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
string outFilePath = "/UploadFile/" + appType + "/" + dtStr + "/";
string outFilePath = appType + "/" + dtStr + "/";
if (!Directory.Exists(physicalDir))
Directory.CreateDirectory(physicalDir);
@@ -214,22 +213,29 @@ namespace YhCore.Service.Impl
if (string.IsNullOrEmpty(fileUrl))
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('/');
const string prefix = "UploadFile/";
string prefix = Config.FilePath;
if (relative.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
relative = relative.Substring(prefix.Length);
return Path.Combine(StorageRoot(), relative.Replace('/', Path.DirectorySeparatorChar));
}
/// <summary>存储根目录(旧 Server.MapPath("~/UploadFile/");Config.FilePath 缺省时退回进程目录/UploadFile)。</summary>
private static string StorageRoot()
/// <summary>存储根目录(旧 Server.MapPath("~/upload/");Config.FilePath 缺省时退回进程目录/upload)。</summary>
public static string StorageRoot()
{
if (!string.IsNullOrEmpty(Config.FilePath))
return Config.FilePath;
return Path.Combine(AppContext.BaseDirectory, "UploadFile");
// 此分支仅在 FILE_PATH 未配置时执行(此时 Config.FilePath 为 null,不可再参与拼接)
return Path.Combine(AppContext.BaseDirectory, Config.FilePath);
}
}
}
-18
View File
@@ -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;
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("未找到要修改的流程!");
info.flowname = model.flowname;
@@ -198,7 +198,7 @@ namespace YhCore.Service.Impl
{
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("未找到要修改的步骤!");
info.flowid = model.flowid;
@@ -207,7 +207,7 @@ namespace YhCore.Service.Impl
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)
{
await _groupuser.RemoveAsync(itt);
@@ -243,7 +243,7 @@ namespace YhCore.Service.Impl
if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null");
var lids = ids.Split(',').Select(a => int.Parse(a)).ToArray();
var flows = await _flowinfo.FindAsync(a => lids.Contains(a.SerID));
var flows = await _flowinfo.FindTrackedAsync(a => lids.Contains(a.SerID));
foreach (var item in flows)
{
item.enable = 3;
@@ -256,7 +256,7 @@ namespace YhCore.Service.Impl
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
var intIds = ids.Select(d => (int)d).ToArray();
var objlist = await _flowstep.FindAsync(m => intIds.Contains(m.SerID));
var objlist = await _flowstep.FindTrackedAsync(m => intIds.Contains(m.SerID));
foreach (var item in objlist)
{
await _flowstep.RemoveAsync(item);
+54 -38
View File
@@ -7,6 +7,7 @@ using Yh.Platform.Abstractions;
using Yh.SchClass.OpenApi;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
using YhCore.Repository;
using YhCore.Service.Interfaces;
@@ -72,7 +73,7 @@ namespace YhCore.Service.Impl
#region 抄送
/// <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();
@@ -88,6 +89,7 @@ namespace YhCore.Service.Impl
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")
.OrderByDescending(m => m.SerID)
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
@@ -101,7 +103,7 @@ namespace YhCore.Service.Impl
if (!TryGetTea(teaMap, item.UserID, out var tea)) continue;
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd"));
}
return result;
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
}
/// <summary>查看抄送人员详情(旧 SendDetailsList)。</summary>
@@ -138,7 +140,7 @@ namespace YhCore.Service.Impl
{
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)
{
model.SendUserID = senduserid;
@@ -249,7 +251,7 @@ namespace YhCore.Service.Impl
/// </summary>
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("未找到该文印记录!");
info.DocType = model.DocType;
@@ -303,7 +305,7 @@ namespace YhCore.Service.Impl
var idArr = ids.Split(',')
.Where(a => !string.IsNullOrEmpty(a) && decimal.TryParse(a, out _))
.Select(decimal.Parse).ToArray();
var objlist = await _doc.FindAsync(m => idArr.Contains(m.SerID));
var objlist = await _doc.FindTrackedAsync(m => idArr.Contains(m.SerID));
foreach (var item in objlist)
{
await _doc.RemoveAsync(item);
@@ -334,7 +336,7 @@ namespace YhCore.Service.Impl
await _flowtranct.AddAsync(tranct);
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)
{
docModel.DocStatus = model.actionstatus.ToString();
@@ -349,7 +351,7 @@ namespace YhCore.Service.Impl
#region 列表
/// <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();
@@ -365,6 +367,7 @@ namespace YhCore.Service.Impl
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)
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
@@ -376,14 +379,14 @@ namespace YhCore.Service.Impl
TryGetTea(teaMap, item.UserID, out var tea); // 旧为左连接,缺失补空
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm", "YZBM"));
}
return result;
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
}
/// <summary>
/// 全部文印列表(旧 AllList 原生 SQL 四分类:x=待我审(0) y=我已审(1) z=抄送我(2) m=我发起(3),按优先级去重)。
/// 旧 Name 过滤的 SQL 拼接存在运算符优先级缺陷,此处按语义实现:内容/教师名/字典名任一命中。
/// </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);
@@ -423,6 +426,7 @@ namespace YhCore.Service.Impl
|| DictNameContains(dictMap, "YZFL", t.doc.DPType, name));
}
var total = query.Count();
var pageItems = query.OrderByDescending(t => t.doc.SerID).ThenByDescending(t => t.doc.TakeDate)
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
@@ -434,16 +438,17 @@ namespace YhCore.Service.Impl
item.ctype = ctype;
result.Add(item);
}
return result;
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
}
/// <summary>
/// 文印审核列表(旧 ShenheList:固定流程 + 指派 + 自由流程三路并集,HashSet 去重)。
/// 旧实现的 Name 过滤作用在未被使用的查询上(死代码),此处保持 Name 不生效的旧行为。
/// </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 total = docs.Count;
docs = docs.OrderBy(m => m.DocStatus).ThenByDescending(m => m.TakeDate)
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
@@ -455,13 +460,13 @@ namespace YhCore.Service.Impl
TryGetTea(teaMap, item.UserID, out var tea);
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd HH:mm"));
}
return result;
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
}
/// <summary>
/// 审批列表(旧 SPList:待审(DS,zt=false)∪ 我已审(YS,zt=true,排除 DS),_disabled 即 zt)。
/// </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);
@@ -493,6 +498,7 @@ namespace YhCore.Service.Impl
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 total = ordered.Count;
var teaMap = await GetTeaMapAsync(schSerId);
var dictMap = await GetDictMapAsync(schSerId);
@@ -504,11 +510,11 @@ namespace YhCore.Service.Impl
item._disabled = zt; // 我已审的记录不可再编辑(覆盖通用 _disabled 语义,与旧一致)
result.Add(item);
}
return result;
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
}
/// <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);
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))
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)
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
@@ -532,11 +539,11 @@ namespace YhCore.Service.Impl
TryGetTea(teaMap, item.UserID, out var tea);
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>
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();
@@ -552,6 +559,7 @@ namespace YhCore.Service.Impl
if (!string.IsNullOrEmpty(name))
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)
.Skip(pageSize * pageIndex).Take(pageSize).ToList();
@@ -563,7 +571,7 @@ namespace YhCore.Service.Impl
TryGetTea(teaMap, item.UserID, out var tea);
result.Add(await BuildListItemAsync(item, tea, dictMap, "yyyy-MM-dd"));
}
return result;
return PageVO<DocPrintListItem>.Of(result, total, pageIndex, pageSize);
}
/// <summary>
@@ -665,7 +673,7 @@ namespace YhCore.Service.Impl
using var tx = await _db.Database.BeginTransactionAsync();
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.DocStatus != "0")
@@ -766,7 +774,7 @@ namespace YhCore.Service.Impl
/// </summary>
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("未找到该文印记录!");
var claimed = await _flowtranct.AnyAsync(a => a.dataid == model.dataid
@@ -817,7 +825,7 @@ namespace YhCore.Service.Impl
{
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);
if (list == null)
{
@@ -895,7 +903,7 @@ namespace YhCore.Service.Impl
}
/// <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();
if (DateTime.TryParse(starttime, out var date1))
@@ -933,13 +941,16 @@ namespace YhCore.Service.Impl
});
}
return full.OrderByDescending(a => a.EditDate)
.Skip(pageSize * pageIndex).Take(pageSize)
.ToList();
var total = full.Count;
return PageVO<DocDetailStatItem>.Of(
full.OrderByDescending(a => a.EditDate)
.Skip(pageSize * pageIndex).Take(pageSize)
.ToList(),
total, pageIndex, pageSize);
}
/// <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();
if (DateTime.TryParse(starttime, out var date1))
@@ -965,23 +976,27 @@ namespace YhCore.Service.Impl
.OrderByDescending(a => a.num)
.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>
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
&& m.DocStatus == "1" && m.EditDate.Year == year && m.EditDate.Month == month))
.OrderByDescending(a => a.EditDate)
var all = (await _doc.FindAsync(m => m.SchSerID == schSerId
&& m.DocStatus == "1" && m.EditDate.Year == year && m.EditDate.Month == month)).ToList();
var total = all.Count;
var docs = all.OrderByDescending(a => a.EditDate)
.Skip(pageSize * pageIndex).Take(pageSize)
.ToList();
return await BuildStatListAsync(docs, schSerId);
return PageVO<StatListItem>.Of(await BuildStatListAsync(docs, schSerId), total, pageIndex, pageSize);
}
/// <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();
if (DateTime.TryParse(starttime, out var date1))
@@ -992,11 +1007,12 @@ namespace YhCore.Service.Impl
docs = docs.Where(m => m.EditDate <= date2).ToList();
}
var total = docs.Count;
docs = docs.OrderByDescending(a => a.EditDate)
.Skip(pageSize * pageIndex).Take(pageSize)
.ToList();
return await BuildStatListAsync(docs, schSerId);
return PageVO<StatListItem>.Of(await BuildStatListAsync(docs, schSerId), total, pageIndex, pageSize);
}
#endregion
@@ -1240,7 +1256,7 @@ namespace YhCore.Service.Impl
/// <summary>自由流程挂链(旧 ApproveFlow.SetPrimarySteptID:从 stepid 起沿 laststep 链回写 dataid)。</summary>
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)
{
node.dataid = dataid;
@@ -1250,7 +1266,7 @@ namespace YhCore.Service.Impl
var nextSer = stepid;
while (true)
{
var next = await _freeflowinfo.FirstOrDefaultAsync(a => a.laststep == nextSer);
var next = await _freeflowinfo.FirstOrDefaultTrackedAsync(a => a.laststep == nextSer);
if (next == null) break;
next.dataid = dataid;
await _freeflowinfo.SaveChangesAsync();
@@ -1261,7 +1277,7 @@ namespace YhCore.Service.Impl
/// <summary>抄送人落库(旧 ApproveFlow.SetChaoSend:按用户+flowType 全删再插)。</summary>
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)
{
await _senduser.RemoveAsync(old);
@@ -1287,7 +1303,7 @@ namespace YhCore.Service.Impl
var fids = ParseIds(upFile);
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)
{
file.TopicID = topicId;
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,8 @@ namespace YhCore.Service.Interfaces
{
/// <summary>
/// 公共文件服务(移植旧 BestSchool.Service.Common.GetEnclosure/FileSaveEnclosure +
/// BestSchool.Common.UpLoadFile.FileSave/FileSaveImage/FileChunkSave/FileMergeSave)。
/// 存储约定:物理根目录 = Config.FilePath(≈旧 ~/UploadFile/),相对路径仍存 /UploadFile/{AppType}/{yyyyMMdd}/{guid}{ext}
/// BestSchool.Common.upload.FileSave/FileSaveImage/FileChunkSave/FileMergeSave)。
/// 存储约定:物理根目录 = Config.FilePath(≈旧 ~/upload/),相对路径仍存 /upload/{AppType}/{yyyyMMdd}/{guid}{ext}
/// (与旧库数据格式一致,兼容历史记录);host 取 Config.FileWebPath(≈旧 AuthConfig.weboa_url)。
/// 旧 ToOfficeHelper.OfficeConvertToPDF(Office COM 转 PDF)新项目缺失,降级:上传不做转 PDF,
/// 预览时本地无 .pdf 则降级 officeapps.live.com 在线预览。
@@ -21,10 +21,10 @@ namespace YhCore.Service.Interfaces
/// <summary>存储附件信息(旧 FileSaveEnclosure:Common_UpFiles 落库,返回 SerID;文件名非法字符替换为 _)。</summary>
Task<int> SaveEnclosureAsync(FileObj file, string appType);
/// <summary>单文件上传(旧 UpLoadFile.FileSave:扩展名白名单 + 保存 + 返回 FileObj)。</summary>
/// <summary>单文件上传(旧 upload.FileSave:扩展名白名单 + 保存 + 返回 FileObj)。</summary>
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);
/// <summary>保存分片(旧 FileChunkSave:临时目录 {guid}/ 下按索引存块)。</summary>
@@ -33,7 +33,7 @@ namespace YhCore.Service.Interfaces
/// <summary>合并分片(旧 FileMergeSave:按块合并 → 正式目录 + FileObj)。</summary>
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);
}
}
+6 -5
View File
@@ -1,3 +1,4 @@
using System.Threading.Tasks;
using YhCore.Entity;
using YhCore.Model.Vo;
@@ -9,13 +10,13 @@ namespace YhCore.Service.Interfaces
public interface ILogService
{
/// <summary>
/// 分页搜索系统审计日志。
/// 分页搜索系统审计日志(sys_operation_log,按 CreateTime 倒序)。
/// </summary>
/// <param name="schId">学校 ID</param>
/// <param name="keyword">关键字(可为空)</param>
/// <param name="systemScope">true=系统操作日志(systemSearch);false=普通操作日志(search)</param>
/// <param name="schId">学校 ID(多校隔离)</param>
/// <param name="keyword">关键字(可为空;模糊匹配用户名/URI/类方法/参数/异常/操作类型)</param>
/// <param name="systemScope">true=系统操作日志(systemSearch,全量);false=普通操作日志(search,排除查询类)</param>
/// <param name="page">页码,从 0 开始</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.Collections.Generic;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
namespace YhCore.Service.Interfaces
{
@@ -11,7 +12,7 @@ namespace YhCore.Service.Interfaces
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<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<List<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<List<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<List<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>> MyListAsync(string name, string status, string startDate, string endDate, string userId, string schSerId, int pageSize, int pageIndex);
Task<PageVO<DocPrintListItem>> AllListAsync(string name, string userId, string schSerId, int pageSize, int pageIndex);
Task<PageVO<DocPrintListItem>> ShenheListAsync(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<PageVO<DocPrintListItem>> YishenListAsync(string name, string userId, 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<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<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<List<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<DocDetailStatItem>> List2Async(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<PageVO<StatListItem>> List4Async(int year, int month, 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.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
@@ -14,7 +16,9 @@ namespace YhCore.Service.Interfaces
/// 落库 WY_SHRole_SHTea.SHTeaID / WY_SHRole_BSHTea.BSHTeaID 仍为 decimal 用户 id;
/// 3. 签名存文件系统(DES 加密),非数据库表;
/// 4. 旧 ServiceResult 的 Data/OtherData/Page 三段合并为单个 Result 对象(getshenheFlow / getStepInfo / getStepByJE / getdocOftable)。
/// 延后:Excel 导入导出(UpfileExcelSHRole/downMBOfSHRole/downUpfileSHRoleFK/ExceltotalListSHRole/WYRDExceldaochu)、审批单 PDF(DwonWYSQD)。
/// Excel 导入导出(UpfileExcelSHRole/downMBOfSHRole/downUpfileSHRoleFK/ExceltotalListSHRole/WYRDExceldaochu)
/// 与审批单 PDF(DwonWYSQD):下载类接口返回 (byte[] Content, string FileName),
/// 控制器按扩展名包装 File();导入反馈表存进程内 IMemoryCache(单实例)。
/// </summary>
public interface IWYSHRoleService
{
@@ -50,10 +54,10 @@ namespace YhCore.Service.Interfaces
// ---- 用户记录查看和审核 ----
Task<SHRoleShenheFlowResult> GetshenheFlowAsync(string schSerId, decimal docprintId, int flowid);
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<List<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<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>> MyListSHRoleAsync(string name, string status, string startDate, string endDate, int pageSize, int pageIndex, string userId, 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<PageVO<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>> 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);
// ---- 规格表 / 签名 / 退回 / 金额 ----
@@ -64,7 +68,26 @@ namespace YhCore.Service.Interfaces
Task<string> TuiHuiAsync(string userId, string cont, decimal docId);
Task<string> UnTuiHuiAsync(string userId, decimal docId);
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);
// ---- 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);
}
}
}
+102
View File
@@ -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);
}
}
}
+9 -2
View File
@@ -22,9 +22,16 @@
<ItemGroup>
<!-- 业务异常 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 引用(原为本地源项目引用) -->
<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>
</Project>
+1 -1
View File
@@ -31,7 +31,7 @@ namespace YhCore.Tasks.Jobs
try
{
var oldList = await repo.FindAsync(l => l.CreateTime < cutoff);
var oldList = await repo.FindTrackedAsync(l => l.CreateTime < cutoff);
if (oldList.Count == 0)
{
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=&lt;hex(回跳地址)&gt;[?{原 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=/&amp;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=&lt;hex&gt;,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 时 &amp;ticket=),校验时必须还原为登录时的
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
/// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后,
/// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=&lt;hex&gt; 段),与登录 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&amp;ticket=ST-y;按 (?|&amp;)ticket=[^&amp;#]* 正则全局删除,
/// 再清理删除后悬空的连接符,保证 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;
}
}
}
+100
View File
@@ -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 中提取 &lt;cas:user&gt;。
/// </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 提取用户名。
/// 成功响应形如 &lt;cas:serviceResponse&gt;&lt;cas:authenticationSuccess&gt;&lt;cas:user&gt;NAME&lt;/cas:user&gt;...
/// </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&lt;AuthenticationTicket&gt;):
/// 登录票据(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=&lt;hex&gt; 形态的已发出链接)。
/// </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=&lt;urlencoded&gt;])</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=&lt;urlencoded&gt;]——回跳走 query 而非平台旧版
/// ;redirectToReferer=&lt;hex&gt; 矩阵参数(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 YhCore.Common;
using YhCore.Common.UserContext;
@@ -12,6 +14,7 @@ namespace YhCore.WebApi.Controllers.Api
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/log")]
[EnableCors("default")]
public class LogController : ControllerBase
{
private readonly ILogService _logService;
@@ -22,30 +25,30 @@ namespace YhCore.WebApi.Controllers.Api
}
/// <summary>
/// 系统操作日志搜索(对应 Java GET /api/log/systemSearch)。
/// 系统操作日志搜索(对应 Java GET /api/log/systemSearch,全量日志)。
/// </summary>
/// <param name="keyword">关键字(可空)</param>
/// <param name="page">页码,从 0 开始(对应 Spring Pageable.page)</param>
/// <param name="size">每页大小(对应 Spring Pageable.size)</param>
[HttpGet("systemSearch")]
public PageVO<SysOperationLog> SystemSearch(
public Task<PageVO<SysOperationLog>> SystemSearch(
[FromQuery] string keyword,
[FromQuery] int page = 0,
[FromQuery] int size = 20)
{
return _logService.Search(UserContextHolder.GetSchId(), keyword, true, page, size);
return _logService.SearchAsync(UserContextHolder.GetSchId(), keyword, true, page, size);
}
/// <summary>
/// 操作日志搜索(对应 Java GET /api/log/search)。
/// 操作日志搜索(对应 Java GET /api/log/search,排除查询类)。
/// </summary>
[HttpGet("search")]
public PageVO<SysOperationLog> Search(
public Task<PageVO<SysOperationLog>> Search(
[FromQuery] string keyword,
[FromQuery] int page = 0,
[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.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Yh.SchClass.OpenApi;
using Yh.SchClass.OpenApi.Model;
@@ -15,6 +16,7 @@ namespace YhCore.WebApi.Controllers.Api
/// 对应 Java 教务 bschclassmanage 的 /oapi 端点(配置节 yh:schclass:open)。
/// <para>SDK 门面 <see cref="SchClassOpenApi"/> 聚合 10 个客户端,本控制器演示其中 5 个。</para>
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/com/schClass")]
public class SchClassController : ControllerBase
@@ -2,7 +2,9 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Collections.Generic;
using System.Threading.Tasks;
using Yh.Platform.Abstractions;
@@ -15,6 +17,7 @@ namespace YhCore.WebApi.Controllers.Api
/// <summary>
/// 用户基础接口(对应 Java 版 controller/api/UserController,路由 /api/base/)。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/base")]
public class UserController : ControllerBase
@@ -47,6 +50,20 @@ namespace YhCore.WebApi.Controllers.Api
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>
/// 登出(对应 Java GET /api/base/logout:session.invalidate())。
/// </summary>
@@ -1,11 +1,14 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Yh.Platform.Abstractions;
using Yh.SchClass.OpenApi;
using Yh.SchClass.OpenApi.Model;
using YhCore.Common;
using YhCore.Common.Config;
using YhCore.Common.UserContext;
@@ -20,19 +23,31 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// 字典部分 8 接口 + 文件部分 4 接口(FileDown / FilePreview / FileUpload / FileUploadImage)已落地;
/// 旧 [NoAuth] 语义:无需用户上下文,schId 由前端传入或取当前用户。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/base")]
public class CommonController : ControllerBase
{
private readonly IDictionaryService _dict;
private readonly IFileService _file;
private readonly SchClassOpenApi _sch;
public CommonController(IDictionaryService dict, IFileService file)
public CommonController(IDictionaryService dict, IFileService file, SchClassOpenApi sch)
{
_dict = dict;
_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>
[HttpGet("GetSysDic")]
public async Task<List<DicOption>> GetSysDic([FromQuery] int type = 0)
@@ -220,42 +235,58 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// <summary>
/// 公用附件上传(旧 FileUpload,[NoAuth])。
/// chunk 参数 → 分片保存返回 {code:1,type:"chunk",msg};fileName 参数 → 合并分片;否则单文件上传。
/// 分片(chunk/chunkIndex + totalChunks&gt;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}。
/// </summary>
[HttpPost("FileUpload")]
public async Task<object> Upload()
{
string apptype = "docprint"; // 旧 WebHelper.AppType 固定 docprint
string guid = Request.Form["guid"];
string chunk = Request.Form["chunk"];
string fileName = Request.Form["fileName"];
string guid = FormOrQuery("guid");
string chunk = FormOrQuery("chunk"); // 旧契约分片序号
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, chunk, chunkFile);
await _file.SaveChunkAsync(guid, string.IsNullOrEmpty(chunk) ? chunkIndex : chunk, file);
return new { code = 1, type = "chunk", msg = "分片上传成功!" };
}
FileObj url;
string errMsg = "上传失败!";
if (string.IsNullOrEmpty(fileName))
if (!string.IsNullOrEmpty(fileName) && file == null)
{
url = await _file.SaveFileAsync(apptype, Request.Form.Files.FirstOrDefault());
url = await _file.MergeSaveAsync(apptype, guid, fileName); // 合并分片
}
else
{
url = await _file.MergeSaveAsync(apptype, guid, fileName);
url = await _file.SaveFileAsync(apptype, file); // 单文件直传
}
if (url == null)
throw new DException(errMsg);
throw new DException("上传失败!");
// 旧 WebHelper.AppCode == "1102" ? "1002" : AppCode → docprint 固定 1003
int serid = await _file.SaveEnclosureAsync(url, DocPrintConstants.AppCode.ToString());
return new { fileurl = url.fileurl, host = url.host, fileid = serid, name = url.filename, type = url.filetype };
}
/// <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>
[HttpPost("FileUploadImage")]
public async Task<object> UploadImg()
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using YhCore.Common;
using YhCore.Common.UserContext;
@@ -12,6 +13,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// 自由审批流控制器(对应旧 Areas/Common/FreeApproveController,路由 api/common/freeapprove)。
/// 7 接口:自由流程节点查询/保存、审核意见、审核回显、抄送人。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/common/freeapprove")]
public class FreeApproveController : ControllerBase
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Yh.SchClass.OpenApi.Model;
using YhCore.Common;
@@ -15,6 +16,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// 审核流程管理控制器(对应旧 Areas/Common/reviewprocessController,路由 api/common/reviewprocess)。
/// 23 接口:流程/步骤 CRUD、教师查询、流程判断、用户角色。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/common/reviewprocess")]
public class ReviewprocessController : ControllerBase
@@ -47,7 +49,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// <summary>添加审核流程。</summary>
[HttpPost("Create")]
public async Task Create([FromBody] Common_flowinfo model)
public async Task Create([FromForm] Common_flowinfo model)
{
model.schSerID = SchSerID();
await _svc.CreateAsync(model);
@@ -55,7 +57,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// <summary>修改审核流程。</summary>
[HttpPost("Edit")]
public async Task Edit([FromBody] Common_flowinfo model)
public async Task Edit([FromForm] Common_flowinfo model)
{
model.schSerID = SchSerID();
await _svc.EditAsync(model);
@@ -63,12 +65,12 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// <summary>添加步骤流程。</summary>
[HttpPost("StepCreate")]
public Task StepCreate([FromBody] Common_flowstep model)
public Task StepCreate([FromForm] Common_flowstep model)
=> _svc.StepCreateAsync(model);
/// <summary>修改步骤流程。</summary>
[HttpPost("StepEdit")]
public Task StepEdit([FromBody] Common_flowstep model)
public Task StepEdit([FromForm] Common_flowstep model)
=> _svc.StepEditAsync(model);
/// <summary>删除审核流程(ids 逗号分隔)。</summary>
@@ -78,7 +80,7 @@ namespace YhCore.WebApi.Controllers.Api.Common
/// <summary>删除步骤。</summary>
[HttpPost("StepDelete")]
public Task StepDelete([FromBody] decimal[] ids)
public Task StepDelete([FromForm] decimal[] ids)
=> _svc.StepDeleteAsync(ids);
/// <summary>流程绑定的步骤列表。</summary>
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Yh.Platform.Abstractions;
using YhCore.Common;
@@ -13,6 +14,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// 旧类级 [NoAuth]:schId 由前端传入(非当前用户),新项目 SSO 关闭时匿名放行、开启时按配置保护,
/// 语义一致。ExportRecord(Excel 导出,NPOI)延后迁移。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/statistics")]
public class DocPrintStatisticsController : ControllerBase
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using YhCore.Common;
using YhCore.Entity;
@@ -11,6 +12,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// 无业务逻辑:执行一次 Base_Log 查询触发数据库连接建立,防止首请求慢。
/// 旧接口 void 无返回体;新项目由 GlobalWrapResultFilter 统一包装为 {code:0,msg:"",data:null}。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/loading")]
public class LoadingController : ControllerBase
@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Yh.Platform.Abstractions;
using YhCore.Common;
using YhCore.Common.UserContext;
@@ -14,9 +17,12 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
{
/// <summary>
/// 审核角色 + 流程配置控制器(对应旧 Areas/DocPrint/WYSHRoleController,路由 api/docprint/wyshrole)。
/// 45 action 中本批落地 39 个;Excel 导入导出(ExceltotalListSHRole/WYRDExceldaochu/UpfileExcelSHRole/
/// downMBOfSHRole/downUpfileSHRoleFK)与审批单 PDF(DwonWYSQD)依赖 NPOI/PdfHelper,延后迁移。
/// 45 action 全部落地(Excel 导入导出 5 个走 NPOI/EPPlus,审批单 PDF 走 wkhtmltopdf)。
/// POST 动作一律 [FromForm]:前端 api/index.js 固定 x-www-form-urlencoded 提交([FromForm] 会 415);
/// 老契约"表单字段塞 JSON"的动作(WYSHRole/SHTeaTxt/listXxxTxt/data/docdata/docnumdata)
/// 以 [FromForm] string 接收后手动反序列化。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/wyshrole")]
public class WYSHRoleController : ControllerBase
@@ -28,6 +34,14 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
_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 审核角色管理
/// <summary>学校是否启用审核角色(旧 getIsSHRole)。</summary>
@@ -47,18 +61,20 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
public async Task<string> UpdateSHRoleName([FromQuery] decimal serID, [FromQuery] string sHRoleName)
=> await _role.UpdateSHRoleNameAsync(SchSerID(), UserId(), serID, sHRoleName);
/// <summary>新增/编辑角色(旧 EditSHRole,form: WYSHRole=JSON → [FromBody])。</summary>
/// <summary>新增/编辑角色(旧 EditSHRole,form: WYSHRole=JSON 字符串)。</summary>
[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("参数错误!");
return await _role.EditSHRoleAsync(SchSerID(), UserId(), model);
}
/// <summary>编辑成员行(旧 editSHTeaRow,form: SHTeaTxt → [FromBody])。</summary>
/// <summary>编辑成员行(旧 editSHTeaRow,form: SHTeaTxt=JSON 字符串)。</summary>
[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("参数错误!");
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)
=> await _role.DelSHTeaRowAsync(SchSerID(), serID);
/// <summary>批量保存审核人(旧 editSHTea,form: SHRoleID + listSHTeaTxt → [FromBody])。</summary>
/// <summary>批量保存审核人(旧 editSHTea,form: SHRoleID + listSHTeaTxt=JSON 数组字符串)。</summary>
[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("参数错误!");
return await _role.EditSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listSHTea);
var list = ParseFormJson<List<WY_SHRole_SHTea>>(listSHTeaTxt) ?? new List<WY_SHRole_SHTea>();
return await _role.EditSHTeaAsync(SchSerID(), UserId(), SHRoleID, list);
}
/// <summary>批量保存被审核人(旧 editBSHTea,form: SHRoleID + listBSHTeaTxt → [FromBody])。</summary>
/// <summary>批量保存被审核人(旧 editBSHTea,form: SHRoleID + listBSHTeaTxt=JSON 数组字符串)。</summary>
[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("参数错误!");
return await _role.EditBSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listBSHTea);
var list = ParseFormJson<List<WY_SHRole_BSHTea>>(listBSHTeaTxt) ?? new List<WY_SHRole_BSHTea>();
return await _role.EditBSHTeaAsync(SchSerID(), UserId(), SHRoleID, list);
}
/// <summary>删除角色(旧 delSHRole,form: listSHRoleIDTxt → [FromBody])。</summary>
/// <summary>删除角色(旧 delSHRole,form: listSHRoleIDTxt=JSON 数组字符串,兼容逗号分隔)。</summary>
[HttpPost("delSHRole")]
public async Task<string> DelSHRole([FromBody] DelSHRoleDto model)
public async Task<string> DelSHRole([FromForm] string listSHRoleIDTxt)
{
if (model == null) throw new DException("参数错误!");
return await _role.DelSHRoleAsync(SchSerID(), model.listSHRoleID);
var list = ParseFormJson<List<decimal?>>(listSHRoleIDTxt);
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
#region 审核角色流程
/// <summary>审核流程列表(旧 ListFlow)。</summary>
/// <summary>审核流程列表(旧 ListFlow;分页返回 PageVO,Content 内对象字段与旧 Data 对齐)。</summary>
[HttpGet("ListFlow")]
public async Task<PageVO<SHRoleFlowListItem>> ListFlow(
[FromQuery] string name = null,
@@ -108,7 +130,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>新增/编辑流程(旧 EditFlow;schSerID 取当前用户)。</summary>
[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("参数错误!");
model.schSerID = SchSerID();
@@ -117,7 +139,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>新增步骤(旧 StepCreateOfSHRole)。</summary>
[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("参数错误!");
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)
=> await _role.GetDicInfoAsync(SchSerID(), typeID);
/// <summary>编辑文印(审核角色视角;旧 editDocPrint,form 三段 JSON → [FromBody])。</summary>
/// <summary>编辑文印(审核角色视角;旧 editDocPrint,form: isedit + data/docdata/docnumdata 三段 JSON 字符串)。</summary>
[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);
}
@@ -205,9 +238,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
public async Task<List<SHRoleDocPrintListItem>> MyListtoOneSHRole([FromQuery] int serID)
=> await _role.MyListtoOneSHRoleAsync(serID, SchSerID());
/// <summary>我的文印(旧 MyListSHRole)。</summary>
/// <summary>我的文印(旧 MyListSHRole,分页返回 PageVO)。</summary>
[HttpGet("MyListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> MyListSHRole(
public async Task<PageVO<SHRoleDocPrintListItem>> MyListSHRole(
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
@@ -216,9 +249,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
[FromQuery] int pageIndex = 0)
=> await _role.MyListSHRoleAsync(name, status, startDate, endDate, pageSize, pageIndex, UserId(), SchSerID());
/// <summary>全部文印(旧 totalListSHRole)。</summary>
/// <summary>全部文印(旧 totalListSHRole,分页返回 PageVO)。</summary>
[HttpGet("totalListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> TotalListSHRole(
public async Task<PageVO<SHRoleDocPrintListItem>> TotalListSHRole(
[FromQuery] int flowid = 0,
[FromQuery] int dwid = 0,
[FromQuery] string name = null,
@@ -229,9 +262,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
[FromQuery] int pageIndex = 0)
=> await _role.TotalListSHRoleAsync(flowid, dwid, name, stateid, starttime, endtime, pageSize, pageIndex, SchSerID());
/// <summary>抄送列表(旧 SendRdListSHRole)。</summary>
/// <summary>抄送列表(旧 SendRdListSHRole,分页返回 PageVO)。</summary>
[HttpGet("SendRdListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> SendRdListSHRole(
public async Task<PageVO<SHRoleDocPrintListItem>> SendRdListSHRole(
[FromQuery] int flowid = 0,
[FromQuery] int dwid = 0,
[FromQuery] string name = null,
@@ -242,9 +275,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
[FromQuery] int pageIndex = 0)
=> await _role.SendRdListSHRoleAsync(flowid, dwid, name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>审批列表(旧 SPListSHRole)。</summary>
/// <summary>审批列表(旧 SPListSHRole,分页返回 PageVO)。</summary>
[HttpGet("SPListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> SPListSHRole(
public async Task<PageVO<SHRoleDocPrintListItem>> SPListSHRole(
[FromQuery] int flowid = 0,
[FromQuery] int dwid = 0,
[FromQuery] string name = null,
@@ -257,23 +290,89 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>审核确认(旧 shenheokSHRole)。</summary>
[HttpPost("shenheokSHRole")]
public async Task<string> ShenheOkSHRole([FromBody] FlowTranctDto model)
public async Task<string> ShenheOkSHRole([FromForm] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
return await _role.ShenheOkSHRoleAsync(model);
}
// 延后:ExceltotalListSHRole / WYRDExceldaochu / UpfileExcelSHRole / downMBOfSHRole / downUpfileSHRoleFK(Excel,NPOI)
// 延后:DwonWYSQD(审批单 PDF,PdfHelper + Server.MapPath)
#region Excel 导入导出 / 审批单 PDF
/// <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
#region 签名 / 退回 / 金额重提
/// <summary>保存签名(旧 SaveSignature,form: pic → [FromBody])。</summary>
/// <summary>保存签名(旧 SaveSignature,form: pic → [FromForm])。</summary>
[HttpPost("SaveSignature")]
public async Task<string> SaveSignature([FromBody] SaveSignatureDto model)
public async Task<string> SaveSignature([FromForm] SaveSignatureDto model)
{
if (model == null) throw new DException("参数错误!");
return await _role.SaveSignatureAsync(SchSerID(), UserId(), model.pic);
@@ -287,9 +386,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
[HttpGet("DelSignature")]
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")]
public async Task<string> TuiHui([FromBody] TuiHuiDto model)
public async Task<string> TuiHui([FromForm] TuiHuiDto model)
{
if (model == null) throw new DException("参数错误!");
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)
=> await _role.UnTuiHuiAsync(UserId(), docID);
/// <summary>金额重提(旧 regetJE,form: DocID 逗号分隔 + type → [FromBody])。</summary>
/// <summary>金额重提(旧 regetJE,form: DocID 逗号分隔 + type → [FromForm])。</summary>
[HttpPost("regetJE")]
public async Task<string> RegetJE([FromBody] RegetJEDto model)
public async Task<string> RegetJE([FromForm] RegetJEDto model)
{
if (model == null) throw new DException("参数错误!");
var ids = (model.DocID ?? "").Split(',')
@@ -311,9 +410,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
return await _role.RegetJEAsync(SchSerID(), UserId(), ids, model.type);
}
/// <summary>重提记录(旧 getRegetJERd)。</summary>
/// <summary>重提记录(旧 getRegetJERd,分页返回 PageVO)。</summary>
[HttpGet("getRegetJERd")]
public async Task<List<SHRoleRegetJERdItem>> GetRegetJERd(
public async Task<PageVO<SHRoleRegetJERdItem>> GetRegetJERd(
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _role.GetRegetJERdAsync(SchSerID(), pageSize, pageIndex);
@@ -1,12 +1,14 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Yh.Platform.Abstractions;
using YhCore.Common;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api.DocPrint
@@ -16,6 +18,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// 第一批:分类 / 版面 / 字典 14 接口;第三批 D1:文印 CRUD / 列表 / 审核 / 统计 / 指派 24 接口;
/// Excel 导出 4 接口延后批次迁移。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/wy_list")]
public class WY_listController : ControllerBase
@@ -35,7 +38,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>添加文印分类。</summary>
[HttpPost("AddDic")]
public async Task AddDic([FromBody] Common_Dictionary model)
public async Task AddDic([FromForm] Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
model.Schserid = SchSerID();
@@ -49,7 +52,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>添加印制版面。</summary>
[HttpPost("AddYZBM")]
public async Task AddYZBM([FromBody] Common_Dictionary model)
public async Task AddYZBM([FromForm] Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
model.Schserid = SchSerID();
@@ -78,20 +81,27 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
[FromQuery] bool showAll = false)
=> 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")]
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();
await _dict.AddDicAsync(model);
}
/// <summary>编辑公共类别(旧调 GetSysDict.EditDic)。</summary>
/// <summary>编辑公共类别(旧调 GetSysDict.EditDic)。同 AddDictionaryType:前端表单编码提交,[FromForm] 绑定。</summary>
[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();
await _dict.EditDicAsync(model);
}
@@ -140,9 +150,9 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
#region 文印核心(第三批 D1:抄送 / 附件 / CRUD / 列表 / 审核 / 统计 / 指派)
/// <summary>抄送列表(旧 SendRdList)。</summary>
/// <summary>抄送列表(旧 SendRdList,分页返回 PageVO)。</summary>
[HttpGet("SendRdList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> SendRdList(
public async Task<PageVO<DocPrintListItem>> SendRdList(
[FromQuery] string name = null,
[FromQuery] string startDate = null,
[FromQuery] string endDate = null,
@@ -167,7 +177,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>添加文印(旧 Create;短信/待办推送已降级为纯落库)。</summary>
[HttpPost("Create")]
public async Task<int> Create([FromBody] WYDocPrintSaveDto model)
public async Task<int> Create([FromForm] WYDocPrintSaveDto model)
{
if (model == null) throw new DException("参数错误!");
model.UserID = UserId();
@@ -177,7 +187,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>修改文印(旧 Edit)。</summary>
[HttpPost("Edit")]
public async Task<int> Edit([FromBody] WYDocPrintSaveDto model)
public async Task<int> Edit([FromForm] WYDocPrintSaveDto model)
{
if (model == null) throw new DException("参数错误!");
model.UserID = UserId();
@@ -187,16 +197,16 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>撤销(旧 Revoke)。</summary>
[HttpPost("Revoke")]
public async Task<string> Revoke([FromBody] FlowTranctDto model)
public async Task<string> Revoke([FromForm] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
model.actionuserid = UserContextHolder.Current?.Id;
return await _wy.RevokeAsync(model);
}
/// <summary>我的文印列表(旧 MyList)。</summary>
/// <summary>我的文印列表(旧 MyList,分页返回 PageVO)。</summary>
[HttpGet("MyList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> MyList(
public async Task<PageVO<DocPrintListItem>> MyList(
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
@@ -205,24 +215,24 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
[FromQuery] int pageIndex = 0)
=> await _wy.MyListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>全部文印列表(旧 AllList 四分类,返回 ctype)。</summary>
/// <summary>全部文印列表(旧 AllList 四分类,返回 ctype;分页返回 PageVO)。</summary>
[HttpGet("AllList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> AllList(
public async Task<PageVO<DocPrintListItem>> AllList(
[FromQuery] string name = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.AllListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>文印审核列表(旧 ShenheList;旧 Name 过滤为死代码,保持不生效)。</summary>
/// <summary>文印审核列表(旧 ShenheList;旧 Name 过滤为死代码,保持不生效;分页返回 PageVO)。</summary>
[HttpGet("ShenheList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> ShenheList(
public async Task<PageVO<DocPrintListItem>> ShenheList(
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.ShenheListAsync(UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>审批列表(旧 SPList)。</summary>
/// <summary>审批列表(旧 SPList,分页返回 PageVO)。</summary>
[HttpGet("SPList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> SPList(
public async Task<PageVO<DocPrintListItem>> SPList(
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
@@ -231,17 +241,17 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
[FromQuery] int pageIndex = 0)
=> await _wy.SPListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>文印已审列表(旧 YishenList)。</summary>
/// <summary>文印已审列表(旧 YishenList,分页返回 PageVO)。</summary>
[HttpGet("YishenList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> YishenList(
public async Task<PageVO<DocPrintListItem>> YishenList(
[FromQuery] string name = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.YishenListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>全部文印列表(旧 totalList)。</summary>
/// <summary>全部文印列表(旧 totalList,分页返回 PageVO)。</summary>
[HttpGet("totalList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> TotalList(
public async Task<PageVO<DocPrintListItem>> TotalList(
[FromQuery] string name = null,
[FromQuery] string stateid = null,
[FromQuery] string starttime = null,
@@ -257,7 +267,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>审核确认(旧 shenheok)。</summary>
[HttpPost("shenheok")]
public async Task<string> ShenheOk([FromBody] FlowTranctDto model)
public async Task<string> ShenheOk([FromForm] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
@@ -266,7 +276,7 @@ namespace YhCore.WebApi.Controllers.Api.DocPrint
/// <summary>领取确认(旧 AffirmApplyforOk)。</summary>
[HttpPost("AffirmApplyforOk")]
public async Task<string> AffirmApplyforOk([FromBody] FlowTranctDto model)
public async Task<string> AffirmApplyforOk([FromForm] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
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)
=> await _wy.List1Async(starttime, endtime, SchSerID());
/// <summary>文印详情统计列表(旧 List2)。</summary>
/// <summary>文印详情统计列表(旧 List2,分页返回 PageVO)。</summary>
[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] int pageSize = 10, [FromQuery] int pageIndex = 0)
=> await _wy.List2Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
/// <summary>文印数量按用户排名(旧 List3)。</summary>
/// <summary>文印数量按用户排名(旧 List3,分页返回 PageVO)。</summary>
[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] int pageSize = 10, [FromQuery] int pageIndex = 0)
=> await _wy.List3Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
/// <summary>按月份统计明细(旧 List4)。</summary>
/// <summary>按月份统计明细(旧 List4,分页返回 PageVO)。</summary>
[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 pageSize = 10, [FromQuery] int pageIndex = 0)
=> await _wy.List4Async(year, month, SchSerID(), pageSize, pageIndex);
/// <summary>按用户统计明细(旧 List5)。</summary>
/// <summary>按用户统计明细(旧 List5,分页返回 PageVO)。</summary>
[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] int pageSize = 10, [FromQuery] int pageIndex = 0)
=> 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.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -16,6 +17,7 @@ namespace YhCore.WebApi.Controllers.Api
/// 登录用户接口(对应 Java 版 controller/api/LoginUserController,路由 /api/GetUserMod 等)。
/// 注意:Java 版路由以大写开头(GetUserMod / GetUserInfo / Logout),此处保持 URL 大小写一致。
/// </summary>
[EnableCors("default")]
[ApiController]
[Route(ApiRoutes.ApiPath)]
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);
}
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());
}
@@ -12,7 +12,7 @@ namespace YhCore.WebApi.Middleware
/// 应用间签名校验中间件(对应 Java yh-app-auth-spring-boot-starter,仅对 /oapi 渠道生效)。
/// 基于新框架 <see cref="AppAuthVerifier"/> 完成请求级签名校验(X-App-Key / X-Timestamp / X-Signature
/// 三件套 + 时间戳过期 + 防重放);Enabled=false(yh:app-auth)时跳过。
/// 签名协议见 yh-platform-net contract/sign-protocol.md。
/// 签名协议见 yh-platform-net contract/sign-protocol.md。
/// </summary>
public class AppAuthMiddleware
{
@@ -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=&lt;loginName&gt; 头)。
///
/// 用途:解决「本地前端(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.Threading.Tasks;
using log4net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@@ -18,6 +19,8 @@ namespace YhCore.WebApi.Middleware
/// </summary>
public class GlobalExceptionMiddleware
{
private static readonly ILog Log = LogManager.GetLogger(typeof(GlobalExceptionMiddleware));
private readonly RequestDelegate _next;
public GlobalExceptionMiddleware(RequestDelegate next)
@@ -53,9 +56,13 @@ namespace YhCore.WebApi.Middleware
timeNow = TimeNow()
});
}
catch (Exception)
catch (Exception ex)
{
// 未捕获异常 → 平台兜底: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
{
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=&lt;hex&gt;:提取 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.Http;
using Microsoft.Extensions.Configuration;
using System;
using System.Threading.Tasks;
using Yh.Platform.Abstractions;
using Yh.Platform.Auth;
using YhCore.WebApi.Auth;
namespace YhCore.WebApi.Middleware
{
@@ -12,11 +14,15 @@ namespace YhCore.WebApi.Middleware
/// 规则(对齐 yh:sso 配置节):
/// - yh:sso:enabled=false(默认):所有路径匿名放行,不影响开发联调;
/// - yh:sso:enabled=true:匹配 /api/* / /mobiapi/* / 根路径(/, 浏览器 SSO 入口) 且未认证的请求
/// → Challenge → Yh.Platform.Auth CasAuthenticationHandler 302 重定向到 CAS 登录页;
/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /signin-cas)未认证时同样 Challenge:
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
/// → Challenge → 本地化 CasAuthenticationHandler(Auth/Cas/)302 重定向到 CAS 登录页;
/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /api/base/GetUserInfo;仅用于识别"回调直访防呆"
/// <see cref="IsCasCallback"/> 与旧 URL 兼容。service=原 URL 形态下回调落在任意受保护 API 路径 +
/// ticket;历史 URL/缓存中的旧中继形态 {path}?returnUrl=…[&amp;ticket] 仍可校验)未认证时同样 Challenge:
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
/// - 受保护 API 的 AJAX/XHR 请求:复用电框架 Challenge 生成 CAS 登录 URL 后改写为 200 + JSON
/// (code:302 + urlToRedirectTo),避免 XHR 跟随跨域 302 被 CORS 拦截;文档导航仍走 302;
/// - Swagger 路径始终放行,方便本地接口调试;
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
@@ -37,62 +43,219 @@ namespace YhCore.WebApi.Middleware
public async Task InvokeAsync(HttpContext context)
{
var sso = _configuration.GetSection("yh:sso");
var isSwaggerPath = context.Request.Path.StartsWithSegments("/swagger");
var isOapiPath = context.Request.Path.StartsWithSegments("/oapi");
var isRootEntry = context.Request.Path == "/";
var isCasCallback = context.Request.Path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas");
var path = context.Request.Path;
// 已认证(含 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)
{
// 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location),
// 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC 端点匹配,/signin-cas 无对应 action,
// 会返回 404 覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。
if (isCasCallback && context.Response.StatusCode == 302 &&
!string.IsNullOrEmpty(context.Response.Headers["Location"]))
var hasTicket = context.Request.Query.ContainsKey("ticket");
if (IsRedirect(context.Response) && (IsCasCallback(path, sso) || hasTicket))
{
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);
return;
}
// oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
if (isOapiPath)
// 2. oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
if (path.StartsWithSegments("/oapi"))
{
await _next(context);
return;
}
var enabled = sso["enabled"] != null
? bool.TryParse(sso["enabled"], out var en) && en
: _configuration.GetValue("Yh:Sso:Enabled", false);
if (enabled && !isSwaggerPath &&
(isRootEntry || isCasCallback || IsProtectedPath(context.Request.Path.Value, sso)))
// 3. Swagger 路径始终放行,方便本地接口调试。
if (path.StartsWithSegments("/swagger"))
{
// 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期,
// 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
if (context.Request.Query.ContainsKey("ticket"))
{
context.Response.StatusCode = 401;
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.WriteAsync(
"<!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\">" +
"<h2>登录票据校验失败或已过期</h2>" +
"<p>CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。</p>" +
"<p><a href=\"/\">重新登录</a></p></body></html>");
return;
}
// 未认证 → Challenge → 302 重定向 CAS 登录页(ticket 校验与回跳由新框架 CasAuthenticationHandler 处理)
await context.ChallengeAsync(YhAuthDefaults.CasScheme);
await _next(context);
return;
}
await _next(context);
// 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 会立刻签发
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
if (context.Request.Query.ContainsKey("ticket"))
{
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=&lt;hex&gt; 插入 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=&lt;hex&gt;
// 插入 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";
return context.Response.WriteAsync(
"<!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\">" +
"<h2>登录票据校验失败或已过期</h2>" +
"<p>CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。</p>" +
"<p><a href=\"/\">重新登录</a></p></body></html>");
}
/// <summary>
/// 是否为 CAS 回调路径。先剥离路径矩阵参数(;xxx=yyy)再比较:
/// StartsWithSegments 按段比较,要求匹配后下一个字符是 '/' 或结束,回调若带
/// ;redirectToReferer=&lt;hex&gt; 后缀(旧形态)会失配,导致"已认证 + 已 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、&lt;a&gt;)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>
@@ -124,5 +287,59 @@ namespace YhCore.WebApi.Middleware
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;
}
}
}
}
+93 -21
View File
@@ -28,8 +28,11 @@ using YhCore.Common.Config;
using YhCore.Common.Exceptions;
using YhCore.Common.UserContext;
using Yh.Platform.Abstractions;
using Yh.Platform.AppAuth;
using Yh.Platform.OpenApi;
using Yh.SchClass.OpenApi;
using Yh.Platform.Auth;
using YhCore.WebApi.Auth;
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 =>
{
o.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss";
o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
o.SerializerSettings.DateFormatString = "yyyy/MM/dd HH:mm:ss";
o.SerializerSettings.ContractResolver = new DefaultContractResolver();
})
// 对应 Java Hibernate Validator
.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Startup>());
@@ -89,16 +95,24 @@ namespace YhCore.WebApi
// 对象映射:对应 Java ModelMapper
services.AddAutoMapper(typeof(Startup));
// 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true");
// AllowAnyOrigin 与 AllowCredentials 不能同时生效,接入 CAS 后按 yh.sso.allow-origins 收紧
// 跨域策略:允许任意来源 + 允许凭据(等价 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true"))。
// SetIsOriginAllowed(_ => true) 会回显任意请求 Origin(而非下发 *),
// 并附 Access-Control-Allow-Credentials: true,使任意来源的前端都能以 credentials:'include' 跨域携带 CAS 登录 Cookie。
// 注意:Cors 规范禁止 * 与凭据同用,故不能直接用 AllowAnyOrigin。
services.AddCors(options => options.AddPolicy("default", builder => builder
.AllowAnyOrigin()
.SetIsOriginAllowed(_ => true)
.AllowAnyMethod()
.AllowAnyHeader()));
.AllowAnyHeader()
.AllowCredentials()));
// 数据层:默认 SQL Server 主库,DatabaseProvider=MySql 可切换
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 自动装配)
services.AddRepositories();
@@ -108,8 +122,26 @@ namespace YhCore.WebApi
// Redis 缓存(RedisUtil 单例,连接串取 Redis:ConnectionString)
services.AddRedisCache(Configuration);
// 框架期占位实现;业务实现就绪后在 Extensions 中统一替换注册
services.AddScoped<ILogService, LogServiceStub>();
// 进程内内存缓存:WYSHRole Excel 导入的反馈表(updateSHRole_{userId},单实例;
// 多实例部署需改 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) =====
services.AddScoped<IDictionaryService, DictionaryService>();
@@ -122,10 +154,14 @@ namespace YhCore.WebApi
services.AddScoped<IFileService, FileService>();
// ===== 基础平台(yh-platform-net 新框架,对应 Java yh-platform) =====
// 聚合注册:/oapi 客户端(Yh:Open)+ 应用签名鉴权(Yh:AppAuth)+ Cookie/CAS 认证(Yh:Sso/Yh:Auth)。
// AddYhAuth 内部含 AddYhOpenApi;AddYhPlatform = AddYhAppAuth + AddYhAuth。
// OpenApi 客户端(Yh:Open)+ 应用签名鉴权(Yh:AppAuth)+ 本地化 CAS 认证(Yh:Sso/Yh:Auth)。
// 认证部分本地化(Auth/Cas/):service 构造为
// "{AppBaseUrl}{SignInCallbackPath}[?returnUrl=<urlencoded 回跳地址>]"(query 形态,兼容旧矩阵参数链接),
// 其余(Cookie 会话票据 / ticket 校验 / 用户上下文)与 Yh.Platform.Auth.AddYhAuth 一致。
// 静态门面 YschBaseOpenApi 由宿主启动时自动装配(YschFacadeWiring: IHostedService)。
services.AddYhPlatform(Configuration);
services.AddYhAppAuth(Configuration);
services.AddYhOpenApi(Configuration);
services.AddYhCasAuth(Configuration);
// 教务(SchClass)开放接口客户端 SDK(Yh.SchClass.OpenApi.dll,配置节 yh:schclass:open)
services.AddSchClassOpenApi(Configuration);
@@ -139,9 +175,19 @@ namespace YhCore.WebApi
.Bind(Configuration.GetSection("Swagger"));
services.AddOptions<YhCore.Common.Config.FileConfig>()
.Bind(Configuration);
// 静态门面(对齐 Java Config):文件路径由配置注入
YhCore.Common.Config.Config.FilePath = Configuration["FILE_PATH"];
YhCore.Common.Config.Config.FileWebPath = Configuration["FILE_WEB_PATH"];
// 静态门面(对齐 Java Config):文件路径由配置注入。
// FILE_PATH 必须以 / 结尾(旧约定:FileService 多处 FilePath + "{appType}/..." 直接拼接,缺斜杠会拼出坏路径);
// 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)
services.AddSwaggerGen(c =>
@@ -156,6 +202,12 @@ namespace YhCore.WebApi
In = ParameterLocation.Header,
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 包)
@@ -169,9 +221,6 @@ namespace YhCore.WebApi
app.UseDeveloperExceptionPage();
}
// 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true")
app.UseCors("default");
app.UseSwagger();
app.UseSwaggerUI(c =>
{
@@ -180,12 +229,34 @@ namespace YhCore.WebApi
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。
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<GlobalExceptionMiddleware>(); // 1. 异常兜底(异常摘要并入接口日志)
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.UseYhUserContext(); // 4. 新框架用户上下文:BoaUserContext + UserContextHolder(LoginUser)
// 5. 应用间签名(oapi 渠道)。必须排在 UseYhUserContext 之后:
@@ -200,15 +271,16 @@ namespace YhCore.WebApi
{
endpoints.MapControllers();
// 免认证健康探针:健康 200 / 不健康 503(容器与负载均衡用)
endpoints.MapHealthChecks("/health");
// 根路径登录状态页:SSO 开启时未登录会先被中间件 302 到 CAS;登录后在此显示当前用户。
endpoints.MapGet("/", async context =>
{
var user = context.User;
if (user?.Identity?.IsAuthenticated != true)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("未登录(SSO 未开启或 Cookie 失效)");
return;
throw new DException("未登录(SSO 未开启或 Cookie 失效)");
}
var name = user.Identity?.Name ?? "-";
var id = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "-";
+8 -5
View File
@@ -6,6 +6,9 @@
<AssemblyName>YhCore.WebApi</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<!-- 生成 XML 注释文件供 Swagger 展示接口描述;1591=无 XML 注释的公共成员告警(大量 DTO 属性无注释,忽略) -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -33,11 +36,11 @@
<ItemGroup>
<!-- 平台基础(Abstractions/AppAuth/Auth/OpenApi + 教务 SDK):从 Nexus nuget-group 引用(原为本地源项目引用) -->
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.1" />
<PackageReference Include="Yh.Platform.AppAuth" Version="1.0.0-preview.1" />
<PackageReference Include="Yh.Platform.Auth" Version="1.0.0-preview.1" />
<PackageReference Include="Yh.Platform.OpenApi" Version="1.0.0-preview.1" />
<PackageReference Include="Yh.SchClass.OpenApi" 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.2" />
<PackageReference Include="Yh.Platform.Auth" Version="1.0.0-preview.2" />
<PackageReference Include="Yh.Platform.OpenApi" Version="1.0.0-preview.2" />
<PackageReference Include="Yh.SchClass.OpenApi" Version="1.0.0-preview.2" />
</ItemGroup>
</Project>
+16 -6
View File
@@ -4,23 +4,27 @@
"AutoCreateMissingTables": true
},
"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"
},
"Redis": {
"ConnectionString": "localhost:6379,defaultDatabase=0"
},
"FILE_PATH": "/data/upload/",
"FILE_WEB_PATH": "",
"wkhtmltopdf_path": "",
"yh": {
"auth": {
"Enabled": true,
"CookieExpireMinutes": 120,
"cookieName": ".yh.auth"
"cookieName": ".yh.auth",
"DebugUserEnabled": true
},
"sso": {
"Enabled": true,
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com/cas",
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com",
"AppBaseUrl": "http://localhost:51835",
"SignInCallbackPath": "/signin-cas",
"SignInCallbackPath": "/api/base/GetUserInfo",
"TicketValidateTimeoutMs": 10000,
"UrlPattern": "/api/*",
"MobiApiPattern": "/mobiapi/*"
@@ -35,7 +39,7 @@
"Enabled": true,
"BaseHost": "http://base.yunhaixingtong.com",
"BasePath": "/userbase",
"SchClassPath": "/bschclass",
"SchClassPath": "/userclass",
"AppKey": "100002",
"AppSecret": "2314a3b158b24a838b2b9b69fd3db118"
}
@@ -48,5 +52,11 @@
"Microsoft.AspNetCore.Authentication": "Information"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Cors": {
"Origins": [
"http://localhost:51835",
"http://localhost:8103"
]
}
}