Procházet zdrojové kódy

fix(s0): close anonymous tenant write gaps and harden NbrType schema (server 1.0.328)

ItemOpCondition / ItemSubstituteDetail: remove class-level [AllowAnonymous], enforce AdoS0TenantScope.TryResolveRequired on all CRUD, explicit TenantId stamp, id+tenant on detail/update/delete, tenant-scope the ItemMaster/Routing lookups. Fixes anonymous inserts persisting tenant=0.
NbrTypeMaster: tenant_id BIGINT NOT NULL via 1.0.328.sql (idempotent, asserts no NULL/0, no auto-backfill, no DEFAULT); [IgnoreTable] so CodeFirst does not revert (verified across two restarts).
Dual-tenant runtime 30/30 PASS. No entity/ITenantIdFilter/global-CodeFirst change; Web unchanged.
YY968XX před 1 dnem
rodič
revize
a1545efd15

+ 6 - 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.327</AssemblyVersion>
-    <FileVersion>1.0.327</FileVersion>
-    <Version>1.0.327</Version>
+    <AssemblyVersion>1.0.328</AssemblyVersion>
+    <FileVersion>1.0.328</FileVersion>
+    <Version>1.0.328</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -316,6 +316,9 @@
     <None Update="UpdateScripts\1.0.324.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.328.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\1.0.325.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

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

@@ -0,0 +1,45 @@
+-- 1.0.328.sql  S0:NbrTypeMaster.tenant_id → BIGINT NOT NULL(幂等 + 前置断言,绝不自动回填)。
+--   背景:NbrTypeMaster 实体 AdoS0NbrTypeMaster 带 [IgnoreTable],CodeFirst 不管理其结构 → DDL 层的 NOT NULL 可持久(重启不被还原,已两次重启实证)。
+--         现状 141 行(797:70 / 824:71),tenant_id 全真实租户、0 NULL、0 zero、0 system → 可安全硬化。
+--   仅操作 NbrTypeMaster 一张表;不插入/更新/删除任何业务数据;不加任何 DEFAULT。
+--   与 S0 收尾小批同批:ItemOpCondition / ItemSubstituteDetail 匿名写 tenant=0 P0 收口(纯 Controller,无 DDL)。
+-- ============================================================================
+
+-- 一、确认 tenant_id 列存在(缺列则全脚本 no-op)
+SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrTypeMaster' AND COLUMN_NAME = 'tenant_id');
+
+-- 二、前置数据断言:存在 NULL 或 0 则中止(SIGNAL),绝不自动 UPDATE / 猜测 tenant
+SET @bad := IF(@has_col > 0,
+    (SELECT COUNT(*) FROM NbrTypeMaster WHERE tenant_id IS NULL OR tenant_id = 0), 0);
+SELECT @bad AS nbrtype_null_or_zero;   -- 期望 0
+SET @sql := IF(@has_col > 0 AND @bad > 0,
+    "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'NbrTypeMaster has NULL/0 tenant_id — abort NOT NULL hardening'",
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 三、幂等 MODIFY NOT NULL(仅当列存在、数据干净、且当前仍 nullable 时执行)
+SET @is_nullable := (SELECT IS_NULLABLE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrTypeMaster' AND COLUMN_NAME = 'tenant_id');
+SET @sql := IF(@has_col > 0 AND @bad = 0 AND @is_nullable = 'YES',
+    'ALTER TABLE NbrTypeMaster MODIFY COLUMN tenant_id BIGINT NOT NULL',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 四、剥离任何遗留 DEFAULT(保证最终态无 DEFAULT 0 / DEFAULT system)
+SET @has_default := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrTypeMaster' AND COLUMN_NAME = 'tenant_id'
+      AND COLUMN_DEFAULT IS NOT NULL);
+SET @sql := IF(@has_default > 0, 'ALTER TABLE NbrTypeMaster ALTER COLUMN tenant_id DROP DEFAULT', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 五、执行后验证
+SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrTypeMaster' AND COLUMN_NAME = 'tenant_id';
+-- 期望:tenant_id bigint / IS_NULLABLE = NO / COLUMN_DEFAULT = NULL。
+
+-- ============================================================================
+-- 回滚 SQL(如需恢复可空,仅在确认无写入依赖非空时执行):
+--   ALTER TABLE NbrTypeMaster MODIFY COLUMN tenant_id BIGINT NULL;
+-- ============================================================================

+ 17 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgMaterialProcessElementsController.cs

@@ -6,9 +6,12 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
+/// <summary>
+/// S0 物料工序条件(ItemOpCondition 语义)。
+/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP 兜底。
+/// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/material-process-elements")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0MfgMaterialProcessElementsController : ControllerBase
 {
@@ -22,12 +25,14 @@ public class AdoS0MfgMaterialProcessElementsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0ItemOpConditionQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var page = q.Page;
         var pageSize = q.PageSize;
         (page, pageSize) = PagingGuard.Normalize(page, pageSize);
 
-        // 去 domain 化(S0-DOMAIN-FILTER-REMOVAL-READONLY-1):不再按 Domain 过滤,租户隔离由 ITenantIdFilter 自动生效;保留 CodeType 业务条件。
+        // 显式租户隔离:不依赖全局 AOP,直接按当前请求租户过滤;保留 CodeType 业务条件。
         var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId)
             .Where(x => x.CodeType == "Prod")
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
                 x =>
@@ -49,7 +54,7 @@ public class AdoS0MfgMaterialProcessElementsController : ControllerBase
         var itemNums = list.Select(x => x.ItemNum).Distinct().ToList();
         // 去 domain 化:物料名补显不再按 Domain 等值;ItemMaster 带 ITenantIdFilter,租户内 ItemNum 唯一,确定性以 IsActive/RecID 稳定排序兜底。
         var items = await _rep.Context.Queryable<AdoS0ItemMaster>()
-            .Where(m => itemNums.Contains(m.ItemNum))
+            .Where(m => m.TenantId == tenantId && itemNums.Contains(m.ItemNum))
             .OrderByDescending(m => m.IsActive)
             .OrderBy(m => m.Id)
             .Select(m => new { m.ItemNum, m.Descr })
@@ -67,7 +72,7 @@ public class AdoS0MfgMaterialProcessElementsController : ControllerBase
         if (opKeys.Count > 0)
         {
             var r = await _rep.Context.Queryable<AdoS0MfgRoutingOpDetail>()
-                .Where(x => x.MaterialCode != null && itemNums.Contains(x.MaterialCode))
+                .Where(x => x.TenantId == tenantId && x.MaterialCode != null && itemNums.Contains(x.MaterialCode))
                 .ToListAsync();
             foreach (var k in opKeys)
             {
@@ -103,16 +108,19 @@ public class AdoS0MfgMaterialProcessElementsController : ControllerBase
     [HttpGet("{id:long}")]
     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();
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ItemOpConditionUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
         var entity = new AdoS0ItemOpCondition
         {
+            TenantId = tenantId,
             Domain = dto.Domain.Trim(),
             CodeType = "Prod",
             ItemNum = dto.ItemNum.Trim(),
@@ -134,7 +142,8 @@ public class AdoS0MfgMaterialProcessElementsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ItemOpConditionUpsertDto 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();
         entity.Domain = dto.Domain.Trim();
         entity.CodeType = "Prod";
@@ -154,7 +163,8 @@ public class AdoS0MfgMaterialProcessElementsController : ControllerBase
     [HttpDelete("{id:long}")]
     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();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });

+ 13 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ItemSubstituteDetailsController.cs

@@ -5,11 +5,12 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Sales;
 
 /// <summary>
-/// S0 物料替代关系(ItemSubstituteDetail 语义)
+/// S0 物料替代关系(ItemSubstituteDetail 语义)。
+/// 多租户隔离:所有读写显式限定当前请求租户(AdoS0TenantScope.TryResolveRequired),不依赖全局 AOP 兜底。
+/// 父/子/替代物料以业务编码(ParentItem/ItemNum/SubstituteItem)存储,替代行本身按租户隔离。
 /// </summary>
 [ApiController]
 [Route("api/s0/sales/item-substitute-details")]
-[AllowAnonymous]
 [NonUnify]
 public class AdoS0ItemSubstituteDetailsController : ControllerBase
 {
@@ -23,9 +24,11 @@ public class AdoS0ItemSubstituteDetailsController : ControllerBase
     [HttpGet]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS0ItemSubstituteDetailQueryDto q)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
 
         var query = _rep.AsQueryable()
+            .Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Domain), x => x.Domain == q.Domain)
             .WhereIF(!string.IsNullOrWhiteSpace(q.SubstituteType), x => x.SubstituteType == q.SubstituteType)
             .WhereIF(
@@ -49,16 +52,19 @@ public class AdoS0ItemSubstituteDetailsController : ControllerBase
     [HttpGet("{id:long}")]
     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();
         return item == null ? NotFound() : Ok(item);
     }
 
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ItemSubstituteDetailUpsertDto dto)
     {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
         var entity = new AdoS0ItemSubstituteDetail
         {
+            TenantId = tenantId,
             ParentItem = dto.ParentItem.Trim(),
             ItemNum = dto.ItemNum.Trim(),
             SubstituteItem = dto.SubstituteItem.Trim(),
@@ -78,7 +84,8 @@ public class AdoS0ItemSubstituteDetailsController : ControllerBase
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ItemSubstituteDetailUpsertDto 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();
 
         entity.ParentItem = dto.ParentItem.Trim();
@@ -98,7 +105,8 @@ public class AdoS0ItemSubstituteDetailsController : ControllerBase
     [HttpDelete("{id:long}")]
     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();
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });