This commit is contained in:
zimoyin
2026-04-03 15:34:04 +08:00
parent 1a84e92384
commit 673c83109f
5 changed files with 894 additions and 247 deletions
@@ -0,0 +1,598 @@
<?php
namespace plugin\admin\app\common;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;
use plugin\admin\app\model\OpmMwPermissionRule;
use support\Db;
/**
* 数据权限规则服务类
*
* 作用:
* 将数据库里配置的数据权限规则,动态应用到 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 Builder|string $query 查询对象
* @return Builder
*/
public function apply(Builder|string $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 Builder $query 查询对象
* @param array $rule 单条规则
* @param string $mainAlias 主表别名
* @param array $tableAliases 查询内所有表别名映射
* @return Builder
*/
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 Builder $query 查询对象
* @param string $field 完整字段名
* @param string $action 操作符
* @param mixed $value 条件值
* @return Builder
*/
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}");
}
}
}