diff --git a/XCode/Membership/DataScopeContext.cs b/XCode/Membership/DataScopeContext.cs
index 54a831902..eb9043e9b 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,12 @@ 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 +313,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 +324,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 +376,24 @@ 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);
+
+ // 其余范围(本部门/本部门及下级/自定义)与普通部门过滤语义一致,空数组仍为恒假条件
+ return BuildDepartmentFilter(context, deptField);
+ }
#endregion
#region 权限校验
@@ -431,6 +454,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 测试用实体类