Ver Fonte

fix(approval-flow): TenantId 改为 override,恢复租户归属护栏 | server 1.0.566

ApprovalFlow 用重新声明同名属性的方式隐藏了 EntityBaseTenantOrgDel.TenantId
(CS0114)。两边类型同为 long?,所以不是可空性冲突,隐藏真正吃掉的是两样东西:

1. 接口分派。子类隐藏但未重新实现 ITenantIdFilter,((ITenantIdFilter)flow).TenantId
   落到基类那个永不赋值的字段上。离线探针实测:flow.TenantId = 797403760988229 时,
   经接口读回 null。任何按 ITenantIdFilter 做内存判断的泛型代码都会读错租户。
2. IsOnlyIgnoreUpdate。基类声明为 true(租户归属只在插入时写定),子类的 SugarColumn
   未带该参数,实测 ORM 元数据被覆盖成 false —— 整实体 Updateable 会把 TenantId
   带进 SET 子句,等于给「改写行的租户归属」开了口子。

两者当时都没有代码路径触发(现有写路径全是 UpdateColumns/SetColumns 白名单或
IgnoreColumns(null)),属 latent bug;但护栏已经没了,而编译器只给一条 warning。

改为 override 并补回 IsOnlyIgnoreUpdate,使 CLR、接口、SqlSugar 三处语义重新统一。
保留 IsNullable=true —— 表中 51 行 TenantId IS NULL 是在用的全局流程,不能推成 NOT NULL。

验证(离线只读探针 + 契约测试,未连库写入):
- ORM: TenantId 列数 1、重复列 0、总列数 20 不变、IsNullable=True、IsOnlyIgnoreUpdate=True
- 分派: 子类/接口/基类三路径在 value、null、反向写入三种场景下完全一致
- 更新: 整实体 Updateable 的 SET 不再含 TenantId;SetColumns/UpdateColumns/Insert 路径不受影响
- 新增 7 条契约测试,net10.0 与 net8.0 各 7/7 通过
- 反向验证: 还原成隐藏写法后 CS0114 复现且 4/7 测试失败,确认测试抓得住回归
- Admin.NET.sln 全 TFM build 0 Error,本批新增 warning 0

不在本批范围(已登记,未改动):全局 QueryFilter 缺 OR TenantId IS NULL 导致的
全局流程管理可见性问题。
YY968XX há 5 dias atrás
pai
commit
ff0ce53f8f

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.565</AssemblyVersion>
-    <FileVersion>1.0.565</FileVersion>
-    <Version>1.0.565</Version>
+    <AssemblyVersion>1.0.566</AssemblyVersion>
+    <FileVersion>1.0.566</FileVersion>
+    <Version>1.0.566</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 93 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/ApprovalFlowTenantIdContractTests.cs

