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(); }) .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()); // 对象映射:对应 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("database", timeout: TimeSpan.FromSeconds(2)) .AddCheck("redis", timeout: TimeSpan.FromSeconds(2)); // 仓储与 Dapper 通道(对应 Java Repository 自动装配) services.AddRepositories(); // 系统审计日志过滤器(全量拦截,TypeFilterAttribute 从 DI 解析依赖) services.AddScoped(); // 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(); // ===== DocPrint 业务服务(对应旧 BestSchool.Service / BestSchool.DocPrint) ===== services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); // ===== 基础平台(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() .Bind(Configuration); services.AddOptions() .Bind(Configuration.GetSection("Redis")); services.AddOptions() .Bind(Configuration.GetSection("Swagger")); services.AddOptions() .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 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(); // 0. 接口访问日志(唯一日志出口) app.UseMiddleware(); // 1. 异常兜底(异常摘要并入接口日志) app.UseAuthentication(); // 2. CAS ticket 校验 + Cookie 恢复(Yh.Platform.Auth) app.UseMiddleware(); // 3. SSO 路径保护(未认证 → 302 CAS;认证通过后才有用户身份) app.UseYhUserContext(); // 4. 新框架用户上下文:BoaUserContext + UserContextHolder(LoginUser) // 5. 应用间签名(oapi 渠道)。必须排在 UseYhUserContext 之后: // UserContextMiddleware 的 finally 会无条件 Clear UserContextHolder, // 若 AppAuth 在其之前写入 LoginUser{AppKey} 会被清空,业务层读不到应用身份。 app.UseMiddleware(); 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( "" + "

YhCore SSO 登录成功

" + $"

用户:{System.Net.WebUtility.HtmlEncode(name)}

" + $"

用户Id:{System.Net.WebUtility.HtmlEncode(id)}

" + $"

学校:{System.Net.WebUtility.HtmlEncode(school)}

" + "

打开接口文档 | 退出登录

" + ""); }); }); // 数据库自动补表:实体模型里有而数据库没有的表,启动时自动创建。 // 开关 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()); 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)"); } } } } /// /// 兼容适配:把新框架 的当前用户转写为旧 /// UserContextHolder(YhCore.Common.UserContext),保证 LogController / /// SysOperationLogFilter / ApiAccessLogMiddleware 等既有业务代码零改动继续工作。 /// SchSerID(学校 UUID)通过教务 SDK SchInfo.GetSchFirstAsync 反查填充,进程内缓存避免每请求调用。 /// private static async System.Threading.Tasks.Task LegacyUserContextAdapter(HttpContext context, Func 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; } } /// 学校 UUID 反查缓存(SchoolId → (值, 缓存时间戳))。成功永久缓存;失败负缓存 60s 后重试。 private static readonly System.Collections.Concurrent.ConcurrentDictionary SchSerIdCache = new System.Collections.Concurrent.ConcurrentDictionary(); /// 失败负缓存有效期(毫秒):教务服务不可达时避免每请求触发,又能在服务恢复后自动重试。 private const long SchSerIdNegativeCacheMs = 60_000; /// /// 通过教务 SDK 反查学校 UUID(SchInfoVo.SchSerId);失败或未命中返回 null。 /// 注意:SDK HttpClient 配置 ConnectTimeout=1500ms,教务地址不可达时(如本地联调 BaseHost=localhost:8086 /// 未起服务)会抛 TaskCanceledException——属预期路径,捕获后走负缓存,不阻断请求。 /// private static async System.Threading.Tasks.Task 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; } /// 反查失败诊断日志(Debug 级,避免干扰正常请求日志)。 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 { // 日志失败不影响请求 } } } }