Explorar el Código

fix(s0): tenant-scope NbrTypeMaster (server 1.0.303)

NbrTypeMaster 数据已按租户填充(797:70/824:71,Domain 分区 8010/2410,
62 NbrType 跨租户各存一份)→ 属租户级主数据,恢复租户隔离。

- 实体 AdoS0NbrTypeMaster:加回 ITenantIdFilter + tenant_id 属性(保留 [IgnoreTable])
- 控制器 AdoS0NbrTypesController:去 [AllowAnonymous] + AdoS0TenantScope 严格租户;
  List/Get/Update/Delete 按 id+tenant;dup 对齐新索引 (tenant,Domain,NbrClass,NbrType)
- ReferenceChecker.NbrTypeReferencesAsync:删除保护按当前租户计数(去跨租户误拦)
- 1.0.303.sql:全局唯一 IX_NbrTypeMaster → 租户内唯一 uk_NbrTypeMaster_tenant_domain_nbrclass_nbrtype

修复 S0TenantEntityContractTests(AdoS0NbrTypeMaster) 红(实体改对后自动转绿,34/0)。
契约测试通过 + build 0 error + DDL 幂等验证;运行时 HTTP 冒烟待并发 S5 WIP 修好后补。
YY968XX hace 3 días
padre
commit
9a7d251a60

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.302</AssemblyVersion>
-    <FileVersion>1.0.302</FileVersion>
-    <Version>1.0.302</Version>
+    <AssemblyVersion>1.0.303</AssemblyVersion>
+    <FileVersion>1.0.303</FileVersion>
+    <Version>1.0.303</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -280,6 +280,9 @@
     <None Update="UpdateScripts\1.0.300.sql">
     <None Update="UpdateScripts\1.0.300.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.303.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
   </ItemGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 45 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.303.sql

