ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用C#编写Windows自动化部署脚本,告别手动发版排队

用C#编写Windows自动化部署脚本,告别手动发版排队 刷到深圳那边千人排队领龙虾的新闻时我第一反应不是龙虾好不好吃而是这画面太像我们做部署的日常了——人工排着看不见的长队备份、停服、传包、覆盖、改配置、重启、验证一台机器十几分钟十台就是两小时中间手一抖整轮就崩。所以我把这套流程写成C#自动化部署脚本双击运行5分钟内静默完成“养虾”。这篇文章从设计思路、核心代码到实战调优都摊开讲适合做Windows桌面应用、上位机程序、服务端发布的朋友参考。1. 从“千人排队”到“一键养虾”手动部署为什么是最大的时间黑洞1.1 手动部署的日常一场看不见的千人排队先说个我自己的真实场景。我们给车间配了十来台工控机跑的是自研上位机程序。平时开发阶段爽得很代码写完本地跑一下就完事可一旦到了发版日整个人就进入“排队模式”。发版那天的固定流程是这样的开远程桌面连到每台机器把新版本的压缩包复制进去先杀正在运行的进程还得提前跟操作工打招呼让人家把手头的活儿停一停把老版本整个文件夹重命名做备份然后解压覆盖接下来改配置文件每台机器的IP、端口、设备串口号都不一样得挨个对着台账改最后重新启动程序还得肉眼确认进程起来了、端口在监听。这一套下来顺利的话一台机器十五分钟不顺利的话——比如有个dll被进程占用删不掉或者配置文件改错了某个参数导致程序起不来——半小时也不是没可能。十台机器就是两三个小时一个下午全耗在这上面。这个画面是不是跟商场门口排队领免费龙虾一个道理你花一上午站在队伍里最后拿到手的可能就是一只冻龙虾跟你付出的时间完全不成正比。手动部署也一样你花一下午站在“部署队伍”里最后换来的只是几台机器跑起来了而这几台机器本来可以很轻松地被自动化工具安排得明明白白。1.2 排队的代价时间、错误、焦虑三笔账手动部署的账得一笔一笔算。时间账。一台机器十五分钟是理想状态算上网络拷贝慢、杀毒软件扫描、远程桌面卡顿实际往往二十分钟往上。量一大时间就不是线性增长是台阶式增长。特别是你还要等操作工吃完午饭回来配合你停设备那时间根本不归你控制。错误账。手动操作最大的问题不是慢是错。我踩过最典型的几个忘了备份就覆盖结果新版有bug想回滚旧版已经没了路径手滑多打一个斜杠文件复制到别的地方去了程序启动之后各种初始化失败改配置的时候把“本机IP”和“服务器IP”搞混部署完怎么都连不上数据库。这些错误在部署场景里不是“小概率事件”而是“大概率事故”因为人在重复劳动状态下注意力是递减的越到后面几步越容易出幺蛾子。焦虑账。为了不影响生产部署窗口一般安排在傍晚甚至凌晨。我每次都是措辞严谨地跟现场沟通“大概一小时完事”结果一搞就是三小时还不敢说“快了快了”因为根本不知道问题出在哪。这种状态下的精神消耗比写一天代码还累。1.3 自动化的目标把“排队”换成“流水线”排队的本质是N个人干同一件重复的事没有标准流程没有组织调度。而自动化部署要做的是把这件事变成“流水线”——输入部署包输出运行状态。我最终给这套脚本定的目标很朴素一条命令或一次双击就能启动不需要人工干预执行顺序固定每一步都有明确校验任何一步失败立即停止输出错误日志必要时自动回滚可以无人值守执行部署完自动健康检查然后把结果发出来。能做到这四点部署这件事就不再是“排队领龙虾”而是“静默养虾”水温和饵料都设定好自动循环到点收成你该干嘛干嘛去。接下来的章节就是我把这个目标落地的全过程。2. 选型思考为什么是C#而不是批处理或PowerShell2.1 C#在部署场景的三个硬优势方案选型这事我前后纠结过好几轮。第一版部署脚本用批处理写的第二版用PowerShell第三版才整体切到C#。不是说前面两个方案不能用而是当我明确要做“带状态检查、带日志、带回滚”的完整部署流程时C#的硬优势就压不住了。第一个优势强类型加编译期检查。批处理和PowerShell都是解释执行写错变量名、调错参数类型不执行到那一行根本发现不了。C#是在编译时就把这类问题全部挡掉。部署脚本出不得“运行到一半才发现写错了”的丑事——编译期报错只丢脸运行期报错可能连数据带服务一起丢。第二个优势异常处理成熟。C#的try-catch-finally配合日志框架可以做到每个阶段失败都有明确留痕。PowerShell虽然也能try-catch但错误处理语义比较绕本地错误、非终止错误、终止错误三套逻辑写复杂了就很容易出现“某一步静默失败了但脚本继续往下跑”的情况。这在部署流程里是致命的。第三个优势直接调用.NET系统API。控制Windows服务有现成的ServiceController启动进程拿退出码有ProcessStartInfo文件操作有File/Directory网络探测有TcpClient、HttpClient。这些API是强类型、可枚举、有文档的不像批处理那样还得解析命令行的文本输出。最典型的就是查端口监听状态PowerShell要Get-NetTCPConnection然后Select-Object解析还得处理“没输出”时的边界条件C#直接new一个TcpClient然后Connect连上了就是通了异常就是没通逻辑一条直线。2.2 和其他脚本方案的横向对比方案核心优势核心劣势适合场景Batch (.bat/.cmd)零依赖、上手最快语法弱、错误处理基本靠goto、不可调试一两步文件复制纯“粘合”用途PowerShell系统集成好、能处理较复杂逻辑版本差异大、转义规则烦人、执行策略限制多Windows系统管理、以服务/注册表为主的操作C# (.NET 6/8)强类型、可编译、可调试、API丰富需要编译环境、有一定学习成本多阶段、多机器、需要日志和回滚的复杂部署Python生态丰富、跨平台目标机器要装Python或打包后体积大大批量文件处理、偏数据侧的操作对比下来我的判断标准很简单部署流程一旦超过五步就值得上C#。五步以内批处理还能靠复制粘贴糊弄一下超过五步代码可维护性、异常可观测性就会迅速变成主要矛盾。2.3 何时不建议用C#写部署脚本说句公道话C#不是万能钥匙。我建议大家按这个思路判断如果你只是做一次性的文件替换Bat或者PowerShell两行就够没必要起一个Visual Studio工程如果你团队里没人会C#但你有一堆现成的Python脚本那继续用Python先跑通流程再说工具选型如果你的部署目标全是Docker容器那老老实实用docker-compose或者K8s那套C#脚本在其中反而显得多余如果你需要调用的部署工具全是Linux-shell生态那C#的跨平台优势也不如在Linux上用bash顺手。我坚持用C#是因为我们的部署目标就是Windows工控机部署对象就是Windows服务和桌面exe整个生态闭路C#就是最顺手的那个工具。3. 核心设计部署脚本的模块拆解与配置驱动3.1 模块清单与职责划分动手写代码之前我先把整个部署流程画了一张阶段图这里不画流程图直接用文字描述阶段顺序。一个完整的部署流程被我拆成八个模块每个模块职责单一互相之间只通过类和方法调用不共享可变状态ConfigLoader加载并校验部署配置Prechecker前置检查包括端口占用、磁盘剩余空间、目标目录是否存在BackupManager备份现有文件带时间戳目录ServiceOperator停止和启动Windows服务或杀进程/拉起进程InstallExecutor执行安装包或文件覆盖逻辑HealthValidator部署完成后做TCP/HTTP/进程三层健康探测Logger全程结构化日志RollbackManager失败时从备份目录恢复并尝试重启旧服务。为什么要拆这么细因为部署脚本最怕的就是“一段代码从头写到尾”出了问题只能靠猜。模块化之后日志里能精确记录“当前是哪个模块在执行”故障定位时间从小时级降到分钟级。3.2 以JSON为核心的配置驱动配置驱动是我做这套脚本的一个核心设计决策。部署动作本身是死的但部署参数是活的安装包路径、目标机器、服务名、健康检查端口……这些东西如果硬编码在代码里每次部署都要重新编译一次那就失去了自动化的意义。配置文件我选JSON主要因为三个原因第一可读性好甲方或者运维同事拿到能直接看懂并修改第二 .NET里System.Text.Json序列化反序列化是标准库不引入额外依赖第三可以针对不同机器写不同配置文件脚本本身不用改动一行代码。配置文件结构大致长这样{ appName: IndustrialTerminal, installerPath: D:\\Release\\setup_1.2.3.exe, installerArgs: /S, serviceName: IndTerminalSvc, targetPath: D:\\Apps\\IndTerminal, backupDirectory: D:\\Backup, backupRetentionCount: 5, preCheckPort: 8080, preCheckDiskFreeGB: 10, healthCheckUrl: http://127.0.0.1:8080/api/health, healthCheckTimeoutSeconds: 30 }字段含义见下表字段作用注意事项appName日志标识和备份目录前缀别用带空格或特殊字符的名字路径拼接会踩坑installerPath安装包完整路径部署前先确认文件存在别等process启动失败才报错installerArgs传给安装包的静默参数具体值要看安装包类型后面会细说serviceName需要停止/启动的Windows服务名注意服务名不等于显示名用sc query确认targetPath部署目标目录备份也是以这个目录为源backupDirectory备份根目录脚本会自动按时间戳建子目录backupRetentionCount保留最近备份数量建议设3~5防止备份目录无限膨胀preCheckPort前置检查与健康检查端口如果程序不监听端口改用进程检测healthCheckUrlHTTP健康检查地址要求目标程序提供/health接口否则用TCP探测3.3 阶段编排与异常处理策略模块拆完之后要把它们串成一个流程。我采用的策略是“线性多阶段 任一阶段失败即中断”。Prepare阶段加载配置、前置检查。磁盘空间不够直接退出别等到拷贝到一半才报“磁盘已满”Stop阶段停服务、杀进程等待完全退出Backup阶段把targetPath完整复制到带时间戳的备份目录Install阶段执行安装包或开始增量文件覆盖Start阶段拉起服务、启动进程Verify阶段健康检查按端口/URL/进程三种方式确认服务真的起来了。整个流程的异常处理原则只有一条任何一个阶段抛异常立即停止后续阶段并尝试从最新备份目录回滚到部署前状态。回滚这个动作在部署脚本里绝不能缺席宁可平时用不上也不能在真正需要的深夜发现回滚逻辑没写。4. 动手实现核心代码精讲4.1 主流程控制代码先说主入口。这个脚本不是Windows Forms应用也不是WPF就是个控制台程序。控制台程序最适合自动化部署因为它没有UI状态依赖可以安静地跑完也可以把输出重定向到日志文件。using System; using System.Threading.Tasks; class DeployRunner { static async Taskint Main(string[] args) { var logger new FileLogger(deploy.log); logger.Info( 部署流程开始 ); try { var config DeployConfig.Load(deploy_config.json); config.Validate(); // Prepare前置检查 Prechecker.Check(config); logger.Info(前置检查通过); // Stop停止服务 if (ServiceOperator.StopService(config.ServiceName)) logger.Info(服务已停止); else logger.Warn(服务未运行跳过停止步骤); // Backup备份现有文件 var backupDir BackupManager.Create(config.TargetPath, config.BackupDirectory, config.AppName); logger.Info($备份完成目录: {backupDir}); // Install执行安装包 int exitCode InstallExecutor.Run(config.InstallerPath, config.InstallerArgs, logger); if (exitCode ! 0) throw new Exception($安装包执行失败退出码: {exitCode}); logger.Info($安装包执行成功退出码: {exitCode}); // Start拉起服务 ServiceOperator.StartService(config.ServiceName); logger.Info(服务已启动开始健康检查); // Verify健康检查 bool healthy HealthValidator.WaitForHealth(config, TimeSpan.FromSeconds(30)); if (!healthy) throw new Exception(健康检查超时触发回滚); logger.Info( 部署成功 ); return 0; } catch (Exception ex) { logger.Error($部署失败: {ex}); logger.Info(开始回滚...); RollbackManager.RestoreLatest(config); return -1; } } }这里有一个关键设计Main方法返回int退出码0表示成功非0表示失败。这样这个控制台程序可以被CI/CD系统直接识别结果也可以被任务计划程序通过返回码做决策。4.2 服务停止与文件备份服务停止是最容易出幺蛾子的地方。你以为调用service.Stop()就完事了不对Stop()是异步的它只是发出了停止命令服务真正退出还需要几秒甚至十几秒。如果你不等待Stopped状态就去覆盖文件大概率撞上“文件被占用无法删除”的异常。using System; using System.ServiceProcess; using System.Threading; public static class ServiceOperator { public static bool StopService(string serviceName) { using var sc new ServiceController(serviceName); if (sc.Status ServiceControllerStatus.Stopped || sc.Status ServiceControllerStatus.StopPending) return false; sc.Stop(); sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(30)); // 额外等500ms让进程句柄完全释放 Thread.Sleep(500); return true; } public static void StartService(string serviceName) { using var sc new ServiceController(serviceName); if (sc.Status ServiceControllerStatus.Stopped) { sc.Start(); sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30)); } } }注意我注释里写的“额外等500ms”。这是我在实际部署中踩出来的经验如果你停掉的是Windows服务WaitForStatus返回Stopped之后文件的写锁往往还挂在系统进程上马上覆盖文件照样报占用。多等这半秒让句柄彻底释放能避免大量莫名其妙的“占用”报错。备份模块没什么花活就是把整个目录原样复制到带时间戳的备份路径下public static class BackupManager { public static string Create(string sourceDir, string backupRoot, string appName) { if (!Directory.Exists(sourceDir)) throw new DirectoryNotFoundException($目标目录不存在: {sourceDir}); var timestamp DateTime.Now.ToString(yyyyMMdd_HHmmss); var backupDir Path.Combine(backupRoot, ${appName}_{timestamp}); Directory.CreateDirectory(backupDir); foreach (var file in Directory.GetFiles(sourceDir, *, SearchOption.AllDirectories)) { var relativePath Path.GetRelativePath(sourceDir, file); var destFile Path.Combine(backupDir, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(destFile)!); File.Copy(file, destFile, true); } CleanupOldBackups(backupRoot, appName); return backupDir; } }备份策略顺便加了一个“保留最近N份”的清理逻辑避免备份目录几年下来变成几百GB的怪物。4.3 静默安装与健康检查静默安装是整个部署流程里最“魔法”的部分。核心思路是用ProcessStartInfo启动安装程序加上对应安装包类型的静默参数然后等待进程退出并拿到退出码。using System; using System.Diagnostics; public static class InstallExecutor { public static int Run(string installerPath, string args, FileLogger logger) { if (!File.Exists(installerPath)) throw new FileNotFoundException($安装包不存在: {installerPath}); logger.Info($执行安装包: {installerPath} {args}); var psi new ProcessStartInfo { FileName installerPath, Arguments args, UseShellExecute false, CreateNoWindow true, RedirectStandardOutput true, RedirectStandardError true }; using var process Process.Start(psi)!; // 读输出和错误防止缓冲区写满导致进程卡死 var stdoutTask process.StandardOutput.ReadToEndAsync(); var stderrTask process.StandardError.ReadToEndAsync(); if (!process.WaitForExit(TimeSpan.FromMinutes(5))) { process.Kill(true); throw new TimeoutException(安装包5分钟内未退出已强制终止); } logger.Info($安装包输出: {stdoutTask.Result.Trim()} {stderrTask.Result.Trim()}); return process.ExitCode; } }这里有两个容易被忽略的点。第一个点UseShellExecute false CreateNoWindow true。如果不这样设置部署脚本一运行桌面上会先弹出安装包的自解压进度窗那就不是“静默”了。CreateNoWindow参数是C#特有的优势PowerShell或者批处理里想完全隐藏外部进程的窗口还得借助第三方工具或技巧。第二个点重定向标准输出和标准错误。很多安装包比如NSIS脚本在静默模式下也会往stdout写日志。不重定向的话这些文字会直接丢到控制台如果控制台窗口隐藏输出会积压在管道缓冲区缓冲区满了进程直接卡死。我踩过这个坑部署到一半不报错也不退出一查发现就是没重定向输出。静默参数按安装包类型分这样几种安装包类型静默参数补充说明MSImsiexec /i xxx.msi /qn /norestart/qn表示完全静默/norestart禁止重启NSISxxx.exe /S注意S必须大写Inno Setupxxx.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART三个参数可以合并写自研exexxx.exe --silent给自家程序写部署时约定一个参数即可健康检查部分我默认做双重校验先探测TCP端口再请求HTTP健康接口。如果目标程序不提供HTTP接口就降级为TCP加进程名双重检查。using System; using System.Net.Http; using System.Net.Sockets; using System.Threading; public static class HealthValidator { public static bool WaitForHealth(DeployConfig config, TimeSpan timeout) { var deadline DateTime.UtcNow timeout; while (DateTime.UtcNow deadline) { if (CheckTcpPort(config.PreCheckPort) CheckUrl(config.HealthCheckUrl)) return true; Thread.Sleep(1000); } return false; } private static bool CheckTcpPort(int port) { try { using var client new TcpClient(); client.Connect(127.0.0.1, port); return true; } catch { return false; } } private static bool CheckUrl(string url) { if (string.IsNullOrEmpty(url)) return true; try { using var http new HttpClient(); http.Timeout TimeSpan.FromSeconds(2); using var resp http.GetAsync(url).GetAwaiter().GetResult(); return resp.IsSuccessStatusCode; } catch { return false; } } }健康检查的轮询间隔我故意设成1秒不是胆大是因为部署完的服务通常能在几秒内监听端口1秒轮询足够灵敏又不会对机器造成什么压力。5. 实测与调优5分钟目标如何达成5.1 基线实测一台机器到底要多久脚本写完第一版我用一台测试工控机做了基线测试。旧版本程序大概600MB新版本略大一点目标机器是普通SSD。测出来的数据是这样的阶段手动操作耗时脚本耗时停止服务约1分钟2秒备份现有文件约5分钟45秒覆盖文件/安装约8分钟1分10秒修改配置文件约5分钟0自动模板替换启动服务与验证约2分钟15秒总计约21分钟2分12秒注意手动操作耗时是我按之前几次发版估算的平均值实际只会更长不会更短。脚本实测2分12秒距离“5分钟”的目标已经留出充足余量。但这里有个陷阱文件总量一大备份和覆盖两个阶段的耗时就会线性上升。如果程序从600MB涨到2GB脚本总耗时也会从2分钟涨到接近5分钟。所以“5分钟”不是凭空来的是靠下面这些优化手段撑住的。5.2 时间瓶颈分析与优化三板斧第一板斧增量备份与增量同步。不是所有文件都变了对比文件大小和修改时间就能筛掉大多数不需要覆盖的静态文件。我加了一个FileNeedUpdate方法先把源文件和目标文件的长度、LastWriteTimeUtc对比一遍只复制有变化的文件。实测在“小版本迭代”场景下比如只改了三个exe和两个dll备份和覆盖阶段可以直接省掉80%的IO时间。static bool FileNeedUpdate(string srcFile, string destFile) { var srcInfo new FileInfo(srcFile); var destInfo new FileInfo(destFile); if (!destInfo.Exists) return true; if (srcInfo.Length ! destInfo.Length) return true; if (srcInfo.LastWriteTimeUtc destInfo.LastWriteTimeUtc) return true; return false; }第二板斧多台机器并行处理。单机2分钟如果机器数量是十台串行就是20分钟。我加了一个并发控制用SemaphoreSlim把并发数限制在3台以内十台机器跑下来大概5分多钟。private static readonly SemaphoreSlim Throttle new(3); static async Task DeployOneMachineAsync(string machineConfigPath) { await Throttle.WaitAsync(); try { // 执行单机部署 } finally { Throttle.Release(); } }为什么要限制并发而不是无脑全开因为所有机器同时从同一个文件服务器拉取部署包可能导致文件服务器带宽被打满。实测并发3台最稳妥网络不会堵死整体速度又够快。第三板斧数据库脚本幂等化。有些部署不只是覆盖文件还要执行SQL脚本。我见过的处理方式是“部署前手动跑一遍SQL”这又变成排队了。正确做法是把数据库脚本改成幂等脚本——比如插入语句前先判断是否已存在加字段用IF NOT EXISTS包裹。这样脚本可以重复执行部署脚本不用区分“第一次部署”还是“升级部署”。5.3 实战中踩过的坑与解决方案说几个只有实际跑到生产环境才会遇到的坑。坑一服务停了文件还是被占用。现象是备份阶段File.Copy抛IOException提示另一个程序正在使用文件。原因除了上面说的句柄未释放还可能是有独立进程在跑程序模块比如一个后台worker exe。解决方法是ServiceController停掉主服务后再用Process.GetProcessesByName把跟程序相关的所有辅助进程找到并停掉。只停服务不够要把“跟这摊程序有关的所有进程”都视为部署对象。坑二静默参数写错安装包弹出UI。NSIS的静默参数是/S字母大写写成小写/s就被当成普通参数传进去了安装包正常弹窗。无人值守部署最怕弹窗因为没人点部署就挂在那一整天。我现在的做法是任何新接手的安装包类型先在本地虚拟机手动跑一遍静默参数确认无UI弹出再进正式部署脚本。坑三杀毒软件把脚本exe当风险程序。C#编译出的控制台exe如果新放在某个机器上Windows Defender偶尔会多管闲事。尤其当你用Process.Start启动另一个exe时杀毒软件可能拦截。规避方案有两个一是给脚本exe做代码签名二是把脚本加到杀毒白名单。个人项目用第二种就行公司内部工具建议还是做签名省心很多。坑四日志不完整出问题不知道卡在哪一步。第一版脚本只在“开始”和“结束”写日志结果某次部署失败只知道“失败”但不知道是停服务时失败还是覆盖文件时失败。后来我改成每个阶段进入、退出、异常都写日志并且标注耗时。现在出了问题看日志三步之内能定位到具体模块。6. 扩展从“养虾”到“养整个水族馆”6.1 多机批量部署与并发控制单机部署脚本跑通之后下一步自然是多机。我在配置里加了一个“目标机器列表”脚本对每一台机器执行同一套逻辑但每台机器用自己的配置文件因为IP、服务名可能各不相同。建议把整个部署包先上传到一个共享目录然后让每台工控机上的部署脚本从共享目录拉取安装包。注意别让所有机器同时拉大文件并发数控制在3~5实测网络和IO都不会被拖垮。多机部署还要额外考虑一个事失败隔离。一台机器部署失败不该影响其他机器继续部署。我的策略是每台机器的结果单独记录成一份状态文件success或failed最后汇总一套“部署报告”而不是一遇到某台机器失败就整个流程中断。6.2 定时触发与CI/CD联动自动化部署最有价值的地方是可以不依赖人触发。结合Windows任务计划程序部署脚本完全可以在半夜两点定点跑。实现方式很简单任务计划程序创建任务触发器选择“每天凌晨2点”操作选择运行编译好的部署exe参数传入“--auto”表示自动模式。在自动模式下脚本不输出交互提示只写日志和退出码。如果团队已经有了CI/CD系统比如GitLab CI也可以把部署脚本作为发布流水线的最后一个阶段代码推送自动构建、自动打包、自动触发部署脚本。这时Main方法返回的退出码就特别关键了——CI系统拿到退出码0才算发布成功非0就标红并且能直接把日志抓出来看。6.3 部署后监控与回滚预案自动化部署打开了“无人值守”的窗户也打开了“无人发现故障就晚了”的窗户。所以部署之后的监控必须跟上。我做的相对简单的方案是健康检查失败时RollbackManager自动从最新备份目录恢复文件并把服务恢复到部署前版本然后日志里明确标注“已自动回滚”。回滚执行完再发一封带日志摘要的通知邮件。备份保留策略也很重要。我保留最近五次部署前的备份每台机器上的备份目录按“备份时间戳应用名”命名。这样回滚时能明确选择“回滚到哪一次版本”而不是只有“最新”和“更早”两个模糊选项。如果团队有余力可以把健康检查结果上报到统一监控平台做一个简单的部署看板。但即使不做看板只要日志和回滚机制靠谱自动化部署的体感就已经远优于手动排队了。最后说点实际体会。这套脚本从第一版批处理到第二版PowerShell再到第三版C#前前后后迭代了三个月。真正稳定跑了大半年之后我发现最值的不是省下的那几分钟或者几小时而是部署这件事本身从“值得紧张”变成了“不必关注”。如果你们也经常被重复部署按在地上摩擦我强烈建议别再硬扛了花半天时间把脚本按照自己的场景改一改从此告别“千人排队”的日常。
返回列表