@@ -0,0 +1,93 @@
+using System.Reflection;
+using Admin.NET.Core;
+using SqlSugar;
+using Xunit;
+using AF = Admin.NET.Plugin.ApprovalFlow.ApprovalFlow;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// ApprovalFlow.TenantId 的属性契约。
+/// <para>本实体曾用「重新声明同名属性」隐藏 <see cref="EntityBaseTenantOrgDel"/> 的 TenantId(CS0114)。
+/// 隐藏会造成两处静默故障:<c>((ITenantIdFilter)flow).TenantId</c> 落到基类那个永不赋值的字段上(实测读回 null),
+/// 以及丢掉基类 <c>IsOnlyIgnoreUpdate = true</c>,使整实体 UPDATE 有机会改写租户归属。</para>
+/// <para>两者当时都没有代码路径触发,编译器也只给一条 warning —— 正因为无声,才需要契约测试钉住。</para>
+/// </summary>
+public class ApprovalFlowTenantIdContractTests
+{
+    private static PropertyInfo TenantIdProperty =>
+        typeof(AF).GetProperty(nameof(ITenantIdFilter.TenantId))!;
+
+    /// <summary>TenantId 必须是 override,而不是隐藏基类属性。</summary>
+    [Fact]
+    public void TenantId_Overrides_BaseProperty_RatherThanHidingIt()
+    {
+        var getter = TenantIdProperty.GetGetMethod()!;
+        Assert.True(getter.IsVirtual, "TenantId 必须是 virtual/override 链的一环");
+
+        // override 的 getter 其 GetBaseDefinition() 指回基类;隐藏(new)则指向自己。
+        Assert.Equal(typeof(EntityBaseTenantOrgDel), getter.GetBaseDefinition().DeclaringType);
+    }
+
+    /// <summary>子类、接口、基类三条访问路径必须读到同一个值(含 null)。</summary>
+    [Theory]
+    [InlineData(797403760988229L)]
+    [InlineData(null)]
+    public void TenantId_IsConsistent_AcrossDerived_Interface_And_Base(long? value)
+    {
+        var flow = new AF { TenantId = value };
+
+        Assert.Equal(value, flow.TenantId);
+        Assert.Equal(value, ((ITenantIdFilter)flow).TenantId);
+        Assert.Equal(value, ((EntityBaseTenantOrgDel)flow).TenantId);
+    }
+
+    /// <summary>反向:经接口写入,子类必须读得到——泛型基础设施按 ITenantIdFilter 赋值时不能落空。</summary>
+    [Fact]
+    public void TenantId_WrittenThroughInterface_IsVisibleOnDerived()
+    {
+        var flow = new AF();
+        ((ITenantIdFilter)flow).TenantId = 797403760988229L;
+
+        Assert.Equal(797403760988229L, flow.TenantId);
+    }
+
+    /// <summary>NULL 全局流程语义:列必须可空,且 ORM 不得把它推导成 NOT NULL。</summary>
+    [Fact]
+    public void TenantId_StaysNullable_ForGlobalFlowSemantics()
+    {
+        Assert.Equal(typeof(long?), TenantIdProperty.PropertyType);
+
+        var sugarColumn = TenantIdProperty
+            .GetCustomAttributes(typeof(SugarColumn), inherit: true)
+            .OfType<SugarColumn>()
+            .SingleOrDefault();
+        Assert.NotNull(sugarColumn);
+        Assert.True(sugarColumn!.IsNullable, "TenantId IS NULL 表示全局流程,不能推导成 NOT NULL");
+    }
+
+    /// <summary>租户归属只在插入时写定:任何整实体 UPDATE 都不得把 TenantId 带进 SET。</summary>
+    [Fact]
+    public void TenantId_IsExcluded_FromWholeEntityUpdate()
+    {
+        var sugarColumn = TenantIdProperty
+            .GetCustomAttributes(typeof(SugarColumn), inherit: true)
+            .OfType<SugarColumn>()
+            .Single();
+
+        Assert.True(sugarColumn.IsOnlyIgnoreUpdate, "IsOnlyIgnoreUpdate 承自基类,丢掉它整实体更新就能改写租户归属");
+    }
+
+    /// <summary>ORM 只能看到一个 TenantId,不允许父子双属性同时进入列元数据。</summary>
+    [Fact]
+    public void TenantId_MapsToExactlyOneClrProperty()
+    {
+        var properties = typeof(AF)
+            .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy)
+            .Where(p => p.Name == nameof(ITenantIdFilter.TenantId))
+            .ToList();
+
+        Assert.Single(properties);
+        Assert.Equal(typeof(AF), properties[0].DeclaringType);
+    }
+}

+ 6 - 2
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Entity/ApprovalFlow.cs

@@ -72,7 +72,11 @@ public class ApprovalFlow : EntityBaseTenantOrgDel
 
     /// <summary>
     /// 租户Id(NULL = 全局,所有租户可用;非 NULL = 仅该租户可用)
+    /// <para>必须是 <c>override</c> 而不是隐藏基类属性:隐藏会让 <c>((ITenantIdFilter)flow).TenantId</c>
+    /// 落到基类那个永不赋值的字段上(实测读回 null),同时丢掉基类的 <c>IsOnlyIgnoreUpdate</c> 护栏。</para>
+    /// <para><c>IsNullable</c> 保留 NULL 全局语义;<c>IsOnlyIgnoreUpdate</c> 承自基类,
+    /// 租户归属只在插入时写定,任何整实体更新都不得改写它。</para>
     /// </summary>
-    [SugarColumn(ColumnDescription = "租户Id", IsNullable = true)]
-    public long? TenantId { get; set; }
+    [SugarColumn(ColumnDescription = "租户Id", IsNullable = true, IsOnlyIgnoreUpdate = true)]
+    public override long? TenantId { get; set; }
 }