using System.Reflection; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Job; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-TENANT-ONLY-BATCH6:异常 / 工作流 / 主数据侧收敛为租户级作用域。 /// /// 本批要消灭的断点:Batch 5 之后规则运行策略的 factory_id 恒为 0, /// 于是自动建单产出的异常 factory_id 也是 0;而异常中心、任务流、卡死扫描 /// 当时仍按 (tenant_id, factory_id) 查。结果是一个**完整闭环的静默失败**: /// 规则真实命中、异常真实落库、页面一条也看不到,且没有任何报错。 /// /// 本批还修掉了一个真实越权(不是等价改写):主数据侧原来按 /// factory_ref_id 划边界,而真库 factory_ref_id = 1000 /// 同时属于租户 824585161322565 与 797403760988229——按它过滤会把另一租户的 /// 613 名员工 / 210 个部门列进本租户的下拉框,并可被绑定成处理人、写进 responsible_dept_id。 /// /// 因此本文件的多数断言是**反向的**("不得再出现 factory 判据"): /// 这两类回归的共同特征是加回一个谓词就静默改变可见集合,正向断言抓不到。 /// public class S8TenantOnlyExceptionScopeTests { private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); /// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。 private static string CodeOnly(string relativePath) { var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return string.Join('\n', File.ReadAllLines(full) .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); } // ───────────────── T1–T3:可信作用域本身 ───────────────── /// T1:S8TrustedScope 只承载租户;工厂不再是它的一个维度。 [Fact] public void T1_TrustedScope_CarriesTenantOnly() { Assert.Null(typeof(S8TrustedScope).GetProperty("FactoryId")); var ctors = typeof(S8TrustedScope).GetConstructors(); Assert.All(ctors, c => Assert.DoesNotContain( c.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase))); var scope = new S8TrustedScope(838257186181189L); Assert.Equal(838257186181189L, scope.TenantId); } /// /// T2:LegacyFactoryId 恒等于平台默认哨兵 0,且**不是**作用域。 /// 它只服务「还要往 factory_id 列写值」的旧代码;一旦有人把它当边界用, /// 得到的是「所有租户都相等」的常量,等于没有边界。 /// [Fact] public void T2_LegacyFactoryId_IsAlwaysPlatformSentinel() { Assert.Equal(S8ConfigScope.GlobalFactoryId, new S8TrustedScope(1L).LegacyFactoryId); Assert.Equal(S8ConfigScope.GlobalFactoryId, new S8TrustedScope(838257186181189L).LegacyFactoryId); Assert.Equal(0L, S8ConfigScope.GlobalFactoryId); } /// T3:解析器只解析租户;工厂解析链(含 OrgType 常量)必须整体消失。 [Fact] public void T3_Resolver_HasNoFactoryResolutionPath() { var src = CodeOnly("Infrastructure/S8TrustedScopeResolver.cs"); Assert.DoesNotContain("ResolveFactoryIdAsync", src); Assert.DoesNotContain("\"501\"", src); Assert.DoesNotContain("SysOrg", src); var resolve = typeof(S8TrustedScopeResolver).GetMethod(nameof(S8TrustedScopeResolver.ResolveAsync)); Assert.NotNull(resolve); Assert.Empty(resolve!.GetParameters()); } // ───────────────── T4–T8:异常可见性 ───────────────── /// T4:异常查询 / 详情 / 筛选项一律不再接受 factoryId。 [Theory] [InlineData(nameof(S8ExceptionService.GetFilterOptionsAsync))] [InlineData(nameof(S8ExceptionService.GetDetailAsync))] [InlineData(nameof(S8ExceptionService.SoftDeleteTestPrefixAsync))] public void T4_ExceptionReadEntryPoints_TakeNoFactory(string method) { var overloads = typeof(S8ExceptionService) .GetMethods(BindingFlags.Instance | BindingFlags.Public) .Where(m => m.Name == method) .ToList(); Assert.NotEmpty(overloads); Assert.All(overloads, m => Assert.DoesNotContain( m.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase))); } /// /// T5:异常表的查询谓词里不得再出现工厂等值。 /// 这是本批的核心断点——留一条就等于「规则命中了但异常中心查不到」。 /// [Theory] [InlineData("Service/S8/S8ExceptionService.cs")] [InlineData("Service/S8/S8TaskFlowService.cs")] [InlineData("Service/S8/S8DetectionLogQueryService.cs")] [InlineData("Service/S8/S8NotificationLogQueryService.cs")] [InlineData("Service/S8/S8IssueLedgerService.cs")] [InlineData("Service/S8/S8AlertAggregationService.cs")] [InlineData("Service/S8/S8DecisionService.cs")] public void T5_ExceptionQueries_HaveNoFactoryPredicate(string src) { var code = CodeOnly(src); Assert.DoesNotContain("FactoryId == factoryId", code); Assert.DoesNotContain("FactoryId == q.FactoryId", code); Assert.DoesNotContain("FactoryId == scope.FactoryId", code); } /// /// T6:Controller 不得再把客户端 / 作用域的 factory 塞进查询 DTO。 /// 留着 q.FactoryId = ... 就是给「以后有人在 Service 端把它读回去」留门。 /// [Theory] [InlineData("Controllers/S8/AdoS8ExceptionsController.cs")] [InlineData("Controllers/S8/AdoS8ConfigDetectionLogsController.cs")] [InlineData("Controllers/S8/AdoS8ConfigNotificationLogsController.cs")] [InlineData("Controllers/S8/AdoS8IssueLedgerController.cs")] [InlineData("Controllers/S8/AdoS8AlertAggregationController.cs")] public void T6_Controllers_DoNotStampFactoryOntoQuery(string src) { var code = CodeOnly(src); Assert.DoesNotContain("q.FactoryId = ", code); Assert.DoesNotContain("scope.FactoryId", code); } /// /// T7:查询 DTO 的 FactoryId 保留(老前端兼容),但必须是**死字段**。 /// 保留字段本身没问题,被重新读起来才是问题。 /// [Fact] public void T7_QueryDtoFactoryField_SurvivesButIsInert() { Assert.NotNull(typeof(AdoS8ExceptionQueryDto).GetProperty("FactoryId")); Assert.NotNull(typeof(AdoS8ExceptionQueryDto).GetProperty("TenantId")); // Service 侧不得读回来。 Assert.DoesNotContain("q.FactoryId", CodeOnly("Service/S8/S8ExceptionService.cs")); } /// T8:异常子资源的作用域校验入口收敛为 (exceptionId, tenantId)。 [Fact] public void T8_ExceptionScopeGuard_IsTenantOnly() { Assert.NotNull(typeof(S8DecisionService).GetMethod( nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long)])); Assert.Null(typeof(S8DecisionService).GetMethod( nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long), typeof(long)])); } // ───────────────── T9–T12:任务流 ───────────────── /// T9:全部按 Id 的流转动作都必须是 (id, tenantId, …),不得再带 factoryId。 [Theory] [InlineData(nameof(S8TaskFlowService.ClaimAsync))] [InlineData(nameof(S8TaskFlowService.TransferAsync))] [InlineData(nameof(S8TaskFlowService.StartProgressAsync))] [InlineData(nameof(S8TaskFlowService.UpgradeAsync))] [InlineData(nameof(S8TaskFlowService.RejectAsync))] [InlineData(nameof(S8TaskFlowService.SubmitVerificationAsync))] [InlineData(nameof(S8TaskFlowService.ApproveVerificationAsync))] [InlineData(nameof(S8TaskFlowService.RejectVerificationAsync))] [InlineData(nameof(S8TaskFlowService.CommentAsync))] public void T9_TaskFlowActions_TakeIdAndTenantOnly(string method) { var overloads = typeof(S8TaskFlowService) .GetMethods(BindingFlags.Instance | BindingFlags.Public) .Where(m => m.Name == method) .ToList(); Assert.NotEmpty(overloads); foreach (var m in overloads) { var ps = m.GetParameters(); Assert.Equal(typeof(long), ps[0].ParameterType); // id Assert.Equal(typeof(long), ps[1].ParameterType); // tenantId Assert.Equal("tenantId", ps[1].Name); Assert.DoesNotContain(ps, p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)); } } /// /// T10:认领 / 转派不再自动改写责任部门。 /// /// 原实现是 Employee.Department(字符串) → DepartmentMaster, /// 本批(S8-SYSUSER-ONLY-1)把处理人改成系统账号后这条链的第一跳没有了 —— /// SysUser 侧不存在指向 DepartmentMaster 的关系 /// (SysUser.OrgId 指的是 SysOrg 公司/工厂树,是另一棵树)。 /// /// 原断言守的是「这条解析别按工厂做」。解析本身没了,守卫对象改为 /// 别用 SysOrg 硬凑一个近似值把它复活 —— 责任部门由建单时确定, /// 之后只能由人显式修改,不再随处理人静默漂移。 /// [Fact] public void T10_ResponsibleDeptIsNotSilentlyRewrittenOnAssignment() { Assert.Null(typeof(S8TaskFlowService).GetMethod("ResolveEmployeeResponsibleDeptIdAsync", Instance)); var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); Assert.DoesNotContain("ResponsibleDeptId =", code); Assert.DoesNotContain("SysOrg", code); } /// T11:绑行查询只按 (Id, TenantId, !IsDeleted)。 [Fact] public void T11_TaskFlowLoad_BindsByTenantOnly() { var load = typeof(S8TaskFlowService).GetMethod("LoadAsync", Instance); Assert.NotNull(load); Assert.Equal(new[] { "id", "tenantId" }, load!.GetParameters().Select(p => p.Name).ToArray()); Assert.Contains("x.Id == id && x.TenantId == tenantId && !x.IsDeleted", CodeOnly("Service/S8/S8TaskFlowService.cs")); } /// T12:SLA 自动升级按异常自身租户调用,不再传工厂。 [Fact] public void T12_AutoEscalation_CallsUpgradeWithTenantOnly() { var code = CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs"); Assert.Contains("_taskFlow.UpgradeAsync(e.Id, e.TenantId, remark)", code); Assert.DoesNotContain("e.FactoryId", code); } // ───────────────── T13–T15:卡死扫描(本批最易静默失效处) ───────────────── /// /// T13:卡死扫描的枚举维度是租户;factory_id > 0 谓词必须消失。 /// 留着它,本批之后(factory_id 恒 0)该扫描的候选集恒为空, /// 而 job 照常运行、日志照常打印——最难发现的那种失效。 /// [Fact] public void T13_ActiveFlowScan_EnumeratesTenants_AndDropsFactoryFilter() { var code = CodeOnly("Service/S8/S8ActiveFlowWatchService.cs"); Assert.Contains("SELECT DISTINCT e.tenant_id", code); Assert.DoesNotContain("e.factory_id > 0", code); Assert.DoesNotContain("e.factory_id AS FactoryId", code); var list = typeof(S8ActiveFlowWatchService).GetMethod( nameof(S8ActiveFlowWatchService.ListActiveTenantsAsync)); Assert.NotNull(list); Assert.Equal(typeof(Task>), list!.ReturnType); } /// T14:ScanAsync 只按租户分片。 [Fact] public void T14_ActiveFlowScan_ScansByTenant() { var scan = typeof(S8ActiveFlowWatchService).GetMethod(nameof(S8ActiveFlowWatchService.ScanAsync)); Assert.NotNull(scan); Assert.Equal("tenantId", scan!.GetParameters()[0].Name); Assert.DoesNotContain(scan.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)); } /// T15:(租户, 工厂) 复合作用域类型必须彻底消失,避免有人再拿它当分片键。 [Fact] public void T15_TenantFactoryScopeType_IsRemoved() { Assert.Null(typeof(S8ActiveFlowWatchService).Assembly .GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8TenantFactoryScope")); Assert.DoesNotContain("S8TenantFactoryScope", CodeOnly("Job/S8ActiveFlowStuckScanJob.cs")); } // ───────────────── T16–T19:主数据(真实越权修复) ───────────────── /// /// T16:主数据三个下拉的边界必须是租户,工厂判据不得残留。 /// /// S8-SYSUSER-ONLY-1 之后人员下拉改列 SysUser(谓词写作 /// u.TenantId == scope.TenantId),因此计数由 3 个 x. 变为 /// 2 个 x. + 1 个 u.。断言总数仍是 3 —— 三个下拉,一个都不能缺边界。 /// [Fact] public void T16_MasterDataDropdowns_AreTenantBound() { var code = CodeOnly("Service/S8/S8MasterDataAdapter.cs"); Assert.DoesNotContain("FactoryRefId", code); Assert.Equal(3, CountOccurrences(code, "x.TenantId == scope.TenantId") + CountOccurrences(code, "u.TenantId == scope.TenantId")); } /// /// T17:操作员绑定已在 S8-SYSUSER-ONLY-1 整体退役,断言随之改为 /// 该服务不得复活。 /// /// 原断言检查的是「员工硬边界从工厂收敛为租户」。那道边界现在没有对象可保护了 —— /// S8 不再把员工绑定成处理人。真正需要守的变成:这个能写 EmployeeMaster 的 /// 写路径不要被重新引回来。删掉断言等于放弃这道守卫,因此改判而不是删除。 /// [Fact] public void T17_OperatorBinding_IsRetiredNotResurrected() { Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs"))); var code = CodeOnly("Controllers/S8/AdoS8ConfigBindingsController.cs"); Assert.Contains("Status410Gone", code); Assert.DoesNotContain("EmployeeMaster", code); } /// T18:异常列表 / 详情的部门名水合按租户查,不按工厂。 [Fact] public void T18_DepartmentHydration_IsTenantBound() { var code = CodeOnly("Service/S8/S8ExceptionService.cs"); Assert.Contains("deptIds.Contains(x.Id) && x.TenantId == tenantId", code); Assert.DoesNotContain("x.FactoryRefId == factoryId", code); } /// /// T19:自动建单的部门校验按租户;「主数据在哪个工厂」这个三级解析必须整体消失。 /// 它存在的唯一前提是「S8 运营工厂 ≠ 部门主数据工厂」,而工厂不再是作用域后该前提不成立。 /// [Fact] public void T19_AutoWatchDeptResolution_DropsMasterDataFactoryIndirection() { var code = CodeOnly("Service/S8/S8ManualReportService.cs"); Assert.DoesNotContain("ResolveDepartmentFactoryRefIdAsync", code); Assert.DoesNotContain("ValidateDeptForMasterDataFactoryAsync", code); Assert.DoesNotContain("_masterDataOptions.DepartmentFactoryRefId", code); Assert.Contains("ValidateDeptInTenantAsync", code); Assert.Contains("x.Department == UnassignedDepartmentCode && x.TenantId == tenantId", code); } // ───────────────── T20–T22:主动提报 / 自动建单写入 ───────────────── /// /// T20:主动提报的可信作用域只解析租户;原来「租户未配机构就拒绝建单」的门禁一并去掉—— /// 它保护不了任何东西(工厂不是隔离维度),却能让一个租户完全无法提报。 /// [Fact] public void T20_ManualReport_ResolvesTenantWithoutOrgPrecondition() { var code = CodeOnly("Service/S8/S8ManualReportService.cs"); Assert.Contains("private long ResolveTrustedTenantId()", code); Assert.DoesNotContain("SysTenant>()", code); Assert.DoesNotContain("当前租户尚未配置所属机构", code); } /// T21:新建异常的 factory_id 一律盖章平台默认哨兵 0(既有行不重写)。 [Fact] public void T21_NewExceptions_StampCompatibilityFactoryZero() { var code = CodeOnly("Service/S8/S8ManualReportService.cs"); Assert.DoesNotContain("FactoryId = factoryId", code); Assert.DoesNotContain("FactoryId = trustedScope.FactoryId", code); Assert.Equal(3, CountOccurrences(code, "FactoryId = S8ConfigScope.GlobalFactoryId")); } /// T22:自动建单入口不再要求工厂上下文,且不接受 factoryId 形参。 [Fact] public void T22_AutoCreationEntryPoints_RequireTenantOnly() { foreach (var name in new[] { nameof(S8ManualReportService.CreateFromWatchAsync), nameof(S8ManualReportService.CreateFromHitAsync), }) { var m = typeof(S8ManualReportService).GetMethods(BindingFlags.Instance | BindingFlags.Public) .Single(x => x.Name == name); Assert.Equal("tenantId", m.GetParameters()[0].Name); Assert.DoesNotContain(m.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)); } Assert.DoesNotContain("自动建单缺失有效租户或工厂上下文", CodeOnly("Service/S8/S8ManualReportService.cs")); } // ───────────────── T23–T25:配置读取(平台默认哨兵必须存活) ───────────────── /// /// T23:配置读取判据收敛为「本租户 ∪ 平台默认」,工厂等值必须消失, /// 但 (0,0) 平台默认哨兵**不得被误删**——它不是工厂隔离,是平台基线。 /// /// S8SceneConfigService 不在列:ado_s8_scene_config 本就没有平台默认行 /// (实测 29 行全部是租户行,tenant_id = 0 的 0 行),它的读取一直是纯租户精确匹配。 /// 把它一起断言"必须有哨兵"会逼出一个库里根本不存在的概念。 /// [Theory] [InlineData("Service/S8/S8ExceptionTypeService.cs")] [InlineData("Service/S8/S8NotificationLayerService.cs")] [InlineData("Service/S8/S8RoleConfigService.cs")] [InlineData("Service/S8/S8DashboardCellConfigService.cs")] [InlineData("Service/S8/S8KpiTargetConfigService.cs")] public void T23_ConfigReads_AreTenantScoped_ButKeepPlatformSentinel(string src) { var code = CodeOnly(src); Assert.DoesNotContain("x.FactoryId == factoryId", code); Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code); Assert.DoesNotContain("x.FactoryId == body.FactoryId", code); Assert.DoesNotContain("x.FactoryId == e.FactoryId", code); // 哨兵语义还在(IsGlobal / GlobalTenantId / GlobalFactoryId 至少出现其一)。 Assert.True( code.Contains("S8ConfigScope.GlobalTenantId", StringComparison.Ordinal) || code.Contains("S8ConfigScope.IsGlobal", StringComparison.Ordinal) || code.Contains("S8ConfigScope.GlobalFactoryId", StringComparison.Ordinal), $"{src} 丢失了平台默认哨兵语义"); } /// /// T24:覆盖优先级不得再按 FactoryId 降序判定。 /// /// 这是本批最容易被写对一半的地方:谓词改成了租户,precedence 却留着 /// OrderByDescending(x => x.FactoryId)。本批之后租户覆盖行的 factory_id 恒为 0, /// 与平台默认相等,排序退化 → 平台默认可能反超租户覆盖, /// 表现是「页面显示的生效配置与运行时真正用的不是同一条」。 /// [Theory] [InlineData("Service/S8/S8ExceptionTypeService.cs")] [InlineData("Service/S8/S8RoleConfigService.cs")] [InlineData("Service/S8/S8NotificationLayerService.cs")] [InlineData("Service/S8/S8DashboardCellConfigService.cs")] [InlineData("Service/S8/S8MonitorDictionaryService.cs")] [InlineData("Service/S8/S8MonitoringService.cs")] [InlineData("Service/S8/S8ManualReportService.cs")] public void T24_ConfigPrecedence_NoLongerRanksByFactoryId(string src) { var code = CodeOnly(src); Assert.DoesNotContain("OrderByDescending(x => x.FactoryId)", code); Assert.DoesNotContain("OrderByDescending(t => t.FactoryId)", code); } /// T25:配置写入把 factory_id 盖章为兼容值 0,绝不写回一个真实工厂号。 [Theory] [InlineData("Service/S8/S8ExceptionTypeService.cs")] [InlineData("Service/S8/S8SceneConfigService.cs")] [InlineData("Service/S8/S8NotificationLayerService.cs")] [InlineData("Service/S8/S8RoleConfigService.cs")] [InlineData("Service/S8/S8DashboardCellConfigService.cs")] public void T25_ConfigWrites_StampCompatibilityFactory(string src) { var code = CodeOnly(src); Assert.DoesNotContain("body.FactoryId = scope.FactoryId", code); Assert.Contains("scope.LegacyFactoryId", code); } // ───────────────── T26–T28:迁移脚本 ───────────────── /// /// T26:唯一键在实体与迁移脚本两侧必须同步收敛,且**只**收敛唯一键。 /// 只改一侧的后果不对称:只改实体 → CodeFirst 环境与迁移环境的 schema 分叉; /// 只改脚本 → 下次 EnableInitTable 启动会把旧唯一键加回来。 /// [Fact] public void T26_ExceptionUniqueKey_ConvergesInEntityAndMigration() { var entity = CodeOnly("Entity/S8/AdoS8Exception.cs"); Assert.Contains("\"uk_ado_s8_exception_tenant_code\", nameof(TenantId), OrderByType.Asc, nameof(ExceptionCode)", entity); Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", entity); var sql = ExecutableSql(MigrationSql()); Assert.Contains("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code (tenant_id, exception_code)", sql); Assert.Contains("DROP INDEX uk_ado_s8_exception_tenant_factory_code", sql); } /// /// T27:迁移必须先门禁后 DDL,且门禁失败要能在 sys_db_migration_log.error_message 里读出原因。 /// 没有 preflight 的收紧唯一键 = 撞上历史重号时报一句索引错误,运维无从下手。 /// [Fact] public void T27_Migration_GatesBeforeDdl() { var sql = ExecutableSql(MigrationSql()); var gate = sql.IndexOf("s8_tenant_only_BLOCKED_see_dupcode_table_1_0_494", StringComparison.Ordinal); var ddl = sql.IndexOf("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code", StringComparison.Ordinal); Assert.True(gate >= 0, "缺少 preflight 门禁"); Assert.True(ddl > gate, "门禁必须在任何 DDL 之前"); Assert.Contains("s8_tenant_only_POSTCHECK_FAILED_1_0_494", sql); // AutoVersionUpdate 对全文做子串匹配拒绝自定义语句分隔符指令——连注释里出现都会被拦。 Assert.DoesNotContain("DELIMITER", MigrationSql(), StringComparison.OrdinalIgnoreCase); } /// /// T28:迁移刻意不碰的对象。 /// factory_id 列、历史行的 factory_id 值、以及前三个已冻结脚本的归档表都必须原样保留—— /// 本批移除的是隔离语义,不是 schema 美化,更不是把历史血缘抹平。 /// [Fact] public void T28_Migration_DoesNotTouchOutOfScopeObjects() { // 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**,那正是我们想要的留档。 var sql = ExecutableSql(MigrationSql()); Assert.DoesNotContain("DROP COLUMN", sql); Assert.DoesNotContain("UPDATE ado_s8_exception", sql); Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql); Assert.DoesNotContain("ado_s8_exception_dedup_bak_1_0_493", sql); Assert.DoesNotContain("ado_s8_scene_config", sql); Assert.DoesNotContain("ado_s8_notification_layer", sql); Assert.DoesNotContain("ado_s8_watch_rule", sql); } private static string ExecutableSql(string sql) => string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal))); private static string MigrationSql() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry"))) dir = dir.Parent; Assert.NotNull(dir); var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.494.sql"); Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}"); return File.ReadAllText(path); } private static int CountOccurrences(string haystack, string needle) { var n = 0; for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) n++; return n; } }