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

fix(s8): enforce runtime authorization for s8 actions

YY968XX 3 дней назад
Родитель
Сommit
6b76a8217b
28 измененных файлов с 607 добавлено и 1 удалено
  1. 279 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AuthorizationGuardTests.cs
  2. 52 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8PermissionCatalog.cs
  3. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8AlertAggregationController.cs
  4. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigAlertRulesController.cs
  5. 8 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDashboardCellsController.cs
  6. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDataSourcesController.cs
  7. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDetectionLogsController.cs
  8. 8 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDraftsController.cs
  9. 11 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigExceptionTypesController.cs
  10. 5 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigKpiTargetsController.cs
  11. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigMonitorDictionaryController.cs
  12. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLayersController.cs
  13. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLogsController.cs
  14. 2 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigPermissionCatalogController.cs
  15. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigRolesController.cs
  16. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigScenesController.cs
  17. 12 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs
  18. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DashboardController.cs
  19. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DictionariesController.cs
  20. 4 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DimensionController.cs
  21. 17 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs
  22. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8IssueLedgerController.cs
  23. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MonitoringController.cs
  24. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8OrderFlowController.cs
  25. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ReportsController.cs
  26. 4 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs
  27. 104 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8PermissionAttribute.cs
  28. 14 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

+ 279 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AuthorizationGuardTests.cs