@@ -0,0 +1,45 @@
+-- 1.0.303.sql  S0:NbrTypeMaster 唯一索引租户化(幂等 + 冲突前置守卫)。
+--   背景:NbrTypeMaster 现已按租户填充(797:70 / 824:71,Domain 按租户分区 8010/2410,62 个 NbrType 跨租户各存一份),
+--         属租户级主数据;原全局唯一索引 IX_NbrTypeMaster(Domain, NbrClass, NbrType) 仅因 Domain 天然分区而未撞,
+--         两租户若共用同一 Domain 即会误撞。改为租户内唯一,使不同租户可复用相同 (Domain,NbrClass,NbrType)。
+--   不改字段类型、不改 tenant_id 可空性、不插入/更新/删除任何业务数据。
+--   实体 AdoS0NbrTypeMaster 带 [IgnoreTable],CodeFirst 不管理其索引 → 本 DDL 为唯一来源,重启不复活旧全局唯一。
+-- ============================================================================
+
+-- ========== 一、冲突前置检测(同租户内重复则跳过创建,绝不自动清理历史数据) ==========
+SET @dup := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, `Domain`, NbrClass, NbrType
+    FROM NbrTypeMaster
+    GROUP BY tenant_id, `Domain`, NbrClass, NbrType
+    HAVING COUNT(*) > 1) t);
+SELECT @dup AS nbrtype_intratenant_dup;   -- 期望 0;若非 0,下方 ADD 被守卫跳过
+
+-- ========== 二、删除旧全局唯一索引 IX_NbrTypeMaster(存在才删) ==========
+SET @has_old := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrTypeMaster'
+      AND INDEX_NAME = 'IX_NbrTypeMaster');
+SET @sql := IF(@has_old > 0, 'ALTER TABLE NbrTypeMaster DROP INDEX IX_NbrTypeMaster', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ========== 三、创建租户内唯一索引(不存在且无同租户冲突才建) ==========
+SET @has_new := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrTypeMaster'
+      AND INDEX_NAME = 'uk_NbrTypeMaster_tenant_domain_nbrclass_nbrtype');
+SET @sql := IF(@has_new = 0 AND @dup = 0,
+    'ALTER TABLE NbrTypeMaster ADD UNIQUE INDEX uk_NbrTypeMaster_tenant_domain_nbrclass_nbrtype (tenant_id, `Domain`, NbrClass, NbrType)',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ========== 四、执行后验证 ==========
+SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrTypeMaster' AND INDEX_NAME <> 'PRIMARY'
+ORDER BY INDEX_NAME, SEQ_IN_INDEX;
+-- 期望:仅 uk_NbrTypeMaster_tenant_domain_nbrclass_nbrtype (tenant_id, Domain, NbrClass, NbrType)(唯一)。
+
+-- ============================================================================
+-- 回滚 SQL(恢复全局唯一,历史数据无跨租户同键冲突时可执行):
+--   ALTER TABLE NbrTypeMaster DROP INDEX uk_NbrTypeMaster_tenant_domain_nbrclass_nbrtype;
+--   ALTER TABLE NbrTypeMaster ADD UNIQUE INDEX IX_NbrTypeMaster (`Domain`, NbrClass, NbrType);
+-- 注意:回滚 ADD 若两租户已共用同一 (Domain,NbrClass,NbrType) 会失败(这正是本次要放开的场景)。
+-- ============================================================================

+ 27 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0NbrTypesController.cs

@@ -5,11 +5,11 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 
 
 /// <summary>
 /// <summary>
-/// S0 单号类型主数据(NbrTypeMaster 语义)
+/// S0 单号类型主数据(NbrTypeMaster 语义)。
+/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP。
 /// </summary>
 /// </summary>
 [ApiController]
 [ApiController]
 [Route("api/s0/warehouse/nbr-types")]
 [Route("api/s0/warehouse/nbr-types")]
-[AllowAnonymous]
 [NonUnify]
 [NonUnify]
 public class AdoS0NbrTypesController : ControllerBase
 public class AdoS0NbrTypesController : ControllerBase
 {
 {
@@ -30,9 +30,11 @@ public class AdoS0NbrTypesController : ControllerBase
     [HttpGet]
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0NbrTypeQueryDto q)
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0NbrTypeQueryDto q)
     {
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
 
 
         var query = _rep.AsQueryable()
         var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId)
             .WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId!.Value)
             .WhereIF(q.CompanyRefId.HasValue, x => x.CompanyRefId == q.CompanyRefId!.Value)
             .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId!.Value)
             .WhereIF(q.FactoryRefId.HasValue, x => x.FactoryRefId == q.FactoryRefId!.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
             .WhereIF(!string.IsNullOrWhiteSpace(q.DomainCode), x => x.DomainCode == q.DomainCode)
@@ -49,7 +51,7 @@ public class AdoS0NbrTypesController : ControllerBase
             .Take(q.PageSize)
             .Take(q.PageSize)
             .ToListAsync();
             .ToListAsync();
 
 
-        await ApplyDeptDescrAsync(list);
+        await ApplyDeptDescrAsync(list, tenantId);
 
 
         return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
         return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
     }
     }
