Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,518 @@
# 探测 IP 可配置化 + 移除组播 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 删除实测无效的组播发现代码;开发机探测 IP 从代码写死改为设备本地配置 `persistentDataPath/dev_server.txt`,底包发现失败时弹 IMGUI 输入框录入并验证后持久化。
**Architecture:** 三个小单元:`DevServerConfig`(Base,dev_server.txt 读写)、`DevServerPrompt`(Base,纯 IMGUI 三态输入框)、`LoadDll` 协程编排两轮发现+弹框循环。`LocalCdnDiscovery.Discover``extraProbeIp` 参数(后台线程不能碰 persistentDataPath,配置由主线程读好传入);热更层 `DevServerDiscovery` 内联同语义配置读取(XWorld.Link 未引用 Base 程序集,沿用"内联+两边同步"惯例)。
**Tech Stack:** C#/.NET(netstandard2.1 共享协议)、Unity 客户端(IMGUI)、xUnit 服务端测试。
**Spec:** `docs/superpowers/specs/2026-07-06-dev-server-config-design.md`
**注意:** 项目根 `.git` 是空目录、非有效 git 仓库,省略 commit 步骤,以"运行测试/目视核对"作为每任务收尾。
**关键背景(执行者必读):**
- 前一个特性(组播叠加,plan `2026-07-06-multicast-discovery.md`)已实施但实测跨网段不通,本计划将其代码**全部删除**。删除时以"还原并改进"为准——不是逐字回滚,注释要更新为新语义(配置文件而非写死 IP)。
- `Server\Framework.Shared\Framework.Shared.csproj` 直接编译 `Client\Assets\Framework\Shared\**\*.cs`,共享协议常量改一处两边生效。**删 `DevDiscovery.MulticastGroup` 前必须先删掉它的最后一个使用点(`DevServerDiscovery.cs`),否则 Unity 编译会断**——任务顺序已按此安排,勿调换。
- `Client\Assets\Base\**` 是 AOT 底包程序集(Base.asmdef),不能引用热更程序集;`Client\Assets\Script\**` 是热更程序集(XWorld.Link.asmdef),其引用列表**没有 Base**,所以热更层也引用不到 Base 的新类。
- `Application.persistentDataPath` 只能在 Unity 主线程访问;两个发现器都跑在后台线程,配置读取必须在主线程完成(参数传入/构造函数存字段)。
- 代码注释用中文、全角标点;网络失败静默吞掉是本模块的既定风格。
- 新建 .cs 文件无需手写 .meta,Unity 编辑器打开时自动生成。
- 测试命令在仓库根执行,路径含 `#`,bash 中必须加引号:`cd "/d/UD/AI/AIC#Project"`
---
### Task 1: 删除服务端组播代码与测试
**Files:**
- Modify: `Server\Gateway\DevDiscoveryResponder.cs`
- Modify: `Server\Gateway.Tests\DevDiscoveryResponderTests.cs`
(共享常量 `DevDiscovery.MulticastGroup` 此任务**不删**——客户端 `DevServerDiscovery.cs` 还在用,Task 4 收尾时删。)
- [ ] **Step 1: 删响应器的组播 join**
`Server\Gateway\DevDiscoveryResponder.cs` 三处:
1. 删文件头 `using System.Net.NetworkInformation;`
2. 删构造函数中这一行:
```csharp
JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
```
3. 删整个 `JoinMulticastOnAllNics` 私有静态方法(含其上三行中文注释,从 `// 逐块 Up 状态非回环 IPv4 网卡加入组播组` 到方法闭括号)。
删后构造函数应恢复为:
```csharp
_udp = new UdpClient(AddressFamily.InterNetwork);
_udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udp.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort));
Port = ((IPEndPoint)_udp.Client.LocalEndPoint).Port;
}
public void Start()
```
- [ ] **Step 2: 删组播测试**
`Server\Gateway.Tests\DevDiscoveryResponderTests.cs`:删整个 `MulticastQuery_ReceivesReply` 测试方法(`[Fact]` 到方法闭括号)。原有 3 条测试(`ValidQuery_ReceivesReply_WithAdvertisedAddresses``WrongToken_NoReply``GarbagePacket_Ignored_LoopStillAnswers`)与 `QueryAsync` 辅助方法不动。
- [ ] **Step 3: 跑 Gateway 测试确认绿**
```bash
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests
```
预期:全部通过,总数比之前少 1(42 条)。
---
### Task 2: 新建 DevServerConfig
**Files:**
- Create: `Client\Assets\Base\DevServerConfig.cs`
无自动化测试(Unity 客户端代码);正确性靠 Task 7 编辑器/真机验收。
- [ ] **Step 1: 写文件**
完整内容:
```csharp
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using UnityEngine;
// 内网联调配置:开发机探测 IP 的本地持久化(persistentDataPath/dev_server.txt,单行 IPv4)。
// 跨网段时广播/组播都出不了网段(路由器不转发),只能向已知开发机 IP 单播——该 IP 由本文件持久化,
// 首次由 DevServerPrompt 弹框录入并验证成功后写入(见 LoadDll.CoLocalDiscoverThenDownload)。
// 注意:Application.persistentDataPath 只能在主线程访问,故 Load/Save 只能在主线程调;
// 发现线程(后台)需要的值由调用方在主线程读好传入。
// 热更层 DevServerDiscovery 内联了同语义的读取(XWorld.Link 未引用 Base 程序集),改格式时两边须同步。
public static class DevServerConfig
{
public static string FilePath => Path.Combine(Application.persistentDataPath, "dev_server.txt");
// 读配置:文件不存在/IO 异常/内容不是合法 IPv4 → null(静默,等同"无配置")。
public static string Load()
{
try
{
string path = FilePath;
if (!File.Exists(path)) return null;
string line = File.ReadAllText(path).Trim();
if (line.Length == 0) return null;
if (!IPAddress.TryParse(line, out IPAddress ip)) return null;
if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4
return ip.ToString();
}
catch (Exception)
{
return null;
}
}
// 写配置:单行覆盖;IO 异常静默吞(写不进只是下次还弹框,不致命)。
public static void Save(string ip)
{
try { File.WriteAllText(FilePath, ip ?? ""); }
catch (Exception) { }
}
}
```
- [ ] **Step 2: 目视核对**
所有类型(`Path`/`File`/`IPAddress`/`AddressFamily`/`Application`)均被文件顶部 using 覆盖;类在全局命名空间(与 `LocalCdnDiscovery` 一致)。
---
### Task 3: LocalCdnDiscovery 删组播、加 extraProbeIp 参数
**Files:**
- Modify: `Client\Assets\Base\LocalCdnDiscovery.cs`
- [ ] **Step 1: 删内联组播常量**
删这一行(const 块中):
```csharp
private const string MulticastGroup = "239.192.48.92"; // = DevDiscovery.MulticastGroup
```
- [ ] **Step 2: 删 DevExtraProbeIps 整块**
删字段及其上方全部 4 行注释(从 `// 跨网段内网联调用:``private static readonly string[] DevExtraProbeIps = { "192.168.130.40" };`)。
- [ ] **Step 3: 改 Discover 签名与方法体**
方法头注释与签名改为:
```csharp
// 阻塞式发现,须在后台线程调用(会最多阻塞 durationMs)。
// 在窗口内周期重发查询(向广播 + 127.0.0.1 单播),收到首个合法应答即返回其 ResourceBaseUrl(CDN 根);超时返回 null。
// extraProbeIp:额外单播探测目标(跨网段联调用,来自 dev_server.txt 或弹框输入,见 DevServerConfig/DevServerPrompt)。
// 广播/定向广播不被路由器转发(RFC 2644),组播亦已实测不通(路由器未开组播路由),跨网段只能靠单播已知 IP。
// 注意:本方法跑在后台线程,不能碰 Unity 主线程 API(如 persistentDataPath),配置由调用方在主线程读好传入。
public static string Discover(string token, int durationMs = 1500, string extraProbeIp = null)
```
方法体内四处改动:
1. 删 TTL 设置(共 3 行,`ReceiveTimeout = 250;` 之后):
```csharp
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
// 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
```
2. targets 构造:把原 `foreach (string s in DevExtraProbeIps) ...` 两行替换为:
```csharp
if (!string.IsNullOrEmpty(extraProbeIp) && IPAddress.TryParse(extraProbeIp, out IPAddress extra))
targetList.Add(new IPEndPoint(extra, Port));
```
3. 删组播端点/网卡列表构建及日志(共 5 行,targets 的 Debug.Log 之后):
```csharp
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(MulticastGroup), Port);
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
UnityEngine.Debug.Log("[Discovery] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
```
4. 删重发块内的逐网卡组播发送与零网卡兜底(targets foreach 之后、`nextSend = ...` 之前的 13 行):
```csharp
foreach (IPAddress nicAddr in multicastNics)
{
try
{
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
udp.Send(query, query.Length, multicastEp);
}
catch { }
}
if (multicastNics.Count == 0)
{
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
}
```
- [ ] **Step 4: 更新 catch 注释、删辅助方法**
1. `catch (Exception)` 内注释改为:
```csharp
// 忽略:发现失败由调用方处理(弹框输入或回退 127.0.0.1)
```
2. 删整个 `GetLocalIPv4Addresses()` 方法(含其上一行注释 `// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。`)。`GetDirectedBroadcasts()``IsPrivateV4`、编解码方法全部不动。
- [ ] **Step 5: 目视核对**
全文不再出现 `Multicast`/`multicast`/`DevExtraProbeIps`/`192.168.130.40`;大括号配平;`extraProbeIp` 是唯一新增参数且带默认值(既有调用 `Discover("xw-dev", 1500)` 不受影响)。
---
### Task 4: DevServerDiscovery 删组播、内联配置读取;删共享组播常量
**Files:**
- Modify: `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs`
- Modify: `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`
- [ ] **Step 1: 替换 DevExtraProbeIps 为配置字段**
删字段及其上方 4 行注释(从 `// 跨网段内网联调用:``private static readonly string[] DevExtraProbeIps = { "192.168.130.40" };`)。
字段区加一行(`private readonly string _token;` 之后):
```csharp
private readonly string _extraProbeIp;
```
构造函数改为:
```csharp
public DevServerDiscovery(string token)
{
_token = token;
// 跨网段联调:广播/定向广播不被路由器转发(RFC 2644),组播亦实测不通(路由器未开组播路由),
// 唯一可靠路径是向已知开发机 IP 单播。IP 来自 persistentDataPath/dev_server.txt——底包首启弹框
// 录入(见 Base 层 DevServerConfig/DevServerPrompt)。persistentDataPath 不能在后台线程访问,
// 故在主线程构造时读好存字段,Run(后台线程)只用字段。
_extraProbeIp = ReadConfiguredProbeIp();
}
```
- [ ] **Step 2: 内联配置读取方法**
`Stop()` 方法之后、类闭括号之前新增:
```csharp
// 读 dev_server.txt 首行并校验为 IPv4,失败一律 null。与 Base 层 DevServerConfig.Load() 语义一致——
// XWorld.Link 未引用 Base 程序集,按本项目"内联+两边同步"惯例复制最小实现,改格式时两边须同步。
private static string ReadConfiguredProbeIp()
{
try
{
string path = System.IO.Path.Combine(Application.persistentDataPath, "dev_server.txt");
if (!System.IO.File.Exists(path)) return null;
string line = System.IO.File.ReadAllText(path).Trim();
if (line.Length == 0) return null;
if (!IPAddress.TryParse(line, out IPAddress ip)) return null;
if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4
return ip.ToString();
}
catch (Exception)
{
return null;
}
}
```
(`System.IO` 用全限定,避免新增 using;`Application`/`IPAddress`/`AddressFamily`/`Exception` 均已有 using 覆盖。)
- [ ] **Step 3: Run() 删组播、改 targets**
1. 删 TTL 设置(共 3 行,`ReceiveTimeout = 250;` 之后):
```csharp
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
// 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
```
2. targets 构造:把原 `foreach (string s in DevExtraProbeIps) ...` 两行替换为:
```csharp
if (!string.IsNullOrEmpty(_extraProbeIp) && IPAddress.TryParse(_extraProbeIp, out IPAddress extra))
targetList.Add(new IPEndPoint(extra, DevDiscovery.Port));
```
3. 删组播端点/网卡列表构建及日志(targets 的 Debug.Log 之后共 5 行,内容同 Task 3 Step 3 第 3 点,仅 `DevDiscovery.MulticastGroup`/`DevDiscovery.Port`/`[DevTest]` 之别)。
4. 删重发块内逐网卡组播发送与零网卡兜底(13 行,内容同 Task 3 Step 3 第 4 点)。
5. 删整个 `GetLocalIPv4Addresses()` 方法(含其上一行注释)。
- [ ] **Step 4: 删共享协议组播常量**
`Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`:删 `MulticastGroup` 常量及其上两行注释:
```csharp
// 组播发现组:组织本地范围(RFC 2365, 239.192.0.0/14),配合发送端 TTL>1 可跨内网多级路由;
// 能否真正跨网段取决于路由器是否开启组播路由(PIM/IGMP Proxy)。
public const string MulticastGroup = "239.192.48.92";
```
删后 `DevDiscovery` 类只剩 `Port`/`ProtoVersion`/`Magic`/`TypeQuery`/`TypeReply`
- [ ] **Step 5: 全仓库确认无残留引用**
```bash
cd "/d/UD/AI/AIC#Project" && grep -rn "MulticastGroup\|239.192.48.92" --include="*.cs" Client/Assets/Base Client/Assets/Script Client/Assets/Framework Server/Gateway Server/Gateway.Tests
```
预期:无输出(docs/ 里的历史记录不算)。
- [ ] **Step 6: 服务端全量回归**
```bash
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
```
预期:全部通过(172 条:比组播版少 1 条测试)。
---
### Task 5: 新建 DevServerPrompt
**Files:**
- Create: `Client\Assets\Base\DevServerPrompt.cs`
- [ ] **Step 1: 写文件**
完整内容:
```csharp
using UnityEngine;
// 内网联调:发现失败时的开发机 IP 输入框(纯 IMGUI,风格同热更层 DevServerSwitchUI)。
// 只负责输入与三态状态机,不做网络/文件 IO——探测与写配置由 LoadDll.CoLocalDiscoverThenDownload 编排:
// [确定] → Confirmed,外部拿 InputIp 去探测;失败则外部调 ResetToWaiting(错误文案) 拨回弹框;
// [跳过] → Skipped,外部走 127.0.0.1 回退。
public sealed class DevServerPrompt : MonoBehaviour
{
public enum PromptState { Waiting, Confirmed, Skipped }
public PromptState State { get; private set; } = PromptState.Waiting;
public string InputIp = "";
public string Error = ""; // 非空则红字显示
// 外部探测失败后调用:显示错误并回到可输入状态。
public void ResetToWaiting(string error)
{
Error = error ?? "";
State = PromptState.Waiting;
}
private void OnGUI()
{
if (State != PromptState.Waiting) return;
const int W = 560, H = 190;
GUILayout.BeginArea(new Rect((Screen.width - W) / 2, (Screen.height - H) / 3, W, H), GUI.skin.box);
GUILayout.Label("内网发现失败:请输入开发机 IP(网关须 --lan --devToken xw-dev");
InputIp = GUILayout.TextField(InputIp ?? "", GUILayout.Height(44));
if (!string.IsNullOrEmpty(Error))
{
GUI.color = new Color(1f, 0.4f, 0.3f);
GUILayout.Label(Error);
GUI.color = Color.white;
}
GUILayout.FlexibleSpace();
GUILayout.BeginHorizontal();
if (GUILayout.Button("确定", GUILayout.Height(44))) { Error = ""; State = PromptState.Confirmed; }
if (GUILayout.Button("跳过", GUILayout.Height(44), GUILayout.Width(120))) State = PromptState.Skipped;
GUILayout.EndHorizontal();
GUILayout.EndArea();
}
}
```
- [ ] **Step 2: 目视核对**
仅依赖 UnityEngine;无网络/文件 IO;`State` 外部只读(private set),状态回拨只经 `ResetToWaiting`
---
### Task 6: LoadDll 流程重写
**Files:**
- Modify: `Client\Assets\Base\LoadDll.cs`(`CoLocalDiscoverThenDownload` 方法,约 191-220 行,`#if !UNITY_WEBGL` 块内)
- [ ] **Step 1: 整体替换 CoLocalDiscoverThenDownload**
把方法(含其上两行注释)整体替换为:
```csharp
//内网热更:先 UDP 广播发现开发机 CDN(拿其 LAN IP),成功则改用它;失败弹输入框让开发者输入开发机 IP,
//探测成功写入 persistentDataPath/dev_server.txt(下次启动第 1 轮直接命中不再弹);[跳过]回退 127.0.0.1(真机需 adb reverse tcp:15081)。
//token 须与热更层 GlobalData.LanDiscoveryToken 及服务端 Gateway.Runner --devToken 一致(均为 "xw-dev")。
IEnumerator CoLocalDiscoverThenDownload()
{
Debug.Log($"[LoadDll] CoLocalDiscoverThenDownload begin");
//第 1 轮:广播/loopback/定向广播 + dev_server.txt 已存 IP(若有)。
//Discover 会阻塞(UDP 收发+超时),放后台线程跑,协程逐帧等它结束,避免卡加载界面;
//persistentDataPath 不能在后台线程访问,配置在主线程先读好传入。
string cdnRoot = null;
string savedIp = DevServerConfig.Load();
{
Thread th = new Thread(() => { cdnRoot = LocalCdnDiscovery.Discover("xw-dev", 1500, savedIp); }) { IsBackground = true };
th.Start();
while (th.IsAlive)
yield return null;
}
//第 1 轮失败 → 弹输入框(预填旧值,开发机换 IP 时可直接改);输入 IP 验证探测成功才写配置。
if (string.IsNullOrEmpty(cdnRoot))
{
DevServerPrompt prompt = gameObject.AddComponent<DevServerPrompt>();
prompt.InputIp = savedIp ?? "";
while (string.IsNullOrEmpty(cdnRoot))
{
while (prompt.State == DevServerPrompt.PromptState.Waiting)
yield return null;
if (prompt.State == DevServerPrompt.PromptState.Skipped)
break;
//Confirmed:先主线程校验格式,非法不发起探测直接回弹框。
string inputIp = (prompt.InputIp ?? "").Trim();
if (!System.Net.IPAddress.TryParse(inputIp, out _))
{
prompt.ResetToWaiting($"IP 格式不对:{inputIp}");
continue;
}
Thread th = new Thread(() => { cdnRoot = LocalCdnDiscovery.Discover("xw-dev", 1500, inputIp); }) { IsBackground = true };
th.Start();
while (th.IsAlive)
yield return null;
if (string.IsNullOrEmpty(cdnRoot))
{
prompt.ResetToWaiting($"探测失败:连不到 {inputIp}:48923(网关起了吗?防火墙放行 UDP 48923 了吗?)");
continue;
}
DevServerConfig.Save(inputIp);
Debug.Log($"[LoadDll] dev server ip saved: {inputIp}");
}
Destroy(prompt);
}
if (!string.IsNullOrEmpty(cdnRoot))
{
if (!cdnRoot.EndsWith("/"))
cdnRoot += "/";
//发现回复的 ResourceBaseUrl 是 CDN 根(http://<LAN-IP>:15081/)DLL 热更在其下的 XWorld/<platform>/。
UpdateServer = $"{cdnRoot}XWorld/{sPlatform}/";
Debug.Log($"[LoadDll] LAN CDN discovered: {UpdateServer}");
}
else
{
UpdateServer = $"http://127.0.0.1:15081/XWorld/{sPlatform}/";
Debug.LogWarning($"[LoadDll] LAN CDN discovery failed/skipped, fallback {UpdateServer} (真机需 adb reverse tcp:15081 tcp:15081,且开发机网关须 --lan --devToken xw-dev)");
}
yield return DownLoadAssets(this.StartGame);
}
```
要点:
- `Thread` 已有 using(原方法就用);`System.Net.IPAddress` 全限定,不新增 using。
- 探测成功但来源无法区分(广播/文件 IP),故第 1 轮成功**不**写文件——只有弹框输入并验证成功才 `Save`(spec 行为规格)。
- `Destroy(prompt)` 在跳出循环后统一执行(成功或跳过都销毁)。
- [ ] **Step 2: 目视核对**
方法内所有 `yield return` 都在协程主体(不在 lambda 内);`cdnRoot` 由后台线程赋值、主线程在 `th.IsAlive == false` 后才读(与原代码同模式);两处 `Thread` 变量分别在独立作用域(第 1 轮包在 `{}` 块里,无重名冲突)。
---
### Task 7: 端到端验证
**Files:** 无代码改动,纯验证。
- [ ] **Step 1: 服务端全量测试**
```bash
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
```
预期:172 条全部通过。
- [ ] **Step 2: 残留扫描**
```bash
cd "/d/UD/AI/AIC#Project" && grep -rn "Multicast\|239.192.48.92\|192.168.130.40\|DevExtraProbeIps\|GetLocalIPv4Addresses" --include="*.cs" Client/Assets/Base Client/Assets/Script Client/Assets/Framework Server/Gateway Server/Gateway.Tests
```
预期:无输出。
- [ ] **Step 3: 编辑器同机自测(需用户配合)**
起网关(`--lan --devToken xw-dev`)+ 编辑器运行:loopback 一轮即中,弹框不出现,`[LoadDll] LAN CDN discovered` 正常——与改前行为一致。
- [ ] **Step 4: 真机验收(需用户配合,重打 APK——Base 是 AOT 底包,DLL 热更交付不了)**
按 spec 验收标准:
1. 跨网段首启(先清 App 数据或删 dev_server.txt):第 1 轮失败 → 弹框 → 输入 192.168.130.40 → 探测成功 → 正常加载
2. 重启:不弹框,第 1 轮直接命中(日志 targets 里含 192.168.130.40)
3. [跳过]:回退 127.0.0.1,与现状一致
4. (可选)改 dev_server.txt 为错误 IP 模拟开发机换 IP:弹框预填旧值可改