@@ -0,0 +1,279 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.Routing;
+using System.Reflection;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-P0-4-API-AUTHORIZATION-1:S8 接口鉴权守卫。
+///
+/// <para>修复前实测(业务账号 <c>UATExceptionA</c>,2026-09-02):
+/// <c>PUT /config/watch-rules/{id}/params</c> 与 <c>POST /config/operator-bindings</c>
+/// 都直达业务校验返回 <b>400</b> 而非 403 —— 鉴权层根本没拦。
+/// 根因是平台 <c>JwtHandler</c> 用「路由路径逐字转权限名」匹配,
+/// 而 S8 是带路径参数的 RESTful 路由(<c>…/exceptions/{id}/claim</c> → <c>…:1329909430019:claim</c>),
+/// 静态权限码永远不可能相等;又因末行「未登记路由默认放行」,全部 S8 接口变成登录即可调用。</para>
+///
+/// <para>本测试全部走**反射元数据**(不是字符串 grep),逐个枚举 S8 Controller 的 Action,
+/// 保证:① 每个 Action 都声明了能力码;② 能力码在正式目录内;
+/// ③ 写动作不得落到只读能力上;④ 各类动作落到正确的能力分组;
+/// ⑤ 不回退到已死的 <c>ado_s8_role_permission_config.permission_codes</c>。</para>
+/// </summary>
+public class S8AuthorizationGuardTests
+{
+    private static readonly Assembly PluginAssembly = typeof(AdoS8ExceptionsController).Assembly;
+
+    /// <summary>S8 命名空间下的全部 Controller。</summary>
+    public static IEnumerable<Type> S8Controllers => PluginAssembly
+        .GetTypes()
+        .Where(t => t.Namespace == typeof(AdoS8ExceptionsController).Namespace
+                 && typeof(ControllerBase).IsAssignableFrom(t)
+                 && !t.IsAbstract)
+        .OrderBy(t => t.Name);
+
+    public static TheoryData<Type> ControllerData()
+    {
+        var data = new TheoryData<Type>();
+        foreach (var t in S8Controllers) data.Add(t);
+        return data;
+    }
+
+    private sealed record ActionInfo(Type Controller, MethodInfo Method, string Verb, string Template, string? Permission);
+
+    private static IEnumerable<ActionInfo> AllActions()
+    {
+        foreach (var c in S8Controllers)
+        {
+            foreach (var m in c.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
+            {
+                var http = m.GetCustomAttributes().OfType<IActionHttpMethodProvider>().FirstOrDefault();
+                if (http == null) continue;
+
+                var verb = http.HttpMethods.FirstOrDefault() ?? "GET";
+                var template = (http as IRouteTemplateProvider)?.Template ?? string.Empty;
+                var perm = m.GetCustomAttribute<S8PermissionAttribute>()?.Code;
+                yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm);
+            }
+        }
+    }
+
+    private static bool IsReadCapability(string code) =>
+        code is S8PermissionCatalog.ExceptionRead
+             or S8PermissionCatalog.ConfigRead
+             or S8PermissionCatalog.DashboardRead;
+
+    // ───────────────────────── ① 全量覆盖 ─────────────────────────
+
+    /// <summary>S8 Controller 必须被枚举到(防止命名空间搬家后本测试静默空跑)。</summary>
+    [Fact]
+    public void AllS8ControllersAreDiscovered()
+    {
+        var controllers = S8Controllers.ToList();
+        Assert.True(controllers.Count >= 25, $"仅发现 {controllers.Count} 个 S8 Controller,疑似枚举失效");
+        Assert.Contains(controllers, t => t == typeof(AdoS8ExceptionsController));
+        Assert.Contains(controllers, t => t == typeof(AdoS8WatchDebugController));
+    }
+
+    /// <summary>每一个 Action 都必须有确定的授权要求,一个都不许漏。</summary>
+    [Fact]
+    public void EveryS8Action_DeclaresAPermission()
+    {
+        var missing = AllActions()
+            .Where(a => string.IsNullOrWhiteSpace(a.Permission))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb} {a.Template}]")
+            .ToList();
+
+        Assert.True(missing.Count == 0,
+            "以下 S8 Action 未声明 [S8Permission],将退回平台『未登记路由默认放行』:\n" + string.Join('\n', missing));
+    }
+
+    /// <summary>声明的权限码必须在正式目录内,杜绝写错字符串导致永远匹配不到。</summary>
+    [Fact]
+    public void EveryDeclaredPermission_ExistsInCatalog()
+    {
+        var known = S8PermissionCatalog.All.Select(x => x.Code).ToHashSet(StringComparer.Ordinal);
+        var unknown = AllActions()
+            .Where(a => a.Permission != null && !known.Contains(a.Permission))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
+            .Distinct().ToList();
+
+        Assert.True(unknown.Count == 0, "以下权限码不在 S8PermissionCatalog 内:\n" + string.Join('\n', unknown));
+    }
+
+    // ───────────────────────── ② 写动作不得落到只读能力 ─────────────────────────
+
+    /// <summary>
+    /// 所有 mutation(非 GET)Action 都不得使用只读能力码,
+    /// 否则「有查看权限的人」就能改配置 / 改业务状态。
+    /// </summary>
+    [Fact]
+    public void MutationActions_DoNotUseReadOnlyCapabilities()
+    {
+        var offenders = AllActions()
+            .Where(a => a.Verb != "GET" && a.Permission != null && IsReadCapability(a.Permission))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission}")
+            .ToList();
+
+        Assert.True(offenders.Count == 0, "以下写动作落在只读能力上:\n" + string.Join('\n', offenders));
+    }
+
+    // ───────────────────────── ③ 分组正确性 ─────────────────────────
+
+    /// <summary>配置类 Controller 的写动作必须落在 config 组,不得使用 operator / verification 能力。</summary>
+    [Theory]
+    [InlineData(typeof(AdoS8ConfigWatchRulesController), S8PermissionCatalog.ConfigWatchRule)]
+    [InlineData(typeof(AdoS8ConfigDataSourcesController), S8PermissionCatalog.ConfigDataSource)]
+    [InlineData(typeof(AdoS8ConfigExceptionTypesController), S8PermissionCatalog.ConfigExceptionType)]
+    [InlineData(typeof(AdoS8ConfigNotificationLayersController), S8PermissionCatalog.ConfigNotification)]
+    [InlineData(typeof(AdoS8ConfigScenesController), S8PermissionCatalog.ConfigScene)]
+    [InlineData(typeof(AdoS8ConfigKpiTargetsController), S8PermissionCatalog.ConfigKpi)]
+    [InlineData(typeof(AdoS8ConfigDashboardCellsController), S8PermissionCatalog.ConfigDashboard)]
+    [InlineData(typeof(AdoS8ConfigBindingsController), S8PermissionCatalog.ConfigOperatorBind)]
+    [InlineData(typeof(AdoS8ConfigRolesController), S8PermissionCatalog.ConfigRoleWrite)]
+    public void ConfigController_MutationsUseItsOwnConfigCapability(Type controller, string expected)
+    {
+        var mutations = AllActions().Where(a => a.Controller == controller && a.Verb != "GET").ToList();
+        Assert.NotEmpty(mutations);
+        Assert.All(mutations, a => Assert.Equal(expected, a.Permission));
+    }
+
+    /// <summary>配置类 Controller 一律不得出现 operator / verification 能力码。</summary>
+    [Fact]
+    public void ConfigControllers_NeverUseOperatorOrQualityCapabilities()
+    {
+        var operatorOrQuality = new[]
+        {
+            S8PermissionCatalog.ExceptionClaim, S8PermissionCatalog.ExceptionStart,
+            S8PermissionCatalog.ExceptionAssign, S8PermissionCatalog.ExceptionUpgrade,
+            S8PermissionCatalog.ExceptionReject, S8PermissionCatalog.ExceptionClose,
+            S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove,
+            S8PermissionCatalog.VerificationReject,
+        }.ToHashSet(StringComparer.Ordinal);
+
+        var offenders = AllActions()
+            .Where(a => a.Controller.Name.Contains("Config", StringComparison.Ordinal)
+                     && a.Permission != null && operatorOrQuality.Contains(a.Permission))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
+            .ToList();
+
+        Assert.True(offenders.Count == 0, string.Join('\n', offenders));
+    }
+
+    /// <summary>复检通过 / 退回必须是 quality 能力;提交复检、认领、开始处理必须是 operator 能力。</summary>
+    [Theory]
+    [InlineData("approve-verification", S8PermissionCatalog.VerificationApprove)]
+    [InlineData("reject-verification", S8PermissionCatalog.VerificationReject)]
+    [InlineData("submit-verification", S8PermissionCatalog.VerificationSubmit)]
+    [InlineData("claim", S8PermissionCatalog.ExceptionClaim)]
+    [InlineData("start-progress", S8PermissionCatalog.ExceptionStart)]
+    [InlineData("transfer", S8PermissionCatalog.ExceptionAssign)]
+    [InlineData("upgrade", S8PermissionCatalog.ExceptionUpgrade)]
+    [InlineData("comment", S8PermissionCatalog.ExceptionComment)]
+    public void ExceptionAction_MapsToExpectedCapability(string routeFragment, string expected)
+    {
+        var action = AllActions().SingleOrDefault(a =>
+            a.Controller == typeof(AdoS8ExceptionsController)
+            && a.Verb != "GET"
+            && a.Template.EndsWith(routeFragment, StringComparison.Ordinal));
+
+        Assert.NotNull(action);
+        Assert.Equal(expected, action!.Permission);
+    }
+
+    /// <summary>「检验通过 / 退回」绝不能与「提交复检」共用能力码,否则处理人可自检自过。</summary>
+    [Fact]
+    public void QualityApproval_IsSeparatedFromOperatorSubmission()
+    {
+        Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove);
+        Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationReject);
+    }
+
+    // ───────────────────────── ④ 调试接口 ─────────────────────────
+
+    /// <summary>
+    /// 调试 / 运维接口必须要求独立的 debug 能力,且不得复用任何业务能力码。
+    /// 该接口历史上是「任意登录用户可调用」,且能对**任意 tenantId/factoryId** 触发自动建单主链。
+    /// </summary>
+    [Fact]
+    public void DebugEndpoints_RequireDedicatedDebugCapability()
+    {
+        var actions = AllActions().Where(a => a.Controller == typeof(AdoS8WatchDebugController)).ToList();
+        Assert.NotEmpty(actions);
+        Assert.All(actions, a => Assert.Equal(S8PermissionCatalog.DebugRun, a.Permission));
+
+        // debug 能力码不得被任何业务接口复用。
+        var leaked = AllActions()
+            .Where(a => a.Permission == S8PermissionCatalog.DebugRun
+                     && a.Controller != typeof(AdoS8WatchDebugController))
+            .Select(a => a.Controller.Name + "." + a.Method.Name)
+            .ToList();
+        Assert.True(leaked.Count == 0, string.Join('\n', leaked));
+    }
+
+    // ───────────────────────── ⑤ 不回退到死表 ─────────────────────────
+
+    /// <summary>
+    /// 授权判定必须走平台 SysMenu 按钮权限(<c>SysMenuService.GetOwnBtnPermList</c>),
+    /// 不得把已确认 0 消费方的 <c>ado_s8_role_permission_config.permission_codes</c> 接回运行链。
+    /// </summary>
+    [Fact]
+    public void AuthorizationDoesNotRevivePermissionCodesTable()
+    {
+        var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+        var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
+
+        Assert.Contains("GetOwnBtnPermList", src);
+        Assert.DoesNotContain("AdoS8RolePermissionConfig", src);
+        Assert.DoesNotContain("PermissionCodes", src);
+    }
+
+    /// <summary>权限门必须 fail-closed:拿不到权限服务 / 抛异常时一律 403,绝不放行。</summary>
+    [Fact]
+    public void PermissionGate_IsFailClosed()
+    {
+        var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+        var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
+
+        Assert.Contains("Status403Forbidden", src);
+        Assert.Contains("catch", src);
+        // 空码构造必须抛,防止 [S8Permission("")] 变成静默放行。
+        Assert.Throws<ArgumentException>(() => new S8PermissionAttribute(""));
+    }
+
+    /// <summary>
+    /// 目录内每个**未废弃**能力码都应至少被一个 Action 使用,避免目录与实现漂移出孤儿码。
+    /// (<c>s8:exception:close</c> 已标 deprecated:S8 无独立关闭接口,关闭由 approve-verification
+    /// 经状态机达成;既有 SysMenu 行与授权保持不动,故不从目录删除。)
+    /// </summary>
+    [Fact]
+    public void CatalogHasNoOrphanCapability()
+    {
+        var used = AllActions().Where(a => a.Permission != null).Select(a => a.Permission!).ToHashSet(StringComparer.Ordinal);
+        var orphans = S8PermissionCatalog.All
+            .Where(x => !x.Deprecated)
+            .Select(x => x.Code)
+            .Where(c => !used.Contains(c))
+            .ToList();
+        Assert.True(orphans.Count == 0, "以下能力码在目录内但无任何 Action 使用:\n" + string.Join('\n', orphans));
+    }
+
+    /// <summary>已废弃能力码不得被任何 Action 引用(否则等于用一个没人维护的码当门禁)。</summary>
+    [Fact]
+    public void DeprecatedCapabilitiesAreNotReferencedByAnyAction()
+    {
+        var deprecated = S8PermissionCatalog.All.Where(x => x.Deprecated).Select(x => x.Code)
+            .ToHashSet(StringComparer.Ordinal);
+        Assert.NotEmpty(deprecated); // 防止标记体系被误删后本测试空跑
+
+        var offenders = AllActions()
+            .Where(a => a.Permission != null && deprecated.Contains(a.Permission))
+            .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
+            .ToList();
+        Assert.True(offenders.Count == 0, string.Join('\n', offenders));
+    }
+}

+ 52 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Const/S8/S8PermissionCatalog.cs

@@ -9,6 +9,14 @@ public sealed class S8PermissionItem
     public string Label { get; init; } = string.Empty;
     public string Group { get; init; } = string.Empty;
     public int SortNo { get; init; }
+
+    /// <summary>
+    /// S8-P0-4-API-AUTHORIZATION-1:已废弃的历史能力码。
+    /// <para>不删除、不从种子移除 —— 对应 <c>SysMenu</c> 行与 <c>SysRoleMenu</c> 授权已存在于各环境,
+    /// 删除属破坏性数据变更。标记后:目录仍列出(便于运维识别),但**任何 Action 都不得引用**,
+    /// 由 <c>S8AuthorizationGuardTests</c> 强制。</para>
+    /// </summary>
+    public bool Deprecated { get; init; }
 }
 
 /// <summary>
@@ -21,6 +29,37 @@ public static class S8PermissionCatalog
     public const string GroupVerification = "verification";
     public const string GroupConfig = "config";
     public const string GroupDashboard = "dashboard";
+    public const string GroupDebug = "debug";
+
+    // ───────── S8-P0-4-API-AUTHORIZATION-1:capability 级权限码常量 ─────────
+    // 只在此处声明字面量;Controller 一律引用常量,杜绝手写字符串拼错导致「声明了但永远匹配不到」。
+    public const string ExceptionRead = "s8:exception:read";
+    public const string ExceptionCreate = "s8:exception:create";
+    public const string ExceptionAssign = "s8:exception:assign";
+    public const string ExceptionClaim = "s8:exception:claim";
+    public const string ExceptionStart = "s8:exception:start";
+    public const string ExceptionReject = "s8:exception:reject";
+    public const string ExceptionUpgrade = "s8:exception:upgrade";
+    public const string ExceptionClose = "s8:exception:close";
+    public const string ExceptionComment = "s8:exception:comment";
+
+    public const string VerificationSubmit = "s8:verification:submit";
+    public const string VerificationApprove = "s8:verification:approve";
+    public const string VerificationReject = "s8:verification:reject";
+
+    public const string ConfigRead = "s8:config:read";
+    public const string ConfigOperatorBind = "s8:config:operator:bind";
+    public const string ConfigRoleWrite = "s8:config:role:write";
+    public const string ConfigWatchRule = "s8:config:watch-rule";
+    public const string ConfigDataSource = "s8:config:data-source";
+    public const string ConfigExceptionType = "s8:config:exception-type";
+    public const string ConfigNotification = "s8:config:notification";
+    public const string ConfigScene = "s8:config:scene";
+    public const string ConfigKpi = "s8:config:kpi";
+    public const string ConfigDashboard = "s8:config:dashboard";
+
+    public const string DashboardRead = "s8:dashboard:read";
+    public const string DebugRun = "s8:debug:run";
 
     private static readonly IReadOnlyList<S8PermissionItem> _all = new List<S8PermissionItem>
     {
@@ -31,14 +70,26 @@ public static class S8PermissionCatalog
         new() { Code = "s8:exception:start",        Label = "开始处理",     Group = GroupException,    SortNo = 50 },
         new() { Code = "s8:exception:reject",       Label = "驳回异常",     Group = GroupException,    SortNo = 60 },
         new() { Code = "s8:exception:upgrade",      Label = "升级异常",     Group = GroupException,    SortNo = 70 },
-        new() { Code = "s8:exception:close",        Label = "关闭异常",     Group = GroupException,    SortNo = 80 },
+        // S8-P0-4:S8 没有独立「关闭异常」接口 —— 关闭是 approve-verification 走状态机
+        // PENDING_VERIFICATION → CLOSED 的结果(S8StatusRules 无 CLOSE 动作)。
+        // 该码自始无对应 Action,标记为 deprecated;既有 SysMenu 行与授权保持不动。
+        new() { Code = "s8:exception:close",        Label = "关闭异常",     Group = GroupException,    SortNo = 80, Deprecated = true },
         new() { Code = "s8:verification:submit",    Label = "提交复检",     Group = GroupVerification, SortNo = 110 },
         new() { Code = "s8:verification:approve",   Label = "检验通过",     Group = GroupVerification, SortNo = 120 },
         new() { Code = "s8:verification:reject",    Label = "检验退回",     Group = GroupVerification, SortNo = 130 },
+        new() { Code = "s8:exception:comment",      Label = "补充说明",     Group = GroupException,    SortNo = 90 },
         new() { Code = "s8:config:read",            Label = "查看配置",     Group = GroupConfig,       SortNo = 210 },
         new() { Code = "s8:config:operator:bind",   Label = "维护操作员绑定", Group = GroupConfig,     SortNo = 220 },
         new() { Code = "s8:config:role:write",      Label = "维护角色权限", Group = GroupConfig,       SortNo = 230 },
+        new() { Code = "s8:config:watch-rule",      Label = "维护监控规则", Group = GroupConfig,       SortNo = 240 },
+        new() { Code = "s8:config:data-source",     Label = "维护数据源",   Group = GroupConfig,       SortNo = 250 },
+        new() { Code = "s8:config:exception-type",  Label = "维护异常类型", Group = GroupConfig,       SortNo = 260 },
+        new() { Code = "s8:config:notification",    Label = "维护通知分层", Group = GroupConfig,       SortNo = 270 },
+        new() { Code = "s8:config:scene",           Label = "维护场景配置", Group = GroupConfig,       SortNo = 280 },
+        new() { Code = "s8:config:kpi",             Label = "维护KPI目标值", Group = GroupConfig,      SortNo = 290 },
+        new() { Code = "s8:config:dashboard",       Label = "维护大屏卡片", Group = GroupConfig,       SortNo = 300 },
         new() { Code = "s8:dashboard:read",         Label = "查看看板",     Group = GroupDashboard,    SortNo = 310 },
+        new() { Code = "s8:debug:run",              Label = "运维调试接口", Group = GroupDebug,        SortNo = 900 },
     };
 
     public static IReadOnlyList<S8PermissionItem> All => _all;

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8AlertAggregationController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -23,6 +25,7 @@ public class AdoS8AlertAggregationController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> QueryAsync([FromQuery] AdoS8AlertAggregationQueryDto q)
     {
         try

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigAlertRulesController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -23,6 +25,7 @@ public class AdoS8ConfigAlertRulesController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -48,6 +51,7 @@ public class AdoS8ConfigAlertRulesController : ControllerBase
             new { message = S8WriteRetiredException.UserMessage });
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8AlertRule body)
     {
         try { return Ok(await _svc.CreateAsync(body, RetiredWriteScope)); }
@@ -57,6 +61,7 @@ public class AdoS8ConfigAlertRulesController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8AlertRule body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, RetiredWriteScope)); }
@@ -66,6 +71,7 @@ public class AdoS8ConfigAlertRulesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try { await _svc.DeleteAsync(id, RetiredWriteScope); }

+ 8 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDashboardCellsController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigDashboardCellsController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -28,6 +31,7 @@ public class AdoS8ConfigDashboardCellsController : ControllerBase
     }
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigDashboard)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8DashboardCellConfig body)
     {
         try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
@@ -36,6 +40,7 @@ public class AdoS8ConfigDashboardCellsController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigDashboard)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8DashboardCellConfig body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
@@ -44,6 +49,7 @@ public class AdoS8ConfigDashboardCellsController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigDashboard)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); }
@@ -57,6 +63,7 @@ public class AdoS8ConfigDashboardCellsController : ControllerBase
     /// 平台默认行不被修改。
     /// </summary>
     [HttpPost("{globalId:long}/factory-override")]
+    [S8Permission(S8PermissionCatalog.ConfigDashboard)]
     public async Task<IActionResult> CreateFactoryOverrideAsync(long globalId)
     {
         try { return Ok(await _svc.CreateFactoryOverrideAsync(globalId, await _scope.ResolveAsync())); }
@@ -69,6 +76,7 @@ public class AdoS8ConfigDashboardCellsController : ControllerBase
     /// 入参是<b>当前工厂覆盖行</b>的 Id;只删除该覆盖行,平台默认行不受影响。
     /// </summary>
     [HttpDelete("{overrideId:long}/factory-override")]
+    [S8Permission(S8PermissionCatalog.ConfigDashboard)]
     public async Task<IActionResult> ResetToGlobalDefaultAsync(long overrideId)
     {
         try { await _svc.ResetToGlobalDefaultAsync(overrideId, await _scope.ResolveAsync()); return Ok(new { overrideId, reset = true }); }

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDataSourcesController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigDataSourcesController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -28,6 +31,7 @@ public class AdoS8ConfigDataSourcesController : ControllerBase
     }
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8DataSource body)
     {
         try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
@@ -36,6 +40,7 @@ public class AdoS8ConfigDataSourcesController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8DataSource body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
@@ -44,6 +49,7 @@ public class AdoS8ConfigDataSourcesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); }
@@ -52,6 +58,7 @@ public class AdoS8ConfigDataSourcesController : ControllerBase
     }
 
     [HttpPost("{id:long}/test")]
+    [S8Permission(S8PermissionCatalog.ConfigDataSource)]
     public async Task<IActionResult> TestAsync(long id)
     {
         try { return Ok(await _svc.TestAsync(id, await _scope.ResolveAsync())); }

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDetectionLogsController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigDetectionLogsController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS8DetectionLogQueryDto q)
     {
         var scope = await _scope.ResolveAsync();

+ 8 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDraftsController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigDraftsController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] AdoS8ConfigDraftListQueryDto query)
     {
         var scope = await _scope.ResolveAsync();
@@ -28,6 +31,7 @@ public class AdoS8ConfigDraftsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetAsync(long id)
     {
         try { return Ok(await _svc.GetAsync(id, await _scope.ResolveAsync())); }
@@ -36,6 +40,7 @@ public class AdoS8ConfigDraftsController : ControllerBase
     }
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8ConfigDraftCreateDto body)
     {
         try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
@@ -44,6 +49,7 @@ public class AdoS8ConfigDraftsController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8ConfigDraftUpdateDto body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
@@ -52,6 +58,7 @@ public class AdoS8ConfigDraftsController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try
@@ -64,6 +71,7 @@ public class AdoS8ConfigDraftsController : ControllerBase
     }
 
     [HttpPost("{id:long}/generate-rule")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> GenerateRuleAsync(long id, [FromBody] AdoS8ConfigDraftGenerateRuleDto body)
     {
         try { return Ok(await _svc.GenerateRuleAsync(id, body, await _scope.ResolveAsync())); }

+ 11 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigExceptionTypesController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1,
@@ -32,6 +35,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
 
     // TASK-015-TREE-DEV-1:2 层异常类型树(5 父节点 + 子节点 + Orphans)。
     [HttpGet("tree")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetTreeAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1,
@@ -44,6 +48,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     }
 
     [HttpGet("{typeCode}")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetAsync(string typeCode, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -54,6 +59,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     }
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigExceptionType)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8ExceptionType body)
     {
         try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
@@ -62,6 +68,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigExceptionType)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8ExceptionType body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
@@ -70,6 +77,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     }
 
     [HttpPut("{id:long}/enabled")]
+    [S8Permission(S8PermissionCatalog.ConfigExceptionType)]
     public async Task<IActionResult> SetEnabledAsync(long id, [FromQuery] bool enabled)
     {
         try { await _svc.SetEnabledAsync(id, enabled, await _scope.ResolveAsync()); return Ok(); }
@@ -78,6 +86,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigExceptionType)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); return Ok(); }
@@ -91,6 +100,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     /// 平台默认行不被修改。
     /// </summary>
     [HttpPost("{globalId:long}/factory-override")]
