NewLife/NewLife.Remoting

集成测试体系建设&依赖升级

- 统一升级XCode、Redis、Stardust、Cube等依赖至2026.403/404,提升兼容性。
- 新增XUnitTest.Samples端到端集成测试项目,覆盖ZeroServer/IoTZero全链路流程,支持Kestrel真实启动与多项目隔离。
- 新增ZeroRpcServer多协议集成测试,完善TCP/UDP/WS/HTTP全流程断言。
- 优化DefaultDeviceService令牌校验、Program.cs测试环境隔离等细节,提升测试健壮性。
- 更新解决方案文件,纳入新测试项目,便于统一管理和持续集成。

无破坏性API变更,主要为测试和文档增强,生产环境无性能影响。
大石头 authored at 2026-04-27 15:33:16
2cffd2f
Tree
1 Parent(s) 0613502
Summary: 23 changed files with 1575 additions and 31 deletions.
Modified +114 -0
Modified +1 -1
Modified +5 -2
Modified +14 -0
Modified +5 -5
Modified +16 -5
Modified +2 -2
Modified +1 -1
Modified +1 -1
Modified +3 -3
Modified +16 -5
Modified +4 -4
Added +368 -0
Added +164 -0
Added +20 -0
Added +12 -0
Added +7 -0
Added +36 -0
Added +360 -0
Added +125 -0
Added +140 -0
Added +158 -0
Modified +3 -2
Modified +114 -0
diff --git a/.github/instructions/xcode.instructions.md b/.github/instructions/xcode.instructions.md
index 2a79c8e..b1e1788 100644
--- a/.github/instructions/xcode.instructions.md
+++ b/.github/instructions/xcode.instructions.md
@@ -344,6 +344,44 @@ var count = User.FindCount(User._.Status == 1);
 var maxId = User.FindMax(User._.Id, null);
 ```
 
+### 5.2.1 字段表达式方法参考
+
+`FieldItem`(`Entity._.FieldName`)提供丰富的表达式方法,可直接用于 `WhereExpression` 或 `FindAll` 条件:
+
+| 方法 | 说明 | 生成 SQL 示例 |
+|------|------|------|
+| `_.Name == value` | 等于 | `Name = 'test'` |
+| `_.Name != value` | 不等于 | `Name <> 'test'` |
+| `_.Id > value` | 大于 | `Id > 10` |
+| `_.Id >= value` | 大于等于 | `Id >= 10` |
+| `_.Id < value` | 小于 | `Id < 100` |
+| `_.Id <= value` | 小于等于 | `Id <= 100` |
+| `_.Name.Contains("x")` | 包含 | `Name Like '%x%'` |
+| `_.Name.NotContains("x")` | 不包含 | `Name Not Like '%x%'` |
+| `_.Name.StartsWith("x")` | 开头 | `Name Like 'x%'` |
+| `_.Name.EndsWith("x")` | 结尾 | `Name Like '%x'` |
+| `_.Id.In(list)` | In 操作 | `Id In(1,2,3)` |
+| `_.Id.NotIn(list)` | Not In | `Id Not In(1,2,3)` |
+| `_.Name.IsNull()` | 是否 NULL | `Name Is Null` |
+| `_.Name.NotIsNull()` | 不为 NULL | `Name Is Not Null` |
+| `_.Name.IsNullOrEmpty()` | NULL 或空串(仅 String) | `(Name Is Null Or Name='')` |
+| **`_.Name.NotIsNullOrEmpty()`** | **不为 NULL 且不为空串**(仅 String) | `(Name Is Not Null And Name<>'')` |
+| `_.Flag.IsTrue(true)` | 布尔真值 | `Flag=True` |
+| `_.Flag.IsTrue(false)` | 布尔假值/NULL | `Flag<>True Or Flag Is Null` |
+| `_.Id.Asc()` | 升序排序 | `Id Asc` |
+| `_.Id.Desc()` | 降序排序 | `Id Desc` |
+
+**空值判断常见写法**:
+```csharp
+// ✅ 推荐:使用内置方法
+exp &= _.DevName.NotIsNullOrEmpty();  // 字段不为空且不为空串
+
+// ❌ 不推荐:手写两个条件
+exp &= _.DevName != "" & _.DevName != null;
+```
+
+**注意**:`IsNullOrEmpty()` / `NotIsNullOrEmpty()` 仅支持 `String` 类型字段,非字符串字段请使用 `IsNull()` / `NotIsNull()`。
+
 ### 5.3 批量操作
 
 ```csharp
@@ -381,6 +419,82 @@ var list = User.FindAllWithCache();
 var user = User.FindByKeyWithCache(1);
 ```
 
+### 5.6 Biz 文件数据层逻辑规范
+
+**核心理念**:所有需要人工编写的数据层逻辑代码,一律放在实体类的 Biz 文件(`*.Biz.cs`)中,包括**高级查询**(`#region 高级查询`)与**添删改查重载**等。外部调用方只传语义化参数,不感知 `WhereExpression` 拼接细节。
+
+#### 高级查询封装选择
+
+| 场景 | 方法形式 | 说明 | 示例 |
+|------|---------|------|------|
+| 返回单个对象,参数 ≤2 个 | `FindByXxx` | 未查到时返回 `null` | `FindByUserId(userId)` |
+| 返回列表,参数 ≤2 个,无模糊查询、无分页 | `FindAllByXxx` / `FindAllByXxxAndYyy` | 未查到时返回空列表,**不返回 null** | `FindAllByUserId(userId)` |
+| 参数较多,或含模糊查询,或含分页 | `Search(...)` | 未查到时返回空列表,**不返回 null** | `Search(userId, key, page)` |
+| 实体缓存内过滤(`Meta.Cache.FindAll(...)`) | `FindAllCachedXxx` / `FindCachedXxx` | — | `FindAllCachedEnabled()` / `FindCachedByQuestion(q)` |
+
+**命名约定说明**:
+- `FindByXxx`:返回**单个对象**(`TEntity?`),语义为"按条件查找一条记录",未找到返回 `null`
+- `FindAllByXxx`:返回**对象列表**(`IList<TEntity>`),语义为"按条件查找所有匹配记录",结果为空时返回空列表而非 `null`
+- `Search`:同样返回**对象列表**,结果为空时返回空列表而非 `null`
+
+#### Search 方法签名约定
+
+参数顺序(由左到右,按重要程度):
+
+```
+Search(业务过滤字段..., DateTime start, DateTime end, String? key, PageParameter page)
+```
+
+- 时间区间 `(DateTime start, DateTime end)` 放在 key / page 左边
+- 模糊查询关键词 `String? key` 放在 page 左边(倒数第二)
+- 分页参数 `PageParameter page` 始终最后
+
+#### 表达式简写
+
+在 Biz 文件的静态方法内部,可**省略类名前缀**:
+
+```csharp
+// ✅ 推荐(Biz 文件内部)
+var exp = _.UserId == userId;
+if (!keyword.IsNullOrEmpty()) exp &= _.Title.Contains(keyword.Trim());
+return FindAll(exp, page);
+
+// ❌ 避免(外部业务代码中拼接表达式)
+var exp = Conversation._.UserId == userId;
+if (!keyword.IsNullOrEmpty()) exp &= Conversation._.Title.Contains(keyword.Trim());
+var list = Conversation.FindAll(exp, p);
+```
+
+#### 示例
+
+```csharp
+// Biz 文件内 #region 高级查询
+
+/// <summary>根据用户编号查找最新一条会话</summary>
+/// <param name="userId">用户编号</param>
+/// <returns>会话对象,不存在时返回 null</returns>
+public static Conversation? FindByUserId(Int32 userId) => Find(_.UserId == userId);
+
+/// <summary>根据用户编号查找所有会话</summary>
+/// <param name="userId">用户编号</param>
+/// <returns>会话列表,不存在时返回空列表</returns>
+public static IList<Conversation> FindAllByUserId(Int32 userId) => FindAll(_.UserId == userId);
+
+/// <summary>分页搜索用户会话列表</summary>
+/// <param name="userId">用户编号</param>
+/// <param name="keyword">标题关键字,为空时不过滤</param>
+/// <param name="page">分页参数</param>
+/// <returns>会话列表,不存在时返回空列表</returns>
+public static IList<Conversation> Search(Int32 userId, String? keyword, PageParameter page)
+{
+    var exp = new WhereExpression();
+    exp &= _.UserId == userId;
+    if (!keyword.IsNullOrEmpty()) exp &= _.Title.Contains(keyword.Trim());
+
+    return FindAll(exp, page);
+}
+```
+
 ---
 
 ## 6. 运行时机制
Modified +1 -1
diff --git a/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj b/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj
index a27cc5b..9dd591f 100644
--- a/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj
+++ b/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj
@@ -64,7 +64,7 @@
     </None>
   </ItemGroup>
   <ItemGroup>
-    <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+    <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
   </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\NewLife.Remoting\NewLife.Remoting.csproj" />
