尧图精选

Winform变身HTTP服务:C# 中通过 HttpListener 实现 Json 接口实战

🕒 发布时间:2026/9/11 3:22:27 📁 来源:尧图网络
工作里经常遇到一类需求某个 Winform 桌面程序要做成一个小型“服务端”让浏览器、小程序、手机 App 或者其他 C# 程序来请求它。最常规的落地方式就是让 Winform 自己监听一个 HTTP 端口用 Json 作为数据交换格式对外提供 GET 和 POST 接口。这篇文章把我在实际项目里这套方案的完整写法、踩过的坑和排查思路都整理出来涉及 C#、Http、Json、winform、Post 这些关键词属于可以直接照着抄的实战经验不是那种只有原理没有代码的科普。我说的“Winform 服务器”不是把 IIS 或者 ASP.NET Core 塞进去而是在一个普通 Winform 进程里通过HttpListener开一个监听端口自己手写路由自己解析请求、拼 Json 返回。听起来像一个“玩具”但对内网工具、设备上位机、本地联调这类场景它往往比引入一套 Web 框架更轻、更好维护也不需要额外的部署环境。这篇文章适合 C# 开发老手快速落地一个最小可用的 HTTP Json 服务也适合刚接触上位机或桌面集成的新手理解底层请求-响应模型。1. 先明确需求什么场景下 Winform 要当 Http Json 服务器1.1 这个需求出现的典型场景我最早遇到这个需求是在做一个设备数据采集的上位机。设备通过串口把数据传到 Winform 程序里程序做了实时展示但现场管理人员希望用手机浏览器或者另一台电脑上的网页面板查看实时数据。如果重新写一套 Web 服务成本太高如果让 Winform 程序主动把数据推给另一个服务又多了中间环节。于是方案变成了“Winform 程序自己开一个 HTTP 端口网页直接 GET 这个端口拿 Json”。后来需求继续扩展网页端也需要下发控制指令于是又加了 POST 接口接收 Json 格式的指令参数Winform 解析后通过串口下发到设备。这类场景在工控、医疗设备、实验室自动化、内部管理工具里非常常见。核心诉求是程序本身是一个带界面的桌面应用但它又需要有对外交互能力。用 HTTP Json 的好处是接口标准、跨语言请求方甚至不需要关心对面是一个 Winform 程序还是一个 Linux 服务。1.2 我为什么选了 HttpListener 而不是自建 SocketC# 里要实现一个 HTTP 服务主流路线有三条直接用HttpListenerWindows 下封装了 HTTP.sys可以处理大部分 HTTP 协议细节自带请求解析和响应写出代码量很小。自己用TcpListener解析原始 HTTP 报文能学到协议本质但容易出错处理 POST body 长度、分块传输、各种 Header 时会非常痛苦。引入 ASP.NET Core 作为内嵌服务功能完整但让 Winform 项目一下子多了巨大的依赖和启动流程还涉及端口配置、中间件等概念对轻量任务来说有点“杀鸡用牛刀”。HttpListener是一个性能与复杂度之间的平衡点。它不是最快的也不是最省内存的但对于“内网并发几十个人查一下数据”这种压力等级毫无压力。它最难得的一点是你只需要写业务处理逻辑连 “HTTP/1.1 200 OK” 这样的原始响应行都不用拼框架会生成。1.3 在设计前必须先定的四个约定动手写代码前我强烈建议先和调用方把接口约定写清楚。不是所有项目都有完整接口文档但至少要在代码里统一这四件事请求路径的语义比如/device/status表示查询设备状态/device/command表示下发指令。不要全部堆在一个根路径下。请求方法的语义查询类操作用 GET状态变更类操作用 POST。虽然技术上 GET 也能带 body但不要这么干很多代理和浏览器会直接丢弃 GET 请求体。返回 Json 的固定结构我常用的结构是{ code: 0, message: ok, data: { } }。code为 0 代表业务成功非 0 代表业务失败HTTP 状态码只表达传输层的成败。编码统一为 UTF-8无论是请求体还是响应体一律 UTF-8避免中文乱码。这四个约定看似简单但在多人协作时能减少大量无意义的沟通返工。特别是返回结构一旦定了客户端那边写解析逻辑就非常顺畅不用每个接口配一套解析模板。2. 技术选型与协议设计HttpListener 还是自建 Socket2.1 Http 请求处理的本质你只是在一个字符串上做解析把 HTTP 请求想成一份结构化文本其实更好理解。客户端发过来的数据可以拆成三个部分请求行比如GET /device/status?deviceIdA001 HTTP/1.1一堆请求头比如Host、Content-Type、Content-Length请求体POST 接口一般就是原始字符串绝大多数情况下是一段 Json 文本服务端要做的事情就是从请求行里知道方法和路径从请求头里知道编码和长度从请求体里读出 Json 字符串然后反序列化成对象做业务处理最后把结果对象序列化成 Json 字符串写回响应。理解了这一点就能明白为什么用HttpListener比自建 Socket 省事——它把这层文本解析全部做完了留给你的是一个HttpListenerContext对象context.Request已经帮你解析好了方法、路径、参数和输入流。2.2 Get 和 Post 的区别以及编码陷阱GET 和 POST 的区别我在实际调试中被问过很多次。从 HTTP 规范角度看GET 被设计为“安全、幂等”的查询操作它不应该对服务器资源产生副作用POST 则允许携带请求体可以表达“创建、变更、执行”这类操作。在代码层面的最大区别是参数位置GET 参数在 URL 的 QueryString 里形如?deviceIdA001type1服务器用request.QueryString[deviceId]取。POST 参数在请求体里服务器需要读取输入流把字符串读出来再做反序列化。有个很容易被忽略的坑URL 中的参数不一定是 UTF-8 编码浏览器地址栏粘贴中文时可能会变成百分号编码。HttpListener默认会做一次 UrlDecode但如果你自己用TcpListener解析就必须手动处理%E4%B8%AD这种编码否则取出来的字符串是乱的。POST 请求体则是纯粹的字节流读取时必须严格使用请求头声明的编码request.ContentEncoding在绝大多数情况下是 UTF-8但为了稳妥我会先用ContentType里解析 charset缺省时再用 UTF-8。另外由于我们规定返回 Json 是 UTF-8响应头的Content-Type一定要写成application/json; charsetutf-8。如果漏掉charsetutf-8某些客户端会因为使用了系统默认编码Windows 下可能是 GBK解析返回体中文就变成乱码了。2.3 Json 序列化选型Newtonsoft.Json 还是 System.Text.JsonC# 里做 Json 序列化现在有两套主流选择老牌的Newtonsoft.Json也就是 Json.NET和微软官方的System.Text.Json。从我个人的项目经验来看如果项目里已经大量依赖Newtonsoft.Json就统一用它避免两套序列化器并存导致行为不一致。它的JObject、JArray动态操作能力很强处理格式不太稳定的 Json 更方便。如果是全新的、不依赖其他库的项目System.Text.Json够用性能也更好。但它在某些场景下对DataTable、dynamic的支持不如 Newtonsoft类型转换比较严格。这篇文章里的示例我以Newtonsoft.Json为主因为很多做 Winform 上位机的老项目都在用它。安装方式很简单NuGet 搜索Newtonsoft.Json一键安装即可。序列化时比较推荐用JsonConvert.SerializeObject返回压缩格式不美化输出。虽然带缩进的可读性好但会在网络上多传不少空格内网场景无所谓物联网或弱网环境就会浪费带宽。调试时可以单独写一个方法打印美化后的 Json只在日志里用。3. 核心代码实现GET 和 POST 接口从 0 到 13.1 初始化监听启动一个不阻塞 UI 的 Http 服务在 Winform 里启动 HttpListener第一个要注意的是不能直接放在 UI 线程里霍霍等请求。否则界面会卡死用户体验非常糟糕。正确做法是启动线程或Task.Run让监听循环不占用 UI 线程。我自己常用的一段初始化代码是这样public class HttpJsonServer { private HttpListener _listener; private bool _isRunning; public void Start(string prefix) { if (_isRunning) return; _listener new HttpListener(); _listener.Prefixes.Add(prefix); _listener.Start(); _isRunning true; Console.WriteLine($[HttpJsonServer] 服务已启动: {prefix}); Task.Run(async () { while (_isRunning) { try { var context await _listener.GetContextAsync(); // 每个请求都在独立线程池线程里处理不阻塞接收下一个请求 Task.Run(() ProcessRequest(context)); } catch (Exception ex) { if (_isRunning) { Console.WriteLine($[HttpJsonServer] 接收请求异常: {ex.Message}); } } } }); } public void Stop() { _isRunning false; _listener?.Stop(); _listener?.Close(); } }这里有个细节HttpListener.GetContextAsync()在监听停止时可能抛出异常所以循环里要捕获并且用_isRunning判断是不是主动停止导致的异常避免退出时打印一堆红色日志。至于监听前缀常见写法有三种http://localhost:8080/只能本机访问不需要管理员权限适合本地联调。http://127.0.0.1:8080/和上一条类似但用 IP 形式显式指定。http://*:8080/或http://:8080/监听本机所有地址局域网其他机器也能访问但 Windows 下可能需要管理员权限或 URLACL 授权。3.2 实现 GET 接口从 QueryString 取参数返回 JsonGET 接口实现起来最简单。比如我要做一个查询设备状态的接口路径是/device/status参数deviceId返回设备名称、状态、时间和版本号。处理逻辑如下private void HandleGetDeviceStatus(HttpListenerContext context) { var request context.Request; var deviceId request.QueryString[deviceId]; if (string.IsNullOrEmpty(deviceId)) { WriteJson(context.Response, new { code 400, message deviceId 不能为空, data (object)null }); return; } // 这里替换成实际的设备查询逻辑可能从内存字典、数据库或设备本身获取 var statusData new { deviceId deviceId, deviceName 温度采集器-01, status online, temperature 26.5, updateTime DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss) }; WriteJson(context.Response, new { code 0, message ok, data statusData }); }这段代码里有一个容易被忽略的点返回的匿名对象用updateTime直接放了格式化后的字符串而不是DateTime对象。这是为了避开 Json 序列化时对 DateTime 格式的争议。如果需要给多个客户端用包括 JS 前端建议要么统一 ISO 8601 格式的字符串要么在序列化时约定DateTime的输出格式。我在内部项目里倾向于直接输出可读字符串省得前端再去处理时区问题。3.3 实现 POST 接口读取 Body 并反序列化POST 接口比 GET 复杂的地方在于要去读请求体。最简朴的读法是用StreamReader从request.InputStream里读字符串然后交给JsonConvert.DeserializeObject。举个具体例子前端需要下发一个设备指令请求路径是/device/command请求体 Json 如下{ deviceId: A001, command: reboot, params: { delay: 5 } }我定义对应的 C# 类public class DeviceCommandRequest { public string DeviceId { get; set; } public string Command { get; set; } public Dictionarystring, object Params { get; set; } }处理函数private void HandlePostDeviceCommand(HttpListenerContext context) { try { var request context.Request; string body; using (var reader new StreamReader(request.InputStream, request.ContentEncoding)) { body reader.ReadToEnd(); } if (string.IsNullOrWhiteSpace(body)) { WriteJson(context.Response, new { code 400, message 请求体不能为空, data (object)null }); return; } var cmdReq JsonConvert.DeserializeObjectDeviceCommandRequest(body); if (cmdReq null || string.IsNullOrWhiteSpace(cmdReq.DeviceId)) { WriteJson(context.Response, new { code 400, message deviceId 不能为空, data (object)null }); return; } // 这里执行实际设备指令下发, 串口或 Socket 通信等 bool success ExecuteDeviceCommand(cmdReq); var data new { deviceId cmdReq.DeviceId, command cmdReq.Command, success success }; WriteJson(context.Response, new { code success ? 0 : 500, message success ? ok : 执行失败, data data }); } catch (JsonException ex) { WriteJson(context.Response, new { code 400, message Json 解析失败: ex.Message, data (object)null }); } catch (Exception ex) { WriteJson(context.Response, new { code 500, message 服务器内部错误: ex.Message, data (object)null }); } }这里有一个关于request.ContentEncoding的知识点它会根据请求头的Content-Type里的 charset 字段来返回编码。如果客户端是标准浏览器/AJAX通常没问题。但如果调用方随手在 Postman 里把编码设成了 ISO-8859-1而实际 body 又是 UTF-8 的中文StreamReader就会解码出乱码。更保守的做法是不信任request.ContentEncoding强制用 UTF-8。代价是如果真有非 UTF-8 的特殊历史调用方反而会乱码。这个取舍根据你的实际调用方来定我内部系统统一强制 UTF-8遇到特殊情况再单个处理。3.4 统一返回值结构与异常处理写响应时最烦的一件事是每个处理函数都写一堆response.StatusCode、response.ContentType、response.OutputStream.Write。我封装了一个统一的写出方法private void WriteJson(HttpListenerResponse response, object data) { string json JsonConvert.SerializeObject(data); byte[] bytes Encoding.UTF8.GetBytes(json); response.StatusCode 200; response.ContentType application/json; charsetutf-8; response.ContentLength64 bytes.Length; response.OutputStream.Write(bytes, 0, bytes.Length); response.OutputStream.Close(); }这个方法会强制把响应体以 UTF-8 写出。即使传入的是匿名对象JsonConvert.SerializeObject也能处理响应结构会保持一致。同时在ProcessRequest里做了最外层异常兜底private void ProcessRequest(HttpListenerContext context) { try { var request context.Request; var path request.Url.AbsolutePath; if (request.HttpMethod GET path /device/status) { HandleGetDeviceStatus(context); } else if (request.HttpMethod POST path /device/command) { HandlePostDeviceCommand(context); } else { WriteJson(context.Response, new { code 404, message 接口不存在, data (object)null }); } } catch (Exception ex) { WriteJson(context.Response, new { code 500, message 服务器内部错误: ex.Message, data (object)null }); } }有了这个兜底任何处理函数抛异常客户端收到的也都是标准 Json 结构而不是一个丑陋的 HTML 错误页。这个细节在联调时特别重要因为前端解析非 Json 响应时会直接报SyntaxError消息很隐晦。统一结构后前端只需要判断code 0就能走业务逻辑。4. 客户端调用与在线 Post 工具联调测试4.1 C# 客户端调用封装HttpClient 的推荐用法服务端写完了客户端这边也要有一套稳定的调用方式。不管调用方是另一个 Winform 程序、控制台工具还是 WPF 客户端我都推荐直接使用HttpClient它是现代 C# 环境下最顺手、坑最少的 HTTP 客户端。一个简单的 GET 调用using (var httpClient new HttpClient()) { httpClient.Timeout TimeSpan.FromSeconds(10); var resp await httpClient.GetAsync(http://127.0.0.1:8080/device/status?deviceIdA001); var body await resp.Content.ReadAsStringAsync(); Console.WriteLine(body); }POST 调用using (var httpClient new HttpClient()) { var payload new { deviceId A001, command reboot, Params new Dictionarystring, object { { delay, 5 } } }; string json JsonConvert.SerializeObject(payload); var content new StringContent(json, Encoding.UTF8, application/json); var resp await httpClient.PostAsync(http://127.0.0.1:8080/device/command, content); var respBody await resp.Content.ReadAsStringAsync(); Console.WriteLine(respBody); }这里最需要注意的一点是StringContent一定要显式传入Encoding.UTF8和application/json。如果漏掉StringContent默认可能是text/plain或其他编码服务端解析时就会出现格式不匹配或中文乱码。我在实际项目中见过不少同事因为漏了这两个参数导致服务端收到 Json 字符串但Content-Type不是application/json于是社区类封装库直接拒收。虽然我们自建的HttpListener不强制检查 Content-Type但在和其他 Web 框架对接时会踩坑。HttpClient还有一个高级用法加Accept头告诉服务端能返回 JsonhttpClient.DefaultRequestHeaders.Accept.Clear(); httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(application/json));加上这个客户端更“规范”服务端如果想做内容协商也能有所依据但对我们自己的接口没影响。4.2 用在线 Post 工具和浏览器快速验证在接口写完、还没写正式客户端之前最快的验证方式是用现成的接口调试工具。我最常用的是在线 POST 工具或者浏览器自带的fetch。如果是纯 GET 接口浏览器地址栏直接输入http://127.0.0.1:8080/device/status?deviceIdA001回车就能看到返回的 Json 文本。这一步能立刻验证服务有没有启动、路由字段是否匹配。如果是 POST 接口用在线 POST 工具更顺手。它通常支持你填写请求 URL、选择方法为 POST、在 Body 里粘贴 Json然后点击发送。接收方能看到请求头和响应的原始内容。调试时有两件事建议每次都看看服务器控制台是否打印了来自客户端的请求日志。看响应里的Content-Type是否包含charsetutf-8如果包含基本可以排除响应编码问题。如果你临时不想打开在线工具还可以用 C# 的交互式窗口快速测一段脚本或者用浏览器 F12 控制台里跑fetch(http://127.0.0.1:8080/device/status?deviceIdA001) .then(r r.json()) .then(data console.log(data));这个方式在调试跨域问题时特别有用因为浏览器控制台会把 CORS 错误直接抛出来。4.3 多端口多路由扩展建议当接口越来越多时建议把路由和处理函数的绑定做成一张表而不是一堆 if-else。简单做法是用Dictionarystring, FuncHttpListenerContext, TaskKey 是GET:/device/status这样的字符串Value 是处理函数。var routeTable new Dictionarystring, ActionHttpListenerContext() { { GET:/device/status, HandleGetDeviceStatus }, { POST:/device/command, HandlePostDeviceCommand }, { GET:/health, HandleHealthCheck } };然后在ProcessRequest里查表调用找不到就返回 404。这样做的好处是新增接口只需要加一行路由表和对应处理方法不需要动主流程。如果以后要加权限校验、日志记录也可以在路由分发前后统一处理维护成本低不少。多端口的问题也顺便说一下。HttpListener可以在Prefixes里添加多个前缀比如同时监听http://localhost:8080/和http://192.168.1.100:8080/。但我实际用下来如果不是特殊网络需求只监听http://*:8080/就足够了因为通配符已经覆盖本机所有网卡地址。除非有两个端口提供不同服务否则没必要开多个。5. 高频问题排查端口、乱码、跨域和线程问题5.1 端口被占用与访问权限HttpListener.Start()最常见的异常是HttpListenerException提示“拒绝访问”或者“端口被占用”。端口被占用很好理解换一个端口就行但注意 Windows 可能有一些动态端口范围避开 49152 到 65535 之间的随机端口也可能被占用。比较稳妥的做法是启动前先测试端口是否可用否则弹窗提示用户换一个端口。访问权限问题比较隐蔽。当你的前缀是http://*:8080/或http://:8080/时Windows 要求当前用户对这个 URL 有这个权限否则就算程序以管理员身份跑也可能报错。最常见的报错形态就是 “Access Denied”。解决办法有两种用管理员身份运行 Winform 程序。用netsh命令添加 URLACLnetsh http add urlacl urlhttp://:8080/ userEveryone。在实际部署内部工具时我不太想让用户每次右键管理员运行太反人类了所以我更推荐在安装脚本里执行那条netsh命令一次性授权之后普通用户就能启动服务。另外需要注意如果用try-catch捕获了HttpListenerException却不看错误码经验不足时会非常困惑。正确套路是先打印ex.ErrorCode如果等于5Access Denied基本就是权限问题。5.2 中文乱码与 ContentType 问题中文乱码是 Winform 服务端联调时被问烂了的问题归根结底就两个根源请求体解析时编码不对。响应体写入时编码不对。响应体乱码的修复办法就是像我前面说的WriteJson里强制Encoding.UTF8.GetBytes并且ContentType带上charsetutf-8。请求体乱码则要确保StreamReader用 UTF-8 读取。如果发现请求体里的中文成了????或者乱码先别急着怀疑服务端用Fiddler或在线工具看请求的原始字节确认客户端实际发送的编码是什么。很多时候是客户端代码错误地用了Encoding.Default去转字节。补充一个小经验Winform 项目里如果进行过“区域语言设置”调整Encoding.Default在中文 Windows 下是 GBK而不是 UTF-8。当客户端和服务端都是 C# 时如果一边用Encoding.Default编码一边用 UTF-8 解码中文必乱。最省心的方式就是全链路写死 UTF-8不让任何环节用默认编码。5.3 跨域问题为什么浏览器能打开页面却请求不了接口如果你把 Winform 服务端作为一个局域网内的小型 HTTP API然后用独立网页去调用很可能遇到“浏览器控制台报 CORS error”。原因很简单浏览器出于安全策略会拦截页面所在源origin与接口所在源host:port不一致的跨域请求。HTTP 协议本身是支持的但浏览器不让 JS 直接读取响应。解决方案是在响应头里加上 CORS 控制字段。在WriteJson方法里增加response.Headers.Add(Access-Control-Allow-Origin, *); response.Headers.Add(Access-Control-Allow-Methods, GET, POST, OPTIONS); response.Headers.Add(Access-Control-Allow-Headers, Content-Type);另外浏览器在发送正式 POST 请求前会先发一个OPTIONS预检请求。如果服务端没有处理OPTIONS浏览器会认为跨域请求失败。所以ProcessRequest里要加一个分支if (request.HttpMethod OPTIONS) { response.StatusCode 204; response.Headers.Add(Access-Control-Allow-Origin, *); response.Headers.Add(Access-Control-Allow-Methods, GET, POST, OPTIONS); response.Headers.Add(Access-Control-Allow-Headers, Content-Type); response.OutputStream.Close(); return; }这个坑我在第一次做“Winform 服务 网页管理面板”时踩得很惨当时只用 Postman 测接口Postman 不发预检请求所以一切正常。一上浏览器就被 CORS 拦住排查了很长时间才明白是缺少 OPTIONS 响应。从那以后只要有网页调用方我都会提前加好 CORS 头。5.4 UI 线程更新与死锁Winform 服务端内部的请求处理线程是线程池线程不能直接操作 UI 控件。比如收到 POST 指令后你可能想刷新界面上的设备状态文字如果直接写this.lblStatus.Text ok大概率会抛 “线程间操作无效” 异常。正确做法是private void UpdateStatusInUI(string message) { if (this.InvokeRequired) { this.Invoke(new Action(() UpdateStatusInUI(message))); return; } this.lblStatus.Text message; }这里还有一个比较隐蔽的死锁场景如果你在 UI 线程上同步等待一个 HTTP 请求的返回而请求处理函数里又想Invoke到 UI 线程刷新控件两边就会互相等待形成死锁。我在一个早期版本里就犯过这个错误。避免方法是发起请求时使用await异步调用不要用.Result或.Wait()同步阻塞 UI 线程。在 Winform 里要养成“UI 线程不阻塞”的习惯所有网络操作能异步就异步。5.5 局域网其他机器访问不到这是网络环境导致的常见问题不代表代码错。解决顺序如下检查 Winform 程序所在机器的防火墙是否允许该端口入站。Windows 防火墙有时会拦截监听端口需要在“允许应用通过防火墙”里放开。确认监听前缀是http://*:8080/而不是http://localhost:8080/。localhost只绑定环回地址局域网无法访问。在局域网另一台机器上用telnet 服务端IP 8080测试端口通不通。如果不通基本是防火墙或网络问题不用改代码。如果只是临时调试可以直接把服务端程序 联调工具放在同一台机器上用127.0.0.1先验证逻辑再考虑对外暴露。6. 我认为这套方案真正好用在哪最后聊一点真实的使用感受。用 Winform HttpListener 做 HTTP Json 服务最大的收获就是“桌面程序瞬间拥有了 Web 接口”整个项目的交互范围一下子从本机扩展到了局域网甚至更远。你不用再为“把数据从 A 程序搬到 B 程序”这件事发愁只要对方能发 HTTP 请求它就能用你的数据。而且在调试时直接用在线 POST 工具或浏览器敲 URL 就能看到效果比以往写自定义 Socket 协议后再做一个配套测试工具要省事得多。局限性当然也有。如果接口数量很多、路由复杂、需要并发处理高流量或者需要数据库操作和权限体系那么还是应该把服务端拆成独立的 ASP.NET Core 项目不要硬扛在 Winform 里。但对中小规模的内部工具、上位机数据服务和设备控制接口这套方案足够清爽单人维护也没什么负担。有一个小细节我每次都会做就是在服务端把每个请求都打一条简短日志记录时间、方法、路径、来源 IP 和响应耗时。排查线上问题时没有日志会非常痛苦有了日志则一分钟内就能定位是客户端参数问题还是服务端处理问题。日志整理成一行一行追加到文本框里并在界面最多保留几百行避免内存无限增长。如果你接下来要做类似功能建议按这个顺序推进先把返回结构定死再搭监听服务再写第一个 GET 接口并用浏览器验证然后写 POST 接口并用在线工具验证最后再补 CORS、权限和日志。这个链路走顺了后面加接口就是纯粹的体力活了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →