简介一份面向C#入门与桌面开发练习者的登录器源码围绕Windows Forms界面搭建、账号密码输入校验与登录流程实现展开适合想通过完整小项目理解C#语法、事件处理与配置读取的读者。压缩包共66个文件、约124KB以14个cs源文件和10组resx/resources界面资源为主另含2个sln与2个csproj工程文件、exe可执行程序、config.xml与data.xml配置数据、INI与readXML等辅助类以及图标、清单和编译中间文件可直接用Visual Studio打开运行与调试。已有438人学习下载。源码结构较完整涵盖多窗体Form1至Form5、XML配置读取、登录验证与错误提示等环节读者可据此梳理WinForms项目的分层方式、控件事件绑定与资源文件组织并在此基础上改写界面或替换验证逻辑作为课程设计、入门练手与窗体开发查漏补缺的参考素材。1. 一个登录窗口背后的工程问题我为什么把登录器当成状态机来写前段时间给内部工具做分发版本一更新就要所有人重新填账号怨气全撒在运维身上。后来索性自己写了个带界面的登录器把账号验证、凭据保存、在线保活、版本检查都收在一个 exe 里。这份 C# 源码就是我最后定型的版本WinForms 界面 三层结构 一套能自洽的登录状态流转你可以直接拿去替换自己的接口地址也可以拆开只看某个模块。它适合会一点 C#、想给自己的工具或服务加一个启动登录窗口的开发者不适合拿来做复杂的企业级身份中台。下面按我实际拆代码的顺序往下走。2. 工程结构拆解UI、Core、Infrastructure 三层怎么切2.1 目录结构把界面、业务、基础设施分开拿到这样一份登录器源码最怕的是所有逻辑塞进LoginForm.cs一个文件按钮事件里直接拼 HTTP 请求、直接读注册表、直接弹 MessageBox。这种代码改一次需求就要重新捋一遍血泪经验。我一般会先按职责切成三层再动手写第一行好处是后面换 UI 框架时业务代码一行不动。LoginLauncher/ ├── LoginLauncher.sln ├── src/ │ ├── LoginLauncher.UI/ # 界面层只负责展示和收集输入 │ │ ├── Forms/ │ │ │ ├── LoginForm.cs │ │ │ ├── MainForm.cs │ │ │ └── RegisterForm.cs │ │ ├── Program.cs │ │ └── LoginLauncher.UI.csproj │ ├── LoginLauncher.Core/ # 业务层不引用任何 UI 类型 │ │ ├── Auth/LoginService.cs │ │ ├── State/LoginStateMachine.cs │ │ ├── Models/LoginModels.cs │ │ └── LoginLauncher.Core.csproj │ └── LoginLauncher.Infrastructure/ # 基础设施网络、配置、加密、日志 │ ├── Http/ApiClient.cs │ ├── Config/AppConfig.cs │ ├── Security/CredentialProtector.cs │ └── LoginLauncher.Infrastructure.csproj ├── tools/MockServer/ # 本地调试用的假接口 ├── tests/LoginLauncher.Core.Tests/ └── build/publish.ps1目录职责关键文件依赖方向UI窗体、控件、事件绑定LoginForm.cs、Program.cs依赖 Core 与 InfrastructureCore登录流程、状态机、校验规则LoginService.cs、LoginStateMachine.cs只依赖 ModelInfrastructureHTTP、配置、加密、日志ApiClient.cs、AppConfig.cs向下依赖不反向引用这个依赖方向是单向的UI 可以引用 CoreCore 不能引用 UI。原因是登录逻辑经常要复用到命令行工具、托盘小程序甚至单元测试里一旦 Core 里出现MessageBox.Show这些场景就全废了。常见做法是让 Core 通过事件或者返回值上报结果由 UI 决定怎么提示用户。2.2 技术选型为什么是 WinForms 而不是 WPF登录窗口是典型的「几个输入框 两个按钮 一个进度条」WinForms 的控件拖拽成本最低生成的 exe 也小对机器没有额外运行时要求。WPF 的数据绑定和样式化确实更舒服但为了一个登录窗口引入 XAML 和 MVVM维护成本反而上去。我一般的判断标准是窗口数量少于五个、不需要复杂动画就 WinForms要做主题切换和复杂列表才换 WPF。依赖库上这份源码保持克制HTTP 通信内置HttpClient配合SocketsHttpHandler控制连接回收不引第三方。JSON 序列化System.Text.Json.NET 6 以后性能和源生成器都够用省一个 NuGet 包。本地凭据加密Windows 自带的 DPAPIProtectedData不需要自己管密钥。日志轻量的文本落盘即可登录器不需要结构化日志平台。提示如果你的目标框架还是 .NET Framework 4.xSystem.Text.Json需要额外 NuGet 包PeriodicTimer也不可用得换成System.Threading.Timer。这两处是迁移时最容易编译不过的地方。2.3 配置与本地数据appsettings.json 加上用户目录配置不写死在代码里是登录器能不能给别人用的分水岭。接口地址、超时时间、心跳间隔这些东西一旦硬编码测试环境和生产环境就得编两遍。我把可变项全部放到 exe 同目录的appsettings.json用户级别的凭据则放到%AppData%下的子目录两者分开。// src/LoginLauncher.Infrastructure/Config/AppConfig.cs public sealed class AppConfig { public string ApiBaseUrl { get; set; } http://localhost:7043; public int TimeoutSeconds { get; set; } 15; // 单次请求超时 public int HeartbeatSeconds { get; set; } 30; // 心跳间隔 public bool RememberAccount { get; set; } true; // 是否记住上次账号 public string LogLevel { get; set; } Info; // 优先读 exe 同目录文件缺失或格式错误时回退到默认值避免启动即崩 public static AppConfig Load(string baseDir) { var path Path.Combine(baseDir, appsettings.json); if (!File.Exists(path)) return new AppConfig(); try { var json File.ReadAllText(path, Encoding.UTF8); return JsonSerializer.DeserializeAppConfig(json, new JsonSerializerOptions { PropertyNameCaseInsensitive true }) ?? new AppConfig(); } catch (JsonException) { return new AppConfig(); } } }Load方法接收一个基础目录而不是直接写Application.StartupPath是为了让单元测试能把配置指向临时目录这是分层之后顺手拿到的好处。PropertyNameCaseInsensitive true让 JSON 里写apiBaseUrl或ApiBaseUrl都能识别减少手工改配置时的玄学问题。反序列化失败直接吞掉异常返回默认值考虑的是登录器这种工具「宁可连不上也不能打不开」。2.4 启动入口单实例、全局异常、依赖装配Program.cs是很多人忽略的地方但登录器一旦允许开多个实例用户双击两次就会出现两个窗口同时登录、心跳互相顶掉的情况。启动入口我只做三件事顺序不能乱。// src/LoginLauncher.UI/Program.cs [STAThread] static void Main() { // 1) 单实例命名 Mutex第二个进程直接退出并激活已有窗口 using var mutex new Mutex(true, Global\\LoginLauncher.SingleInstance, out var isFirst); if (!isFirst) return; // 2) 全局异常兜底防止未捕获异常静默退出 Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); Application.ThreadException (_, e) ErrorReporter.Write(e.Exception); AppDomain.CurrentDomain.UnhandledException (_, e) ErrorReporter.Write(e.ExceptionObject as Exception); // 3) 装配依赖配置 → 客户端 → 服务 → 窗体 ApplicationConfiguration.Initialize(); var cfg AppConfig.Load(AppContext.BaseDirectory); using var api new ApiClient(cfg); using var login new LoginService(api, new LoginStateMachine()); Application.Run(new LoginForm(login, cfg)); }Global\前缀让 Mutex 跨会话生效多用户登录同一台机器时也能拦住。ErrorReporter.Write是我自己写的一个小类把异常堆栈落到日志目录并弹一个带路径的提示框比直接崩掉强得多。第三步的using顺序要留意ApiClient内部持有HttpClient窗体关闭后由using统一释放避免进程退出时连接还挂着。3. 核心链路拆解一次登录从点击按钮到进入主界面3.1 请求与响应模型用返回码而不是布尔值登录接口最容易写得含糊的地方是用bool表示成功失败。上线之后你会发现需要区分「密码错」「账号锁定」「必须升级客户端」「服务端维护」这些状态一个 bool 装不下最后变成在 message 字符串里做包含判断这是典型的后悔药场景。我一般会定义一个明确的返回码枚举。// src/LoginLauncher.Core/Models/LoginModels.cs public sealed record LoginRequest( string Account, string PasswordCipher, // 已加密的密码串绝不传明文 string ClientVersion, string DeviceId); public sealed record LoginResponse( int Code, string Message, string? Token, DateTimeOffset? ExpireAt, UserProfile? User); public sealed record UserProfile(string UserId, string NickName, string AvatarUrl); public enum LoginCode { Ok 0, BadCredential 1001, AccountLocked 1002, ClientTooOld 1003, ServerBusy 1005 }PasswordCipher字段名就明确表达了「这里是密文」防止后来的人顺手把明文塞进来。ExpireAt用DateTimeOffset而不是DateTime避免服务端和客户端时区不一致导致 token 提前失效——这种问题排查起来极其费劲日志上看时间是对的实际比对已经过期了。DeviceId留着做单点登录和风控取机器指纹的哈希即可不要上传原始硬件信息。3.2 网络层封装HttpClient 只 new 一次ApiClient是整份源码里被调用最频繁的类。这里有个经典翻车点每次请求都new HttpClient()短时间内大量连接处于 TIME_WAIT端口耗尽之后报SocketException。正确做法是复用实例同时用SocketsHttpHandler控制连接生命周期防止 DNS 变更后一直连旧 IP。// src/LoginLauncher.Infrastructure/Http/ApiClient.cs public sealed class ApiClient : IDisposable { private readonly HttpClient _http; public ApiClient(AppConfig cfg) { var handler new SocketsHttpHandler { PooledConnectionLifetime TimeSpan.FromMinutes(5), // 定期换连接兼容服务端扩缩容 AutomaticDecompression DecompressionMethods.All }; _http new HttpClient(handler) { BaseAddress new Uri(cfg.ApiBaseUrl), Timeout TimeSpan.FromSeconds(cfg.TimeoutSeconds) }; _http.DefaultRequestHeaders.Add(X-Client-Version, ClientInfo.Version); } public async TaskLoginResponse LoginAsync(LoginRequest req, CancellationToken ct) { using var resp await _http.PostAsJsonAsync(/api/auth/login, req, ct) .ConfigureAwait(false); var body await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) return new LoginResponse((int)resp.StatusCode, $HTTP {(int)resp.StatusCode}, null, null, null); return JsonSerializer.DeserializeLoginResponse(body, JsonOpts.Default) ?? new LoginResponse(-1, 响应解析失败, null, null, null); } public void Dispose() _http.Dispose(); }PooledConnectionLifetime设成 5 分钟是为了让客户端在服务端做灰度发布、切换节点后不会一直连旧连接。Timeout从配置读登录接口一般 10 到 15 秒足够设太长用户会以为程序卡死。每个await后面跟ConfigureAwait(false)因为ApiClient不碰 UI不需要回到 UI 上下文能少一次线程切换。HTTP 非 2xx 时不抛异常而是构造一个带状态码的响应让上层统一按Code分支处理避免异常穿透到 UI。3.3 密码处理加盐哈希之后再做一次非对称加密明文密码走网络是绝对不行的哪怕上了 HTTPS。常见做法分两步服务端存的是「盐 明文」的 SHA-256 哈希客户端传的是这个哈希再用服务端公钥加密后的密文。这样即使链路被抓包拿到的密文也没法直接重放——每次登录请求里的时间戳和随机数由服务端校验。// src/LoginLauncher.Infrastructure/Security/CredentialProtector.cs public static class CredentialProtector { // 第一步加盐哈希盐由服务端下发或按账号固定拼接 public static string HashPassword(string plain, string salt) { var bytes SHA256.HashData(Encoding.UTF8.GetBytes(salt plain)); return Convert.ToHexString(bytes).ToLowerInvariant(); } // 第二步用服务端公钥做 RSA-OAEP 加密 public static string EncryptForServer(string text, string publicKeyPem) { using var rsa RSA.Create(); rsa.ImportFromPem(publicKeyPem); var cipher rsa.Encrypt(Encoding.UTF8.GetBytes(text), RSAEncryptionPadding.OaepSHA256); return Convert.ToBase64String(cipher); } }HashPassword里盐拼在明文前面顺序要和服务端约定一致改一边就会全量登录失败。SHA-256 输出 32 字节转十六进制是 64 个字符正好能塞进 RSA-2048 的单块明文上限190 字节左右不用分段加密。公钥从配置或首次启动时下发的 PEM 读取私钥只在服务端。RSAEncryptionPadding.OaepSHA256不能用 PKCS1后者对抗选择密文攻击的能力弱。注意客户端里出现私钥、或者把「加密」写成 Base64是最常见的两类伪安全。Base64 是编码不是加密任何人 base64 -d 一下就还原了。3.4 登录状态机把界面上的各种禁用状态收敛成一个枚举登录窗口的按钮状态会随着流程变化点击登录后按钮变灰、输入框禁用、显示转圈失败后恢复成功后关闭窗口进主界面。如果全靠零散的button.Enabled false控制代码很快就会乱。我一般先画一张状态表然后写成一个显式的状态机所有界面更新都订阅它。// src/LoginLauncher.Core/State/LoginStateMachine.cs public enum LoginState { Idle, Authenticating, Online, Reconnecting, Offline } public sealed class LoginStateMachine { // 只允许表里列出的迁移其余一律拒绝 private static readonly DictionaryLoginState, LoginState[] Allowed new() { [LoginState.Idle] new[] { LoginState.Authenticating }, [LoginState.Authenticating] new[] { LoginState.Online, LoginState.Offline, LoginState.Idle }, [LoginState.Online] new[] { LoginState.Reconnecting, LoginState.Offline }, [LoginState.Reconnecting] new[] { LoginState.Online, LoginState.Offline }, [LoginState.Offline] new[] { LoginState.Authenticating, LoginState.Idle } }; public LoginState Current { get; private set; } LoginState.Idle; public event EventHandlerLoginState? StateChanged; public bool TryMove(LoginState next) { if (next Current) return true; if (!Allowed.TryGetValue(Current, out var targets) || !targets.Contains(next)) return false; Current next; StateChanged?.Invoke(this, next); return true; } }Allowed字典就是这张状态表的代码化表达新增状态时只改这一个地方。TryMove返回布尔值而不是抛异常因为心跳线程和 UI 线程可能同时尝试迁移冲突属于正常情况不应该让它变成异常噪音。UI 层订阅StateChanged之后在事件里用BeginInvoke切回 UI 线程更新控件——这个细节下面避坑章节会展开。3.5 心跳与掉线重连定时器不能写在 UI 线程里登录成功后要维持在线状态否则服务端无法判断用户是否还在线也没法做单点登录踢人。心跳用PeriodicTimer放在后台任务里跑和 UI 完全解耦。// src/LoginLauncher.Core/Auth/HeartbeatService.cs public sealed class HeartbeatService : IDisposable { private readonly ApiClient _api; private readonly LoginStateMachine _fsm; private readonly TimeSpan _interval; private CancellationTokenSource? _cts; public HeartbeatService(ApiClient api, LoginStateMachine fsm, int seconds) { _api api; _fsm fsm; _interval TimeSpan.FromSeconds(seconds); } public void Start(string token) { _cts new CancellationTokenSource(); _ LoopAsync(token, _cts.Token); // 后台跑不占用 UI 线程 } private async Task LoopAsync(string token, CancellationToken ct) { using var timer new PeriodicTimer(_interval); try { while (await timer.WaitForNextTickAsync(ct)) { try { var alive await _api.HeartbeatAsync(token, ct); _fsm.TryMove(alive ? LoginState.Online : LoginState.Reconnecting); } catch (OperationCanceledException) { throw; } catch { // 网络抖动不算失败先进入重连态下一轮再试 _fsm.TryMove(LoginState.Reconnecting); } } } catch (OperationCanceledException) { /* 正常退出 */ } finally { _fsm.TryMove(LoginState.Offline); } } public void Dispose() { _cts?.Cancel(); _cts?.Dispose(); } }PeriodicTimer相比Timer的好处是不会重入上一次心跳还没返回下一次不会叠加触发。_interval从配置读一般 30 秒太短会给服务端制造无谓压力太长则掉线感知迟钝。内层catch吞掉普通异常并切到Reconnecting是为了应对地铁、Wi-Fi 切换这类几秒钟的网络抖动连续失败若干次才由 UI 提示「已断开」。Dispose里取消令牌窗体关闭时调用避免后台任务在进程退出时还在写日志。4. 编译与运行从源码到能双击打开的 exe4.1 环境准备与第一次构建拿到源码先别急着 F5先确认 SDK 版本。目标框架写在 csproj 里用dotnet --list-sdks看一眼本机装了什么版本低于项目要求就先装 SDK。# 查看已安装的 SDK 与运行时 dotnet --list-sdks dotnet --list-runtimes # 还原依赖并编译在解决方案根目录执行 dotnet restore dotnet build -c Debug # 直接运行界面项目 dotnet run --project src/LoginLauncher.UIrestore会去 NuGet 拉依赖公司内网环境记得先配好镜像源否则会卡在这一步很久。build成功后产物在src/LoginLauncher.UI/bin/Debug/net8.0-windows/下。用dotnet run启动的好处是能看到控制台输出的异常信息比直接双击 exe 更容易定位启动阶段的问题。4.2 没有服务端接口怎么办起一个本地假接口源码里客户端是完整的但服务端接口地址是空的直接运行会连接失败。这时候不用等后端起一个最小假接口就能把流程跑通。我用 ASP.NET Core 的 minimal API 写了一个几十行的 mock放在tools/MockServer。// tools/MockServer/Program.cs var builder WebApplication.CreateBuilder(args); var app builder.Build(); app.MapPost(/api/auth/login, (LoginRequest req) { // 演示账号 test密码 test123盐固定为 s1仅用于本地联调 var expect Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(s1 test123))).ToLowerInvariant(); if (req.Account ! test) return Results.Json(new LoginResponse(1001, 账号不存在, null, null, null)); // 本地联调跳过 RSA 解密真实环境必须解开后比对哈希 return Results.Json(new LoginResponse( 0, ok, mock-token-123, DateTimeOffset.UtcNow.AddHours(2), new UserProfile(u_1, 测试用户, ))); }); app.MapPost(/api/auth/heartbeat, () Results.Json(new { alive true })); app.Run(http://localhost:7043); // 和 appsettings.json 里的 ApiBaseUrl 保持一致把appsettings.json的ApiBaseUrl指向http://localhost:7043先dotnet run --project tools/MockServer再启动客户端就能看到完整的登录成功流程。真实环境里PasswordCipher是 RSA 密文mock 里为了省事直接返回成功这一点在自己接后端时不要照抄。心跳接口也一并 mock否则登录成功后几秒钟就会掉到重连态。4.3 发布打包单文件、图标、版本号给非开发同事用的时候一个目录几十个 dll 很容易被误删。我用单文件发布加自包含运行时生成一个 exe目标机器不需要装 .NET。# 在 Windows PowerShell 中执行 dotnet publish src/LoginLauncher.UI/LoginLauncher.UI.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFiletrue -p:IncludeNativeLibrariesForSelfExtracttrue -p:DebugTypeNone -o ./publish-r win-x64指定运行时标识要兼容 32 位机器就再加一次win-x86的发布。--self-contained true让 exe 自带运行时体积会到 60MB 以上换来的是免安装。IncludeNativeLibrariesForSelfExtract把原生库也塞进单文件否则首次运行会缺 dll。DebugTypeNone去掉 pdb 引用正式包不该带调试符号。版本号在 csproj 的Version里改客户端会把它带在X-Client-Version请求头里服务端据此判断是否需要强制升级。5. 避坑与排查登录器上线后最容易翻车的五个地方5.1 跨线程更新控件直接抛异常现象是点完登录按钮进度条刚转起来程序就崩异常信息是「跨线程操作无效」。原因是ApiClient的await之后加了ConfigureAwait(false)回调落在了线程池线程上而label.Text 登录中必须在 UI 线程执行。解决办法是 UI 层订阅状态机事件时统一用BeginInvoke切回private void OnStateChanged(object? sender, LoginState state) { if (IsDisposed) return; BeginInvoke(() { btnLogin.Enabled state ! LoginState.Authenticating; lblStatus.Text state switch { LoginState.Authenticating 正在登录…, LoginState.Online 已连接, LoginState.Reconnecting 网络不稳定重连中, LoginState.Offline 已断开, _ 请输入账号密码 }; }); }IsDisposed判断不能省窗体已经关掉但后台任务还在推事件时BeginInvoke会抛ObjectDisposedException。5.2 每次请求 new HttpClient 导致端口耗尽现象是用量大之后出现SocketException: 由于系统缓冲区空间不足或队列已满或者连接超时率突然升高。原因是每次调用都创建新的HttpClient底层 socket 进入 TIME_WAIT 状态无法及时复用。解决就是 3.2 节的写法全局复用实例配PooledConnectionLifetime。如果项目里已经在别处 new 了很多次用netstat -ano | findstr :7043能看到成片的 TIME_WAIT 记录基本可以确诊。5.3 记住密码功能把明文写进了本地文件现象是安全扫描报告里出现「本地凭据明文存储」。原因是「记住密码」实现成了把密码直接写进 json 或注册表。正确做法是不保存密码只保存账号和 tokentoken 用 DPAPI 加密后写文件// 只保存账号 加密后的 token密码永不落地 var raw Encoding.UTF8.GetBytes(token); var cipher ProtectedData.Protect(raw, null, DataProtectionScope.CurrentUser); File.WriteAllBytes(tokenPath, cipher);DataProtectionScope.CurrentUser保证换一个 Windows 账号就解不开比固定密钥安全得多。如果必须要免输密码就存一个长期 refresh token而不是密码本身。5.4 单实例锁没做好两个窗口互相踢下线现象是用户双击两次之后两个窗口来回跳「您的账号在别处登录」。根因是 Mutex 只在本进程内生效或者用了Local\前缀导致多用户会话下失效。解决是 2.4 节的写法用Global\并且在第二个进程启动时通过命名管道通知第一个实例激活窗口。另一个容易忽略的点是调试时 VS 会残留进程改代码前先在任务管理器里确认没有旧 exe 在跑。5.5 高分屏下界面模糊、控件错位现象是在 4K 屏上整个窗口糊成一片。原因是项目没有声明 DPI 感知模式Windows 直接做了位图拉伸。在 csproj 里加一行即可ApplicationHighDpiModePerMonitorV2/ApplicationHighDpiModePerMonitorV2让窗口在多显示器之间拖动时按各自的缩放比例重绘。加完之后如果个别控件还是错位多半是用了固定像素坐标改成TableLayoutPanel或者Anchor布局。这个改动要趁早做等到界面控件堆满再调整布局返工量会大很多。6. 二次开发换主题、加注册、接第三方登录的改造路线6.1 换皮肤与控件库WinForms 默认控件外观比较朴素想换风格有两条路。轻量方案是引入一个纯托管的控件库把Form基类换成该库提供的皮肤窗体登录窗口的几个控件重新拖一遍即可业务代码不用动。重量方案是自己写OnPaint代价是每个控件都要处理悬停、按下、禁用三种状态工作量翻几倍。我一般选前者只在主界面做少量自绘。改造时要注意一点皮肤库通常会把窗体的FormBorderStyle改成None原来靠系统标题栏实现的拖动和关闭就失效了需要在MouseDown里手动调ReleaseCapture和SendMessage实现拖动并在右上角补一个关闭按钮。6.2 增加注册与找回密码流程注册流程本质上是一次额外的接口调用改动集中在三处ApiClient加一个RegisterAsyncLoginService加对应的状态迁移UI 加一个RegisterForm。状态机上要新增Registering状态并在Allowed表里允许Idle → Registering → Idle的双向迁移否则注册完回到登录界面时状态机拒绝迁移界面会卡在禁用态。找回密码涉及验证码这里有个细节验证码倒计时的定时器必须在窗体关闭时停掉否则用户反复开关找回密码窗口会积攒一堆计时器同时往同一个 label 上写秒数界面上的数字会乱跳。惯常写法是在FormClosed事件里timer.Dispose()。6.3 桌面端接第三方登录第三方登录在桌面端最麻烦的是回调。浏览器授权完成后会跳转到一个redirect_uri桌面程序没法直接接收 HTTP 回调。常见做法有两种本地起一个临时 HTTP 监听如http://127.0.0.1:随机端口/callback接收 code或者用自定义协议头如myapp://auth注册到系统。前者实现简单但要注意随机端口要提前告知授权服务器多数平台要求redirect_uri预先备案只能用固定端口。后者需要写注册表安装包卸载时要清理干净。拿到 code 之后换 token 的请求仍然走ApiClient但要注意这一步必须由自己的服务端去换客户端拿到client_secret就等于泄露。所以桌面端只负责把 code 传回自家后端由后端完成换取。6.4 改造后的验证清单每次改完登录链路我都会按下面这张表过一遍少一步都不敢发版。验证项操作方法期望结果正常登录输入正确账号密码关闭登录窗进入主界面状态为 Online密码错误输入错误密码提示错误按钮恢复可用状态回 Idle断网重连登录后拔网线 30 秒再插回状态先 Reconnecting 再回到 Online单实例连续双击 exe 两次只有一个进程第二个激活第一个窗口高分屏系统缩放调到 150% 重启文字清晰控件不错位凭据安全登录后检查%AppData%目录无明文密码token 为二进制密文这张表里最容易被跳过的是「断网重连」本地开发环境网络太稳很难自然触发。我一般用防火墙临时阻断目标端口来模拟比拔网线可控。改这个登录器最深刻的一次教训是早期版本把心跳定时器直接写在LoginForm里窗体一关定时器还在跑进程退不干净任务管理器里留一堆僵尸进程。从那以后我每次动登录相关代码都会先把状态机和心跳服务的生命周期画一遍确认每个Start都有对应的Dispose才敢提交。希望这份拆解能帮你少走几段弯路把登录器这块基础设施一次做扎实。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?