Modified +5 -2
diff --git a/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs b/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
index 9061fab..158885a 100644
--- a/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
+++ b/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
@@ -436,11 +436,14 @@ public abstract class DefaultDeviceService<TDevice, TOnline>(ISessionManager ses
         var target = GetDevice(model.Code!);
         if (target == null) throw new ArgumentNullException(nameof(model.Code), "未找到指定设备 " + model.Code);
 
-        // 验证令牌
+        // 验证令牌。[AllowAnonymous] 接口(如 SendCommand)使用应用令牌而非设备令牌:
+        // 若未注册 ITokenService(测试环境),则跳过;注册了则必须携带合法令牌。
         var tokenService = serviceProvider.GetService<ITokenService>();
         if (tokenService != null)
         {
-            var (jwt, ex) = tokenService.DecodeToken(context.Token!);
+            if (context.Token.IsNullOrEmpty())
+                throw new ApiException(ApiCode.Unauthorized, "SendCommand 需要应用令牌");
+            var (jwt, ex) = tokenService.DecodeToken(context.Token);
             if (ex != null) throw ex;
         }
 
Modified +14 -0
diff --git a/NewLife.Remoting.sln b/NewLife.Remoting.sln
index 4a24dbf..9deffc2 100644
--- a/NewLife.Remoting.sln
+++ b/NewLife.Remoting.sln
@@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Benchmark", "Benchmark", "{
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NewLife.Remoting.Benchmarks", "Benchmark\NewLife.Remoting.Benchmarks.csproj", "{F57E02C1-1ED8-4029-BB8A-14CDCE233551}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XUnitTest.Samples", "XUnitTest.Samples\XUnitTest.Samples.csproj", "{8058AB29-267E-4678-8615-61F44F733DAE}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -167,6 +169,18 @@ Global
 		{F57E02C1-1ED8-4029-BB8A-14CDCE233551}.Release|x64.Build.0 = Release|Any CPU
 		{F57E02C1-1ED8-4029-BB8A-14CDCE233551}.Release|x86.ActiveCfg = Release|Any CPU
 		{F57E02C1-1ED8-4029-BB8A-14CDCE233551}.Release|x86.Build.0 = Release|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x64.Build.0 = Debug|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x86.Build.0 = Debug|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Release|Any CPU.Build.0 = Release|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Release|x64.ActiveCfg = Release|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Release|x64.Build.0 = Release|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Release|x86.ActiveCfg = Release|Any CPU
+		{8058AB29-267E-4678-8615-61F44F733DAE}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
Modified +5 -5
diff --git a/Samples/IoTZero/IoTZero.csproj b/Samples/IoTZero/IoTZero.csproj
index 450f1fe..3b3df38 100644
--- a/Samples/IoTZero/IoTZero.csproj
+++ b/Samples/IoTZero/IoTZero.csproj
@@ -23,12 +23,12 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Cube.Core" Version="6.9.2026.318-beta1121" />
+    <PackageReference Include="NewLife.Cube.Core" Version="6.10.2026.404" />
     <PackageReference Include="NewLife.IoT" Version="2.7.2026.201" />
-    <PackageReference Include="NewLife.MQTT" Version="3.0.2026.305" />
-    <PackageReference Include="NewLife.Redis" Version="6.5.2026.303" />
-    <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.307" />
-    <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+    <PackageReference Include="NewLife.MQTT" Version="3.0.2026.403" />
+    <PackageReference Include="NewLife.Redis" Version="6.5.2026.403" />
+    <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.403" />
+    <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
   </ItemGroup>
 
   <ItemGroup>
Modified +16 -5
diff --git a/Samples/IoTZero/Program.cs b/Samples/IoTZero/Program.cs
index 9cc164c..a8ffe3d 100644
--- a/Samples/IoTZero/Program.cs
+++ b/Samples/IoTZero/Program.cs
@@ -65,18 +65,26 @@ app.MapControllerRoute(
     name: "default",
     pattern: "{controller=CubeHome}/{action=Index}/{id?}");
 
-app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
+if (!app.Environment.IsEnvironment("Testing"))
+    app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
 
 // 反射查找并调用客户端测试,该代码仅用于测试,实际项目中不要这样做
-var clientType = "IoTZero.Clients.ClientTest".GetTypeEx();
-var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
-if (test != null) _ = Task.Run(() => test(app.Services));
+if (!app.Environment.IsEnvironment("Testing"))
+{
+    var clientType = "IoTZero.Clients.ClientTest".GetTypeEx();
+    var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
+    if (test != null) _ = Task.Run(() => test(app.Services));
+}
 
 app.Run();
 
 void InitConfig()
 {
     // 把数据目录指向上层,例如部署到 /root/iot/edge/,这些目录放在 /root/iot/
+    // Testing 环境下路径已由 Fixture 配置,跳过默认路径重写
+    // 注意:InitConfig 在 builder.Build() 之前调用,必须用 builder.Environment 而非 app.Environment
+    if (builder.Environment.IsEnvironment("Testing")) return;
+
     var set = NewLife.Setting.Current;
     if (set.IsNew)
     {
@@ -100,4 +108,7 @@ void InitConfig()
         set3.SingleCacheExpire = 60;
         set3.Save();
     }
-}
\ No newline at end of file
+}
+
+// 供 WebApplicationFactory 反射访问
+public partial class Program { }
\ No newline at end of file
Modified +2 -2
diff --git a/Samples/Zero.Desktop/Zero.Desktop.csproj b/Samples/Zero.Desktop/Zero.Desktop.csproj
index b7b15fd..bb0acea 100644
--- a/Samples/Zero.Desktop/Zero.Desktop.csproj
+++ b/Samples/Zero.Desktop/Zero.Desktop.csproj
@@ -26,8 +26,8 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Stardust" Version="3.7.2026.307" />
-    <PackageReference Include="System.Speech" Version="10.0.5" />
+    <PackageReference Include="NewLife.Stardust" Version="3.7.2026.403" />
+    <PackageReference Include="System.Speech" Version="10.0.7" />
   </ItemGroup>
 
   <ItemGroup>
Modified +1 -1
diff --git a/Samples/Zero.RpcServer/ClientTest.cs b/Samples/Zero.RpcServer/ClientTest.cs
index 3662ceb..a388e29 100644
--- a/Samples/Zero.RpcServer/ClientTest.cs
+++ b/Samples/Zero.RpcServer/ClientTest.cs
@@ -106,7 +106,7 @@ static class ClientTest
         var client = new ApiHttpClient($"http://127.0.0.2:{port}");
         client.Log = XTrace.Log;
 
-        var apis = await client.GetAsync<String[]>("api/all");
+         var apis = await client.GetAsync<String[]>("api/all");
         client.WriteLog("共有接口数:{0}", apis.Length);
 
         var state = Rand.NextString(8);
Modified +1 -1
diff --git a/Samples/Zero.RpcServer/Program.cs b/Samples/Zero.RpcServer/Program.cs
index eaed88b..6f7826a 100644
--- a/Samples/Zero.RpcServer/Program.cs
+++ b/Samples/Zero.RpcServer/Program.cs
@@ -24,7 +24,7 @@ services.AddSingleton<ICacheProvider, RedisCacheProvider>();
 
 EntityFactory.InitAll();
 
-var port = 8080;
+var port = 8780;
 
 // 实例化RPC服务端,指定端口,同时在Tcp/Udp/IPv4/IPv6上监听
 using var server = new ApiServer(port)
Modified +3 -3
diff --git a/Samples/Zero.RpcServer/Zero.RpcServer.csproj b/Samples/Zero.RpcServer/Zero.RpcServer.csproj
index d9e4d55..ec026cc 100644
--- a/Samples/Zero.RpcServer/Zero.RpcServer.csproj
+++ b/Samples/Zero.RpcServer/Zero.RpcServer.csproj
@@ -20,9 +20,9 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Redis" Version="6.5.2026.303" />
-    <PackageReference Include="NewLife.Stardust" Version="3.7.2026.307" />
-    <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+    <PackageReference Include="NewLife.Redis" Version="6.5.2026.403" />
+    <PackageReference Include="NewLife.Stardust" Version="3.7.2026.403" />
+    <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
   </ItemGroup>
 
   <ItemGroup>
Modified +16 -5
diff --git a/Samples/ZeroServer/Program.cs b/Samples/ZeroServer/Program.cs
index dab5fe9..f1da039 100644
--- a/Samples/ZeroServer/Program.cs
+++ b/Samples/ZeroServer/Program.cs
@@ -57,18 +57,26 @@ app.MapControllerRoute(
     name: "default",
     pattern: "{controller=CubeHome}/{action=Index}/{id?}");
 
-app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
+if (!app.Environment.IsEnvironment("Testing"))
+    app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
 
 // 反射查找并调用客户端测试,该代码仅用于测试,实际项目中不要这样做
-var clientType = "ZeroClient.ClientTest".GetTypeEx();
-var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
-if (test != null) _ = Task.Run(() => test(app.Services));
+if (!app.Environment.IsEnvironment("Testing"))
+{
+    var clientType = "ZeroClient.ClientTest".GetTypeEx();
+    var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
+    if (test != null) _ = Task.Run(() => test(app.Services));
+}
 
 app.Run();
 
 void InitConfig()
 {
     // 把数据目录指向上层,例如部署到 /root/iot/edge/,这些目录放在 /root/iot/
+    // Testing 环境下路径已由 Fixture 配置,跳过默认路径重写
+    // 注意:InitConfig 在 builder.Build() 之前调用,必须用 builder.Environment 而非 app.Environment
+    if (builder.Environment.IsEnvironment("Testing")) return;
+
     var set = NewLife.Setting.Current;
     if (set.IsNew)
     {
@@ -92,4 +100,7 @@ void InitConfig()
         set3.SingleCacheExpire = 60;
         set3.Save();
     }
-}
\ No newline at end of file
+}
+
+// 供 WebApplicationFactory 反射访问
+public partial class Program { }
\ No newline at end of file
Modified +4 -4
diff --git a/Samples/ZeroServer/ZeroServer.csproj b/Samples/ZeroServer/ZeroServer.csproj
index e6caa5c..bf44450 100644
--- a/Samples/ZeroServer/ZeroServer.csproj
+++ b/Samples/ZeroServer/ZeroServer.csproj
@@ -19,10 +19,10 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Cube.Core" Version="6.9.2026.318-beta1121" />
-    <PackageReference Include="NewLife.Redis" Version="6.5.2026.303" />
-    <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.307" />
-    <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+    <PackageReference Include="NewLife.Cube.Core" Version="6.10.2026.404" />
+    <PackageReference Include="NewLife.Redis" Version="6.5.2026.403" />
+    <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.403" />
+    <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
   </ItemGroup>
 
   <ItemGroup>