@@ -57,23 +59,28 @@ public class AdoS0NbrTypesController : ControllerBase
     [HttpGet("{id:long}")]
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetAsync(long id)
     public async Task<IActionResult> GetAsync(long id)
     {
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (item == null) return NotFound();
         if (item == null) return NotFound();
-        await ApplyDeptDescrAsync(new List<AdoS0NbrTypeMaster> { item });
+        await ApplyDeptDescrAsync(new List<AdoS0NbrTypeMaster> { item }, tenantId);
         return Ok(item);
         return Ok(item);
     }
     }
 
 
     [HttpPost]
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0NbrTypeUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0NbrTypeUpsertDto dto)
     {
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (string.IsNullOrWhiteSpace(dto.DomainCode))
         if (string.IsNullOrWhiteSpace(dto.DomainCode))
             return AdoS0ApiErrors.InvalidRequest("请填写域编码");
             return AdoS0ApiErrors.InvalidRequest("请填写域编码");
 
 
-        if (await _rep.IsAnyAsync(x => x.FactoryRefId == dto.FactoryRefId && x.NbrType == dto.NbrType))
+        // 唯一口径对齐数据库 uk_NbrTypeMaster_tenant_domain_nbrclass_nbrtype (tenant_id, Domain, NbrClass, NbrType)
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.DomainCode == (dto.DomainCode ?? string.Empty)
+                && x.NbrClass == dto.NbrClass && x.NbrType == dto.NbrType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "单号类型编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "单号类型编码已存在");
 
 
         var entity = new AdoS0NbrTypeMaster
         var entity = new AdoS0NbrTypeMaster
         {
         {
+            TenantId = tenantId,
             CompanyRefId = dto.CompanyRefId,
             CompanyRefId = dto.CompanyRefId,
             FactoryRefId = dto.FactoryRefId,
             FactoryRefId = dto.FactoryRefId,
             DomainCode = dto.DomainCode ?? string.Empty,
             DomainCode = dto.DomainCode ?? string.Empty,
@@ -93,12 +100,16 @@ public class AdoS0NbrTypesController : ControllerBase
     [HttpPut("{id:long}")]
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0NbrTypeUpsertDto dto)
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0NbrTypeUpsertDto dto)
     {
     {
-        var entity = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
-        if (await _rep.IsAnyAsync(x => x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.NbrType == dto.NbrType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.DomainCode == (dto.DomainCode ?? string.Empty)
+                && x.NbrClass == dto.NbrClass && x.NbrType == dto.NbrType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "单号类型编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "单号类型编码已存在");
 
 
+        // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant
         entity.CompanyRefId = dto.CompanyRefId;
         entity.CompanyRefId = dto.CompanyRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.DomainCode = dto.DomainCode ?? string.Empty;
         entity.DomainCode = dto.DomainCode ?? string.Empty;
@@ -117,10 +128,13 @@ public class AdoS0NbrTypesController : ControllerBase
     [HttpDelete("{id:long}")]
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     public async Task<IActionResult> DeleteAsync(long id)
     {
     {
-        var item = await _rep.GetByIdAsync(id);
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var item = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (item == null) return NotFound();
         if (item == null) return NotFound();
 
 
-        var refInfo = await _refChecker.NbrTypeReferencesAsync(item.NbrType);
+        // 引用检查限定当前租户:不因他租户同名 NbrType 的规则被引用而误拦删除
+        var refInfo = await _refChecker.NbrTypeReferencesAsync(tenantId, item.NbrType);
         if (refInfo is { } r)
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
                 $"存在 {r.Count} 条 {r.Table} 引用该单号类型,无法删除");
                 $"存在 {r.Count} 条 {r.Table} 引用该单号类型,无法删除");
@@ -129,13 +143,14 @@ public class AdoS0NbrTypesController : ControllerBase
         return Ok(new { message = "删除成功" });
         return Ok(new { message = "删除成功" });
     }
     }
 
 
-    private async Task ApplyDeptDescrAsync(List<AdoS0NbrTypeMaster> items)
+    /// <summary>补显部门说明。关联 DepartmentMaster 限定当前租户,避免同编码部门跨租户补错描述。</summary>
+    private async Task ApplyDeptDescrAsync(List<AdoS0NbrTypeMaster> items, long tenantId)
     {
     {
         if (items.Count == 0) return;
         if (items.Count == 0) return;
 
 
         var domainCodes = items.Select(x => x.DomainCode).Distinct().ToList();
         var domainCodes = items.Select(x => x.DomainCode).Distinct().ToList();
         var depts = await _deptRep.AsQueryable()
         var depts = await _deptRep.AsQueryable()
-            .Where(d => domainCodes.Contains(d.DomainCode))
+            .Where(d => d.TenantId == tenantId && domainCodes.Contains(d.DomainCode))
             .ToListAsync();
             .ToListAsync();
 
 
         foreach (var item in items)
         foreach (var item in items)

+ 9 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0NbrTypeMaster.cs

@@ -5,11 +5,14 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 /// DB 存在新旧两套列:业务真实数据落在旧 PascalCase 列;snake_case 列由历史 CodeFirst 阶段补加但全空。
 /// DB 存在新旧两套列:业务真实数据落在旧 PascalCase 列;snake_case 列由历史 CodeFirst 阶段补加但全空。
 /// 本 Entity 将业务字段 ColumnName 改回 DB 真实 PascalCase 列,使 70 行历史数据可见;
 /// 本 Entity 将业务字段 ColumnName 改回 DB 真实 PascalCase 列,使 70 行历史数据可见;
 /// 组织 / 审计字段保留 snake_case 列映射(Controller 写入路径仍走 company_ref_id / factory_ref_id)。
 /// 组织 / 审计字段保留 snake_case 列映射(Controller 写入路径仍走 company_ref_id / factory_ref_id)。
-/// 加 [IgnoreTable] 防止 CodeFirst 继续 ALTER 表结构;旧 ITenantIdFilter 移除(tenant_id 列 70 行全 NULL,无业务过滤价值)。
+/// 加 [IgnoreTable] 防止 CodeFirst 继续 ALTER 该 legacy 表结构(保留旧列口径)。
+/// 租户:tenant_id 现已按租户填充(797/824 各一套,62 个 NbrType 跨租户各存一份,Domain 按租户分区 8010/2410),
+/// 属租户级主数据 → 重新实现 ITenantIdFilter,恢复全局 AOP 租户过滤;租户内唯一由 UpdateScripts 索引保证
+/// (uk_NbrTypeMaster_tenant_domain_nbrclass_nbrtype,[IgnoreTable] 故不经 CodeFirst 管理)。
 /// </summary>
 /// </summary>
 [IgnoreTable]
 [IgnoreTable]
 [SugarTable("NbrTypeMaster", "单号类型主数据(复刻 NbrTypeMaster)")]
 [SugarTable("NbrTypeMaster", "单号类型主数据(复刻 NbrTypeMaster)")]
-public class AdoS0NbrTypeMaster
+public class AdoS0NbrTypeMaster : ITenantIdFilter
 {
 {
     [SugarColumn(ColumnName = "RecID", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true)]
     [SugarColumn(ColumnName = "RecID", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true)]
     public long Id { get; set; }
     public long Id { get; set; }
@@ -95,6 +98,10 @@ public class AdoS0NbrTypeMaster
     [SugarColumn(ColumnName = "UpdateTime", ColumnDescription = "更新时间", IsNullable = true)]
     [SugarColumn(ColumnName = "UpdateTime", ColumnDescription = "更新时间", IsNullable = true)]
     public DateTime? UpdateTime { get; set; }
     public DateTime? UpdateTime { get; set; }
 
 
+    /// <summary>租户 ID(映射既有 tenant_id 列;实现 ITenantIdFilter,启用全局 AOP 租户隔离)。</summary>
+    [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
+    public long? TenantId { get; set; }
+
     /// <summary>列表展示:部门说明(不落库,来自 DepartmentMaster 关联)</summary>
     /// <summary>列表展示:部门说明(不落库,来自 DepartmentMaster 关联)</summary>
     [SugarColumn(IsIgnore = true)]
     [SugarColumn(IsIgnore = true)]
     public string? DeptDescr { get; set; }
     public string? DeptDescr { get; set; }

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0ReferenceChecker.cs

@@ -351,12 +351,13 @@ public sealed class AdoS0ReferenceChecker
     /// 单号类型(NbrTypeMaster)删除前引用检查。
     /// 单号类型(NbrTypeMaster)删除前引用检查。
     /// 覆盖下游:单号规则(NbrControl.NbrType)。
     /// 覆盖下游:单号规则(NbrControl.NbrType)。
     /// </summary>
     /// </summary>
-    public async Task<(int Count, string Table)?> NbrTypeReferencesAsync(string? nbrType)
+    public async Task<(int Count, string Table)?> NbrTypeReferencesAsync(long tenantId, string? nbrType)
     {
     {
         if (string.IsNullOrWhiteSpace(nbrType)) return null;
         if (string.IsNullOrWhiteSpace(nbrType)) return null;
 
 
+        // 限定当前租户:NbrType 为租户级主数据,删除保护只统计本租户的单号规则引用,杜绝跨租户误拦
         var ctrl = await _db.Queryable<AdoS0NbrControl>()
         var ctrl = await _db.Queryable<AdoS0NbrControl>()
-            .Where(x => x.NbrType == nbrType)
+            .Where(x => x.TenantId == tenantId && x.NbrType == nbrType)
             .CountAsync();
             .CountAsync();
         if (ctrl > 0) return (ctrl, "单号规则 (NbrControl)");
         if (ctrl > 0) return (ctrl, "单号规则 (NbrControl)");