This commit is contained in:
wanghu
2026-09-02 10:23:24 +08:00
parent 5a414b14d2
commit ce436cbb32
117 changed files with 13768 additions and 0 deletions
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using YhCore.Common;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Model.Vo;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api
{
/// <summary>
/// 日志查询接口(对应 Java 版 controller/api/LogController,路由 /api/log/)。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/log")]
public class LogController : ControllerBase
{
private readonly ILogService _logService;
public LogController(ILogService logService)
{
_logService = logService;
}
/// <summary>
/// 系统操作日志搜索(对应 Java GET /api/log/systemSearch)。
/// </summary>
/// <param name="keyword">关键字(可空)</param>
/// <param name="page">页码,从 0 开始(对应 Spring Pageable.page)</param>
/// <param name="size">每页大小(对应 Spring Pageable.size)</param>
[HttpGet("systemSearch")]
public PageVO<SysOperationLog> SystemSearch(
[FromQuery] string keyword,
[FromQuery] int page = 0,
[FromQuery] int size = 20)
{
return _logService.Search(UserContextHolder.GetSchId(), keyword, true, page, size);
}
/// <summary>
/// 操作日志搜索(对应 Java GET /api/log/search)。
/// </summary>
[HttpGet("search")]
public PageVO<SysOperationLog> Search(
[FromQuery] string keyword,
[FromQuery] int page = 0,
[FromQuery] int size = 20)
{
return _logService.Search(UserContextHolder.GetSchId(), keyword, false, page, size);
}
}
}
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Yh.SchClass.OpenApi;
using Yh.SchClass.OpenApi.Model;
using YhCore.Common;
namespace YhCore.WebApi.Controllers.Api
{
/// <summary>
/// 教务(SchClass)开放接口演示控制器:引用 Yh.SchClass.OpenApi.dll(Library),
/// 对应 Java 教务 bschclassmanage 的 /oapi 端点(配置节 yh:schclass:open)。
/// <para>SDK 门面 <see cref="SchClassOpenApi"/> 聚合 10 个客户端,本控制器演示其中 5 个。</para>
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/com/schClass")]
public class SchClassController : ControllerBase
{
private readonly SchClassOpenApi _sch;
public SchClassController(SchClassOpenApi sch)
{
_sch = sch ?? throw new ArgumentNullException(nameof(sch));
}
/// <summary>
/// 全部学校列表(GET /oapi/SchInfo/AllList)。
/// </summary>
[HttpGet("schools")]
public async Task<List<SchInfoVo>> Schools()
{
return await _sch.SchInfo.AllListAsync();
}
/// <summary>
/// 按学校外部序列号查询学校(GET /oapi/SchInfo/GetSchFirst)。
/// </summary>
/// <param name="schId">学校外部序列号</param>
[HttpGet("school")]
public async Task<SchInfoVo> School([FromQuery] string schId)
{
return await _sch.SchInfo.GetSchFirstAsync(schId);
}
/// <summary>
/// 按学校查询教师列表(GET /oapi/OATea/GetSchTeainfo)。
/// </summary>
/// <param name="schSerId">学校外部序列号</param>
/// <param name="schId">学校内部 ID(优先使用)</param>
[HttpGet("teachers")]
public async Task<List<TeaInfoVo>> Teachers(
[FromQuery] string schSerId,
[FromQuery] int? schId)
{
return await _sch.TeaInfo.GetSchTeaInfoAsync(schSerId, schId);
}
/// <summary>
/// 按学校查询班级列表(GET /oapi/VSchClass/GetVClassList)。
/// </summary>
/// <param name="schSerId">学校外部序列号</param>
[HttpGet("classes")]
public async Task<List<Dictionary<string, object>>> Classes([FromQuery] string schSerId)
{
var rows = await _sch.VClass.GetVClassListAsync(schSerId);
return rows.Select(ToPlainRow).ToList();
}
/// <summary>
/// 按学校查询学生列表(GET /oapi/OAStu/GetSchStuList)。
/// </summary>
/// <param name="schserid">学校外部序列号</param>
/// <param name="schgroupid">学校分组 ID(可空)</param>
[HttpGet("students")]
public async Task<List<Dictionary<string, object>>> Students(
[FromQuery] string schserid,
[FromQuery] string schgroupid)
{
var rows = await _sch.StuInfo.GetSchStuListAsync(schserid, schgroupid);
return rows.Select(ToPlainRow).ToList();
}
// ===================== JsonElement → 普通对象 =====================
// SDK 行类型 SchClassRow = Dictionary<string, JsonElement>(Java Map<String,Object> 兼容);
// 直接返回会被 Newtonsoft 序列化成 JsonElement 内部结构,这里转成原生对象。
private static Dictionary<string, object> ToPlainRow(Dictionary<string, JsonElement> row)
=> row.ToDictionary(kv => kv.Key, kv => ToPlain(kv.Value));
private static object ToPlain(JsonElement el)
{
switch (el.ValueKind)
{
case JsonValueKind.String:
return el.GetString();
case JsonValueKind.Number:
return el.TryGetInt32(out var i) ? (object)i : el.GetDouble();
case JsonValueKind.True:
return true;
case JsonValueKind.False:
return false;
case JsonValueKind.Null:
return null;
case JsonValueKind.Array:
return el.EnumerateArray().Select(ToPlain).ToList();
case JsonValueKind.Object:
return el.EnumerateObject().ToDictionary(p => p.Name, p => ToPlain(p.Value));
default:
return el.ToString();
}
}
}
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Threading.Tasks;
using Yh.Platform.Abstractions;
using Yh.Platform.Auth;
using Yh.Platform.OpenApi;
using YhCore.Common;
namespace YhCore.WebApi.Controllers.Api
{
/// <summary>
/// 用户基础接口(对应 Java 版 controller/api/UserController,路由 /api/base/)。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/base")]
public class UserController : ControllerBase
{
private readonly IOpenApiUserClient _openApiUserClient;
public UserController(IOpenApiUserClient openApiUserClient)
{
_openApiUserClient = openApiUserClient;
}
/// <summary>
/// 获取当前用户可见的应用模块(对应 Java GET /api/base/getUserMod,
/// 内部调用 YschBaseOpenApi.getUserModule)。
/// </summary>
[HttpGet("getUserMod")]
public async Task<List<BoaModule>> GetUserMod()
{
var boa = BoaUserContext.Get();
return await _openApiUserClient.GetUserModuleAsync(boa?.Id ?? 0, boa?.School?.Id);
}
/// <summary>
/// 获取当前登录用户信息(对应 Java GET /api/base/getUserInfo,
/// Java 版直接返回认证切面注入的 authUserWithDept)。
/// </summary>
[HttpGet("getUserInfo")]
public BoaUser GetUserInfo()
{
return BoaUserContext.Get();
}
/// <summary>
/// 登出(对应 Java GET /api/base/logout:session.invalidate())。
/// </summary>
[HttpGet("logout")]
public async Task<IActionResult> Logout()
{
// 清除本地 Cookie 登录态
await HttpContext.SignOutAsync(YhAuthDefaults.CookieScheme);
if (HttpContext.Features.Get<ISessionFeature>() != null)
{
HttpContext.Session.Clear();
}
return Ok();
}
}
}
@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Yh.Platform.Abstractions;
using YhCore.Common;
using YhCore.Common.Config;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api.Common
{
/// <summary>
/// 公共字典 / 文件(对应旧 Areas/Common/CommonController,路由 api/base)。
/// 字典部分 8 接口 + 文件部分 4 接口(FileDown / FilePreview / FileUpload / FileUploadImage)已落地;
/// 旧 [NoAuth] 语义:无需用户上下文,schId 由前端传入或取当前用户。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/base")]
public class CommonController : ControllerBase
{
private readonly IDictionaryService _dict;
private readonly IFileService _file;
public CommonController(IDictionaryService dict, IFileService file)
{
_dict = dict;
_file = file;
}
/// <summary>获取指定字典数据(Type 1~10)。</summary>
[HttpGet("GetSysDic")]
public async Task<List<DicOption>> GetSysDic([FromQuery] int type = 0)
{
var typeId = DictionaryTypeHelper.FromSysDicType(type);
if (typeId == null)
{
throw new DException("参数错误");
}
return await _dict.GetDictListAsync(typeId, SchSerID());
}
/// <summary>字典类别列表([NoAuth],排除部门)。</summary>
[HttpGet("GetDicTypeList")]
public async Task<List<DicOption>> GetDicTypeList() => await _dict.GetDicTypeListAsync();
/// <summary>字典列表管理(Type=0/DEPT 取全校;其他仅超管 bestsch)。</summary>
[HttpGet("GetDictMangeList")]
public async Task<List<DicManageItem>> GetDictMangeList([FromQuery] string type)
{
var typeId = DictionaryTypeHelper.ResolveTypeId(type);
if (typeId == null)
{
throw new DException("参数错误");
}
if (DictionaryTypeHelper.IsDept(typeId))
{
return await _dict.GetDictMangeListAsync("DEPT", SchSerID());
}
//if (UserContextHolder.Current.() =1 )
//{
// throw new DException("非超管不得操作!");
//}
return await _dict.GetDictMangeListAsync(typeId, "");
}
/// <summary>添加字典。</summary>
[HttpPost("AddDic")]
public async Task AddDic([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
var info = new Common_Dictionary
{
typeID = model.typeID,
typeName = DictionaryTypeHelper.GetTypeName(model.typeID),
Name = model.Name,
Schserid = SchSerID()
};
await _dict.AddDicAsync(info);
}
/// <summary>更新字典。</summary>
[HttpPost("UpdateDic")]
public async Task UpdateDic([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数有误!");
model.Schserid = SchSerID();
await _dict.UpdateDicAsync(model);
}
/// <summary>编辑字典名称。</summary>
[HttpPost("EditDic")]
public async Task EditDic([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数有误!");
model.Schserid = SchSerID();
await _dict.EditDicAsync(model);
}
/// <summary>添加发文部门。</summary>
[HttpPost("AddDeptDic")]
public async Task AddDeptDic([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数有误!");
var info = new Common_Dictionary
{
typeID = "DEPT",
typeName = "部门类别",
Name = model.Name,
Schserid = SchSerID()
};
await _dict.AddDicAsync(info);
}
/// <summary>更新发文部门。</summary>
[HttpPost("UpdateDeptDic")]
public async Task UpdateDeptDic([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数有误!");
model.Schserid = SchSerID();
await _dict.UpdateDicAsync(model);
}
#region 文件:下载 / 预览 / 上传
/// <summary>
/// 公用下载文件(旧 FileDown,[NoAuth])。yl=true 且为图片/PDF 时重定向到文件 URL(webapp 预览),
/// 否则物理文件下载。
/// </summary>
[HttpGet("FileDown")]
public async Task<IActionResult> FileDown([FromQuery] int id, [FromQuery] bool yl = false)
{
if (id == 0)
return Ok();
var file = await _file.GetEnclosureAsync(id);
string[] previewArry = { ".jpg", ".gif", ".png", ".pdf", ".jpeg", ".bmp" };
if (yl)
{
// 旧 sch_weboa_url.Replace("notice/api", AppType + "/") + fileurl → 新 FileWebPath + fileurl
string fileUrl = (Config.FileWebPath ?? "") + file.fileurl;
string suffName = Path.GetExtension(fileUrl);
if (previewArry.Any(x => x == suffName.ToLower()))
return Redirect(fileUrl);
}
// WebHelper.DownFile:物理文件下载
string filePath = _file.MapPhysicalPath(file.fileurl);
if (!System.IO.File.Exists(filePath))
return Content("请求的文件不存在!");
string contentType = GetContentType(filePath);
string outputFileName = file.filename ?? "file";
return PhysicalFile(filePath, contentType, outputFileName);
}
/// <summary>
/// 公用预览 office 文件(旧 FilePreview / CommonPreview,[NoAuth])。
/// type=1 → 返回可用预览 url(本地 .pdf 存在优先);type=2 → 重定向本地 pdf 或 officeapps.live.com;
/// 其余 → 重定向替换后缀为 .pdf 的地址。跳转返回 RedirectResult(全局包装过滤器不拦截)。
/// </summary>
[HttpGet("FilePreview")]
public async Task<object> FilePreview([FromQuery] int id, [FromQuery] string type = null)
{
if (id == 0)
throw new DException("文件ID错误!");
var file = await _file.GetEnclosureAsync(id);
// 公文另做处理
if (file.fileurl != null && file.fileurl.IndexOf("gw.") > -1)
{
string fileUrl = "http://" + file.fileurl;
string suffName = Path.GetExtension(fileUrl);
string newName = fileUrl.Remove(fileUrl.LastIndexOf(suffName, StringComparison.Ordinal), suffName.Length) + ".pdf";
return Redirect(newName);
}
string fileUrl2 = (Config.FileWebPath ?? "") + file.fileurl;
string suffName2 = Path.GetExtension(fileUrl2);
if (!string.IsNullOrEmpty(type))
{
// 单独读取文件是否存在
string newFile = file.fileurl;
string pdfFile = newFile.Remove(newFile.LastIndexOf(suffName2, StringComparison.Ordinal), suffName2.Length) + ".pdf";
string outFile = _file.MapPhysicalPath(pdfFile);
if (type == "1")
{
if (System.IO.File.Exists(outFile)) return (Config.FileWebPath ?? "") + pdfFile;
return fileUrl2;
}
if (type == "2")
{
string pdfUrl = string.Empty;
if (System.IO.File.Exists(outFile)) pdfUrl = (Config.FileWebPath ?? "") + pdfFile;
else pdfUrl = "https://view.officeapps.live.com/op/view.aspx?src=" + fileUrl2;
return Redirect(pdfUrl);
}
return "";
}
string newName2 = fileUrl2.Remove(fileUrl2.LastIndexOf(suffName2, StringComparison.Ordinal), suffName2.Length) + ".pdf";
return Redirect(newName2);
}
/// <summary>
/// 公用附件上传(旧 FileUpload,[NoAuth])。
/// chunk 参数 → 分片保存返回 {code:1,type:"chunk",msg};fileName 参数 → 合并分片;否则单文件上传。
/// 落库 Common_UpFiles(TopicType=AppCode),返回 {fileurl,host,fileid,name,type}。
/// </summary>
[HttpPost("FileUpload")]
public async Task<object> Upload()
{
string apptype = "docprint"; // 旧 WebHelper.AppType 固定 docprint
string guid = Request.Form["guid"];
string chunk = Request.Form["chunk"];
string fileName = Request.Form["fileName"];
if (!string.IsNullOrEmpty(chunk))
{
var chunkFile = Request.Form.Files.FirstOrDefault(f => f.Name == "file") ?? Request.Form.Files.FirstOrDefault();
await _file.SaveChunkAsync(guid, chunk, chunkFile);
return new { code = 1, type = "chunk", msg = "分片上传成功!" };
}
FileObj url;
string errMsg = "上传失败!";
if (string.IsNullOrEmpty(fileName))
{
url = await _file.SaveFileAsync(apptype, Request.Form.Files.FirstOrDefault());
}
else
{
url = await _file.MergeSaveAsync(apptype, guid, fileName);
}
if (url == null)
throw new DException(errMsg);
// 旧 WebHelper.AppCode == "1102" ? "1002" : AppCode → docprint 固定 1003
int serid = await _file.SaveEnclosureAsync(url, DocPrintConstants.AppCode.ToString());
return new { fileurl = url.fileurl, host = url.host, fileid = serid, name = url.filename, type = url.filetype };
}
/// <summary>公用图片上传(旧 FileUploadImage,[NoAuth];多图,返回数组)。</summary>
[HttpPost("FileUploadImage")]
public async Task<object> UploadImg()
{
string apptype = "docprint";
var urlList = await _file.SaveFileImageAsync(apptype, Request.Form.Files);
if (urlList.Count == 0)
throw new DException("上传失败!");
var result = new List<object>();
foreach (var item in urlList)
{
int serid = await _file.SaveEnclosureAsync(item, DocPrintConstants.AppCode.ToString());
result.Add(new { fileurl = item.fileurl, host = item.host, fileid = serid, name = item.filename, type = item.filetype });
}
return result;
}
/// <summary>按扩展名取 ContentType(替代旧 MimeMapping.GetMimeMapping)。</summary>
private static string GetContentType(string filePath)
{
string ext = Path.GetExtension(filePath).ToLower();
return ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".png" => "image/png",
".bmp" => "image/bmp",
".pdf" => "application/pdf",
".txt" => "text/plain",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt" => "application/vnd.ms-powerpoint",
".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
".mp4" => "video/mp4",
_ => "application/octet-stream",
};
}
#endregion
private static string SchSerID() => UserContextHolder.Current.SchSerID;
}
}
@@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using YhCore.Common;
using YhCore.Common.UserContext;
using YhCore.Model.Dto;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api.Common
{
/// <summary>
/// 自由审批流控制器(对应旧 Areas/Common/FreeApproveController,路由 api/common/freeapprove)。
/// 7 接口:自由流程节点查询/保存、审核意见、审核回显、抄送人。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/common/freeapprove")]
public class FreeApproveController : ControllerBase
{
private readonly IFreeApproveService _svc;
public FreeApproveController(IFreeApproveService svc)
{
_svc = svc;
}
/// <summary>获取当前自由流程节点(dataid 为空时取最大 dataid)。</summary>
[HttpGet("GetFleeFlow")]
public Task<List<FreeFlowNode>> GetFleeFlow([FromQuery] int? dataid = null)
=> _svc.GetFleeFlowAsync(UserContextHolder.Current?.Id ?? 0, dataid);
/// <summary>保存自由流程审核人,返回首步骤 SerID。</summary>
[HttpGet("EditFleeFlow")]
public Task<int> EditFleeFlow([FromQuery] string FlowUids, [FromQuery] int? dataid = null, [FromQuery] bool IsFlowNull = false)
=> _svc.EditFleeFlowAsync(UserContextHolder.Current?.Id ?? 0, SchSerID(), dataid, FlowUids, IsFlowNull);
/// <summary>添加审核意见。</summary>
[HttpGet("AddShenheRemark")]
public Task<string> AddShenheRemark([FromQuery] int? dataid, [FromQuery] string remark, [FromQuery] int freestepid)
=> _svc.AddShenheRemarkAsync(UserContextHolder.Current?.Id ?? 0, dataid, remark, freestepid);
/// <summary>获取后续自由流程节点。</summary>
[HttpGet("GetAfterFleeFlow")]
public Task<List<FreeFlowNode>> GetAfterFleeFlow([FromQuery] int? dataid, [FromQuery] int freestepid)
=> _svc.GetAfterFleeFlowAsync(dataid, freestepid);
/// <summary>编辑后续自由流程,返回末步骤 SerID。</summary>
[HttpGet("EditAfterFleeFlow")]
public Task<int> EditAfterFleeFlow([FromQuery] int? dataid, [FromQuery] int freestepid, [FromQuery] string FlowUids)
=> _svc.EditAfterFleeFlowAsync(dataid, freestepid, FlowUids);
/// <summary>自由流程审核回显(发起人 + 审核人步骤)。</summary>
[HttpGet("ShenheFlow")]
public Task<List<FreeShenheInfo>> ShenheFlow([FromQuery] int uid, [FromQuery] string ctime, [FromQuery] int dataid,
[FromQuery] int? freestepid = null, [FromQuery] int? flowid = null)
=> _svc.ShenheFlowAsync(uid, ctime, dataid, freestepid, flowid);
/// <summary>抄送人列表(按 uids 查用户)。</summary>
[HttpGet("ChaoSendList")]
public Task<List<ChaoSendUser>> ChaoSendList([FromQuery] string uids)
=> _svc.ChaoSendListAsync(uids);
private static string SchSerID() => UserContextHolder.Current.SchSerID;
}
}
@@ -0,0 +1,167 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Yh.SchClass.OpenApi.Model;
using YhCore.Common;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api.Common
{
/// <summary>
/// 审核流程管理控制器(对应旧 Areas/Common/reviewprocessController,路由 api/common/reviewprocess)。
/// 23 接口:流程/步骤 CRUD、教师查询、流程判断、用户角色。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/common/reviewprocess")]
public class ReviewprocessController : ControllerBase
{
private readonly IReviewProcessService _svc;
public ReviewprocessController(IReviewProcessService svc)
{
_svc = svc;
}
#region 流程与步骤
/// <summary>审核流程列表(分页)。</summary>
[HttpGet("List")]
public Task<PageVO<FlowListItem>> List([FromQuery] string Name, [FromQuery] int PageSize,
[FromQuery] int PageIndex, [FromQuery] int flowType, [FromQuery] bool Edit = false)
=> _svc.ListAsync(Name, PageSize, PageIndex, flowType, Edit, SchSerID());
/// <summary>审核流程列表【局版】(降级为当前学校)。</summary>
[HttpGet("List2")]
public Task<PageVO<FlowListItem>> List2([FromQuery] string Name, [FromQuery] int PageSize,
[FromQuery] int PageIndex, [FromQuery] int flowType, [FromQuery] bool Edit = false)
=> _svc.List2Async(Name, PageSize, PageIndex, flowType, Edit, SchSerID());
/// <summary>步骤流程列表。</summary>
[HttpGet("StepList")]
public Task<PageVO<StepListItem>> StepList([FromQuery] string Name, [FromQuery] int Serid)
=> _svc.StepListAsync(Name, Serid);
/// <summary>添加审核流程。</summary>
[HttpPost("Create")]
public async Task Create([FromBody] Common_flowinfo model)
{
model.schSerID = SchSerID();
await _svc.CreateAsync(model);
}
/// <summary>修改审核流程。</summary>
[HttpPost("Edit")]
public async Task Edit([FromBody] Common_flowinfo model)
{
model.schSerID = SchSerID();
await _svc.EditAsync(model);
}
/// <summary>添加步骤流程。</summary>
[HttpPost("StepCreate")]
public Task StepCreate([FromBody] Common_flowstep model)
=> _svc.StepCreateAsync(model);
/// <summary>修改步骤流程。</summary>
[HttpPost("StepEdit")]
public Task StepEdit([FromBody] Common_flowstep model)
=> _svc.StepEditAsync(model);
/// <summary>删除审核流程(ids 逗号分隔)。</summary>
[HttpGet("Delete")]
public Task Delete([FromQuery] string ids)
=> _svc.DeleteAsync(ids);
/// <summary>删除步骤。</summary>
[HttpPost("StepDelete")]
public Task StepDelete([FromBody] decimal[] ids)
=> _svc.StepDeleteAsync(ids);
/// <summary>流程绑定的步骤列表。</summary>
[HttpGet("BindstepList")]
public Task<List<BindStepItem>> BindstepList([FromQuery] int Serid)
=> _svc.BindstepListAsync(Serid);
/// <summary>绑定步骤的人员。</summary>
[HttpGet("BindGroupuserList")]
public Task<List<BindGroupuserItem>> BindGroupuserList([FromQuery] int flowid, [FromQuery] int prestep)
=> _svc.BindGroupuserListAsync(flowid, prestep);
/// <summary>绑定流程(流程 + 抄送人)。</summary>
[HttpGet("Bindflowinfo")]
public Task<List<BindFlowItem>> Bindflowinfo([FromQuery] int flowType)
=> _svc.BindflowinfoAsync(SchSerID(), flowType);
#endregion
#region 教师 / 人员
/// <summary>当前学校教师列表。</summary>
[HttpGet("GetTeaList")]
public Task<List<TeaOption>> GetTeaList()
=> _svc.GetTeaListAsync(SchSerID());
/// <summary>学校 + 教育局教师列表(降级为当前学校)。</summary>
[HttpGet("GetTeaListSup")]
public Task<List<TeaOption>> GetTeaListSup()
=> _svc.GetTeaListSupAsync(SchSerID());
/// <summary>教师信息分页。</summary>
[HttpGet("GetUserinfo")]
public Task<PageVO<TeaInfoVo>> GetUserinfo([FromQuery] string Name, [FromQuery] int statetype,
[FromQuery] int PageSize, [FromQuery] int PageIndex)
=> _svc.GetUserinfoAsync(SchSerID(), Name, statetype, PageSize, PageIndex);
/// <summary>非中层领导人员。</summary>
[HttpGet("GetNoLeadingMiddleInfo")]
public Task<string[]> GetNoLeadingMiddleInfo()
=> _svc.GetNoLeadingMiddleInfoAsync(SchSerID());
/// <summary>非中层领导人员【局版】(降级为当前学校)。</summary>
[HttpGet("GetNoLeadingMiddleInfoSup")]
public Task<string[]> GetNoLeadingMiddleInfoSup()
=> _svc.GetNoLeadingMiddleInfoSupAsync(SchSerID());
/// <summary>设置/取消中层领导(教务 SDK 未提供,暂不支持)。</summary>
[HttpGet("changeLeadingMiddleStatus")]
public Task ChangeLeadingMiddleStatus([FromQuery] int state, [FromQuery] string UserTeaSerIDstr)
=> _svc.ChangeLeadingMiddleStatusAsync(state, UserTeaSerIDstr);
/// <summary>附件数据源。</summary>
[HttpGet("filelist")]
public Task<List<FileListItem>> Filelist([FromQuery] string ids)
=> _svc.FilelistAsync(ids);
#endregion
#region 流程判断 / 用户
/// <summary>该校审核流程是固定还是自由。</summary>
[HttpGet("IsShenheflow")]
public Task<ShenheFlowResult> IsShenheflow()
=> _svc.IsShenheflowAsync(SchSerID(), UserContextHolder.Current?.Id ?? 0);
/// <summary>绑定抄送默认人。</summary>
[HttpGet("IsSendUser")]
public Task<List<SendUserItem>> IsSendUser()
=> _svc.IsSendUserAsync(SchSerID(), UserContextHolder.Current?.Id ?? 0);
/// <summary>当前用户基础信息。</summary>
[HttpGet("GetUser")]
public Task<CurrentUserInfo> GetUser()
=> _svc.GetUserAsync();
/// <summary>用户流程角色(1 有流程,0 无)。</summary>
[HttpGet("GetUserFlowRole")]
public Task<int> GetUserFlowRole()
=> _svc.GetUserFlowRoleAsync(UserContextHolder.Current?.Id ?? 0);
#endregion
private static string SchSerID() => UserContextHolder.Current.SchSerID;
}
}
@@ -0,0 +1,65 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Yh.Platform.Abstractions;
using YhCore.Common;
using YhCore.Model.Dto;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api.DocPrint
{
/// <summary>
/// 文印统计看板控制器(对应旧 Areas/DocPrint/DocPrintStatisticsController,路由 api/docprint/statistics)。
/// 旧类级 [NoAuth]:schId 由前端传入(非当前用户),新项目 SSO 关闭时匿名放行、开启时按配置保护,
/// 语义一致。ExportRecord(Excel 导出,NPOI)延后迁移。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/statistics")]
public class DocPrintStatisticsController : ControllerBase
{
private readonly IDocPrintStatisticsService _statistics;
public DocPrintStatisticsController(IDocPrintStatisticsService statistics)
{
_statistics = statistics;
}
/// <summary>打印总数/消耗/最多类型统计(旧 GetDocPrintCount)。</summary>
[HttpGet("GetDocPrintCount")]
public async Task<System.Collections.Generic.List<DocPrintCountItem>> GetDocPrintCount(
[FromQuery] int schId,
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null)
=> await _statistics.GetDocPrintCountAsync(schId, startDate, endDate);
/// <summary>饼图(按类型,消耗/数量,旧 GetPieChart)。</summary>
[HttpGet("GetPieChart")]
public async Task<System.Collections.Generic.List<PieChartSeries>> GetPieChart(
[FromQuery] int schId,
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null)
=> await _statistics.GetPieChartAsync(schId, startDate, endDate);
/// <summary>打印类型列表(旧 GetDocPrintType)。</summary>
[HttpGet("GetDocPrintType")]
public async Task<System.Collections.Generic.List<DocPrintTypeItem>> GetDocPrintType([FromQuery] int schId)
=> await _statistics.GetDocPrintTypeAsync(schId);
/// <summary>分页明细(旧 GetDocPrintList;旧 1 基 pageIndex 内部转 0 基)。</summary>
[HttpGet("GetDocPrintList")]
public async Task<DocPrintListResult> GetDocPrintList(
[FromQuery] int schId,
[FromQuery] string docType = "ALL",
[FromQuery] string likeName = null,
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 10,
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null)
{
if (pageSize <= 0) throw new DException("pageSize 必须大于 0");
return await _statistics.GetDocPrintListAsync(schId, docType, likeName, pageIndex, pageSize, startDate, endDate);
}
// 延后:ExportRecord(Excel 导出,NPOI)
}
}
@@ -0,0 +1,32 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using YhCore.Common;
using YhCore.Entity;
using YhCore.Repository;
namespace YhCore.WebApi.Controllers.Api.DocPrint
{
/// <summary>
/// 启动预热控制器(对应旧 Areas/DocPrint/LoadingController,路由 api/docprint/loading)。
/// 无业务逻辑:执行一次 Base_Log 查询触发数据库连接建立,防止首请求慢。
/// 旧接口 void 无返回体;新项目由 GlobalWrapResultFilter 统一包装为 {code:0,msg:"",data:null}。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/loading")]
public class LoadingController : ControllerBase
{
private readonly IRepository<Base_Log> _log;
public LoadingController(IRepository<Base_Log> log)
{
_log = log;
}
/// <summary>预热(执行一次 Base_Log 查询)。</summary>
[HttpGet("List")]
public async Task List()
{
await _log.FirstOrDefaultAsync(a => a.Id > 0);
}
}
}
@@ -0,0 +1,334 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Yh.Platform.Abstractions;
using YhCore.Common;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Model.Vo;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api.DocPrint
{
/// <summary>
/// 审核角色 + 流程配置控制器(对应旧 Areas/DocPrint/WYSHRoleController,路由 api/docprint/wyshrole)。
/// 45 action 中本批落地 39 个;Excel 导入导出(ExceltotalListSHRole/WYRDExceldaochu/UpfileExcelSHRole/
/// downMBOfSHRole/downUpfileSHRoleFK)与审批单 PDF(DwonWYSQD)依赖 NPOI/PdfHelper,延后迁移。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/wyshrole")]
public class WYSHRoleController : ControllerBase
{
private readonly IWYSHRoleService _role;
public WYSHRoleController(IWYSHRoleService role)
{
_role = role;
}
#region 审核角色管理
/// <summary>学校是否启用审核角色(旧 getIsSHRole)。</summary>
[HttpGet("getIsSHRole")]
public async Task<string> GetIsSHRole() => await _role.GetIsSHRoleAsync(SchSerID());
/// <summary>保存启用开关(旧 saveIsSHRole)。</summary>
[HttpGet("saveIsSHRole")]
public async Task<string> SaveIsSHRole([FromQuery] string val) => await _role.SaveIsSHRoleAsync(SchSerID(), val);
/// <summary>审核角色列表(旧 getSHRole)。</summary>
[HttpGet("getSHRole")]
public async Task<List<WYSHRoleExt>> GetSHRole() => await _role.GetSHRoleAsync(SchSerID());
/// <summary>角色改名(旧 UpdateSHRoleName)。</summary>
[HttpGet("UpdateSHRoleName")]
public async Task<string> UpdateSHRoleName([FromQuery] decimal serID, [FromQuery] string sHRoleName)
=> await _role.UpdateSHRoleNameAsync(SchSerID(), UserId(), serID, sHRoleName);
/// <summary>新增/编辑角色(旧 EditSHRole,form: WYSHRole=JSON → [FromBody])。</summary>
[HttpPost("EditSHRole")]
public async Task<string> EditSHRole([FromBody] WYSHRoleExt model)
{
if (model == null) throw new DException("参数错误!");
return await _role.EditSHRoleAsync(SchSerID(), UserId(), model);
}
/// <summary>编辑成员行(旧 editSHTeaRow,form: SHTeaTxt → [FromBody])。</summary>
[HttpPost("editSHTeaRow")]
public async Task<string> EditSHTeaRow([FromBody] WY_SHRole_SHTea model)
{
if (model == null) throw new DException("参数错误!");
return await _role.EditSHTeaRowAsync(SchSerID(), UserId(), model);
}
/// <summary>删除成员行(旧 delSHTeaRow)。</summary>
[HttpGet("delSHTeaRow")]
public async Task<string> DelSHTeaRow([FromQuery] decimal serID)
=> await _role.DelSHTeaRowAsync(SchSerID(), serID);
/// <summary>批量保存审核人(旧 editSHTea,form: SHRoleID + listSHTeaTxt → [FromBody])。</summary>
[HttpPost("editSHTea")]
public async Task<string> EditSHTea([FromBody] EditSHTeaDto model)
{
if (model == null) throw new DException("参数错误!");
return await _role.EditSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listSHTea);
}
/// <summary>批量保存被审核人(旧 editBSHTea,form: SHRoleID + listBSHTeaTxt → [FromBody])。</summary>
[HttpPost("editBSHTea")]
public async Task<string> EditBSHTea([FromBody] EditBSHTeaDto model)
{
if (model == null) throw new DException("参数错误!");
return await _role.EditBSHTeaAsync(SchSerID(), UserId(), model.SHRoleID, model.listBSHTea);
}
/// <summary>删除角色(旧 delSHRole,form: listSHRoleIDTxt → [FromBody])。</summary>
[HttpPost("delSHRole")]
public async Task<string> DelSHRole([FromBody] DelSHRoleDto model)
{
if (model == null) throw new DException("参数错误!");
return await _role.DelSHRoleAsync(SchSerID(), model.listSHRoleID);
}
#endregion
#region 审核角色流程
/// <summary>审核流程列表(旧 ListFlow)。</summary>
[HttpGet("ListFlow")]
public async Task<PageVO<SHRoleFlowListItem>> ListFlow(
[FromQuery] string name = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0,
[FromQuery] int flowType = 0,
[FromQuery] bool edit = false)
=> await _role.ListFlowAsync(name, pageSize, pageIndex, SchSerID(), flowType, edit);
/// <summary>新增/编辑流程(旧 EditFlow;schSerID 取当前用户)。</summary>
[HttpPost("EditFlow")]
public async Task<string> EditFlow([FromBody] Common_flowinfo model)
{
if (model == null) throw new DException("参数错误!");
model.schSerID = SchSerID();
return await _role.EditFlowAsync(model);
}
/// <summary>新增步骤(旧 StepCreateOfSHRole)。</summary>
[HttpPost("StepCreateOfSHRole")]
public async Task<string> StepCreateOfSHRole([FromBody] Common_flowstep model)
{
if (model == null) throw new DException("参数错误!");
return await _role.StepCreateOfSHRoleAsync(model);
}
/// <summary>步骤详情(旧 getStepInfo)。</summary>
[HttpGet("getStepInfo")]
public async Task<SHRoleStepInfo> GetStepInfo([FromQuery] decimal flowid)
=> await _role.GetStepInfoAsync(SchSerID(), flowid);
/// <summary>删除步骤(旧 StepDelete)。</summary>
[HttpGet("StepDelete")]
public async Task<string> StepDelete([FromQuery] int id)
=> await _role.StepDeleteAsync(new List<int> { id });
/// <summary>改步骤名/条件/金额/按钮显隐(旧 updateStepName)。</summary>
[HttpGet("updateStepName")]
public async Task<string> UpdateStepName(
[FromQuery] int serID,
[FromQuery] int flowid,
[FromQuery] string stepName,
[FromQuery] int cond = 0,
[FromQuery] string isshowJE = "0",
[FromQuery] string isshowBtn = "1")
=> await _role.UpdateStepNameAsync(SchSerID(), serID, flowid, stepName, cond, isshowJE, isshowBtn);
/// <summary>步骤绑定角色(旧 AddStepSHRole)。</summary>
[HttpGet("AddStepSHRole")]
public async Task<string> AddStepSHRole([FromQuery] int flowid, [FromQuery] int stepid, [FromQuery] decimal shRoleid)
=> await _role.AddStepSHRoleAsync(SchSerID(), flowid, stepid, shRoleid);
/// <summary>步骤解绑角色(旧 delStepSHRole)。</summary>
[HttpGet("delStepSHRole")]
public async Task<string> DelStepSHRole([FromQuery] int stepid, [FromQuery] decimal shRoleid)
=> await _role.DelStepSHRoleAsync(SchSerID(), stepid, shRoleid);
/// <summary>按金额取步骤(旧 getStepByJE)。</summary>
[HttpGet("getStepByJE")]
public async Task<SHRoleStepByJEResult> GetStepByJE([FromQuery] int flowid, [FromQuery] int je)
=> await _role.GetStepByJEAsync(SchSerID(), UserId(), flowid, je);
#endregion
#region 文印申请页面
/// <summary>流程发起人列表(旧 getLCOfUser)。</summary>
[HttpGet("getLCOfUser")]
public async Task<List<WYFlowForUser>> GetLCOfUser()
=> await _role.GetLCOfUserAsync(SchSerID(), UserId(), UserName());
/// <summary>当前用户是否发起人(旧 IsSendUserSHRole)。</summary>
[HttpGet("IsSendUserSHRole")]
public async Task<List<SendUserItem>> IsSendUserSHRole([FromQuery] int flowid)
=> await _role.IsSendUserSHRoleAsync(SchSerID(), UserContextHolder.Current?.Id ?? 0, flowid);
/// <summary>字典信息(旧 getDicInfo)。</summary>
[HttpGet("getDicInfo")]
public async Task<List<Common_Dictionary>> GetDicInfo([FromQuery] string typeID)
=> await _role.GetDicInfoAsync(SchSerID(), typeID);
/// <summary>编辑文印(审核角色视角;旧 editDocPrint,form 三段 JSON → [FromBody])。</summary>
[HttpPost("editDocPrint")]
public async Task<int> EditDocPrint([FromBody] WYSHRoleEditDocPrintDto model)
{
if (model == null) throw new DException("参数错误!");
return await _role.EditDocPrintAsync(SchSerID(), UserId(), model);
}
/// <summary>审核流程回显(旧 getshenheFlow)。</summary>
[HttpGet("getshenheFlow")]
public async Task<SHRoleShenheFlowResult> GetshenheFlow([FromQuery] decimal docprintID, [FromQuery] int flowid)
=> await _role.GetshenheFlowAsync(SchSerID(), docprintID, flowid);
/// <summary>文印表格数据(旧 getdocOftable)。</summary>
[HttpGet("getdocOftable")]
public async Task<SHRoleDocOfTableResult> GetDocOftable([FromQuery] decimal docPrintID)
=> await _role.GetDocOftableAsync(SchSerID(), docPrintID);
#endregion
#region 审核角色列表与审批
/// <summary>单条详情(旧 MyListtoOneSHRole)。</summary>
[HttpGet("MyListtoOneSHRole")]
public async Task<List<SHRoleDocPrintListItem>> MyListtoOneSHRole([FromQuery] int serID)
=> await _role.MyListtoOneSHRoleAsync(serID, SchSerID());
/// <summary>我的文印(旧 MyListSHRole)。</summary>
[HttpGet("MyListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> MyListSHRole(
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
[FromQuery] string endDate = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _role.MyListSHRoleAsync(name, status, startDate, endDate, pageSize, pageIndex, UserId(), SchSerID());
/// <summary>全部文印(旧 totalListSHRole)。</summary>
[HttpGet("totalListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> TotalListSHRole(
[FromQuery] int flowid = 0,
[FromQuery] int dwid = 0,
[FromQuery] string name = null,
[FromQuery] string stateid = null,
[FromQuery] string starttime = null,
[FromQuery] string endtime = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _role.TotalListSHRoleAsync(flowid, dwid, name, stateid, starttime, endtime, pageSize, pageIndex, SchSerID());
/// <summary>抄送列表(旧 SendRdListSHRole)。</summary>
[HttpGet("SendRdListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> SendRdListSHRole(
[FromQuery] int flowid = 0,
[FromQuery] int dwid = 0,
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
[FromQuery] string endDate = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _role.SendRdListSHRoleAsync(flowid, dwid, name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>审批列表(旧 SPListSHRole)。</summary>
[HttpGet("SPListSHRole")]
public async Task<List<SHRoleDocPrintListItem>> SPListSHRole(
[FromQuery] int flowid = 0,
[FromQuery] int dwid = 0,
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
[FromQuery] string endDate = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _role.SPListSHRoleAsync(flowid, dwid, name, status, startDate, endDate, UserId(), UserName(), SchSerID(), pageSize, pageIndex);
/// <summary>审核确认(旧 shenheokSHRole)。</summary>
[HttpPost("shenheokSHRole")]
public async Task<string> ShenheOkSHRole([FromBody] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
return await _role.ShenheOkSHRoleAsync(model);
}
// 延后:ExceltotalListSHRole / WYRDExceldaochu / UpfileExcelSHRole / downMBOfSHRole / downUpfileSHRoleFK(Excel,NPOI)
// 延后:DwonWYSQD(审批单 PDF,PdfHelper + Server.MapPath)
#endregion
#region 签名 / 退回 / 金额重提
/// <summary>保存签名(旧 SaveSignature,form: pic → [FromBody])。</summary>
[HttpPost("SaveSignature")]
public async Task<string> SaveSignature([FromBody] SaveSignatureDto model)
{
if (model == null) throw new DException("参数错误!");
return await _role.SaveSignatureAsync(SchSerID(), UserId(), model.pic);
}
/// <summary>获取签名(旧 GetSignature)。</summary>
[HttpGet("GetSignature")]
public async Task<string> GetSignature() => await _role.GetSignatureAsync(SchSerID(), UserId());
/// <summary>删除签名(旧 DelSignature)。</summary>
[HttpGet("DelSignature")]
public async Task<string> DelSignature() => await _role.DelSignatureAsync(SchSerID(), UserId());
/// <summary>退回(旧 TuiHui,form: DocID + cont → [FromBody])。</summary>
[HttpPost("TuiHui")]
public async Task<string> TuiHui([FromBody] TuiHuiDto model)
{
if (model == null) throw new DException("参数错误!");
return await _role.TuiHuiAsync(UserId(), model.cont, model.DocID);
}
/// <summary>撤销退回(旧 unTuiHui)。</summary>
[HttpGet("unTuiHui")]
public async Task<string> UnTuiHui([FromQuery] decimal docID)
=> await _role.UnTuiHuiAsync(UserId(), docID);
/// <summary>金额重提(旧 regetJE,form: DocID 逗号分隔 + type → [FromBody])。</summary>
[HttpPost("regetJE")]
public async Task<string> RegetJE([FromBody] RegetJEDto model)
{
if (model == null) throw new DException("参数错误!");
var ids = (model.DocID ?? "").Split(',')
.Where(a => !string.IsNullOrEmpty(a) && decimal.TryParse(a, out _))
.Select(decimal.Parse).ToList();
return await _role.RegetJEAsync(SchSerID(), UserId(), ids, model.type);
}
/// <summary>重提记录(旧 getRegetJERd)。</summary>
[HttpGet("getRegetJERd")]
public async Task<List<SHRoleRegetJERdItem>> GetRegetJERd(
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _role.GetRegetJERdAsync(SchSerID(), pageSize, pageIndex);
/// <summary>记录下载次数(旧 setDownFileTimes)。</summary>
[HttpGet("setDownFileTimes")]
public async Task<int> SetDownFileTimes([FromQuery] int serID)
=> await _role.SetDownFileTimesAsync(serID);
#endregion
private static string SchSerID() => UserContextHolder.Current.SchSerID;
private static string UserId() => UserContextHolder.Current?.Id.ToString() ?? "0";
private static string UserName() => UserContextHolder.Current?.UserName ?? "";
}
}
@@ -0,0 +1,335 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Yh.Platform.Abstractions;
using YhCore.Common;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Model.Dto;
using YhCore.Service.Interfaces;
namespace YhCore.WebApi.Controllers.Api.DocPrint
{
/// <summary>
/// 文印业务主控制器(对应旧 Areas/DocPrint/WY_listController,路由 api/docprint/wy_list)。
/// 第一批:分类 / 版面 / 字典 14 接口;第三批 D1:文印 CRUD / 列表 / 审核 / 统计 / 指派 24 接口;
/// Excel 导出 4 接口延后批次迁移。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath + "/docprint/wy_list")]
public class WY_listController : ControllerBase
{
private readonly IDocPrintClassifyService _classify;
private readonly IDictionaryService _dict;
private readonly IWYDocPrintService _wy;
public WY_listController(IDocPrintClassifyService classify, IDictionaryService dict, IWYDocPrintService wy)
{
_classify = classify;
_dict = dict;
_wy = wy;
}
#region 分类 / 版面 / 字典
/// <summary>添加文印分类。</summary>
[HttpPost("AddDic")]
public async Task AddDic([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
model.Schserid = SchSerID();
await _classify.AddDicAsync(model);
}
/// <summary>文印分类列表。</summary>
[HttpGet("CategoryList")]
public async Task<System.Collections.Generic.List<CategoryItem>> CategoryList([FromQuery] string name = null)
=> await _classify.CategoryListAsync(SchSerID(), name);
/// <summary>添加印制版面。</summary>
[HttpPost("AddYZBM")]
public async Task AddYZBM([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
model.Schserid = SchSerID();
await _classify.AddYZBMAsync(model);
}
/// <summary>印制版面列表。</summary>
[HttpGet("YZBMList")]
public async Task<System.Collections.Generic.List<CategoryItem>> YZBMList([FromQuery] string name = null)
=> await _classify.YZBMListAsync(SchSerID(), name);
/// <summary>删除文印分类/版面(ids 逗号分隔)。</summary>
[HttpGet("DeleteDic")]
public async Task DeleteDic([FromQuery] string ids)
{
if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null");
decimal[] idArr = ids.Split(',').Select(a => decimal.Parse(a)).ToArray();
await _classify.DeleteDicAsync(idArr);
}
/// <summary>公共类别列表。</summary>
[HttpGet("DictionaryTypeList")]
public async Task<System.Collections.Generic.List<DictionaryTypeItem>> DictionaryTypeList(
[FromQuery] string typeID,
[FromQuery] string name = "",
[FromQuery] bool showAll = false)
=> await _classify.DictionaryTypeListAsync(SchSerID(), name, showAll, typeID);
/// <summary>添加公共类别(复用字典添加逻辑)。</summary>
[HttpPost("AddDictionaryType")]
public async Task AddDictionaryType([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数错误!");
model.Schserid = SchSerID();
await _dict.AddDicAsync(model);
}
/// <summary>编辑公共类别(旧调 GetSysDict.EditDic)。</summary>
[HttpPost("EditDictionaryType")]
public async Task EditDictionaryType([FromBody] Common_Dictionary model)
{
if (model == null) throw new DException("参数有误!");
model.Schserid = SchSerID();
await _dict.EditDicAsync(model);
}
/// <summary>删除公共类别(软删除)。</summary>
[HttpGet("DeleteDictionaryType")]
public async Task DeleteDictionaryType([FromQuery] string ids)
{
if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null");
string[] idArr = ids.Split(',');
await _classify.DeleteDictionaryTypeAsync(idArr);
}
/// <summary>公共类别显隐。</summary>
[HttpGet("SetDictionaryOrd")]
public async Task SetDictionaryOrd([FromQuery] int dicSerID, [FromQuery] bool show)
=> await _classify.SetDictionaryOrdAsync(SchSerID(), dicSerID, show);
/// <summary>公共类别排序。</summary>
[HttpGet("OrderDic")]
public async Task OrderDic([FromQuery] string ids)
=> await _classify.OrderDicAsync(ids, SchSerID());
/// <summary>结果链接(依赖资源平台,已降级)。</summary>
[HttpGet("GetResultUrl")]
public async Task<object> GetResultUrl([FromQuery] int dataId)
{
var user = UserContextHolder.Current;
return await _classify.GetResultUrlAsync(dataId, user?.Id ?? 0, 0, SchSerID());
}
/// <summary>学校文印资源开关。</summary>
[HttpGet("GetSchZyBit")]
public async Task<bool> GetSchZyBit() => await _classify.GetSchZyBitAsync(SchSerID());
/// <summary>设置学校文印资源开关。</summary>
[HttpGet("SetSchZyBit")]
public async Task SetSchZyBit([FromQuery] bool zyBit) => await _classify.SetSchZyBitAsync(SchSerID(), zyBit);
/// <summary>待审数量角标。</summary>
[HttpGet("GetShnheCount")]
public async Task<int> GetShnheCount()
=> await _classify.GetShnheCountAsync(UserContextHolder.Current?.Id.ToString(), SchSerID());
#endregion
#region 文印核心(第三批 D1:抄送 / 附件 / CRUD / 列表 / 审核 / 统计 / 指派)
/// <summary>抄送列表(旧 SendRdList)。</summary>
[HttpGet("SendRdList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> SendRdList(
[FromQuery] string name = null,
[FromQuery] string startDate = null,
[FromQuery] string endDate = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.SendRdListAsync(name, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>查看抄送人员详情(旧 SendDetailsList)。</summary>
[HttpGet("SendDetailsList")]
public async Task<System.Collections.Generic.List<SendDetailItem>> SendDetailsList([FromQuery] int serID)
=> await _wy.SendDetailsListAsync(serID);
/// <summary>修改抄送人(旧 EditSend)。</summary>
[HttpGet("EditSend")]
public async Task<string> EditSend([FromQuery] int serID, [FromQuery] string senduserid, [FromQuery] string mysenduserid)
=> await _wy.EditSendAsync(serID, senduserid, mysenduserid, SchSerID(), UserId());
/// <summary>获取附件数据源(旧 filelist)。</summary>
[HttpGet("filelist")]
public async Task<System.Collections.Generic.List<DocPrintFileItem>> FileList([FromQuery] string ids)
=> await _wy.FileListAsync(ids);
/// <summary>添加文印(旧 Create;短信/待办推送已降级为纯落库)。</summary>
[HttpPost("Create")]
public async Task<int> Create([FromBody] WYDocPrintSaveDto model)
{
if (model == null) throw new DException("参数错误!");
model.UserID = UserId();
model.SchSerID = SchSerID();
return await _wy.CreateAsync(model);
}
/// <summary>修改文印(旧 Edit)。</summary>
[HttpPost("Edit")]
public async Task<int> Edit([FromBody] WYDocPrintSaveDto model)
{
if (model == null) throw new DException("参数错误!");
model.UserID = UserId();
model.SchSerID = SchSerID();
return await _wy.EditAsync(model);
}
/// <summary>撤销(旧 Revoke)。</summary>
[HttpPost("Revoke")]
public async Task<string> Revoke([FromBody] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
model.actionuserid = UserContextHolder.Current?.Id;
return await _wy.RevokeAsync(model);
}
/// <summary>我的文印列表(旧 MyList)。</summary>
[HttpGet("MyList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> MyList(
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
[FromQuery] string endDate = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.MyListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>全部文印列表(旧 AllList 四分类,返回 ctype)。</summary>
[HttpGet("AllList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> AllList(
[FromQuery] string name = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.AllListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>文印审核列表(旧 ShenheList;旧 Name 过滤为死代码,保持不生效)。</summary>
[HttpGet("ShenheList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> ShenheList(
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.ShenheListAsync(UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>审批列表(旧 SPList)。</summary>
[HttpGet("SPList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> SPList(
[FromQuery] string name = null,
[FromQuery] string status = null,
[FromQuery] string startDate = null,
[FromQuery] string endDate = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.SPListAsync(name, status, startDate, endDate, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>文印已审列表(旧 YishenList)。</summary>
[HttpGet("YishenList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> YishenList(
[FromQuery] string name = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.YishenListAsync(name, UserId(), SchSerID(), pageSize, pageIndex);
/// <summary>全部文印列表(旧 totalList)。</summary>
[HttpGet("totalList")]
public async Task<System.Collections.Generic.List<DocPrintListItem>> TotalList(
[FromQuery] string name = null,
[FromQuery] string stateid = null,
[FromQuery] string starttime = null,
[FromQuery] string endtime = null,
[FromQuery] int pageSize = 10,
[FromQuery] int pageIndex = 0)
=> await _wy.TotalListAsync(name, stateid, starttime, endtime, SchSerID(), pageSize, pageIndex);
/// <summary>获取当前一条文印详情(旧 MyListtoOne;附件明细并入条目 tempresultfile)。</summary>
[HttpGet("MyListtoOne")]
public async Task<System.Collections.Generic.List<DocPrintDetailItem>> MyListtoOne([FromQuery] int serID)
=> await _wy.MyListtoOneAsync(serID, SchSerID());
/// <summary>审核确认(旧 shenheok)。</summary>
[HttpPost("shenheok")]
public async Task<string> ShenheOk([FromBody] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
return await _wy.ShenheOkAsync(model);
}
/// <summary>领取确认(旧 AffirmApplyforOk)。</summary>
[HttpPost("AffirmApplyforOk")]
public async Task<string> AffirmApplyforOk([FromBody] FlowTranctDto model)
{
if (model == null) throw new DException("参数错误!");
model.actionuserid = UserContextHolder.Current?.Id ?? 0;
return await _wy.AffirmApplyforOkAsync(model);
}
/// <summary>删除文印(旧 Delete)。</summary>
[HttpGet("Delete")]
public async Task Delete([FromQuery] string ids)
{
if (string.IsNullOrWhiteSpace(ids)) throw new DException("ids is not null");
await _wy.DeleteAsync(ids);
}
/// <summary>绑定流程(旧 Bindflowinfo)。</summary>
[HttpGet("Bindflowinfo")]
public async Task<System.Collections.Generic.List<BindFlowOption>> Bindflowinfo([FromQuery] int flowType)
=> await _wy.BindflowinfoAsync(SchSerID(), flowType);
/// <summary>查询指派(旧 GetUserZhipai)。</summary>
[HttpGet("GetUserZhipai")]
public async Task<string> GetUserZhipai([FromQuery] int? flowid, [FromQuery] int? flowstepid, [FromQuery] string type)
=> await _wy.GetUserZhipaiAsync(UserContextHolder.Current?.Id ?? 0, SchSerID(), flowid, flowstepid, type);
/// <summary>文印数量按月统计(旧 List1)。</summary>
[HttpGet("List1")]
public async Task<System.Collections.Generic.List<MonthStatItem>> List1([FromQuery] string starttime = null, [FromQuery] string endtime = null)
=> await _wy.List1Async(starttime, endtime, SchSerID());
/// <summary>文印详情统计列表(旧 List2)。</summary>
[HttpGet("List2")]
public async Task<System.Collections.Generic.List<DocDetailStatItem>> List2(
[FromQuery] string starttime = null, [FromQuery] string endtime = null,
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
=> await _wy.List2Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
/// <summary>文印数量按用户排名(旧 List3)。</summary>
[HttpGet("List3")]
public async Task<System.Collections.Generic.List<UserRankItem>> List3(
[FromQuery] string starttime = null, [FromQuery] string endtime = null,
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
=> await _wy.List3Async(starttime, endtime, SchSerID(), pageSize, pageIndex);
/// <summary>按月份统计明细(旧 List4)。</summary>
[HttpGet("List4")]
public async Task<System.Collections.Generic.List<StatListItem>> List4(
[FromQuery] int year, [FromQuery] int month,
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
=> await _wy.List4Async(year, month, SchSerID(), pageSize, pageIndex);
/// <summary>按用户统计明细(旧 List5)。</summary>
[HttpGet("List5")]
public async Task<System.Collections.Generic.List<StatListItem>> List5(
[FromQuery] string userid = null, [FromQuery] string starttime = null, [FromQuery] string endtime = null,
[FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
=> await _wy.List5Async(userid, starttime, endtime, SchSerID(), pageSize, pageIndex);
// 延后批次:ExceltotalList / Exceldaochu1 / Exceldaochu2 / Exceldaochu3(Excel 导出,方案待定)
#endregion
private static string SchSerID() => UserContextHolder.Current.SchSerID;
private static string UserId() => UserContextHolder.Current?.Id.ToString() ?? "0";
}
}
@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Threading.Tasks;
using Yh.Platform.Abstractions;
using Yh.Platform.Auth;
using Yh.Platform.OpenApi;
using YhCore.Common;
namespace YhCore.WebApi.Controllers.Api
{
/// <summary>
/// 登录用户接口(对应 Java 版 controller/api/LoginUserController,路由 /api/GetUserMod 等)。
/// 注意:Java 版路由以大写开头(GetUserMod / GetUserInfo / Logout),此处保持 URL 大小写一致。
/// </summary>
[ApiController]
[Route(ApiRoutes.ApiPath)]
public class LoginUserController : ControllerBase
{
private readonly IOpenApiUserClient _openApiUserClient;
public LoginUserController(IOpenApiUserClient openApiUserClient)
{
_openApiUserClient = openApiUserClient;
}
/// <summary>
/// 获取当前用户可见的应用模块(对应 Java GET /api/GetUserMod,
/// 内部调用 YschBaseOpenApi.getUserModule(id, schoolId))。
/// </summary>
[HttpGet("GetUserMod")]
public async Task<List<BoaModule>> GetUserMod()
{
var boa = BoaUserContext.Get();
return await _openApiUserClient.GetUserModuleAsync(boa?.Id ?? 0, boa?.School?.Id);
}
/// <summary>
/// 获取当前登录用户信息(对应 Java GET /api/GetUserInfo,
/// Java 版直接返回认证切面注入的 authUserWithDept;未登录时为 null)。
/// 返回新框架用户上下文解析的完整 BoaUser(含 School/CurrentRole/RoleIds),与 Java 返回结构一致。
/// </summary>
[HttpGet("GetUserInfo")]
public BoaUser GetUserInfo()
{
return BoaUserContext.Get();
}
/// <summary>
/// 判断是否登录(对应 Java GET /api/checkLogin):
/// 已登录 → 重定向到运维工作台 /work-view.html(兼容 context-path,.NET 用相对路径);
/// 未登录 → 401 + {"code":401,"msg":"未登录"}(通常 CAS 拦截器会先行 302 到 CAS 登录页)。
/// </summary>
[HttpGet("checkLogin")]
public IActionResult CheckLogin()
{
if (BoaUserContext.Get()?.Id > 0)
{
// 对应 Java request.getContextPath() + "/work-view.html"(.NET 无 contextPath,用根相对路径)
return Redirect("/work-view.html");
}
return new ContentResult
{
Content = "{\"code\":401,\"msg\":\"未登录\"}",
ContentType = "application/json;charset=UTF-8",
StatusCode = StatusCodes.Status401Unauthorized
};
}
/// <summary>
/// 登出(对应 Java GET /api/Logout:session.invalidate())。
/// 清除本地 Cookie 认证(新框架 YhCookie 方案)+ 可选跳转到 CAS 单点登出(CasServerUrlPrefix + logout)。
/// TODO:按需启用单点登出(去 yh:sso:cas-server-url-prefix + logout 退出所有 SSO 应用)。
/// </summary>
[HttpGet("Logout")]
public async Task<IActionResult> Logout()
{
// 清除本地 Cookie 登录态(对应 session.invalidate())
await HttpContext.SignOutAsync(YhAuthDefaults.CookieScheme);
// 仅在已启用 Session 中间件时再清一份
if (HttpContext.Features.Get<ISessionFeature>() != null)
{
HttpContext.Session.Clear();
}
return Ok();
}
}
}
@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Linq;
using System.Threading.Tasks;
using YhCore.Common.Result;
namespace YhCore.WebApi.Filters
{
/// <summary>
/// 排除统一包装特性:标注在 Controller 或 Action 上,该处返回值不参与全局包装。
/// 适用:CAS 协议端点(XML/跳转)、文件下载、已手工构造响应体的接口。
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class NoWrapResultAttribute : Attribute, IFilterMetadata
{
}
/// <summary>
/// 全局统一响应包装过滤器:所有 Controller 的 ObjectResult 正常返回
/// 自动包装为 ApiResult{code=0, msg="", data=...}。
/// 规则:
/// - [NoWrapResult] 标注的 Controller/Action 跳过;
/// - 非 2xx 状态码(参数校验 400 等)不包装,保持错误结构 {code,msg,data,timeNow};
/// - 已是 ApiResult&lt;T&gt; 的返回值不重复包装(防双层嵌套);
/// - ContentResult / Redirect / File 等非对象结果不包装(checkLogin 401、OAuth/CAS 协议端点等);
/// - null 返回值包装为 {"code":0,"msg":"","data":null}(原为 204)。
/// </summary>
public class GlobalWrapResultFilter : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
// 1) 显式排除
if (context.Filters.Any(f => f is NoWrapResultAttribute))
{
await next();
return;
}
// 2) 只包装对象结果
if (context.Result is ObjectResult objectResult)
{
var statusCode = objectResult.StatusCode ?? StatusCodes.Status200OK;
var isErrorStatus = statusCode < 200 || statusCode >= 300;
if (!isErrorStatus && !IsApiResult(objectResult.Value))
{
context.Result = new ObjectResult(ApiResult<object>.Ok(objectResult.Value))
{
StatusCode = StatusCodes.Status200OK
};
}
}
else if (context.Result is OkResult || context.Result is EmptyResult)
{
// Ok()(200 空 body)/ EmptyResult(204)→ 统一包装为 {"code":0,"msg":"","data":null}
context.Result = new ObjectResult(ApiResult<object>.Ok())
{
StatusCode = StatusCodes.Status200OK
};
}
await next();
}
/// <summary>值是否已是 ApiResult&lt;T&gt;(防双层包装)</summary>
private static bool IsApiResult(object value)
{
if (value == null)
{
return false;
}
var type = value.GetType();
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ApiResult<>);
}
}
}
@@ -0,0 +1,334 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Newtonsoft.Json;
using Yh.Platform.Abstractions;
using YhCore.Common.UserContext;
using YhCore.Entity;
using YhCore.Repository;
namespace YhCore.WebApi.Filters
{
/// <summary>
/// 系统审计日志过滤器(对应 Java SysOperationLogAspect)。
/// 全量拦截所有 Controller Action(不依赖标注),每次请求后异步写入 sys_operation_log 表。
/// 与 Java 对齐的行为:
/// - 排除日志查询控制器自身(防递归刷屏);
/// - 成功路径记录响应 GZIP + 业务状态码(0 成功 / 非 0 失败);
/// - 异常路径记录错误码 + 异常堆栈摘要(截断 8000),异常继续向外传播;
/// - 落库失败不影响主流程(审计不阻断业务)。
/// 注:请求/响应脱敏(Java SensitiveDataUtil)未实现,仅做长度截断,后续可接入。
/// </summary>
public class SysOperationLogFilter : IAsyncActionFilter, IAsyncResultFilter
{
private const string AuditEntityKey = "__sys_operation_log_entity";
private const string AuditStopwatchKey = "__sys_operation_log_stopwatch";
private const int MaxResponseSize = 10240; // 响应 JSON 截断长度(对齐 Java DEFAULT_MAX_RESPONSE_SIZE)
private const int MaxErrorMsg = 8000; // 异常信息截断长度(对齐 Java MAX_ERROR_MSG)
private const int MaxParamsSize = 4096; // 请求参数截断长度(Java 无此限制,此处防超大请求体)
/// <summary>排除的控制器(日志查询自身,防递归)</summary>
private static readonly string[] ExcludeControllers =
{
"YhCore.WebApi.Controllers.Api.LogController",
};
private static readonly string[] DeleteKeywords = { "delete", "del", "remove" };
private static readonly string[] CreateKeywords = { "add", "create", "insert", "save", "import", "upload" };
private readonly IRepository<SysOperationLog> _repo;
public SysOperationLogFilter(IRepository<SysOperationLog> repo)
{
_repo = repo;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (IsExcluded(context))
{
await next();
return;
}
var entity = BuildEntity(context);
var stopwatch = Stopwatch.StartNew();
context.HttpContext.Items[AuditEntityKey] = entity;
context.HttpContext.Items[AuditStopwatchKey] = stopwatch;
var executed = await next();
stopwatch.Stop();
entity.CostTime = stopwatch.ElapsedMilliseconds;
if (executed.Exception != null)
{
// 异常路径:Result 过滤器不会执行,在此记录错误码与摘要后放行(异常继续传播,由 GlobalExceptionMiddleware 兜底)
entity.StatusCode = ResolveErrorCode(executed.Exception);
entity.ErrorMsg = BuildErrorMsg(executed.Exception);
await SaveQuietlyAsync(entity);
}
// 成功路径:StatusCode / ResponseData 由 OnResultExecutionAsync 填充并落库
}
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
if (!context.HttpContext.Items.TryGetValue(AuditEntityKey, out var raw) || raw is not SysOperationLog entity)
{
await next();
return;
}
if (context.HttpContext.Items.TryGetValue(AuditStopwatchKey, out var sw) && sw is Stopwatch stopwatch)
{
entity.CostTime = stopwatch.ElapsedMilliseconds;
}
// 从结果对象提取业务状态码与响应体
switch (context.Result)
{
case ObjectResult objectResult:
entity.StatusCode = ResolveStatusCode(objectResult.Value);
entity.ResponseData = BuildResponseData(objectResult.Value);
break;
case OkResult:
case EmptyResult:
case StatusCodeResult statusCodeResult when statusCodeResult.StatusCode >= 200 && statusCodeResult.StatusCode < 300:
entity.StatusCode = ErrorCode.Success.Code;
break;
default:
// 非对象结果(重定向/文件等)仅记 200 默认成功,不序列化响应体
entity.StatusCode = ErrorCode.Success.Code;
break;
}
await next();
await SaveQuietlyAsync(entity);
}
// ==================== 构建 ====================
private static bool IsExcluded(ActionExecutingContext context)
{
if (context.ActionDescriptor is not ControllerActionDescriptor descriptor)
{
return true;
}
var fullName = descriptor.ControllerTypeInfo.FullName;
return ExcludeControllers.Any(c => string.Equals(fullName, c, StringComparison.Ordinal));
}
private static SysOperationLog BuildEntity(ActionExecutingContext context)
{
var descriptor = (ControllerActionDescriptor)context.ActionDescriptor;
var http = context.HttpContext;
var request = http.Request;
var user = UserContextHolder.Current;
var uri = request.Path.HasValue ? request.Path.Value : "";
var httpMethod = request.Method ?? "";
var actionName = descriptor.ActionName ?? "";
var (level, operationType) = ResolveLevelAndType(httpMethod, uri, actionName);
return new SysOperationLog
{
CreateTime = DateTime.Now,
UserId = user?.Id ?? 0,
UserName = string.IsNullOrEmpty(user?.UserName) ? "anonymous" : user.UserName,
SchId = user?.SchoolId,
RequestIp = http.Connection.RemoteIpAddress?.ToString() ?? "",
RequestUri = uri,
RequestMethod = httpMethod,
ClassMethod = $"{descriptor.ControllerTypeInfo.FullName}.{actionName}",
LogLevel = level,
OperationType = operationType,
UserAgent = request.Headers["User-Agent"].ToString(),
RequestParams = BuildRequestParams(context),
StatusCode = ErrorCode.Success.Code
};
}
/// <summary>请求参数序列化(过滤基础设施类型,对齐 Java buildRequestParams)</summary>
private static string BuildRequestParams(ActionExecutingContext context)
{
var args = context.ActionArguments.Values
.Where(v => v != null && !IsInfrastructureType(v))
.ToList();
if (args.Count == 0)
{
return "";
}
try
{
var json = JsonConvert.SerializeObject(args);
return json.Length > MaxParamsSize
? json.Substring(0, MaxParamsSize) + "...[截断]"
: json;
}
catch
{
return "";
}
}
private static bool IsInfrastructureType(object value)
{
var type = value.GetType();
return typeof(IFormFile).IsAssignableFrom(type)
|| type == typeof(CancellationToken)
|| typeof(HttpRequest).IsAssignableFrom(type)
|| typeof(HttpResponse).IsAssignableFrom(type);
}
/// <summary>响应 JSON → GZIP 压缩(对齐 Java fillResponse + GzipUtil.compress)</summary>
private static byte[] BuildResponseData(object value)
{
if (value == null)
{
return null;
}
try
{
var json = JsonConvert.SerializeObject(value);
if (json.Length > MaxResponseSize)
{
json = json.Substring(0, MaxResponseSize) + "...[截断]";
}
var bytes = Encoding.UTF8.GetBytes(json);
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, true))
{
gzip.Write(bytes, 0, bytes.Length);
}
return output.ToArray();
}
catch
{
return null;
}
}
/// <summary>业务状态码:返回值带 int Code 属性则取之(ApiResult 等),否则默认 0 成功</summary>
private static int ResolveStatusCode(object value)
{
if (value == null)
{
return ErrorCode.Success.Code;
}
var codeProperty = value.GetType().GetProperty("Code", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (codeProperty != null && codeProperty.PropertyType == typeof(int))
{
try
{
return (int)codeProperty.GetValue(value);
}
catch
{
// 忽略取值异常,回落默认成功
}
}
return ErrorCode.Success.Code;
}
/// <summary>异常错误码:DException 取业务码,其余取平台兜底码 6(对齐 Java resolveErrorCode)</summary>
private static int ResolveErrorCode(Exception exception)
{
if (exception is DException dex && dex.Code != null)
{
return dex.Code.Code;
}
return ErrorCode.InternalServerError.Code;
}
/// <summary>异常信息摘要:message + 堆栈(截断 8000,对齐 Java buildErrorMsg)</summary>
private static string BuildErrorMsg(Exception exception)
{
var msg = exception.Message;
var head = string.IsNullOrWhiteSpace(msg) ? exception.GetType().Name : msg;
var text = head + Environment.NewLine + exception.StackTrace;
return text.Length > MaxErrorMsg ? text.Substring(0, MaxErrorMsg) : text;
}
/// <summary>日志级别/类型推断(复刻 Java OperationLogResolver):GET=查询、DELETE=删除、关键字兜底、其余更新</summary>
private static (int level, string type) ResolveLevelAndType(string httpMethod, string uri, string actionName)
{
var method = string.IsNullOrEmpty(httpMethod) ? "" : httpMethod.ToUpperInvariant();
if (method == "")
{
return (2, "UPDATE");
}
if (method == "GET")
{
return (3, "QUERY");
}
if (method == "DELETE")
{
return (1, "DELETE");
}
var text = (uri ?? "").ToLowerInvariant() + " " + (actionName ?? "").ToLowerInvariant();
if (ContainsAny(text, DeleteKeywords))
{
return (1, "DELETE");
}
if (ContainsAny(text, CreateKeywords))
{
return (2, "CREATE");
}
return (2, "UPDATE");
}
private static bool ContainsAny(string text, IEnumerable<string> keywords)
{
foreach (var keyword in keywords)
{
if (text.Contains(keyword, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
/// <summary>异步落库,失败不影响主流程(对齐 Java 操作日志不阻断业务)</summary>
private async Task SaveQuietlyAsync(SysOperationLog entity)
{
try
{
await _repo.AddAsync(entity);
await _repo.SaveChangesAsync();
}
catch
{
// 忽略:落库失败仅影响审计,不影响接口结果
}
}
}
}
@@ -0,0 +1,141 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using log4net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using YhCore.Common.UserContext;
namespace YhCore.WebApi.Middleware
{
/// <summary>
/// 接口访问日志中间件(唯一日志出口,方案 §6.2)。
/// 记录:请求时间 / 渠道 / 路径与方法 / Query / Body(敏感字段脱敏)/
/// 客户端 IP / 当前用户 / 响应状态码 / 耗时 / 异常摘要。
/// 其余一切日志(SQL、框架、业务 Debug)均已关闭。
/// </summary>
public class ApiAccessLogMiddleware
{
private static readonly ILog Log = LogManager.GetLogger(typeof(ApiAccessLogMiddleware));
/// <summary>需要脱敏的入参 key(对应 Java SensitiveDataUtil)</summary>
private static readonly string[] SensitiveKeys = { "password", "pwd", "sign", "token", "secret" };
private readonly RequestDelegate _next;
private readonly ILogger<ApiAccessLogMiddleware> _fallbackLogger;
public ApiAccessLogMiddleware(RequestDelegate next, ILogger<ApiAccessLogMiddleware> fallbackLogger)
{
_next = next;
_fallbackLogger = fallbackLogger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
var request = context.Request;
// 请求体读取(可重复读)
var body = await ReadBodyAsync(request);
string exceptionSummary = null;
try
{
await _next(context);
}
catch (Exception ex)
{
exceptionSummary = ex.Message;
throw; // 异常继续向上,由 GlobalExceptionMiddleware 处理响应
}
finally
{
sw.Stop();
WriteLog(context, body, sw.ElapsedMilliseconds, exceptionSummary);
}
}
private void WriteLog(HttpContext context, string body, long elapsedMs, string exceptionSummary)
{
var request = context.Request;
var user = UserContextHolder.Current;
var sb = new StringBuilder();
sb.Append("time=").Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"));
sb.Append(" | channel=").Append(ResolveChannel(request.Path.Value));
sb.Append(" | method=").Append(request.Method);
sb.Append(" | path=").Append(request.Path.Value);
sb.Append(" | query=").Append(MaskSensitive(request.QueryString.Value));
if (!string.IsNullOrEmpty(body))
{
sb.Append(" | body=").Append(MaskSensitive(body));
}
sb.Append(" | ip=").Append(context.Connection.RemoteIpAddress?.ToString() ?? "-");
sb.Append(" | user=").Append(user?.Id.ToString() ?? "-");
sb.Append(" | status=").Append(context.Response.StatusCode);
sb.Append(" | cost=").Append(elapsedMs).Append("ms");
if (!string.IsNullOrEmpty(exceptionSummary))
{
sb.Append(" | exception=").Append(exceptionSummary);
}
Log.Info(sb.ToString());
}
/// <summary>渠道解析:/api → api、/mobiapi → mobiapi、/oapi → oapi、其余 -</summary>
private static string ResolveChannel(string path)
{
if (string.IsNullOrEmpty(path)) return "-";
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) return "api";
if (path.StartsWith("/mobiapi/", StringComparison.OrdinalIgnoreCase)) return "mobiapi";
if (path.StartsWith("/oapi/", StringComparison.OrdinalIgnoreCase)) return "oapi";
return "-";
}
/// <summary>对查询串/请求体做敏感字段脱敏(key=value 中的 value 替换为 ***)</summary>
private static string MaskSensitive(string raw)
{
if (string.IsNullOrEmpty(raw)) return raw;
var parts = raw.Split('&');
for (var i = 0; i < parts.Length; i++)
{
var eq = parts[i].IndexOf('=');
if (eq <= 0) continue;
var key = parts[i].Substring(0, eq);
if (SensitiveKeys.Any(k => key.Contains(k, StringComparison.OrdinalIgnoreCase)))
{
parts[i] = key + "=***";
}
}
return string.Join("&", parts);
}
private static async Task<string> ReadBodyAsync(HttpRequest request)
{
if (!request.Body.CanRead
|| !request.ContentLength.HasValue
|| request.ContentLength <= 0)
{
return null;
}
request.EnableBuffering();
try
{
using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0; // 复位,供后续中间件/模型绑定读取
return body.Length > 2048 ? body.Substring(0, 2048) + "...(truncated)" : body;
}
catch
{
return null;
}
}
}
}
@@ -0,0 +1,90 @@
using System;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Yh.Platform.AppAuth;
using Yh.Platform.Auth;
namespace YhCore.WebApi.Middleware
{
/// <summary>
/// 应用间签名校验中间件(对应 Java yh-app-auth-spring-boot-starter,仅对 /oapi 渠道生效)。
/// 基于新框架 <see cref="AppAuthVerifier"/> 完成请求级签名校验(X-App-Key / X-Timestamp / X-Signature
/// 三件套 + 时间戳过期 + 防重放);Enabled=false(yh:app-auth)时跳过。
/// 签名协议见 yh-platform-net contract/sign-protocol.md。
/// </summary>
public class AppAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly AppAuthVerifier _verifier;
private readonly AppAuthOptions _options;
public AppAuthMiddleware(RequestDelegate next, AppAuthVerifier verifier, IOptions<AppAuthOptions> options)
{
_next = next;
_verifier = verifier;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
if (_options.Enabled)
{
var path = context.Request.Path.Value ?? string.Empty;
if (path.StartsWith("/oapi", StringComparison.OrdinalIgnoreCase))
{
var appKey = GetHeader(context, "X-App-Key");
var timestamp = GetHeader(context, "X-Timestamp");
var signature = GetHeader(context, "X-Signature");
var nonce = GetHeader(context, "X-Nonce");
if (string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature))
{
await RejectAsync(context, "缺少签名头(X-App-Key / X-Timestamp / X-Signature)");
return;
}
// canonical target:path + 排序 query(对齐 Java AppSignInterceptor,挂根路径无 basePath)
var canonicalTarget = SignCanonical.BuildTarget(
new Uri(context.Request.Scheme + "://" + context.Request.Host + context.Request.Path + context.Request.QueryString),
basePath: null);
var ok = await _verifier.VerifyAsync(
appKey, timestamp, signature,
context.Request.Method, canonicalTarget,
string.IsNullOrEmpty(nonce) ? null : nonce,
context.RequestAborted);
if (!ok)
{
await RejectAsync(context, "签名校验失败(签名错误 / 时间戳过期 / 重复请求)");
return;
}
// 验签通过:写入调用方应用身份(对齐 LoginUser.AppKey,业务可经 UserContextHolder 读取)
UserContextHolder.Set(new LoginUser { AppKey = appKey });
}
}
await _next(context);
}
private static async Task RejectAsync(HttpContext context, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(JsonSerializer.Serialize(new
{
code = 4,
msg = message,
data = (object)null,
}));
}
private static string GetHeader(HttpContext context, string name)
{
return context.Request.Headers.TryGetValue(name, out var value) ? value.ToString() : null;
}
}
}
@@ -0,0 +1,79 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Yh.Platform.Abstractions;
namespace YhCore.WebApi.Middleware
{
/// <summary>
/// 全局异常中间件(对应 Java @ControllerAdvice)。
/// 捕获未处理异常 → 返回统一错误响应 {code, msg, data, timeNow}(对齐 bschclassmanage ValidationExceptionHandler
/// 与平台 GlobalExceptionHandler);异常摘要由接口访问日志(ApiAccessLogMiddleware)统一记录,本中间件不单独输出日志(方案 §6.2 / §6.4)。
/// 规则(对齐 Java):
/// DException(业务异常,含 /oapi 业务失败信封)→ HTTP 200 + {code, msg, data, timeNow}
/// JSON 解析失败 / 请求体异常 → HTTP 400 + {code:1, msg:"请求体(JSON)解析失败: ...", data:null, timeNow}
/// 未捕获异常(平台兜底) → HTTP 200 + {code:6(INTERNAL_SERVER_ERROR), msg:"内部服务错误", data:null, timeNow}
/// </summary>
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
public GlobalExceptionMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (DException dex)
{
// 业务异常(含 /oapi 信封失败)→ 200 + {code, msg, data, timeNow}(code 原样透传,对齐 Java handleDException)
await WriteJsonAsync(context, StatusCodes.Status200OK, new
{
code = dex.Code?.Code ?? ErrorCode.Error.Code,
msg = dex.Message,
data = dex.EnvelopeData,
timeNow = TimeNow()
});
}
catch (BadHttpRequestException ex)
{
// @RequestBody 反序列化失败(JSON 格式错 / 字段类型不符)→ 400 + 真实原因透出(对齐 Java handleNotReadable)
await WriteJsonAsync(context, StatusCodes.Status400BadRequest, new
{
code = ErrorCode.Error.Code,
msg = $"请求体(JSON)解析失败: {ex.Message}",
data = (object)null,
timeNow = TimeNow()
});
}
catch (Exception)
{
// 未捕获异常 → 平台兜底:200 + code:6 内部服务错误(对齐 Java GlobalExceptionHandler.handleException)
await WriteJsonAsync(context, StatusCodes.Status200OK, new
{
code = ErrorCode.InternalServerError.Code,
msg = ErrorCode.InternalServerError.Msg,
data = (object)null,
timeNow = TimeNow()
});
}
}
private static async Task WriteJsonAsync(HttpContext context, int statusCode, object body)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(JsonConvert.SerializeObject(body));
}
/// <summary>时间戳(对齐 Java timeNow 格式 yyyy-MM-dd HH:mm:ss)</summary>
private static string TimeNow() => DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
}
@@ -0,0 +1,128 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Yh.Platform.Auth;
namespace YhCore.WebApi.Middleware
{
/// <summary>
/// SSO 路径保护中间件(对应 Java yh-auth-spring-boot-starter 的 /api/* 保护行为)。
/// 规则(对齐 yh:sso 配置节):
/// - yh:sso:enabled=false(默认):所有路径匿名放行,不影响开发联调;
/// - yh:sso:enabled=true:匹配 /api/* / /mobiapi/* / 根路径(/, 浏览器 SSO 入口) 且未认证的请求
/// → Challenge → Yh.Platform.Auth CasAuthenticationHandler 302 重定向到 CAS 登录页;
/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /signin-cas)未认证时同样 Challenge:
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
/// - Swagger 路径始终放行,方便本地接口调试;
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
/// /oapi/* 也不拦截),保证应用间调用不受浏览器登录流程影响。
/// 调用方:Startup.Configure 中 UseAuthentication + UseYhUserContext 之后、UseAuthorization 之前。
/// </summary>
public class SsoProtectionMiddleware
{
private readonly RequestDelegate _next;
private readonly IConfiguration _configuration;
public SsoProtectionMiddleware(RequestDelegate next, IConfiguration configuration)
{
_next = next;
_configuration = configuration;
}
public async Task InvokeAsync(HttpContext context)
{
var sso = _configuration.GetSection("yh:sso");
var isSwaggerPath = context.Request.Path.StartsWithSegments("/swagger");
var isOapiPath = context.Request.Path.StartsWithSegments("/oapi");
var isRootEntry = context.Request.Path == "/";
var isCasCallback = context.Request.Path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas");
// 已认证(含 Cookie 恢复登录态)直接放行
if (context.User?.Identity?.IsAuthenticated == true)
{
// 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location),
// 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC 端点匹配,/signin-cas 无对应 action,
// 会返回 404 覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。
if (isCasCallback && context.Response.StatusCode == 302 &&
!string.IsNullOrEmpty(context.Response.Headers["Location"]))
{
return;
}
await _next(context);
return;
}
// oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
if (isOapiPath)
{
await _next(context);
return;
}
var enabled = sso["enabled"] != null
? bool.TryParse(sso["enabled"], out var en) && en
: _configuration.GetValue("Yh:Sso:Enabled", false);
if (enabled && !isSwaggerPath &&
(isRootEntry || isCasCallback || IsProtectedPath(context.Request.Path.Value, sso)))
{
// 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期,
// 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
if (context.Request.Query.ContainsKey("ticket"))
{
context.Response.StatusCode = 401;
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.WriteAsync(
"<!DOCTYPE html><html lang=\"zh\"><head><meta charset=\"utf-8\"><title>登录失效</title></head>" +
"<body style=\"font-family:system-ui;text-align:center;padding-top:80px\">" +
"<h2>登录票据校验失败或已过期</h2>" +
"<p>CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。</p>" +
"<p><a href=\"/\">重新登录</a></p></body></html>");
return;
}
// 未认证 → Challenge → 302 重定向 CAS 登录页(ticket 校验与回跳由新框架 CasAuthenticationHandler 处理)
await context.ChallengeAsync(YhAuthDefaults.CasScheme);
return;
}
await _next(context);
}
/// <summary>路径是否命中保护模式(yh:sso:urlPattern / yh:sso:mobiApiPattern,默认 /api/*、/mobiapi/*,通配后缀匹配,忽略大小写)</summary>
private static bool IsProtectedPath(string path, IConfigurationSection sso)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
var urlPattern = sso["urlPattern"] ?? "/api/*";
var mobiPattern = sso["mobiApiPattern"] ?? "/mobiapi/*";
return MatchesPattern(path, urlPattern) || MatchesPattern(path, mobiPattern);
}
private static bool MatchesPattern(string path, string pattern)
{
if (string.IsNullOrEmpty(pattern) || string.IsNullOrEmpty(path))
{
return false;
}
// 形如 /api/* → 前缀匹配;否则精确匹配(忽略大小写)
if (pattern.EndsWith("/*", StringComparison.Ordinal))
{
var prefix = pattern.Substring(0, pattern.Length - 1); // 去掉 *
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
return path.Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace YhCore.WebApi
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
@@ -0,0 +1,27 @@
{
"profiles": {
"YhCore.WebApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:51835"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:58057/",
"sslPort": 44399
}
}
}
+367
View File
@@ -0,0 +1,367 @@
using System;
using System.IO;
using System.Linq;
using FluentValidation.AspNetCore;
using log4net.Config;
using log4net;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using Newtonsoft.Json.Serialization;
using YhCore.Common.Cache;
using YhCore.Repository;
using YhCore.Repository.Extensions;
using YhCore.Service.Impl;
using YhCore.Service.Interfaces;
using YhCore.Tasks;
using YhCore.WebApi.Filters;
using YhCore.WebApi.Middleware;
using YhCore.Common.Config;
using YhCore.Common.Exceptions;
using YhCore.Common.UserContext;
using Yh.Platform.Abstractions;
using Yh.SchClass.OpenApi;
using Yh.Platform.Auth;
namespace YhCore.WebApi
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
// log4net 初始化(唯一日志出口:接口访问日志)
var log4netConfig = Path.Combine(AppContext.BaseDirectory, "log4net.config");
if (File.Exists(log4netConfig))
{
XmlConfigurator.Configure(new FileInfo(log4netConfig));
}
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers(options =>
{
// 系统审计日志:全量拦截 Controller(对齐 Java SysOperationLogAspect,内部排除日志查询控制器)
options.Filters.Add(new TypeFilterAttribute(typeof(SysOperationLogFilter)));
// 全局统一响应包装:Controller 正常返回自动包 ApiResult{code,msg,data}
options.Filters.Add<GlobalWrapResultFilter>();
})
.ConfigureApiBehaviorOptions(options =>
{
// 模型校验失败 → 400 + {code, msg, data, timeNow}(对齐 Java ValidationExceptionHandler 参数校验返回)
options.InvalidModelStateResponseFactory = context =>
{
var msgs = context.ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.Where(m => !string.IsNullOrEmpty(m))
.ToList();
return new BadRequestObjectResult(new
{
code = ErrorCode.Error.Code,
msg = string.Join(";", msgs),
data = (object)null,
timeNow = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
});
};
})
// 对齐 Java Jackson:日期格式 yyyy-MM-dd HH:mm:ss + 字段 camelCase(code/msg/data)
.AddNewtonsoftJson(o =>
{
o.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss";
o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
})
// 对应 Java Hibernate Validator
.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<Startup>());
// 对象映射:对应 Java ModelMapper
services.AddAutoMapper(typeof(Startup));
// 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true");
// AllowAnyOrigin 与 AllowCredentials 不能同时生效,接入 CAS 后按 yh.sso.allow-origins 收紧
services.AddCors(options => options.AddPolicy("default", builder => builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()));
// 数据层:默认 SQL Server 主库,DatabaseProvider=MySql 可切换
services.AddDataLayer(Configuration);
// 仓储与 Dapper 通道(对应 Java Repository 自动装配)
services.AddRepositories();
// 系统审计日志过滤器(全量拦截,TypeFilterAttribute 从 DI 解析依赖)
services.AddScoped<SysOperationLogFilter>();
// Redis 缓存(RedisUtil 单例,连接串取 Redis:ConnectionString)
services.AddRedisCache(Configuration);
// 框架期占位实现;业务实现就绪后在 Extensions 中统一替换注册
services.AddScoped<ILogService, LogServiceStub>();
// ===== DocPrint 业务服务(对应旧 BestSchool.Service / BestSchool.DocPrint) =====
services.AddScoped<IDictionaryService, DictionaryService>();
services.AddScoped<IDocPrintClassifyService, DocPrintClassifyService>();
services.AddScoped<IReviewProcessService, ReviewProcessService>();
services.AddScoped<IFreeApproveService, FreeApproveService>();
services.AddScoped<IWYDocPrintService, WYDocPrintService>();
services.AddScoped<IWYSHRoleService, WYSHRoleService>();
services.AddScoped<IDocPrintStatisticsService, DocPrintStatisticsService>();
services.AddScoped<IFileService, FileService>();
// ===== 基础平台(yh-platform-net 新框架,对应 Java yh-platform) =====
// 聚合注册:/oapi 客户端(Yh:Open)+ 应用签名鉴权(Yh:AppAuth)+ Cookie/CAS 认证(Yh:Sso/Yh:Auth)。
// AddYhAuth 内部含 AddYhOpenApi;AddYhPlatform = AddYhAppAuth + AddYhAuth。
// 静态门面 YschBaseOpenApi 由宿主启动时自动装配(YschFacadeWiring: IHostedService)。
services.AddYhPlatform(Configuration);
// 教务(SchClass)开放接口客户端 SDK(Yh.SchClass.OpenApi.dll,配置节 yh:schclass:open)
services.AddSchClassOpenApi(Configuration);
// 统一配置管理(YhCore.Common.Config):数据库 / Redis / Swagger / 文件路径
services.AddOptions<YhCore.Common.Config.DatabaseConfig>()
.Bind(Configuration);
services.AddOptions<YhCore.Common.Config.RedisConfig>()
.Bind(Configuration.GetSection("Redis"));
services.AddOptions<YhCore.Common.Config.SwaggerConfig>()
.Bind(Configuration.GetSection("Swagger"));
services.AddOptions<YhCore.Common.Config.FileConfig>()
.Bind(Configuration);
// 静态门面(对齐 Java Config):文件路径由配置注入
YhCore.Common.Config.Config.FilePath = Configuration["FILE_PATH"];
YhCore.Common.Config.Config.FileWebPath = Configuration["FILE_WEB_PATH"];
// 接口文档:对应 Java SpringDoc,按渠道分三组(api / mobiapi / oapi)
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("api", new OpenApiInfo { Title = "YhCore API", Version = "v1" });
c.SwaggerDoc("mobiapi", new OpenApiInfo { Title = "YhCore MobiApi", Version = "v1" });
c.SwaggerDoc("oapi", new OpenApiInfo { Title = "YhCore OApi", Version = "v1" });
c.AddSecurityDefinition("Token", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Description = "登录后携带的令牌(对接 CAS SSO 后使用)"
});
});
// 任务调度:Quartz 宿主 + Job 模板(对应 Java scheduler 包)
services.AddQuartzHosted();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILogger<Startup> logger)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
// 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true")
app.UseCors("default");
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/api/swagger.json", "API");
c.SwaggerEndpoint("/swagger/mobiapi/swagger.json", "MobiApi");
c.SwaggerEndpoint("/swagger/oapi/swagger.json", "OApi");
});
// 管道顺序:先路由,再认证;CAS 回调必须在认证阶段消费 ticket 并写入 Cookie。
app.UseRouting();
app.UseMiddleware<ApiAccessLogMiddleware>(); // 0. 接口访问日志(唯一日志出口)
app.UseMiddleware<GlobalExceptionMiddleware>(); // 1. 异常兜底(异常摘要并入接口日志)
app.UseAuthentication(); // 2. CAS ticket 校验 + Cookie 恢复(Yh.Platform.Auth)
app.UseMiddleware<SsoProtectionMiddleware>(); // 3. SSO 路径保护(未认证 → 302 CAS;认证通过后才有用户身份)
app.UseYhUserContext(); // 4. 新框架用户上下文:BoaUserContext + UserContextHolder(LoginUser)
// 5. 应用间签名(oapi 渠道)。必须排在 UseYhUserContext 之后:
// UserContextMiddleware 的 finally 会无条件 Clear UserContextHolder,
// 若 AppAuth 在其之前写入 LoginUser{AppKey} 会被清空,业务层读不到应用身份。
app.UseMiddleware<AppAuthMiddleware>();
app.Use(LegacyUserContextAdapter); // 6. 兼容旧 YhCore.Common.UserContextHolder(业务依赖 UserContext)
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
// 根路径登录状态页:SSO 开启时未登录会先被中间件 302 到 CAS;登录后在此显示当前用户。
endpoints.MapGet("/", async context =>
{
var user = context.User;
if (user?.Identity?.IsAuthenticated != true)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("未登录(SSO 未开启或 Cookie 失效)");
return;
}
var name = user.Identity?.Name ?? "-";
var id = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "-";
var school = user.FindFirst("schoolname")?.Value ?? "-";
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.WriteAsync(
"<html><body style=\"font-family:system-ui;padding:40px\">" +
"<h2>YhCore SSO 登录成功</h2>" +
$"<p>用户:{System.Net.WebUtility.HtmlEncode(name)}</p>" +
$"<p>用户Id:{System.Net.WebUtility.HtmlEncode(id)}</p>" +
$"<p>学校:{System.Net.WebUtility.HtmlEncode(school)}</p>" +
"<p><a href=\"/swagger/index.html\">打开接口文档</a> | <a href=\"/api/Logout\">退出登录</a></p>" +
"</body></html>");
});
});
// 数据库自动补表:实体模型里有而数据库没有的表,启动时自动创建。
// 开关 Database:AutoCreateMissingTables(默认 true);失败只记录不影响启动。
// 放在 Configure 末尾而非 Main:此处 Web 宿主配置/DI 已完整就绪,DbContext 连接串有效。
if (Configuration.GetValue("Database:AutoCreateMissingTables", true))
{
using (var scope = app.ApplicationServices.CreateScope())
{
try
{
var created = DatabaseInitializer.EnsureMissingTables(
scope.ServiceProvider.GetRequiredService<YHDbContext>());
if (created.Count > 0)
{
Console.WriteLine(
"[DatabaseInitializer] auto-created missing table(s): "
+ string.Join(", ", created));
logger.LogInformation(
"[DatabaseInitializer] auto-created missing table(s): {Tables}",
string.Join(", ", created));
}
}
catch (Exception ex)
{
Console.WriteLine("[DatabaseInitializer] ERROR: " + ex);
logger.LogError(ex,
"[DatabaseInitializer] auto-create skipped due to error (app continues)");
}
}
}
}
/// <summary>
/// 兼容适配:把新框架 <see cref="BoaUserContext"/> 的当前用户转写为旧
/// UserContextHolder(YhCore.Common.UserContext),保证 LogController /
/// SysOperationLogFilter / ApiAccessLogMiddleware 等既有业务代码零改动继续工作。
/// SchSerID(学校 UUID)通过教务 SDK SchInfo.GetSchFirstAsync 反查填充,进程内缓存避免每请求调用。
/// </summary>
private static async System.Threading.Tasks.Task LegacyUserContextAdapter(HttpContext context, Func<System.Threading.Tasks.Task> next)
{
var boa = BoaUserContext.Get();
if (boa != null)
{
var userContext = new YhCore.Common.UserContext.UserContext
{
Id = boa.Id ?? 0,
UserName = boa.Name,
Account = boa.Account,
SchoolId = boa.School?.Id,
SchoolName = boa.School?.Name,
UserType = boa.Type,
Role = boa.RoleIds?.FirstOrDefault() ?? boa.CurrentRole?.RoleId,
FullBoaUser = boa,
};
if ((userContext.SchoolId ?? 0) > 0)
{
userContext.SchSerID = await ResolveSchSerIDAsync(context, userContext.SchoolId.Value);
}
YhCore.Common.UserContext.UserContextHolder.Current = userContext;
}
try
{
await next();
}
finally
{
// 请求结束清理,避免 AsyncLocal 泄漏到连接池复用线程
YhCore.Common.UserContext.UserContextHolder.Current = null;
}
}
/// <summary>学校 UUID 反查缓存(SchoolId → (值, 缓存时间戳))。成功永久缓存;失败负缓存 60s 后重试。</summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<int, (string Value, long Ticks)> SchSerIdCache =
new System.Collections.Concurrent.ConcurrentDictionary<int, (string Value, long Ticks)>();
/// <summary>失败负缓存有效期(毫秒):教务服务不可达时避免每请求触发,又能在服务恢复后自动重试。</summary>
private const long SchSerIdNegativeCacheMs = 60_000;
/// <summary>
/// 通过教务 SDK 反查学校 UUID(SchInfoVo.SchSerId);失败或未命中返回 null。
/// 注意:SDK HttpClient 配置 ConnectTimeout=1500ms,教务地址不可达时(如本地联调 BaseHost=localhost:8086
/// 未起服务)会抛 TaskCanceledException——属预期路径,捕获后走负缓存,不阻断请求。
/// </summary>
private static async System.Threading.Tasks.Task<string> ResolveSchSerIDAsync(HttpContext context, int schoolId)
{
var now = System.DateTime.UtcNow.Ticks;
if (SchSerIdCache.TryGetValue(schoolId, out var cached))
{
if (!string.IsNullOrEmpty(cached.Value))
return cached.Value;
// 负缓存:有效期内不再重试;过期后放行重新反查(教务服务可能已恢复)
if (now - cached.Ticks < SchSerIdNegativeCacheMs * System.TimeSpan.TicksPerMillisecond)
return null;
}
string result = null;
try
{
var sch = context.RequestServices.GetService(typeof(Yh.SchClass.OpenApi.SchClassOpenApi))
as Yh.SchClass.OpenApi.SchClassOpenApi;
if (sch != null)
{
var info = await sch.SchInfo.GetUserSchFirstAsync(schoolId, System.Threading.CancellationToken.None);
result = info?.SchSerId;
}
}
catch (System.OperationCanceledException)
{
// 连接超时(ConnectTimeout)/ 请求取消:教务不可达,走负缓存,SchSerID 保持 null
LogSchSerIdResolve(context, schoolId, "connect timeout");
}
catch (System.Exception ex)
{
LogSchSerIdResolve(context, schoolId, ex.GetType().Name + ": " + ex.Message);
}
SchSerIdCache[schoolId] = (result ?? "", now);
return result;
}
/// <summary>反查失败诊断日志(Debug 级,避免干扰正常请求日志)。</summary>
private static void LogSchSerIdResolve(HttpContext context, int schoolId, string reason)
{
try
{
var logger = context.RequestServices.GetService(typeof(Microsoft.Extensions.Logging.ILoggerFactory))
as Microsoft.Extensions.Logging.ILoggerFactory;
logger?.CreateLogger("YhCore.WebApi.Startup").LogDebug(
"Resolve SchSerID for schoolId={SchoolId} failed: {Reason}", schoolId, reason);
}
catch
{
// 日志失败不影响请求
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RootNamespace>YhCore.WebApi</RootNamespace>
<AssemblyName>YhCore.WebApi</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 接口文档:对应 Java SpringDoc OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
<!-- 对象映射:对应 Java ModelMapper -->
<PackageReference Include="AutoMapper" Version="11.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<!-- 参数校验:对应 Java Hibernate Validator -->
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.6" />
<!-- 日志落地:仅接口访问日志 -->
<PackageReference Include="log4net" Version="2.0.15" />
<!-- JSON 序列化:对应 Java Jackson/fastjson -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.17" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YhCore.Common\YhCore.Common.csproj" />
<ProjectReference Include="..\YhCore.Entity\YhCore.Entity.csproj" />
<ProjectReference Include="..\YhCore.Model\YhCore.Model.csproj" />
<ProjectReference Include="..\YhCore.Repository\YhCore.Repository.csproj" />
<ProjectReference Include="..\YhCore.Service\YhCore.Service.csproj" />
<ProjectReference Include="..\YhCore.Task\YhCore.Task.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Yh.Platform.Abstractions">
<HintPath>..\..\..\Library\Yh.Platform.Abstractions.dll</HintPath>
</Reference>
<Reference Include="Yh.Platform.AppAuth">
<HintPath>..\..\..\Library\Yh.Platform.AppAuth.dll</HintPath>
</Reference>
<Reference Include="Yh.Platform.Auth">
<HintPath>..\..\..\Library\Yh.Platform.Auth.dll</HintPath>
</Reference>
<Reference Include="Yh.Platform.OpenApi">
<HintPath>..\..\..\Library\Yh.Platform.OpenApi.dll</HintPath>
</Reference>
<Reference Include="Yh.SchClass.OpenApi">
<HintPath>..\..\..\Library\Yh.SchClass.OpenApi.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+52
View File
@@ -0,0 +1,52 @@
{
"DatabaseProvider": "SqlServer",
"Database": {
"AutoCreateMissingTables": true
},
"ConnectionStrings": {
"SqlServer": "Server=localhost;Database=EduCloudDocPrint;User Id=sa;Password=Password01!;MultipleActiveResultSets=true",
"Mysql": "Server=localhost;Port=3306;Database=EduCloudDocPrint;User Id=root;Password=123123;CharSet=utf8mb4"
},
"Redis": {
"ConnectionString": "localhost:6379,defaultDatabase=0"
},
"yh": {
"auth": {
"Enabled": true,
"CookieExpireMinutes": 1,
"cookieName": ".yh.auth"
},
"sso": {
"Enabled": true,
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com/cas",
"AppBaseUrl": "http://localhost:51835",
"SignInCallbackPath": "/signin-cas",
"TicketValidateTimeoutMs": 10000,
"UrlPattern": "/api/*",
"MobiApiPattern": "/mobiapi/*"
},
"app-auth": {
"Enabled": true,
"AppKey": "100002",
"AppSecret": "2314a3b158b24a838b2b9b69fd3db118",
"ExpireMs": 300000
},
"open": {
"Enabled": true,
"BaseHost": "http://base.yunhaixingtong.com",
"BasePath": "/userbase",
"SchClassPath": "/bschclass",
"AppKey": "100002",
"AppSecret": "2314a3b158b24a838b2b9b69fd3db118"
}
},
"Logging": {
"LogLevel": {
"Default": "None",
"Microsoft": "None",
"Microsoft.Hosting.Lifetime": "None",
"Microsoft.AspNetCore.Authentication": "Information"
}
},
"AllowedHosts": "*"
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<log4net>
<!-- 唯一日志出口:接口访问日志。按天滚动单个文件,不做级别分文件(方案 §6.2) -->
<appender name="ApiAccessLogAppender" type="log4net.Appender.RollingFileAppender">
<file type="log4net.Util.PatternString" value="logs/api-access-%date{yyyyMMdd}.log" />
<appendToFile value="true" />
<rollingStyle value="Date" />
<datePattern value="yyyyMMdd" />
<maxSizeRollBackups value="30" />
<layout type="log4net.Layout.PatternLayout">
<conversionPattern value="%date{yyyy-MM-dd HH:mm:ss.fff} | %message%newline" />
</layout>
</appender>
<root>
<level value="ALL" />
<appender-ref ref="ApiAccessLogAppender" />
</root>
</log4net>