Files
xuetang/server/app/common/service/DataScope/DataScopeService.php
T
2026-09-08 11:40:15 +08:00

267 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service\DataScope;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\auth\AdminRole;
use app\common\model\auth\SystemRole;
use think\facade\Config;
/**
* 数据范围(数据隔离)工具服务。
*
* 设计约定:
* - ALL (1) = 全部数据,不附加过滤
* - DEPT_AND_CHILD (2) = 本部门及所有子部门(取 admin 全部部门的并集)
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
* - SELF (4) = 仅本人
*
* 多角色时,2/3/4 范围按授权并集取最宽范围;迁移期遗留的普通 scope=1 角色不会
* 自动冲掉有限范围。只有明确的管理员角色、root 或 exempt_roles 才能在组合角色中放开全部。
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
*
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 nullALL = 不过滤)。
*/
class DataScopeService
{
public const SCOPE_ALL = 1;
public const SCOPE_DEPT_AND_CHILD = 2;
public const SCOPE_DEPT = 3;
public const SCOPE_SELF = 4;
/** @var string[] 明确允许在多角色组合中放开全部数据的内置角色名。 */
private const ALL_SCOPE_ROLE_NAMES = ['管理员', '系统管理员'];
/**
* 计算当前 admin 的有效数据范围。
*/
public static function getEffectiveScope(array $adminInfo): int
{
if (!self::isEnabled()) {
return self::SCOPE_ALL;
}
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return self::SCOPE_ALL;
}
$roleIds = self::normalizeRoleIds($adminInfo['role_id'] ?? null);
$exempt = array_map('intval', Config::get('project.data_scope.exempt_roles', []) ?: []);
if ($roleIds !== [] && array_intersect($roleIds, $exempt) !== []) {
return self::SCOPE_ALL;
}
if ($roleIds === []) {
return self::SCOPE_SELF;
}
$roleRows = SystemRole::whereIn('id', $roleIds)
->whereNull('delete_time')
->field('id,name,data_scope')
->select()
->toArray();
return self::mergeRoleScopes($roleRows);
}
/**
* 合并多个角色的数据范围。
*
* 历史迁移曾把全部旧角色默认成 scope=1;如果账号同时具有有限范围角色,普通的
* scope=1 视为功能角色而不参与放大,防止“医生 + 医助”等组合意外获得全站数据。
* 2/3/4 是嵌套授权,取最小值即可表达多个有效数据角色的可见范围并集。
*
* @param array<int,array<string,mixed>> $roleRows
*/
private static function mergeRoleScopes(array $roleRows): int
{
$validRows = [];
foreach ($roleRows as $roleRow) {
$scope = (int) ($roleRow['data_scope'] ?? 0);
if ($scope < self::SCOPE_ALL || $scope > self::SCOPE_SELF) {
continue;
}
$validRows[] = [
'name' => trim((string) ($roleRow['name'] ?? '')),
'scope' => $scope,
];
}
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到本人。
if ($validRows === []) {
return self::SCOPE_SELF;
}
foreach ($validRows as $roleRow) {
if ($roleRow['scope'] === self::SCOPE_ALL
&& in_array($roleRow['name'], self::ALL_SCOPE_ROLE_NAMES, true)) {
return self::SCOPE_ALL;
}
}
$boundedScopes = array_column(array_values(array_filter(
$validRows,
static fn (array $roleRow): bool => $roleRow['scope'] > self::SCOPE_ALL
)), 'scope');
if ($boundedScopes !== []) {
return (int) min($boundedScopes);
}
// 只有普通 scope=1 角色时保持原有“全部数据”行为。
return self::SCOPE_ALL;
}
/**
* 统一解析 token/cache 中的 role_id(数组 | 单整数 | JSON 字符串)。
*
* @return int[]
*/
private static function normalizeRoleIds(mixed $raw): array
{
if ($raw === null || $raw === '') {
return [];
}
if (\is_int($raw) || \is_float($raw)) {
$v = (int) $raw;
return $v > 0 ? [$v] : [];
}
if (\is_string($raw) && is_numeric($raw)) {
$v = (int) $raw;
return $v > 0 ? [$v] : [];
}
if (\is_string($raw)) {
$decoded = json_decode($raw, true);
if (\is_array($decoded)) {
$raw = $decoded;
} else {
return [];
}
}
if (!\is_array($raw)) {
return [];
}
return array_values(array_filter(array_map(
static fn ($v): int => (int) $v,
$raw
), static fn (int $v): bool => $v > 0));
}
/**
* 可见 admin id 集合;null = 不过滤(ALL
*
* @return array<int>|null
*/
public static function getVisibleAdminIds(int $adminId, array $adminInfo): ?array
{
$scope = self::getEffectiveScope($adminInfo);
if ($scope === self::SCOPE_ALL) {
return null;
}
if ($scope === self::SCOPE_SELF) {
return $adminId > 0 ? [$adminId] : [];
}
$myDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
$myDeptIds = array_values(array_filter(array_map('intval', $myDeptIds), static function (int $v): bool {
return $v > 0;
}));
if ($myDeptIds === []) {
$fallback = (bool) Config::get('project.data_scope.no_dept_fallback_self', true);
return $fallback ? [$adminId] : [];
}
$targetDeptIds = [];
if ($scope === self::SCOPE_DEPT) {
$targetDeptIds = $myDeptIds;
} else {
foreach ($myDeptIds as $did) {
foreach (DeptLogic::getSelfAndDescendantIds($did) as $id) {
$id = (int) $id;
if ($id > 0) {
$targetDeptIds[$id] = true;
}
}
}
$targetDeptIds = array_keys($targetDeptIds);
}
if ($targetDeptIds === []) {
return $adminId > 0 ? [$adminId] : [];
}
$ids = AdminDept::whereIn('dept_id', $targetDeptIds)->column('admin_id');
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $v): bool {
return $v > 0;
})));
if ($adminId > 0 && !in_array($adminId, $ids, true)) {
$ids[] = $adminId;
}
return $ids;
}
public static function isEnabled(): bool
{
return (bool) Config::get('project.data_scope.enabled', true);
}
public static function isAll(array $adminInfo): bool
{
return self::getEffectiveScope($adminInfo) === self::SCOPE_ALL;
}
/**
* 数据范围下:可见成员所在部门及其下级部门 id(与业绩看板 deptOptions、部门类下拉收窄一致)。
*
* @return array<int, true>|null null 表示不限制;[] 表示无可选部门
*/
public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
{
if ($adminId <= 0 || !self::isEnabled()) {
return null;
}
$visibleIds = self::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === null) {
return null;
}
if ($visibleIds === []) {
return [];
}
$set = [];
foreach ($visibleIds as $aid) {
$deptRows = AdminDept::where('admin_id', (int) $aid)->column('dept_id');
foreach ($deptRows as $d) {
$d = (int) $d;
if ($d <= 0) {
continue;
}
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
$x = (int) $x;
if ($x > 0) {
$set[$x] = true;
}
}
}
}
return $set;
}
/**
* 文字描述(日志 / 接口返回可选使用)
*/
public static function scopeLabel(int $scope): string
{
return [
self::SCOPE_ALL => '全部',
self::SCOPE_DEPT_AND_CHILD => '本部门及下级',
self::SCOPE_DEPT => '仅本部门',
self::SCOPE_SELF => '仅本人',
][$scope] ?? '全部';
}
}