Files
DocprintService/src/YhCore.WebApi/Startup.cs
T
wanghu 4d6c07c34c
build / build-push (push) Successful in 31s
1
2026-09-03 17:32:05 +08:00

395 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
// 跨域策略:任意源 + 任意方法/头,但不允许凭据(无 AllowCredentials)。
// 响应回 Access-Control-Allow-Origin: *,浏览器跨域请求不携带 CAS 登录 Cookie
// (登录态仅同源请求可用;跨域只能访问免认证接口)。
services.AddCors(options => options.AddPolicy("default", builder => builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()));
// 数据层:默认 SQL Server 主库,DatabaseProvider=MySql 可切换
services.AddDataLayer(Configuration);
// 健康探针:/health(免认证,路径不在 SSO 保护段内),数据库/Redis 各 2 秒超时
services.AddHealthChecks()
.AddCheck<HealthChecks.DbContextHealthCheck>("database", timeout: TimeSpan.FromSeconds(2))
.AddCheck<HealthChecks.RedisHealthCheck>("redis", timeout: TimeSpan.FromSeconds(2));
// 仓储与 Dapper 通道(对应 Java Repository 自动装配)
services.AddRepositories();
// 系统审计日志过滤器(全量拦截,TypeFilterAttribute 从 DI 解析依赖)
services.AddScoped<SysOperationLogFilter>();
// Redis 缓存(RedisUtil 单例,连接串取 Redis:ConnectionString)
services.AddRedisCache(Configuration);
// ===== 会话状态(Session)=====
// 对应 Java HttpSession / session.invalidate();旧 DocPrintCore 同款配置。
// 注意:YhAuth 的登录态走自定义短 Cookie(SessionTicketDataFormat,票据存进程内存),
// 这里的 ASP.NET Core Session 仅用于 Logout 时 HttpContext.Session.Clear() 释放会话级数据,
// 与认证 Cookie 相互独立(各自独立 Cookie)。
// 使用进程内分布式缓存(单实例);多实例部署请改用 Redis 支撑的 IDistributedCache。
services.AddDistributedMemoryCache();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(20);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
// 审计日志查询(sys_operation_log 分页搜索,LogServiceStub 已被真实实现替换)
services.AddScoped<ILogService, LogService>();
// ===== DocPrint 业务服务(对应旧 BestSchool.Service / BestSchool.DocPrint) =====
services.AddScoped<IDictionaryService, DictionaryService>();
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();
}
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();
// CORS 中间件必须位于 UseRouting 之后、UseEndpoints 之前(endpoint-aware 模式):
// 否则带 [EnableCors] 元数据的控制器端点执行时抛 InvalidOperationException
// "contains CORS metadata, but a middleware was not found that supports CORS"(曾导致 GetUserInfo code6)。
// 对应 Java @CrossOrigin(originPatterns={"*"}, allowCredentials="true")
app.UseCors("default");
// 启用 ASP.NET Core Session(需在 UseEndpoints 之前、且在使用 Session 的组件之前注册)。
// 仅 Logout 真正读写;其余请求不触碰 Session,不会额外下发 .AspNetCore.Session Cookie。
app.UseSession();
app.UseMiddleware<ApiAccessLogMiddleware>(); // 0. 接口访问日志(唯一日志出口)
app.UseMiddleware<GlobalExceptionMiddleware>(); // 1. 异常兜底(异常摘要并入接口日志)
app.UseAuthentication(); // 2. CAS ticket 校验 + Cookie 恢复(Yh.Platform.Auth)
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();
// 免认证健康探针:健康 200 / 不健康 503(容器与负载均衡用)
endpoints.MapHealthChecks("/health");
// 根路径登录状态页:SSO 开启时未登录会先被中间件 302 到 CAS;登录后在此显示当前用户。
endpoints.MapGet("/", async context =>
{
var user = context.User;
if (user?.Identity?.IsAuthenticated != true)
{
throw new DException("未登录(SSO 未开启或 Cookie 失效)");
}
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
{
// 日志失败不影响请求
}
}
}
}