+    [S8Permission(S8PermissionCatalog.ConfigExceptionType)]
     public async Task<IActionResult> CreateFactoryOverrideAsync(long globalId)
     {
         try { return Ok(await _svc.CreateFactoryOverrideAsync(globalId, await _scope.ResolveAsync())); }
@@ -103,6 +113,7 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
     /// 入参是<b>当前工厂覆盖行</b>的 Id;只删除该覆盖行,平台默认行不受影响。
     /// </summary>
     [HttpDelete("{overrideId:long}/factory-override")]
+    [S8Permission(S8PermissionCatalog.ConfigExceptionType)]
     public async Task<IActionResult> ResetToGlobalDefaultAsync(long overrideId)
     {
         try { await _svc.ResetToGlobalDefaultAsync(overrideId, await _scope.ResolveAsync()); return Ok(new { overrideId, reset = true }); }

+ 5 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigKpiTargetsController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -24,6 +26,7 @@ public class AdoS8ConfigKpiTargetsController : ControllerBase
 
     /// <summary>列表:5 项 Result KPI 的生效目标值(工厂覆盖优先)。</summary>
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync()
         => Ok(await _svc.ListAsync(await _scope.ResolveAsync()));
 
@@ -31,6 +34,7 @@ public class AdoS8ConfigKpiTargetsController : ControllerBase
     /// 自定义本工厂 / 编辑本工厂目标值。按 metricCode 定位,归属由服务端盖章。
     /// </summary>
     [HttpPut("{metricCode}/factory-override")]
+    [S8Permission(S8PermissionCatalog.ConfigKpi)]
     public async Task<IActionResult> UpsertFactoryOverrideAsync(string metricCode, [FromBody] AdoS8KpiTargetUpdateBody body)
     {
         try
@@ -44,6 +48,7 @@ public class AdoS8ConfigKpiTargetsController : ControllerBase
 
     /// <summary>恢复平台默认:只删当前工厂覆盖行,平台默认行不受影响。</summary>
     [HttpDelete("{metricCode}/factory-override")]
+    [S8Permission(S8PermissionCatalog.ConfigKpi)]
     public async Task<IActionResult> ResetToGlobalDefaultAsync(string metricCode)
     {
         try { return Ok(await _svc.ResetToGlobalDefaultAsync(metricCode, await _scope.ResolveAsync())); }

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigMonitorDictionaryController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -23,6 +25,7 @@ public class AdoS8ConfigMonitorDictionaryController : ControllerBase
     /// CONFIG-MONITOR-DICT-READONLY-SEED-1。
     /// </summary>
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetOptionsAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1)

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLayersController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,10 +21,12 @@ public class AdoS8ConfigNotificationLayersController : ControllerBase
     }
 
     [HttpGet("role-options")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetRoleOptionsAsync() =>
         Ok(await _svc.GetRoleOptionsAsync());
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -32,6 +36,7 @@ public class AdoS8ConfigNotificationLayersController : ControllerBase
     }
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigNotification)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8NotificationLayer body)
     {
         try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
@@ -40,6 +45,7 @@ public class AdoS8ConfigNotificationLayersController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigNotification)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8NotificationLayer body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
@@ -48,6 +54,7 @@ public class AdoS8ConfigNotificationLayersController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigNotification)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); }

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigNotificationLogsController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigNotificationLogsController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS8NotificationLogQueryDto q)
     {
         var scope = await _scope.ResolveAsync();

+ 2 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigPermissionCatalogController.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Const.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
@@ -11,6 +12,7 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 public class AdoS8ConfigPermissionCatalogController : ControllerBase
 {
     [HttpGet("permission-catalog")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public IActionResult Get() => Ok(S8PermissionCatalog.All.Select(x => new
     {
         code = x.Code,

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigRolesController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigRolesController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -28,6 +31,7 @@ public class AdoS8ConfigRolesController : ControllerBase
     }
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigRoleWrite)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8RolePermissionConfig body)
     {
         try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
@@ -36,6 +40,7 @@ public class AdoS8ConfigRolesController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigRoleWrite)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8RolePermissionConfig body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
@@ -44,6 +49,7 @@ public class AdoS8ConfigRolesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigRoleWrite)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); }
@@ -55,6 +61,7 @@ public class AdoS8ConfigRolesController : ControllerBase
     /// 从系统角色表一键导入,返回本次新增条数。
     /// </summary>
     [HttpPost("import-from-sys")]
+    [S8Permission(S8PermissionCatalog.ConfigRoleWrite)]
     public async Task<IActionResult> ImportFromSysAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1)

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigScenesController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -19,6 +21,7 @@ public class AdoS8ConfigScenesController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -28,6 +31,7 @@ public class AdoS8ConfigScenesController : ControllerBase
     }
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigScene)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8SceneConfig body)
     {
         try { return Ok(await _svc.CreateAsync(body, await _scope.ResolveAsync())); }
@@ -36,6 +40,7 @@ public class AdoS8ConfigScenesController : ControllerBase
     }
 
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigScene)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8SceneConfig body)
     {
         try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
@@ -44,6 +49,7 @@ public class AdoS8ConfigScenesController : ControllerBase
     }
 
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigScene)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         try { await _svc.DeleteAsync(id, await _scope.ResolveAsync()); }

