395 lines
20 KiB
C#
395 lines
20 KiB
C#
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
|
||
{
|
||
// 日志失败不影响请求
|
||
}
|
||
}
|
||
}
|
||
}
|