Added +368 -0
diff --git a/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs b/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs
new file mode 100644
index 0000000..5a688e2
--- /dev/null
+++ b/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs
@@ -0,0 +1,368 @@
+extern alias IoTZero;
+
+using System.Text.Json;
+using IoTZero::IoT.Data;
+using IoTZero::IoTEdge;
+using NewLife;
+using NewLife.Log;
+using NewLife.Remoting.Models;
+using NewLife.Security;
+using XCode;
+using Xunit;
+
+namespace XUnitTest.Samples.IoTZero;
+
+/// <summary>
+/// IoTZero HTTP 全链路集成测试。
+/// 使用 WebApplicationFactory 启动真实 Kestrel(端口自动分配),HttpDevice 通过 HTTP 连接。
+/// 单个测试方法串行执行 9 步,覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、
+/// 心跳计数、WebSocket 通知建立、SendCommand 投递、升级检查、事件上报。
+/// </summary>
+[Collection("SamplesIntegration")]
+[TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
+public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
+{
+    private readonly IoTZeroWebFactory _factory;
+
+    public IoTZeroIntegrationTests(IoTZeroWebFactory factory) => _factory = factory;
+
+    #region 9 步串行集成测试
+    [Fact(DisplayName = "IoTZero_9步全链路集成测试")]
+    public async Task FullIntegrationFlow_AllNineSteps()
+    {
+        // 确保 Factory 已启动(首次访问时触发 ConfigureWebHost + CreateHost)
+        Assert.False(_factory.BaseUrl.IsNullOrEmpty(), "BaseUrl 未初始化,Factory 启动失败");
+        XTrace.WriteLine("IoTZero 测试服务地址:{0}", _factory.BaseUrl);
+
+        // 清空上次测试残留数据:XCode DAL 连接 IoT 持久化在 AppBase/Data 目录,
+        // 跨测试运行累积;若不清理,Step3 的 Assert.Empty(DeviceOnline) 会因旧记录而误判失败
+        DeviceOnline.Delete("1=1");
+        DeviceHistory.Delete("1=1");
+
+        // 创建新的 ClientSetting(空 DeviceCode/DeviceSecret,触发服务端自动注册)
+        var setting = new ClientSetting { Server = _factory.BaseUrl };
+
+        using var client = new HttpDevice(setting);
+        client.Log = XTrace.Log;
+
+        // Step 1 - 自动注册登录
+        var (code, secret) = await Step1_AutoRegisterLogin(client, setting);
+
+        // Step 2 - 服务端实体验证(通过 XCode 直接查询,同进程内共享 DAL 连接)
+        var deviceId = await Step2_VerifyServerEntities(code);
+
+        // Step 3 - 注销
+        await Step3_Logout(client, deviceId);
+
+        // Step 4 - 修改密钥后重登
+        await Step4_ReLoginWithNewSecret(client, setting, code, deviceId);
+
+        // Step 5 - 心跳
+        await Step5_Ping(client, deviceId);
+
+        // Step 6 - WebSocket 建立确认
+        await Step6_WaitWebSocket(deviceId);
+
+        // Step 7 - SendCommand 投递(Timeout=0,服务端立即返回,客户端通过 WebSocket 收到命令)
+        await Step7_SendCommandAndReceive(client, code);
+
+        // Step 8 - 升级检查
+        await Step8_Upgrade(client);
+
+        // Step 9 - 事件上报
+        await Step9_PostEvents(client);
+    }
+    #endregion
+
+    #region Step 1 — 自动注册登录
+    private async Task<(String code, String secret)> Step1_AutoRegisterLogin(HttpDevice client, ClientSetting setting)
+    {
+        XTrace.WriteLine("=== Step 1: 自动注册登录 ===");
+
+        // 确保以空 DeviceCode/DeviceSecret 开始,触发服务端自动注册
+        setting.DeviceCode   = null!;
+        setting.DeviceSecret = null!;
+
+        await client.Login(null, CancellationToken.None).ConfigureAwait(false);
+
+        // 客户端状态验证
+        Assert.True(client.Logined, "Login 后 Logined 应为 true");
+
+        // 服务端应已写回 DeviceCode 和 DeviceSecret(通过 IClientSetting.Code/Secret 接口)
+        Assert.False(setting.DeviceCode.IsNullOrEmpty(),   "服务端应已回填 DeviceCode");
+        Assert.False(setting.DeviceSecret.IsNullOrEmpty(), "服务端应已回填 DeviceSecret");
+
+        XTrace.WriteLine("自动注册成功,DeviceCode={0}", setting.DeviceCode);
+
+        // 等待 ClientSetting.Save() 写文件(异步 IO,稍作等待)
+        await Task.Delay(300).ConfigureAwait(false);
+
+        // 配置文件持久化验证(ClientSetting 的 [Config("IoTClient")] 写到 config/IoTClient.config)
+        var configContent = _factory.ReadClientConfigFile();
+        Assert.False(configContent.IsNullOrEmpty(), "config/IoTClient.config 应已写入磁盘");
+        Assert.Contains(setting.DeviceCode,   configContent!, StringComparison.Ordinal);
+        Assert.Contains(setting.DeviceSecret, configContent!, StringComparison.Ordinal);
+
+        XTrace.WriteLine("配置文件已写入,内容长度={0}", configContent!.Length);
+
+        return (setting.DeviceCode, setting.DeviceSecret);
+    }
+    #endregion
+
+    #region Step 2 — 服务端实体验证
+    private async Task<Int32> Step2_VerifyServerEntities(String code)
+    {
+        XTrace.WriteLine("=== Step 2: 验证服务端实体 ===");
+
+        // 等待服务端异步写库完成(Device/DeviceOnline 由 Save 同步写入,无需长等待)
+        await Task.Delay(300).ConfigureAwait(false);
+
+        // 验证 Device 实体已创建(FindByCode 单参数)
+        var device = Device.FindByCode(code);
+        Assert.NotNull(device);
+        Assert.Equal(code, device.Code);
+        Assert.True(device.Enable, "新注册的 Device 应为启用状态");
+        Assert.False(device.Secret.IsNullOrEmpty(), "Device.Secret 不应为空");
+
+        XTrace.WriteLine("Device 已创建,ID={0}, Enable={1}", device.Id, device.Enable);
+
+        // 验证 DeviceOnline 已创建(DeviceOnline 无 FindAllByDeviceId,使用 FindAll 表达式)
+        var onlines = DeviceOnline.FindAll(DeviceOnline._.DeviceId == device.Id, null, null, 0, 0);
+        XTrace.WriteLine("DeviceOnline 数量={0}, DeviceId={1}", onlines.Count, device.Id);
+        if (onlines.Count == 0)
+        {
+            // 诊断:打印所有 DeviceOnline 记录
+            var allOnlines = DeviceOnline.FindAll(null, null, null, 0, 0);
+            XTrace.WriteLine("所有 DeviceOnline({0}):", allOnlines.Count);
+            foreach (var o in allOnlines)
+                XTrace.WriteLine("  Id={0}, DeviceId={1}, SessionId={2}", o.Id, o.DeviceId, o.SessionId);
+        }
+        Assert.NotEmpty(onlines);
+
+        XTrace.WriteLine("DeviceOnline 数量={0}", onlines.Count);
+
+        // 验证 DeviceHistory 已记录登录(DefaultDeviceService.OnLogin 写 source+"登录",source=Http)
+        // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+        var logins = await WaitForDeviceHistory(device.Id, "Http登录").ConfigureAwait(false);
+        Assert.NotEmpty(logins);
+        Assert.True(logins[0].Success, "第一次登录的 DeviceHistory 应为成功");
+
+        XTrace.WriteLine("DeviceHistory 登录记录={0}", logins.Count);
+
+        return device.Id;
+    }
+    #endregion
+
+    #region Step 3 — 注销
+    private async Task Step3_Logout(HttpDevice client, Int32 deviceId)
+    {
+        XTrace.WriteLine("=== Step 3: 注销 ===");
+
+        await client.Logout("集成测试注销", CancellationToken.None).ConfigureAwait(false);
+
+        Assert.False(client.Logined, "Logout 后 Logined 应为 false");
+
+        // 等待服务端写库
+        await Task.Delay(500).ConfigureAwait(false);
+
+        // DeviceOnline 应已清除(直接查 DB,绕过实体缓存)
+        var onlines = DeviceOnline.FindAll(DeviceOnline._.DeviceId == deviceId, null, null, 0, 0);
+        if (onlines.Count > 0)
+        {
+            foreach (var o in onlines)
+                XTrace.WriteLine("残留 DeviceOnline: Id={0}, DeviceId={1}, SessionId={2}, UpdateTime={3}", o.Id, o.DeviceId, o.SessionId, o.UpdateTime);
+        }
+        Assert.True(onlines.Count == 0, $"DeviceOnline 应已清除,但有 {onlines.Count} 条记录,SessionIds=[{String.Join(",", onlines.Select(o => o.SessionId))}]");
+
+        // DeviceHistory 应有下线记录(DefaultDeviceService.Logout 写 source+"设备下线",source=Http)
+        // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+        var logouts = await WaitForDeviceHistory(deviceId, "Http设备下线").ConfigureAwait(false);
+        Assert.NotEmpty(logouts);
+
+        XTrace.WriteLine("注销成功,DeviceHistory 下线记录={0}", logouts.Count);
+    }
+    #endregion
+
+    #region Step 4 — 修改密钥后重登
+    private async Task Step4_ReLoginWithNewSecret(HttpDevice client, ClientSetting setting, String code, Int32 deviceId)
+    {
+        XTrace.WriteLine("=== Step 4: 修改密钥后重登 ===");
+
+        // 在服务端直接修改 Device.Secret(模拟运维修改密钥场景)
+        var device = Device.FindByCode(code)!;
+        var newSecret = Rand.NextString(16);
+        device.Secret = newSecret;
+        device.Update();
+
+        // 客户端同步使用新密钥(通过 IClientSetting.Secret 接口设置 DeviceSecret)
+        setting.DeviceSecret = newSecret;
+
+        // 重新登录
+        await client.Login("重登测试", CancellationToken.None).ConfigureAwait(false);
+
+        Assert.True(client.Logined, "修改密钥后重登,Logined 应为 true");
+
+        // 等待写库
+        await Task.Delay(300).ConfigureAwait(false);
+
+        // DeviceOnline 应重新出现
+        var onlines = DeviceOnline.FindAll(DeviceOnline._.DeviceId == deviceId);
+        Assert.NotEmpty(onlines);
+
+        // 登录历史应有 2 条记录(第一次自动注册 + 本次)
+        // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+        var logins = await WaitForDeviceHistory(deviceId, "Http登录", 2).ConfigureAwait(false);
+        Assert.True(logins.Count >= 2, $"应有至少 2 条登录历史,实际={logins.Count}");
+
+        XTrace.WriteLine("重登成功,DeviceHistory 登录记录共={0}", logins.Count);
+    }
+    #endregion
+
+    #region Step 5 — 心跳
+    private async Task Step5_Ping(HttpDevice client, Int32 deviceId)
+    {
+        XTrace.WriteLine("=== Step 5: 心跳 ===");
+
+        // 记录心跳前的 Pings(DeviceOnline 的 AdditionalFields 有 Pings 累加字段)
+        var onlineBefore = DeviceOnline.Find(DeviceOnline._.DeviceId == deviceId);
+        var pingsBefore  = onlineBefore?.Pings ?? 0;
+
+        var rs = await client.Ping(CancellationToken.None).ConfigureAwait(false);
+        Assert.NotNull(rs);
+
+        // 等待服务端写库
+        await Task.Delay(300).ConfigureAwait(false);
+
+        // 刷新实体缓存后验证 Pings 增加
+        var onlineAfter = DeviceOnline.Find(DeviceOnline._.DeviceId == deviceId);
+        Assert.NotNull(onlineAfter);
+        Assert.True(onlineAfter.Pings > pingsBefore,
+            $"Ping 后 Pings 应增加,before={pingsBefore}, after={onlineAfter.Pings}");
+
+        XTrace.WriteLine("心跳成功,Pings={0}", onlineAfter.Pings);
+    }
+    #endregion
+
+    #region Step 6 — WebSocket 建立确认
+    private async Task Step6_WaitWebSocket(Int32 deviceId)
+    {
+        XTrace.WriteLine("=== Step 6: 等待 WebSocket 建立 ===");
+
+        // HttpDevice 登录后会自动建立 WebSocket 通知连接(Features 包含 Notify)
+        // 轮询最长 5 秒等待 DeviceOnline.WebSocket = true
+        var deadline = DateTime.Now.AddSeconds(5);
+        DeviceOnline? online = null;
+        while (DateTime.Now < deadline)
+        {
+            online = DeviceOnline.Find(DeviceOnline._.DeviceId == deviceId);
+            if (online?.WebSocket == true) break;
+            await Task.Delay(200).ConfigureAwait(false);
+        }
+
+        Assert.NotNull(online);
+        Assert.True(online.WebSocket, "DeviceOnline.WebSocket 应在 5 秒内变为 true");
+
+        XTrace.WriteLine("WebSocket 已建立,SessionId={0}", online.SessionId);
+    }
+    #endregion
+
+    #region Step 7 — SendCommand 投递
+    private async Task Step7_SendCommandAndReceive(HttpDevice client, String code)
+    {
+        XTrace.WriteLine("=== Step 7: SendCommand 投递 ===");
+
+        var tcs = new TaskCompletionSource<CommandEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        client.Received += (_, e) =>
+        {
+            // 只接受我们发出的命令;不设置 e.Reply,避免触发 Thing/ServiceReply(NotImplementedException)
+            if (e.Model?.Command == "test:echo")
+                tcs.TrySetResult(e);
+        };
+
+        // 通过 HttpClient 调用 [AllowAnonymous] 的 Device/SendCommand 接口
+        // SendCommand 在 Service 层使用应用令牌验证,此处用已登录的设备令牌代替(测试环境 TokenService 不区分令牌来源)
+        using var http = new HttpClient();
+        var deviceToken = ((NewLife.Remoting.IApiClient)client).Token;
+        if (!deviceToken.IsNullOrEmpty())
+            http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {deviceToken}");
+        var payload = JsonSerializer.Serialize(new
+        {
+            Code     = code,
+            Command  = "test:echo",
+            Argument = "hello",
+            Timeout  = 0,
+        });
+        var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
+
+        var response = await http.PostAsync($"{_factory.BaseUrl}/Device/SendCommand", content).ConfigureAwait(false);
+        XTrace.WriteLine("SendCommand HTTP 状态={0}", response.StatusCode);
+
+        // 等待客户端通过 WebSocket 收到命令(最多 10 秒)
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        var cmdEvt = await tcs.Task.WaitAsync(cts.Token).ConfigureAwait(false);
+
+        Assert.NotNull(cmdEvt.Model);
+        Assert.Equal("test:echo", cmdEvt.Model!.Command);
+        Assert.Equal("hello",     cmdEvt.Model.Argument);
+
+        XTrace.WriteLine("已收到命令,Command={0}, Argument={1}", cmdEvt.Model.Command, cmdEvt.Model.Argument);
+    }
+    #endregion
+
+    #region Step 8 — 升级检查
+    private async Task Step8_Upgrade(HttpDevice client)
+    {
+        XTrace.WriteLine("=== Step 8: 升级检查 ===");
+
+        // 无新版本时应返回 null,不应抛出异常
+        IUpgradeInfo? info = null;
+        var ex = await Record.ExceptionAsync(async () =>
+        {
+            info = await client.Upgrade(null, CancellationToken.None).ConfigureAwait(false);
+        });
+
+        Assert.Null(ex);
+        // info 为 null 表示无可用升级,合法
+        XTrace.WriteLine("升级检查成功,info={0}", info == null ? "null(无升级)" : info.Version);
+    }
+    #endregion
+
+    #region Step 9 — 事件上报
+    private async Task Step9_PostEvents(HttpDevice client)
+    {
+        XTrace.WriteLine("=== Step 9: 事件上报 ===");
+
+        var events = new[]
+        {
+            new EventModel { Name = "TestEvent1", Type = "info",  Remark = "IoTZero 集成测试事件1" },
+            new EventModel { Name = "TestEvent2", Type = "alert", Remark = "IoTZero 集成测试事件2" },
+        };
+
+        var count = await client.PostEvents(events).ConfigureAwait(false);
+
+        Assert.Equal(2, count);
+        XTrace.WriteLine("事件上报成功,返回数量={0}", count);
+    }
+    #endregion
+
+    #region 辅助方法
+    /// <summary>轮询等待 SaveAsync 写库并按条件查询 DeviceHistory(直接查 DB,绕过实体缓存)。
+    /// XCode EntityQueue 约每 1 秒刷新一次,最多等待 5 秒。</summary>
+    /// <param name="deviceId">设备 ID</param>
+    /// <param name="action">动作名称</param>
+    /// <param name="minCount">最少记录数,达到后提前返回</param>
+    private static async Task<IList<DeviceHistory>> WaitForDeviceHistory(Int32 deviceId, String action, Int32 minCount = 1)
+    {
+        IList<DeviceHistory> result = [];
+        for (var i = 0; i < 25; i++)
+        {
+            await Task.Delay(200).ConfigureAwait(false);
+            // 使用 FindAll + 显式 WHERE 直接查 DB,绕过实体缓存,避免 SaveAsync 未提交时缓存为空
+            result = DeviceHistory.FindAll(DeviceHistory._.DeviceId == deviceId & DeviceHistory._.Action == action, null, null, 0, 0);
+            if (result.Count >= minCount) break;
+        }
+        return result;
+    }
+    #endregion
+}
Added +164 -0
diff --git a/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs b/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs
new file mode 100644
index 0000000..0b77aec
--- /dev/null
+++ b/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs
@@ -0,0 +1,164 @@
+extern alias IoTZero;
+
+using IoTZero::IoT.Data;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using NewLife;
+using NewLife.Log;
+using Xunit;
+
+namespace XUnitTest.Samples.IoTZero;
+
+/// <summary>IoTZero 集成测试 WebApplicationFactory。使用真实 Kestrel(端口0),完全独立临时数据库</summary>
+public sealed class IoTZeroWebFactory : WebApplicationFactory<IoTZero::Program>, IAsyncLifetime
+{
+    #region 属性
+    /// <summary>服务端真实监听地址,如 http://127.0.0.1:12345</summary>
+    public String BaseUrl { get; private set; } = null!;
+
+    /// <summary>临时数据/配置目录(测试结束后删除)</summary>
+    public String TempDir { get; private set; } = null!;
+
+    /// <summary>原始工作目录(析构时恢复)</summary>
+    private String _origCurrentDir = null!;
+    #endregion
+
+    #region WebApplicationFactory 重写
+    protected override void ConfigureWebHost(IWebHostBuilder builder)
+    {
+        XTrace.UseConsole();
+
+        // 每次 WAF 初始化时创建独立的临时目录
+        TempDir = Path.Combine(Path.GetTempPath(), "IoTZeroTest_" + Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(TempDir);
+        Directory.CreateDirectory(Path.Combine(TempDir, "config"));
+        Directory.CreateDirectory(Path.Combine(TempDir, "Data"));
+
+        // 切换工作目录:ClientSetting 的 config/IoTClient.config 会写到 TempDir
+        _origCurrentDir = Environment.CurrentDirectory;
+        Environment.CurrentDirectory = TempDir;
+
+        // Testing 环境:Program.cs 会跳过 RegisterService 和 ClientTest 调用
+        builder.UseEnvironment("Testing");
+
+        // 覆盖连接串为临时 SQLite 文件,每次测试数据完全隔离
+        var dbIoT        = $"Data Source={Path.Combine(TempDir, "Data", "IoT.db")};Provider=Sqlite";
+        var dbIoTData    = $"Data Source={Path.Combine(TempDir, "Data", "IoTData.db")};ShowSql=false;Provider=Sqlite";
+        var dbMembership = $"Data Source={Path.Combine(TempDir, "Data", "Membership.db")};Provider=Sqlite";
+
+        builder.ConfigureAppConfiguration((_, cfg) =>
+        {
+            cfg.AddInMemoryCollection(new Dictionary<String, String?>
+            {
+                ["ConnectionStrings:IoT"]        = dbIoT,
+                ["ConnectionStrings:IoTData"]    = dbIoTData,
+                ["ConnectionStrings:Membership"] = dbMembership,
+                ["XCodeSetting:ShowSQL"]         = "false",
+            });
+        });
+    }
+
+    protected override IHost CreateHost(IHostBuilder builder) => base.CreateHost(builder);
+    #endregion
+
+    #region IAsyncLifetime
+    /// <summary>初始化:启用真实 Kestrel(端口0),触发 WAF 启动服务,读取实际监听地址,预置产品数据</summary>
+    async Task IAsyncLifetime.InitializeAsync()
+    {
+        UseKestrel(0);  // 必须在服务器启动前调用:告知 WAF 使用真实 Kestrel 而非 TestServer
+        _ = Services;   // 触发 WebApplicationFactory.EnsureHost() → ConfigureHostBuilder → CreateHost
+        // WAF 在 CreateHost 后调用 TryExtractHostAddress,自动将实际端口写入 ClientOptions.BaseAddress
+        BaseUrl = ClientOptions.BaseAddress?.ToString()?.TrimEnd('/') ?? "";
+        XTrace.WriteLine("IoTZeroWebFactory 启动,地址:{0}", BaseUrl);
+        // XCode 静态 DAL 在进程内持久化,同一进程多次运行测试会复用共享数据库中的历史数据。
+        // 在服务启动后、测试开始前,清空所有业务表,确保每次测试数据完全隔离。
+        // 注意:必须在 _ = Services 之后执行(DAL 连接已建立),并在 SeedProduct 之前执行(避免误清产品数据)。
+        CleanTestData();
+        // 预置测试所需的 Product(EdgeGateway),否则 OnRegister 会抛出“无效产品”
+        SeedProduct();
+        await Task.CompletedTask;
+    }
+
+    async Task IAsyncLifetime.DisposeAsync()
+    {
+        Dispose();
+        await Task.CompletedTask;
+    }
+    #endregion
+
+    #region 析构
+    protected override void Dispose(Boolean disposing)
+    {
+        base.Dispose(disposing);
+
+        if (disposing)
+        {
+            // 恢复工作目录
+            if (!_origCurrentDir.IsNullOrEmpty())
+                Environment.CurrentDirectory = _origCurrentDir;
+
+            // 清理临时目录
+            if (!TempDir.IsNullOrEmpty() && Directory.Exists(TempDir))
+            {
+                try { Directory.Delete(TempDir, true); }
+                catch { /* 忽略清理异常 */ }
+            }
+        }
+    }
+    #endregion
+
+    #region 辅助
+    /// <summary>读取 IoTClient.config 文件内容(登录后由 ClientSetting.Save 写入)</summary>
+    /// <remarks>Config&lt;T&gt; 使用 GetBasePath() 保存到 AppDomain.CurrentDomain.BaseDirectory/Config/ 目录</remarks>
+    public String? ReadClientConfigFile()
+    {
+        var configFile = Path.Combine(AppContext.BaseDirectory, "Config", "IoTClient.config");
+        return File.Exists(configFile) ? File.ReadAllText(configFile) : null;
+    }
+
+    /// <summary>预置产品数据(EdgeGateway),供设备自动注册使用</summary>
+    /// <remarks>OnRegister 验证:Product.FindByCode(ProductKey) != null &amp;&amp; product.Enable == true</remarks>
+    private static void SeedProduct()
+    {
+        var product = Product.FindByCode("EdgeGateway");
+        if (product == null)
+        {
+            product = new Product
+            {
+                Code   = "EdgeGateway",
+                Name   = "边缘网关(集成测试)",
+                Enable = true,
+            };
+            product.Insert();
+        }
+        else if (!product.Enable)
+        {
+            product.Enable = true;
+            product.Update();
+        }
+
+        XTrace.WriteLine("IoTZero 产品已预置,Code=EdgeGateway, Id={0}", product.Id);
+    }
+
+    /// <summary>清空业务表数据。XCode 静态 DAL 在进程内持久化,同一进程多次运行测试须在每次测试前重置数据</summary>
+    private static void CleanTestData()
+    {
+        // 直接执行 SQL 删除,绕过实体缓存,速度快;删除后清空实体缓存
+        DeviceOnline.Meta.Session.Execute("DELETE FROM DeviceOnline");
+        DeviceHistory.Meta.Session.Execute("DELETE FROM DeviceHistory");
+        Device.Meta.Session.Execute("DELETE FROM Device");
+        Product.Meta.Session.Execute("DELETE FROM Product");
+
+        // 清空实体缓存,避免残留对象影响后续 FindAll 查询
+        DeviceOnline.Meta.Cache.Clear("清空测试数据");
+        DeviceHistory.Meta.Cache.Clear("清空测试数据");
+        Device.Meta.Cache.Clear("清空测试数据");
+        Product.Meta.Cache.Clear("清空测试数据");
+        DeviceOnline.Meta.SingleCache.Clear("清空测试数据");
+        Device.Meta.SingleCache.Clear("清空测试数据");
+
+        XTrace.WriteLine("IoTZero 测试数据已清空");
+    }
+    #endregion
+}
Added +20 -0
diff --git a/XUnitTest.Samples/NullTokenService.cs b/XUnitTest.Samples/NullTokenService.cs
new file mode 100644
index 0000000..3323986
--- /dev/null
+++ b/XUnitTest.Samples/NullTokenService.cs
@@ -0,0 +1,20 @@
+using NewLife.Remoting.Models;
+using NewLife.Remoting.Services;
+using NewLife.Web;
+
+namespace XUnitTest.Samples;
+
+/// <summary>测试用 noop 令牌服务。替换 ITokenService,使 SendCommand 等接口在集成测试中无需真实应用令牌</summary>
+internal sealed class NullTokenService : ITokenService
+{
+    /// <summary>颁发令牌(测试用,返回固定 token)</summary>
+    /// <param name="name">名称</param>
+    /// <param name="id">标识</param>
+    /// <returns>令牌</returns>
+    public IToken IssueToken(String name, String? id = null) => new TokenModel { AccessToken = "test", ExpireIn = 3600 };
+
+    /// <summary>验证令牌(测试用,始终放行)</summary>
+    /// <param name="token">令牌字符串</param>
+    /// <returns>解析结果,始终无错误</returns>
+    public (JwtBuilder, Exception?) DecodeToken(String token) => (new JwtBuilder(), null);
+}
Added +12 -0
diff --git a/XUnitTest.Samples/Properties/launchSettings.json b/XUnitTest.Samples/Properties/launchSettings.json
new file mode 100644
index 0000000..4b1c0f4
--- /dev/null
+++ b/XUnitTest.Samples/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+  "profiles": {
+    "XUnitTest.Samples": {
+      "commandName": "Project",
+      "launchBrowser": true,
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      },
+      "applicationUrl": "https://localhost:49229;http://localhost:49230"
+    }
+  }
+}
\ No newline at end of file
Added +7 -0
diff --git a/XUnitTest.Samples/SamplesCollectionDefinition.cs b/XUnitTest.Samples/SamplesCollectionDefinition.cs
new file mode 100644
index 0000000..369d35a
--- /dev/null
+++ b/XUnitTest.Samples/SamplesCollectionDefinition.cs
@@ -0,0 +1,7 @@
+using Xunit;
+
+namespace XUnitTest.Samples;
+
+/// <summary>集成测试集合定义。将 ZeroServer 和 IoTZero 测试放在同一集合中串行执行,避免 XCode 静态 DAL 连接跨进程污染</summary>
+[CollectionDefinition("SamplesIntegration")]
+public sealed class SamplesIntegrationCollection { }
Added +36 -0
diff --git a/XUnitTest.Samples/XUnitTest.Samples.csproj b/XUnitTest.Samples/XUnitTest.Samples.csproj
new file mode 100644
index 0000000..face2ca
--- /dev/null
+++ b/XUnitTest.Samples/XUnitTest.Samples.csproj
@@ -0,0 +1,36 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+  <PropertyGroup>
+    <TargetFramework>net10.0</TargetFramework>
+    <AssemblyTitle>Samples 集成测试集</AssemblyTitle>
+    <Description>端到端集成测试:ZeroServer、IoTZero。真实 Kestrel 启动,NodeClient/HttpDevice 真实连接</Description>
+    <Company>新生命开发团队</Company>
+    <Copyright>©2002-2026 NewLife</Copyright>
+    <IsPackable>false</IsPackable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <LangVersion>latest</LangVersion>
+    <!-- 不生成独立输出目录,避免与 Samples 冲突 -->
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
+    <PackageReference Include="NewLife.UnitTest" Version="1.1.2026.102" />
+    <PackageReference Include="xunit" Version="2.9.3" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
+  </ItemGroup>
+
+  <ItemGroup>
+    <!-- 使用程序集别名隔离两个样本项目中同名的 partial class Program -->
+    <ProjectReference Include="..\Samples\ZeroServer\ZeroServer.csproj">
+      <Aliases>ZeroServer</Aliases>
+    </ProjectReference>
+    <ProjectReference Include="..\Samples\IoTZero\IoTZero.csproj">
+      <Aliases>IoTZero</Aliases>
+    </ProjectReference>
+  </ItemGroup>
+
+</Project>
Added +360 -0
diff --git a/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs b/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs
new file mode 100644
index 0000000..6914136
--- /dev/null
+++ b/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs
@@ -0,0 +1,360 @@
+extern alias ZeroServer;
+
+using System.Text.Json;
+using NewLife;
+using NewLife.Log;
+using NewLife.Remoting.Models;
+using NewLife.Security;
+using XCode;
+using ZeroServer::Zero.Data.Nodes;
+using ZeroServer::ZeroClient;
+using Xunit;
+
+namespace XUnitTest.Samples.ZeroServer;
+
+/// <summary>
+/// ZeroServer HTTP 全链路集成测试。
+/// 使用 WebApplicationFactory 启动真实 Kestrel(端口自动分配),NodeClient 通过 HTTP 连接。
+/// 单个测试方法串行执行 9 步,覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、
+/// 心跳计数、WebSocket 通知建立、SendCommand + CommandReply、升级检查、事件上报。
+/// </summary>
+[Collection("SamplesIntegration")]
+[TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
+public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
+{
+    private readonly ZeroServerWebFactory _factory;
+
+    public ZeroServerIntegrationTests(ZeroServerWebFactory factory) => _factory = factory;
+
+    #region 9 步串行集成测试
+    [Fact(DisplayName = "ZeroServer_9步全链路集成测试")]
+    public async Task FullIntegrationFlow_AllNineSteps()
+    {
+        // 确保 Factory 已启动(首次访问时触发 ConfigureWebHost + CreateHost)
+        Assert.False(_factory.BaseUrl.IsNullOrEmpty(), "BaseUrl 未初始化,Factory 启动失败");
+        XTrace.WriteLine("ZeroServer 测试服务地址:{0}", _factory.BaseUrl);
+
+        // 清空上次测试残留数据:XCode DAL 连接 StardustData 持久化在 AppBase/Data 目录,
+        // 跨测试运行累积;若不清理,Step3 的 Assert.Empty(NodeOnline) 会因旧记录而误判失败
+        NodeOnline.Delete("1=1");
+        NodeHistory.Delete("1=1");
+
+        // 创建新的 ClientSetting(空 Code/Secret,触发服务端自动注册)
+        var setting = new ClientSetting { Server = _factory.BaseUrl };
+
+        using var client = new NodeClient(setting);
+        client.Log = XTrace.Log;
+
+        // Step 1 - 自动注册登录
+        var (code, secret) = await Step1_AutoRegisterLogin(client, setting);
+
+        // Step 2 - 服务端实体验证(通过 XCode 直接查询,同进程内共享 DAL 连接)
+        var nodeId = await Step2_VerifyServerEntities(code);
+
+        // Step 3 - 注销
+        await Step3_Logout(client, nodeId);
+
+        // Step 4 - 修改密钥后重登
+        await Step4_ReLoginWithNewSecret(client, setting, code, nodeId);
+
+        // Step 5 - 心跳
+        await Step5_Ping(client, nodeId);
+
+        // Step 6 - WebSocket 建立确认
+        await Step6_WaitWebSocket(nodeId);
+
+        // Step 7 - SendCommand + CommandReply
+        await Step7_SendCommandAndReply(client, code);
+
+        // Step 8 - 升级检查
+        await Step8_Upgrade(client);
+
+        // Step 9 - 事件上报
+        await Step9_PostEvents(client);
+    }
+    #endregion
+
+    #region Step 1 — 自动注册登录
+    private async Task<(String code, String secret)> Step1_AutoRegisterLogin(NodeClient client, ClientSetting setting)
+    {
+        XTrace.WriteLine("=== Step 1: 自动注册登录 ===");
+
+        // 确保以空 Code/Secret 开始,触发服务端自动注册
+        setting.Code   = null!;
+        setting.Secret = null!;
+
+        await client.Login(null, CancellationToken.None).ConfigureAwait(false);
+
+        // 客户端状态验证
+        Assert.True(client.Logined, "Login 后 Logined 应为 true");
+
+        // 服务端应已写回 Code 和 Secret
+        Assert.False(setting.Code.IsNullOrEmpty(),   "服务端应已回填 Code");
+        Assert.False(setting.Secret.IsNullOrEmpty(), "服务端应已回填 Secret");
+
+        XTrace.WriteLine("自动注册成功,Code={0}", setting.Code);
+
+        // 等待 ClientSetting.Save() 写文件(异步 IO,稍作等待)
+        await Task.Delay(300).ConfigureAwait(false);
+
+        // 配置文件持久化验证
+        var configContent = _factory.ReadClientConfigFile();
+        Assert.False(configContent.IsNullOrEmpty(), "config/ZeroClient.config 应已写入磁盘");
+        Assert.Contains(setting.Code,   configContent!, StringComparison.Ordinal);
+        Assert.Contains(setting.Secret, configContent!, StringComparison.Ordinal);
+
+        XTrace.WriteLine("配置文件已写入,内容长度={0}", configContent!.Length);
+
+        return (setting.Code, setting.Secret);
+    }
+    #endregion
+
+    #region Step 2 — 服务端实体验证
+    private async Task<Int32> Step2_VerifyServerEntities(String code)
+    {
+        XTrace.WriteLine("=== Step 2: 验证服务端实体 ===");
+
+        // 等待服务端异步写库完成(Node 实体同步写入,短暂等待即可)
+        await Task.Delay(300).ConfigureAwait(false);
+
+        // 验证 Node 实体已创建
+        var node = Node.FindByCodeWithCache(code, false);
+        Assert.NotNull(node);
+        Assert.Equal(code, node.Code);
+        Assert.True(node.Enable, "新注册的 Node 应为启用状态");
+        Assert.False(node.Secret.IsNullOrEmpty(), "Node.Secret 不应为空");
+
+        XTrace.WriteLine("Node 已创建,ID={0}, Enable={1}", node.Id, node.Enable);
+
+        // 验证 NodeOnline 已创建(直接 SQL,绕过实体缓存)
+        var onlines = NodeOnline.FindAll(NodeOnline._.NodeId == node.Id);
+        Assert.NotEmpty(onlines);
+
+        XTrace.WriteLine("NodeOnline 数量={0}", onlines.Count);
+
+        // 验证 NodeHistory 已记录登录(WriteHistory 使用 EntityQueue 异步写库,需轮询等待)
+        var logins = await WaitForNodeHistory(node.Id, "Http登录");
+        Assert.NotEmpty(logins);
+        Assert.True(logins[0].Success, "第一次登录的 NodeHistory 应为成功");
+
+        XTrace.WriteLine("NodeHistory 登录记录={0}", logins.Count);
+
+        return node.Id;
+    }
+    #endregion
+
+    #region Step 3 — 注销
+    private async Task Step3_Logout(NodeClient client, Int32 nodeId)
+    {
+        XTrace.WriteLine("=== Step 3: 注销 ===");
+
+        await client.Logout("集成测试注销", CancellationToken.None).ConfigureAwait(false);
+
+        Assert.False(client.Logined, "Logout 后 Logined 应为 false");
+
+        // 轮询等待 NodeOnline 被删除(RemoveOnline 同步执行,但直接 SQL 读取可能有短暂延迟)
+        var emptyOnlines = await WaitForNodeOnlineEmpty(nodeId);
+        Assert.Empty(emptyOnlines);
+
+        // NodeHistory 下线记录由 EntityQueue 异步写入,需轮询等待
+        var logouts = await WaitForNodeHistory(nodeId, "Http设备下线");
+        Assert.NotEmpty(logouts);
+
+        XTrace.WriteLine("注销成功,NodeHistory 注销记录={0}", logouts.Count);
+    }
+    #endregion
+
+    #region Step 4 — 修改密钥后重登
+    private async Task Step4_ReLoginWithNewSecret(NodeClient client, ClientSetting setting, String code, Int32 nodeId)
+    {
+        XTrace.WriteLine("=== Step 4: 修改密钥后重登 ===");
+
+        // 在服务端直接修改 Node.Secret(模拟运维修改密钥场景)
+        var node = Node.FindByCodeWithCache(code, false)!;
+        var newSecret = Rand.NextString(16);
+        node.Secret = newSecret;
+        node.Update();
+
+        // 客户端同步使用新密钥
+        setting.Secret = newSecret;
+
+        // 重新登录
+        await client.Login("重登测试", CancellationToken.None).ConfigureAwait(false);
+
+        Assert.True(client.Logined, "修改密钥后重登,Logined 应为 true");
+
+        // NodeOnline 应重新出现(直接 SQL,绕过实体缓存)
+        var onlines = NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
+        Assert.NotEmpty(onlines);
+
+        // 登录历史应有 2 条记录(第一次自动注册 + 本次);EntityQueue 异步写入,需轮询等待
+        var logins = await WaitForNodeHistory(nodeId, "Http登录", 2);
+        Assert.True(logins.Count >= 2, $"应有至少 2 条登录历史,实际={logins.Count}");
+
+        XTrace.WriteLine("重登成功,NodeHistory 登录记录共={0}", logins.Count);
+    }
+    #endregion
+
+    #region Step 5 — 心跳
+    private async Task Step5_Ping(NodeClient client, Int32 nodeId)
+    {
+        XTrace.WriteLine("=== Step 5: 心跳 ===");
+
+        // 记录心跳前的 PingCount
+        var onlineBefore = NodeOnline.FindByNodeId(nodeId);
+        var pingCountBefore = onlineBefore?.PingCount ?? 0;
+
+        var rs = await client.Ping(CancellationToken.None).ConfigureAwait(false);
+        Assert.NotNull(rs);
+
+        // 等待服务端写库
+        await Task.Delay(300).ConfigureAwait(false);
+
+        // 刷新实体缓存后验证 PingCount 增加
+        var onlineAfter = NodeOnline.Find(NodeOnline._.NodeId == nodeId);
+        Assert.NotNull(onlineAfter);
+        Assert.True(onlineAfter.PingCount > pingCountBefore,
+            $"Ping 后 PingCount 应增加,before={pingCountBefore}, after={onlineAfter.PingCount}");
+
+        XTrace.WriteLine("心跳成功,PingCount={0}", onlineAfter.PingCount);
+    }
+    #endregion
+
+    #region Step 6 — WebSocket 通知建立
+    private async Task Step6_WaitWebSocket(Int32 nodeId)
+    {
+        XTrace.WriteLine("=== Step 6: 等待 WebSocket 建立 ===");
+
+        // NodeClient 登录后会自动建立 WebSocket 通知连接(因为 Features 包含 Notify)
+        // 轮询最长 5 秒等待 NodeOnline.WebSocket = true
+        var deadline = DateTime.Now.AddSeconds(5);
+        NodeOnline? online = null;
+        while (DateTime.Now < deadline)
+        {
+            online = NodeOnline.Find(NodeOnline._.NodeId == nodeId);
+            if (online?.WebSocket == true) break;
+            await Task.Delay(200).ConfigureAwait(false);
+        }
+
+        Assert.NotNull(online);
+        Assert.True(online.WebSocket, "NodeOnline.WebSocket 应在 5 秒内变为 true");
+
+        XTrace.WriteLine("WebSocket 已建立,SessionId={0}", online.SessionId);
+    }
+    #endregion
+
+    #region Step 7 — SendCommand + CommandReply
+    private async Task Step7_SendCommandAndReply(NodeClient client, String code)
+    {
+        XTrace.WriteLine("=== Step 7: SendCommand + CommandReply ===");
+
+        var tcs = new TaskCompletionSource<CommandEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        client.Received += (_, e) =>
+        {
+            // 只接受我们发出的命令
+            if (e.Model?.Command == "test:echo")
+                tcs.TrySetResult(e);
+        };
+
+        // 通过 HttpClient 调用 [AllowAnonymous] 的 Node/SendCommand 接口
+        // SendCommand 在 Service 层使用应用令牌验证,此处用已登录的设备令牌代替(测试环境 TokenService 不区分令牌来源)
+        using var http = new HttpClient();
+        var deviceToken = ((NewLife.Remoting.IApiClient)client).Token;
+        if (!deviceToken.IsNullOrEmpty())
+            http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {deviceToken}");
+        var payload = JsonSerializer.Serialize(new
+        {
+            Code     = code,
+            Command  = "test:echo",
+            Argument = "hello",
+            Timeout  = 10,   // 等待响应最多 10 秒
+        });
+        var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
+
+        var response = await http.PostAsync($"{_factory.BaseUrl}/Node/SendCommand", content).ConfigureAwait(false);
+        XTrace.WriteLine("SendCommand HTTP 状态={0}", response.StatusCode);
+
+        // 等待客户端收到命令(最多 10 秒)
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        var cmdEvt = await tcs.Task.WaitAsync(cts.Token).ConfigureAwait(false);
+
+        Assert.NotNull(cmdEvt.Model);
+        Assert.Equal("test:echo", cmdEvt.Model!.Command);
+        Assert.Equal("hello",     cmdEvt.Model.Argument);
+
+        XTrace.WriteLine("已收到命令,Command={0}, Argument={1}", cmdEvt.Model.Command, cmdEvt.Model.Argument);
+    }
+    #endregion
+
+    #region Step 8 — 升级检查
+    private async Task Step8_Upgrade(NodeClient client)
+    {
+        XTrace.WriteLine("=== Step 8: 升级检查 ===");
+
+        // 无新版本时应返回 null,不应抛出异常
+        IUpgradeInfo? info = null;
+        var ex = await Record.ExceptionAsync(async () =>
+        {
+            info = await client.Upgrade(null, CancellationToken.None).ConfigureAwait(false);
+        });
+
+        Assert.Null(ex);
+        // info 为 null 表示无可用升级,合法
+        XTrace.WriteLine("升级检查成功,info={0}", info == null ? "null(无升级)" : info.Version);
+    }
+    #endregion
+
+    #region 辅助方法
+    /// <summary>轮询等待 NodeHistory 出现指定条数记录(绕过实体缓存,直接 SQL 查询)</summary>
+    /// <param name="nodeId">节点 ID</param>
+    /// <param name="action">操作名称</param>
+    /// <param name="minCount">最少记录数,默认 1</param>
+    /// <returns>查到的记录列表(可能超过 minCount)</returns>
+    private static async Task<IList<NodeHistory>> WaitForNodeHistory(Int32 nodeId, String action, Int32 minCount = 1)
+    {
+        var deadline = DateTime.Now.AddSeconds(5);
+        while (DateTime.Now < deadline)
+        {
+            // 直接 SQL,绕过 EntityListCache(EntityQueue 异步写入,缓存尚未更新)
+            var list = NodeHistory.FindAll(NodeHistory._.NodeId == nodeId & NodeHistory._.Action == action);
+            if (list.Count >= minCount) return list;
+            await Task.Delay(200).ConfigureAwait(false);
+        }
+        return NodeHistory.FindAll(NodeHistory._.NodeId == nodeId & NodeHistory._.Action == action);
+    }
+
+    /// <summary>轮询等待 NodeOnline 对应 nodeId 的记录全部消失</summary>
+    /// <param name="nodeId">节点 ID</param>
+    /// <returns>查到的剩余记录(超时后返回当时状态)</returns>
+    private static async Task<IList<NodeOnline>> WaitForNodeOnlineEmpty(Int32 nodeId)
+    {
+        var deadline = DateTime.Now.AddSeconds(5);
+        while (DateTime.Now < deadline)
+        {
+            var list = NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
+            if (list.Count == 0) return list;
+            await Task.Delay(200).ConfigureAwait(false);
+        }
+        return NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
+    }
+    #endregion
+
+    #region Step 9 — 事件上报
+    private async Task Step9_PostEvents(NodeClient client)
+    {
+        XTrace.WriteLine("=== Step 9: 事件上报 ===");
+
+        var events = new[]
+        {
+            new EventModel { Name = "TestEvent1", Type = "info",  Remark = "集成测试事件1" },
+            new EventModel { Name = "TestEvent2", Type = "alert", Remark = "集成测试事件2" },
+        };
+
+        var count = await client.PostEvents(events).ConfigureAwait(false);
+
+        Assert.Equal(2, count);
+        XTrace.WriteLine("事件上报成功,返回数量={0}", count);
+    }
+    #endregion
+}
Added +125 -0
diff --git a/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs b/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs
new file mode 100644
index 0000000..f089d47
--- /dev/null
+++ b/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs
@@ -0,0 +1,125 @@
+extern alias ZeroServer;
+
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using NewLife;
+using NewLife.Log;
+using Xunit;
+
+namespace XUnitTest.Samples.ZeroServer;
+
+/// <summary>ZeroServer 集成测试 WebApplicationFactory。使用真实 Kestrel(端口0),完全独立临时数据库</summary>
+public sealed class ZeroServerWebFactory : WebApplicationFactory<ZeroServer::Program>, IAsyncLifetime
+{
+    #region 属性
+    /// <summary>服务端真实监听地址,如 http://127.0.0.1:12345</summary>
+    public String BaseUrl { get; private set; } = null!;
+
+    /// <summary>临时数据/配置目录(测试结束后删除)</summary>
+    public String TempDir { get; private set; } = null!;
+
+    /// <summary>原始工作目录(析构时恢复)</summary>
+    private String _origCurrentDir = null!;
+    #endregion
+
+    #region WebApplicationFactory 重写
+    protected override void ConfigureWebHost(IWebHostBuilder builder)
+    {
+        XTrace.UseConsole();
+
+        // 每次 WAF 初始化时创建独立的临时目录
+        TempDir = Path.Combine(Path.GetTempPath(), "ZeroServerTest_" + Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(TempDir);
+        Directory.CreateDirectory(Path.Combine(TempDir, "config"));
+        Directory.CreateDirectory(Path.Combine(TempDir, "Data"));
+
+        // 切换工作目录:ClientSetting 的 config/ZeroClient.config 会写到 TempDir
+        _origCurrentDir = Environment.CurrentDirectory;
+        Environment.CurrentDirectory = TempDir;
+
+        // Testing 环境:Program.cs 会跳过 RegisterService 和 ClientTest 调用
+        builder.UseEnvironment("Testing");
+
+        // 覆盖连接串为临时 SQLite 文件,每次测试数据完全隔离
+        // ZeroServer 实体 ConnName 映射:Node→"Zero",NodeOnline/NodeHistory→"StardustData",Area→"Membership"
+        // 同时覆盖 "IoT",防止 EntityFactory.InitAll() 用 appsettings.json 的相对路径初始化 IoTZero 的实体连接
+        var dataDir      = Path.Combine(TempDir, "Data");
+        var dbZero       = $"Data Source={Path.Combine(dataDir, "Zero.db")};Provider=Sqlite";
+        var dbStardust   = $"Data Source={Path.Combine(dataDir, "StardustData.db")};Provider=Sqlite";
+        var dbMembership = $"Data Source={Path.Combine(dataDir, "Membership.db")};Provider=Sqlite";
+        var dbIoT        = $"Data Source={Path.Combine(dataDir, "IoT.db")};Provider=Sqlite";
+
+        builder.ConfigureAppConfiguration((_, cfg) =>
+        {
+            cfg.AddInMemoryCollection(new Dictionary<String, String?>
+            {
+                ["ConnectionStrings:Zero"]        = dbZero,
+                ["ConnectionStrings:StardustData"] = dbStardust,
+                ["ConnectionStrings:Membership"]  = dbMembership,
+                ["ConnectionStrings:IoT"]         = dbIoT,
+                ["XCodeSetting:ShowSQL"]          = "false",
+            });
+        });
+    }
+
+    protected override IHost CreateHost(IHostBuilder builder) => base.CreateHost(builder);
+    #endregion
+
+    #region IAsyncLifetime
+    /// <summary>初始化:启用真实 Kestrel(端口0),触发 WAF 启动服务,读取实际监听地址</summary>
+    async Task IAsyncLifetime.InitializeAsync()
+    {
+        UseKestrel(0);  // 必须在服务器启动前调用:告知 WAF 使用真实 Kestrel 而非 TestServer
+        _ = Services;   // 触发 WebApplicationFactory.EnsureHost() → ConfigureHostBuilder → CreateHost
+        // WAF 在 CreateHost 后调用 TryExtractHostAddress,自动将实际端口写入 ClientOptions.BaseAddress
+        BaseUrl = ClientOptions.BaseAddress?.ToString()?.TrimEnd('/') ?? "";
+        XTrace.WriteLine("ZeroServerWebFactory 启动,地址:{0}", BaseUrl);
+        await Task.CompletedTask;
+    }
+
+    async Task IAsyncLifetime.DisposeAsync()
+    {
+        Dispose();
+        await Task.CompletedTask;
+    }
+    #endregion
+
+    #region 析构
+    protected override void Dispose(Boolean disposing)
+    {
+        base.Dispose(disposing);
+
+        if (disposing)
+        {
+            // 恢复工作目录
+            if (!_origCurrentDir.IsNullOrEmpty())
+                Environment.CurrentDirectory = _origCurrentDir;
+
+            // 清理临时目录
+            if (!TempDir.IsNullOrEmpty() && Directory.Exists(TempDir))
+            {
+                try { Directory.Delete(TempDir, true); }
+                catch { /* 忽略清理异常 */ }
+            }
+        }
+    }
+    #endregion
+
+    #region 辅助
+    /// <summary>读取 ZeroClient.config 文件内容(登录后由 ClientSetting.Save 写入)</summary>
+    /// <remarks>Config&lt;T&gt; 使用 GetBasePath() 保存到 AppDomain.CurrentDomain.BaseDirectory/Config/ 目录</remarks>
+    public String? ReadClientConfigFile()
+    {
+        var configFile = Path.Combine(AppContext.BaseDirectory, "Config", "ZeroClient.config");
+        return File.Exists(configFile) ? File.ReadAllText(configFile) : null;
+    }
+
+    /// <summary>删除 ZeroClient.config,让下一次测试从空状态开始</summary>
+    public void DeleteClientConfig()
+    {
+        var configFile = Path.Combine(AppContext.BaseDirectory, "Config", "ZeroClient.config");
+        if (File.Exists(configFile)) File.Delete(configFile);
+    }
+    #endregion
+}
Added +140 -0
diff --git a/XUnitTest/Samples/ZeroRpcServerFixture.cs b/XUnitTest/Samples/ZeroRpcServerFixture.cs
new file mode 100644
index 0000000..c9526b1
--- /dev/null
+++ b/XUnitTest/Samples/ZeroRpcServerFixture.cs
@@ -0,0 +1,140 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using NewLife;
+using NewLife.Data;
+using NewLife.Log;
+using NewLife.Remoting;
+using XCode;
+using XCode.Membership;
+using Xunit;
+
+namespace XUnitTest.Samples;
+
+/// <summary>ZeroRpcServer 集成测试夹具。启动完整的 ApiServer(端口0自动分配),并初始化临时 SQLite 数据库</summary>
+public sealed class ZeroRpcServerFixture : IAsyncLifetime
+{
+    #region 属性
+    /// <summary>服务端监听端口(系统自动分配)</summary>
+    public Int32 Port { get; private set; }
+
+    /// <summary>测试用户 ID</summary>
+    public Int32 UserId { get; private set; }
+
+    private ApiServer _server = null!;
+    private String _tempDir = null!;
+    private String _origDataPath = null!;
+    private String _origCurrentDir = null!;
+    #endregion
+
+    #region 初始化与清理
+    public async Task InitializeAsync()
+    {
+        XTrace.UseConsole();
+
+        // 独立临时目录,测试互不干扰
+        _tempDir = Path.Combine(Path.GetTempPath(), "ZeroRpcTest_" + Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(_tempDir);
+
+        // 重定向 XCode 数据目录到临时目录,避免污染工作目录
+        _origDataPath = NewLife.Setting.Current.DataPath;
+        NewLife.Setting.Current.DataPath = _tempDir;
+
+        _origCurrentDir = Environment.CurrentDirectory;
+        Environment.CurrentDirectory = _tempDir;
+
+        // 初始化所有 XCode 实体表(自动建表)
+        EntityFactory.InitAll();
+
+        // 预置用户,让 UserController 能正常查询到
+        var user = new User
+        {
+            Name = "TestUser",
+            DisplayName = "测试用户",
+            Enable = true,
+        };
+        user.Save();
+        UserId = user.ID;
+
+        // 启动 ApiServer(端口0自动分配,同时监听 TCP/UDP/WS/HTTP)
+        _server = new ApiServer(0)
+        {
+            Name = "ZeroRpcTestServer",
+            Encoder = new JsonEncoder(),
+            Log = XTrace.Log,
+#if DEBUG
+            EncoderLog = XTrace.Log,
+#endif
+        };
+
+        _server.Register<MyController>();
+        _server.Register<UserTestController>();
+        _server.Start();
+
+        Port = _server.Port;
+
+        XTrace.WriteLine("ZeroRpcServerFixture 启动,端口:{0}", Port);
+
+        await Task.CompletedTask;
+    }
+
+    public async Task DisposeAsync()
+    {
+        _server.TryDispose();
+
+        // 恢复工作目录
+        if (!_origCurrentDir.IsNullOrEmpty()) Environment.CurrentDirectory = _origCurrentDir;
+        if (!_origDataPath.IsNullOrEmpty()) NewLife.Setting.Current.DataPath = _origDataPath;
+
+        // 删除临时目录
+        if (Directory.Exists(_tempDir))
+        {
+            try { Directory.Delete(_tempDir, true); }
+            catch { /* 忽略清理异常 */ }
+        }
+
+        await Task.CompletedTask;
+    }
+    #endregion
+
+    #region 内联控制器(镜像 Zero.RpcServer 中的控制器逻辑)
+    internal class MyController
+    {
+        /// <summary>整数加法</summary>
+        public Int32 Add(Int32 x, Int32 y) => x + y;
+
+        /// <summary>RC4 加解密往返</summary>
+        public IPacket RC4(IPacket pk)
+        {
+            var data = pk.ToArray();
+            var pass = "NewLife".GetBytes();
+            return (ArrayPacket)data.RC4(pass);
+        }
+    }
+
+    [Api("User")]
+    internal class UserTestController : IApi, IActionFilter
+    {
+        public IApiSession Session { get; set; } = null!;
+
+        [Api(nameof(FindByID))]
+        public async Task<Object?> FindByID(Int32 id)
+        {
+            var times = Session["Times"].ToInt();
+            times++;
+            Session["Times"] = times;
+
+            if (times >= 2)
+                throw new ApiException(ApiCode.TooManyRequests, $"调用次数过多!Times={times}");
+
+            await Task.Delay(10);
+            var user = User.FindByID(id);
+            if (user == null) return null;
+            return new { user.ID, user.Name, user.DisplayName, user.Enable };
+        }
+
+        public void OnActionExecuting(ControllerContext filterContext) { }
+        public void OnActionExecuted(ControllerContext filterContext) { }
+    }
+    #endregion
+}
Added +158 -0
diff --git a/XUnitTest/Samples/ZeroRpcServerIntegrationTests.cs b/XUnitTest/Samples/ZeroRpcServerIntegrationTests.cs
new file mode 100644
index 0000000..0de21f6
--- /dev/null
+++ b/XUnitTest/Samples/ZeroRpcServerIntegrationTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using NewLife;
+using NewLife.Data;
+using NewLife.Log;
+using NewLife.Remoting;
+using NewLife.Security;
+using XCode.Membership;
+using Xunit;
+
+namespace XUnitTest.Samples;
+
+/// <summary>ZeroRpcServer 多协议集成测试。覆盖 TCP / UDP / WebSocket / HTTP 四种协议</summary>
+/// <remarks>
+/// 四个测试方法共享同一 ApiServer 实例(IClassFixture),减少启动开销。
+/// 每个协议独立建立连接,Session 状态各自隔离,互不干扰。
+/// </remarks>
+[Collection("ZeroRpcServer")]
+[TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
+public class ZeroRpcServerIntegrationTests : IClassFixture<ZeroRpcServerFixture>
+{
+    private readonly ZeroRpcServerFixture _fixture;
+
+    public ZeroRpcServerIntegrationTests(ZeroRpcServerFixture fixture) => _fixture = fixture;
+
+    #region 辅助:通用断言逻辑
+    /// <summary>
+    /// 对给定客户端执行一整套 RPC 断言流程:
+    /// api/all、My/Add、My/RC4、User/FindByID(首次成功 + 第二次 429)
+    /// </summary>
+    private static async Task AssertRpcCallsAsync(IApiClient client, Int32 userId)
+    {
+        // 1. api/all —— 返回接口列表,包含我们注册的接口
+        var apis = await client.InvokeAsync<String[]>("Api/All");
+        Assert.NotNull(apis);
+        Assert.True(apis.Length >= 5, $"接口数量应 ≥ 5,实际:{apis.Length}");
+        Assert.Contains(apis, a => a.Contains("My/Add"));
+        Assert.Contains(apis, a => a.Contains("My/RC4"));
+        Assert.Contains(apis, a => a.Contains("User/FindByID"));
+
+        // 2. api/info —— 返回服务端信息,含 MachineName
+        var state = Rand.NextString(8);
+        var state2 = Rand.NextString(8);
+        var info = await client.InvokeAsync<IDictionary<String, Object>>("Api/Info", new { state, state2 });
+        Assert.NotNull(info);
+        Assert.True(info.ContainsKey("MachineName"), "info 应包含 MachineName");
+        Assert.True(info.ContainsKey("State"), "info 应包含 State");
+        Assert.Equal(state, info["State"]?.ToString());
+
+        // 3. My/Add —— 整数加法,精确验证返回值
+        var sum = await client.InvokeAsync<Int32>("My/Add", new { x = 13, y = 7 });
+        Assert.Equal(20, sum);
+
+        var sum2 = await client.InvokeAsync<Int32>("My/Add", new { x = -5, y = 5 });
+        Assert.Equal(0, sum2);
+
+        // 4. My/RC4 —— 二进制往返:原文加密后再次加密应还原
+        // 注: RC4 服务返回 IPacket 二进制,必须用 InvokeAsync<IPacket>(ApiClient 直接返回 message.Data,不过 JSON 解码)
+        var original = "Hello NewLife RC4".GetBytes();
+        var pk1 = (ArrayPacket)original;
+        var encrypted = await client.InvokeAsync<IPacket>("My/RC4", pk1);
+        Assert.NotNull(encrypted);
+        var encryptedBytes = encrypted.ToArray();
+        Assert.NotEqual(original, encryptedBytes); // 加密后应不同
+
+        var pk2 = (ArrayPacket)encryptedBytes;
+        var decrypted = await client.InvokeAsync<IPacket>("My/RC4", pk2);
+        var decryptedBytes = decrypted?.ToArray();
+        Assert.Equal(original, decryptedBytes);    // 解密后应还原
+
+        // 5. User/FindByID(首次)—— 返回实体,校验字段
+        var user = await client.InvokeAsync<IDictionary<String, Object>>("User/FindByID", new { id = userId });
+        Assert.NotNull(user);
+        Assert.True(user.ContainsKey("Name"), "User 响应应包含 Name 字段");
+        Assert.False(user["Name"]?.ToString().IsNullOrEmpty(), "User.Name 不应为空");
+
+        // 6. User/FindByID(第二次同 Session)—— 触发 429 TooManyRequests
+        var ex = await Assert.ThrowsAsync<ApiException>(
+            () => client.InvokeAsync<IDictionary<String, Object>>("User/FindByID", new { id = userId }));
+        Assert.Equal(429, ex.Code);
+        Assert.Contains("调用次数过多", ex.Message);
+    }
+    #endregion
+
+    #region TCP 协议测试
+    [Fact(DisplayName = "TCP协议_完整RPC调用链")]
+    public async Task TcpProtocolTest()
+    {
+        using var client = new ApiClient($"tcp://127.0.0.1:{_fixture.Port}")
+        {
+            Log = XTrace.Log,
+        };
+
+        await AssertRpcCallsAsync(client, _fixture.UserId);
+    }
+    #endregion
+
+    #region UDP 协议测试
+    [Fact(DisplayName = "UDP协议_完整RPC调用链")]
+    public async Task UdpProtocolTest()
+    {
+        using var client = new ApiClient($"udp://127.0.0.1:{_fixture.Port}")
+        {
+            Log = XTrace.Log,
+        };
+
+        await AssertRpcCallsAsync(client, _fixture.UserId);
+    }
+    #endregion
+
+    #region WebSocket 协议测试
+    [Fact(DisplayName = "WebSocket协议_完整RPC调用链")]
+    public async Task WebSocketProtocolTest()
+    {
+        using var client = new ApiClient($"ws://127.0.0.1:{_fixture.Port}")
+        {
+            Log = XTrace.Log,
+        };
+
+        await AssertRpcCallsAsync(client, _fixture.UserId);
+    }
+    #endregion
+
+    #region HTTP 协议测试
+    [Fact(DisplayName = "HTTP协议_完整RPC调用链")]
+    public async Task HttpProtocolTest()
+    {
+        var client = new ApiHttpClient($"http://127.0.0.1:{_fixture.Port}")
+        {
+            Log = XTrace.Log,
+        };
+
+        // 1. api/all(GET):返回完整签名格式 "ReturnType Route(params)",用 Contains 子串匹配
+        var apis = await client.GetAsync<String[]>("Api/All");
+        Assert.NotNull(apis);
+        Assert.True(apis.Length >= 5, $"接口数量应 ≥ 5,实际:{apis.Length}");
+        Assert.Contains(apis, a => a.Contains("My/Add"));
+
+        // 2. api/info(POST),校验 State 字段回显
+        var state = Rand.NextString(8);
+        var state2 = Rand.NextString(8);
+        var info = await client.PostAsync<IDictionary<String, Object>>("Api/Info", new { state, state2 });
+        Assert.NotNull(info);
+        Assert.Equal(state, info["State"]?.ToString());
+
+        // 3. My/Add(GET:名称含 Get 或参数为基础类型时自动转 GET)
+        var sum = await client.InvokeAsync<Int32>("My/Add", new { x = 100, y = 200 }, default);
+        Assert.Equal(300, sum);
+
+        // 4. User/FindByID(GET)
+        var user = await client.GetAsync<IDictionary<String, Object>>("User/FindByID", new { id = _fixture.UserId });
+        Assert.NotNull(user);
+        Assert.True(user.ContainsKey("Name"));
+        Assert.False(user["Name"]?.ToString().IsNullOrEmpty());
+    }
+    #endregion
+}
Modified +3 -2
diff --git a/XUnitTest/XUnitTest.csproj b/XUnitTest/XUnitTest.csproj
index baedaca..1216f30 100644
--- a/XUnitTest/XUnitTest.csproj
+++ b/XUnitTest/XUnitTest.csproj
@@ -11,8 +11,9 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
+    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
+    <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
     <PackageReference Include="Moq" Version="4.20.72" />
     <PackageReference Include="NewLife.Core" Version="11.14.2026.402" />
     <PackageReference Include="NewLife.UnitTest" Version="1.1.2026.102" />