From 96a59eb3db19d04df6e9f82a4d4142843186eea1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:26:35 +0000
Subject: [PATCH 1/3] Initial plan
From e5c219c49f0d0d210ebd6902eb08586d4dcc3f09 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:39:42 +0000
Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DataScope=20=E8=A1=8C?=
=?UTF-8?q?=E6=9D=83=E4=BA=8B=E5=AE=9E=E6=BA=90=EF=BC=9ARole.Valid/?=
=?UTF-8?q?=E6=8B=A6=E6=88=AA=E5=99=A8/=E5=86=85=E7=BD=AE=E5=AE=9E?=
=?UTF-8?q?=E4=BD=93=E6=8C=82=E8=BD=BD/=E7=BC=93=E5=AD=98=E9=94=AE/?=
=?UTF-8?q?=E8=8F=9C=E5=8D=95=E9=BB=98=E8=AE=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: nnhy <506367+nnhy@users.noreply.github.com>
---
XCode/Membership/DataScopeContext.cs | 42 ++++-
XCode/Membership/DataScopeModule.cs | 4 +-
.../\346\227\245\345\277\227.Biz.cs" | 14 +-
.../\347\224\250\346\210\267.Biz.cs" | 1 +
.../\350\217\234\345\215\225.Biz.cs" | 5 +
.../\350\247\222\350\211\262.Biz.cs" | 4 +-
.../\351\203\250\351\227\250.Biz.cs" | 13 +-
XUnitTest.XCode/Membership/DataScopeTests.cs | 169 +++++++++++++++++-
8 files changed, 242 insertions(+), 10 deletions(-)
diff --git a/XCode/Membership/DataScopeContext.cs b/XCode/Membership/DataScopeContext.cs
index 54a831902..1e9b96e98 100644
--- a/XCode/Membership/DataScopeContext.cs
+++ b/XCode/Membership/DataScopeContext.cs
@@ -110,7 +110,8 @@ public class DataScopeContext
// 全部权限不需要缓存
if (scope == DataScopes.全部) return null;
- var key = $"DataScope:{userId}:{(Int32)scope}";
+ // 缓存键包含部门编号,用户调岗(DepartmentID 变化)后立即使用新的部门列表,无需等待过期
+ var key = $"DataScope:{userId}:{deptId}:{(Int32)scope}";
return _cache.GetOrAdd(key, k => DataScopeHelper.GetAccessibleDepartmentIds(deptId, roles, scope));
}
@@ -120,10 +121,11 @@ public static void ClearCache(Int32 userId = 0)
{
if (userId > 0)
{
- // 清除该用户所有范围的缓存
- for (var i = 0; i <= 4; i++)
+ // 清除该用户所有缓存键(同时兼容旧键 DataScope:{userId}:{scope} 与新键 DataScope:{userId}:{deptId}:{scope})
+ var prefix = $"DataScope:{userId}:";
+ foreach (var key in _cache.Keys.ToArray())
{
- _cache.Remove($"DataScope:{userId}:{i}");
+ if (key.StartsWith(prefix)) _cache.Remove(key);
}
}
else
@@ -310,6 +312,8 @@ public static Int32[] ParseDepartmentIds(String? departmentIds)
if (typeof(IUserScope).IsAssignableFrom(type))
{
+ // 纯用户实体(无部门列,如日志):非全部权限始终按当前用户过滤。
+ // 无部门列时,本部门/自定义等范围不会 join 用户表放大成同事数据,避免越权可见。
var userField = GetUserField(factory);
if (userField is not null)
return userField.Equal(context.UserId);
@@ -319,7 +323,7 @@ public static Int32[] ParseDepartmentIds(String? departmentIds)
{
var deptField = GetDepartmentField(factory);
if (deptField is not null)
- return BuildDepartmentFilter(context, deptField);
+ return BuildDepartmentScopeFilter(context, deptField);
}
return null;
@@ -371,6 +375,30 @@ public static Int32[] ParseDepartmentIds(String? departmentIds)
return deptField.In(deptIds);
}
+
+ /// 构建纯部门实体的过滤表达式
+ ///
+ /// 适用于“一行一个部门”的表(如部门表,DepartmentId 映射到主键 ID)。
+ /// 仅本人时没有可访问部门列表,退化为“当前用户所在部门”,即 ID=当前用户.DepartmentID,
+ /// 而不是恒假条件 Equal(-1) 导致空表。
+ ///
+ private static Expression? BuildDepartmentScopeFilter(DataScopeContext context, FieldItem? deptField)
+ {
+ if (deptField is null) return null;
+
+ // 仅本人:部门表退化为“当前用户所在部门”
+ if (context.DataScope == DataScopes.仅本人)
+ return deptField.Equal(context.DepartmentId);
+
+ var deptIds = context.AccessibleDepartmentIds;
+ if (deptIds == null) return null; // null 表示不限制
+
+ // 空数组时退化为当前用户所在部门,避免恒假条件导致空表
+ if (deptIds.Length == 0) return deptField.Equal(context.DepartmentId);
+ if (deptIds.Length == 1) return deptField.Equal(deptIds[0]);
+
+ return deptField.In(deptIds);
+ }
#endregion
#region 权限校验
@@ -431,6 +459,10 @@ public static Boolean CanAccess(IDepartmentScope entity, DataScopeContext? conte
// 全部权限可访问所有数据
if (context.DataScope == DataScopes.全部) return true;
+ // 仅本人:部门表“一行一个部门”,退化为“当前用户所在部门”
+ if (context.DataScope == DataScopes.仅本人)
+ return entity.DepartmentId == context.DepartmentId;
+
var deptIds = context.AccessibleDepartmentIds;
if (deptIds == null) return true;
diff --git a/XCode/Membership/DataScopeModule.cs b/XCode/Membership/DataScopeModule.cs
index bb303b2c5..5b9ce1af9 100644
--- a/XCode/Membership/DataScopeModule.cs
+++ b/XCode/Membership/DataScopeModule.cs
@@ -72,8 +72,10 @@ protected override Boolean OnValid(IEntity entity, DataMethod method)
}
catch (InvalidOperationException ex)
{
- // 失败时记录审计日志
+ // 失败时记录审计日志,并拒绝该操作(返回 false),避免越权写入
LogProvider.Provider.WriteLog(entity.GetType(), method + "", false, ex.Message);
+
+ return false;
}
return true;
diff --git "a/XCode/Membership/\346\227\245\345\277\227.Biz.cs" "b/XCode/Membership/\346\227\245\345\277\227.Biz.cs"
index 2d8c44ee2..d0decae65 100644
--- "a/XCode/Membership/\346\227\245\345\277\227.Biz.cs"
+++ "b/XCode/Membership/\346\227\245\345\277\227.Biz.cs"
@@ -5,7 +5,7 @@
namespace XCode.Membership;
/// 日志
-public partial class Log : Entity
+public partial class Log : Entity, IUserScope, IDataScopeFieldProvider
{
#region 对象操作
static Log()
@@ -17,6 +17,7 @@ static Log()
Meta.Interceptors.Add();
Meta.Interceptors.Add();
Meta.Interceptors.Add();
+ Meta.Interceptors.Add();
#if !DEBUG
// 关闭SQL日志
@@ -232,4 +233,15 @@ public static IList FindAllByCreateUserID(Int32 createUserId)
///
public override String ToString() => $"{Category} {Action} {UserName} {CreateTime:yyyy-MM-dd HH:mm:ss} {Remark}";
#endregion
+
+ #region IUserScope 成员
+ // 日志表没有部门列,仅按创建用户过滤;本部门/自定义等范围不会 join 用户表放大成同事数据
+ Int32 IUserScope.UserId { get => CreateUserID; set => CreateUserID = value; }
+ #endregion
+
+ #region IDataScopeFieldProvider 成员
+ XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetUserField() => _.CreateUserID;
+ XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetDepartmentField() => null;
+ XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetTenantField() => null;
+ #endregion
}
\ No newline at end of file
diff --git "a/XCode/Membership/\347\224\250\346\210\267.Biz.cs" "b/XCode/Membership/\347\224\250\346\210\267.Biz.cs"
index 7a52e8e0f..6aaced164 100644
--- "a/XCode/Membership/\347\224\250\346\210\267.Biz.cs"
+++ "b/XCode/Membership/\347\224\250\346\210\267.Biz.cs"
@@ -41,6 +41,7 @@ static User()
Meta.Interceptors.Add();
Meta.Interceptors.Add();
Meta.Interceptors.Add();
+ Meta.Interceptors.Add();
}
/// 首次连接数据库时初始化数据,仅用于实体类重载,用户不应该调用该方法
diff --git "a/XCode/Membership/\350\217\234\345\215\225.Biz.cs" "b/XCode/Membership/\350\217\234\345\215\225.Biz.cs"
index 6ae7aee90..f25f8259f 100644
--- "a/XCode/Membership/\350\217\234\345\215\225.Biz.cs"
+++ "b/XCode/Membership/\350\217\234\345\215\225.Biz.cs"
@@ -37,6 +37,11 @@ public override Boolean Valid(DataMethod method)
if (Icon == "") Icon = null;
+ // 数据范围默认 -1(使用角色默认)。列默认值为 -1,但枚举默认是 0=全部,
+ // 新建或未显式配置的菜单若保存为 0 会覆盖角色默认为“全部”,故此处保持 -1。
+ if (method == DataMethod.Insert && !IsDirty(__.DataScope) && (Int32)DataScope == 0)
+ DataScope = (DataScopes)(-1);
+
SavePermission();
return base.Valid(method);
diff --git "a/XCode/Membership/\350\247\222\350\211\262.Biz.cs" "b/XCode/Membership/\350\247\222\350\211\262.Biz.cs"
index 66c795d6e..0706b84bc 100644
--- "a/XCode/Membership/\350\247\222\350\211\262.Biz.cs"
+++ "b/XCode/Membership/\350\247\222\350\211\262.Biz.cs"
@@ -130,7 +130,9 @@ public override Boolean Valid(DataMethod method)
Type = IsSystem ? RoleTypes.系统 : RoleTypes.普通;
}
- if (DataScope == 0)
+ // 仅在新增且未显式设置数据范围时,按角色类型填充默认值。
+ // DataScopes.全部==0,更新或已显式赋值时,0 就表示“全部”,不再改写。
+ if (method == DataMethod.Insert && !IsDirty(__.DataScope) && DataScope == 0)
{
DataScope = Type switch
{
diff --git "a/XCode/Membership/\351\203\250\351\227\250.Biz.cs" "b/XCode/Membership/\351\203\250\351\227\250.Biz.cs"
index ece2b76f0..15a69d22b 100644
--- "a/XCode/Membership/\351\203\250\351\227\250.Biz.cs"
+++ "b/XCode/Membership/\351\203\250\351\227\250.Biz.cs"
@@ -9,7 +9,7 @@
namespace XCode.Membership;
/// 部门。组织机构,多级树状结构
-public partial class Department : Entity, ITenantScope
+public partial class Department : Entity, ITenantScope, IDepartmentScope, IDataScopeFieldProvider
{
#region 对象操作
private static Int32 MaxCacheCount = 10000;
@@ -28,6 +28,7 @@ static Department()
Meta.Interceptors.Add();
Meta.Interceptors.Add();
Meta.Interceptors.Add();
+ Meta.Interceptors.Add();
}
/// 验证并修补数据,返回验证结果,或者通过抛出异常的方式提示验证失败。
@@ -261,4 +262,14 @@ public static IList Search(Int32 tenantId, DepartmentTypes type, Int
#region 业务操作
#endregion
+
+ #region IDepartmentScope 成员
+ Int32 IDepartmentScope.DepartmentId { get => ID; set => ID = value; }
+ #endregion
+
+ #region IDataScopeFieldProvider 成员
+ XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetUserField() => null;
+ XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetDepartmentField() => _.ID;
+ XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetTenantField() => _.TenantId;
+ #endregion
}
\ No newline at end of file
diff --git a/XUnitTest.XCode/Membership/DataScopeTests.cs b/XUnitTest.XCode/Membership/DataScopeTests.cs
index 38ea8e3fe..cef98f1c9 100644
--- a/XUnitTest.XCode/Membership/DataScopeTests.cs
+++ b/XUnitTest.XCode/Membership/DataScopeTests.cs
@@ -477,7 +477,8 @@ public void DataScopeInterceptor_OnValid_Update_NoDirty_SkipsValidation()
DataScope = DataScopes.仅本人
};
var entity = new DataScopeTestEntity { UserId = 999, DepartmentId = 888 };
- // 不修改任何属性,所以没有脏数据
+ // 清除脏数据,模拟没有任何字段被修改的更新场景
+ ((IEntity)entity).Dirtys.Clear();
// Act
var result = module.Valid(entity, DataMethod.Update);
@@ -1390,6 +1391,172 @@ public void DataScopes_EnumValues_Correct()
Assert.Equal(4, (Int32)DataScopes.自定义);
}
#endregion
+
+ #region 行权事实源验收测试
+ [Fact]
+ [DisplayName("角色_普通角色全部_Update后仍为全部")]
+ public void Role_NormalRole_ScopeAll_Update_StaysAll()
+ {
+ // Arrange 普通角色显式使用“全部(0)”
+ var role = new Role { Name = "普通角色", Type = RoleTypes.普通, DataScope = DataScopes.全部 };
+
+ // Act 更新时不应把 0 改写成“本部门”
+ role.Valid(DataMethod.Update);
+
+ // Assert
+ Assert.Equal(DataScopes.全部, role.DataScope);
+ }
+
+ [Fact]
+ [DisplayName("角色_Insert未赋DataScope时填充Type默认值")]
+ public void Role_Insert_WithoutDataScope_FillsTypeDefault()
+ {
+ // Arrange 未显式赋值 DataScope
+ var role = new Role { Name = "业务角色", Type = RoleTypes.普通 };
+
+ // Act
+ role.Valid(DataMethod.Insert);
+
+ // Assert 普通角色默认本部门
+ Assert.Equal(DataScopes.本部门, role.DataScope);
+ }
+
+ [Fact]
+ [DisplayName("拦截器_仅本人_拒绝修改他人IDataScope数据")]
+ public void Interceptor_SelfOnly_RejectUpdateOthersData()
+ {
+ // Arrange
+ var module = new DataScopeInterceptor();
+ DataScopeContext.Current = new DataScopeContext
+ {
+ UserId = 100,
+ DepartmentId = 200,
+ DataScope = DataScopes.仅本人
+ };
+ var entity = new DataScopeTestEntity { UserId = 999, DepartmentId = 888 };
+ entity.Name = "改动"; // 制造脏数据
+
+ // Act
+ var result = module.Valid(entity, DataMethod.Update);
+
+ // Assert 校验失败不再放行
+ Assert.False(result);
+ }
+
+ [Fact]
+ [DisplayName("拦截器_本部门_拒绝在越权部门插入")]
+ public void Interceptor_Department_RejectInsertForeignDept()
+ {
+ // Arrange
+ var module = new DataScopeInterceptor();
+ DataScopeContext.Current = new DataScopeContext
+ {
+ UserId = 100,
+ DepartmentId = 200,
+ DataScope = DataScopes.本部门,
+ AccessibleDepartmentIds = [200]
+ };
+ var entity = new DepartmentScopeTestEntity { DepartmentId = 999 };
+
+ // Act
+ var result = module.Valid(entity, DataMethod.Insert);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ [DisplayName("User_本部门滤DepartmentID_仅本人滤ID")]
+ public void User_Filter_Department_And_SelfOnly()
+ {
+ // 本部门:按部门过滤
+ DataScopeContext.Current = new DataScopeContext
+ {
+ UserId = 100,
+ DepartmentId = 200,
+ DataScope = DataScopes.本部门,
+ AccessibleDepartmentIds = [200]
+ };
+ var deptFilter = DataScopeHelper.GetFilter();
+ Assert.NotNull(deptFilter);
+ var deptSql = deptFilter!.ToString();
+ Assert.Contains("DepartmentID", deptSql);
+ Assert.Contains("200", deptSql);
+
+ // 仅本人:按用户主键 ID 过滤
+ DataScopeContext.Current = new DataScopeContext
+ {
+ UserId = 100,
+ DepartmentId = 200,
+ DataScope = DataScopes.仅本人
+ };
+ var selfFilter = DataScopeHelper.GetFilter();
+ Assert.NotNull(selfFilter);
+ var selfSql = selfFilter!.ToString();
+ Assert.DoesNotContain("DepartmentID", selfSql);
+ Assert.Contains("100", selfSql);
+ }
+
+ [Fact]
+ [DisplayName("Log_本部门仍按CreateUserID过滤")]
+ public void Log_Filter_Department_UsesCreateUserID()
+ {
+ // 日志表无部门列,本部门范围仍按创建用户过滤,不放大成同事数据
+ DataScopeContext.Current = new DataScopeContext
+ {
+ UserId = 100,
+ DepartmentId = 200,
+ DataScope = DataScopes.本部门,
+ AccessibleDepartmentIds = [200]
+ };
+ var filter = DataScopeHelper.GetFilter();
+ Assert.NotNull(filter);
+ var sql = filter!.ToString();
+ Assert.Contains("CreateUserID", sql);
+ Assert.Contains("100", sql);
+ }
+
+ [Fact]
+ [DisplayName("Department_仅本人滤当前部门ID而非-1")]
+ public void Department_Filter_SelfOnly_UsesCurrentDeptId()
+ {
+ // 部门表“一行一个部门”,仅本人退化为 ID=当前用户所在部门,而不是恒假条件 -1
+ DataScopeContext.Current = new DataScopeContext
+ {
+ UserId = 100,
+ DepartmentId = 200,
+ DataScope = DataScopes.仅本人
+ };
+ var filter = DataScopeHelper.GetFilter();
+ Assert.NotNull(filter);
+ var sql = filter!.ToString();
+ Assert.Contains("200", sql);
+ Assert.DoesNotContain("-1", sql);
+ }
+
+ [Fact]
+ [DisplayName("缓存_更换部门后可访问部门列表立即更新")]
+ public void Cache_DepartmentChange_UpdatesImmediately()
+ {
+ // Arrange
+ DataScopeContext.ClearCache();
+ var user = new MockUser { ID = 100, DepartmentID = 200 };
+ var role = new MockRole { ID = 1, IsSystem = false, DataScope = DataScopes.本部门 };
+ user.Role = role;
+ user.Roles = [role];
+
+ // Act 初次创建
+ var ctx1 = DataScopeContext.Create(user);
+ Assert.NotNull(ctx1);
+ Assert.Equal([200], ctx1!.AccessibleDepartmentIds);
+
+ // 调岗后再次创建,缓存键含部门编号,立即生效
+ user.DepartmentID = 300;
+ var ctx2 = DataScopeContext.Create(user);
+ Assert.NotNull(ctx2);
+ Assert.Equal([300], ctx2!.AccessibleDepartmentIds);
+ }
+ #endregion
}
#region 测试用实体类
From ca2f7072033b0cb402484e5a1e91a44a0d9e319f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:41:29 +0000
Subject: [PATCH 3/3] =?UTF-8?q?=E6=8C=89=E8=AF=84=E5=AE=A1=E6=84=8F?=
=?UTF-8?q?=E8=A7=81=E7=BB=9F=E4=B8=80=E9=83=A8=E9=97=A8=E7=A9=BA=E6=95=B0?=
=?UTF-8?q?=E7=BB=84=E8=AF=AD=E4=B9=89=E5=B9=B6=E8=AF=B4=E6=98=8E=E7=BC=93?=
=?UTF-8?q?=E5=AD=98=E5=A4=B1=E6=95=88=E4=B8=BA=E5=B0=BD=E5=8A=9B=E8=80=8C?=
=?UTF-8?q?=E4=B8=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: nnhy <506367+nnhy@users.noreply.github.com>
---
XCode/Membership/DataScopeContext.cs | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/XCode/Membership/DataScopeContext.cs b/XCode/Membership/DataScopeContext.cs
index 1e9b96e98..eb9043e9b 100644
--- a/XCode/Membership/DataScopeContext.cs
+++ b/XCode/Membership/DataScopeContext.cs
@@ -122,6 +122,7 @@ public static void ClearCache(Int32 userId = 0)
if (userId > 0)
{
// 清除该用户所有缓存键(同时兼容旧键 DataScope:{userId}:{scope} 与新键 DataScope:{userId}:{deptId}:{scope})
+ // 缓存失效为尽力而为:快照期间并发新增的键可能被漏删,但其值本身就是最新计算结果,漏删无害
var prefix = $"DataScope:{userId}:";
foreach (var key in _cache.Keys.ToArray())
{
@@ -380,24 +381,18 @@ public static Int32[] ParseDepartmentIds(String? departmentIds)
///
/// 适用于“一行一个部门”的表(如部门表,DepartmentId 映射到主键 ID)。
/// 仅本人时没有可访问部门列表,退化为“当前用户所在部门”,即 ID=当前用户.DepartmentID,
- /// 而不是恒假条件 Equal(-1) 导致空表。
+ /// 而不是恒假条件 Equal(-1) 导致空表。其余数据范围与普通部门过滤保持一致。
///
private static Expression? BuildDepartmentScopeFilter(DataScopeContext context, FieldItem? deptField)
{
if (deptField is null) return null;
- // 仅本人:部门表退化为“当前用户所在部门”
+ // 仅本人:部门表退化为“当前用户所在部门”,避免恒假条件导致空表
if (context.DataScope == DataScopes.仅本人)
return deptField.Equal(context.DepartmentId);
- var deptIds = context.AccessibleDepartmentIds;
- if (deptIds == null) return null; // null 表示不限制
-
- // 空数组时退化为当前用户所在部门,避免恒假条件导致空表
- if (deptIds.Length == 0) return deptField.Equal(context.DepartmentId);
- if (deptIds.Length == 1) return deptField.Equal(deptIds[0]);
-
- return deptField.In(deptIds);
+ // 其余范围(本部门/本部门及下级/自定义)与普通部门过滤语义一致,空数组仍为恒假条件
+ return BuildDepartmentFilter(context, deptField);
}
#endregion