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
+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
{
// 日志失败不影响请求
}
}
}
}