+ 12 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using System.Security.Claims;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
@@ -28,6 +30,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -42,6 +45,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     /// </summary>
     [Obsolete("Legacy full-create endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params for safe params editing.")]
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8WatchRule body)
     {
         MarkLegacyDeprecated("POST /api/aidop/s8/config/watch-rules");
@@ -71,6 +75,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     // ================================================================================
     [Obsolete("Retired full-update endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params or /schedule.")]
     [HttpPut("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8WatchRule body)
     {
         MarkLegacyDeprecated($"PUT /api/aidop/s8/config/watch-rules/{id}", id);
@@ -96,6 +101,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     /// </summary>
     [Obsolete("Legacy delete endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params for safe params editing.")]
     [HttpDelete("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         MarkLegacyDeprecated($"DELETE /api/aidop/s8/config/watch-rules/{id}", id);
@@ -110,6 +116,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     /// </summary>
     [Obsolete("Legacy test endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params for safe params editing.")]
     [HttpPost("{id:long}/test")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> TestAsync(long id)
     {
         MarkLegacyDeprecated($"POST /api/aidop/s8/config/watch-rules/{id}/test", id);
@@ -124,6 +131,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     /// 服务端按 rule_type 用对应 evaluator 的 Params.Parse 进行 schema 校验。
     /// </summary>
     [HttpPut("{id:long}/params")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> UpdateParamsAsync(long id, [FromBody] S8WatchRuleParamsPayload body)
     {
         try { return Ok(await _svc.UpdateParamsAsync(id, body, await _scope.ResolveAsync())); }
@@ -136,6 +144,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     /// 不动 params_json / rule_type / expression / data_source_id / scene_code。
     /// </summary>
     [HttpPut("{id:long}/schedule")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> UpdateScheduleAsync(long id, [FromBody] S8WatchRuleSchedulePayload body)
     {
         try { return Ok(await _svc.UpdateScheduleAsync(id, body, await _scope.ResolveAsync())); }
@@ -145,6 +154,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
 
     /// <summary>S8-SCHED-FRONTEND-1:立即执行一次,next_run_at 置为 NOW,由下一 tick 拾取。</summary>
     [HttpPost("{id:long}/run-now")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> RunNowAsync(long id)
     {
         try { return Ok(await _svc.RunNowAsync(id, await _scope.ResolveAsync())); }
@@ -154,6 +164,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
 
     /// <summary>S8-SCHED-FRONTEND-1:手工暂停,paused_until 置为远未来哨兵 + pause_reason=MANUAL_PAUSED。</summary>
     [HttpPost("{id:long}/pause")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> PauseAsync(long id)
     {
         try { return Ok(await _svc.PauseAsync(id, await _scope.ResolveAsync())); }
@@ -163,6 +174,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
 
     /// <summary>S8-SCHED-FRONTEND-1:恢复,清 paused_until / pause_reason / last_error / consecutive_failure_count,next_run_at = NOW。</summary>
     [HttpPost("{id:long}/resume")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
     public async Task<IActionResult> ResumeAsync(long id)
     {
         try { return Ok(await _svc.ResumeAsync(id, await _scope.ResolveAsync())); }

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DashboardController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -30,6 +32,7 @@ public class AdoS8DashboardController : ControllerBase
     // Compatibility-only. Security scope is resolved server-side.
 
     [HttpGet("overview")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> OverviewAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1,
@@ -44,6 +47,7 @@ public class AdoS8DashboardController : ControllerBase
     }
 
     [HttpGet("trends")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> TrendsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromQuery] int days = 14, [FromQuery] string? period = null)
     {
@@ -53,6 +57,7 @@ public class AdoS8DashboardController : ControllerBase
     }
 
     [HttpGet("distributions")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> DistributionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1, [FromQuery] string? period = null)
     {
         _ = tenantId; _ = factoryId;
@@ -61,6 +66,7 @@ public class AdoS8DashboardController : ControllerBase
     }
 
     [HttpGet("quick-exceptions")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> QuickExceptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromQuery] string mode = "latest")
     {
@@ -70,6 +76,7 @@ public class AdoS8DashboardController : ControllerBase
     }
 
     [HttpGet("dept-backlog")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> DeptBacklogAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1, [FromQuery] string? period = null)
     {
         _ = tenantId; _ = factoryId;
@@ -78,6 +85,7 @@ public class AdoS8DashboardController : ControllerBase
     }
 
     [HttpGet("dim-trends")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> DimTrendsAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1,
@@ -94,6 +102,7 @@ public class AdoS8DashboardController : ControllerBase
     /// 按配置获取单个大屏卡片的数据。配置来源 ado_s8_dashboard_cell_config。
     /// </summary>
     [HttpGet("cell-data")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> CellDataAsync([FromQuery] AdoS8CellDataQueryDto q)
     {
         try
@@ -112,6 +121,7 @@ public class AdoS8DashboardController : ControllerBase
     /// 不替代 cell-data,不触发 ado_s8_exception 查询。
     /// </summary>
     [HttpGet("page-config")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> PageConfigAsync([FromQuery] AdoS8PageConfigQueryDto q)
     {
         try

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DictionariesController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
@@ -12,6 +14,7 @@ public class AdoS8DictionariesController : ControllerBase
     public AdoS8DictionariesController(S8DictionaryService svc) => _svc = svc;
 
     [HttpGet("{dictType}")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public IActionResult Get(string dictType)
     {
         var result = _svc.GetDictionary(dictType);

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8DimensionController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -18,6 +20,7 @@ public class AdoS8DimensionController : ControllerBase
     }
 
     [HttpGet("dimensions")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetDimensionsAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1)
@@ -29,6 +32,7 @@ public class AdoS8DimensionController : ControllerBase
     }
 
     [HttpGet("dimension-nodes")]
+    [S8Permission(S8PermissionCatalog.ConfigRead)]
     public async Task<IActionResult> GetDimensionNodesAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1,

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -27,6 +29,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> GetPagedAsync([FromQuery] AdoS8ExceptionQueryDto q)
     {
         // S8-TENANT-FACTORY-P0-CLOSURE-1:作用域一律服务端解析,覆盖客户端传入值。
@@ -38,6 +41,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("filter-options")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> GetFilterOptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -47,6 +51,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> GetDetailAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -57,6 +62,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}/timeline")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> GetTimelineAsync(long id)
     {
         if (!await IsInScopeAsync(id)) return NotFound();
@@ -64,6 +70,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}/decisions")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> GetDecisionsAsync(long id)
     {
         if (!await IsInScopeAsync(id)) return NotFound();
@@ -71,6 +78,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpGet("{id:long}/evidences")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> GetEvidencesAsync(long id)
     {
         if (!await IsInScopeAsync(id)) return NotFound();
@@ -85,6 +93,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/claim")]
+    [S8Permission(S8PermissionCatalog.ExceptionClaim)]
     public async Task<IActionResult> ClaimAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8ClaimBody? body = null)
     {
@@ -99,6 +108,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/transfer")]
+    [S8Permission(S8PermissionCatalog.ExceptionAssign)]
     public async Task<IActionResult> TransferAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8TransferDto? body = null)
     {
@@ -113,6 +123,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/start-progress")]
+    [S8Permission(S8PermissionCatalog.ExceptionStart)]
     public async Task<IActionResult> StartProgressAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8CommentDto? body = null)
     {
@@ -127,6 +138,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/upgrade")]
+    [S8Permission(S8PermissionCatalog.ExceptionUpgrade)]
     public async Task<IActionResult> UpgradeAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8CommentDto? body = null)
     {
@@ -141,6 +153,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/reject")]
+    [S8Permission(S8PermissionCatalog.ExceptionReject)]
     public async Task<IActionResult> RejectAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8CommentDto? body = null)
     {
@@ -155,6 +168,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/submit-verification")]
+    [S8Permission(S8PermissionCatalog.VerificationSubmit)]
     public async Task<IActionResult> SubmitVerificationAsync(long id,
         [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8SubmitVerificationDto? body = null)
@@ -171,6 +185,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/approve-verification")]
+    [S8Permission(S8PermissionCatalog.VerificationApprove)]
     public async Task<IActionResult> ApproveVerificationAsync(long id,
         [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8ApproveVerificationDto? body = null)
@@ -186,6 +201,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/reject-verification")]
+    [S8Permission(S8PermissionCatalog.VerificationReject)]
     public async Task<IActionResult> RejectVerificationAsync(long id,
         [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
         [FromBody] AdoS8RejectVerificationDto? body = null)
@@ -201,6 +217,7 @@ public class AdoS8ExceptionsController : ControllerBase
     }
 
     [HttpPost("{id:long}/comment")]
+    [S8Permission(S8PermissionCatalog.ExceptionComment)]
     public async Task<IActionResult> CommentAsync(long id, [FromBody] AdoS8CommentDto? body = null)
     {
         try

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8IssueLedgerController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -23,6 +25,7 @@ public class AdoS8IssueLedgerController : ControllerBase
     }
 
     [HttpGet]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> QueryAsync([FromQuery] AdoS8IssueLedgerQueryDto q)
     {
         try

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MonitoringController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Service.S8;
@@ -23,6 +25,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// sceneCode 不传时返回全部模块聚合(综合全景页)。
     /// </summary>
     [HttpGet("summary")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetSummaryAsync([FromQuery] AdoS8MonitoringSummaryQueryDto q)
     {
         // S8-TENANT-FACTORY-P0-CLOSURE-1:作用域一律服务端解析,覆盖客户端传入值。
@@ -36,6 +39,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// 9宫格数据:S1-S7 订单健康分布 + S8业务类别汇总 + S9部门汇总。
     /// </summary>
     [HttpGet("order-grid")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetOrderGridAsync([FromQuery] string? period = null)
     {
         var scope = await _scope.ResolveAsync();
@@ -48,6 +52,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// (ORDER_DUE_DATE_DELAY / PRODUCT_DESIGN_DELAY / DELIVERY_DELAY_WARNING)。
     /// </summary>
     [HttpGet("delivery-trend")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetDeliveryTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7, string? period = null)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -62,6 +67,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// (EQUIP_FAULT / MFG_MATERIAL_ABNORMAL / MFG_QUALITY_ABNORMAL / BODY_PRODUCTION_DELAY_WARNING)。
     /// </summary>
     [HttpGet("production-trend")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetProductionTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7, string? period = null)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -77,6 +83,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// 仓库工单备料 / 仓库工单发料 / 采购执行延期 / 物料库存 / 库存周转)。
     /// </summary>
     [HttpGet("supply-trend")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetSupplyTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7, string? period = null)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -90,6 +97,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// domain ∈ {DELIVERY, PRODUCTION, SUPPLY};window ∈ {LAST_24H(默认), LAST_7D};period 优先于 window。
     /// </summary>
     [HttpGet("domain-type-metrics")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetDomainTypeMetricsAsync(string domain, string window = "LAST_24H", string? period = null, long tenantId = 1, long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -102,6 +110,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// TASK-010:S9 卡片 QDC 四主线运营聚合(质量 / 交付 / 成本 / 库存)。
     /// </summary>
     [HttpGet("qdc-summary")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetQdcSummaryAsync(long tenantId = 1, long factoryId = 1, string? period = null)
     {
         // Compatibility-only. Security scope is resolved server-side.
@@ -115,6 +124,7 @@ public class AdoS8MonitoringController : ControllerBase
     /// 与 qdc-summary 是不同语义:本接口返回结果指标占位,待真实计算入口(RATIO-REAL-1)后续接入。
     /// </summary>
     [HttpGet("result-kpi-summary")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> GetResultKpiSummaryAsync(long tenantId = 1, long factoryId = 1)
     {
         // Compatibility-only. Security scope is resolved server-side.

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8OrderFlowController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
 using Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
 
@@ -22,26 +24,31 @@ public class AdoS8OrderFlowController : ControllerBase
 
     /// <summary>订单档案列表(不分页,当前 baseline 20 单)。</summary>
     [HttpGet("orders")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> OrdersAsync([FromQuery] AdoS8OrderFlowOrderQueryDto query)
         => Ok(await _svc.GetOrdersAsync(query));
 
     /// <summary>单订单详情(含 lifecycle 5 阶段,不含 substep/unit)。</summary>
     [HttpGet("orders/{orderCode}")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> OrderDetailAsync(string orderCode)
         => Ok(await _svc.GetOrderAsync(orderCode));
 
     /// <summary>订单链路(含 lifecycle + L2 substeps + L3 units;ProcurementPivot 留待后续切片注入)。</summary>
     [HttpGet("orders/{orderCode}/chain")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> ChainAsync(string orderCode)
         => Ok(await _svc.GetChainAsync(orderCode));
 
     /// <summary>链路聚合视图:BASELINE_PPT 走 snapshot;CURRENT_FILTERED 走实时聚合。</summary>
     [HttpGet("aggregate")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> AggregateAsync([FromQuery] AdoS8OrderFlowAggregateQueryDto query)
         => Ok(await _svc.GetAggregateAsync(query));
 
     /// <summary>采购透视:BASELINE_PPT 读 procurement_pivot 表 baseline 行。</summary>
     [HttpGet("procurement-pivot")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> ProcurementPivotAsync([FromQuery] AdoS8OrderFlowProcurementPivotQueryDto query)
         => Ok(await _svc.GetProcurementPivotAsync(query));
 
@@ -51,6 +58,7 @@ public class AdoS8OrderFlowController : ControllerBase
     /// productType 仅过滤 drawings 明细列表,summary.categories 始终返回常规/非标/合计三档。
     /// </summary>
     [HttpGet("product-design/drawings")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> ProductDesignDrawingsAsync(
         [FromQuery] AdoS8OrderFlowProductDesignDrawingsQueryDto query)
         => Ok(await _svc.GetProductDesignDrawingsAsync(query));
@@ -61,6 +69,7 @@ public class AdoS8OrderFlowController : ControllerBase
     /// orderCodes 为空(CURRENT_FILTERED)时返回空骨架,不静默 fallback baseline。
     /// </summary>
     [HttpGet("manufacturing-pivot")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> ManufacturingPivotAsync(
         [FromQuery] AdoS8OrderFlowManufacturingPivotQueryDto query)
         => Ok(await _svc.GetManufacturingPivotAsync(query));
@@ -75,6 +84,7 @@ public class AdoS8OrderFlowController : ControllerBase
     /// collabSummary 留作 exception 派生补充。
     /// </summary>
     [HttpGet("final-assembly-pivot")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> FinalAssemblyPivotAsync(
         [FromQuery] AdoS8FinalAssemblyPivotQueryDto query)
         => Ok(await _svc.GetFinalAssemblyPivotAsync(query));

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ReportsController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Dto.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -13,12 +15,14 @@ public class AdoS8ReportsController : ControllerBase
     public AdoS8ReportsController(S8ManualReportService svc) => _svc = svc;
 
     [HttpGet("form-options")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     // Compatibility-only. Security scope is resolved server-side
     // (S8ManualReportService.ResolveTrustedScopeAsync 覆盖入参)。
     public async Task<IActionResult> FormOptionsAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) =>
         Ok(await _svc.GetFormOptionsAsync(tenantId, factoryId));
 
     [HttpPost]
+    [S8Permission(S8PermissionCatalog.ExceptionCreate)]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS8ManualReportCreateDto dto)
     {
         try { return Ok(await _svc.CreateAsync(dto)); }
@@ -27,6 +31,7 @@ public class AdoS8ReportsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
+    [S8Permission(S8PermissionCatalog.ExceptionRead)]
     public async Task<IActionResult> GetAsync(long id)
     {
         var e = await _svc.GetAsync(id);
@@ -34,6 +39,7 @@ public class AdoS8ReportsController : ControllerBase
     }
 
     [HttpPost("{id:long}/attachments")]
+    [S8Permission(S8PermissionCatalog.ExceptionCreate)]
     public async Task<IActionResult> AttachmentsAsync(long id, [FromBody] AdoS8AttachmentCreateDto dto)
     {
         try { return Ok(await _svc.AddAttachmentAsync(id, dto)); }

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Const.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Microsoft.Extensions.Configuration;
 
@@ -25,6 +27,7 @@ public class AdoS8WatchDebugController : ControllerBase
     }
 
     [HttpPost("run-once")]
+    [S8Permission(S8PermissionCatalog.DebugRun)]
     public async Task<IActionResult> RunOnceAsync(
         [FromQuery] long tenantId = 1,
         [FromQuery] long factoryId = 1)
@@ -58,6 +61,7 @@ public class AdoS8WatchDebugController : ControllerBase
     /// - 仅 soft delete(SET IsDeleted=true),不触发物理 DELETE。
     /// </summary>
     [HttpDelete("test-cleanup")]
+    [S8Permission(S8PermissionCatalog.DebugRun)]
     public async Task<IActionResult> TestCleanupAsync(
         [FromQuery] long? tenantId,
         [FromQuery] long? factoryId,

+ 104 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8PermissionAttribute.cs

@@ -0,0 +1,104 @@
+using Admin.NET.Core;
+using Admin.NET.Core.Service;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// S8-P0-4-API-AUTHORIZATION-1:S8 接口能力级授权门。
+///
+/// <para><b>为什么不能直接复用平台的路由名匹配</b>(这是本方案的唯一设计前提,先读这段):
+/// <see cref="Admin.NET.Web.Core"/> 的 <c>JwtHandler.CheckAuthorizeAsync</c> 把
+/// <c>HttpContext.Request.Path</c> 逐字转成权限名(<c>/</c> → <c>:</c>)再与
+/// <c>SysMenu.Permission</c> 比对。平台自身接口全是 <c>/api/sysPos/delete</c> 这类
+/// <b>无路径参数</b>的动词式路由,于是 <c>sysPos:delete</c> 天然对得上。</para>
+///
+/// <para>S8 是 RESTful 路由,两点结构性不兼容:</para>
+/// <list type="number">
+///   <item><b>路径参数</b>:<c>/api/aidop/s8/exceptions/1329909430019/claim</c> 会被转成
+///         <c>aidop:s8:exceptions:1329909430019:claim</c> —— 含实例 id,
+///         任何静态权限码<b>永远</b>不可能相等;</item>
+///   <item><b>命名体系</b>:S8 权限码是能力语义(<c>s8:exception:claim</c>),
+///         而非路由字面量(<c>aidop:s8:exceptions:…</c>)。</item>
+/// </list>
+///
+/// <para>再加上 <c>JwtHandler</c> 末行 <c>allBtnPermList.TrueForAll(u =&gt; !routeName.Equals(u))</c>
+/// —— 未登记路由<b>默认放行</b>,于是全部 S8 接口实测为「登录即可调用」:
+/// 2026-09-02 以业务账号 <c>UATExceptionA</c> 实测 <c>PUT /config/watch-rules/{id}/params</c>
+/// 与 <c>POST /config/operator-bindings</c> 均直达业务校验返回 400(而非 403)。</para>
+///
+/// <para><b>本类做什么</b>:只替换「路由名 → 权限码」这一步为<b>显式声明</b>,
+/// 其余全部复用平台既有设施 —— 权限仍存 <see cref="SysMenu"/>(Type=Btn).Permission,
+/// 仍经 <c>SysRoleMenu</c> 授予角色,仍由平台的
+/// <see cref="SysMenuService.GetOwnBtnPermList"/>(含缓存)取用户权限集。
+/// <b>不新造权限存储、不改 JwtHandler</b>(那是所有模块共用的平台代码,改它会外溢到 S0–S7)。</para>
+///
+/// <para><b>失败语义</b>:未持有声明的权限码 → 直接 <c>403</c>,
+/// 在进入 Controller Action 与任何 Service 之前返回,DB 零写入。</para>
+///
+/// <para><b>超管豁免</b>:与 <c>JwtHandler.CheckAuthorizeAsync</c> 完全同口径
+/// (<see cref="UserManager.SuperAdmin"/>),避免出现「平台放行、S8 拦截」的不一致。</para>
+/// </summary>
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
+public sealed class S8PermissionAttribute : Attribute, IAsyncAuthorizationFilter
+{
+    /// <summary>本 Action 要求的能力码,必须取自 <see cref="S8PermissionCatalog"/> 常量。</summary>
+    public string Code { get; }
+
+    public S8PermissionAttribute(string code)
+    {
+        if (string.IsNullOrWhiteSpace(code))
+            throw new ArgumentException("S8 权限码不能为空", nameof(code));
+        Code = code;
+    }
+
+    public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
+    {
+        // 认证本身由平台管道负责;这里只判「已认证身份是否具备该能力」。
+        // 未认证时交回平台的 401 语义,不在此重复判定。
+        var user = context.HttpContext.User;
+        if (user?.Identity?.IsAuthenticated != true) return;
+
+        var services = context.HttpContext.RequestServices;
+
+        // 超管豁免:与 JwtHandler 同口径。
+        var userManager = services.GetService<UserManager>();
+        if (userManager is { SuperAdmin: true }) return;
+
+        var menuService = services.GetService<SysMenuService>();
+        if (menuService == null)
+        {
+            // 拿不到平台权限服务时按拒绝处理(fail-closed)。
+            // 绝不 fail-open —— 本模块的事故类型全部是「本不该放行却放行了」。
+            context.Result = Forbid();
+            return;
+        }
+
+        List<string> owned;
+        try
+        {
+            owned = await menuService.GetOwnBtnPermList();
+        }
+        catch
+        {
+            context.Result = Forbid();
+            return;
+        }
+
+        if (owned != null && owned.Contains(Code, StringComparer.OrdinalIgnoreCase)) return;
+
+        context.Result = Forbid();
+    }
+
+    /// <summary>
+    /// 统一 403 出参。文案不回显所需权限码与用户已有权限,避免把权限拓扑透给调用方。
+    /// </summary>
+    private static ObjectResult Forbid() => new(new { message = "没有权限执行该操作" })
+    {
+        StatusCode = StatusCodes.Status403Forbidden
+    };
+}

+ 14 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -1306,6 +1306,20 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             (13, baseId + 13, "s8:config:role:write",      "维护角色权限", 230),
             // 看板
             (14, s8DashboardId, "s8:dashboard:read",        "查看看板",     310),
+            // ── S8-P0-4-API-AUTHORIZATION-1:配置类 capability 细分 + 运维调试。
+            // 原目录只有 config:read / operator:bind / role:write 三个配置码,
+            // 无法表达「改监控规则」与「改数据源」的差别,也没有 debug 能力码,
+            // 于是 118 个 S8 接口实测全部「登录即可调用」。这里补齐到与
+            // S8PermissionCatalog 一一对应,Btn ID 段续用 baseId+115..123。
+            (15, baseId + 16, "s8:config:watch-rule",      "维护监控规则",  240),
+            (16, baseId + 15, "s8:config:data-source",     "维护数据源",    250),
+            (17, baseId + 17, "s8:config:exception-type",  "维护异常类型",  260),
+            (18, baseId + 12, "s8:config:notification",    "维护通知分层",  270),
+            (19, baseId + 11, "s8:config:scene",           "维护场景配置",  280),
+            (20, baseId + 19, "s8:config:kpi",             "维护KPI目标值", 290),
+            (21, baseId + 18, "s8:config:dashboard",       "维护大屏卡片",  300),
+            (22, baseId + 10, "s8:exception:comment",      "补充说明",      90),
+            (23, baseId + 4,  "s8:debug:run",              "运维调试接口",  900),
         };
         foreach (var (off, pid, perm, title, order) in btns)
         {