Files
dsserver/plugin/admin/app/common/DataPermissionService.php
T
2026-04-03 16:21:28 +08:00

721 lines
23 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
namespace plugin\admin\app\common;
use InvalidArgumentException;
use plugin\admin\app\model\OpmMwPermissionRule;
use support\Db;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
/**
* 数据权限规则服务类
*
* 作用:
* 将数据库里配置的数据权限规则,动态应用到 Eloquent 查询构造器上。
*
* 规则配置示例:
* - table: opm_mw_info_data // 规则对应的主表
* - field: organ_id // 需要限制的字段
* - field_table: opm_mw_info_data // 字段属于哪个表,可选
* - admin_attr: hospitals // 当前管理员属性名
* - admin_attr_map: opm_mw_department.id...opm_mw_department.name
* - action: in
* - status: 1
* - sort: 1
*
* 说明:
* 1. 默认规则字段会拼到主表别名上;
* 2. 如果 field_table 配置了,就优先使用对应表别名;
* 3. admin_attr_map 当前按“同表映射”使用,即:
* 从 sourceField 查出 targetField 的值。
*/
class DataPermissionService
{
/**
* 当前登录管理员信息
*
* 例如:
* [
* 'id' => 1,
* 'name' => 'admin',
* 'hospitals' => '1,2,3'
* ]
*/
protected array $admin;
/**
* 构造函数
*
* @param array $admin 当前管理员信息
*/
public function __construct(array $admin)
{
$this->admin = $admin;
}
/**
* 将权限规则应用到查询构造器
*
* 逻辑:
* 1. 从查询中解析主表和别名;
* 2. 读取该表对应的权限规则;
* 3. 收集查询中所有表别名;
* 4. 逐条应用权限规则;
*
* @param QueryBuilder|EloquentBuilder $query 查询对象
* @return QueryBuilder|EloquentBuilder
*/
public function apply(QueryBuilder|EloquentBuilder $query): QueryBuilder|EloquentBuilder
{
// 统一拿 baseQuery(核心修复点)
if ($query instanceof EloquentBuilder) {
$baseQuery = $query->getQuery();
$tableRaw = $query->getModel()->getTable();
} else {
$baseQuery = $query;
$tableRaw = $query->from;
}
// 解析主表 & 别名
[$fromTable, $fromAlias] = $this->parseTableAlias((string)($tableRaw ?? ''));
if ($fromTable === '') {
return $query;
}
// 加载规则
$rules = $this->loadRules($fromTable);
if (empty($rules)) {
return $query;
}
// 获取所有表别名
$tableAliases = $this->getAllTableAliases($baseQuery);
// 应用规则
foreach ($rules as $rule) {
$query = $this->applySingleRule($query, $rule, $fromAlias, $tableAliases);
}
return $query;
}
// public function apply(EloquentBuilder $query): Builder
// {
// // 兼容传入 Builder 的情况;这里统一取底层 Query Builder
// $baseQuery = $query->getQuery();
//
// // 解析主表名和主表别名,例如:table as t => [table, t]
// [$fromTable, $fromAlias] = $this->parseTableAlias((string)($baseQuery->from ?? ''));
//
// // 如果无法识别主表,直接返回,不处理
// if ($fromTable === '') {
// return $query;
// }
//
// // 加载该主表对应的启用规则
// $rules = $this->loadRules($fromTable);
//
// // 没有规则则不做任何限制
// if (empty($rules)) {
// return $query;
// }
//
// // 获取查询里所有表的别名映射,后续用于拼字段
// $tableAliases = $this->getAllTableAliases($baseQuery);
//
// // 逐条应用规则
// foreach ($rules as $rule) {
// $query = $this->applySingleRule($query, $rule, $fromAlias, $tableAliases);
// }
//
// return $query;
// }
/**
* 加载指定表的启用权限规则
*
* 只读取:
* - table = $tableName
* - status = 1
* 并按照 sort 升序排列
*
* @param string $tableName 表名
* @return array
*/
protected function loadRules(string $tableName): array
{
$this->validateIdentifierPath($tableName, '表名');
return OpmMwPermissionRule::query()
->where('table', $tableName)
->where('status', 1)
->orderBy('sort', 'asc')
->get()
->toArray();
}
/**
* 应用单条权限规则
*
* 核心流程:
* 1. 读取 rule 配置;
* 2. 取出管理员对应属性;
* 3. 如为 * 则表示拥有全部权限,直接跳过;
* 4. 根据 admin_attr_map 解析映射值;
* 5. 得到完整字段名;
* 6. 根据 action 生成 where 条件。
*
* @param QueryBuilder|EloquentBuilder $query 查询对象
* @param array $rule 单条规则
* @param string $mainAlias 主表别名
* @param array $tableAliases 查询内所有表别名映射
* @return QueryBuilder|EloquentBuilder
*/
protected function applySingleRule(
QueryBuilder|EloquentBuilder $query,
array $rule,
string $mainAlias,
array $tableAliases
): QueryBuilder|EloquentBuilder
{
$field = trim((string)($rule['field'] ?? ''));
$adminAttr = trim((string)($rule['admin_attr'] ?? ''));
$adminAttrMap = trim((string)($rule['admin_attr_map'] ?? ''));
$action = strtolower(trim((string)($rule['action'] ?? 'in')));
$fieldTable = trim((string)($rule['field_table'] ?? ''));
// 规则字段或管理员属性为空,视为无效规则,直接跳过
if ($field === '' || $adminAttr === '') {
return $query;
}
// 校验字段写法是否合法
$this->validateFieldPath($field, '规则字段');
// 如果配置了字段所属表,也要校验
if ($fieldTable !== '') {
$this->validateIdentifierPath($fieldTable, '字段所属表');
}
// 取管理员对应属性值
$rawValue = $this->admin[$adminAttr] ?? '';
// * 表示不受限制
if ($rawValue === '*') {
return $query;
}
// 解析管理员属性值,必要时通过映射转换成目标值
$resolvedValues = $this->resolveAdminAttrValue($rawValue, $adminAttrMap);
// 如果最终解析不到任何可用值,则直接返回空结果
if (empty($resolvedValues) && !in_array('0', $resolvedValues, true)) {
return $query->whereRaw('1 = 0');
}
// 获取规则字段的完整字段名,如 dept.id / t.organ_id
$fullField = $this->getFullFieldName($field, $mainAlias, $tableAliases, $fieldTable);
// in / not in 这类操作符需要数组值
if ($this->isArrayOperator($action)) {
return $this->buildCondition($query, $fullField, $action, $resolvedValues);
}
// 单值操作符只能对应一个结果,否则说明配置有问题
if (count($resolvedValues) !== 1) {
throw new InvalidArgumentException(
"规则配置错误:操作符 {$action} 只能对应单个值,但当前解析出 " . count($resolvedValues) . " 个值"
);
}
return $this->buildCondition($query, $fullField, $action, $resolvedValues[0]);
}
// protected function applySingleRule(
// Builder $query,
// array $rule,
// string $mainAlias,
// array $tableAliases
// ): Builder
// {
// $field = trim((string)($rule['field'] ?? ''));
// $adminAttr = trim((string)($rule['admin_attr'] ?? ''));
// $adminAttrMap = trim((string)($rule['admin_attr_map'] ?? ''));
// $action = strtolower(trim((string)($rule['action'] ?? 'in')));
// $fieldTable = trim((string)($rule['field_table'] ?? ''));
//
// // 规则字段或管理员属性为空,视为无效规则,直接跳过
// if ($field === '' || $adminAttr === '') {
// return $query;
// }
//
// // 校验字段写法是否合法
// $this->validateFieldPath($field, '规则字段');
//
// // 如果配置了字段所属表,也要校验
// if ($fieldTable !== '') {
// $this->validateIdentifierPath($fieldTable, '字段所属表');
// }
//
// // 取管理员对应属性值
// $rawValue = $this->admin[$adminAttr] ?? '';
//
// // * 表示不受限制
// if ($rawValue === '*') {
// return $query;
// }
//
// // 解析管理员属性值,必要时通过映射转换成目标值
// $resolvedValues = $this->resolveAdminAttrValue($rawValue, $adminAttrMap);
//
// // 如果最终解析不到任何可用值,则直接返回空结果
// if (empty($resolvedValues) && !in_array('0', $resolvedValues, true)) {
// return $query->whereRaw('1 = 0');
// }
//
// // 获取规则字段的完整字段名,如 dept.id / t.organ_id
// $fullField = $this->getFullFieldName($field, $mainAlias, $tableAliases, $fieldTable);
//
// // in / not in 这类操作符需要数组值
// if ($this->isArrayOperator($action)) {
// return $this->buildCondition($query, $fullField, $action, $resolvedValues);
// }
//
// // 单值操作符只能对应一个结果,否则说明配置有问题
// if (count($resolvedValues) !== 1) {
// throw new InvalidArgumentException(
// "规则配置错误:操作符 {$action} 只能对应单个值,但当前解析出 " . count($resolvedValues) . " 个值"
// );
// }
//
// return $this->buildCondition($query, $fullField, $action, $resolvedValues[0]);
// }
/**
* 解析管理员属性值
*
* 支持:
* - 字符串:1,2,3 / 123 / 1 2 3
* - 数组:['1', '2']
*
* admin_attr_map 格式:
* - source_table.source_field...target_table.target_field
*
* 示例:
* - opm_mw_department.id...opm_mw_department.name
*
* 说明:
* - 当前实现按照“同表映射”使用 sourceField -> targetField
* - sourceTable 主要用于配置语义和校验
*
* @param mixed $rawValue 管理员原始属性值
* @param string $mapStr 映射配置字符串
* @return array
*/
protected function resolveAdminAttrValue($rawValue, string $mapStr): array
{
// 先把原始值拆成列表
$values = $this->parseListValues($rawValue);
// 没有任何值,直接返回空数组
if (empty($values)) {
return [];
}
// 没有映射配置,则直接返回原始值列表
if ($mapStr === '') {
return $values;
}
// 映射格式必须包含 ...
if (!str_contains($mapStr, '...')) {
throw new InvalidArgumentException("无效的 admin_attr_map 格式:{$mapStr}");
}
[$sourcePart, $targetPart] = explode('...', $mapStr, 2);
$sourcePart = trim($sourcePart);
$targetPart = trim($targetPart);
if ($sourcePart === '' || $targetPart === '') {
throw new InvalidArgumentException("无效的 admin_attr_map 格式:{$mapStr}");
}
if (!str_contains($sourcePart, '.') || !str_contains($targetPart, '.')) {
throw new InvalidArgumentException("admin_attr_map 必须包含表名与字段名:{$mapStr}");
}
[$sourceTable, $sourceField] = explode('.', $sourcePart, 2);
[$targetTable, $targetField] = explode('.', $targetPart, 2);
$sourceTable = trim($sourceTable);
$sourceField = trim($sourceField);
$targetTable = trim($targetTable);
$targetField = trim($targetField);
// 校验表名和字段名合法性,防止非法 SQL 标识符
$this->validateIdentifierPath($sourceTable, '映射源表');
$this->validateIdentifierPath($sourceField, '映射源字段');
$this->validateIdentifierPath($targetTable, '映射目标表');
$this->validateIdentifierPath($targetField, '映射目标字段');
if ($sourceTable !== $targetTable) {
// 当前版本先按同表映射处理,避免复杂 join 映射带来歧义
// 如果后面要扩展跨表映射,可以在这里补 join / 子查询 方案
// admin_attr_map 本身是将 admin.sourceField 进行了一个跨表
// 如果 admin_attr_map 内部再次跨表逻辑会更加混乱
}
// 通过 sourceField 找到对应 targetField 的值
$results = Db::table($targetTable)
->whereIn($sourceField, $values)
->pluck($targetField)
->toArray();
// 统一转成字符串,去除空值
$results = array_map(static fn($v) => trim((string)$v), $results);
$results = array_filter($results, static fn($v) => $v !== '');
// 去重并重新排序
return array_values(array_unique($results));
}
/**
* 构建 WHERE 条件
*
* 支持两类:
* 1. 数组型条件:in / not in
* 2. 单值条件:= / > / < / like / is null 等
*
* @param QueryBuilder|EloquentBuilder $query 查询对象
* @param string $field 完整字段名
* @param string $action 操作符
* @param mixed $value 条件值
* @return QueryBuilder|EloquentBuilder
*/
protected function buildCondition(QueryBuilder|EloquentBuilder $query, string $field, string $action, $value): QueryBuilder|EloquentBuilder
{
// 数组条件
if (is_array($value)) {
return match ($action) {
'in' => $query->whereIn($field, $value),
'not in', '<>in' => $query->whereNotIn($field, $value),
default => throw new InvalidArgumentException("不支持的数组操作符:{$action}"),
};
}
// 单值条件
return match ($action) {
'=', 'eq' => $query->where($field, '=', $value),
'>', 'gt' => $query->where($field, '>', $value),
'<', 'lt' => $query->where($field, '<', $value),
'>=', 'gte' => $query->where($field, '>=', $value),
'<=', 'lte' => $query->where($field, '<=', $value),
'<>', '!=', 'ne' => $query->where($field, '<>', $value),
'like' => $query->where($field, 'like', $this->normalizeLikeValue((string)$value)),
'not like' => $query->where($field, 'not like', $this->normalizeLikeValue((string)$value)),
'is null' => $query->whereNull($field),
'is not null' => $query->whereNotNull($field),
default => throw new InvalidArgumentException("不支持的操作符:{$action}"),
};
}
// protected function buildCondition(Builder $query, string $field, string $action, $value): Builder
// {
// // 数组条件
// if (is_array($value)) {
// return match ($action) {
// 'in' => $query->whereIn($field, $value),
// 'not in', '<>in' => $query->whereNotIn($field, $value),
// default => throw new InvalidArgumentException("不支持的数组操作符:{$action}"),
// };
// }
//
// // 单值条件
// return match ($action) {
// '=', 'eq' => $query->where($field, '=', $value),
// '>', 'gt' => $query->where($field, '>', $value),
// '<', 'lt' => $query->where($field, '<', $value),
// '>=', 'gte' => $query->where($field, '>=', $value),
// '<=', 'lte' => $query->where($field, '<=', $value),
// '<>', '!=', 'ne' => $query->where($field, '<>', $value),
// 'like' => $query->where($field, 'like', $this->normalizeLikeValue((string)$value)),
// 'not like' => $query->where($field, 'not like', $this->normalizeLikeValue((string)$value)),
// 'is null' => $query->whereNull($field),
// 'is not null' => $query->whereNotNull($field),
// default => throw new InvalidArgumentException("不支持的操作符:{$action}"),
// };
// }
/**
* 获取完整字段名
*
* 优先级:
* 1. 字段已带表名:直接返回
* 2. 配置了 field_table:使用对应表别名
* 3. 默认使用主表别名
*
* @param string $field 字段名
* @param string $mainAlias 主表别名
* @param array $tableAliases 查询中的表别名映射
* @param string $fieldTable 字段所属表
* @return string
*/
protected function getFullFieldName(string $field, string $mainAlias, array $tableAliases, string $fieldTable = ''): string
{
// 如果 field 已经是 table.field 格式,直接返回
if (str_contains($field, '.')) {
$this->validateFieldPath($field, '规则字段');
return $field;
}
// 默认使用主表别名
$alias = $mainAlias;
// 如果规则里指定了字段所属表,则按该表找别名
if ($fieldTable !== '') {
$alias = $this->resolveTableAlias($fieldTable, $tableAliases, $mainAlias);
}
if ($alias === '') {
$alias = $mainAlias;
}
// 校验别名是否合法
$this->validateIdentifierPath($alias, '表别名');
return "{$alias}.{$field}";
}
/**
* 获取查询中所有表的别名映射
*
* 返回格式:
* [
* 'opm_mw_info_data' => 'data',
* 'data' => 'data',
* 'opm_mw_department' => 'dept',
* 'dept' => 'dept',
* ]
*
* @param mixed $baseQuery 基础查询对象
* @return array
*/
protected function getAllTableAliases($baseQuery): array
{
$aliases = [];
// 主表别名
[$fromTable, $fromAlias] = $this->parseTableAlias((string)($baseQuery->from ?? ''));
if ($fromTable !== '') {
$aliases[$fromTable] = $fromAlias;
$aliases[$fromAlias] = $fromAlias;
}
// 关联表别名
foreach (($baseQuery->joins ?? []) as $join) {
[$joinTable, $joinAlias] = $this->parseTableAlias((string)($join->table ?? ''));
if ($joinTable !== '') {
$aliases[$joinTable] = $joinAlias;
$aliases[$joinAlias] = $joinAlias;
}
}
return $aliases;
}
/**
* 解析表名和别名
*
* 支持:
* - table
* - table as t
* - table t
*
* @param string $table 表表达式
* @return array [tableName, alias]
*/
protected function parseTableAlias(string $table): array
{
// 去掉反引号并去首尾空格
$table = trim(str_replace('`', '', $table));
if ($table === '') {
return ['', ''];
}
// 子查询或复杂表达式不处理
if (str_starts_with($table, '(')) {
return ['', ''];
}
// 处理 as 写法
if (stripos($table, ' as ') !== false) {
[$name, $alias] = preg_split('/\s+as\s+/i', $table, 2);
$name = trim((string)$name);
$alias = trim((string)$alias);
return [$name, $alias !== '' ? $alias : $name];
}
// 处理 table alias 写法
$parts = preg_split('/\s+/', $table) ?: [];
if (count($parts) >= 2) {
return [trim($parts[0]), trim($parts[1])];
}
// 没有别名时,默认表名即别名
return [$table, $table];
}
/**
* 解析列表值
*
* 支持:
* - 数组
* - 字符串:1,2,3 / 123 / 1 2 3
*
* 返回值统一为字符串数组,避免把非数字内容强制转 int 后出错
*
* @param mixed $raw 原始值
* @return array
*/
protected function parseListValues($raw): array
{
if (is_array($raw)) {
$values = [];
array_walk_recursive($raw, static function ($item) use (&$values) {
$values[] = trim((string)$item);
});
} else {
$raw = trim((string)$raw);
// 空值或 * 视为无值
if ($raw === '' || $raw === '*') {
return [];
}
// 支持英文逗号、中文逗号、空格分隔
$values = preg_split('/[,\s]+/u', $raw) ?: [];
$values = array_map(static fn($v) => trim((string)$v), $values);
}
// 清理空项
$values = array_filter($values, static fn($v) => $v !== '');
// 去重并重建索引
return array_values(array_unique($values));
}
/**
* 判断是否为数组型操作符
*
* @param string $action 操作符
* @return bool
*/
protected function isArrayOperator(string $action): bool
{
return in_array($action, ['in', 'not in', '<>in'], true);
}
/**
* like 值自动补 %
*
* 规则:
* - 如果用户已经写了 %,则保持原样
* - 如果没有写 %,则默认转为 %xxx%
*
* @param string $value 原始值
* @return string
*/
protected function normalizeLikeValue(string $value): string
{
if ($value === '') {
return $value;
}
if (!str_contains($value, '%')) {
return '%' . $value . '%';
}
return $value;
}
/**
* 解析表名或别名到实际别名
*
* @param string $tableOrAlias 表名或别名
* @param array $tableAliases 别名映射
* @param string $mainAlias 主表别名
* @return string
*/
protected function resolveTableAlias(string $tableOrAlias, array $tableAliases, string $mainAlias): string
{
$tableOrAlias = trim($tableOrAlias);
if ($tableOrAlias === '') {
return $mainAlias;
}
if (isset($tableAliases[$tableOrAlias])) {
return $tableAliases[$tableOrAlias];
}
return $tableOrAlias;
}
/**
* 校验字段路径
*
* 支持:
* - field
* - table.field
*
* @param string $field 字段路径
* @param string $label 错误提示标签
* @return void
*/
protected function validateFieldPath(string $field, string $label = '字段'): void
{
$field = trim($field);
if ($field === '') {
throw new InvalidArgumentException("{$label}不能为空");
}
$parts = explode('.', $field);
foreach ($parts as $part) {
$this->validateIdentifierPath($part, $label);
}
}
/**
* 校验单个标识符
*
* 仅允许:
* - 字母
* - 数字
* - 下划线
*
* 且不能以数字开头。
*
* @param string $value 标识符
* @param string $label 错误提示标签
* @return void
*/
protected function validateIdentifierPath(string $value, string $label = '标识符'): void
{
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException("{$label}不能为空");
}
// 仅允许字母、数字、下划线,且首字符不能为数字
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $value)) {
throw new InvalidArgumentException("{$label}不合法:{$value}");
}
}
}