using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using System.Text.Unicode; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Rmsys.Api.Endpoints; using Rmsys.Api.Compat; using Rmsys.Infrastructure; using Rmsys.Infrastructure.Licensing; // 内容根目录:作为 Windows 服务启动时,当前目录是 System32,必须显式指向程序所在目录, // 否则找不到 wwwroot(页面 404)与 App_Data(上传/数据包会写错地方)。 // 开发态(dotnet run)下 exe 目录没有 wwwroot,回退到当前目录,行为不变。 var exeDir = AppContext.BaseDirectory; var contentRoot = File.Exists(Path.Combine(exeDir, "wwwroot", "admin.html")) ? exeDir : Directory.GetCurrentDirectory(); var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot }); // 连接串优先级:环境变量 RMSYS_MYSQL(一键安装写这里,便于不改文件)> appsettings.json > 内置默认 var connStr = Environment.GetEnvironmentVariable("RMSYS_MYSQL") ?? builder.Configuration.GetConnectionString("MySql") ?? DbConfig.DefaultMySql; // ---------- 登录鉴权(JWT)---------- // 签名密钥不写在 appsettings.json 里:优先从数据库 sysconfigs 读取, // 首次启动自动生成 48 字节随机密钥并写库。库不可用时才回退配置文件。 var jwtKey = SysConfigStore.GetOrCreate(connStr, "JwtKey", SysConfigStore.NewSecret, "JWT 签名密钥(首次启动自动生成,勿外泄)") ?? builder.Configuration["Rmsys:JwtKey"] ?? "RMSYS-FALLBACK-KEY-db-unreachable-change-me!!"; var jwtIssuer = builder.Configuration["Rmsys:JwtIssuer"] ?? "rmsys"; // 把解析出的密钥回写进配置:验证端(AddJwtBearer)与签发端(AuthEndpoints)必须用同一把, // 否则会出现「校验用库里的密钥、签发用默认值」→ 登录直接 500。 builder.Configuration["Rmsys:JwtKey"] = jwtKey; // ---------- 授权服务(许可证 + 机器码 + 时间锚点)---------- // 延迟创建:命令行模式(--machine-code 等)不需要连库时不会触发。 var licenseDataDir = Path.Combine(contentRoot, builder.Configuration["Rmsys:DataDir"] ?? "App_Data"); builder.Services.AddSingleton(sp => new LicenseService( licenseDataDir, key => SysConfigStore.Get(connStr, key), (key, value) => SysConfigStore.Set(connStr, key, value, "授权时间锚点(程序自动维护)"))); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtIssuer, ValidAudience = jwtIssuer, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), ClockSkew = TimeSpan.FromMinutes(2) }; // 令牌吊销校验:每次请求都回库比对「令牌版本号」。 // 改口令 / 管理员强制下线 / 账号停用 都会让版本号变化, // 从而让此前签发的令牌立即失效,而不是继续有效到 8 小时后。 o.Events = new JwtBearerEvents { /* iframe / 新窗口直接打开 PDF 时带不了 Authorization 头, 允许用 ?access_token=xxx 传令牌(仅内网预览用) */ OnMessageReceived = ctx => { // iframe / 新窗口打开时带不了 Authorization 头,改用 ?access_token= 传令牌。 // 只对「只读 GET」且路径在白名单内生效:写操作仍必须走 Authorization 头, // 避免令牌出现在日志 / 浏览器历史里被复用。 var q = ctx.Request.Query["access_token"]; if (!string.IsNullOrEmpty(q) && HttpMethods.IsGet(ctx.Request.Method)) { var path = ctx.HttpContext.Request.Path; if (path.StartsWithSegments("/api/admin/appointdocs") // 任免文件原件预览 || path.StartsWithSegments("/api/admin/materials") // 考察材料等原件预览/下载 || path.StartsWithSegments("/api/admin/packages") // 数据包文件下载 || path.StartsWithSegments("/api/admin/pad-preview")) // PAD 预览数据 { ctx.Token = q; } } return Task.CompletedTask; }, OnTokenValidated = async ctx => { var uidStr = ctx.Principal?.FindFirst("uid")?.Value; var tvStr = ctx.Principal?.FindFirst("tv")?.Value; if (!int.TryParse(uidStr, out var uid) || tvStr == null) { ctx.Fail("令牌信息不完整,请重新登录"); return; } var db = ctx.HttpContext.RequestServices.GetRequiredService(); var u = await db.SysUsers.AsNoTracking() .Where(x => x.Id == uid) .Select(x => new { x.TokenVersion, x.Enabled }) .FirstOrDefaultAsync(); if (u == null) { ctx.Fail("账号不存在"); return; } if (u.Enabled != 1) { ctx.Fail("账号已停用,请联系管理员"); return; } if (!string.Equals(u.TokenVersion.ToString(), tvStr, StringComparison.Ordinal)) { ctx.Fail("登录已失效(口令已变更或被强制下线),请重新登录"); return; } } }; }); builder.Services.AddAuthorization(); // ---------- 上传大小上限(单文件 50 MB,材料扫描件常常较大)---------- builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 50L * 1024 * 1024); builder.Services.Configure(o => { o.MultipartBodyLengthLimit = 50L * 1024 * 1024; }); // ---------- 数据库(MySQL 5.7,utf8mb4)---------- builder.Services.AddDbContext(opt => opt.UseMySql(connStr, DbConfig.ServerVersion)); builder.Services.AddMemoryCache(); // 图形验证码存内存,3 分钟过期 builder.Services.AddControllers(); // 中文不转义成 \uXXXX(JSON 依然合法,前端与调试都更直观) builder.Services.ConfigureHttpJson(o => { o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new() { Title = "干部任免上会系统 API", Version = "v1" })); // CORS:默认不允许任何跨域(同源部署即可)。需要跨域时在配置里显式列白名单: // Rmsys:CorsOrigins = "http://10.1.1.20:8080,http://localhost:5173" var corsOrigins = (builder.Configuration["Rmsys:CorsOrigins"] ?? "") .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); builder.Services.AddCors(o => o.AddDefaultPolicy(p => { if (corsOrigins.Length > 0) p.WithOrigins(corsOrigins).AllowAnyHeader().AllowAnyMethod(); // 白名单为空 → 不给任何来源授权(默认拒绝,而不是 AllowAnyOrigin) })); // Windows 服务托管:作为服务运行时接管生命周期(控制台/调试下是空操作) builder.Host.UseWindowsService(); var app = builder.Build(); // ---------- 启动清理:残留的明文临时数据包 ---------- // .plain.tmp 是导出过程中的未加密 SQLite,正常流程会在 finally 里清零后删除。 // 若启动时还在,说明上次异常退出(或被杀毒软件占用没删掉)—— // 明文包留在磁盘上等同于材料外泄,所以启动时一律清零删除。 try { var pkgDir = Path.Combine(app.Environment.ContentRootPath, "App_Data", "packages"); if (Directory.Exists(pkgDir)) { var cleaned = 0; foreach (var f in Directory.GetFiles(pkgDir, "*.plain.tmp")) { Rmsys.Infrastructure.PackageExporter.WipeAndDelete(f); cleaned++; } if (cleaned > 0) Console.WriteLine($"[启动清理] 已清除 {cleaned} 个残留明文临时包(*.plain.tmp)"); } } catch { /* 清理失败不影响启动 */ } // 统一异常出口:任何未捕获异常都返回 JSON(code + message + traceId), // 不把堆栈和内部细节吐给前端;排障靠 traceId 去日志里查。 app.UseExceptionHandler(errApp => errApp.Run(async ctx => { var ex = ctx.Features.Get()?.Error; ctx.Response.StatusCode = 500; ctx.Response.ContentType = "application/json; charset=utf-8"; await ctx.Response.WriteAsJsonAsync(new { code = "INTERNAL_ERROR", message = "服务端处理异常,请联系管理员", errorType = ex?.GetType().Name, traceId = ctx.TraceIdentifier }); })); // Swagger 默认关闭:接口结构本身也属敏感信息(暴露全部端点与字段)。 // 需要排障时临时在配置里设置 Rmsys:EnableSwagger=true 再重启。 if (app.Configuration.GetValue("Rmsys:EnableSwagger")) { app.UseSwagger(); app.UseSwaggerUI(); } // 请求日志(落文件,不引第三方库):排障靠它,出了事也靠它还原访问轨迹。 // 只记方法/路径/状态码/耗时/操作人/IP,不记请求体(避免把材料内容写进日志)。 app.Use(async (ctx, next) => { var sw = System.Diagnostics.Stopwatch.StartNew(); try { await next(); } finally { sw.Stop(); try { var logDir = Path.Combine(app.Environment.ContentRootPath, "App_Data", "logs"); Directory.CreateDirectory(logDir); var line = string.Concat( DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), "\t", ctx.Request.Method, "\t", ctx.Request.Path, ctx.Request.QueryString.Value, "\t", ctx.Response.StatusCode, "\t", sw.ElapsedMilliseconds, "ms\t", ctx.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "-", "\t", ctx.Connection.RemoteIpAddress?.ToString() ?? "-", "\n"); await File.AppendAllTextAsync( Path.Combine(logDir, DateTime.Now.ToString("yyyyMMdd") + ".log"), line); } catch { /* 日志失败绝不影响业务 */ } } }); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); // ============ 授权守卫(License guard)============ // 三态:未激活(只有激活页可用)/ 正常 / 只读(禁止写,可查看导出)。 // 只做「拦」,不改任何业务逻辑;判定结果缓存在 LicenseService 里, // 每 60 秒最多重新判定一次(避免每个请求都读文件+注册表+数据库)。 app.Use(async (ctx, next) => { var lic = ctx.RequestServices.GetRequiredService(); // 正常态下最多 60 秒重新判定一次即可; // 未激活/只读态则每次请求都重新判定 —— 这样用命令行导入或续期后, // 客户刷新页面立刻就能看到生效,不用等 60 秒缓存过期。 var needRecheck = true; if (lic.Status.State == LicenseState.Active && (DateTime.UtcNow - lic.LastRefreshUtc).TotalSeconds <= 60) { needRecheck = false; } if (needRecheck) lic.Refresh(); var st = lic.Status; if (st.State == LicenseState.Active) { await next(); return; } var path = ctx.Request.Path.Value ?? ""; var isApi = path.StartsWith("/api", StringComparison.OrdinalIgnoreCase); var isWrite = ctx.Request.Method is "POST" or "PUT" or "PATCH" or "DELETE"; // ---- 未激活:页面能打开(显示激活页),业务接口一律 403 ---- if (st.State == LicenseState.Inactive) { if (!isApi || IsLicenseExempt(path)) { await next(); return; } ctx.Response.StatusCode = 403; await ctx.Response.WriteAsJsonAsync(new { ok = false, code = "LICENSE_INACTIVE", message = string.IsNullOrWhiteSpace(st.Reason) ? "系统尚未激活,请导入许可证后使用。" : st.Reason }); return; } // ---- 只读:放行查看与导出(GET/HEAD),拦掉一切新增与修改 ---- if (st.State == LicenseState.ReadOnly && isWrite && isApi && !IsReadOnlyAllowed(path)) { ctx.Response.StatusCode = 403; await ctx.Response.WriteAsJsonAsync(new { ok = false, code = "LICENSE_READONLY", message = "授权已于 " + (st.ExpireAt?.ToLocalTime().ToString("yyyy-MM-dd") ?? "到期日") + " 到期,系统处于只读状态:可以查看与导出,不能新增或修改。请联系服务方续期。" }); return; } await next(); }); // 未激活时仍需放行:登录、验证码、探活、授权相关接口 static bool IsLicenseExempt(string path) => path.StartsWith("/api/auth/", StringComparison.OrdinalIgnoreCase) || path.Equals("/api/ping", StringComparison.OrdinalIgnoreCase) || path.StartsWith("/api/license/", StringComparison.OrdinalIgnoreCase); // 只读态放行:登录、改自己口令、导入新许可证 static bool IsReadOnlyAllowed(string path) => path.StartsWith("/api/auth/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("/api/license/", StringComparison.OrdinalIgnoreCase); // 只读账号(DataScope 含「只读」)禁止任何写操作; // 放在授权之后,此时 ctx.User 已完成认证。 app.Use(async (ctx, next) => { var method = ctx.Request.Method; var isWrite = method is "POST" or "PUT" or "PATCH" or "DELETE"; if (isWrite && ctx.Request.Path.StartsWithSegments("/api") && !ctx.Request.Path.StartsWithSegments("/api/auth") && ctx.User.Identity?.IsAuthenticated == true) { var db = ctx.RequestServices.GetRequiredService(); var acc = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var scopeRaw = await db.SysUsers.AsNoTracking() .Where(x => x.Account == acc).Select(x => x.DataScope).FirstOrDefaultAsync(); if (scopeRaw != null && scopeRaw.Contains("只读", StringComparison.Ordinal)) { ctx.Response.StatusCode = 403; await ctx.Response.WriteAsJsonAsync(new { ok = false, message = "当前账号为「只读」权限,不能执行修改操作" }); return; } } await next(); }); app.UseDefaultFiles(); app.UseStaticFiles(); app.MapControllers(); app.MapAuthEndpoints(); app.MapLicenseEndpoints(); app.MapPadPreview(); app.MapAdminEndpoints(); app.MapWriteEndpoints(); app.MapMaterialEndpoints(); app.MapLrmxEndpoints(); app.MapRecycleEndpoints(); app.MapUserEndpoints(); app.MapCaptchaEndpoints(); app.MapDocMatchEndpoints(); // ---------- 探活(供负载均衡/监控用,不含任何业务数据,故免登录)---------- app.MapGet("/api/ping", () => Results.Ok(new { ok = true, ts = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") })); // ---------- 健康检查:真连一次数据库,并核对中文不乱码 ---------- // 含库结构、数据量与样例姓名,属敏感信息 —— 必须登录后调用 app.MapGet("/api/health", async (RmsysDbContext db) => { try { var c = db.Database.GetDbConnection(); await c.OpenAsync(); using var cmd = c.CreateCommand(); // 注意:不能用 @@character_set_database —— 会话未选中默认库时它返回服务端默认值(latin1), // 会误报。直接查 information_schema 才是库的真实定义。 cmd.CommandText = "select (select version()) as v, " + "(select default_character_set_name from information_schema.schemata where schema_name = database()) as cs, " + "(select default_collation_name from information_schema.schemata where schema_name = database()) as col, " + "(select count(*) from information_schema.tables where table_schema = database() and table_collation not like 'utf8mb4%') as bad"; using var rd = await cmd.ExecuteReaderAsync(); await rd.ReadAsync(); var ver = rd.GetString(0); var cs = rd.IsDBNull(1) ? "?" : rd.GetString(1); var col = rd.IsDBNull(2) ? "?" : rd.GetString(2); var badTables = Convert.ToInt32(rd.GetValue(3)); await c.CloseAsync(); var depts = await db.Depts.CountAsync(); var meetings = await db.Meetings.CountAsync(x => x.IsDeleted == 0); var cadres = await db.Cadres.CountAsync(); var sample = await db.Cadres.OrderBy(x => x.Id).Select(x => x.Name).FirstOrDefaultAsync(); var zhOk = string.IsNullOrEmpty(sample) || System.Text.RegularExpressions.Regex.IsMatch(sample, @"^[\u4e00-\u9fa5]{2,4}$"); return Results.Ok(new { ok = true, server = ver, charset = cs, collation = col, nonUtf8mb4Tables = badTables, counts = new { depts, meetings, cadres }, chineseOk = zhOk, sampleName = sample }); } catch (Exception ex) { return Results.Ok(new { ok = false, error = ex.GetType().Name, message = ex.Message }); } }).RequireAuthorization(); // ---------- 基础只读接口(全部需登录;数据均属涉密材料)---------- app.MapGet("/api/depts", async (RmsysDbContext db) => Results.Ok(await db.Depts.Where(x => x.Enabled == 1).OrderBy(x => x.Sort) .Select(x => new { x.Id, x.Code, x.Name, x.Leader, x.Duty }).ToListAsync())) .RequireAuthorization(); app.MapGet("/api/meetings", async (RmsysDbContext db) => Results.Ok(await db.Meetings.Where(x => x.IsDeleted == 0).OrderByDescending(x => x.Id) .Select(x => new { x.Id, x.Type, x.SessionNo, x.Name, x.HeldAt, x.Place, x.Host, x.Secret, x.SecretStars, x.PwdLevel, x.Status, CadreCount = x.Cadres.Count }).ToListAsync())) .RequireAuthorization(); app.MapGet("/api/meetings/{id:int}/cadres", async (int id, ClaimsPrincipal p, RmsysDbContext db) => { // 会议不存在 → 404(此前返回 200 + 空数组,调用方无法区分「没数据」和「出错」) var exists = await db.Meetings.AnyAsync(x => x.Id == id && x.IsDeleted == 0); if (!exists) return Results.Json(new { code = "MEETING_NOT_FOUND", message = "会议不存在" }, statusCode: 404); var q = db.MeetingCadres.Where(mc => mc.MeetingId == id); // 本处室账号只看本处室干部 var sc = await Rmsys.Api.Auth.ScopeGuard.ResolveAsync(p, db); if (!sc.IsAll) q = q.Where(mc => mc.Cadre!.DeptId == sc.DeptId); var list = await q .OrderBy(mc => mc.SortNo) .Select(mc => new { mc.SortNo, mc.IssueNo, mc.Result, mc.Vote, mc.Cadre!.Id, mc.Cadre.Name, mc.Cadre.Sex, mc.Cadre.Birth, mc.Cadre.Nation, mc.Cadre.NativePlace, mc.Cadre.CurrentPost, mc.Cadre.ProposedPost, mc.Cadre.DismissPost }).ToListAsync(); return Results.Ok(list); }).RequireAuthorization(); // ============ 部署自检:服务端版本 + 关键接口是否齐全 ============ // 为什么要有:.NET 6 曾因「Minimal API 不支持 IFormFile 参数绑定」把上传接口整体跳过, // 表现就是前端报「接口不存在」,而服务本身看起来一切正常。这类问题光看日志发现不了, // 所以提供一个能一眼看出版本与接口齐不齐的入口。 app.MapGet("/api/version", (EndpointDataSource eds) => { var routes = eds.Endpoints.OfType() .Select(e => e.RoutePattern.RawText ?? string.Empty) .Where(r => r.StartsWith("/api/", StringComparison.Ordinal)) .Distinct() .OrderBy(r => r, StringComparer.Ordinal) .ToList(); // 上传类接口最容易因框架差异被跳过,逐个点名核对 var mustHave = new[] { "/api/admin/materials/upload", "/api/admin/appointdocs/upload", "/api/admin/receipts/import", "/api/admin/lrmx/import" }; var missing = mustHave.Where(m => !routes.Contains(m)).ToList(); var asm = typeof(Program).Assembly; string buildTime; try { buildTime = System.IO.File.GetLastWriteTime(asm.Location).ToString("yyyy-MM-dd HH:mm"); } catch { buildTime = "-"; } return Results.Ok(new { ok = missing.Count == 0, product = "任免上会系统", framework = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, version = asm.GetName().Version?.ToString() ?? "-", buildTime, endpointCount = routes.Count, missingEndpoints = missing, hint = missing.Count == 0 ? "服务正常" : "缺少接口:部署的服务端与后台页面版本不一致(多为覆盖安装时旧程序未更新)。请重新运行最新安装包并确认服务已重启。" }); }); // 未匹配到的 /api 路径统一返回 JSON 404(而不是空的 404 或 HTML), // 调用方可以按 code 判断,不用猜。 app.MapFallback("/api/{**rest}", (HttpContext ctx) => Results.Json(new { code = "NOT_FOUND", message = "接口不存在:" + ctx.Request.Path }, statusCode: 404)); // ============ 授权命令行(现场排查 / 界面打不开时的兜底)============ // 用法(服务器上管理员命令行): // Rmsys.Api.exe --machine-code 打印本机机器码 // Rmsys.Api.exe --license-status 打印授权状态 // Rmsys.Api.exe --import-license D:\license.lic 导入许可证 if (args.Length > 0) { var cmd = args[0].ToLowerInvariant(); if (cmd is "--machine-code" or "--license-status" or "--import-license") { var licSvc = app.Services.GetRequiredService(); if (cmd == "--machine-code") { var mc = MachineCode.Collect(); Console.WriteLine("本机机器码:" + mc.Code); foreach (var kv in mc.Parts) Console.WriteLine(" " + kv.Key + " = " + (string.IsNullOrEmpty(kv.Value) ? "(取不到)" : kv.Value)); if (mc.Degraded) Console.WriteLine(" 注意:有采集项取不到(精简系统或 WMI 被禁用),已按可用项计算。"); return 0; } if (cmd == "--license-status") { var s = licSvc.Status; Console.WriteLine("授权状态:" + s.StateText); Console.WriteLine(" 机器码 :" + s.MachineCode); if (s.ExpireAt != null) { Console.WriteLine(" 客户  :" + s.Customer); Console.WriteLine(" 许可证号:" + s.Serial); Console.WriteLine(" 有效期 :" + s.NotBefore?.ToLocalTime().ToString("yyyy-MM-dd") + " ~ " + s.ExpireAt?.ToLocalTime().ToString("yyyy-MM-dd") + "(" + s.PeriodText + ")"); Console.WriteLine(" 剩余天数:" + s.DaysLeft); Console.WriteLine(" 判定时间:" + s.NowUsed.ToString("yyyy-MM-dd HH:mm") + "(按历史最大时间)"); } if (!string.IsNullOrWhiteSpace(s.Reason)) Console.WriteLine(" 说明  :" + s.Reason); if (s.ClockAlert) Console.WriteLine(" 时间告警:" + s.ClockAlertText); if (s.AnchorAlert) Console.WriteLine(" 锚点告警:检测到时间锚点不一致,已按最大历史时间判定。"); return 0; } if (args.Length < 2 || !File.Exists(args[1])) { Console.Error.WriteLine("用法:Rmsys.Api.exe --import-license "); return 1; } var content = File.ReadAllText(args[1]); var r = licSvc.Import(content); Console.WriteLine((r.Ok ? "[成功] " : "[失败] ") + r.Message); if (r.Ok) { var s2 = licSvc.Status; Console.WriteLine(" 当前状态:" + s2.StateText + (s2.ExpireAt != null ? ",到期 " + s2.ExpireAt?.ToLocalTime().ToString("yyyy-MM-dd") : "")); } return r.Ok ? 0 : 1; } } // ============ 管理员口令重置(忘记口令时的唯一补救通道)============ // 用法(在服务器上,管理员身份打开命令提示符): // cd /d C:\Rmsys\app // Rmsys.Api.exe --reset-admin // 会生成新口令、更新 admin 账号、写回 App_Data\首次登录口令.txt,然后退出(不启动网站)。 // 存在的意义:口令是 PBKDF2 加盐哈希,库里无法还原;如果 txt 丢了、口令又忘了, // 没有这个入口就只能重装,客户数据会全丢。 if (args.Any(a => string.Equals(a, "--reset-admin", StringComparison.OrdinalIgnoreCase))) { using (var rscope = app.Services.CreateScope()) { var rdb = rscope.ServiceProvider.GetRequiredService(); var adm = await rdb.SysUsers.FirstOrDefaultAsync(x => x.Account == "admin") ?? await rdb.SysUsers.OrderBy(x => x.Id).FirstOrDefaultAsync(); if (adm == null) { Console.WriteLine("[重置] 库里还没有 admin 账号(数据库可能尚未初始化)。"); Console.WriteLine(" 请先启动一次服务让它自动建库建账号,再执行本命令。"); return 1; } const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; var rrnd = new Random(); var newPwd = "Rmsys@"; for (var i = 0; i < 8; i++) newPwd += chars[rrnd.Next(chars.Length)]; var (nhash, nsalt) = Rmsys.Api.Auth.PasswordHasher.Hash(newPwd); adm.PwdHash = nhash; adm.PwdSalt = nsalt; adm.PwdUpdatedAt = DateTime.Now; adm.Enabled = 1; adm.FailCount = 0; adm.LockedUntil = null; adm.TokenVersion = adm.TokenVersion + 1; // 旧令牌全部失效 await rdb.SaveChangesAsync(); var rdataDir = Path.Combine(app.Environment.ContentRootPath, app.Configuration["Rmsys:DataDir"] ?? "App_Data"); Directory.CreateDirectory(rdataDir); var rtxt = "默认管理员\r\n账号:" + adm.Account + "\r\n初始口令:" + newPwd + "\r\n\r\n" + "(本文件由「重置管理员口令」生成,登录后请立即修改口令并删除本文件)\r\n" + "生成时间:" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\r\n"; try { await File.WriteAllTextAsync(Path.Combine(rdataDir, "首次登录口令.txt"), rtxt, new System.Text.UTF8Encoding(true)); } catch { } Console.WriteLine(); Console.WriteLine("=================================================="); Console.WriteLine(" 管理员口令已重置"); Console.WriteLine(" 账号:" + adm.Account); Console.WriteLine(" 新口令:" + newPwd); Console.WriteLine(" 已写入:" + Path.Combine(rdataDir, "首次登录口令.txt")); Console.WriteLine("=================================================="); Console.WriteLine(); return 0; } } // ============ 一键安装自举(首次启动)============ // 建表 + 建参考数据 + 建默认管理员,全程幂等,重复启动不会重复插入 // 说明:Windows 服务的接管在 builder 阶段完成(见上方 UseWindowsService 调用) // 2) 首次启动:建表 + 建参考数据 + 建默认管理员,全程幂等,重复启动不会重复插入 using (var boot = app.Services.CreateScope()) { var bdb = boot.ServiceProvider.GetRequiredService(); var log = app.Logger; if (app.Configuration.GetValue("Rmsys:AutoMigrate", true)) { try { await bdb.Database.MigrateAsync(); log.LogInformation("[初始化] 数据库结构已就绪"); } catch (Exception ex) { log.LogError(ex, "[初始化] 建表失败:{Msg}", ex.Message); } } // 参考数据:角色 + 处室(表为空时写入,已有数据不动) if (!await bdb.SysRoles.AnyAsync()) { bdb.SysRoles.AddRange( new Rmsys.Domain.Entities.SysRole { Code = "admin", Name = "系统管理员", Perms = "[\"*\"]", Sort = 1 }, new Rmsys.Domain.Entities.SysRole { Code = "cadre_chief", Name = "干部科科长", Perms = "[]", Sort = 2 }, new Rmsys.Domain.Entities.SysRole { Code = "clerk", Name = "处室业务员", Perms = "[]", Sort = 3 }, new Rmsys.Domain.Entities.SysRole { Code = "minister", Name = "部领导", Perms = "[]", Sort = 4 }, new Rmsys.Domain.Entities.SysRole { Code = "meeting_support", Name = "会议保障", Perms = "[]", Sort = 5 }, new Rmsys.Domain.Entities.SysRole { Code = "auditor", Name = "审计只读", Perms = "[]", Sort = 6 }); await bdb.SaveChangesAsync(); log.LogInformation("[初始化] 已写入默认角色 6 个"); } if (!await bdb.Depts.AnyAsync()) { bdb.Depts.AddRange( new Rmsys.Domain.Entities.Dept { Code = "yichu", Name = "干部一处", Duty = "党政领导干部", Enabled = 1, Sort = 1 }, new Rmsys.Domain.Entities.Dept { Code = "erchu", Name = "干部二处", Duty = "企业事业单位干部", Enabled = 1, Sort = 2 }, new Rmsys.Domain.Entities.Dept { Code = "rsc", Name = "人事处", Duty = "机关人事与公务员管理", Enabled = 1, Sort = 3 }); await bdb.SaveChangesAsync(); log.LogInformation("[初始化] 已写入默认处室 3 个"); } // 默认管理员:只在没有任何账号时创建;口令取配置,未配置则随机生成并写到 App_Data if (!await bdb.SysUsers.AnyAsync()) { var pwd = app.Configuration["Rmsys:AdminInitPassword"]; var generated = string.IsNullOrWhiteSpace(pwd); if (generated) { const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; var rnd = new Random(); pwd = "Rmsys@"; for (var i = 0; i < 8; i++) pwd += chars[rnd.Next(chars.Length)]; } var (hash, salt) = Rmsys.Api.Auth.PasswordHasher.Hash(pwd!); var rootRole = await bdb.SysRoles.FirstOrDefaultAsync(r => r.Code == "admin"); var adm = new Rmsys.Domain.Entities.SysUser { Account = "admin", RealName = "系统管理员", PwdHash = hash, PwdSalt = salt, PwdUpdatedAt = DateTime.Now, Enabled = 1, DataScope = "全部处室", CreatedAt = DateTime.Now }; bdb.SysUsers.Add(adm); await bdb.SaveChangesAsync(); if (rootRole != null) { bdb.SysUserRoles.Add(new Rmsys.Domain.Entities.SysUserRole { UserId = adm.Id, RoleId = rootRole.Id }); await bdb.SaveChangesAsync(); } try { // 与上传/数据包同目录(内容根,而非 bin),发布后两者一致 var dataDir = Path.Combine(app.Environment.ContentRootPath, app.Configuration["Rmsys:DataDir"] ?? "App_Data"); Directory.CreateDirectory(dataDir); await File.WriteAllTextAsync(Path.Combine(dataDir, "首次登录口令.txt"), "默认管理员\r\n账号:admin\r\n初始口令:" + pwd + "\r\n\r\n" + "请登录后立即在「系统与审计 → 账户」中修改口令,然后删除本文件。\r\n" + "生成时间:" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\r\n", new System.Text.UTF8Encoding(true)); // 再追加一份到 App_Data\logs\初始化日志.txt。 // 原因:服务方式运行时控制台输出看不到;万一客户按上面那句提示把 // 「首次登录口令.txt」删了又忘了口令,这里还能找回。 try { var logsDir = Path.Combine(dataDir, "logs"); Directory.CreateDirectory(logsDir); await File.AppendAllTextAsync(Path.Combine(logsDir, "初始化日志.txt"), DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\t创建管理员 " + adm.Account + "\t初始口令:" + pwd + "\r\n", new System.Text.UTF8Encoding(true)); } catch { } } catch { /* 写文件失败不影响启动,口令已打到日志 */ } log.LogInformation("[初始化] 已创建默认管理员 admin,初始口令:{Pwd}", pwd); } } app.Run(); return 0;