Compare commits

..
97 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
wanghu 5fc313b707 Merge branch 'main' of http://gitea.yunhaixingtong.com/docprint/DocprintService
build / build-push (push) Failing after 13s
2026-09-02 18:11:32 +08:00
wanghu 20bdcaf63e LogService 2026-09-02 18:11:19 +08:00
wanghu 12fc0c5ce8 定时任务修改 2026-09-02 16:04:07 +08:00
wanghu 2650b87cdc 1 2026-09-02 14:52:40 +08:00
wanghu a3d97d0988 YHDbContext 2026-09-02 14:50:34 +08:00
admin 151e62be68 ci: init Dockerfile [skip ci] 2026-09-02 14:14:03 +08:00
admin ca1e125896 ci: update build workflow [skip ci] 2026-09-02 14:14:02 +08:00
chenxing fc9d53ed10 删除 docker/Dockerfile 2026-09-02 14:13:57 +08:00
chenxing e0ae71ab81 更新 docker/Dockerfile 2026-09-02 14:06:33 +08:00
wanghu a238bd8587 git 2026-09-02 12:18:30 +08:00
wanghu 13df04c6af Merge branch 'main' of http://gitea.yunhaixingtong.com/docprint/DocprintService 2026-09-02 12:16:35 +08:00
wanghu 65bf4460d1 1 2026-09-02 12:16:28 +08:00
admin ceb1dc5a83 ci: init Dockerfile [skip ci] 2026-09-02 12:15:21 +08:00
admin ffedc2acd6 ci: update build workflow [skip ci] 2026-09-02 12:15:21 +08:00
wanghu c6d9619990 1 2026-09-02 12:13:36 +08:00
wanghu f34e69480f 0 2026-09-02 12:04:40 +08:00
wanghu c55ef5a1f5 Merge branch 'main' of http://gitea.yunhaixingtong.com/docprint/DocprintService 2026-09-02 12:04:20 +08:00
wanghu be6dcf259c 1 2026-09-02 12:03:55 +08:00
admin ac24d318a2 ci: init Dockerfile [skip ci] 2026-09-02 12:03:02 +08:00
admin 3d372d5246 ci: update build workflow [skip ci] 2026-09-02 12:03:01 +08:00
wanghu 4433b081bd 1 2026-09-02 12:00:59 +08:00
wanghu 27a6fc078f config 2026-09-02 11:51:11 +08:00
wanghu f19f4e220b NuGet bag 2026-09-02 11:48:47 +08:00
wanghu 117b58359b git 2026-09-02 10:52:13 +08:00
admin 9938260704 fix: buster EOL apt 源 404,切 archive.debian.org 修复 docker build 2026-09-02 10:28:33 +08:00
wanghu ce436cbb32 bag 2026-09-02 10:23:24 +08:00
admin 5a414b14d2 ci: init Dockerfile [skip ci] 2026-09-02 10:06:48 +08:00
admin a19eb89ec3 ci: update build workflow [skip ci] 2026-09-02 10:06:48 +08:00
admin 352da81300 ci: init build workflow [skip ci] 2026-09-02 09:46:34 +08:00
admin abbe322cad init: readme guide [skip ci] 2026-09-02 09:46:27 +08:00
admin 87a4eed341 Initial commit 2026-09-02 09:46:26 +08:00
131 changed files with 17003 additions and 62 deletions
+9
View File
@@ -0,0 +1,9 @@
**/bin/
**/obj/
**/target/
**/.vs/
**/.idea/
**/logs/
*.user
*.suo
.DS_Store
+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)
+47
View File
@@ -0,0 +1,47 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
artifacts/
# Visual Studio cache/options
.vs/
.idea/
*.DotSettings.user
# NuGet
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
*.nuget.props
*.nuget.targets
# Rider
*.sln.iml
# OS
.DS_Store
Thumbs.db
# 业务侧特有
# 临时验证目录(Mock CAS 脚本、curl 日志、清理清单等)
Temp/
# 项目记忆与本地技能(含会话数据,不入库)
.workbuddy/
# 日志
*.log
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<!-- 统一从 Nexus 拉包:nuget-group 聚合了内部 hosted + nuget.org 代理 -->
<add key="nexus-nuget-group" value="http://nexus.yunhaixingtong.com/repository/nuget-group/index.json" protocolVersion="3" allowInsecureConnections="true" />
</packageSources>
</configuration>
-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 优先,不会被覆盖。
+129
View File
@@ -0,0 +1,129 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YhCore.Common", "src\YhCore.Common\YhCore.Common.csproj", "{84233313-0D7F-4266-9FBF-A84F5EE2524C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YhCore.Entity", "src\YhCore.Entity\YhCore.Entity.csproj", "{A70BFD7D-318A-4CC4-A926-4CD94DED2833}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YhCore.Model", "src\YhCore.Model\YhCore.Model.csproj", "{51A59F85-64FE-4250-94AC-0EB758A2FAE6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YhCore.Repository", "src\YhCore.Repository\YhCore.Repository.csproj", "{D4ECD214-4AB7-4D1F-B371-EBD20998164E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YhCore.Service", "src\YhCore.Service\YhCore.Service.csproj", "{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YhCore.Task", "src\YhCore.Task\YhCore.Task.csproj", "{83FEE60E-7F7A-4031-B86E-C98EA30287C0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YhCore.WebApi", "src\YhCore.WebApi\YhCore.WebApi.csproj", "{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Debug|x64.ActiveCfg = Debug|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Debug|x64.Build.0 = Debug|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Debug|x86.ActiveCfg = Debug|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Debug|x86.Build.0 = Debug|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Release|Any CPU.Build.0 = Release|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Release|x64.ActiveCfg = Release|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Release|x64.Build.0 = Release|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Release|x86.ActiveCfg = Release|Any CPU
{84233313-0D7F-4266-9FBF-A84F5EE2524C}.Release|x86.Build.0 = Release|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Debug|x64.ActiveCfg = Debug|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Debug|x64.Build.0 = Debug|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Debug|x86.ActiveCfg = Debug|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Debug|x86.Build.0 = Debug|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Release|Any CPU.Build.0 = Release|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Release|x64.ActiveCfg = Release|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Release|x64.Build.0 = Release|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Release|x86.ActiveCfg = Release|Any CPU
{A70BFD7D-318A-4CC4-A926-4CD94DED2833}.Release|x86.Build.0 = Release|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Debug|x64.ActiveCfg = Debug|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Debug|x64.Build.0 = Debug|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Debug|x86.ActiveCfg = Debug|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Debug|x86.Build.0 = Debug|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Release|Any CPU.Build.0 = Release|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Release|x64.ActiveCfg = Release|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Release|x64.Build.0 = Release|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Release|x86.ActiveCfg = Release|Any CPU
{51A59F85-64FE-4250-94AC-0EB758A2FAE6}.Release|x86.Build.0 = Release|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Debug|x64.ActiveCfg = Debug|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Debug|x64.Build.0 = Debug|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Debug|x86.ActiveCfg = Debug|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Debug|x86.Build.0 = Debug|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Release|Any CPU.Build.0 = Release|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Release|x64.ActiveCfg = Release|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Release|x64.Build.0 = Release|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Release|x86.ActiveCfg = Release|Any CPU
{D4ECD214-4AB7-4D1F-B371-EBD20998164E}.Release|x86.Build.0 = Release|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Debug|x64.ActiveCfg = Debug|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Debug|x64.Build.0 = Debug|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Debug|x86.ActiveCfg = Debug|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Debug|x86.Build.0 = Debug|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Release|Any CPU.Build.0 = Release|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Release|x64.ActiveCfg = Release|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Release|x64.Build.0 = Release|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Release|x86.ActiveCfg = Release|Any CPU
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A}.Release|x86.Build.0 = Release|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Debug|x64.ActiveCfg = Debug|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Debug|x64.Build.0 = Debug|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Debug|x86.ActiveCfg = Debug|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Debug|x86.Build.0 = Debug|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Release|Any CPU.Build.0 = Release|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Release|x64.ActiveCfg = Release|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Release|x64.Build.0 = Release|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Release|x86.ActiveCfg = Release|Any CPU
{83FEE60E-7F7A-4031-B86E-C98EA30287C0}.Release|x86.Build.0 = Release|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Debug|x64.ActiveCfg = Debug|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Debug|x64.Build.0 = Debug|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Debug|x86.ActiveCfg = Debug|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Debug|x86.Build.0 = Debug|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Release|Any CPU.Build.0 = Release|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Release|x64.ActiveCfg = Release|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Release|x64.Build.0 = Release|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Release|x86.ActiveCfg = Release|Any CPU
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{84233313-0D7F-4266-9FBF-A84F5EE2524C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A70BFD7D-318A-4CC4-A926-4CD94DED2833} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{51A59F85-64FE-4250-94AC-0EB758A2FAE6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D4ECD214-4AB7-4D1F-B371-EBD20998164E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A8B2534C-75CE-46CE-AA5F-32593BBEA36A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{83FEE60E-7F7A-4031-B86E-C98EA30287C0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{1EB570F6-D7F7-4874-AF49-DA43242DA4AB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
+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/*
+285
View File
@@ -0,0 +1,285 @@
# 底层框架代码审查报告
> 审查对象:`YHDocPrintCore`(业务侧,YhCore.\*)+ `YHBasePlatformDotnet`(底层框架,Yh.Platform.\*)
> 审查日期:2026-09-01 | 审查范围:Startup / 中间件 / 公共类 / 框架注册与验签链路 / CAS 认证 / OpenApi 客户端
## 结论
整体迁移质量良好:DI 生命周期、配置绑定、异常链路、签名协议与 Java 侧对齐度高,测试套件 98/98 通过。
审查发现 **5 个 P1 契约缺陷**(3 个静态审查 + 2 个冒烟触发)与 **4 个 P2 健壮性问题**,均已修复。
---
## 一、P1 问题(已修复)
### 1.1 管道顺序缺陷:/oapi 应用身份(AppKey)被下游中间件清空
**位置**:`YhCore.WebApi/Startup.cs`(管道顺序)+ `Yh.Platform.Auth/UserContextMiddleware.cs`
**现象**:原管道 `AppAuth(2) → Authentication(3) → UseYhUserContext(4) → ...`。`AppAuthMiddleware` 验签通过后写入 `UserContextHolder.Set(LoginUser{AppKey})`(位置 2),而 `UserContextMiddleware` 的 `finally` 在请求结束前**无条件** `UserContextHolder.Clear()`(位置 4 更内层,其 finally 在整条链路末尾执行)。结果:`/oapi` 业务 Controller 中 `UserContextHolder.Get()` 恒为 `null`,`AppAuthMiddleware` 注释承诺的"业务可经 UserContextHolder 读取 AppKey"失效。
**修复**:调整管道顺序,`AppAuth` 移到 `UseYhUserContext` 之后——`Authentication → SsoProtection → UseYhUserContext → AppAuth → LegacyAdapter → Authorization`。`UserContextMiddleware` 的 finally 在 AppAuth 写入之后才执行,AppKey 可存活到业务层;且不再被误清空。
```
修复前: ApiAccessLog → GlobalException → AppAuth → Auth → YhUserContext → Legacy → Sso → Authorization
修复后: ApiAccessLog → GlobalException → Auth → Sso → YhUserContext → AppAuth → Legacy → Authorization
```
### 1.2 业务自定义错误码被 `ErrorCodeUtil.From` 吞成 Error=1
**位置**:`YhCore.Common/Exceptions/BizException.cs`
**现象**:`ErrorCodeUtil.From(int)` 对未知码 `_ => ErrorCode.Error`(Code=1)。业务 `throw new BizException("库存不足", 5001)` 经映射后 `Code` 变成 1,前端收到 `code:1` 而非 `code:5001`,错误码契约失效。
**修复**:未知码返回保留原始 code 的 `RawErrorCode(code, "")`(内部 record),msg 由业务消息承载。已知码(0/2/3/4/5/6/405)映射不变。
### 1.3 CAS 回跳开放重定向
**位置**:`Yh.Platform.Auth/CasAuthenticationHandler.cs`
**现象**:`HandleAuthenticateAsync` 中 `Response.Redirect(returnUrl)` 未校验来源,攻击者可诱导已登录用户访问 `https://app/api/x?returnUrl=https://evil.com`,登录后浏览器被跳转到外站(开放重定向)。
**修复**:新增 `IsLocalUrl` 校验(仅放行 `/` 开头的本地相对路径,拒绝 `//host`、`/\host`、绝对地址),Challenge 拼 service 与回调回跳两处均校验;非法 returnUrl 回退 `/`。
### 1.4 UserContextMiddleware 构造注入 Scoped 服务,服务启动即崩溃
**位置**:`Yh.Platform.Auth/UserContextMiddleware.cs`
**现象**:`IUserDetailsProvider` 注册为 **Scoped**,而中间件在**构造函数**注入它。ASP.NET Core 的 `UseMiddleware<T>` 在管道构建(启动)时用 `ActivatorUtilities` 从**根容器**激活中间件,构造含 Scoped 依赖直接抛 `InvalidOperationException: Cannot resolve scoped service 'Yh.Platform.Auth.IUserDetailsProvider' from root provider`,**WebApi 起不来**(此前迁移冒烟未重启实例,缺陷被掩盖;本轮验证 /oapi 时首次触发)。
**修复**:改为 ASP.NET Core 官方模式——构造仅保留 `RequestDelegate`(Singleton),Scoped 依赖 `IUserDetailsProvider` 移到 `InvokeAsync(HttpContext, IUserDetailsProvider)` **方法注入**(每请求由 DI 从请求作用域解析)。同步更新 `AuthContextTests` 两处调用。
### 1.5 服务端验签密钥链不读配置,合法签名恒 401
**位置**:`Yh.Platform.AppAuth/SignKeyResolver.cs`
**现象**:`GetSecretAsync` 解析链仅 `Redis(sign:app:{appKey}) → IMemoryCache → UserBase 兜底`,**不消费 `yh:app-auth` 的 AppKey/AppSecret**;且 `AppAuthOptions.TrustedApps` 对应的 `PropertyAppSecretProvider` 从未被注册(`IAppSecretProvider` 写死为 `SignKeyResolverAppSecretProvider`)。本地联调无 Redis 预置 key、无 UserBase → secret 恒为 null → **任意合法签名都 401**。此前冒烟只断言"无签名/假签名 → 401",未验证"合法签名 → 通过",契约缺陷未被发现。
**修复**:解析链头部增加「配置内置应用」层——`appKey == yh:app-auth:AppKey` 时直接使用配置的 `AppSecret`(覆盖"A 以自身密钥调 B 的 /oapi,B 用配置密钥验签"的自服务/联调场景);第三方应用密钥仍走 Redis → Cache → UserBase。配置密钥从此对服务端验签生效。
---
## 二、P2 问题(已修复)
### 2.1 MemoryReplayGuard 并发下防重放失效
**位置**:`Yh.Platform.AppAuth/ReplayGuard.cs`
**现象**:`TryGetValue` 检查后 `_seen[key] = ...` 写入,非原子。两个并发相同签名请求(同 appKey/timestamp/nonce)可同时通过检查同时放行,防重放被绕过。
**修复**:改为 `ConcurrentDictionary.TryAdd` 原子插入;未过期命中拒绝,过期条目允许覆盖。
### 2.2 SignCanonical 非法 URL 编码导致 500
**位置**:`Yh.Platform.AppAuth/SignCanonical.cs`
**现象**:`Uri.UnescapeDataString` 对未编码的 `%`(如 `?a=100%`)抛 `UriFormatException`,`AppAuthMiddleware` 验签前调用 `BuildTarget` 未捕获 → 兜底返回 `code:6` 内部错误,而非 `401`。
**修复**:`UrlDecode` 捕获 `UriFormatException` 原样返回;恶意参数验签必失败 → 正确返回 401。
### 2.3 CasAuthenticationOptions.TicketValidateTimeoutMs 配置未生效
**位置**:`Yh.Platform.Auth/CasServiceTicketValidator.cs` + `AuthServiceCollectionExtensions.cs` + `CasAuthenticationHandler.cs`
**现象**:配置项定义了(默认 5000ms)但 validator 硬编码 `5000`,改配置无效。
**修复(两阶段)**:
1. `CasServiceTicketValidator` 构造函数接收 `timeoutMs`,注册时从 `IOptions<CasAuthenticationOptions>` 传入;
2. **补漏(9/1 SSO 故障排查发现)**:`CasAuthenticationHandler` 此前绕过 DI、自建 validator 时只传 URL 不传超时 → 配置仍不生效。改为**构造注入 DI 注册的 `CasServiceTicketValidator`**,注册源升级 `IOptionsMonitor<CasAuthenticationOptions>.CurrentValue`。业务侧 `yh:sso:TicketValidateTimeoutMs` 提升到 10000(实测 CAS serviceValidate 延迟 0.5~4.5s+ 波动,5s 默认值存在偶发超时风险)。
### 2.4 AppAuthMiddleware 手拼 JSON 转义不完整
**位置**:`YhCore.WebApi/Middleware/AppAuthMiddleware.cs`
**现象**:`"{\"code\":4,\"msg\":\"" + message.Replace("\"", "\\\"") + ...` 仅转义引号,含反斜杠/换行时产出非法 JSON(当前 message 均为内部常量,风险低)。
**修复**:改用 `System.Text.Json.JsonSerializer.Serialize`。
---
## 三、观察项(未改动,建议后续处理)
| # | 位置 | 说明 | 建议 |
|---|---|---|---|
| 3.1 | `OpenApiOptions.Enabled` / `YhAuthOptions.Enabled` | 配置项定义了但注册链路未消费,`yh:open:Enabled=false` 仍会注册客户端并装配门面 | 消费方明确后接入(涉及门面装配语义,需测试覆盖) |
| 3.2 | `Startup` CORS | `AllowAnyOrigin` 宽松,注释已标注待 CAS 接入后按 `yh.sso.allow-origins` 收紧 | 上线前提供允许源清单后收紧 |
| 3.3 | 反代部署 | `BuildServiceUrl` 依赖 `request.Scheme/Host`,反代后 HTTPS 终止时 service 可能错配 | 生产配置 `AppBaseUrl` 或加 `UseForwardedHeaders` |
| 3.4 | `CasServiceTicketValidator` | 回调低频,每次 new `SocketsHttpHandler`,可接受 | 高并发可换共享 `HttpClient` |
| 3.5 | `GlobalExceptionMiddleware` | `OperationCanceledException`(客户端断开)走兜底 `code:6` | 若需区分可前置 catch |
| 3.6 | `OpenApiClientBase` | `GetRawText()` 二次反序列化,大响应内存开销 | 数据量大时改流式/直接反序列化 |
| 3.7 | `SsoProtectionMiddleware` | 直接读 `IConfiguration` 而非 `IOptions`,且保留旧节 `Yh:Sso:Enabled` 兼容 | 框架稳定后移除旧节回退逻辑 |
---
## 四、验证结果
- ✅ 框架重编(Release):**0 警告 0 错误**
- ✅ 框架测试套件:**98/98 通过**(Abstractions 8 / AppAuth 42 / Auth 11 / OpenApi 37)——9 处修复零回归
- ✅ 业务侧编译(Debug):**0 错误**(仅 net5.0 EOL 警告 NETSDK1138)
- ✅ 框架 Release net5.0 产物已拷回 `E:\YHXT\net\Library`(时间戳 2026-09-01 10:03)
- ✅ **/oapi 冒烟矩阵**(实例 51835 实启验证):
| 用例 | 结果 | 断言 |
|---|---|---|
| 无签名 `GET /oapi/hello` | 401 `{code:4,"msg":"缺少签名头…"}` | 未带票据被拒 |
| 合法签名 `GET /oapi/hello`(无 Cookie) | **404**(直达路由层) | 验签通过、**匿名直访成功** |
| 合法签名 + 排序 query | 404 | 签名口径含 query 正确 |
| 假签名 | 401 `{code:4}` | 票据非法被拒 |
| 无 Cookie `GET /api/xxx` | **302** → CAS 登录页 | SSO 保护不受影响 |
| 无 Cookie `GET /mobiapi/xxx` | **302** → CAS 登录页 | SSO 保护不受影响 |
> 备注:编译期间遇到 `MSB3021/MSB3027` 文件锁定,根因为**上次冒烟测试遗留的 YhCore.WebApi 实例进程(PID 21104,监听 51999)**仍持有 `bin` 下 DLL 句柄,`Stop-Process` 结束后编译恢复。排查路径:`netstat -ano | grep <PID>` → 确认连 Redis → taskkill。
## 五、修复文件清单
**业务侧(YHDocPrintCore)**
- `src/YhCore.WebApi/Startup.cs` — 管道顺序调整(1.1)
- `src/YhCore.Common/Exceptions/BizException.cs` — 未知码透传(1.2)
- `src/YhCore.WebApi/Middleware/AppAuthMiddleware.cs` — JSON 序列化(2.4)
- `src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs` — 显式放行 /oapi(渠道契约,1.1 配套)
**框架侧(YHBasePlatformDotnet)**
- `src/Yh.Platform.Auth/CasAuthenticationHandler.cs` — 开放重定向修复(1.3)
- `src/Yh.Platform.Auth/UserContextMiddleware.cs` — Scoped 构造注入改方法注入(1.4)
- `tests/Yh.Platform.Auth.Tests/AuthContextTests.cs` — 同步新调用约定(1.4)
- `src/Yh.Platform.AppAuth/SignKeyResolver.cs` — 配置内置应用密钥层(1.5)
- `src/Yh.Platform.AppAuth/ReplayGuard.cs` — 并发防重放(2.1)
- `src/Yh.Platform.AppAuth/SignCanonical.cs` — 非法编码容错(2.2)
- `src/Yh.Platform.Auth/CasServiceTicketValidator.cs` — 超时配置接入(2.3)
- `src/Yh.Platform.Auth/AuthServiceCollectionExtensions.cs` — 超时配置接入(2.3)
---
## 六、追加:BizException 退役(9/1)
业务异常统一改为**直接使用框架 `DException`**(`Yh.Platform.Abstractions`),`BizException` 包装类已删除:
- `src/YhCore.Common/Exceptions/BizException.cs` **已删除**;`ErrorCodeUtil.From(int)` 独立为 `ErrorCodeUtil.cs`(int → IErrorCode 映射保留,业务抛码仍可用)
- `GlobalExceptionMiddleware`:原先逻辑完全相同的 `catch (BizException)` + `catch (DException)` 合并为单个 `catch (DException)`
- `WrapBizExceptionAttribute` 更名 `WrapDExceptionAttribute`(当前无业务标注,改名安全)
- 业务写法:`throw new DException(ErrorCodeUtil.From(5001), "库存不足")`;仅提示语用 `new DException("提示语")`(默认 Custom=5)
---
## 七、追加:SSO 单点登录失败排查(9/1)
**现象**:浏览器打开 `http://localhost:51835/signin-cas?ticket=ST-7-...` 报"单点登录失败"。
### 根因(四层)
1. **CAS 票据单次有效**:`ST-7` 来自更早会话(00:41 首个实例),已被消费或过期。curl 直调 `serviceValidate` 实测 CAS 返回 `<cas:authenticationFailure code="INVALID_TICKET">Ticket 'ST-7-...' not recognized</cas:authenticationFailure>`——**任何重复校验必然失败**,这是 CAS 协议设计(防重放),非缺陷。
2. **应用当时未运行**:51835 端口无监听进程(netstat 确认),回调 URL 无法被服务。
3. **UX 缺陷**:修复前失效票据回调 → `AuthenticateResult.Fail` → 未认证 → 路径不在保护集 → 落 MVC 裸 **404**,用户感知为"登录失败"。
4. **潜在超时风险**:实测 CAS serviceValidate 延迟 **0.5~4.5s+ 波动**,框架默认超时 5s 且此前配置未真正生效(见 2.3 补漏),CAS 慢时登录偶发失败。
### 修复(框架 2 处 + 业务 2 处,第一轮)
- 框架 `CasAuthenticationHandler`:**构造注入 DI 注册的 `CasServiceTicketValidator`**(2.3 补漏),并补回旧版有、迁移时丢失的 **`Context.User = principal`**(SignInAsync 只写 Cookie 不改当前请求身份,缺它后续中间件误判未认证)。
- 框架 `AuthServiceCollectionExtensions`:validator 注册源 `IOptions` → `IOptionsMonitor<CasAuthenticationOptions>.CurrentValue`。
- 业务 `SsoProtectionMiddleware`:CAS 回调路径(`yh:sso:signInCallbackPath`)纳入保护集——失效票据回调**自动 302 弹回 CAS 登录页重新登录**,不再裸 404。
- 业务 `appsettings.json`:`yh:sso:TicketValidateTimeoutMs: 10000`(吸收 0.5~4.5s+ 延迟波动)。
### 验证(实例 51835 实启,第一轮)
| 用例 | 修复前 | 修复后 |
|---|---|---|
| 旧票据 `GET /signin-cas?ticket=ST-7-...` | **404** | **302 → CAS 登录页**(自动重登) |
| `GET /` | 302 → CAS | 302 → CAS(不变) |
| 无 Cookie `GET /api/GetUserInfo` | 302 → CAS | 302 → CAS(不变) |
| 无签名 `GET /oapi/Test` | 401 | 401(不变) |
| `GET /swagger/index.html` | 200 | 200(不变) |
⚠️ **此轮验证只覆盖了失败路径**:真实成功路径(票据校验 → Cookie → 回跳)依赖真实 CAS 账号,无法端到端验证。随后用本地 Mock CAS 服务器补测,**立刻暴露一个更深层的框架级缺陷**(见附录八)——第一轮的"302 弹回登录页"只是防循环表象,票据校验实际从未被触发。
> **给使用者的操作指引**:CAS 票据单次有效且时效短——登录流程必须**从受保护页面(`/` 或 `/api/xxx`)发起**,不要收藏/刷新带 `ticket` 的回调 URL(刷新必失败)。失败后回到 `/` 重新走一遍即可。
## 八、追加:SSO 成功路径端到端验证——DefaultScheme 缺陷(9/1 第二轮)
**背景**:附录七修复后,用本地 Mock CAS(`Temp/mock-cas.js`,自动登录 + 单次票据语义)通过环境变量 `yh__sso__CasServerUrlPrefix` 覆盖指向 Mock,对**成功路径**做全链路验证。结果发现:票据校验请求**从未送达** CAS(Mock 日志只有 `/cas/login`,无 `/serviceValidate`),回调 2ms 即返回——校验在网络请求之前就被跳过。
### 根因(框架级,P0)
`AuthServiceCollectionExtensions.AddAuthentication` 中 **`DefaultAuthenticateScheme = CookieScheme`**。`UseAuthentication` 每请求只调用 `AuthenticateAsync(默认 scheme)` 恢复 Cookie 登录态;**CAS 票据校验(`CasAuthenticationHandler.HandleAuthenticateAsync` 的 ticket 分支)在回调请求上没有任何触发点**:
```
回调 /signin-cas?ticket=ST-… 进入
└─ UseAuthentication → 只查 Cookie(无)→ 未认证
└─ SsoProtectionMiddleware:带 ticket + 未认证 → 直接 401 防循环(附录七引入)
└─ CasAuthenticationHandler.HandleAuthenticateAsync 从未执行 → 票据从不校验 → 登录永远失败
```
即:第一轮"防循环"把**合法票据路径一并拦截**,与 `DefaultScheme` 错误叠加,导致任何 CAS 登录都不可能成功(Mock 端到端立即暴露,真实账号难以察觉)。
### 修复(框架 1 处 + 业务 1 处)
- **框架 `AuthServiceCollectionExtensions`**:`DefaultAuthenticateScheme` 改为 `YhAuthDefaults.CasScheme`。`CasAuthenticationHandler.HandleAuthenticateAsync` 第一步就是 `AuthenticateAsync(CookieScheme)` 恢复登录态,因此语义等价"先 Cookie 恢复、后 ticket 校验",每请求行为不变、仅补上票据校验触发点。
- **业务 `SsoProtectionMiddleware`**:回调路径**已认证且已 302**(handler 验票成功 `Response.Redirect`)时**短路管道**——handler 返回 `NoResult` 不短路,继续走 MVC 端点匹配会因 `/signin-cas` 无 action 返回 **404 覆盖 302**(实测现象),短路保住回跳响应。
### 验证(Mock CAS 端到端,实例 51835 实启)
| # | 场景 | 结果 |
|---|---|---|
| A | `/` → Mock 登录 → 回调验票 → Cookie → 回跳 `/` | **302 → 200**,Cookie 落盘 ✓ |
| B | 带 Cookie `GET /api/GetUserInfo` | **200**(登录态生效)✓ |
| C | 复用已消费票据 `GET /signin-cas?ticket=同前` | **401 防循环页** ✓(Mock 第二次校验返回 INVALID_TICKET) |
| D | 无 Cookie `GET /api/GetUserInfo` | 302 → CAS ✓ |
| E | 无签名 `GET /oapi/Test` | 401(SSO 不覆盖)✓ |
Mock 日志互证:`02:35:09 /cas/serviceValidate?ticket=ST-MOCK-…` 送达并成功、票据被消费;复用同一票据时 Mock 返回 `INVALID_TICKET` → 应用 401。
框架重编 0 错误、测试 **98/98 通过**、业务编译 0 错误;`Yh.Platform.Auth.dll` 已更新至 `E:\YHXT\net\Library`。
> **教训**:SSO 类链路缺陷必须覆盖**成功路径**验证——失败路径的"防循环 401/302"表象可能掩盖"校验从未发生"的框架级问题。无真实账号时用 Mock CAS 全链路补测是必要手段。
---
## 附录九:登录态 Cookie 会话改造(9/1 需求:关闭失效 / 1 分钟过期 / 32 位)
**需求**:① 关闭浏览器 Cookie 失效;② 过期时间 1 分钟;③ Cookie 长度为 32 位。
**实现(框架 `Yh.Platform.Auth` 3 处 + 业务 1 处)**:
| 文件 | 变更 |
|---|---|
| `SessionTicketDataFormat.cs`(新增) | 自定义 `ISecureDataFormat<AuthenticationTicket>`:`Protect` 把完整票据存服务端内存、返回 **32 位 key**(Guid N 格式);`Unprotect` 查表并校验 `ExpiresUtc`(过期返回 null 并清理)。key 为 128 位随机熵,不可枚举 |
| `AuthServiceCollectionExtensions.cs` | Cookie 注册:`options.TicketDataFormat = new SessionTicketDataFormat()`;`ExpireTimeSpan` 由 `yh:auth:CookieExpireMinutes` 决定(默认 1 分钟);`SlidingExpiration = false`(固定过期不滑动) |
| `CasAuthenticationHandler.cs` | `IsPersistent: true → false`(会话级 Cookie,关闭浏览器失效;旧版 `_legacy` 本为 false,系迁移回归,已修正) |
| 业务 `appsettings.json` | `CookieExpireHours: 8` → `CookieExpireMinutes: 1`(注:文件曾被外部覆盖一次,CookieName 现为用户保存的 `.yh.auth`) |
**关键技术发现**:**.NET 5 的 `CookieAuthenticationOptions.SessionStore` 并不能缩短 Cookie**——官方源码(v5.0.17 `CookieAuthenticationHandler.HandleSignInAsync`)显示即便配置了 SessionStore,cookie 值仍是 `TicketDataFormat.Protect(精简票据)` 的加密长串(`CfDJ8…`),只是把完整用户票据移到了服务端。**要实现"Cookie 32 位",必须自定义 `TicketDataFormat`(Protect 落库返回 key)**,而非依赖 SessionStore。首次尝试(SessionTicketStore + SessionStore)经最小复现程序(独立 net5.0 Web 项目直连框架 DLL)证实无效后放弃。
**验证(Mock CAS 端到端 + 真实配置冒烟)**:
| 需求 | 验证结果 |
|---|---|
| ③ Cookie 32 位 | `Set-Cookie: .yh.auth=3a3f40993606438e86d8e756b99a6f07` = **32 字符** ✓ |
| ① 关闭浏览器失效 | Set-Cookie 无 `Max-Age`/`Expires`(会话 Cookie)+ `IsPersistent=false` ✓ |
| ② 1 分钟过期 | 登录后立即带 Cookie 访问 `/` → 200;**65 秒后** → **302 弹回 CAS 登录页**(服务端票据过期)✓ |
框架双目标(net5.0/net8.0)编译 0 错误 0 警告、测试 **98/98 通过**、业务编译 0 错误;DLL 已更新至 `E:/YHXT/net/Library`。
**注意**:票据存进程内存(ConcurrentDictionary)——应用重启后所有登录态失效(用户需重新登录);多实例部署时各实例票据互不可见(如需跨实例共享可换 Redis 实现)。
### 附录九补充:退出即吊销——显式登出服务端票据立即失效(9/1 第二轮)
**背景**:附录九初版只覆盖"登录态自然过期/关浏览器"。审查发现缺口:`SignOutAsync`(业务 `GET /api/Logout`)只删 Cookie,**服务端内存票据残留到 ExpireTimeSpan 过期**(默认 1 分钟)——期间若旧 Cookie key 被恢复/复用,登录态仍有效。
**修复(框架 `Yh.Platform.Auth` 2 处)**:
| 文件 | 变更 |
|---|---|
| `SessionTicketDataFormat.cs` | 新增 `Revoke(string? key)`:删除指定票据(显式退出立即失效);新增分钟级摊还清理 `CleanupExpiredIfDue()`(Unprotect/Revoke 路径触发,Interlocked 门闩保证并发下每分钟最多一次全量扫描,防内存膨胀) |
| `AuthServiceCollectionExtensions.cs` | `SessionTicketDataFormat` 提升为 DI 单例(`services.AddSingleton`,TicketDataFormat 与事件共享同一票据表);Cookie 注册挂 `options.Events.OnSigningOut`:SignOutAsync 时从请求 Cookie 读 32 位 key → `Revoke(key)` → 服务端票据**立即删除** |
**语义闭环**:① 关闭浏览器 → 会话 Cookie 删除(服务端无法感知浏览器关闭,1 分钟过期兜底);② 显式退出(/api/Logout)→ Cookie 删除 **+** 服务端票据立即吊销,不等过期。
**验证(Mock CAS 端到端,实例 51835 实启)**:
| 步骤 | 期望 | 实测 |
|---|---|---|
| 登录流 → Cookie(32 位 key) | 200 + Cookie 落盘 | ✓ |
| 带 Cookie 访问 `/api/GetUserInfo` | 200 | ✓ |
| `GET /api/Logout`(带 Cookie) | 200 | ✓ |
| **Logout 后旧 key 手动放回访问** | **302 弹回 CAS**(服务端已吊销;旧行为下会 200) | ✓ **302** |
框架测试 **106/106 通过**(Auth 19 = 原 11 + 新增 `SessionTicketDataFormatTests` 8 项:32 位 key 往返 / 未知 key null / Revoke 立即失效 / 过期 null / 未过期正常 / Revoke 不影响其他票据等),双目标编译 0 错误 0 警告;`Yh.Platform.Auth.dll`(Release net5.0)已更新至 `E:/YHXT/net/Library`。
+86
View File
@@ -0,0 +1,86 @@
# YhCore 开发规范
> .NET 5.0 后端框架(参照 Java 版 bschclassmanage 迁移),仅框架无业务逻辑。
## 1. 解决方案结构
```
YhCore.sln
├─ src/
│ ├─ YhCore.WebApi # 启动项:Controllers / Middleware / Filters / Attributes / Auth
│ ├─ YhCore.Service # 业务服务层:Interfaces / Impl(不含业务时用 *Stub 占位)
│ ├─ YhCore.Repository # 数据层:YHDbContext / IRepository<T> / Specification / DapperChannel
│ ├─ YhCore.Entity # 数据库实体(旧 EF6 POCO 迁移而来,无导航属性)
│ ├─ YhCore.Model # DTO / VO / Boa 平台占位模型
│ ├─ YhCore.Common # 工具:ApiRoutes / UserContext / RedisUtil / PageUtil / ApiResult
│ └─ YhCore.Tasks # 调度:QuartzHostedService / Jobs
└─ lib / docker / docs / sqlscript
```
**依赖方向(单向,禁止反向引用)**:WebApi → Service → Repository → Entity;WebApi → Model ← Service;Common 不依赖任何上层。
**底层框架(Yh.Platform.\*)**:CAS 认证、用户上下文(BoaUserContext)、应用验签(AppAuthVerifier)、用户中心(OpenApi)均由底层框架提供:
- 引用 DLL:`Yh.Platform.Abstractions` / `Yh.Platform.AppAuth` / `Yh.Platform.Auth` / `Yh.Platform.OpenApi`(lib 目录,源码在 `E:\YHXT\net\YHBasePlatformDotnet`)
- `Startup` 用 `services.AddYhPlatform(Configuration)` 聚合注册(= AddYhAppAuth + AddYhAuth[含 AddYhOpenApi]),管道用 `app.UseYhUserContext()` 注入用户上下文
- 旧自研 CAS/验签/用户中心实现已退役到 `src/_legacy/`(不参与编译,仅留档备份);业务代码取当前用户用 `BoaUserContext.Get()`
**`/oapi` 开放渠道契约**:应用间调用渠道,**无需用户登录**,凭签名票据直接访问:
- 票据 = 请求头 `X-App-Key` / `X-Timestamp` / `X-Signature`(`signature = Base64(HmacSHA256(METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp, secret))`,详见框架仓库 `YHBasePlatformDotnet\BasePlatformDotnet\contract\sign-protocol.md`)
- 未带票据/票据非法 → `401 {code:4,msg:"缺少签名头…"/"签名校验失败…"}`;验签通过 → 放行到业务(无用户上下文,业务只可读 `UserContextHolder.Get().AppKey` 应用身份)
- SSO 登录保护**永不拦截** `/oapi`(SsoProtectionMiddleware 显式放行,即使 `yh:sso:urlPattern` 误配也不影响);服务端验签密钥解析链:配置内置应用(`yh:app-auth` AppKey/AppSecret)→ Redis `sign:app:{appKey}` → 本地缓存 → UserBase 兜底
**`/oapi` 冒烟验证方法**:本地联调/自测票据可用 `scripts/oapi-sign.js` 生成(对齐签名协议):
- 生成票据:`node scripts/oapi-sign.js GET "/oapi/hello?a=2&b=1"` → 输出 `appKey/ts/signature` 三头
- 直接出 curl:`node scripts/oapi-sign.js --curl POST "/oapi/xxx"` → 复制替换 `PORT` 即可
- 密钥默认取开发联调密钥(`yh:app-auth`),生产环境用 `APP_KEY` / `APP_SECRET` 环境变量覆盖,**禁止提交真实密钥到仓库**
- 断言基线:无票据 → 401 `{code:4}`;合法票据 → 直达业务(404/业务结果);假票据 → 401;`/api`、`/mobiapi` 无 Cookie → 302 CAS(SSO 保护不受影响)
## 2. 强制规范
| 约定 | 说明 |
|---|---|
| **接口路径** | 一律用 `[Route(ApiRoutes.ApiPath + "/xxx")]`,禁止硬编码 `"api/..."`;渠道前缀见 `ApiRoutes`(api/mobiapi/oapi) |
| **接口返回结构** | 与 Java 版保持一致(裸返回),**全局 `GlobalWrapResultFilter` 自动统一包装**(ApiResult{code,msg,data});需要裸返回用 `[NoWrapResult]` 显式排除;异常由 `GlobalExceptionMiddleware` 统一转失败响应 |
| **业务异常** | Service 层抛 `new DException(ErrorCodeUtil.From(code), "提示语")`(框架 `Yh.Platform.Abstractions`);仅提示语可用便捷构造 `new DException("提示语")`(默认 Custom=5);中间件自动转统一失败响应 |
| **日志** | **只允许接口访问日志**(ApiAccessLogMiddleware 自动记录)。业务代码禁止调用 `ILogger` 写日志;异常摘要由接口日志统一带出 |
| **缓存 Key** | 必须用 `RedisConstants.KeyOf(...)` 生成;过期时间用 `RedisConstants.CACHE_EXPIRE_*` |
| **分页** | 页码从 0 开始(与 Spring Pageable 对齐);数据库分页用 `IRepository<T>.PageAsync`,内存分页用 `PageUtil` |
| **动态条件** | 用 `Specification.True<T>().And(...).AndIf(cond, ...)` 拼装 |
| **操作日志** | **接口级审计日志,全局自动**:`SysOperationLogFilter`(Startup 全局注册)每次 Controller 请求后落库 `sys_operation_log`(对齐 Java `SysOperationLog`,含 URI/参数/响应 GZIP/耗时/状态码/异常),无需任何标注;级别按 HTTP 方法推导(GET=QUERY/POST=ADD/PUT=EDIT/DELETE=DELETE);`LogController` 已显式排除防递归 |
| **实体主键** | 实体无 `[Key]` 时按约定推断(Id → SerID);新增表实体建议显式标注 |
| **数据库** | 默认 SQL Server 主库;`DatabaseProvider=MySql` 切换;**缺表自动补建**(`Database:AutoCreateMissingTables`,默认 true:启动时对比 EF 模型与数据库,缺失的表按模型自动 CREATE,幂等;已存在表的结构变更仍走 sqlscript 脚本) |
| **数据库写操作** | 注意 `RepositoryBase` 的 Add/Update 需调用 `SaveChangesAsync()` 提交(UnitOfWork 模式) |
## 3. 常用命令
```bash
dotnet build # 编译
dotnet run --project src/YhCore.WebApi # 本地运行(Swagger: /swagger/index.html)
dotnet watch run --project src/YhCore.WebApi
```
## 4. 环境配置(appsettings.json 或环境变量覆盖)
| 配置 | 默认值 | 说明 |
|---|---|---|
| `DatabaseProvider` | `SqlServer` | `SqlServer` / `MySql` |
| `Database:AutoCreateMissingTables` | `true` | 启动时自动补建 EF 模型中有而数据库缺失的表(仅 SQL Server;幂等;已存在表不触碰,结构变更仍走 sqlscript) |
| `ConnectionStrings__SqlServer` | localhost/EduCloudDocPrint | 主库连接串(生产用环境变量,勿提交明文密码) |
| `ConnectionStrings__Mysql` | localhost:3306 | MySQL 备用连接串 |
| `Redis:ConnectionString` | localhost:6379,defaultDatabase=0 | Redis 连接串 |
| `yh:auth:CookieName` | `.yh.auth` | 认证 Cookie 名 |
| `yh:auth:CookieExpireMinutes` | `1` | 登录态固定过期(分钟),不滑动续期;票据存服务端内存(SessionTicketDataFormat),Cookie 只写 32 位 key;会话级 Cookie(IsPersistent=false),关闭浏览器即失效;**显式退出(/api/Logout)时服务端票据立即吊销**(OnSigningOut 事件,不等过期兜底) |
| `yh:sso:Enabled` | `true` | CAS SSO 开关(拦截 urlPattern / mobiApiPattern) |
| `yh:sso:CasServerUrlPrefix` | - | CAS 服务端地址前缀(Challenge 跳转用) |
| `yh:app-auth:Enabled` | `true` | /oapi 应用验签开关(X-App-Key / X-Timestamp / X-Signature) |
| `yh:open:AppKey` / `yh:open:AppSecret` | 100002 | OpenApi 调用方凭证 |
## 5. 待办(TODO)
- [x] CAS 认证:已由 `Yh.Platform.Auth`(CasScheme)接管,旧本地实现退役到 `_legacy/Auth`
- [x] 应用签名:`AppAuthMiddleware` 已接入 `AppAuthVerifier` + `SignCanonical` 真实验签
- [x] 用户中心:`IUserCenterService` 已替换为 `Yh.Platform.OpenApi`(`BoaUserContext.Get()` 取上下文)
- [ ] `ILogService` 真实查询实现(替换 LogServiceStub)
- [x] Quartz Job 按业务启用:`CleanupLogJob` 已实现(每日凌晨 3:30 删除 30 天前 `sys_operation_log`),`QuartzHostedService.InitJobs` 已注册 `cleanup-log-job`(cron `0 30 3 * * ?`)
- [ ] 审计日志增强(`SysOperationLogFilter` 目前按 HTTP 方法推导级别,`Module/Operation` 两列恒空,可接入业务字典映射;日志查询走 `LogController` 但 `LogServiceStub` 尚未实现真实分页查询)
- [ ] .NET 5 已 EOL:框架代码避免使用 5.0 专属 API,便于日后升 6/8
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
// /oapi 签名票据生成器(对齐 yh-platform-net SignUtil.SignRequest / SignCanonical 协议)
//
// 协议:signature = Base64(HmacSHA256(METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp, secret))
// - query 按 k=v 整体字典序排序后拼接参与签名
// - timestamp 为毫秒级 Unix 时间戳(服务端校验 5 分钟窗口)
//
// 用法:
// node scripts/oapi-sign.js [METHOD] [path?query]
// node scripts/oapi-sign.js --curl POST /oapi/print/submit # 输出可直接复制的 curl 命令
//
// 密钥来源(优先级):环境变量 APP_KEY / APP_SECRET → 文件默认值(开发联调密钥)
// 生产环境务必通过环境变量注入,勿提交真实密钥。
const crypto = require('crypto');
const APP_KEY = process.env.APP_KEY || '100002';
const APP_SECRET = process.env.APP_SECRET || '2314a3b158b24a838b2b9b69fd3db118';
// 排序 query(对齐 SignCanonical.SortRawQuery:k=v 整体排序,空值保留 = 号)
function sortQuery(raw) {
if (!raw) return '';
return raw.split('&').filter(Boolean).map(p => {
const i = p.indexOf('=');
return (i < 0 ? p : p.slice(0, i)) + '=' + (i < 0 ? '' : p.slice(i + 1));
}).sort().join('&');
}
function sign(method, pathWithQuery, ts) {
const qi = pathWithQuery.indexOf('?');
const target = qi >= 0
? pathWithQuery.slice(0, qi) + '?' + sortQuery(pathWithQuery.slice(qi + 1))
: pathWithQuery;
const source = method.toUpperCase() + '\n' + target + '\n' + ts;
const sig = crypto.createHmac('sha256', APP_SECRET).update(source, 'utf8').digest('base64');
return { ts, target, signature: sig };
}
const args = process.argv.slice(2);
let method = 'GET';
let pathWithQuery = '/oapi/hello';
let curlMode = false;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--curl') { curlMode = true; continue; }
if (!/^-/.test(a)) {
if (method === 'GET' && !/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$/i.test(a) && !a.startsWith('/')) {
// 第一个非选项参数若不像 METHOD 也不像路径,则视为路径
pathWithQuery = a;
} else if (a.startsWith('/')) {
pathWithQuery = a;
} else {
method = a;
}
}
}
const { ts, target, signature } = sign(method, pathWithQuery, Date.now().toString());
if (curlMode) {
const u = target.replace(/^\//, '');
console.log(`curl -i -X ${method.toUpperCase()} "http://localhost:PORT/${u}" \\`);
console.log(` -H "X-App-Key: ${APP_KEY}" \\`);
console.log(` -H "X-Timestamp: ${ts}" \\`);
console.log(` -H "X-Signature: ${signature}"`);
} else {
console.log(JSON.stringify({ appKey: APP_KEY, method, target, ts, signature, curl: [
'-H', `X-App-Key: ${APP_KEY}`,
'-H', `X-Timestamp: ${ts}`,
'-H', `X-Signature: ${signature}`
].join(' ') }, null, 2));
}
+26
View File
@@ -0,0 +1,26 @@
-- ============================================================
-- 数据变更日志表(A_DataLog)
-- 对应实体:YhCore.Entity.A_DataLog(旧 EF6 EDMX 生成 POCO,主键约定 SerID)
-- 用途:数据变更审计预留表(当前无业务代码使用,仅 DbContext 注册)
-- 数据库:SQL Server(EduCloudDocPrint)
-- 风格:对齐同库 Java 遗留表(SerID 主键自增、varchar 字符串、可空)
-- ============================================================
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'A_DataLog')
BEGIN
CREATE TABLE [dbo].[A_DataLog] (
[SerID] INT IDENTITY(1,1) NOT NULL CONSTRAINT [PK_A_DataLog] PRIMARY KEY,
[SchSerID] VARCHAR(200) NULL,
[ModuleName] VARCHAR(50) NULL,
[TableName] VARCHAR(50) NULL,
[OldSerID] INT NULL,
[NowSerID] INT NULL,
[RId] INT NULL
);
PRINT 'A_DataLog table created.';
END
ELSE
BEGIN
PRINT 'A_DataLog table already exists, skip.';
END
@@ -0,0 +1,55 @@
-- ============================================================
-- 系统审计日志表(sys_operation_log)
-- 对应实体:YhCore.Entity.SysOperationLog(EF Core,OnModelCreating ToTable 映射)
-- 对应 Java:bschclassmanage 的 entity.SysOperationLog / sqlscript/sys_operation_log.sql
-- 用途:SysOperationLogFilter 全量记录 HTTP 接口日志 / CleanupLogJob 30 天清理
-- 说明:本表取代旧业务操作日志表 OperationLogRecord(旧表无数据,直接删除)
-- 数据库:SQL Server(EduCloudDocPrint)
-- ============================================================
-- 1) 清理旧业务操作日志表(OperationLogRecord,已被 sys_operation_log 取代)
IF OBJECT_ID(N'[dbo].[OperationLogRecord]', N'U') IS NOT NULL
BEGIN
DROP TABLE [dbo].[OperationLogRecord];
PRINT 'Legacy OperationLogRecord table dropped.';
END
-- 2) 创建审计日志表(幂等)
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'sys_operation_log')
BEGIN
CREATE TABLE [dbo].[sys_operation_log] (
[Id] BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT [PK_sys_operation_log] PRIMARY KEY,
[LogLevel] TINYINT NOT NULL, -- 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)
[OperationType] NVARCHAR(16) NOT NULL, -- DELETE/CREATE/UPDATE/QUERY
[UserId] INT NULL, -- 操作人 ID
[UserName] NVARCHAR(64) NULL, -- 操作人姓名
[SchId] INT NULL, -- 学校 ID(多校隔离)
[RequestIp] NVARCHAR(64) NOT NULL, -- 客户端 IP
[RequestUri] NVARCHAR(512) NOT NULL, -- 请求 URI
[RequestMethod] NVARCHAR(16) NOT NULL, -- HTTP 方法
[ClassMethod] NVARCHAR(512) NOT NULL, -- 类名.方法名
[RequestParams] NVARCHAR(MAX) NULL, -- 请求参数(JSON,截断)
[ResponseData] VARBINARY(MAX) NULL, -- GZIP 压缩后的响应 JSON
[StatusCode] INT NOT NULL, -- 业务状态码:0 成功 / 非 0 失败
[CostTime] BIGINT NOT NULL, -- 耗时(ms)
[UserAgent] NVARCHAR(512) NULL,
[ErrorMsg] NVARCHAR(MAX) NULL, -- 异常信息(截断 8000)
[RetryCount] INT NULL DEFAULT 0, -- 重试次数(对齐 Java 双轨字段,.NET 暂不写入)
[LastRetryStatus] INT NULL, -- 上次重试状态:1 成功 / 0 失败
[LastRetryTime] DATETIME NULL, -- 上次重试时间
[CreateTime] DATETIME NOT NULL -- 记录时间
);
-- 查询/清理索引(对齐 Java idx_sol_*)
CREATE INDEX [IX_sol_CreateTime] ON [dbo].[sys_operation_log] ([CreateTime]);
CREATE INDEX [IX_sol_UserId_Time] ON [dbo].[sys_operation_log] ([UserId], [CreateTime]);
CREATE INDEX [IX_sol_SchId_Time] ON [dbo].[sys_operation_log] ([SchId], [CreateTime]);
CREATE INDEX [IX_sol_Uri_Time] ON [dbo].[sys_operation_log] ([RequestUri], [CreateTime]);
CREATE INDEX [IX_sol_Level_Time] ON [dbo].[sys_operation_log] ([LogLevel], [CreateTime]);
PRINT 'sys_operation_log table created.';
END
ELSE
BEGIN
PRINT 'sys_operation_log table already exists, skip.';
END
+20
View File
@@ -0,0 +1,20 @@
namespace YhCore.Common
{
/// <summary>
/// 渠道路由前缀常量(对应 Java 版 config.Config 枚举的
/// API_PATH / MOBILEAPI_PATH / OAPI)。
/// Controller 一律通过常量引用,禁止硬编码 "api/..." 字符串:
/// <code>[Route(ApiRoutes.ApiPath + "/log")]</code>
/// </summary>
public static class ApiRoutes
{
/// <summary>PC 端接口前缀(对应 Config.API_PATH)</summary>
public const string ApiPath = "api";
/// <summary>移动端接口前缀(对应 Config.MOBILEAPI_PATH)</summary>
public const string MobiApiPath = "mobiapi";
/// <summary>开放接口前缀(对应 Config.OAPI)</summary>
public const string OApiPath = "oapi";
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace YhCore.Common.Cache
{
/// <summary>
/// Redis Key 规范常量(对应 Java 版 utils.RedisConstants)。
/// 所有业务 Key 必须基于 <see cref="CachePrefix"/> 通过 <see cref="KeyOf"/> 生成,禁止散落硬编码。
/// </summary>
public static class RedisConstants
{
/// <summary>本项目缓存前缀(Java 班级管理为 bsch.classmanage,本项目为 yh.docprint)</summary>
public const string CachePrefix = "yh.docprint";
// ---- 常用过期时间(秒),对应 Java CACHE_EXPIRE_* ----
public const long CACHE_EXPIRE_1M = 60;
public const long CACHE_EXPIRE_5M = 5 * 60;
public const long CACHE_EXPIRE_10M = 10 * 60;
public const long CACHE_EXPIRE_30M = 3 * CACHE_EXPIRE_10M;
public const long CACHE_EXPIRE_1H = 60 * 60;
public const long CACHE_EXPIRE_1D = 24 * CACHE_EXPIRE_1H;
public const long CACHE_EXPIRE_7D = 7 * CACHE_EXPIRE_1D;
public const long CACHE_EXPIRE_30D = 30 * CACHE_EXPIRE_1D;
/// <summary>分布式锁 Key 前缀</summary>
public const string LockPrefix = CachePrefix + ".lock";
/// <summary>
/// 生成规范 Key:CachePrefix + "." + 各段拼接。
/// 例:KeyOf("sch", "list") → "yh.docprint.sch.list"
/// </summary>
public static string KeyOf(params string[] parts)
{
return CachePrefix + "." + string.Join(".", parts);
}
/// <summary>生成分布式锁 Key</summary>
public static string LockKeyOf(string name)
{
return LockPrefix + "." + name;
}
// TODO:业务缓存 Key 按模块在此追加(参照 Java 版 SYSTEM_* / SCHOOL_* 的命名风格)
}
}
@@ -0,0 +1,29 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
namespace YhCore.Common.Cache
{
/// <summary>
/// Redis 缓存注册扩展(对应 Java RedisConfig 的 Bean 装配)。
/// 连接串取配置 "Redis:ConnectionString",如 "localhost:6379,defaultDatabase=0"。
/// </summary>
public static class RedisServiceCollectionExtensions
{
public static IServiceCollection AddRedisCache(this IServiceCollection services, IConfiguration configuration)
{
var connStr = configuration["Redis:ConnectionString"];
if (string.IsNullOrWhiteSpace(connStr))
{
connStr = "localhost:6379,defaultDatabase=0";
}
// 连接多路复用器全局单例(对应 Java 单例 RedisTemplate)
services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(connStr));
services.AddSingleton<RedisUtil>();
return services;
}
}
}
+287
View File
@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using StackExchange.Redis;
namespace YhCore.Common.Cache
{
/// <summary>
/// Redis 操作封装(对应 Java 版 utils.RedisUtil)。
/// 基于 StackExchange.Redis,对象值统一 JSON 序列化(Newtonsoft,与全局 JSON 方案一致);
/// Incr/Decr 计数类 Key 存裸数字,不走 JSON。
/// 注册为单例(内部复用 IConnectionMultiplexer,线程安全)。
/// </summary>
public class RedisUtil
{
private readonly IConnectionMultiplexer _connection;
public RedisUtil(IConnectionMultiplexer connection)
{
_connection = connection;
}
private IDatabase Db => _connection.GetDatabase();
// ==================== Key / 过期 ====================
/// <summary>设置过期时间(秒),对应 expire(key, time)</summary>
public bool Expire(string key, long seconds)
{
return Db.KeyExpire(key, TimeSpan.FromSeconds(seconds));
}
/// <summary>获取剩余过期时间(秒;0=已过期或无此 key;-1=无过期时间),对应 getExpire</summary>
public long GetExpire(string key)
{
var ttl = Db.KeyTimeToLive(key);
return ttl.HasValue ? (long)ttl.Value.TotalSeconds : -1;
}
/// <summary>判断 key 是否存在,对应 hasKey</summary>
public bool HasKey(string key)
{
return Db.KeyExists(key);
}
/// <summary>删除一个或多个 key,对应 del(key...)</summary>
public void Del(params string[] keys)
{
Db.KeyDelete(keys.Select(k => (RedisKey)k).ToArray());
}
/// <summary>按模式扫描 key(对应 keys(pattern);生产慎用通配全库)</summary>
public List<string> Keys(string pattern)
{
var server = _connection.GetServer(_connection.GetEndPoints().First());
return server.Keys(pattern: pattern).Select(k => (string)k).ToList();
}
// ==================== String ====================
/// <summary>读取并反序列化为指定类型;不存在或反序列化失败返回 default</summary>
public T Get<T>(string key)
{
var value = Db.StringGet(key);
if (value.IsNullOrEmpty)
{
return default;
}
return JsonConvert.DeserializeObject<T>(value);
}
/// <summary>读取并反序列化为 List&lt;T&gt;,对应 getList</summary>
public List<T> GetList<T>(string key)
{
return Get<List<T>>(key);
}
/// <summary>写入(不过期),对应 set(key, value)</summary>
public bool Set(string key, object value)
{
return Db.StringSet(key, Serialize(value));
}
/// <summary>写入并设置过期时间(秒),对应 set(key, value, time)</summary>
public bool Set(string key, object value, long seconds)
{
return Db.StringSet(key, Serialize(value), TimeSpan.FromSeconds(seconds));
}
/// <summary>自增(delta 可为负),对应 incr/decr</summary>
public long Incr(string key, long delta = 1)
{
return delta >= 0 ? Db.StringIncrement(key, delta) : Db.StringDecrement(key, -delta);
}
/// <summary>自减,对应 decr</summary>
public long Decr(string key, long delta = 1)
{
return Db.StringDecrement(key, delta);
}
// ==================== Hash ====================
public T HGet<T>(string key, string field)
{
var value = Db.HashGet(key, field);
return value.IsNullOrEmpty ? default : JsonConvert.DeserializeObject<T>(value);
}
/// <summary>获取整个 Hash(field → 原始 JSON 字符串),对应 hmget</summary>
public Dictionary<string, string> HGetAll(string key)
{
return Db.HashGetAll(key).ToDictionary(e => (string)e.Name, e => (string)e.Value);
}
public bool HSet(string key, string field, object value)
{
return Db.HashSet(key, field, Serialize(value));
}
/// <summary>写入并设置整个 key 过期时间(秒),对应 hset(key, item, value, time)</summary>
public bool HSet(string key, string field, object value, long seconds)
{
var ok = HSet(key, field, value);
if (ok)
{
Expire(key, seconds);
}
return ok;
}
/// <summary>批量写入,对应 hmset</summary>
public void HMSet(string key, Dictionary<string, object> map, long? seconds = null)
{
var entries = map.Select(kv => new HashEntry(kv.Key, Serialize(kv.Value))).ToArray();
Db.HashSet(key, entries);
if (seconds.HasValue)
{
Expire(key, seconds.Value);
}
}
public void HDel(string key, params string[] fields)
{
Db.HashDelete(key, fields.Select(f => (RedisValue)f).ToArray());
}
public bool HHasKey(string key, string field)
{
return Db.HashExists(key, field);
}
public double HIncr(string key, string field, double by = 1)
{
return Db.HashIncrement(key, field, by);
}
public double HDecr(string key, string field, double by = 1)
{
return Db.HashDecrement(key, field, by);
}
// ==================== Set ====================
public List<T> SGet<T>(string key)
{
return Db.SetMembers(key)
.Select(v => JsonConvert.DeserializeObject<T>(v))
.ToList();
}
public bool SHasKey(string key, object value)
{
return Db.SetContains(key, Serialize(value));
}
/// <summary>向 Set 添加成员,返回新增数量,对应 sSet</summary>
public long SAdd(string key, params object[] values)
{
return Db.SetAdd(key, values.Select(Serialize).ToArray());
}
/// <summary>添加并设置过期时间(秒),对应 sSetAndTime</summary>
public long SAdd(string key, long seconds, params object[] values)
{
var count = SAdd(key, values);
Expire(key, seconds);
return count;
}
public long SSize(string key)
{
return Db.SetLength(key);
}
/// <summary>移除成员,返回移除数量,对应 setRemove</summary>
public long SRemove(string key, params object[] values)
{
return Db.SetRemove(key, values.Select(Serialize).ToArray());
}
// ==================== List ====================
/// <summary>按区间读取(-1 表示末尾),对应 lGet</summary>
public List<T> LGet<T>(string key, long start = 0, long end = -1)
{
return Db.ListRange(key, start, end)
.Select(v => JsonConvert.DeserializeObject<T>(v))
.ToList();
}
public long LSize(string key)
{
return Db.ListLength(key);
}
public T LGetIndex<T>(string key, long index)
{
var value = Db.ListGetByIndex(key, index);
return value.IsNullOrEmpty ? default : JsonConvert.DeserializeObject<T>(value);
}
/// <summary>右推入单个元素,对应 lSet(key, value)</summary>
public long LPush(string key, object value, long? seconds = null)
{
var len = Db.ListRightPush(key, Serialize(value));
if (seconds.HasValue)
{
Expire(key, seconds.Value);
}
return len;
}
/// <summary>右推入整个列表(覆盖式写入对应 Java lSet(key, list))</summary>
public long LPushRange(string key, IEnumerable<object> values, long? seconds = null)
{
var len = Db.ListRightPush(key, values.Select(Serialize).ToArray());
if (seconds.HasValue)
{
Expire(key, seconds.Value);
}
return len;
}
/// <summary>按索引更新,对应 lUpdateIndex</summary>
public void LSetByIndex(string key, long index, object value)
{
Db.ListSetByIndex(key, index, Serialize(value));
}
/// <summary>移除 count 个等于 value 的元素,对应 lRemove</summary>
public long LRemove(string key, long count, object value)
{
return Db.ListRemove(key, Serialize(value), count);
}
// ==================== 分布式锁 ====================
/// <summary>
/// 尝试获取分布式锁(SET NX EX)。token 建议使用 Guid,
/// 释放时必须传入同一 token(防止误删他人锁)。
/// </summary>
public bool TryLock(string name, string token, int expireSeconds = 30)
{
return Db.StringSet(RedisConstants.LockKeyOf(name), token,
TimeSpan.FromSeconds(expireSeconds), When.NotExists);
}
/// <summary>释放锁(Lua 校验 token 后删除,原子操作)</summary>
public bool ReleaseLock(string name, string token)
{
const string script =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
var result = (int)Db.ScriptEvaluate(script,
new RedisKey[] { RedisConstants.LockKeyOf(name) },
new RedisValue[] { token });
return result == 1;
}
private static RedisValue Serialize(object value)
{
return value is string s ? s : JsonConvert.SerializeObject(value);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace YhCore.Common.Config
{
/// <summary>
/// 全局配置门面(对齐 Java com.yunhai.bschclassmanage.config.Config 枚举:
/// API_PATH / MOBILEAPI_PATH / OAPI + FILE_WEB_PATH / FILE_PATH)。
/// 路由常量复用 <see cref="YhCore.Common.ApiRoutes"/>;文件路径由 Startup 从配置初始化。
/// </summary>
public static class ApiPathConfig
{
/// <summary>PC 端接口前缀(对应 Java Config.API_PATH)</summary>
public const string ApiPath = "api";
/// <summary>移动端接口前缀(对应 Java Config.MOBILEAPI_PATH)</summary>
public const string MobiApiPath = "mobiapi";
/// <summary>开放接口前缀(对应 Java Config.OAPI)</summary>
public const string OApiPath = "oapi";
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace YhCore.Common.Config
{
/// <summary>
/// 全局配置静态门面(对齐 Java com.yunhai.bschclassmanage.config.Config 枚举:
/// API_PATH / MOBILEAPI_PATH / OAPI + FILE_WEB_PATH / FILE_PATH)。
/// 路由常量复用 <see cref="YhCore.Common.ApiRoutes"/>;文件路径由 Startup 从配置初始化。
/// </summary>
public static class Config
{
/// <summary>PC 端接口前缀(对应 Java Config.API_PATH)</summary>
public const string ApiPath = "api";
/// <summary>移动端接口前缀(对应 Java Config.MOBILEAPI_PATH)</summary>
public const string MobiApiPath = "mobiapi";
/// <summary>开放接口前缀(对应 Java Config.OAPI)</summary>
public const string OApiPath = "oapi";
/// <summary>文件存储根路径(对应 Java FILE_PATH,由 Startup 注入)</summary>
public static string FilePath { get; set; }
/// <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; }
}
}
@@ -0,0 +1,17 @@
namespace YhCore.Common.Config
{
/// <summary>
/// 数据库配置(对应 appsettings DatabaseProvider + ConnectionStrings 节)。
/// </summary>
public class DatabaseConfig
{
/// <summary>数据库提供者:SqlServer / MySql</summary>
public string DatabaseProvider { get; set; } = "SqlServer";
/// <summary>SQL Server 连接串</summary>
public string SqlServer { get; set; }
/// <summary>MySQL 连接串</summary>
public string Mysql { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace YhCore.Common.Config
{
/// <summary>
/// 文件存储配置(对应 appsettings FILE_PATH / FILE_WEB_PATH,对齐 Java config.FileConfig)。
/// </summary>
public class FileConfig
{
/// <summary>文件存储根路径(对应 Java FILE_PATH)</summary>
public string FilePath { get; set; }
/// <summary>文件访问 URL 前缀(对应 Java FILE_WEB_PATH)</summary>
public string FileWebPath { get; set; }
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace YhCore.Common.Config
{
/// <summary>
/// Redis 配置(对应 appsettings Redis 节,对齐 Java config.RedisConfig)。
/// </summary>
public class RedisConfig
{
/// <summary>连接串(如 localhost:6379,defaultDatabase=0)</summary>
public string ConnectionString { get; set; } = "localhost:6379,defaultDatabase=0";
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace YhCore.Common.Config
{
/// <summary>
/// Swagger 配置(对应 appsettings Swagger 节,对齐 Java config.SwaggerConfig)。
/// </summary>
public class SwaggerConfig
{
public string Title { get; set; } = "YhCore API";
public string Description { get; set; }
public string Version { get; set; } = "v1";
/// <summary>分组(api / mobiapi / oapi)</summary>
public string[] Groups { get; set; } = { ApiPathConfig.ApiPath, ApiPathConfig.MobiApiPath, ApiPathConfig.OApiPath };
}
}
@@ -0,0 +1,30 @@
using Yh.Platform.Abstractions;
namespace YhCore.Common.Exceptions
{
/// <summary>int 错误码 → 新框架 <see cref="IErrorCode"/> 映射(对齐 Java ErrorCode 常量值)。</summary>
/// <remarks>
/// 用途:业务直接抛 <see cref="DException"/> 时,把数值错误码转成 IErrorCode:
/// <code>throw new DException(ErrorCodeUtil.From(5001), "库存不足");</code>
/// </remarks>
public static class ErrorCodeUtil
{
public const int Default = 1;
/// <summary>按数值映射平台已知错误码;未知码保留原值(避免业务自定义错误码被吞成 Error=1)。</summary>
public static IErrorCode From(int code) => code switch
{
0 => ErrorCode.Success,
2 => ErrorCode.ParamError,
3 => ErrorCode.Forbidden,
4 => ErrorCode.Unauthorized,
5 => ErrorCode.Custom,
6 => ErrorCode.InternalServerError,
405 => ErrorCode.HttpMethodNotAllowed,
_ => new RawErrorCode(code, ""),
};
/// <summary>未知码透传实现:保留调用方原始 code,msg 由业务消息承载。</summary>
private sealed record RawErrorCode(int Code, string Msg) : IErrorCode;
}
}
+52
View File
@@ -0,0 +1,52 @@
using Newtonsoft.Json;
using Yh.Platform.Abstractions;
namespace YhCore.Common.Result
{
/// <summary>
/// 统一响应(对应 Java yh-api-std:com.yunhai.platform.apistd.ApiResponse)。
/// 信封字段 code/msg/data 以 [JsonProperty] 显式钉死小写——全局序列化已改为
/// DefaultContractResolver 原样输出(data 内容对齐旧项目混合大小写字段),信封不受影响。
/// 注意:Controller 返回值由全局过滤器 GlobalWrapResultFilter 自动包装;
/// 不需要包装的接口用 [NoWrapResult] 特性显式排除(CAS 协议端点、文件下载等)。
/// </summary>
public class ApiResult<T>
{
/// <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 };
public static ApiResult<T> Ok() => new() { Code = ErrorCode.Success.Code, Msg = "" };
/// <summary>失败(对齐 Java fail(IErrorCode) / fail(int, String))</summary>
public static ApiResult<T> Fail(int code, string msg) => new() { Code = code, Msg = msg };
/// <summary>失败(保留旧签名:msg 在前,默认错误码 ERROR=1)</summary>
public static ApiResult<T> Fail(string msg, int code = 1) => new() { Code = code, Msg = msg };
/// <summary>失败(对齐 Java fail(IErrorCode[, String]),msg 缺省取错误码默认消息)</summary>
public static ApiResult<T> Fail(IErrorCode errorCode, string msg = null) => new()
{
Code = errorCode.Code,
Msg = string.IsNullOrEmpty(msg) ? errorCode.Msg : msg
};
}
/// <summary>无数据体的统一响应便捷类</summary>
public static class ApiResult
{
public static ApiResult<object> Ok(object data = null) => ApiResult<object>.Ok(data);
public static ApiResult<object> Fail(string msg, int code = 1) => ApiResult<object>.Fail(msg, code);
public static ApiResult<object> Fail(int code, string msg) => ApiResult<object>.Fail(code, msg);
public static ApiResult<object> Fail(IErrorCode errorCode, string msg = null) => ApiResult<object>.Fail(errorCode, msg);
}
}
@@ -0,0 +1,46 @@
namespace YhCore.Common.UserContext
{
/// <summary>
/// 当前登录用户上下文(对应 Java 版 com.yunhai.bschclassmanage.utils.UserContext)。
/// 由 UserContextMiddleware 在请求入口写入,全链路通过 <see cref="UserContextHolder"/> 读取。
/// </summary>
public class UserContext
{
/// <summary>用户 ID(对应 BoaUser.id)</summary>
public int Id { get; set; }
/// <summary>用户姓名</summary>
public string UserName { get; set; }
/// <summary>登录账号</summary>
public string Account { get; set; }
/// <summary>所属学校 ID(对应 Java UserContext.getSchId())</summary>
public int? SchoolId { get; set; }
/// <summary>
/// 所属学校 UUID 标识(旧 SysUser.schId / SchInfoVo.SchSerId,如 "xxxx-xxxx-xxxx")。
/// 由 LegacyUserContextAdapter 通过教务 SDK GetSchFirstAsync 反查填充(进程内缓存,避免每请求调用);
/// 反查失败或无学校时为 null。
/// </summary>
public string SchSerID { get; set; }
/// <summary>所属学校名称</summary>
public string SchoolName { get; set; }
/// <summary>
/// 用户类型(对应 Java UserContext.getType() / BoaUser.Type;1=教师等,具体枚举见用户中心约定)。
/// </summary>
public int? UserType { get; set; }
/// <summary>
/// 角色 ID(对应 Java UserContext.getRoleId():取 BoaUser.RoleIds 首个,回退 CurrentRole.RoleId)。
/// </summary>
public int? Role { get; set; }
/// <summary>
/// 用户中心返回的完整 BoaUser(JSON 从认证 Claim 反序列化,GetUserInfo 直接返回)。
/// </summary>
public object FullBoaUser { get; set; }
}
}
@@ -0,0 +1,23 @@
using System.Threading;
namespace YhCore.Common.UserContext
{
/// <summary>
/// 用户上下文持有器。基于 <see cref="AsyncLocal{T}"/> 实现,
/// 是 Java ThreadLocal 方案在 .NET async 环境下的安全等价物。
/// </summary>
public static class UserContextHolder
{
private static readonly AsyncLocal<UserContext> _current = new AsyncLocal<UserContext>();
/// <summary>当前请求的用户;未登录或中间件未写入时为 null。</summary>
public static UserContext Current
{
get => _current.Value;
set => _current.Value = value;
}
/// <summary>当前用户学校 ID;无上下文时返回 0(对应 Java UserContext.getSchId())。</summary>
public static int GetSchId() => _current.Value?.SchoolId ?? 0;
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace YhCore.Common.Utils
{
/// <summary>
/// 分页元信息(对应 Java 版 model.tool.PageInfo)。
/// C# 属性为 PascalCase,ASP.NET Core 默认 camelCase 序列化后
/// JSON 字段与 Java 版一致(pageNum/pageSize/total/totalPages/hasNext/hasPrev)。
/// </summary>
public class PageInfo
{
/// <summary>当前页码(从 0 开始,与 Spring Pageable 一致)</summary>
public int PageNum { get; set; }
/// <summary>每页大小</summary>
public int PageSize { get; set; }
/// <summary>总记录数</summary>
public int Total { get; set; }
/// <summary>总页数</summary>
public int TotalPages { get; set; }
/// <summary>是否有下一页</summary>
public bool HasNext { get; set; }
/// <summary>是否有上一页</summary>
public bool HasPrev { get; set; }
public PageInfo()
{
}
public PageInfo(int pageNum, int pageSize, int total, int totalPages, bool hasNext, bool hasPrev)
{
PageNum = pageNum;
PageSize = pageSize;
Total = total;
TotalPages = totalPages;
HasNext = hasNext;
HasPrev = hasPrev;
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace YhCore.Common.Utils
{
/// <summary>
/// 内存分页工具(对应 Java 版 utils.PageUtil)。
/// 用于已对全量数据在内存中的场景(如外部接口聚合结果)做分页;
/// 数据库分页请在 Repository 层用 IQueryable Skip/Take 下推,不要本类。
/// 页码从 0 开始,与 Spring Pageable 一致。
/// </summary>
public static class PageUtil
{
/// <summary>
/// 对列表做内存分页,返回当前页数据(对应 Java paginate)。
/// 空列表或页码越界时返回空列表。
/// </summary>
/// <param name="list">全量数据</param>
/// <param name="pageNum">页码,从 0 开始</param>
/// <param name="pageSize">每页大小</param>
public static List<T> Paginate<T>(IReadOnlyCollection<T> list, int pageNum, int pageSize)
{
if (list == null || list.Count == 0 || pageSize <= 0)
{
return new List<T>();
}
var fromIndex = pageNum * pageSize;
if (fromIndex >= list.Count)
{
return new List<T>();
}
return list.Skip(fromIndex).Take(pageSize).ToList();
}
/// <summary>
/// 获取分页元信息(对应 Java getPageInfo)。
/// </summary>
/// <param name="list">原始数据列表</param>
/// <param name="pageNum">页码,从 0 开始</param>
/// <param name="pageSize">每页大小</param>
public static PageInfo GetPageInfo<T>(IReadOnlyCollection<T> list, int pageNum, int pageSize)
{
var total = list?.Count ?? 0;
var totalPages = pageSize > 0 ? (int)Math.Ceiling((double)total / pageSize) : 0;
return new PageInfo(
pageNum,
pageSize,
total,
totalPages,
hasNext: pageNum < totalPages - 1,
hasPrev: pageNum > 0);
}
/// <summary>
/// 内存分页 + 元信息一次完成(Paginate 与 GetPageInfo 的组合便捷方法)。
/// </summary>
public static (List<T> Items, PageInfo Info) PaginateWithInfo<T>(
IReadOnlyCollection<T> list, int pageNum, int pageSize)
{
return (Paginate(list, pageNum, pageSize), GetPageInfo(list, pageNum, pageSize));
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>YhCore.Common</RootNamespace>
<AssemblyName>YhCore.Common</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StackExchange.Redis" Version="2.6.122" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<!-- 平台模型与信封(DException/ErrorCode/BoaUser 等):从 Nexus nuget-group 引用(原为本地源项目引用) -->
<PackageReference Include="Yh.Platform.Abstractions" Version="1.0.0-preview.2" />
</ItemGroup>
</Project>
+25
View File
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class A_DataLog
{
public int SerID { get; set; }
public string SchSerID { get; set; }
public string ModuleName { get; set; }
public string TableName { get; set; }
public Nullable<int> OldSerID { get; set; }
public Nullable<int> NowSerID { get; set; }
public Nullable<int> RId { get; set; }
}
}
+33
View File
@@ -0,0 +1,33 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class AppMsgRecord
{
public int SerID { get; set; }
public int AppSerID { get; set; }
public string AppName { get; set; }
public string AppCode { get; set; }
public string MsgType { get; set; }
public string MsgContent { get; set; }
public string MsgError { get; set; }
public string UserID { get; set; }
public string SUserID { get; set; }
public string SUserPhone { get; set; }
public string SendType { get; set; }
public string TabCode { get; set; }
public string SchSerID { get; set; }
public Nullable<System.DateTime> Datetime { get; set; }
public Nullable<int> Read { get; set; }
}
}
+28
View File
@@ -0,0 +1,28 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Base_Log
{
public Nullable<System.DateTime> Date { get; set; }
public Nullable<int> Thread { get; set; }
public string LEVEL { get; set; }
public string Logger { get; set; }
public string MESSAGE { get; set; }
public string EXCEPTION { get; set; }
public int Id { get; set; }
public string AppName { get; set; }
public string UserId { get; set; }
public string Type { get; set; }
}
}
+30
View File
@@ -0,0 +1,30 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_Dictionary
{
public decimal SerID { get; set; }
public string typeID { get; set; }
public string typeName { get; set; }
public string CodeID { get; set; }
public string Name { get; set; }
public string Des { get; set; }
public int Enable { get; set; }
public string Schserid { get; set; }
public Nullable<System.Guid> PID { get; set; }
public string Path { get; set; }
public Nullable<int> Sort { get; set; }
public string Code { get; set; }
}
}
+23
View File
@@ -0,0 +1,23 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_DictionaryOrd
{
public int SerID { get; set; }
public int DicSerID { get; set; }
public string SchSerID { get; set; }
public Nullable<bool> Open { get; set; }
public Nullable<int> Ord { get; set; }
}
}
+24
View File
@@ -0,0 +1,24 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_Groupuser
{
public int id { get; set; }
public Nullable<int> usergroup { get; set; }
public Nullable<int> userid { get; set; }
public Nullable<int> flowid { get; set; }
public string SchSerID { get; set; }
public Nullable<int> IsOk { get; set; }
}
}
@@ -0,0 +1,24 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_Groupuser_SHRole
{
public int id { get; set; }
public Nullable<int> usergroup { get; set; }
public Nullable<decimal> SHRoleid { get; set; }
public Nullable<int> flowid { get; set; }
public string SchSerID { get; set; }
public Nullable<int> IsOk { get; set; }
}
}
+24
View File
@@ -0,0 +1,24 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_SendUserinfo
{
public int SerID { get; set; }
public Nullable<int> userid { get; set; }
public string Senduserid { get; set; }
public string schserid { get; set; }
public Nullable<System.DateTime> createTime { get; set; }
public Nullable<int> flowType { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_UpFiles
{
public int SerID { get; set; }
public string TopicType { get; set; }
public Nullable<int> TopicID { get; set; }
public string FileTrueName { get; set; }
public string FileName { get; set; }
public Nullable<int> FileSize { get; set; }
public string FileType { get; set; }
public Nullable<System.DateTime> DateAndTime { get; set; }
public Nullable<int> DownTimes { get; set; }
}
}
@@ -0,0 +1,26 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_UserFlowZhipai
{
public int SerID { get; set; }
public int UserID { get; set; }
public int MSerID { get; set; }
public Nullable<int> FlowID { get; set; }
public Nullable<int> FlowStepID { get; set; }
public string zhipai { get; set; }
public string type { get; set; }
public string SchSerID { get; set; }
}
}
+30
View File
@@ -0,0 +1,30 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_flowinfo
{
public int SerID { get; set; }
public string flowname { get; set; }
public string flowdes { get; set; }
public int enable { get; set; }
public int flowType { get; set; }
public System.DateTime deitdate { get; set; }
public string schSerID { get; set; }
public string deptid { get; set; }
public string typeid { get; set; }
public string ChaoSend { get; set; }
public Nullable<int> StepSend { get; set; }
public string WYPageGG { get; set; }
}
}
+28
View File
@@ -0,0 +1,28 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_flowstep
{
public int SerID { get; set; }
public Nullable<int> flowid { get; set; }
public string StepName { get; set; }
public Nullable<int> ordernum { get; set; }
public Nullable<int> prestep { get; set; }
public Nullable<int> action_user { get; set; }
public string SchSerID { get; set; }
public Nullable<int> cond { get; set; }
public string isshowJE { get; set; }
public string isshowBtn { get; set; }
}
}
@@ -0,0 +1,30 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_flowtranctInfo
{
public int SerID { get; set; }
public string datatype { get; set; }
public int dataid { get; set; }
public int stepid { get; set; }
public Nullable<int> actionuserid { get; set; }
public short actionstatus { get; set; }
public Nullable<System.DateTime> actiondate { get; set; }
public string remark { get; set; }
public string undertaker { get; set; }
public string Pid { get; set; }
public string DoneUserID { get; set; }
public string filespath { get; set; }
}
}
+23
View File
@@ -0,0 +1,23 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_flowuser
{
public int id { get; set; }
public Nullable<int> usergroup { get; set; }
public Nullable<int> userid { get; set; }
public Nullable<int> flowid { get; set; }
public string SchSerID { get; set; }
}
}
+24
View File
@@ -0,0 +1,24 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_freeGroupuser
{
public int id { get; set; }
public Nullable<int> usergroup { get; set; }
public Nullable<int> userid { get; set; }
public Nullable<int> flowid { get; set; }
public string SchSerID { get; set; }
public Nullable<int> IsOk { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class Common_freeflowinfo
{
public int SerID { get; set; }
public Nullable<int> userid { get; set; }
public string shenheuserid { get; set; }
public string schserid { get; set; }
public Nullable<int> steptype { get; set; }
public Nullable<System.DateTime> createTime { get; set; }
public Nullable<int> flowType { get; set; }
public Nullable<int> dataid { get; set; }
public Nullable<int> laststep { get; set; }
}
}
+73
View File
@@ -0,0 +1,73 @@
using System;
namespace YhCore.Entity
{
/// <summary>
/// 系统审计日志(对应 Java 版 entity.SysOperationLog,表 sys_operation_log)。
/// HTTP 接口级全量日志:由 SysOperationLogFilter 在每次 Controller 请求后写入。
/// 字段与 Java 实体一一对齐;重试三字段(RetryCount/LastRetryStatus/LastRetryTime)
/// 为 Java 双轨同步保留,.NET 侧当前不写入(保持 NULL/默认)。
/// 表名映射见 YHDbContext.OnModelCreating:ToTable("sys_operation_log")。
/// </summary>
public class SysOperationLog
{
public long Id { get; set; }
/// <summary>日志级别:1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)</summary>
public int LogLevel { get; set; }
/// <summary>操作类型:DELETE / CREATE / UPDATE / QUERY</summary>
public string OperationType { get; set; }
/// <summary>操作人 ID(来自 UserContext.Id)</summary>
public int? UserId { get; set; }
/// <summary>操作人姓名(来自 UserContext.UserName,匿名时 anonymous)</summary>
public string UserName { get; set; }
/// <summary>学校 ID(多校隔离,来自 UserContext.SchoolId)</summary>
public int? SchId { get; set; }
/// <summary>客户端 IP</summary>
public string RequestIp { get; set; }
/// <summary>请求 URI</summary>
public string RequestUri { get; set; }
/// <summary>HTTP 方法</summary>
public string RequestMethod { get; set; }
/// <summary>类名.方法名</summary>
public string ClassMethod { get; set; }
/// <summary>请求参数(JSON 序列化,含截断)</summary>
public string RequestParams { get; set; }
/// <summary>GZIP 压缩后的响应 JSON</summary>
public byte[] ResponseData { get; set; }
/// <summary>业务状态码:0 成功 / 非 0 失败(全局返回对象 code)</summary>
public int StatusCode { get; set; }
/// <summary>耗时(ms)</summary>
public long CostTime { get; set; }
/// <summary>User-Agent</summary>
public string UserAgent { get; set; }
/// <summary>异常信息(截断 8000)</summary>
public string ErrorMsg { get; set; }
/// <summary>重试次数(对齐 Java 双轨字段,暂不写入)</summary>
public int? RetryCount { get; set; }
/// <summary>上次重试状态:1 成功 / 0 失败(对齐 Java 双轨字段,暂不写入)</summary>
public int? LastRetryStatus { get; set; }
/// <summary>上次重试时间(对齐 Java 双轨字段,暂不写入)</summary>
public DateTime? LastRetryTime { get; set; }
/// <summary>记录时间</summary>
public DateTime CreateTime { get; set; }
}
}
+47
View File
@@ -0,0 +1,47 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_DocPrint
{
public decimal SerID { get; set; }
public string DocTitle { get; set; }
public string DocType { get; set; }
public string DocStyle { get; set; }
public int DocNum { get; set; }
public string DocContent { get; set; }
public string UserID { get; set; }
public System.DateTime EditDate { get; set; }
public string ChkUserID { get; set; }
public string DocStatus { get; set; }
public string ChkDate { get; set; }
public string UpFile { get; set; }
public string DPType { get; set; }
public Nullable<System.DateTime> TakeDate { get; set; }
public string DocCode { get; set; }
public Nullable<int> DocStep { get; set; }
public Nullable<int> flowid { get; set; }
public Nullable<int> rid { get; set; }
public Nullable<int> reid { get; set; }
public string SchSerID { get; set; }
public string explain { get; set; }
public string grade { get; set; }
public string Examtype { get; set; }
public Nullable<int> DocFix { get; set; }
public Nullable<int> DocShare { get; set; }
public string FreeUserID { get; set; }
public string SendUserID { get; set; }
public Nullable<int> freestepid { get; set; }
public string zhipai { get; set; }
}
}
+32
View File
@@ -0,0 +1,32 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_DocPrint_Num
{
public decimal SerID { get; set; }
public Nullable<decimal> DocPrintID { get; set; }
public string YSLXName { get; set; }
public string YSLXtypeID { get; set; }
public Nullable<double> YSLXOrder { get; set; }
public string YSGGName { get; set; }
public string YSGGtypeID { get; set; }
public Nullable<double> YSGGOrder { get; set; }
public Nullable<int> DocNum { get; set; }
public Nullable<int> DocPage { get; set; }
public Nullable<int> DocFix { get; set; }
public Nullable<int> DocShare { get; set; }
public Nullable<double> UnitPrice { get; set; }
public Nullable<double> TotalPrice { get; set; }
}
}
+29
View File
@@ -0,0 +1,29 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_DocPrint_SH
{
public decimal SerID { get; set; }
public Nullable<decimal> DocPrintSerID { get; set; }
public Nullable<decimal> flowSerID { get; set; }
public Nullable<decimal> stepSerID { get; set; }
public Nullable<int> stepOrder { get; set; }
public Nullable<decimal> SHTeaID { get; set; }
public string SHTeaName { get; set; }
public string AndOr { get; set; }
public string isSH { get; set; }
public Nullable<System.DateTime> inserttime { get; set; }
public string writor { get; set; }
}
}
+23
View File
@@ -0,0 +1,23 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_SHRole
{
public decimal SerID { get; set; }
public string SchSerId { get; set; }
public string SHRoleName { get; set; }
public Nullable<System.DateTime> inserttime { get; set; }
public string writor { get; set; }
}
}
+25
View File
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_SHRole_BSHTea
{
public decimal SerID { get; set; }
public string SchSerID { get; set; }
public Nullable<decimal> SHRoleID { get; set; }
public Nullable<decimal> BSHTeaID { get; set; }
public string BSHTeaName { get; set; }
public Nullable<System.DateTime> inserttime { get; set; }
public string writor { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_SHRole_SHTea
{
public decimal SerID { get; set; }
public string SchSerID { get; set; }
public Nullable<decimal> SHRoleID { get; set; }
public Nullable<decimal> SHTeaID { get; set; }
public string SHTeaName { get; set; }
public string AndOr { get; set; }
public string photo { get; set; }
public Nullable<System.DateTime> inserttime { get; set; }
public string writor { get; set; }
}
}
+21
View File
@@ -0,0 +1,21 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_SchZyBit
{
public int SerID { get; set; }
public string SchSerID { get; set; }
public Nullable<bool> ziyuanbit { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码已从模板生成。
//
// 手动更改此文件可能导致应用程序出现意外的行为。
// 如果重新生成代码,将覆盖对此文件的手动更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace YhCore.Entity
{
using System;
using System.Collections.Generic;
public partial class WY_reGetJERd
{
public decimal SerID { get; set; }
public string SchSerID { get; set; }
public Nullable<decimal> DocPrintID { get; set; }
public string DocTitle { get; set; }
public string DocContent { get; set; }
public Nullable<System.DateTime> EditDatetime { get; set; }
public string writor { get; set; }
public string type { get; set; }
public string UnitPrice { get; set; }
}
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>YhCore.Entity</RootNamespace>
<AssemblyName>YhCore.Entity</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
</Project>
+117
View File
@@ -0,0 +1,117 @@
using System;
namespace YhCore.Model.Dto
{
/// <summary>字典下拉框选项(旧 GetDictList 的 {k=CodeID, v=Name})。</summary>
public class DicOption
{
public string K { get; set; }
public string V { get; set; }
}
/// <summary>字典管理列表项(旧 GetDictMangeList 的 {SerID, CodeID, Name, Des, Enable})。</summary>
public class DicManageItem
{
public decimal SerID { get; set; }
public string CodeID { get; set; }
public string Name { get; set; }
public string Des { get; set; }
public int Enable { get; set; }
}
/// <summary>文印分类 / 印制版面列表项(旧 CategoryList / YZBMList)。</summary>
public class CategoryItem
{
public decimal SerID { get; set; }
public string CodeID { get; set; }
public string Name { get; set; }
public string Des { get; set; }
public int Enable { get; set; }
public int? Sort { get; set; }
public string SchSerID { get; set; }
public string UserSchSerID { get; set; }
public bool _disabled { get; set; }
}
/// <summary>公共类别列表项(旧 DictionaryTypeList)。</summary>
public class DictionaryTypeItem
{
public string Code { get; set; }
public decimal SerID { get; set; }
public string CodeID { get; set; }
public string Name { get; set; }
public string Des { get; set; }
public int Enable { get; set; }
public int? Sort { get; set; }
public string SchSerID { get; set; }
public string UserSchSerID { get; set; }
public bool _disabled { get; set; }
public bool Status { get; set; }
public bool Open { get; set; }
public int Ord { get; set; }
public string Path { get; set; }
}
/// <summary>
/// 字典类型常量与映射(对齐旧 EnumConfig.Dictionary 枚举及 CommonController 的硬编码 switch)。
/// </summary>
public static class DictionaryTypeHelper
{
/// <summary>GetSysDic 入参 Type(1~10) → 字典 typeID(对齐旧 switch)。</summary>
private static readonly string[] SysDic =
{ null, "DEPT", "WYFL", "YZBM", "YZFL", "LeaveType", "FoodType", "Unit", "LevelOrder", "filetype", "doclevel" };
/// <summary>旧 EnumConfig.Dictionary 枚举序值 → typeID(用于 GetDictMangeList 的 Type 参数)。</summary>
private static readonly string[] EnumOrdinal =
{ "DEPT", "BXBM", "WYFL", "YZBM", "YZFL", "LeaveType", "FoodType", "Unit", "VenueType", "Vefacilities", "Time", "Repeat", "LevelOrder", "filetype", "doclevel", "XKCategory" };
/// <summary>全部字典类别(typeID, typeName),对齐旧 EnumConfig.Dictionary 的 Description。</summary>
public static readonly (string Id, string Name)[] AllTypes =
{
("WYFL", "文印分类"),
("YZBM", "印制版面"),
("YZFL", "印制分类"),
("filetype", "文件类型")
};
/// <summary>GetSysDic 的 Type(int) → typeID;越界返回 null。</summary>
public static string FromSysDicType(int type) => type >= 1 && type <= 10 ? SysDic[type] : null;
/// <summary>GetDictMangeList 的 Type 参数 → typeID(支持枚举名、枚举序值、typeID 直传)。</summary>
public static string ResolveTypeId(string type)
{
if (string.IsNullOrEmpty(type)) return null;
foreach (var t in AllTypes)
{
if (t.Id == type) return t.Id;
}
if (int.TryParse(type, out var n) && n >= 0 && n < EnumOrdinal.Length)
{
return EnumOrdinal[n];
}
return type;
}
/// <summary>typeID → typeName(描述);未知则原样返回。</summary>
public static string GetTypeName(string typeId)
{
foreach (var t in AllTypes)
{
if (t.Id == typeId) return t.Name;
}
return typeId;
}
public static bool IsDept(string typeId) => typeId == "DEPT";
/// <summary>Des(排序字符串)→ int,解析失败/空回退 int.MaxValue(对齐旧 int.Parse 的降级,避免非数值崩)。</summary>
public static int ParseSort(string des)
{
if (string.IsNullOrEmpty(des)) return int.MaxValue;
return int.TryParse(des, out var n) ? n : int.MaxValue;
}
}
}
@@ -0,0 +1,73 @@
using System.Collections.Generic;
namespace YhCore.Model.Dto
{
/// <summary>
/// 文印统计看板 DTO(移植旧 BestSchool.DocPrint.DocPrintStatistics,5 接口)。
/// 旧实现以 dynamic 匿名对象返回 {k,v} 结构,新项目统一为强类型 DTO。
/// 说明:旧 GetSchSerID(int) 走 HTTP 把 int schId 映射为 GUID;新库 SchSerID 约定已是 int 字符串,
/// 故此处直接用 schId.ToString() 匹配,不再依赖外部转换。
/// </summary>
/// <summary>统计项(旧 GetDocPrintCount 的 {k,v},v 为数量或类型名)。</summary>
public class DocPrintCountItem
{
public string k { get; set; }
public object v { get; set; }
}
/// <summary>饼图分类项(旧 GetPieChart 的 series.list 元素 {k,v})。</summary>
public class PieChartKv
{
public string k { get; set; }
public object v { get; set; }
}
/// <summary>饼图系列(旧 GetPieChart 的 {title,list})。</summary>
public class PieChartSeries
{
public string title { get; set; }
public List<PieChartKv> list { get; set; } = new List<PieChartKv>();
}
/// <summary>印刷类型下拉项(旧 GetDocPrintType 的 {CodeID,Name})。</summary>
public class DocPrintTypeItem
{
public string CodeID { get; set; }
public string Name { get; set; }
}
/// <summary>文印统计列表项(旧 GetDocPrintList 的投影)。</summary>
public class DocPrintStatItem
{
public int SerID { get; set; }
public string DocContent { get; set; }
public string DocType { get; set; }
public int DocNum { get; set; }
public string DocTypeName { get; set; }
public string DocStyle { get; set; }
public string DocStyleName { get; set; }
public string EditDate { get; set; }
public string TakeDate { get; set; }
public string UserID { get; set; }
public string teaname { get; set; }
public string UpFile { get; set; }
public string DocTitle { get; set; }
public string DocStatus { get; set; }
public string DocStatusName { get; set; }
public int? DocStep { get; set; }
public int? flowid { get; set; }
public string FreeUserID { get; set; }
public string SendUserID { get; set; }
public int? freestepid { get; set; }
public int? DocShare { get; set; }
public string explain { get; set; }
}
/// <summary>文印统计列表结果(旧 GetDocPrintList 的 {tlist,totle})。</summary>
public class DocPrintListResult
{
public List<DocPrintStatItem> tlist { get; set; } = new List<DocPrintStatItem>();
public int totle { get; set; }
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace YhCore.Model.Dto
{
/// <summary>
/// 文件信息(旧 BestSchool.Entity.FileObj,GetEnclosure/FileSave/FileSaveImage/FileMergeSave 共用)。
/// fileurl 为相对路径(如 /upload/docprint/20260101/{guid}.jpg,与旧库数据格式一致);
/// host 为文件访问主机前缀(旧 AuthConfig.weboa_url,新 Config.FileWebPath)。
/// </summary>
public class FileObj
{
/// <summary>附件表 SerID(SaveEnclosure 落库后回填)。</summary>
public int fileid { get; set; }
/// <summary>文件大小(字节;旧 FileMergeSave 存的是 MB 整数,保持旧语义)。</summary>
public int filesize { get; set; }
/// <summary>扩展名(含点,如 ".jpg")。</summary>
public string filetype { get; set; }
/// <summary>文件真实名称(含扩展名)。</summary>
public string filename { get; set; }
/// <summary>相对访问路径(如 /upload/docprint/20260101/{guid}.jpg)。</summary>
public string fileurl { get; set; }
/// <summary>文件主机前缀(Config.FileWebPath)。</summary>
public string host { get; set; }
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Collections.Generic;
namespace YhCore.Model.Dto
{
/// <summary>自由流程审核人(旧 ApproveFlow.GetFleeFlow/GetAfterFleeFlow 的 user 数组元素)。</summary>
public class FreeFlowUser
{
public int userid { get; set; }
public string name { get; set; }
public string photo { get; set; }
}
/// <summary>自由流程节点(旧 GetFleeFlow 列表元素:{ user: [...] })。</summary>
public class FreeFlowNode
{
public List<FreeFlowUser> user { get; set; }
}
/// <summary>审核流程步骤信息(旧 ApproveFlow.FreeShenheinfo,Remark 序列化后为 camelCase 的 remark)。</summary>
public class FreeShenheInfo
{
public string steptname { get; set; }
public string steptstatus { get; set; }
public string photo { get; set; }
public string Remark { get; set; }
public string actiondate { get; set; }
public string actionstatus { get; set; }
}
/// <summary>抄送人(旧 ChaoSendList 返回 { uid, photo, name, status })。</summary>
public class ChaoSendUser
{
public int uid { get; set; }
public string photo { get; set; }
public string name { get; set; }
public bool status { get; set; }
}
}
+120
View File
@@ -0,0 +1,120 @@
using System.Collections.Generic;
namespace YhCore.Model.Dto
{
/// <summary>
/// DocPrint 应用常量(旧 WebHelper.AppCode / AuthConfig.AppKeyMap["docprint"][3])。
/// </summary>
public static class DocPrintConstants
{
/// <summary>文印申请 AppCode,即审核流程 / 自由流程的 flowType。</summary>
public const int AppCode = 1003;
}
/// <summary>审核流程列表项(旧 Reviewprocess.List / List2 的匿名类型)。</summary>
public class FlowListItem
{
public string SerID { get; set; }
public string flowname { get; set; }
public string deptid { get; set; }
public string typeid { get; set; }
public int enable { get; set; }
public string enablename { get; set; }
public string miaoshu { get; set; }
public string deitdate { get; set; }
}
/// <summary>步骤流程列表项(旧 StepList 的匿名类型)。</summary>
public class StepListItem
{
public string SerID { get; set; }
public string StepName { get; set; }
public string flowid { get; set; }
public int? prestep { get; set; }
public string TeaName { get; set; }
public string Teaid { get; set; }
public bool istrue { get; set; }
}
/// <summary>流程绑定步骤项(旧 BindstepList)。</summary>
public class BindStepItem
{
public int SerID { get; set; }
public string StepName { get; set; }
}
/// <summary>绑定步骤人员项(旧 BindGroupuserList)。</summary>
public class BindGroupuserItem
{
public int StepId { get; set; }
public int? userid { get; set; }
public string StepName { get; set; }
public string TeaName { get; set; }
}
/// <summary>抄送人(旧 Bindflowinfo 的 ChaoSendArr 元素)。</summary>
public class ChaoSendItem
{
public string uid { get; set; }
public string name { get; set; }
public string photo { get; set; }
}
/// <summary>绑定流程项(旧 Bindflowinfo)。</summary>
public class BindFlowItem
{
public string SerID { get; set; }
public string flowname { get; set; }
public List<ChaoSendItem> ChaoSendArr { get; set; }
}
/// <summary>教师下拉选项(旧 GetTeaList 的 {value,label})。</summary>
public class TeaOption
{
public int value { get; set; }
public string label { get; set; }
}
/// <summary>附件项(旧 filelist)。</summary>
public class FileListItem
{
public int SerID { get; set; }
public string FileTrueName { get; set; }
public string FileName { get; set; }
public int? FileSize { get; set; }
public string FileType { get; set; }
}
/// <summary>自由流程审核人(旧 IsShenheflow 的 OtherData 元素)。</summary>
public class FreeFlowItem
{
public string shenheuserid { get; set; }
public string teamnamestr { get; set; }
}
/// <summary>审核流程类型判断结果(旧 IsShenheflow:Data=flowflag,OtherData=自由流程审核人)。</summary>
public class ShenheFlowResult
{
public bool flowflag { get; set; }
public List<FreeFlowItem> freeflowlist { get; set; }
}
/// <summary>绑定抄送人(旧 IsSendUser)。</summary>
public class SendUserItem
{
public int uid { get; set; }
public string photo { get; set; }
public string name { get; set; }
public bool status { get; set; }
}
/// <summary>当前用户信息(旧 GetUser 返回 SysUser,新框架取 UserContext 有限字段)。</summary>
public class CurrentUserInfo
{
public int Id { get; set; }
public string UserName { get; set; }
public string Account { get; set; }
public int? SchoolId { get; set; }
public string SchoolName { get; set; }
}
}
+228
View File
@@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
namespace YhCore.Model.Dto
{
/// <summary>
/// 文印新增/修改入参(旧 BestSchool.Entity.Model.WY_DocPrintDto : WY_DocPrint + ctype/sms)。
/// SerID 仅修改时使用;UserID/SchSerID 由控制器从登录态覆盖。
/// </summary>
public class WYDocPrintSaveDto
{
public decimal SerID { get; set; }
public string DocTitle { get; set; }
public string DocType { get; set; }
public string DocStyle { get; set; }
public int DocNum { get; set; }
public string DocContent { get; set; }
public string UserID { get; set; }
public string UpFile { get; set; }
public string DPType { get; set; }
public DateTime? TakeDate { get; set; }
public int? DocStep { get; set; }
public int? flowid { get; set; }
public int? rid { get; set; }
public int? reid { get; set; }
public string SchSerID { get; set; }
public string explain { get; set; }
public string grade { get; set; }
public string Examtype { get; set; }
public int? DocFix { get; set; }
public int? DocShare { get; set; }
public string FreeUserID { get; set; }
public string SendUserID { get; set; }
public int? freestepid { get; set; }
public string zhipai { get; set; }
/// <summary>旧 WY_DocPrintDto.ctype(AllList 四分类标记),保存时不用,仅保持入参兼容。</summary>
public int ctype { get; set; }
/// <summary>旧 sms 短信开关;新项目无短信/待办通道,仅保留入参不生效。</summary>
public bool sms { get; set; }
}
/// <summary>
/// 审核动作入参(旧 Common_flowtranctInfoDto : Common_flowtranctInfo + SendUserID/MySendUserID/sms),
/// 用于 Revoke / shenheok / AffirmApplyforOk;actionuserid 由控制器从登录态覆盖。
/// </summary>
public class FlowTranctDto
{
public int dataid { get; set; }
public int stepid { get; set; }
public short actionstatus { get; set; }
public int? actionuserid { get; set; }
public string remark { get; set; }
public string undertaker { get; set; }
public string Pid { get; set; }
public string DoneUserID { get; set; }
public string filespath { get; set; }
public string SendUserID { get; set; }
public string MySendUserID { get; set; }
/// <summary>旧 sms 短信开关;新项目无短信通道,仅保留入参不生效。</summary>
public bool sms { get; set; }
}
/// <summary>
/// 文印列表项(旧 MyList/SendRdList/SPList/ShenheList/YishenList/totalList/AllList 的匿名投影,
/// 序列化 camelCase 后字段名与旧 JSON 一致;ctype 仅 AllList 填充)。
/// </summary>
public class DocPrintListItem
{
public int SerID { get; set; }
public string DocContent { get; set; }
public string DocType { get; set; }
public string DocTypeName { get; set; }
public string DocStyle { get; set; }
public string DocStyleName { get; set; }
public string DPType { get; set; }
public string DPTypeName { get; set; }
public string EditDate { get; set; }
public string TakeDate { get; set; }
public string UserID { get; set; }
public string teaname { get; set; }
public string TeaPhoto { get; set; }
public int? DocShare { get; set; }
public string UpFile { get; set; }
public string DocTitle { get; set; }
public string DocStatus { get; set; }
public string DocStatusName { get; set; }
public int? DocStep { get; set; }
public int? flowid { get; set; }
public bool _disabled { get; set; }
public string FreeUserID { get; set; }
public string SendUserID { get; set; }
public int? freestepid { get; set; }
public string explain { get; set; }
/// <summary>AllList 四分类标记:0=待我审 / 1=我已审 / 2=抄送我 / 3=我发起;仅 AllList 返回,其余列表为 null。</summary>
public int? ctype { get; set; }
}
/// <summary>
/// 文印单条详情(旧 MyListtoOne 的匿名投影:DocStep/flowid 为字符串,含完整业务字段;
/// 旧 OtherData.tempresultfile(附件明细)并入 data,因新统一响应无 OtherData 槽位)。
/// </summary>
public class DocPrintDetailItem
{
public int SerID { get; set; }
public string DocContent { get; set; }
public string DocType { get; set; }
public string DocTypeName { get; set; }
public string DocStyle { get; set; }
public string DocStyleName { get; set; }
public string DPType { get; set; }
public string DPTypeName { get; set; }
public string EditDate { get; set; }
public string TakeDate { get; set; }
public string UserID { get; set; }
public string teaname { get; set; }
public string TeaPhoto { get; set; }
public string UpFile { get; set; }
public string DocTitle { get; set; }
public string DocStatus { get; set; }
public string DocStatusName { get; set; }
/// <summary>旧实现为 item.DocStep.ToString()(null → 空串)。</summary>
public string DocStep { get; set; }
public string explain { get; set; }
/// <summary>旧实现为 item.flowid.ToString()(null → 空串)。</summary>
public string flowid { get; set; }
public int? DocShare { get; set; }
public int? DocFix { get; set; }
public int DocNum { get; set; }
public bool _disabled { get; set; }
public string FreeUserID { get; set; }
public string SendUserID { get; set; }
public int? freestepid { get; set; }
public string zhipai { get; set; }
public int? rid { get; set; }
public int? reid { get; set; }
public string grade { get; set; }
public string Examtype { get; set; }
/// <summary>旧 OtherData.tempresultfile:附件明细(office 文件替换为 .pdf 预览名)。</summary>
public List<DocPrintFileItem> tempresultfile { get; set; }
}
/// <summary>抄送人详情(旧 SendDetailsList 的 {TeaName, TeaPhoto})。</summary>
public class SendDetailItem
{
public string TeaName { get; set; }
public string TeaPhoto { get; set; }
}
/// <summary>附件项(旧 WY_list.filelist / MyListtoOne.tempresultfile)。</summary>
public class DocPrintFileItem
{
public int SerID { get; set; }
public string FileTrueName { get; set; }
public string FileName { get; set; }
public int? FileSize { get; set; }
public string FileType { get; set; }
public int DownTimes { get; set; }
}
/// <summary>绑定流程下拉项(旧 Bindflowinfo 的 {SerID: string, flowname})。</summary>
public class BindFlowOption
{
public string SerID { get; set; }
public string flowname { get; set; }
}
/// <summary>文印数量按月统计项(旧 List1 / Exceldaochu1 的 {dateyear, datemonth, num, DocNum})。</summary>
public class MonthStatItem
{
public int dateyear { get; set; }
public int datemonth { get; set; }
public int num { get; set; }
public int DocNum { get; set; }
}
/// <summary>文印详情统计项(旧 List2 匿名投影)。</summary>
public class DocDetailStatItem
{
public string UserID { get; set; }
public string teaname { get; set; }
public string DocContent { get; set; }
public string DocType { get; set; }
public string DocTypeName { get; set; }
public string DocStyle { get; set; }
public string DocStyleName { get; set; }
public string DPType { get; set; }
public string DPTypeName { get; set; }
public string EditDate { get; set; }
public string UpFile { get; set; }
public string DocTitle { get; set; }
public int DocNum { get; set; }
public string DocStatus { get; set; }
public string DocStatusName { get; set; }
}
/// <summary>文印数量按用户排名项(旧 List3 / Exceldaochu3 的 {UserID, teaname, num, DocNum})。</summary>
public class UserRankItem
{
public string UserID { get; set; }
public string teaname { get; set; }
public int num { get; set; }
public int DocNum { get; set; }
}
/// <summary>按月/按用户统计明细项(旧 List4 / List5 匿名投影,editstatus 对应旧 Common.GetEditDeleteStatus)。</summary>
public class StatListItem
{
public int SerID { get; set; }
public string DocContent { get; set; }
public string DocType { get; set; }
public string DocTypeName { get; set; }
public string DocStyle { get; set; }
public string DocStyleName { get; set; }
public string DPType { get; set; }
public string DPTypeName { get; set; }
public string EditDate { get; set; }
public string UserID { get; set; }
public string teaname { get; set; }
public string UpFile { get; set; }
public string DocTitle { get; set; }
public string DocStatus { get; set; }
public string DocStatusName { get; set; }
/// <summary>旧 Common.GetEditDeleteStatus(status, step):一审(且非通过/拒绝态)可编辑 → false。</summary>
public bool editstatus { get; set; }
public int? DocStep { get; set; }
public int? flowid { get; set; }
}
}
+236
View File
@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using YhCore.Entity;
namespace YhCore.Model.Dto
{
/// <summary>
/// 审核角色扩展对象(旧 WY_SHRoleP.cs 文件尾 WYSHRoleExt):角色 + 审核人 + 被审核人树形结构。
/// SerID == 0 表示新增,否则更新。
/// </summary>
public class WYSHRoleExt
{
public decimal SerID { get; set; }
public string SchSerId { get; set; }
public string SHRoleName { get; set; }
public List<WY_SHRole_SHTea> SHTea { get; set; } = new List<WY_SHRole_SHTea>();
public List<WY_SHRole_BSHTea> BSHTea { get; set; } = new List<WY_SHRole_BSHTea>();
public DateTime? inserttime { get; set; }
public string writor { get; set; }
}
/// <summary>步骤 + 审核教师(旧 WYStepSHTea,SHState 形如 "待审核_0" / "同意_1")。</summary>
public class WYStepSHTea
{
public int SerID { get; set; }
public int? flowid { get; set; }
public string StepName { get; set; }
public string SHState { get; set; }
public string SHtime { get; set; }
public int? ordernum { get; set; }
public int? prestep { get; set; }
public int? cond { get; set; }
public List<WY_SHRole_SHTea> SHTea { get; set; } = new List<WY_SHRole_SHTea>();
}
/// <summary>用户可申请的流程(旧 WYFlowForUser,getLCOfUser / editDocPrint 的 data 段)。</summary>
public class WYFlowForUser
{
public decimal SerID { get; set; }
public string txt { get; set; }
public string flowname { get; set; }
public string UserID { get; set; }
public string UserName { get; set; }
public string DateNowStr { get; set; }
public string ChaoSend { get; set; }
public string ChaoSendName { get; set; }
public int? StepSend { get; set; }
public string WYPageGG { get; set; }
public List<WYStepSHTea> StepSHTea { get; set; } = new List<WYStepSHTea>();
}
/// <summary>审核轨迹展示项(旧 FreeShenheinfoSHRole)。</summary>
public class FreeShenheinfoSHRole
{
public string steptname { get; set; }
public string steptstatus { get; set; }
public string photo { get; set; }
public string Remark { get; set; }
public string actiondate { get; set; }
public string actionstatus { get; set; }
public List<string> SignaturePic { get; set; } = new List<string>();
}
/// <summary>审核角色流程列表项(旧 ListFlow 投影,含 ChaoSend/StepSend/WYPageGG)。</summary>
public class SHRoleFlowListItem
{
public string SerID { get; set; }
public string flowname { get; set; }
public string deptid { get; set; }
public string typeid { get; set; }
public int enable { get; set; }
public string enablename { get; set; }
public string miaoshu { get; set; }
public string deitdate { get; set; }
public string ChaoSend { get; set; }
public int? StepSend { get; set; }
public string WYPageGG { get; set; }
}
/// <summary>步骤详情(旧 getStepInfo:Data=步骤,OtherData=步骤绑定的审核角色)。</summary>
public class SHRoleStepInfo
{
public List<Common_flowstep> Steps { get; set; } = new List<Common_flowstep>();
public List<Common_Groupuser_SHRole> GroupUsers { get; set; } = new List<Common_Groupuser_SHRole>();
}
/// <summary>
/// 审核轨迹结果(旧 getshenheFlow:Data=时间线,OtherData=步骤+审核教师明细,Page=当前步骤列表)。
/// 新统一响应无 OtherData/Page 槽位,三部分合并为一个对象返回。
/// </summary>
public class SHRoleShenheFlowResult
{
public List<FreeShenheinfoSHRole> Flow { get; set; } = new List<FreeShenheinfoSHRole>();
public List<WYStepSHTea> Steps { get; set; } = new List<WYStepSHTea>();
public List<Common_flowstep> CurrentStep { get; set; } = new List<Common_flowstep>();
}
/// <summary>
/// 文印规格表结果(旧 getdocOftable:Data=规格表,OtherData=每规格默认页数,Page=首列合并行数数组)。
/// 合并为一个对象返回。
/// </summary>
public class SHRoleDocOfTableResult
{
public List<WY_DocPrint_Num> Table { get; set; } = new List<WY_DocPrint_Num>();
public List<int> MinPageNum { get; set; } = new List<int>();
public List<int> RowSpan { get; set; } = new List<int>();
}
/// <summary>
/// 审核角色视角的文印列表项(旧 MyListSHRole / totalListSHRole / SendRdListSHRole / SPListSHRole 投影,
/// 各列表仅填充自身所需字段,其余为默认值)。
/// </summary>
public class SHRoleDocPrintListItem
{
public int SerID { get; set; }
public string DocContent { get; set; }
public string DocType { get; set; }
public string DocTypeName { get; set; }
public string DocStyle { get; set; }
public string DocStyleName { get; set; }
public string DPType { get; set; }
public string DPTypeName { get; set; }
public string EditDate { get; set; }
public string TakeDate { get; set; }
public string UserID { get; set; }
public string teaname { get; set; }
public string TeaPhoto { get; set; }
public string UpFile { get; set; }
public string DocTitle { get; set; }
public string DocStatus { get; set; }
public string DocStatusName { get; set; }
public bool _disabled { get; set; }
public int? DocStep { get; set; }
public int? flowid { get; set; }
public string FreeUserID { get; set; }
public string SendUserID { get; set; }
public int? freestepid { get; set; }
public int? DocShare { get; set; }
public string explain { get; set; }
public int? rid { get; set; }
public int? reid { get; set; }
public string DWName { get; set; }
public string ZBName { get; set; }
public string grade { get; set; }
public string Examtype { get; set; }
public int DocNum { get; set; }
public int? DocFix { get; set; }
public string zhipai { get; set; }
public List<WY_DocPrint_Num> DocPrintNum { get; set; } = new List<WY_DocPrint_Num>();
public string SHRName { get; set; }
public bool isshowje { get; set; }
public string ChkDate { get; set; }
public string firstSHDate { get; set; }
public string firstSHDateTiket { get; set; }
public int FJDownInfo { get; set; }
/// <summary>附件明细(仅 MyListtoOneSHRole 填充,旧 OtherData.tempresultfile 并入 data)。</summary>
public List<DocPrintFileItem> tempresultfile { get; set; }
}
/// <summary>
/// 按金额取步骤结果(旧 getStepByJE:Data=可见步骤+审核教师,OtherData=通过金额过滤的全部步骤)。
/// 新统一响应无 OtherData 槽位,两部分合并为一个对象返回。
/// </summary>
public class SHRoleStepByJEResult
{
public List<WYStepSHTea> StepSHTea { get; set; } = new List<WYStepSHTea>();
public List<Common_flowstep> Steps { get; set; } = new List<Common_flowstep>();
}
/// <summary>金额重算记录列表项(旧 getRegetJERd 投影)。</summary>
public class SHRoleRegetJERdItem
{
public string teaname { get; set; }
public string writor { get; set; }
public int SerID { get; set; }
public int? DcoPrintID { get; set; }
public string DocTitle { get; set; }
public string DocContent { get; set; }
public string DocInfo { get; set; }
public string EditDatetime { get; set; }
public string datestr { get; set; }
public string type { get; set; }
public string typename { get; set; }
public string SchSerID { get; set; }
public string UnitPrice { get; set; }
}
/// <summary>editDocPrint 入参(旧 form:isedit + data + docdata + docnumdata)。</summary>
public class WYSHRoleEditDocPrintDto
{
public int isedit { get; set; }
public WYFlowForUser data { get; set; }
public WYDocPrintSaveDto docdata { get; set; }
public List<WY_DocPrint_Num> docnumdata { get; set; } = new List<WY_DocPrint_Num>();
}
/// <summary>editSHTea 入参(旧 form:SHRoleID + listSHTeaTxt)。</summary>
public class EditSHTeaDto
{
public decimal SHRoleID { get; set; }
public List<WY_SHRole_SHTea> listSHTea { get; set; } = new List<WY_SHRole_SHTea>();
}
/// <summary>editBSHTea 入参(旧 form:SHRoleID + listBSHTeaTxt)。</summary>
public class EditBSHTeaDto
{
public decimal SHRoleID { get; set; }
public List<WY_SHRole_BSHTea> listBSHTea { get; set; } = new List<WY_SHRole_BSHTea>();
}
/// <summary>delSHRole 入参(旧 form:listSHRoleIDTxt)。</summary>
public class DelSHRoleDto
{
public List<decimal?> listSHRoleID { get; set; } = new List<decimal?>();
}
/// <summary>SaveSignature 入参(旧 form:pic)。</summary>
public class SaveSignatureDto
{
public string pic { get; set; }
}
/// <summary>TuiHui 入参(旧 form:DocID + cont)。</summary>
public class TuiHuiDto
{
public decimal DocID { get; set; }
public string cont { get; set; }
}
/// <summary>regetJE 入参(旧 form:DocID 逗号串 + type)。</summary>
public class RegetJEDto
{
public string DocID { get; set; }
public string type { get; set; }
}
}
+40
View File
@@ -0,0 +1,40 @@
using System.Collections.Generic;
namespace YhCore.Model.Vo
{
/// <summary>
/// 分页返回对象(对应 Java 版 PageVO,字段与 Spring Page 序列化结构保持一致,
/// 保证前端零改动切换)。
/// </summary>
public class PageVO<T>
{
/// <summary>当前页数据</summary>
public List<T> Content { get; set; } = new List<T>();
/// <summary>总记录数</summary>
public long TotalElements { get; set; }
/// <summary>总页数</summary>
public int TotalPages { get; set; }
/// <summary>当前页码(从 0 开始,与 Spring Pageable 一致)</summary>
public int Number { get; set; }
/// <summary>每页大小</summary>
public int Size { get; set; }
public static PageVO<T> Of(List<T> content, long totalElements, int number, int size)
{
return new PageVO<T>
{
Content = content ?? new List<T>(),
TotalElements = totalElements,
TotalPages = size > 0 ? (int)((totalElements + size - 1) / size) : 0,
Number = number,
Size = size
};
}
public static PageVO<T> Empty(int number = 0, int size = 20) => Of(new List<T>(), 0, number, size);
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>YhCore.Model</RootNamespace>
<AssemblyName>YhCore.Model</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 审核角色 DTO 直接引用实体(WY_SHRole_SHTea / Common_flowstep / WY_DocPrint_Num 等) -->
<ProjectReference Include="..\YhCore.Entity\YhCore.Entity.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Microsoft.EntityFrameworkCore;
namespace YhCore.Repository.Dapper
{
/// <summary>
/// Dapper 通道(对应 Java 显式引入 JdbcTemplate 的场景,如 backupAllSnapshot 批量快照插入)。
/// 与 EF Core 共用同一连接(YHDbContext.Database.GetDbConnection),连接生命周期由 DbContext 管理。
/// </summary>
public class DapperChannel
{
private readonly YHDbContext _context;
public DapperChannel(YHDbContext context)
{
_context = context;
}
private IDbConnection Connection => _context.Database.GetDbConnection();
/// <summary>执行非查询 SQL(增删改),返回受影响行数</summary>
public async Task<int> ExecuteAsync(string sql, object param = null)
{
return await Connection.ExecuteAsync(sql, param);
}
/// <summary>查询单值(如 SELECT COUNT(*))</summary>
public async Task<T> ExecuteScalarAsync<T>(string sql, object param = null)
{
return await Connection.ExecuteScalarAsync<T>(sql, param);
}
/// <summary>查询列表</summary>
public async Task<IEnumerable<T>> QueryAsync<T>(string sql, object param = null)
{
return await Connection.QueryAsync<T>(sql, param);
}
/// <summary>查询单条</summary>
public async Task<T> QueryFirstOrDefaultAsync<T>(string sql, object param = null)
{
return await Connection.QueryFirstOrDefaultAsync<T>(sql, param);
}
/// <summary>
/// 批量快照插入(对应 Java backupAllSnapshot 的批量插入场景)。
/// 逐条循环 Insert 的通用实现;数据量大时可替换为 SqlBulkCopy / 表值参数提升性能。
/// </summary>
public async Task<int> BulkInsertAsync<T>(string table, IEnumerable<T> rows)
{
var affected = 0;
foreach (var row in rows)
{
affected += await Connection.ExecuteAsync(
$"INSERT INTO {table} ({string.Join(",", GetColumns(row))}) " +
$"VALUES ({string.Join(",", GetColumns(row).Select(c => "@" + c))})", row);
}
return affected;
}
private static string[] GetColumns<T>(T row)
{
var props = row?.GetType().GetProperties() ?? System.Array.Empty<System.Reflection.PropertyInfo>();
return props.Select(p => p.Name).ToArray();
}
}
}
@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
namespace YhCore.Repository
{
/// <summary>
/// 数据库自动补表初始化器:对比 EF Core 实体模型与数据库实际表,
/// 把「实体里有而数据库没有」的表自动创建(仅补建缺失表,不触碰已存在表;
/// 已存在表的结构变更仍走 sqlscript 脚本,与既有约定一致)。
/// 免除新增实体后手写建表脚本。
/// </summary>
public static class DatabaseInitializer
{
/// <summary>
/// 补建缺失表。返回本次创建的表名列表(空 = 无缺失表)。
/// 仅支持 SQL Server(对象存在性守卫/脚本语法为 SQL Server 专用);
/// 其他 Provider(如 MySQL)跳过不动作,避免破坏其脚本语法。
/// </summary>
public static List<string> EnsureMissingTables(YHDbContext context)
{
if (!context.Database.IsSqlServer())
{
return new List<string>();
}
// 提前捕获连接串:此后不依赖 EF 托管连接(GenerateCreateScript 等内部操作
// 可能干扰 EF 连接状态),DDL 用独立 SqlConnection 执行
var connectionString = context.Database.GetDbConnection().ConnectionString;
// 1. 实体模型期望的表(Keyless 实体不建表,与 EnsureCreated 语义一致)
var expected = context.Model.GetEntityTypes()
.Where(e => e.FindPrimaryKey() != null)
.Select(e => e.GetTableName())
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (expected.Count == 0)
{
return new List<string>();
}
// 2. 数据库不存在时:EnsureCreated 一次创建库 + 全部表
if (!context.Database.CanConnect())
{
if (context.Database.EnsureCreated())
{
return expected.ToList();
}
}
// 3. 数据库实际存在的表
var existing = GetExistingTables(context);
// 4. 差集:缺失的表
var missing = expected.Except(existing, StringComparer.OrdinalIgnoreCase).ToList();
if (missing.Count == 0)
{
return new List<string>();
}
// 5. 从 EF 生成的建库脚本中提取缺失表的 DDL 并执行
return ExecuteMissingTableScripts(context, missing, connectionString);
}
private static HashSet<string> GetExistingTables(YHDbContext context)
{
var tables = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var conn = context.Database.GetDbConnection();
var wasOpen = conn.State == ConnectionState.Open;
if (!wasOpen)
{
conn.Open();
}
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
tables.Add(reader.GetString(0));
}
}
finally
{
// 只关闭不释放:连接由 EF 托管,外部释放会破坏其内部状态
if (!wasOpen)
{
conn.Close();
}
}
return tables;
}
private static List<string> ExecuteMissingTableScripts(
YHDbContext context, List<string> missing, string connectionString)
{
var missingSet = missing.ToHashSet(StringComparer.OrdinalIgnoreCase);
var script = context.Database.GenerateCreateScript();
// EF 生成的脚本以独立 GO 行为语句分隔;无 GO 时整体视为一段
var segments = Regex.Split(script, @"(?m)^\s*GO\s*$", RegexOptions.IgnoreCase)
.Select(s => s.Trim())
.Where(s => s.Length > 0);
var statements = new List<string>();
foreach (var segment in segments)
{
var tableMatch = Regex.Match(segment,
@"^\s*CREATE\s+TABLE\s+(?:\[([^\]]+)\]\.)?\[([^\]]+)\]",
RegexOptions.IgnoreCase);
if (tableMatch.Success)
{
var schema = tableMatch.Groups[1].Success ? tableMatch.Groups[1].Value : "dbo";
var name = tableMatch.Groups[2].Value;
if (missingSet.Contains(name))
{
// 守卫包裹:竞态/重复执行安全(与迁移幂等脚本语义一致)
statements.Add(
$"IF OBJECT_ID(N'[{schema}].[{name}]', N'U') IS NULL\nBEGIN\n{segment}\nEND;");
}
continue;
}
var indexMatch = Regex.Match(segment,
@"^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+\[([^\]]+)\]\s+ON\s+(?:\[([^\]]+)\]\.)?\[([^\]]+)\]",
RegexOptions.IgnoreCase);
if (indexMatch.Success)
{
var indexName = indexMatch.Groups[1].Value;
var schema = indexMatch.Groups[2].Success ? indexMatch.Groups[2].Value : "dbo";
var tableName = indexMatch.Groups[3].Value;
if (missingSet.Contains(tableName))
{
statements.Add(
$"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{indexName}' AND object_id = OBJECT_ID(N'[{schema}].[{tableName}]'))\nBEGIN\n{segment}\nEND;");
}
}
}
if (statements.Count == 0)
{
// 模型表与脚本段未匹配上(理论上不发生):保守起见不执行任何 DDL
return new List<string>();
}
// 用独立 SqlConnection 执行 DDL(不依赖 EF 托管连接,避免其状态干扰)
using var conn = new SqlConnection(connectionString);
conn.Open();
using (var tx = conn.BeginTransaction())
{
foreach (var sql in statements)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
tx.Commit();
}
return missing.ToList();
}
}
}
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using YhCore.Repository.Dapper;
namespace YhCore.Repository.Extensions
{
/// <summary>
/// 数据层注册扩展。按配置 DatabaseProvider 选择数据库提供方:
/// "SqlServer"(默认,主库)或 "MySql"(可切换)。
/// </summary>
public static class DataLayerServiceCollectionExtensions
{
public static IServiceCollection AddDataLayer(this IServiceCollection services, IConfiguration configuration)
{
var provider = configuration["DatabaseProvider"];
services.AddDbContext<YHDbContext>(options =>
{
if (string.Equals(provider, "MySql", StringComparison.OrdinalIgnoreCase))
{
// MySQL 切换通道(对应 Java 版 application.yml 的 MySQL 数据源)
var conn = configuration.GetConnectionString("Mysql");
options.UseMySql(conn, ServerVersion.AutoDetect(conn));
}
else
{
// 默认主库:SQL Server(EduCloudDocPrint)
options.UseSqlServer(configuration.GetConnectionString("SqlServer"));
}
});
return services;
}
/// <summary>
/// 批量注册泛型仓储与 Dapper 通道(对应 Java Repository 包由 Spring 自动装配)。
/// 注入方式:IRepository&lt;WY_DocPrint&gt; / DapperChannel。
/// </summary>
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
services.AddScoped(typeof(IRepository<>), typeof(RepositoryBase<>));
services.AddScoped<DapperChannel>();
return services;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading.Tasks;
using YhCore.Model.Vo;
namespace YhCore.Repository
{
/// <summary>
/// 泛型仓储接口(对应 Java Spring Data JPA Repository)。
/// </summary>
public interface IRepository<T> where T : class
{
// ---- 读取 ----
Task<T> GetByIdAsync(object id);
Task<List<T>> GetAllAsync();
Task<List<T>> FindAsync(Expression<Func<T, bool>> predicate);
Task<T> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate);
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);
// ---- 写入 ----
Task<T> AddAsync(T entity);
Task AddRangeAsync(IEnumerable<T> entities);
Task UpdateAsync(T entity);
Task RemoveAsync(T entity);
Task RemoveAsync(object id);
Task<int> SaveChangesAsync();
}
}
+143
View File
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using YhCore.Model.Vo;
namespace YhCore.Repository
{
/// <summary>
/// 泛型仓储基类(对应 Java Spring Data JPA 的 SimpleJpaRepository)。
/// 基于 EF Core IQueryable,分页查询下推到数据库执行。
/// </summary>
public class RepositoryBase<T> : IRepository<T> where T : class
{
protected readonly YHDbContext Context;
protected readonly DbSet<T> DbSet;
public RepositoryBase(YHDbContext context)
{
Context = context;
DbSet = context.Set<T>();
}
public virtual async Task<T> GetByIdAsync(object id)
{
return await DbSet.FindAsync(id);
}
public virtual async Task<List<T>> GetAllAsync()
{
return await DbSet.AsNoTracking().ToListAsync();
}
public virtual async Task<List<T>> FindAsync(Expression<Func<T, bool>> predicate)
{
return await DbSet.AsNoTracking().Where(predicate).ToListAsync();
}
public virtual async Task<T> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
{
return await DbSet.AsNoTracking().FirstOrDefaultAsync(predicate);
}
public virtual async Task<List<T>> FindTrackedAsync(Expression<Func<T, bool>> predicate)
{
return await DbSet.Where(predicate).ToListAsync();
}
public virtual async Task<List<T>> GetAllTrackedAsync()
{
return await DbSet.ToListAsync();
}
public virtual async Task<T> FirstOrDefaultTrackedAsync(Expression<Func<T, bool>> predicate)
{
return await DbSet.FirstOrDefaultAsync(predicate);
}
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate)
{
return await DbSet.AnyAsync(predicate);
}
public virtual async Task<long> CountAsync(Expression<Func<T, bool>> predicate = null)
{
return predicate == null
? await DbSet.LongCountAsync()
: await DbSet.LongCountAsync(predicate);
}
public virtual async Task<PageVO<T>> PageAsync(
Expression<Func<T, bool>> predicate,
int page, int size,
Expression<Func<T, object>> orderBy = null,
bool asc = true)
{
if (page < 0) page = 0;
if (size <= 0) size = 20;
IQueryable<T> query = DbSet.AsNoTracking();
if (predicate != null)
{
query = query.Where(predicate);
}
var total = await query.LongCountAsync();
IOrderedQueryable<T> ordered;
if (orderBy != null)
{
ordered = asc ? query.OrderBy(orderBy) : query.OrderByDescending(orderBy);
}
else
{
// 无显式排序时按主键约定排序(Id → SerID),保证 Skip/Take 稳定
var keyName = typeof(T).GetProperty("Id") != null ? "Id" : "SerID";
ordered = query.OrderBy(e => EF.Property<object>(e, keyName));
}
var items = await ordered.Skip(page * size).Take(size).ToListAsync();
return PageVO<T>.Of(items, total, page, size);
}
public virtual async Task<T> AddAsync(T entity)
{
var entry = await DbSet.AddAsync(entity);
return entry.Entity;
}
public virtual async Task AddRangeAsync(IEnumerable<T> entities)
{
await DbSet.AddRangeAsync(entities);
}
public virtual Task UpdateAsync(T entity)
{
DbSet.Update(entity);
return Task.CompletedTask;
}
public virtual Task RemoveAsync(T entity)
{
DbSet.Remove(entity);
return Task.CompletedTask;
}
public virtual async Task RemoveAsync(object id)
{
var entity = await GetByIdAsync(id);
if (entity != null)
{
DbSet.Remove(entity);
}
}
public virtual async Task<int> SaveChangesAsync()
{
return await Context.SaveChangesAsync();
}
}
}
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
namespace YhCore.Repository.Specification
{
/// <summary>
/// 动态条件表达式组合器(对应 Java Repository/specification 包的轻量实现)。
/// 用于按需拼装 where 条件:
/// <code>
/// var spec = Specification.True&lt;SchClass&gt;()
/// .And(c =&gt; c.SchoolId == schId)
/// .AndIf(!string.IsNullOrEmpty(name), c =&gt; c.Name.Contains(name));
/// </code>
/// </summary>
public static class Specification
{
public static Expression<Func<T, bool>> True<T>() => _ => true;
public static Expression<Func<T, bool>> False<T>() => _ => false;
public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
if (left == null) return right;
if (right == null) return left;
var body = Expression.AndAlso(left.Body, right.Body.ReplaceParameter(right.Parameters[0], left.Parameters[0]));
return Expression.Lambda<Func<T, bool>>(body, left.Parameters);
}
public static Expression<Func<T, bool>> Or<T>(
this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
if (left == null) return right;
if (right == null) return left;
var body = Expression.OrElse(left.Body, right.Body.ReplaceParameter(right.Parameters[0], left.Parameters[0]));
return Expression.Lambda<Func<T, bool>>(body, left.Parameters);
}
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> expr)
{
if (expr == null) return null;
return Expression.Lambda<Func<T, bool>>(Expression.Not(expr.Body), expr.Parameters);
}
/// <summary>条件成立时才追加条件(Java Specification 常用写法)</summary>
public static Expression<Func<T, bool>> AndIf<T>(
this Expression<Func<T, bool>> spec, bool condition, Expression<Func<T, bool>> conditionExpr)
{
return condition ? spec.And(conditionExpr) : spec;
}
/// <summary>条件不成立时才追加条件</summary>
public static Expression<Func<T, bool>> AndIfNot<T>(
this Expression<Func<T, bool>> spec, bool condition, Expression<Func<T, bool>> conditionExpr)
{
return spec.AndIf(!condition, conditionExpr);
}
private static Expression ReplaceParameter(this Expression expression, ParameterExpression source, ParameterExpression target)
{
return new ParameterReplacer(source, target).Visit(expression);
}
private class ParameterReplacer : ExpressionVisitor
{
private readonly ParameterExpression _source;
private readonly ParameterExpression _target;
public ParameterReplacer(ParameterExpression source, ParameterExpression target)
{
_source = source;
_target = target;
}
protected override Expression VisitParameter(ParameterExpression node)
{
return node == _source ? _target : base.VisitParameter(node);
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using System.Linq;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using YhCore.Entity;
namespace YhCore.Repository
{
/// <summary>
/// EF Core 数据上下文。同时支持 SQL Server(主库)与 MySQL(可切换),
/// 提供方在 Startup 通过 AddDataLayer 按 DatabaseProvider 配置选择。
/// 生产环境禁用自动迁移,结构变更走 sqlscript 脚本。
/// </summary>
public class YHDbContext : DbContext
{
public YHDbContext(DbContextOptions<YHDbContext> options) : base(options)
{
}
// 文印业务
public DbSet<WY_DocPrint> WY_DocPrint { get; set; }
public DbSet<WY_DocPrint_Num> WY_DocPrint_Num { get; set; }
public DbSet<WY_DocPrint_SH> WY_DocPrint_SH { get; set; }
public DbSet<WY_reGetJERd> WY_reGetJERd { get; set; }
public DbSet<WY_SchZyBit> WY_SchZyBit { get; set; }
// 审批流
public DbSet<Common_flowinfo> Common_flowinfo { get; set; }
public DbSet<Common_flowstep> Common_flowstep { get; set; }
public DbSet<Common_flowtranctInfo> Common_flowtranctInfo { get; set; }
public DbSet<Common_flowuser> Common_flowuser { get; set; }
public DbSet<Common_freeflowinfo> Common_freeflowinfo { get; set; }
public DbSet<Common_freeGroupuser> Common_freeGroupuser { get; set; }
public DbSet<Common_Groupuser> Common_Groupuser { get; set; }
public DbSet<Common_Groupuser_SHRole> Common_Groupuser_SHRole { get; set; }
public DbSet<Common_SendUserinfo> Common_SendUserinfo { get; set; }
public DbSet<Common_UserFlowZhipai> Common_UserFlowZhipai { get; set; }
// 审核角色
public DbSet<WY_SHRole> WY_SHRole { get; set; }
public DbSet<WY_SHRole_BSHTea> WY_SHRole_BSHTea { get; set; }
public DbSet<WY_SHRole_SHTea> WY_SHRole_SHTea { get; set; }
// 基础数据
public DbSet<Common_Dictionary> Common_Dictionary { get; set; }
public DbSet<Common_DictionaryOrd> Common_DictionaryOrd { get; set; }
public DbSet<Common_UpFiles> Common_UpFiles { get; set; }
// 日志
public DbSet<A_DataLog> A_DataLog { get; set; }
public DbSet<Base_Log> Base_Log { get; set; }
public DbSet<AppMsgRecord> AppMsgRecord { get; set; }
// 系统审计日志(HTTP 接口级,对齐 Java sys_operation_log,表名在 OnModelCreating 映射)
public DbSet<SysOperationLog> SysOperationLog { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 主键约定:实体为旧 EF6 EDMX 生成的纯 POCO(无 [Key] 标注),
// 统一按属性名推断主键——优先 Id(不区分大小写,兼容小写 id),其次 SerID;
// 两者皆无的实体按 Keyless 处理(仅用于查询)。
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (entityType.FindPrimaryKey() != null)
{
continue;
}
var clrType = entityType.ClrType;
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
var keyProperty = clrType.GetProperty("Id", flags)
?? clrType.GetProperty("SerID", flags);
if (keyProperty != null)
{
modelBuilder.Entity(clrType).HasKey(keyProperty.Name);
}
else
{
modelBuilder.Entity(clrType).HasNoKey();
}
}
// decimal 主键(多数表的 SerID)EF Core 约定不自增;
// 若数据库对应列为 IDENTITY,需在此补充 ValueGeneratedOnAdd 配置。
// WY_DocPrint.SerID:旧 EF6 依赖数据库 IDENTITY 回填新增单据号,此处显式声明。
// WY_SHRole* / WY_DocPrint_SH / WY_DocPrint_Num / WY_reGetJERd / Common_Dictionary
// 同属旧库 IDENTITY 列(旧代码新增时均不显式赋值 SerID),一并补充。
// ⚠️ decimal 默认映射 decimal(18,2),scale≠0 的 IDENTITY 列 SQL Server 不允许
//(Msg 5720),必须显式声明 decimal(18,0)——否则自动补表整批失败回滚。
modelBuilder.Entity<WY_DocPrint>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
modelBuilder.Entity<WY_SHRole>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
modelBuilder.Entity<WY_SHRole_SHTea>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
modelBuilder.Entity<WY_SHRole_BSHTea>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
modelBuilder.Entity<WY_DocPrint_SH>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
modelBuilder.Entity<WY_DocPrint_Num>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
modelBuilder.Entity<WY_reGetJERd>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
modelBuilder.Entity<Common_Dictionary>().Property(e => e.SerID).HasColumnType("decimal(18,0)").ValueGeneratedOnAdd();
// 系统审计日志表名映射:sys_operation_log(与 Java bschclassmanage 同表结构,双轨对齐)
modelBuilder.Entity<SysOperationLog>().ToTable("sys_operation_log");
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>YhCore.Repository</RootNamespace>
<AssemblyName>YhCore.Repository</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 主库:SQL Server -->
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="5.0.17" />
<!-- 可切换:MySQL(Pomelo,对齐 EF Core 5) -->
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="5.0.4" />
<!-- Dapper 通道:复杂报表/批量快照插入(对应 Java JdbcTemplate 场景) -->
<PackageReference Include="Dapper" Version="2.0.123" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YhCore.Entity\YhCore.Entity.csproj" />
<ProjectReference Include="..\YhCore.Model\YhCore.Model.csproj" />
</ItemGroup>
</Project>
+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;
}
}
}
@@ -0,0 +1,171 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Yh.Platform.Abstractions;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Repository;
using YhCore.Service.Interfaces;
namespace YhCore.Service.Impl
{
/// <summary>
/// 公共字典服务实现(移植旧 BestSchool.Service.GetSysDict)。
/// </summary>
public class DictionaryService : IDictionaryService
{
private readonly IRepository<Common_Dictionary> _dic;
public DictionaryService(IRepository<Common_Dictionary> dic)
{
_dic = dic;
}
public async Task<List<DicOption>> GetDictListAsync(string typeId, string schId)
{
var has = await _dic.AnyAsync(d => d.typeID == typeId);
if (!has)
{
throw new DException("尚未添加发文部门,请联系管理员添加!");
}
var list = await _dic.FindAsync(d =>
d.typeID == typeId &&
(string.IsNullOrEmpty(schId) || d.Schserid == schId) &&
d.Enable == 1);
return list
.OrderBy(d => DictionaryTypeHelper.ParseSort(d.Des))
.Select(d => new DicOption { K = d.CodeID, V = d.Name })
.ToList();
}
public Task<List<DicOption>> GetDicTypeListAsync()
{
var list = DictionaryTypeHelper.AllTypes
.Where(t => t.Id != "DEPT")
.Select(t => new DicOption { K = t.Id, V = t.Name })
.ToList();
return Task.FromResult(list);
}
public async Task<List<DicManageItem>> GetDictMangeListAsync(string typeId, string schId)
{
var has = await _dic.AnyAsync(d => d.typeID == typeId);
if (!has)
{
throw new DException("字典类型错误,未找到该字典类型");
}
var list = await _dic.FindAsync(d =>
d.typeID == typeId &&
(string.IsNullOrEmpty(schId) || d.Schserid == schId));
return list
.OrderBy(d => string.IsNullOrEmpty(d.Des) ? 999 : DictionaryTypeHelper.ParseSort(d.Des))
.Select(d => new DicManageItem
{
SerID = d.SerID,
CodeID = d.CodeID,
Name = d.Name,
Des = d.Des,
Enable = d.Enable
})
.ToList();
}
public async Task AddDicAsync(Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
if (string.IsNullOrWhiteSpace(model.Name)) throw new DException("输入字符串格式不正确!");
var listed = await _dic.AnyAsync(b =>
b.typeID == model.typeID &&
(b.Schserid == model.Schserid || b.Schserid == null || b.Schserid == "") &&
b.Name == model.Name && b.Enable != 3);
if (listed || await IsDicnameAsync(model.typeID, model.Name))
{
throw new DException("已存在该分类,请重新填写!");
}
var candidates = await _dic.FindAsync(x =>
x.typeID == model.typeID && x.Enable != 3 &&
(x.Schserid == model.Schserid || x.Schserid == null || x.Schserid == ""));
if (candidates.Count == 0)
{
model.Sort = 1;
model.Des = "1";
}
else
{
var maxSort = candidates.Max(c => c.Sort ?? (int)c.SerID);
model.Sort = maxSort + 1;
model.Des = model.Sort.ToString();
}
model.Enable = 1;
model.CodeID = model.typeID + model.Sort.ToString();
await _dic.AddAsync(model);
await _dic.SaveChangesAsync();
}
public async Task UpdateDicAsync(Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
var dup = await _dic.AnyAsync(m =>
m.SerID != model.SerID && m.Name == model.Name &&
(m.Schserid == model.Schserid || m.Schserid == null) && m.Enable != 3);
if (dup || await IsDicnameAsync(model.typeID, model.Name))
{
throw new DException("已存在该分类,请重新填写!");
}
var obj = await _dic.FirstOrDefaultTrackedAsync(x => x.SerID == model.SerID);
if (obj == null) throw new DException("未找到要更新的分类!");
obj.Name = model.Name;
obj.Des = model.Des;
obj.Sort = model.Sort;
obj.Enable = model.Enable;
await _dic.UpdateAsync(obj);
await _dic.SaveChangesAsync();
}
public async Task EditDicAsync(Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
var current = await _dic.FirstOrDefaultTrackedAsync(m => m.SerID == model.SerID);
if (current == null) throw new DException("未找到要编辑的分类!");
var dup = await _dic.AnyAsync(m =>
m.SerID != model.SerID && m.Enable != 3 && m.typeID == current.typeID &&
m.Name == model.Name && (m.Schserid == model.Schserid || m.Schserid == null));
if (dup)
{
throw new DException("已存在该分类,请重新填写!");
}
current.Name = model.Name;
current.Code = model.Code;
current.Path = model.Path;
await _dic.UpdateAsync(current);
await _dic.SaveChangesAsync();
}
/// <summary>判断全局(Schserid 为空)是否已存在同名类别(对齐旧 IsDicname)。</summary>
private async Task<bool> IsDicnameAsync(string typeId, string dicName)
{
var names = await _dic.FindAsync(b => b.typeID == typeId && b.Schserid == null);
return names.Select(a => a.Name).Distinct().Contains(dicName);
}
}
}
@@ -0,0 +1,338 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Yh.Platform.Abstractions;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Repository;
using YhCore.Service.Interfaces;
namespace YhCore.Service.Impl
{
/// <summary>
/// 文印分类 / 印制版面 / 公共类别服务实现
/// (移植旧 BestSchool.DocPrint.WY_list 分类部分 + WY_Classify)。
/// </summary>
public class DocPrintClassifyService : IDocPrintClassifyService
{
private readonly IRepository<Common_Dictionary> _dic;
private readonly IRepository<Common_DictionaryOrd> _ord;
private readonly IRepository<WY_DocPrint> _doc;
private readonly IRepository<Common_Groupuser> _groupuser;
private readonly IRepository<Common_freeGroupuser> _freegroup;
private readonly IRepository<WY_SchZyBit> _zybit;
public DocPrintClassifyService(
IRepository<Common_Dictionary> dic,
IRepository<Common_DictionaryOrd> ord,
IRepository<WY_DocPrint> doc,
IRepository<Common_Groupuser> groupuser,
IRepository<Common_freeGroupuser> freegroup,
IRepository<WY_SchZyBit> zybit)
{
_dic = dic;
_ord = ord;
_doc = doc;
_groupuser = groupuser;
_freegroup = freegroup;
_zybit = zybit;
}
#region 文印分类 / 印制版面
public async Task AddDicAsync(Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
var listed = await _dic.AnyAsync(b =>
b.typeID == model.typeID && b.Name == model.Name && b.Schserid == model.Schserid);
if (listed) throw new DException("该分类已经存在!");
var max = (await _dic.FindAsync(x => x.typeID == model.typeID))
.OrderByDescending(x => x.SerID).FirstOrDefault();
model.Sort = max != null ? (max.Sort ?? 0) + 1 : 1;
model.Enable = 1;
model.CodeID = model.typeID + model.Sort;
model.typeName = "印制分类";
await _dic.AddAsync(model);
await _dic.SaveChangesAsync();
}
public async Task AddYZBMAsync(Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
var listed = await _dic.AnyAsync(b =>
b.typeID == model.typeID && b.Name == model.Name && b.Schserid == model.Schserid);
if (listed) throw new DException("该版面已经存在!");
var max = (await _dic.FindAsync(x => x.typeID == model.typeID))
.OrderByDescending(x => x.SerID).FirstOrDefault();
model.Sort = max != null ? (max.Sort ?? 0) + 1 : 1;
model.Enable = 1;
model.CodeID = model.typeID + model.Sort;
model.typeName = "印制版面";
await _dic.AddAsync(model);
await _dic.SaveChangesAsync();
}
public async Task<List<CategoryItem>> CategoryListAsync(string schSerId, string name)
=> await QueryClassifyListAsync("印制分类", schSerId, name);
public async Task<List<CategoryItem>> YZBMListAsync(string schSerId, string name)
=> await QueryClassifyListAsync("印制版面", schSerId, name);
private async Task<List<CategoryItem>> QueryClassifyListAsync(string typeName, string schSerId, string name)
{
var query = await _dic.FindAsync(m => m.typeName == typeName);
if (!string.IsNullOrEmpty(schSerId))
{
query = query.Where(m => m.Schserid == schSerId || m.Schserid == null || m.Schserid == "").ToList();
}
if (!string.IsNullOrEmpty(name))
{
query = query.Where(m => m.Name != null && m.Name.IndexOf(name) >= 0).ToList();
}
return query
.OrderBy(x => string.IsNullOrEmpty(x.Des) ? 999 : DictionaryTypeHelper.ParseSort(x.Des))
.Select(item => new CategoryItem
{
SerID = item.SerID,
CodeID = item.CodeID,
Name = item.Name,
Des = item.Des,
Enable = item.Enable,
Sort = item.Sort,
SchSerID = item.Schserid,
UserSchSerID = schSerId,
_disabled = string.IsNullOrEmpty(item.Schserid)
})
.ToList();
}
public async Task DeleteDicAsync(decimal[] ids)
{
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
var items = await _dic.FindTrackedAsync(m => ids.Contains(m.SerID));
if (items.Count == 0) throw new DException("未找到要删除的分类!");
foreach (var item in items)
{
var used = await _doc.AnyAsync(m => m.DPType == item.CodeID);
if (used) throw new DException("该类别下有使用记录无法删除!");
}
foreach (var item in items)
{
await _dic.RemoveAsync(item);
}
await _dic.SaveChangesAsync();
}
#endregion
#region 公共类别
public async Task<List<DictionaryTypeItem>> DictionaryTypeListAsync(string schSerId, string name, bool showAll, string typeId)
{
var resultList = await _dic.FindAsync(m => m.typeID == typeId && m.Enable == 1);
if (!string.IsNullOrEmpty(schSerId))
{
resultList = resultList.Where(m => m.Schserid == schSerId || m.Schserid == null || m.Schserid == "").ToList();
}
if (!string.IsNullOrEmpty(name))
{
resultList = resultList.Where(m => m.Name != null && m.Name.IndexOf(name) >= 0).ToList();
}
var ordList = await _ord.FindAsync(a => a.SchSerID == schSerId);
var items = resultList.Select(item => new DictionaryTypeItem
{
Code = item.Code,
SerID = item.SerID,
CodeID = item.CodeID,
Name = item.Name,
Des = item.Des,
Enable = item.Enable,
Sort = item.Sort,
SchSerID = item.Schserid,
UserSchSerID = schSerId,
_disabled = string.IsNullOrEmpty(item.Schserid),
Status = false,
Open = ordList.FirstOrDefault(a => a.DicSerID == (int)item.SerID)?.Open ?? true,
Ord = ordList.FirstOrDefault(a => a.DicSerID == (int)item.SerID)?.Ord
?? (string.IsNullOrEmpty(item.Des) ? 999 : DictionaryTypeHelper.ParseSort(item.Des)),
Path = item.Path
})
.OrderBy(a => a.Ord)
.ToList();
if (!showAll)
{
items = items.Where(a => a.Open).ToList();
}
return items;
}
public async Task DeleteDictionaryTypeAsync(string[] ids)
{
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
var items = await _dic.FindTrackedAsync(m => ids.Contains(m.SerID.ToString()));
foreach (var item in items)
{
item.Enable = 3;
}
await _dic.SaveChangesAsync();
}
public async Task SetDictionaryOrdAsync(string schSerId, int dicSerId, bool show)
{
var obj = await _ord.FirstOrDefaultTrackedAsync(m => m.DicSerID == dicSerId && m.SchSerID == schSerId);
if (obj == null)
{
await _ord.AddAsync(new Common_DictionaryOrd
{
DicSerID = dicSerId,
Open = show,
SchSerID = schSerId
});
}
else
{
obj.Open = show;
await _ord.UpdateAsync(obj);
}
await _ord.SaveChangesAsync();
}
public async Task OrderDicAsync(string ids, string schSerId)
{
if (string.IsNullOrEmpty(ids)) throw new DException("ids is not null");
var ssids = ids.Split(',').Select(a => int.Parse(a)).ToArray();
var list = await _ord.FindTrackedAsync(m => m.SchSerID == schSerId);
var i = 0;
foreach (var id in ssids)
{
var obj = list.FirstOrDefault(a => a.DicSerID == id && a.SchSerID == schSerId);
if (obj == null)
{
await _ord.AddAsync(new Common_DictionaryOrd
{
DicSerID = id,
Open = true,
SchSerID = schSerId,
Ord = i
});
}
else
{
obj.Ord = i;
await _ord.UpdateAsync(obj);
}
i++;
}
await _ord.SaveChangesAsync();
}
#endregion
#region 文印资源开关 / 结果链接 / 待审角标
/// <summary>
/// 结果链接:旧实现调用资源平台 ziyuan 的 /SaveResource/Batch 生成分享链接,
/// 新项目未接入资源平台,按「未配置 ziyuan」的旧分支降级返回 false。
/// </summary>
public Task<object> GetResultUrlAsync(int dataId, int uid, int schId, string schSerId)
{
return Task.FromResult<object>(false);
}
public async Task<bool> GetSchZyBitAsync(string schSerId)
{
// 旧实现仅在 ziyuan 配置存在时返回库值、否则 false;新项目未接入 ziyuan,直接返回库内开关值。
var bit = await _zybit.FirstOrDefaultAsync(a => a.SchSerID == schSerId);
return bit?.ziyuanbit ?? true;
}
public async Task SetSchZyBitAsync(string schSerId, bool zyBit)
{
var obj = await _zybit.FirstOrDefaultTrackedAsync(a => a.SchSerID == schSerId);
if (obj == null)
{
await _zybit.AddAsync(new WY_SchZyBit { SchSerID = schSerId, ziyuanbit = zyBit });
}
else
{
obj.ziyuanbit = zyBit;
await _zybit.UpdateAsync(obj);
}
await _zybit.SaveChangesAsync();
}
public async Task<int> GetShnheCountAsync(string userId, string schSerId)
{
if (!int.TryParse(userId, out var userid))
{
return 0;
}
var docs = await _doc.FindAsync(a => a.SchSerID == schSerId && a.DocStatus == "0");
var fixedGroupIds = (await _groupuser.FindAsync(b => b.userid == userid))
.Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList();
var freeGroupIds = (await _freegroup.FindAsync(b => b.userid == userid))
.Select(b => b.usergroup).Where(g => g.HasValue).Select(g => g.Value).ToList();
var result = new HashSet<decimal>();
foreach (var a in docs)
{
bool match = false;
// 固定流程:DocStep 命中本人组
if (a.flowid != null && a.zhipai == null && a.DocStep.HasValue && fixedGroupIds.Contains(a.DocStep.Value))
{
match = true;
}
// 指派:zhipai 含本人
if (!match && a.zhipai != null && a.flowid != null && a.zhipai.Contains(userId))
{
match = true;
}
// 自由流程:freestepid 命中本人自由组
if (!match && a.freestepid.HasValue && a.freestepid.Value > 0 && freeGroupIds.Contains(a.freestepid.Value))
{
match = true;
}
if (match)
{
result.Add(a.SerID);
}
}
return result.Count;
}
#endregion
}
}
@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Yh.SchClass.OpenApi;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Repository;
using YhCore.Service.Interfaces;
namespace YhCore.Service.Impl
{
/// <summary>
/// 文印统计看板服务(移植旧 BestSchool.DocPrint.DocPrintStatistics,4 接口,ExportRecord 延后)。
/// 旧 [NoAuth] 无用户上下文,schId 由前端传入;新库 SchSerID 已是 int 字符串,直接 schId.ToString() 匹配,
/// 不再依赖旧 GetSchSerID(int) 的 HTTP 转换。roleId 参数旧实现零引用,已删除。
/// 旧 StatusArray = {"1","11"}(印刷中/已印刷)。
/// </summary>
public class DocPrintStatisticsService : IDocPrintStatisticsService
{
private static readonly string[] StatusArray = { "1", "11" };
private readonly IRepository<WY_DocPrint> _doc;
private readonly IRepository<Common_Dictionary> _dic;
private readonly SchClassOpenApi _sch;
public DocPrintStatisticsService(
IRepository<WY_DocPrint> doc,
IRepository<Common_Dictionary> dic,
SchClassOpenApi sch)
{
_doc = doc;
_dic = dic;
_sch = sch;
}
public async Task<List<DocPrintCountItem>> GetDocPrintCountAsync(int schId, DateTime? startDate = null, DateTime? endDate = null)
{
var schSerId = schId.ToString();
var list = (await _doc.FindAsync(a =>
a.SchSerID == schSerId && StatusArray.Contains(a.DocStatus))).ToList();
if (startDate != null)
list = list.Where(a => a.EditDate >= startDate).ToList();
if (endDate != null)
list = list.Where(a => a.EditDate <= endDate).ToList();
var count = list.Count;
var xhzsl = list.Select(a => a.DocNum).DefaultIfEmpty(0).Sum();
var doctypeId = list.GroupBy(a => a.DPType)
.Select(a => new { type = a.Key, sum = a.Sum(s => s.DocNum) })
.OrderByDescending(a => a.sum).FirstOrDefault()?.type ?? "";
string yzfl = "";
if (!string.IsNullOrEmpty(doctypeId))
{
yzfl = (await _dic.FindAsync(g =>
g.typeID == "YZFL" && g.CodeID == doctypeId &&
(g.Schserid == schSerId || g.Schserid == null)))
.OrderBy(g => g.Enable).Select(g => g.Name).FirstOrDefault() ?? "";
}
return new List<DocPrintCountItem>
{
new DocPrintCountItem { k = "文印数量(条)", v = count },
new DocPrintCountItem { k = "消耗总数(张)", v = xhzsl },
new DocPrintCountItem { k = "最多消耗类型", v = yzfl },
};
}
public async Task<List<PieChartSeries>> GetPieChartAsync(int schId, DateTime? startDate = null, DateTime? endDate = null)
{
var schSerId = schId.ToString();
var list = (await _doc.FindAsync(a =>
a.SchSerID == schSerId && StatusArray.Contains(a.DocStatus))).ToList();
if (startDate != null)
list = list.Where(a => a.EditDate >= startDate).ToList();
if (endDate != null)
list = list.Where(a => a.EditDate <= endDate).ToList();
var yzfl = (await _dic.FindAsync(g =>
g.typeID == "YZFL" && (g.Schserid == schSerId || g.Schserid == null)))
.OrderBy(g => g.Enable).ToList();
var yslx = list.GroupBy(a => a.DPType)
.Select(a => new { k = a.Key, v = a.Sum(s => s.DocNum) }).ToList()
.Select(a => new PieChartKv { k = yzfl.FirstOrDefault(s => s.CodeID == a.k)?.Name, v = a.v }).ToList();
var ysxh = list.GroupBy(a => a.DPType)
.Select(a => new { k = a.Key, v = a.Count() }).ToList()
.Select(a => new PieChartKv { k = yzfl.FirstOrDefault(s => s.CodeID == a.k)?.Name, v = a.v }).ToList();
return new List<PieChartSeries>
{
new PieChartSeries { title = "印刷类型", list = yslx },
new PieChartSeries { title = "印刷消耗", list = ysxh },
};
}
public async Task<List<DocPrintTypeItem>> GetDocPrintTypeAsync(int schId)
{
var schSerId = schId.ToString();
var list = await _dic.FindAsync(g =>
g.typeID == "YZFL" && g.Enable == 1 && (g.Schserid == schSerId || g.Schserid == null));
return list.OrderBy(g => g.Des)
.Select(a => new DocPrintTypeItem { CodeID = a.CodeID, Name = a.Name })
.ToList();
}
public async Task<DocPrintListResult> GetDocPrintListAsync(int schId, string docType, string likeName, int pageIndex, int pageSize, DateTime? startDate = null, DateTime? endDate = null)
{
var schSerId = schId.ToString();
if (pageSize <= 0) pageSize = 10;
var list = (await _doc.FindAsync(a =>
a.SchSerID == schSerId && StatusArray.Contains(a.DocStatus))).ToList();
if (!string.IsNullOrEmpty(docType) && docType != "ALL")
list = list.Where(a => a.DPType == docType).ToList();
if (startDate != null)
list = list.Where(a => a.TakeDate >= startDate).ToList();
if (endDate != null)
list = list.Where(a => a.TakeDate <= endDate).ToList();
if (!string.IsNullOrEmpty(likeName))
{
var teaMap = await GetTeaMapAsync(schSerId);
var userids = teaMap.Where(a => a.Value.Name.Contains(likeName))
.Select(a => a.Key.ToString()).ToArray();
list = list.Where(m =>
(m.DocTitle != null && m.DocTitle.Contains(likeName)) ||
(m.DocContent != null && m.DocContent.Contains(likeName)) ||
(m.UserID != null && userids.Contains(m.UserID))).ToList();
}
var totle = list.Count;
if (totle == 0)
return new DocPrintListResult { tlist = new List<DocPrintStatItem>(), totle = totle };
// 旧 1 基分页 → 0 基
pageIndex -= 1;
if (pageIndex < 0) pageIndex = 0;
var yzflList = (await _dic.FindAsync(g =>
g.typeID == "YZFL" && (g.Schserid == schSerId || g.Schserid == null)))
.OrderBy(g => g.Des)
.Select(a => new DocPrintTypeItem { CodeID = a.CodeID, Name = a.Name }).ToList();
var yzbmList = (await _dic.FindAsync(g =>
g.typeID == "YZBM" && (g.Schserid == schSerId || g.Schserid == null)))
.OrderBy(g => g.Des)
.Select(a => new DocPrintTypeItem { CodeID = a.CodeID, Name = a.Name }).ToList();
var teaMap2 = await GetTeaMapAsync(schSerId);
var tlist = list.OrderByDescending(a => a.TakeDate)
.Skip(pageIndex * pageSize).Take(pageSize)
.Select(item => new DocPrintStatItem
{
SerID = (int)item.SerID,
DocContent = item.DocContent,
DocType = item.DPType,
DocNum = item.DocNum,
DocTypeName = yzflList.FirstOrDefault(a => a.CodeID == item.DPType)?.Name,
DocStyle = item.DocStyle,
DocStyleName = yzbmList.FirstOrDefault(a => a.CodeID == item.DocStyle)?.Name,
EditDate = item.EditDate.ToString("yyyy-MM-dd HH:mm"),
TakeDate = item.TakeDate?.ToString("yyyy-MM-dd"),
UserID = item.UserID,
teaname = GetTeaName(teaMap2, item.UserID),
UpFile = item.UpFile,
DocTitle = item.DocTitle,
DocStatus = item.DocStatus,
DocStatusName = item.DocStatus == "1" ? "印刷中" : "已印刷",
DocStep = item.DocStep,
flowid = item.flowid,
FreeUserID = item.FreeUserID,
SendUserID = item.SendUserID,
freestepid = item.freestepid,
DocShare = item.DocShare,
explain = item.explain,
})
.ToList();
return new DocPrintListResult { tlist = tlist, totle = totle };
}
#region 辅助
private async Task<Dictionary<int, (string Name, string Photo)>> GetTeaMapAsync(string schSerId)
{
var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null);
return teas
.Where(t => int.TryParse(t.TeaSerId, out _))
.GroupBy(t => int.Parse(t.TeaSerId))
.ToDictionary(g => g.Key, g => (g.First().Name ?? "", g.First().Photo ?? ""));
}
private static string GetTeaName(Dictionary<int, (string Name, string Photo)> teaMap, string userId)
{
if (!int.TryParse(userId, out var uid)) return "";
return teaMap.TryGetValue(uid, out var tea) ? tea.Name : "";
}
#endregion
}
}
+241
View File
@@ -0,0 +1,241 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Yh.Platform.Abstractions;
using YhCore.Common.Config;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Repository;
using YhCore.Service.Interfaces;
namespace YhCore.Service.Impl
{
/// <summary>
/// 公共文件服务实现(见 IFileService 注释)。
/// 降级:Office→PDF(ToOfficeHelper)新项目缺失,上传不转 PDF;预览兜底 officeapps.live.com。
/// </summary>
public class FileService : IFileService
{
private readonly IRepository<Common_UpFiles> _upfiles;
public FileService(IRepository<Common_UpFiles> upfiles)
{
_upfiles = upfiles;
}
public async Task<FileObj> GetEnclosureAsync(int id)
{
var model = await _upfiles.FirstOrDefaultAsync(x => x.SerID == id);
if (model == null)
throw new DException("文件ID错误!");
return new FileObj
{
fileurl = model.FileName,
filename = model.FileTrueName,
filetype = model.FileType,
filesize = model.FileSize ?? 0,
};
}
public async Task<int> SaveEnclosureAsync(FileObj file, string appType)
{
// 替换文件名中的特殊字符(旧 FileSaveEnclosure)
string safeName = file.filename ?? "";
foreach (var c in Path.GetInvalidFileNameChars())
safeName = safeName.Replace(c, '_');
safeName = safeName.Replace(",", "_").Replace(",", "_");
var model = new Common_UpFiles
{
FileName = file.fileurl,
FileSize = file.filesize,
FileTrueName = safeName,
FileType = file.filetype,
DateAndTime = DateTime.Now,
TopicType = appType,
};
await _upfiles.AddAsync(model);
await _upfiles.SaveChangesAsync();
return model.SerID;
}
public async Task<FileObj> SaveFileAsync(string appType, IFormFile file)
{
if (file == null || file.Length == 0)
throw new DException("上传失败!");
string dtStr = DateTime.Now.ToString("yyyyMMdd");
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
string outFilePath = appType + "/" + dtStr + "/";
if (!Directory.Exists(physicalDir))
Directory.CreateDirectory(physicalDir);
string guid = Guid.NewGuid().ToString();
string fileName = Path.GetFileName(file.FileName);
string suffixName = Path.GetExtension(file.FileName);
string[] suffArry = { "jpg", "gif", "png", "xls", "doc", "txt", "docx", "xlsx", "zip", "rar", "pdf", "ppt", "pptx" };
suffArry = suffArry.Select(x => "." + x).ToArray();
bool isSuffType = suffArry.Any(x => x == suffixName.ToLower());
if (!isSuffType)
throw new DException("不能上传" + suffixName + "格式的文件");
int fileSize = (int)file.Length;
string savePath = Path.Combine(physicalDir, guid + suffixName);
using (var stream = new FileStream(savePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
// 旧 ToOfficeHelper.OfficeConvertToPDF(Office COM)新项目缺失 → 降级不转 PDF
return new FileObj
{
fileurl = outFilePath + guid + suffixName,
filename = fileName,
filetype = suffixName,
filesize = fileSize,
host = Config.FileWebPath,
};
}
public async Task<List<FileObj>> SaveFileImageAsync(string appType, IFormFileCollection files)
{
if (appType.ToLower() == "noticemsg") appType = "notice";
string dtStr = DateTime.Now.ToString("yyyyMMdd");
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
string outFilePath = appType + "/" + dtStr + "/";
if (!Directory.Exists(physicalDir))
Directory.CreateDirectory(physicalDir);
string[] suffArry = { "jpg", "gif", "png", "jpeg" };
suffArry = suffArry.Select(x => "." + x).ToArray();
var list = new List<FileObj>();
foreach (var file in files)
{
if (file == null || file.Length == 0) continue;
string suffixName = Path.GetExtension(file.FileName);
if (!suffArry.Any(x => x == suffixName.ToLower())) continue;
string guid = Guid.NewGuid().ToString();
string fileName = Path.GetFileName(file.FileName);
int fileSize = (int)file.Length;
string savePath = Path.Combine(physicalDir, guid + suffixName);
using (var stream = new FileStream(savePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
list.Add(new FileObj
{
fileurl = outFilePath + guid + suffixName,
filename = fileName,
filetype = suffixName,
filesize = fileSize,
host = Config.FileWebPath,
});
}
return list;
}
public async Task SaveChunkAsync(string guid, string chunkIndex, IFormFile chunk)
{
if (string.IsNullOrEmpty(guid) || chunk == null || chunk.Length == 0)
throw new DException("上传失败!");
var dir = Path.Combine(StorageRoot(), guid);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, chunkIndex);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await chunk.CopyToAsync(stream);
}
}
public async Task<FileObj> MergeSaveAsync(string appType, string guid, string fileName)
{
if (string.IsNullOrEmpty(guid) || string.IsNullOrEmpty(fileName))
throw new DException("上传失败!");
if (appType.ToLower() == "noticemsg") appType = "notice";
string dtStr = DateTime.Now.ToString("yyyyMMdd");
string physicalDir = Path.Combine(StorageRoot(), appType, dtStr);
string outFilePath = appType + "/" + dtStr + "/";
if (!Directory.Exists(physicalDir))
Directory.CreateDirectory(physicalDir);
var uploadDir = Path.Combine(StorageRoot());
var dir = Path.Combine(uploadDir, guid);
if (!Directory.Exists(dir))
throw new DException("上传失败!");
var files = Directory.GetFiles(dir);
string fileRealName = Path.GetFileName(fileName);
string suffixName = Path.GetExtension(fileRealName);
string fileGuid = Guid.NewGuid().ToString();
string outUrl = outFilePath + fileGuid + suffixName;
string finalPath = Path.Combine(physicalDir, fileGuid + suffixName);
using (var fs = new FileStream(finalPath, FileMode.Create))
{
foreach (var part in files.OrderBy(x => x.Length).ThenBy(x => x))
{
var bytes = await File.ReadAllBytesAsync(part);
fs.Write(bytes, 0, bytes.Length);
File.Delete(part);
}
}
int fileSize = (int)(new FileInfo(finalPath).Length / 1024 / 1024); // 旧实现以 MB 存储,保持一致
Directory.Delete(dir);
// 旧 ToOfficeHelper.OfficeConvertToPDF 缺失 → 降级不转 PDF
return new FileObj
{
fileurl = outUrl,
filename = fileRealName,
filetype = suffixName,
filesize = fileSize,
host = Config.FileWebPath,
};
}
public string MapPhysicalPath(string fileUrl)
{
if (string.IsNullOrEmpty(fileUrl))
return "";
// 新数据: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('/');
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("~/upload/");Config.FilePath 缺省时退回进程目录/upload)。</summary>
public static string StorageRoot()
{
if (!string.IsNullOrEmpty(Config.FilePath))
return Config.FilePath;
// 此分支仅在 FILE_PATH 未配置时执行(此时 Config.FilePath 为 null,不可再参与拼接)
return Path.Combine(AppContext.BaseDirectory, Config.FilePath);
}
}
}
@@ -0,0 +1,595 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Yh.Platform.Abstractions;
using Yh.SchClass.OpenApi;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Repository;
using YhCore.Service.Interfaces;
namespace YhCore.Service.Impl
{
/// <summary>
/// 自由审批流服务(移植旧 BestSchool.Service.ApproveFlow)。
/// 教师查询改走教务 SDK(Yh.SchClass.OpenApi),教师标识用 teaId(TeaSerId)对齐新教务。
/// </summary>
public class FreeApproveService : IFreeApproveService
{
private readonly IRepository<Common_freeflowinfo> _freeflowinfo;
private readonly IRepository<Common_freeGroupuser> _freegroup;
private readonly IRepository<Common_flowtranctInfo> _flowtranct;
private readonly IRepository<Common_flowstep> _flowstep;
private readonly IRepository<Common_Groupuser> _groupuser;
private readonly IRepository<Common_UserFlowZhipai> _userflowzhipai;
private readonly YHDbContext _db;
private readonly SchClassOpenApi _sch;
public FreeApproveService(
IRepository<Common_freeflowinfo> freeflowinfo,
IRepository<Common_freeGroupuser> freegroup,
IRepository<Common_flowtranctInfo> flowtranct,
IRepository<Common_flowstep> flowstep,
IRepository<Common_Groupuser> groupuser,
IRepository<Common_UserFlowZhipai> userflowzhipai,
YHDbContext db,
SchClassOpenApi sch)
{
_freeflowinfo = freeflowinfo;
_freegroup = freegroup;
_flowtranct = flowtranct;
_flowstep = flowstep;
_groupuser = groupuser;
_userflowzhipai = userflowzhipai;
_db = db;
_sch = sch;
}
public async Task<List<FreeFlowNode>> GetFleeFlowAsync(int userId, int? dataid)
{
int appid = DocPrintConstants.AppCode;
if ((dataid ?? 0) == 0)
{
var all = await _freeflowinfo.FindAsync(a => a.flowType == appid && a.userid == userId);
dataid = all.Count == 0 ? 0 : all.Max(a => a.dataid ?? 0);
}
if ((dataid ?? 0) <= 0) return new List<FreeFlowNode>();
var nodes = (await _freeflowinfo.FindAsync(a => a.flowType == appid && a.userid == userId && a.dataid == dataid))
.OrderBy(a => a.SerID).ToList();
return await BuildFreeFlowNodesAsync(nodes);
}
public async Task<int> EditFleeFlowAsync(int userId, string schSerId, int? dataid, string flowUids, bool isFlowNull)
{
if (string.IsNullOrEmpty(flowUids))
{
if (!isFlowNull)
throw new DException("请选择审核步骤人员!");
flowUids = "";
}
var flowUidArr = flowUids.Split('_');
int appid = DocPrintConstants.AppCode;
int firstStepId = 0;
using var tx = await _db.Database.BeginTransactionAsync();
try
{
if ((dataid ?? 0) > 0)
{
var old = await _freeflowinfo.FindAsync(a => a.dataid == dataid && a.flowType == appid && a.userid == userId);
foreach (var item in old)
{
await _freeflowinfo.RemoveAsync(item.SerID);
}
await _freeflowinfo.SaveChangesAsync();
}
var upSeptid = 0;
var date = DateTime.Now;
foreach (var fw in flowUidArr)
{
var free = new Common_freeflowinfo
{
laststep = upSeptid,
userid = userId,
createTime = date,
dataid = dataid,
schserid = schSerId,
flowType = appid,
};
await _freeflowinfo.AddAsync(free);
await _freeflowinfo.SaveChangesAsync();
if (upSeptid == 0)
firstStepId = free.SerID;
upSeptid = free.SerID;
foreach (var guserid in fw.Split(','))
{
await _freegroup.AddAsync(new Common_freeGroupuser
{
flowid = upSeptid,
SchSerID = schSerId,
usergroup = upSeptid,
userid = string.IsNullOrEmpty(guserid) ? (int?)null : int.Parse(guserid),
});
}
await _freegroup.SaveChangesAsync();
}
await tx.CommitAsync();
return firstStepId;
}
catch
{
await tx.RollbackAsync();
throw;
}
}
public async Task<string> AddShenheRemarkAsync(int userId, int? dataid, string remark, int freestepid)
{
var info = new Common_flowtranctInfo
{
actionstatus = -1,
remark = remark,
dataid = dataid ?? 0,
stepid = freestepid,
datatype = DocPrintConstants.AppCode.ToString(),
actiondate = DateTime.Now,
actionuserid = userId,
};
await _flowtranct.AddAsync(info);
await _flowtranct.SaveChangesAsync();
return "备注成功!";
}
public async Task<List<FreeFlowNode>> GetAfterFleeFlowAsync(int? dataid, int freestepid)
{
int appid = DocPrintConstants.AppCode;
if ((dataid ?? 0) <= 0) return new List<FreeFlowNode>();
var nodes = (await _freeflowinfo.FindAsync(a => a.flowType == appid && a.SerID > freestepid && a.dataid == dataid))
.OrderBy(a => a.SerID).ToList();
return await BuildFreeFlowNodesAsync(nodes);
}
public async Task<int> EditAfterFleeFlowAsync(int? dataid, int freestepid, string flowUids)
{
int appid = DocPrintConstants.AppCode;
// 删除 freestepid 之后的剩余步骤
var nextSteps = await _freeflowinfo.FindAsync(a => a.dataid == dataid && a.SerID > freestepid);
if (nextSteps.Count > 0)
{
foreach (var item in nextSteps)
{
await _freeflowinfo.RemoveAsync(item.SerID);
}
await _freeflowinfo.SaveChangesAsync();
}
if (string.IsNullOrEmpty(flowUids))
return 0;
var flowUidArr = flowUids.Split('_');
var laststept = await _freeflowinfo.FirstOrDefaultAsync(a => a.SerID == freestepid);
if (laststept == null)
throw new DException("未找到自由流程步骤!");
var upSeptid = freestepid;
using var tx = await _db.Database.BeginTransactionAsync();
try
{
var date = DateTime.Now;
foreach (var fw in flowUidArr)
{
var free = new Common_freeflowinfo
{
laststep = upSeptid,
userid = laststept.userid,
createTime = date,
dataid = dataid,
schserid = laststept.schserid,
flowType = appid,
};
await _freeflowinfo.AddAsync(free);
await _freeflowinfo.SaveChangesAsync();
upSeptid = free.SerID;
foreach (var guserid in fw.Split(','))
{
await _freegroup.AddAsync(new Common_freeGroupuser
{
flowid = upSeptid,
SchSerID = laststept.schserid,
usergroup = upSeptid,
userid = string.IsNullOrEmpty(guserid) ? (int?)null : int.Parse(guserid),
});
}
await _freegroup.SaveChangesAsync();
}
await tx.CommitAsync();
return upSeptid;
}
catch
{
await tx.RollbackAsync();
throw;
}
}
public async Task<List<FreeShenheInfo>> ShenheFlowAsync(int uid, string ctime, int dataid, int? freestepid, int? flowid)
{
int flowType = DocPrintConstants.AppCode;
string appCode = DocPrintConstants.AppCode.ToString();
if ((freestepid ?? 0) > 0)
return await BuildFreeShenheFlowAsync(uid, ctime, dataid, freestepid, flowType, appCode);
if ((flowid ?? 0) > 0)
return await BuildFixShenheFlowAsync(uid, ctime, dataid, flowid, appCode);
return await BuildOnlyTranctShenheFlowAsync(uid, ctime, dataid, appCode);
}
public async Task<List<ChaoSendUser>> ChaoSendListAsync(string uids)
{
var result = new List<ChaoSendUser>();
if (string.IsNullOrEmpty(uids) || uids == "undefined")
return result;
var userids = uids.Split(',')
.Where(a => !string.IsNullOrEmpty(a) && int.TryParse(a, out _))
.Select(int.Parse).ToArray();
if (userids.Length == 0)
return result;
var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", userids));
return teas.Select(a => new ChaoSendUser
{
uid = ParseTeaId(a.TeaSerId),
photo = a.Photo,
name = a.Name,
status = false,
}).ToList();
}
#region 审核流程回显
private async Task<List<FreeShenheInfo>> BuildFreeShenheFlowAsync(int uid, string ctime, int dataid, int? freestepid, int flowType, string appCode)
{
var freeflow = (await _freeflowinfo.FindAsync(a => a.dataid == dataid && a.flowType == flowType))
.OrderBy(a => a.SerID).ToList();
var freeflowids = freeflow.Select(a => a.SerID).ToArray();
var freeflowGroup = (await _freegroup.FindAsync(a => freeflowids.Contains(a.usergroup ?? 0))).ToList();
var freeshenhe = await _flowtranct.FindAsync(a => a.dataid == dataid && a.datatype == appCode);
var frees = new List<FreeShenheInfo>();
var firstFlow = freeflow.FirstOrDefault();
frees.Add(new FreeShenheInfo
{
steptname = await GetUserNameAsync(firstFlow?.userid?.ToString() ?? ""),
steptstatus = "发起申请",
photo = await GetUserPhotoAsync(firstFlow?.userid?.ToString() ?? ""),
actiondate = firstFlow?.createTime?.ToString("yyyy-MM-dd HH:mm") ?? "",
});
if (freeshenhe.Count(a => a.actionstatus > 2) == 0)
{
foreach (var item in freeflow)
{
var act = new FreeShenheInfo();
var isShok = freeshenhe.FirstOrDefault(a => a.stepid == item.SerID);
if (isShok != null)
{
act.steptname = await ChaoSendNameAsync(isShok.actionuserid?.ToString());
act.photo = await GetUserPhotoAsync(isShok.actionuserid?.ToString());
act.steptstatus = ActionstatusName(isShok.actionstatus);
act.actionstatus = isShok.actionstatus.ToString();
act.Remark = isShok.remark;
act.actiondate = isShok.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "";
if (isShok.actionstatus == 2)
{
frees.Add(act);
break;
}
}
else
{
var userids = ToUserIds(freeflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid));
act.steptname = await ChaoSendNameAsync(string.Join(",", userids));
act.photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png";
act.steptstatus = "待审核";
act.actionstatus = "0";
}
frees.Add(act);
}
}
else
{
foreach (var item in freeshenhe)
{
frees.Add(new FreeShenheInfo
{
steptname = await ChaoSendNameAsync((item.actionuserid ?? uid).ToString()),
photo = await GetUserPhotoAsync((item.actionuserid ?? uid).ToString()),
steptstatus = ActionstatusName(item.actionstatus),
actionstatus = item.actionstatus.ToString(),
Remark = item.remark,
actiondate = item.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "",
});
}
var zuihouyibu = freeshenhe.Where(a => a.actionstatus >= 2).OrderByDescending(a => a.SerID).FirstOrDefault();
if (zuihouyibu != null && zuihouyibu.actionstatus == 4)
{
var isShok = freeshenhe.FirstOrDefault(a => a.SerID > zuihouyibu.SerID);
if (isShok == null)
{
var userids = ToUserIds(freeflowGroup.Where(a => a.usergroup == freestepid).Select(a => a.userid));
frees.Add(new FreeShenheInfo
{
steptname = await ChaoSendNameAsync(string.Join(",", userids)),
photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png",
steptstatus = "待审核",
actionstatus = "0",
});
}
}
else if (zuihouyibu != null && zuihouyibu.actionstatus == 6)
{
foreach (var item in freeflow)
{
var isShok = freeshenhe.FirstOrDefault(a => a.stepid == item.SerID && a.SerID > zuihouyibu.SerID);
if (isShok == null)
{
var userids = ToUserIds(freeflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid));
frees.Add(new FreeShenheInfo
{
steptname = await ChaoSendNameAsync(string.Join(",", userids)),
photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png",
steptstatus = "待审核",
actionstatus = "0",
});
}
}
}
}
return frees;
}
private async Task<List<FreeShenheInfo>> BuildFixShenheFlowAsync(int uid, string ctime, int dataid, int? flowid, string appCode)
{
var fixflow = (await _flowstep.FindAsync(a => a.flowid == flowid)).OrderBy(a => a.SerID).ToList();
var fixflowids = fixflow.Select(a => a.SerID).ToArray();
var fixflowGroup = (await _groupuser.FindAsync(a => fixflowids.Contains(a.usergroup ?? 0))).ToList();
var fixshenhe = await _flowtranct.FindAsync(a => a.dataid == dataid && a.datatype == appCode);
var frees = new List<FreeShenheInfo>();
frees.Add(new FreeShenheInfo
{
steptname = await GetUserNameAsync(uid.ToString()),
steptstatus = "发起申请",
actiondate = ctime,
photo = await GetUserPhotoAsync(uid.ToString()),
});
if (fixshenhe.Count(a => a.actionstatus > 2) == 0)
{
foreach (var item in fixflow)
{
var act = new FreeShenheInfo();
var isShok = fixshenhe.FirstOrDefault(a => a.stepid == item.SerID);
if (isShok != null)
{
act.steptname = await ChaoSendNameAsync(isShok.actionuserid?.ToString());
act.photo = await GetUserPhotoAsync(isShok.actionuserid?.ToString());
act.steptstatus = ActionstatusName(isShok.actionstatus);
act.actionstatus = isShok.actionstatus.ToString();
act.Remark = isShok.remark;
act.actiondate = isShok.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "";
if (isShok.actionstatus == 2)
{
frees.Add(act);
break;
}
}
else
{
var userids = ToUserIds(fixflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid));
var userzhipai = await _userflowzhipai.FirstOrDefaultAsync(
a => a.MSerID == dataid && a.FlowID == flowid && a.FlowStepID == item.SerID);
if (!string.IsNullOrEmpty(userzhipai?.zhipai))
{
userids = userzhipai.zhipai.Split(',')
.Where(s => int.TryParse(s, out _))
.Select(int.Parse).Distinct().ToArray();
}
act.steptname = await ChaoSendNameAsync(string.Join(",", userids));
act.photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png";
act.steptstatus = "待审核";
act.actionstatus = "0";
}
frees.Add(act);
}
}
else
{
foreach (var item in fixshenhe)
{
frees.Add(new FreeShenheInfo
{
steptname = await ChaoSendNameAsync((item.actionuserid ?? uid).ToString()),
photo = await GetUserPhotoAsync((item.actionuserid ?? uid).ToString()),
steptstatus = ActionstatusName(item.actionstatus),
actionstatus = item.actionstatus.ToString(),
Remark = item.remark,
actiondate = item.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "",
});
}
var zuihouyibu = fixshenhe.Where(a => a.actionstatus >= 2).OrderByDescending(a => a.SerID).FirstOrDefault();
if (zuihouyibu != null && zuihouyibu.actionstatus == 4)
{
var isShok = fixshenhe.FirstOrDefault(a => a.SerID > zuihouyibu.SerID);
if (isShok == null)
{
var lastStepId = fixflowids.OrderByDescending(p => p).FirstOrDefault();
var userids = ToUserIds(fixflowGroup.Where(a => a.usergroup == lastStepId).Select(a => a.userid));
frees.Add(new FreeShenheInfo
{
steptname = await ChaoSendNameAsync(string.Join(",", userids)),
photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png",
steptstatus = "待审核",
actionstatus = "0",
});
}
}
else if (zuihouyibu != null && zuihouyibu.actionstatus == 6)
{
foreach (var item in fixflow)
{
var isShok = fixshenhe.FirstOrDefault(a => a.stepid == item.SerID && a.SerID > zuihouyibu.SerID);
if (isShok == null)
{
var userids = ToUserIds(fixflowGroup.Where(a => a.usergroup == item.SerID).Select(a => a.userid));
frees.Add(new FreeShenheInfo
{
steptname = await ChaoSendNameAsync(string.Join(",", userids)),
photo = userids.Length == 1 ? await GetUserPhotoAsync(userids[0].ToString()) : "/static/image/site/moreperpeo.png",
steptstatus = "待审核",
actionstatus = "0",
});
}
}
}
}
return frees;
}
private async Task<List<FreeShenheInfo>> BuildOnlyTranctShenheFlowAsync(int uid, string ctime, int dataid, string appCode)
{
var shenhe = await _flowtranct.FindAsync(a => a.dataid == dataid && a.datatype == appCode);
var frees = new List<FreeShenheInfo>();
frees.Add(new FreeShenheInfo
{
steptname = await GetUserNameAsync(uid.ToString()),
steptstatus = "发起申请",
actiondate = ctime,
photo = await GetUserPhotoAsync(uid.ToString()),
});
if (shenhe.Count(a => a.actionstatus > 2) > 0)
{
foreach (var item in shenhe)
{
frees.Add(new FreeShenheInfo
{
steptname = await ChaoSendNameAsync(item.actionuserid?.ToString()),
photo = await GetUserPhotoAsync(item.actionuserid?.ToString()),
steptstatus = ActionstatusName(item.actionstatus),
actionstatus = item.actionstatus.ToString(),
Remark = item.remark,
actiondate = item.actiondate?.ToString("yyyy-MM-dd HH:mm") ?? "",
});
}
}
return frees;
}
#endregion
#region 辅助
private async Task<List<FreeFlowNode>> BuildFreeFlowNodesAsync(List<Common_freeflowinfo> nodes)
{
var result = new List<FreeFlowNode>();
foreach (var node in nodes)
{
var userIds = (await _freegroup.FindAsync(n => n.flowid == node.SerID))
.Select(n => n.userid).Where(u => u.HasValue).Select(u => u.Value).ToList();
var users = new List<FreeFlowUser>();
if (userIds.Count > 0)
{
var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", userIds));
users = teas.Select(t => new FreeFlowUser
{
userid = ParseTeaId(t.TeaSerId),
name = t.Name,
photo = t.Photo,
}).ToList();
}
result.Add(new FreeFlowNode { user = users });
}
return result;
}
private static int ParseTeaId(string teaSerId)
=> int.TryParse(teaSerId, out var id) ? id : 0;
private static int[] ToUserIds(IEnumerable<int?> source)
=> source.Select(u => u ?? 0).ToArray();
private async Task<string> GetUserNameAsync(string teaId)
{
if (!int.TryParse(teaId, out var id)) return "";
var tea = await _sch.TeaInfo.GetTeaInfoAsync(id);
return tea?.Name ?? "";
}
private async Task<string> GetUserPhotoAsync(string teaId)
{
if (!int.TryParse(teaId, out var id)) return "";
var tea = await _sch.TeaInfo.GetTeaInfoAsync(id);
return tea?.Photo ?? "";
}
private async Task<string> ChaoSendNameAsync(string uids)
{
if (string.IsNullOrEmpty(uids) || uids == "undefined") return "";
var ids = uids.Split(',')
.Where(s => int.TryParse(s, out _)).Select(int.Parse).Distinct().ToArray();
if (ids.Length == 0) return "";
var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", ids));
return string.Join(",", teas.Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n)));
}
private static string ActionstatusName(int actionstatus)
{
return actionstatus == -1 ? "备注"
: actionstatus == 0 ? "发起"
: actionstatus == 1 ? "已同意"
: actionstatus == 2 ? "拒绝"
: actionstatus == 3 ? "已撤销"
: actionstatus == 4 ? "撤销发起"
: actionstatus == 5 ? "通过(撤销被拒绝)"
: actionstatus == 6 ? "发起修改申请"
: actionstatus == 7 ? "已领取"
: actionstatus == 8 ? "已维修"
: actionstatus == 9 ? "已归还"
: actionstatus == 10 ? "已领取"
: actionstatus == 11 ? "印刷中"
: "其他";
}
#endregion
}
}
+53
View File
@@ -0,0 +1,53 @@
using System.Threading.Tasks;
using YhCore.Entity;
using YhCore.Model.Vo;
using YhCore.Repository;
using YhCore.Service.Interfaces;
namespace YhCore.Service.Impl
{
/// <summary>
/// 日志查询服务(对应 Java 版 LogServiceImpl,替换框架期 LogServiceStub)。
/// 查询 sys_operation_log:按学校隔离;systemScope=false 排除查询类(GET=LogLevel 3);
/// keyword 模糊匹配用户名/URI/类方法/请求参数/异常/操作类型;按 CreateTime 倒序分页。
/// </summary>
public class LogService : ILogService
{
/// <summary>查询类日志级别(与 SysOperationLogFilter 推导一致:GET → 3/QUERY)</summary>
private const int QueryLogLevel = 3;
/// <summary>每页大小上限(审计日志量大,防一次拉全表)</summary>
private const int MaxPageSize = 200;
private readonly IRepository<SysOperationLog> _log;
public LogService(IRepository<SysOperationLog> log)
{
_log = log;
}
public Task<PageVO<SysOperationLog>> SearchAsync(int schId, string keyword, bool systemScope, int page, int size)
{
if (page < 0) page = 0;
if (size <= 0) size = 20;
if (size > MaxPageSize) size = MaxPageSize;
var kw = keyword?.Trim();
return _log.PageAsync(
a => a.SchId == schId
// 普通操作日志:只看增删改(排除查询类);系统操作日志全量
&& (systemScope || a.LogLevel != QueryLogLevel)
// 关键字模糊匹配(EF 翻译为 SQL LIKE,NULL 列不命中)
&& (string.IsNullOrEmpty(kw)
|| a.UserName.Contains(kw)
|| a.RequestUri.Contains(kw)
|| a.ClassMethod.Contains(kw)
|| a.RequestParams.Contains(kw)
|| a.ErrorMsg.Contains(kw)
|| a.OperationType.Contains(kw)),
page, size,
a => a.CreateTime, asc: false);
}
}
}
@@ -0,0 +1,530 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Yh.Platform.Abstractions;
using Yh.SchClass.OpenApi;
using Yh.SchClass.OpenApi.Model;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
using YhCore.Repository;
using YhCore.Service.Interfaces;
namespace YhCore.Service.Impl
{
/// <summary>
/// 审核流程管理服务(移植旧 BestSchool.Service.Reviewprocess + UserFlowRole)。
/// 教师查询改走教务 SDK(Yh.SchClass.OpenApi),缺失 groupId 的局版接口降级为当前学校。
/// </summary>
public class ReviewProcessService : IReviewProcessService
{
private readonly IRepository<Common_flowinfo> _flowinfo;
private readonly IRepository<Common_flowstep> _flowstep;
private readonly IRepository<Common_Groupuser> _groupuser;
private readonly IRepository<Common_freeflowinfo> _freeflowinfo;
private readonly IRepository<Common_freeGroupuser> _freegroup;
private readonly IRepository<Common_SendUserinfo> _senduser;
private readonly IRepository<Common_UpFiles> _upfiles;
private readonly SchClassOpenApi _sch;
public ReviewProcessService(
IRepository<Common_flowinfo> flowinfo,
IRepository<Common_flowstep> flowstep,
IRepository<Common_Groupuser> groupuser,
IRepository<Common_freeflowinfo> freeflowinfo,
IRepository<Common_freeGroupuser> freegroup,
IRepository<Common_SendUserinfo> senduser,
IRepository<Common_UpFiles> upfiles,
SchClassOpenApi sch)
{
_flowinfo = flowinfo;
_flowstep = flowstep;
_groupuser = groupuser;
_freeflowinfo = freeflowinfo;
_freegroup = freegroup;
_senduser = senduser;
_upfiles = upfiles;
_sch = sch;
}
#region 流程与步骤
public Task<PageVO<FlowListItem>> List2Async(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId)
=> ListAsync(name, pageSize, pageIndex, flowType, edit, schSerId);
public async Task<PageVO<FlowListItem>> ListAsync(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId)
{
var resultList = await _flowinfo.FindAsync(m => m.flowType == flowType && m.schSerID == schSerId);
if (pageSize == 100 && pageIndex == 0)
{
if (!edit)
resultList = resultList.Where(a => a.enable == 1).ToList();
var stepFlowIds = (await _flowstep.FindAsync(s => true))
.Select(s => s.flowid).Where(f => f.HasValue).Select(f => f.Value).Distinct().ToHashSet();
resultList = resultList.Where(a => stepFlowIds.Contains(a.SerID)).ToList();
}
else
{
if (!edit)
resultList = resultList.Where(a => a.enable != 3).ToList();
}
if (!string.IsNullOrEmpty(name))
{
resultList = resultList.Where(m => m.flowname != null && m.flowname.IndexOf(name) >= 0).ToList();
}
var ordered = resultList.OrderBy(m => m.SerID).ToList();
var items = ordered.Skip(pageSize * pageIndex).Take(pageSize)
.Select(item => new FlowListItem
{
SerID = item.SerID.ToString(),
flowname = item.flowname,
deptid = item.deptid,
typeid = item.typeid,
enable = item.enable,
enablename = item.enable == 1 ? "是" : "否",
miaoshu = item.flowdes,
deitdate = item.deitdate.ToString("yyyy-MM-dd"),
})
.ToList();
return PageVO<FlowListItem>.Of(items, ordered.Count, pageIndex, pageSize);
}
public async Task<PageVO<StepListItem>> StepListAsync(string name, int serid)
{
var steps = await _flowstep.FindAsync(a => a.flowid == serid);
if (!string.IsNullOrEmpty(name))
{
steps = steps.Where(m => m.StepName != null && m.StepName.IndexOf(name) >= 0).ToList();
}
var ordered = steps.OrderBy(m => m.SerID).ToList();
var items = new List<StepListItem>();
foreach (var item in ordered)
{
items.Add(new StepListItem
{
SerID = item.SerID.ToString(),
StepName = item.StepName,
flowid = item.flowid?.ToString(),
prestep = item.prestep,
TeaName = await GetTeaNamesByStepAsync(item.SerID),
Teaid = await GetTeaIdsByStepAsync(item.SerID),
istrue = steps.Any(a => a.prestep == item.SerID),
});
}
return PageVO<StepListItem>.Of(items, ordered.Count, 0, ordered.Count == 0 ? 20 : ordered.Count);
}
public async Task CreateAsync(Common_flowinfo model)
{
if (model == null) throw new DException("参数错误!");
var flowname = model.flowname?.Trim();
var dup = await _flowinfo.AnyAsync(a =>
a.schSerID == model.schSerID && a.flowType == model.flowType &&
a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3);
if (dup) throw new DException("流程名不能重名!");
var info = new Common_flowinfo
{
flowname = model.flowname,
deptid = model.deptid,
typeid = model.typeid,
flowdes = model.flowdes,
enable = model.enable,
flowType = model.flowType,
deitdate = DateTime.Now,
schSerID = model.schSerID,
};
await _flowinfo.AddAsync(info);
await _flowinfo.SaveChangesAsync();
}
public async Task EditAsync(Common_flowinfo model)
{
if (model == null) throw new DException("参数错误!");
var flowname = model.flowname?.Trim();
var dup = (await _flowinfo.FindAsync(a =>
a.schSerID == model.schSerID && a.flowType == DocPrintConstants.AppCode &&
a.flowname != null && a.flowname.Trim() == flowname && a.enable != 3)).Count;
if (dup > 1) throw new DException("流程名不能重名!");
var info = await _flowinfo.FirstOrDefaultTrackedAsync(a => a.SerID == model.SerID);
if (info == null) throw new DException("未找到要修改的流程!");
info.flowname = model.flowname;
info.deptid = model.deptid;
info.typeid = model.typeid;
info.flowdes = model.flowdes;
info.enable = model.enable;
await _flowinfo.UpdateAsync(info);
await _flowinfo.SaveChangesAsync();
}
public async Task StepCreateAsync(Common_flowstep model)
{
if (model == null) throw new DException("参数错误!");
var info = new Common_flowstep
{
StepName = model.StepName,
prestep = model.prestep,
flowid = model.flowid,
ordernum = 0,
};
await _flowstep.AddAsync(info);
await _flowstep.SaveChangesAsync();
if (string.IsNullOrEmpty(model.SchSerID))
{
throw new DException("添加失败!");
}
await AddGroupUsersAsync(info.SerID, model.flowid, model.SchSerID);
}
public async Task StepEditAsync(Common_flowstep model)
{
if (model == null) throw new DException("参数错误!");
var info = await _flowstep.FirstOrDefaultTrackedAsync(a => a.SerID == model.SerID);
if (info == null) throw new DException("未找到要修改的步骤!");
info.flowid = model.flowid;
info.StepName = model.StepName;
info.prestep = model.prestep;
await _flowstep.UpdateAsync(info);
// 重写该步骤的人员绑定(先清后加)
var existing = await _groupuser.FindTrackedAsync(a => a.usergroup == info.SerID);
foreach (var itt in existing)
{
await _groupuser.RemoveAsync(itt);
}
if (string.IsNullOrEmpty(model.SchSerID))
{
await _groupuser.SaveChangesAsync();
throw new DException("修改失败!");
}
await AddGroupUsersAsync(info.SerID, info.flowid, model.SchSerID);
}
private async Task AddGroupUsersAsync(int stepSerId, int? flowId, string userIds)
{
foreach (var idStr in userIds.Split(','))
{
if (!int.TryParse(idStr.Trim(), out var userId)) continue;
await _groupuser.AddAsync(new Common_Groupuser
{
usergroup = stepSerId,
flowid = flowId,
userid = userId,
IsOk = 0,
});
}
await _groupuser.SaveChangesAsync();
}
public async Task DeleteAsync(string ids)
{
if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null");
var lids = ids.Split(',').Select(a => int.Parse(a)).ToArray();
var flows = await _flowinfo.FindTrackedAsync(a => lids.Contains(a.SerID));
foreach (var item in flows)
{
item.enable = 3;
}
await _flowinfo.SaveChangesAsync();
}
public async Task StepDeleteAsync(decimal[] ids)
{
if (ids == null || ids.Length == 0) throw new DException("ids is not null");
var intIds = ids.Select(d => (int)d).ToArray();
var objlist = await _flowstep.FindTrackedAsync(m => intIds.Contains(m.SerID));
foreach (var item in objlist)
{
await _flowstep.RemoveAsync(item);
}
await _flowstep.SaveChangesAsync();
}
public async Task<List<BindStepItem>> BindstepListAsync(int serid)
{
var steps = await _flowstep.FindAsync(a => a.flowid == serid);
return steps.OrderBy(a => a.prestep)
.Select(a => new BindStepItem { SerID = a.SerID, StepName = a.StepName })
.ToList();
}
public async Task<List<BindGroupuserItem>> BindGroupuserListAsync(int flowid, int prestep)
{
var steps = await _flowstep.FindAsync(a => a.flowid == flowid && a.prestep == prestep);
var result = new List<BindGroupuserItem>();
foreach (var step in steps)
{
var users = await _groupuser.FindAsync(b => b.usergroup == step.SerID);
foreach (var u in users)
{
result.Add(new BindGroupuserItem
{
StepId = step.SerID,
userid = u.userid,
StepName = step.StepName,
TeaName = await GetTeaNameAsync(u.userid?.ToString()),
});
}
}
return result;
}
public async Task<List<BindFlowItem>> BindflowinfoAsync(string schSerId, int flowType)
{
var flows = await _flowinfo.FindAsync(a => a.flowType == flowType && a.schSerID == schSerId && a.enable == 1);
var result = new List<BindFlowItem>();
foreach (var item in flows.OrderBy(m => m.SerID))
{
var chaoSend = new List<ChaoSendItem>();
if (!string.IsNullOrEmpty(item.ChaoSend))
{
foreach (var uid in item.ChaoSend.Split(','))
{
if (string.IsNullOrEmpty(uid)) continue;
chaoSend.Add(new ChaoSendItem
{
uid = uid,
name = await GetTeaNameAsync(uid),
photo = await GetTeaPhotoAsync(uid),
});
}
}
result.Add(new BindFlowItem
{
SerID = item.SerID.ToString(),
flowname = item.flowname,
ChaoSendArr = chaoSend,
});
}
return result;
}
#endregion
#region 教师 / 人员
public async Task<List<TeaOption>> GetTeaListAsync(string schSerId)
{
var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null);
return teas.Select(a => new TeaOption { value = ParseTeaId(a.TeaSerId), label = a.Name }).ToList();
}
public Task<List<TeaOption>> GetTeaListSupAsync(string schSerId)
=> GetTeaListAsync(schSerId);
public async Task<PageVO<TeaInfoVo>> GetUserinfoAsync(string schSerId, string name, int statetype, int pageSize, int pageIndex)
{
var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null);
if (!string.IsNullOrEmpty(name))
{
teas = teas.Where(m => m.Name != null && m.Name.IndexOf(name) >= 0).ToList();
}
if (statetype > -1)
{
teas = teas.Where(m => m.IsLeadingMiddle == statetype).ToList();
}
var ordered = teas.OrderBy(m => ParseTeaId(m.TeaSerId)).ToList();
var items = ordered.Skip(pageSize * pageIndex).Take(pageSize).ToList();
return PageVO<TeaInfoVo>.Of(items, ordered.Count, pageIndex, pageSize);
}
public async Task<string[]> GetNoLeadingMiddleInfoAsync(string schSerId)
{
var teas = await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, null);
return teas.Where(m => m.IsLeadingMiddle != 1)
.Select(m => m.TeaSerId)
.Where(id => !string.IsNullOrEmpty(id))
.ToArray();
}
public Task<string[]> GetNoLeadingMiddleInfoSupAsync(string schSerId)
=> GetNoLeadingMiddleInfoAsync(schSerId);
public Task ChangeLeadingMiddleStatusAsync(int state, string userTeaSerIdStr)
{
// 旧实现调用 OATea/EditIntheBearingStatus 写教师状态;教务 SDK 未提供该写接口,降级拒绝。
throw new DException("该功能依赖教师状态写接口,教务 SDK 暂未提供,无法修改中层领导状态!");
}
public async Task<List<FileListItem>> FilelistAsync(string ids)
{
if (string.IsNullOrWhiteSpace(ids)) return new List<FileListItem>();
var idStrings = ids.Split(',');
var all = await _upfiles.FindAsync(a => true);
return all.Where(a => idStrings.Contains(a.SerID.ToString()))
.Select(a => new FileListItem
{
SerID = a.SerID,
FileTrueName = string.IsNullOrEmpty(a.FileTrueName) ? a.FileTrueName : a.FileTrueName.Split('.')[0],
FileName = a.FileName,
FileSize = a.FileSize,
FileType = a.FileType,
})
.ToList();
}
#endregion
#region 流程判断 / 用户
public async Task<ShenheFlowResult> IsShenheflowAsync(string schSerId, int userId)
{
var stepFlowIds = (await _flowstep.FindAsync(s => true))
.Select(s => s.flowid).Where(f => f.HasValue).Select(f => f.Value).Distinct().ToHashSet();
var flowlist = await _flowinfo.FindAsync(a =>
a.schSerID == schSerId && a.flowType == DocPrintConstants.AppCode && a.enable == 1);
var hasFixed = flowlist.Any(a => stepFlowIds.Contains(a.SerID));
var result = new ShenheFlowResult { flowflag = hasFixed, freeflowlist = new List<FreeFlowItem>() };
if (!hasFixed)
{
var freeflowlist = await _freeflowinfo.FindAsync(a => a.userid == userId && a.flowType == DocPrintConstants.AppCode);
foreach (var a in freeflowlist)
{
result.freeflowlist.Add(new FreeFlowItem
{
shenheuserid = a.shenheuserid,
teamnamestr = await GetTeaNamesStrAsync(a.shenheuserid),
});
}
}
return result;
}
public async Task<List<SendUserItem>> IsSendUserAsync(string schSerId, int userId)
{
var result = new List<SendUserItem>();
var senduserlist = await _senduser.FirstOrDefaultAsync(a => a.userid == userId && a.flowType == DocPrintConstants.AppCode);
if (senduserlist == null || string.IsNullOrEmpty(senduserlist.Senduserid))
{
return result;
}
var userids = senduserlist.Senduserid.Split(',')
.Where(a => !string.IsNullOrEmpty(a) && a != "undefined" && int.TryParse(a, out _))
.Select(int.Parse).ToArray();
if (userids.Length == 0) return result;
var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", userids));
return teas.Select(a => new SendUserItem
{
uid = ParseTeaId(a.TeaSerId),
photo = a.Photo,
name = a.Name,
status = false,
}).ToList();
}
public Task<CurrentUserInfo> GetUserAsync()
{
var user = UserContextHolder.Current;
return Task.FromResult(new CurrentUserInfo
{
Id = user?.Id ?? 0,
UserName = user?.UserName,
Account = user?.Account,
SchoolId = user?.SchoolId,
SchoolName = user?.SchoolName,
});
}
public async Task<int> GetUserFlowRoleAsync(int userId)
{
var fixFlowIds = (await _flowinfo.FindAsync(f => f.enable == 1 && f.flowType == DocPrintConstants.AppCode))
.Select(f => f.SerID).ToHashSet();
var fixFlow = await _groupuser.AnyAsync(a => a.userid == userId && a.flowid.HasValue && fixFlowIds.Contains(a.flowid.Value));
var freeFlowIds = (await _freeflowinfo.FindAsync(f => f.flowType == DocPrintConstants.AppCode))
.Select(f => f.SerID).ToHashSet();
var freeFlow = await _freegroup.AnyAsync(a => a.userid == userId && a.flowid.HasValue && freeFlowIds.Contains(a.flowid.Value));
return (fixFlow || freeFlow) ? 1 : 0;
}
#endregion
#region 教师名称/头像辅助
private static int ParseTeaId(string teaSerId)
=> int.TryParse(teaSerId, out var id) ? id : 0;
private async Task<string> GetTeaNameAsync(string teaId)
{
if (!int.TryParse(teaId, out var id)) return "";
var tea = await _sch.TeaInfo.GetTeaInfoAsync(id);
return tea?.Name ?? "";
}
private async Task<string> GetTeaPhotoAsync(string teaId)
{
if (!int.TryParse(teaId, out var id)) return "";
var tea = await _sch.TeaInfo.GetTeaInfoAsync(id);
return tea?.Photo ?? "";
}
private async Task<string> GetTeaIdsByStepAsync(int stepSerId)
{
var ids = (await _groupuser.FindAsync(a => a.usergroup == stepSerId))
.Select(a => a.userid).Where(u => u.HasValue).Select(u => u.Value).ToList();
return string.Join(",", ids);
}
private async Task<string> GetTeaNamesByStepAsync(int stepSerId)
{
var ids = (await _groupuser.FindAsync(a => a.usergroup == stepSerId))
.Select(a => a.userid).Where(u => u.HasValue).Select(u => u.Value).ToList();
return await GetTeaNamesByIdsAsync(ids);
}
private async Task<string> GetTeaNamesByIdsAsync(IEnumerable<int> ids)
{
var list = ids.Distinct().ToList();
if (list.Count == 0) return "";
var teas = await _sch.TeaInfo.GetArrTeaInfoAsync(string.Join(",", list));
return string.Join(",", teas.Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n)));
}
private async Task<string> GetTeaNamesStrAsync(string commaIds)
{
if (string.IsNullOrEmpty(commaIds)) return "";
var ids = commaIds.Split(',')
.Where(s => int.TryParse(s, out _)).Select(int.Parse).Distinct().ToList();
if (ids.Count == 0) return "";
return await GetTeaNamesByIdsAsync(ids);
}
#endregion
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using YhCore.Entity;
using YhCore.Model.Dto;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 公共字典服务(对应旧 BestSchool.Service.GetSysDict)。
/// </summary>
public interface IDictionaryService
{
/// <summary>指定字典下拉框列表({k=CodeID, v=Name},Enable=1)。</summary>
Task<List<DicOption>> GetDictListAsync(string typeId, string schId);
/// <summary>字典类别列表(排除 DEPT)。</summary>
Task<List<DicOption>> GetDicTypeListAsync();
/// <summary>字典管理列表(含已删除,供超管管理)。</summary>
Task<List<DicManageItem>> GetDictMangeListAsync(string typeId, string schId);
/// <summary>添加字典(typeID/typeName/Name/Schserid 由调用方填好,此处计算 Sort/Des/Enable/CodeID)。</summary>
Task AddDicAsync(Common_Dictionary model);
/// <summary>更新字典(Name/Des/Sort/Enable)。</summary>
Task UpdateDicAsync(Common_Dictionary model);
/// <summary>编辑字典名称(Name/Code/Path)。</summary>
Task EditDicAsync(Common_Dictionary model);
}
}
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using YhCore.Entity;
using YhCore.Model.Dto;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 文印分类 / 印制版面 / 公共类别服务(对应旧 BestSchool.DocPrint.WY_list 分类部分 + WY_Classify)。
/// </summary>
public interface IDocPrintClassifyService
{
/// <summary>添加文印分类(typeName 固定「印制分类」)。</summary>
Task AddDicAsync(Common_Dictionary model);
/// <summary>文印分类列表。</summary>
Task<List<CategoryItem>> CategoryListAsync(string schSerId, string name);
/// <summary>添加印制版面(typeName 固定「印制版面」)。</summary>
Task AddYZBMAsync(Common_Dictionary model);
/// <summary>印制版面列表。</summary>
Task<List<CategoryItem>> YZBMListAsync(string schSerId, string name);
/// <summary>删除文印分类/版面(有使用记录则拒绝)。</summary>
Task DeleteDicAsync(decimal[] ids);
/// <summary>公共类别列表(含排序/显隐)。</summary>
Task<List<DictionaryTypeItem>> DictionaryTypeListAsync(string schSerId, string name, bool showAll, string typeId);
/// <summary>删除公共类别(软删除 Enable=3)。</summary>
Task DeleteDictionaryTypeAsync(string[] ids);
/// <summary>公共类别显隐。</summary>
Task SetDictionaryOrdAsync(string schSerId, int dicSerId, bool show);
/// <summary>公共类别排序。</summary>
Task OrderDicAsync(string ids, string schSerId);
/// <summary>结果链接(依赖资源平台 ziyuan,新项目未接入,降级返回 false)。</summary>
Task<object> GetResultUrlAsync(int dataId, int uid, int schId, string schSerId);
/// <summary>学校文印资源开关。</summary>
Task<bool> GetSchZyBitAsync(string schSerId);
/// <summary>设置学校文印资源开关。</summary>
Task SetSchZyBitAsync(string schSerId, bool zyBit);
/// <summary>待审数量角标。</summary>
Task<int> GetShnheCountAsync(string userId, string schSerId);
}
}
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using YhCore.Model.Dto;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 文印统计看板服务(移植旧 BestSchool.DocPrint.DocPrintStatistics,5 接口)。
/// 旧 [NoAuth] 无用户上下文,schId 由前端传入;新库 SchSerID 已是 int 字符串,直接 schId.ToString() 匹配。
/// roleId 参数旧实现零引用,已删除。ExportRecord(Excel 导出,NPOI)延后。
/// </summary>
public interface IDocPrintStatisticsService
{
Task<List<DocPrintCountItem>> GetDocPrintCountAsync(int schId, DateTime? startDate = null, DateTime? endDate = null);
Task<List<PieChartSeries>> GetPieChartAsync(int schId, DateTime? startDate = null, DateTime? endDate = null);
Task<List<DocPrintTypeItem>> GetDocPrintTypeAsync(int schId);
Task<DocPrintListResult> GetDocPrintListAsync(int schId, string docType, string likeName, int pageIndex, int pageSize, DateTime? startDate = null, DateTime? endDate = null);
}
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using YhCore.Model.Dto;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 公共文件服务(移植旧 BestSchool.Service.Common.GetEnclosure/FileSaveEnclosure +
/// 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 在线预览。
/// </summary>
public interface IFileService
{
/// <summary>按附件 id 取文件信息(旧 GetEnclosure:Common_UpFiles → FileObj)。</summary>
Task<FileObj> GetEnclosureAsync(int id);
/// <summary>存储附件信息(旧 FileSaveEnclosure:Common_UpFiles 落库,返回 SerID;文件名非法字符替换为 _)。</summary>
Task<int> SaveEnclosureAsync(FileObj file, string appType);
/// <summary>单文件上传(旧 upload.FileSave:扩展名白名单 + 保存 + 返回 FileObj)。</summary>
Task<FileObj> SaveFileAsync(string appType, IFormFile file);
/// <summary>图片多文件上传(旧 upload.FileSaveImage:jpg/gif/png/jpeg)。</summary>
Task<List<FileObj>> SaveFileImageAsync(string appType, IFormFileCollection files);
/// <summary>保存分片(旧 FileChunkSave:临时目录 {guid}/ 下按索引存块)。</summary>
Task SaveChunkAsync(string guid, string chunkIndex, IFormFile chunk);
/// <summary>合并分片(旧 FileMergeSave:按块合并 → 正式目录 + FileObj)。</summary>
Task<FileObj> MergeSaveAsync(string appType, string guid, string fileName);
/// <summary>相对路径 → 物理路径(fileurl 以 /upload/ 开头 → Config.FilePath + 子路径)。</summary>
string MapPhysicalPath(string fileUrl);
}
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using YhCore.Model.Dto;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 自由审批流服务(移植旧 BestSchool.Service.ApproveFlow)。
/// 教师查询走教务 SDK(Yh.SchClass.OpenApi)。
/// </summary>
public interface IFreeApproveService
{
/// <summary>获取自己的自由流程节点(dataid 为空时取最大 dataid)。</summary>
Task<List<FreeFlowNode>> GetFleeFlowAsync(int userId, int? dataid);
/// <summary>保存自由流程审核人,返回首步骤 SerID。</summary>
Task<int> EditFleeFlowAsync(int userId, string schSerId, int? dataid, string flowUids, bool isFlowNull);
/// <summary>添加审核意见(写 flowtranctInfo,actionstatus=-1)。</summary>
Task<string> AddShenheRemarkAsync(int userId, int? dataid, string remark, int freestepid);
/// <summary>获取 freestepid 之后的自由流程节点。</summary>
Task<List<FreeFlowNode>> GetAfterFleeFlowAsync(int? dataid, int freestepid);
/// <summary>编辑 freestepid 之后的自由流程,返回末步骤 SerID。</summary>
Task<int> EditAfterFleeFlowAsync(int? dataid, int freestepid, string flowUids);
/// <summary>查看发起人 + 审核人步骤(自由 / 固定 / 仅记录)。</summary>
Task<List<FreeShenheInfo>> ShenheFlowAsync(int uid, string ctime, int dataid, int? freestepid, int? flowid);
/// <summary>抄送人列表(按 uids 查用户)。</summary>
Task<List<ChaoSendUser>> ChaoSendListAsync(string uids);
}
}
@@ -0,0 +1,22 @@
using System.Threading.Tasks;
using YhCore.Entity;
using YhCore.Model.Vo;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 日志查询服务(对应 Java 版 ILogService)。
/// </summary>
public interface ILogService
{
/// <summary>
/// 分页搜索系统审计日志(sys_operation_log,按 CreateTime 倒序)。
/// </summary>
/// <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>
Task<PageVO<SysOperationLog>> SearchAsync(int schId, string keyword, bool systemScope, int page, int size);
}
}
@@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Yh.SchClass.OpenApi.Model;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 审核流程管理服务(对应旧 BestSchool.Service.Reviewprocess + UserFlowRole)。
/// </summary>
public interface IReviewProcessService
{
/// <summary>审核流程列表(分页)。</summary>
Task<PageVO<FlowListItem>> ListAsync(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId);
/// <summary>审核流程列表【局版】(新框架无 groupId,降级为与 List 同源)。</summary>
Task<PageVO<FlowListItem>> List2Async(string name, int pageSize, int pageIndex, int flowType, bool edit, string schSerId);
/// <summary>步骤流程列表。</summary>
Task<PageVO<StepListItem>> StepListAsync(string name, int serid);
/// <summary>添加审核流程。</summary>
Task CreateAsync(Common_flowinfo model);
/// <summary>修改审核流程。</summary>
Task EditAsync(Common_flowinfo model);
/// <summary>添加步骤流程。</summary>
Task StepCreateAsync(Common_flowstep model);
/// <summary>修改步骤流程。</summary>
Task StepEditAsync(Common_flowstep model);
/// <summary>删除审核流程(软删除 enable=3)。</summary>
Task DeleteAsync(string ids);
/// <summary>删除步骤。</summary>
Task StepDeleteAsync(decimal[] ids);
/// <summary>流程绑定的步骤列表。</summary>
Task<List<BindStepItem>> BindstepListAsync(int serid);
/// <summary>绑定步骤的人员。</summary>
Task<List<BindGroupuserItem>> BindGroupuserListAsync(int flowid, int prestep);
/// <summary>绑定流程(流程 + 抄送人)。</summary>
Task<List<BindFlowItem>> BindflowinfoAsync(string schSerId, int flowType);
/// <summary>当前学校教师列表。</summary>
Task<List<TeaOption>> GetTeaListAsync(string schSerId);
/// <summary>学校 + 教育局教师列表(降级为当前学校)。</summary>
Task<List<TeaOption>> GetTeaListSupAsync(string schSerId);
/// <summary>教师信息分页。</summary>
Task<PageVO<TeaInfoVo>> GetUserinfoAsync(string schSerId, string name, int statetype, int pageSize, int pageIndex);
/// <summary>非中层领导人员(teaId 数组)。</summary>
Task<string[]> GetNoLeadingMiddleInfoAsync(string schSerId);
/// <summary>非中层领导人员【局版】(降级为当前学校)。</summary>
Task<string[]> GetNoLeadingMiddleInfoSupAsync(string schSerId);
/// <summary>设置/取消中层领导(教务 SDK 未提供写接口,暂不支持)。</summary>
Task ChangeLeadingMiddleStatusAsync(int state, string userTeaSerIdStr);
/// <summary>附件数据源。</summary>
Task<List<FileListItem>> FilelistAsync(string ids);
/// <summary>该校审核流程是固定还是自由。</summary>
Task<ShenheFlowResult> IsShenheflowAsync(string schSerId, int userId);
/// <summary>绑定抄送默认人。</summary>
Task<List<SendUserItem>> IsSendUserAsync(string schSerId, int userId);
/// <summary>当前用户基础信息。</summary>
Task<CurrentUserInfo> GetUserAsync();
/// <summary>用户流程角色(1 有固定/自由流程,0 无)。</summary>
Task<int> GetUserFlowRoleAsync(int userId);
}
}
@@ -0,0 +1,52 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 文印核心业务服务(移植旧 BestSchool.DocPrint.WY_list 除分类/字典外的部分:
/// 抄送、附件、文印 CRUD、各类列表、审核动作、统计、指派)。
/// </summary>
public interface IWYDocPrintService
{
// ---- 抄送 ----
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);
// ---- 附件 ----
Task<List<DocPrintFileItem>> FileListAsync(string ids);
// ---- 文印 CRUD ----
Task<int> CreateAsync(WYDocPrintSaveDto model);
Task<int> EditAsync(WYDocPrintSaveDto model);
Task DeleteAsync(string ids);
Task<string> RevokeAsync(FlowTranctDto model);
// ---- 列表 ----
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);
// ---- 审核动作 ----
Task<string> ShenheOkAsync(FlowTranctDto model);
Task<string> AffirmApplyforOkAsync(FlowTranctDto model);
// ---- 指派 ----
Task<string> GetUserZhipaiAsync(int userid, string schSerId, int? flowid, int? flowstepid, string type);
// ---- 统计 ----
Task<List<MonthStatItem>> List1Async(string starttime, string endtime, string schSerId);
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);
}
}
@@ -0,0 +1,93 @@
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;
namespace YhCore.Service.Interfaces
{
/// <summary>
/// 审核角色业务服务(移植旧 BestSchool.DocPrint.WY_SHRoleP,45 接口)。
/// 降级说明:
/// 1. 旧 SendMessage/SendUnread/Backlog(短信、已读未读、待办中心)新项目缺失,全部降级为纯落库;
/// 2. 教师/头像改走教务 SDK(Yh.SchClass.OpenApi),教师标识用 int(TeaSerId 解析),
/// 落库 WY_SHRole_SHTea.SHTeaID / WY_SHRole_BSHTea.BSHTeaID 仍为 decimal 用户 id;
/// 3. 签名存文件系统(DES 加密),非数据库表;
/// 4. 旧 ServiceResult 的 Data/OtherData/Page 三段合并为单个 Result 对象(getshenheFlow / getStepInfo / getStepByJE / getdocOftable)。
/// Excel 导入导出(UpfileExcelSHRole/downMBOfSHRole/downUpfileSHRoleFK/ExceltotalListSHRole/WYRDExceldaochu)
/// 与审批单 PDF(DwonWYSQD):下载类接口返回 (byte[] Content, string FileName),
/// 控制器按扩展名包装 File();导入反馈表存进程内 IMemoryCache(单实例)。
/// </summary>
public interface IWYSHRoleService
{
// ---- 审核角色管理 ----
Task<string> GetIsSHRoleAsync(string schSerId);
Task<string> SaveIsSHRoleAsync(string schSerId, string val);
Task<List<WYSHRoleExt>> GetSHRoleAsync(string schSerId);
Task<string> UpdateSHRoleNameAsync(string schSerId, string userId, decimal serId, string roleName);
Task<string> EditSHRoleAsync(string schSerId, string userId, WYSHRoleExt roleInfo);
Task<string> EditSHTeaRowAsync(string schSerId, string userId, WY_SHRole_SHTea data);
Task<string> DelSHTeaRowAsync(string schSerId, decimal serId);
Task<string> EditSHTeaAsync(string schSerId, string userId, decimal shRoleId, List<WY_SHRole_SHTea> listSHTea);
Task<string> EditBSHTeaAsync(string schSerId, string userId, decimal shRoleId, List<WY_SHRole_BSHTea> listBSHTea);
Task<string> DelSHRoleAsync(string schSerId, List<decimal?> listSHRoleId);
// ---- 审核角色流程 ----
Task<PageVO<SHRoleFlowListItem>> ListFlowAsync(string name, int pageSize, int pageIndex, string schSerId, int flowType, bool edit);
Task<string> EditFlowAsync(Common_flowinfo model);
Task<string> StepCreateOfSHRoleAsync(Common_flowstep model);
Task<SHRoleStepInfo> GetStepInfoAsync(string schSerId, decimal flowid);
Task<string> StepDeleteAsync(List<int> ids);
Task<string> UpdateStepNameAsync(string schSerId, int serId, int flowid, string stepName, int cond, string isshowJE, string isshowBtn);
Task<string> AddStepSHRoleAsync(string schSerId, int flowid, int stepid, decimal shRoleid);
Task<string> DelStepSHRoleAsync(string schSerId, int stepid, decimal shRoleid);
Task<SHRoleStepByJEResult> GetStepByJEAsync(string schSerId, string userId, int flowid, int je);
// ---- 文印申请页面 ----
Task<List<WYFlowForUser>> GetLCOfUserAsync(string schSerId, string userId, string userName);
Task<List<SendUserItem>> IsSendUserSHRoleAsync(string schSerId, int userId, int flowid);
Task<List<Common_Dictionary>> GetDicInfoAsync(string schSerId, string typeId);
Task<int> EditDocPrintAsync(string schSerId, string userId, WYSHRoleEditDocPrintDto model);
// ---- 用户记录查看和审核 ----
Task<SHRoleShenheFlowResult> GetshenheFlowAsync(string schSerId, decimal docprintId, int flowid);
Task<List<SHRoleDocPrintListItem>> MyListtoOneSHRoleAsync(int serId, string schSerId);
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);
// ---- 规格表 / 签名 / 退回 / 金额 ----
Task<SHRoleDocOfTableResult> GetDocOftableAsync(string schSerId, decimal docPrintId);
Task<string> SaveSignatureAsync(string schSerId, string userId, string pic);
Task<string> GetSignatureAsync(string schSerId, string userId);
Task<string> DelSignatureAsync(string schSerId, string userId);
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<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);
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>YhCore.Service</RootNamespace>
<AssemblyName>YhCore.Service</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 文件上传:IFileService 使用 IFormFile / IFormFileCollection(ASP.NET Core 共享框架) -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YhCore.Entity\YhCore.Entity.csproj" />
<ProjectReference Include="..\YhCore.Model\YhCore.Model.csproj" />
<ProjectReference Include="..\YhCore.Repository\YhCore.Repository.csproj" />
<ProjectReference Include="..\YhCore.Common\YhCore.Common.csproj" />
</ItemGroup>
<ItemGroup>
<!-- 业务异常 DException / ErrorCode / IErrorCode:从 Nexus nuget-group 引用(原为本地源项目引用) -->
<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.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>
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Quartz;
using YhCore.Entity;
using YhCore.Repository;
namespace YhCore.Tasks.Jobs
{
/// <summary>
/// 审计日志清理 Job(对应 Java scheduler/SysOperationLogCleanupTask)。
/// 定时删除 30 天前的系统审计日志(sys_operation_log 表)。
/// </summary>
public class CleanupLogJob : IJob
{
private const int BatchSize = 1000;
public async Task Execute(IJobExecutionContext context)
{
var services = (IServiceProvider)context.Scheduler.Context.Get("services");
using var scope = services.CreateScope();
var provider = scope.ServiceProvider;
var logger = provider.GetRequiredService<ILogger<CleanupLogJob>>();
var repo = provider.GetRequiredService<IRepository<SysOperationLog>>();
var cutoff = DateTime.Now.AddDays(-30);
logger.LogInformation("审计日志清理开始:删除 {Cutoff:yyyy-MM-dd HH:mm:ss} 之前的数据", cutoff);
try
{
var oldList = await repo.FindTrackedAsync(l => l.CreateTime < cutoff);
if (oldList.Count == 0)
{
logger.LogInformation("审计日志清理完成:无 30 天前的数据");
return;
}
logger.LogInformation("审计日志清理:待删除 {Count} 条,分块提交(每批 {Batch} 条)", oldList.Count, BatchSize);
var removed = 0;
foreach (var batch in Chunk(oldList, BatchSize))
{
foreach (var item in batch)
{
await repo.RemoveAsync(item);
}
removed += await repo.SaveChangesAsync();
}
logger.LogInformation("审计日志清理完成:共删除 {Count} 条", removed);
}
catch (Exception ex)
{
// 缺表/数据库异常时记录错误日志,不让 Job 崩溃(Quartz 连续失败会标记 Job 错误状态)
logger.LogError(ex, "审计日志清理失败:{Message}", ex.Message);
}
}
/// <summary>将列表分块(net5.0 无 Enumerable.Chunk,自行实现)</summary>
private static IEnumerable<List<T>> Chunk<T>(List<T> source, int size)
{
for (var i = 0; i < source.Count; i += size)
{
yield return source.GetRange(i, Math.Min(size, source.Count - i));
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Quartz;
using YhCore.Tasks.Jobs;
namespace YhCore.Tasks
{
/// <summary>
/// Quartz 调度宿主(对应 Java scheduler 包由 Spring 管理的调度器)。
/// 承载框架内置 Job 模板;业务 Job 在 InitJobs 中追加注册。
/// </summary>
public class QuartzHostedService : IHostedService
{
private const string ServiceProviderKey = "services";
private readonly ISchedulerFactory _schedulerFactory;
private readonly IServiceProvider _serviceProvider;
private IScheduler _scheduler;
public QuartzHostedService(ISchedulerFactory schedulerFactory, IServiceProvider serviceProvider)
{
_schedulerFactory = schedulerFactory;
_serviceProvider = serviceProvider;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
_scheduler.Context.Put(ServiceProviderKey, _serviceProvider); // Job 经此取 DI 服务
await InitJobs();
await _scheduler.Start(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_scheduler != null)
{
await _scheduler.Shutdown(waitForJobsToComplete: true, cancellationToken);
}
}
private async Task InitJobs()
{
// ============ 定时任务注册 ============
// 1. 审计日志清理(对应 Java SysOperationLogCleanupTask:每日凌晨 1:00 删除 30 天前 sys_operation_log 数据;
// 设定时间窗口 0-2 点,取 1:00 居中)
await ScheduleJobAsync<CleanupLogJob>("cleanup-log-job", "0 0 1 * * ?");
// 2. 其他业务 Job 在此追加注册(示例:每 10 分钟执行一次)
// await ScheduleJobAsync<TJob>("job-name", "0 */10 * * * ?");
await Task.CompletedTask;
}
/// <summary>注册一个 Cron 定时任务</summary>
protected async Task ScheduleJobAsync<TJob>(string jobName, string cronExpression) where TJob : IJob
{
var job = JobBuilder.Create<TJob>()
.WithIdentity(jobName)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity(jobName + "-trigger")
.WithCronSchedule(cronExpression)
.Build();
await _scheduler.ScheduleJob(job, trigger);
}
}
}
@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using Quartz;
using Quartz.Impl;
namespace YhCore.Tasks
{
/// <summary>
/// 任务调度注册扩展:Quartz 调度器工厂 + 宿主服务(对应 Java scheduler 自动装配)。
/// </summary>
public static class QuartzServiceCollectionExtensions
{
public static IServiceCollection AddQuartzHosted(this IServiceCollection services)
{
services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>();
services.AddHostedService<QuartzHostedService>();
return services;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>YhCore.Task</RootNamespace>
<AssemblyName>YhCore.Task</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 任务调度:对应 Java Quartz / scheduler 包 -->
<PackageReference Include="Quartz" Version="3.3.3" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YhCore.Common\YhCore.Common.csproj" />
<ProjectReference Include="..\YhCore.Entity\YhCore.Entity.csproj" />
<ProjectReference Include="..\YhCore.Repository\YhCore.Repository.csproj" />
</ItemGroup>
</Project>
@@ -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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More