Просмотр исходного кода

fix(dataplatform): 库位角色人工映射加白名单校验 | server 1.0.587

与上一提交的岗位码同类漏洞:PUT /api/DataPlatform/location-roles 对 location_role
无任何校验,role = row.LocationRole.Trim() 直接入库。写错的角色不会报错,
只会让 NeutralTransTypeCodes.LocationRules 的「事务码+角色+数量方向」组合永远判不出阶段码,
库存流水静默落进未映射隔离;且因写入 role_source='MANUAL' 不会被自动推断纠正。

- NeutralTransTypeCodes 新增 NormalizeLocationRole(),白名单沿用既有 AllLocationRoles
- 忽略大小写但归一化为大写标准值:MySQL 默认排序规则不区分大小写,
  小写值不挡就会绕过校验入库,却与 LocationRules 的等值比对「看起来」仍匹配
- SaveLocations 改为经 NormalizeLocationRole 校验,非法值抛 Oops.Oh 并回显合法取值
- 补守卫:Normalize 边界、保存路径确实接了校验、前端下拉与 AllLocationRoles 逐字一致
  (NeutralTransTypeCodes 文档明写「禁止在别处再手写一份」,前端那份副本此前无人钉住)

另:.gitignore 补 __pycache__/ 与 *.pyc。此前只有 /ai-dop-platform/ 下有此规则,
doc/ 与 tools/ 的脚本产物每轮都会污染 git status。

数据层已核:mdp_employee_position_map、mdp_std_employee 的 position_code 零条越界;
mdp_location_role 在用的 10 个角色全部在白名单内,无历史脏值待清理。

测试:3201 个用例,7 个失败均为改动前既有基线,零回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 1 день назад
Родитель
Сommit
c2d53ec156

+ 4 - 0
.gitignore

@@ -55,6 +55,10 @@ server/Admin.NET.Application/Configuration/DeepSeek.json
 /ai-dop-platform/**/__pycache__/
 /ai-dop-platform/**/.venv/
 
+# Python bytecode (doc/ 与 tools/ 下的脚本每次执行都会生成,勿提交)
+__pycache__/
+*.pyc
+
 # Local Docker runtime data (do not commit)
 /docker/mysql/mysql/
 /docker/redis/data/

+ 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.586</AssemblyVersion>
-    <FileVersion>1.0.586</FileVersion>
-    <Version>1.0.586</Version>
+    <AssemblyVersion>1.0.587</AssemblyVersion>
+    <FileVersion>1.0.587</FileVersion>
+    <Version>1.0.587</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 41 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/NeutralTransTypeMappingTests.cs

@@ -179,6 +179,47 @@ public class NeutralTransTypeMappingTests
         Assert.Contains("API_INBOUND", api);
     }
 
+    [Theory]
+    [InlineData("MATERIAL", "MATERIAL")]
+    [InlineData("  FG_LINE_LOC  ", "FG_LINE_LOC")]
+    // MySQL 默认排序规则不区分大小写,小写值不挡住就会绕过校验入库。
+    [InlineData("material", "MATERIAL")]
+    [InlineData("Unknown", "UNKNOWN")]
+    [InlineData("MATERIAL1", null)]
+    [InlineData("WAREHOUSE", null)]
+    [InlineData("", null)]
+    [InlineData(null, null)]
+    public void NormalizeLocationRole_OnlyAcceptsAllLocationRoles(string? input, string? expected)
+    {
+        Assert.Equal(expected, NeutralTransTypeCodes.NormalizeLocationRole(input));
+    }
+
+    /// <summary>人工保存必须经过白名单,否则写错的角色会让 LocationRules 永远判不出阶段码。</summary>
+    [Fact]
+    public void SaveLocations_ValidatesAgainstWhitelist()
+    {
+        var service = ReadRepoFile("server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs");
+
+        Assert.Contains("NeutralTransTypeCodes.NormalizeLocationRole(row.LocationRole)", service);
+        Assert.Contains("NeutralTransTypeCodes.AllLocationRoles", service);
+        // 旧写法直接把前端传入值落库,不得复活。
+        Assert.DoesNotContain("role = row.LocationRole.Trim()", service);
+    }
+
+    /// <summary>
+    /// 前端下拉是 AllLocationRoles 的手写副本,而本类文档禁止在别处再写一份,故逐字钉住两边一致。
+    /// </summary>
+    [Fact]
+    public void FrontendLocationRoleOptions_MatchAllLocationRoles()
+    {
+        var vue = ReadRepoFile("Web/src/views/aidop/data-platform/sourceMappings.vue");
+
+        var expected = "const roles = ["
+            + string.Join(", ", NeutralTransTypeCodes.AllLocationRoles.Select(role => $"'{role}'"))
+            + "];";
+        Assert.Contains(expected, vue);
+    }
+
     private static string ReadScript(string name) =>
         ReadRepoFile($"server/Admin.NET.Web.Entry/UpdateScripts/{name}");
 

+ 4 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs

@@ -109,6 +109,9 @@ public class MdpSourceMappingService : IDynamicApiController, ITransient
         {
             if (string.IsNullOrWhiteSpace(row.Location) || string.IsNullOrWhiteSpace(row.LocationRole))
                 continue;
+            // 库位角色写错不会报错,只会让 LocationRules 的组合永远判不出阶段码,流水静默落进未映射隔离。
+            var role = NeutralTransTypeCodes.NormalizeLocationRole(row.LocationRole)
+                ?? throw Oops.Oh($"库位角色 {row.LocationRole} 不合法,只能是 {string.Join(" / ", NeutralTransTypeCodes.AllLocationRoles)}");
             n += await _db.Ado.ExecuteCommandAsync(
                 """
                 INSERT INTO mdp_location_role (tenant_id, domain, location, location_role, role_source, remark)
@@ -120,7 +123,7 @@ public class MdpSourceMappingService : IDynamicApiController, ITransient
                     tenant,
                     dom = row.Domain?.Trim() ?? "",
                     loc = row.Location.Trim(),
-                    role = row.LocationRole.Trim(),
+                    role,
                     remark = row.Remark
                 });
         }

+ 12 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NeutralTransTypeCodes.cs

@@ -28,6 +28,18 @@ public static class NeutralTransTypeCodes
 
     public const string UnknownRole = "UNKNOWN";
 
+    /// <summary>
+    /// 规范化人工传入的库位角色:命中返回标准大写值,非法返回 null。
+    /// MySQL 默认排序规则不区分大小写,小写值不挡住就会绕过校验入库,却与 LocationRules 的等值比对「看起来」仍匹配。
+    /// </summary>
+    public static string? NormalizeLocationRole(string? role)
+    {
+        if (string.IsNullOrWhiteSpace(role))
+            return null;
+        var text = role.Trim();
+        return AllLocationRoles.FirstOrDefault(allowed => string.Equals(allowed, text, StringComparison.OrdinalIgnoreCase));
+    }
+
     /// <summary>只看事务码即可判定的映射(与库位无关)。</summary>
     public static readonly (string Source, string Stage)[] SourceToStage =
     [