初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\AdminRoleDao;
use core\base\BaseService;
use core\exception\handler\AdminException;
use support\Container;
/**
* @author Mr.April
* @since 1.0
* @method query()
*/
class AdminRoleService extends BaseService
{
public function __construct(AdminRoleDao $dao)
{
$this->dao = $dao;
}
/**
* 保存用户角色关联
*
* @param array $data 包含user_id和role_id_list的数组
*
* @throws AdminException
*/
public function save(array $data): void
{
try {
$this->transaction(function () use ($data) {
$userId = $data['id'] ?? 0;
$newRoleIds = $data['role_id_list'] ?? [];
if ($userId <= 0) {
throw new AdminException("用户ID不能为空");
}
// 1. 获取用户现有角色ID
$oldRoleIds = array_column(
$this->query()->where('admin_id', $userId)->get()->toArray(),
'role_id'
);
// 2. 计算需要删除和新增的角色ID(差集计算)
$rolesToDelete = array_diff($oldRoleIds, $newRoleIds);
$rolesToAdd = array_diff($newRoleIds, $oldRoleIds);
// 3. 批量清理旧角色关联
if (!empty($rolesToDelete)) {
// 中间表批量删除
$this->dao->getModel()
->where('admin_id', $userId)
->whereIn('role_id', $rolesToDelete)
->delete();
}
// 4. 批量添加新角色关联
if (!empty($rolesToAdd)) {
// 中间表批量插入
$insertData = array_map(function ($roleId) use ($userId) {
return ['admin_id' => $userId, 'role_id' => $roleId];
}, $rolesToAdd);
$this->dao->getModel()->insert($insertData);
}
// 5. 清理用户权限缓存
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
$currentUser->clearCache($userId);
});
} catch (\Throwable $e) {
throw new AdminException("角色授权失败:{$e->getMessage()}");
}
}
/**
* 移除用户-关联角色
*
* @param array $data
*
* @throws \core\exception\handler\AdminException
*/
public function removeUserRole(array $data)
{
try {
$this->transaction(function () use ($data) {
$userIds = [];
foreach ($data as $item) {
$this->dao->getModel()->where($item)->delete();
$userIds[] = $item['admin_id'];
}
// 清理相关用户的权限缓存
$userIds = array_unique($userIds);
if (!empty($userIds)) {
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
foreach ($userIds as $userId) {
$currentUser->clearCache($userId);
}
}
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 保存用户-关联角色
*
* @param array $data
*
* @return void
* @throws \core\exception\handler\AdminException
*/
public function saveUserRoles(array $data): void
{
try {
$this->transaction(function () use ($data) {
$this->dao->saveAll($data);
// 清理相关用户的权限缓存
$userIds = array_unique(array_column($data, 'admin_id'));
if (!empty($userIds)) {
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
foreach ($userIds as $userId) {
$currentUser->clearCache($userId);
}
}
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 通过角色ID获取用户ID列表
*
* @param int|string $roleId
* @return array
*/
public function getUserIdsByRoleId(int|string $roleId): array
{
return $this->dao->getModel()
->where('role_id', $roleId)
->pluck('admin_id')
->toArray();
}
}
@@ -0,0 +1,723 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\adminapi\event\LoginLogEvent;
use app\dao\system\AdminDao;
use app\enum\system\PolicyPrefix;
use app\model\system\Admin;
use app\scope\global\AccessPermissionScope;
use app\service\admin\logs\LoginLogService;
use core\base\BaseService;
use core\cache\CacheService;
use core\exception\handler\AdminException;
use core\jwt\JwtToken;
use core\tool\RSAService;
use support\Container;
use Webman\Event\Event;
/**
* @method getAdminInfo(string $username)
* @method getAdminById($uid, $withoutScopes = null)
* @method getList(mixed $where, mixed $field, mixed $page, mixed $limit, mixed $order, array $array, false $false)
* @method getUsersListByRoleId(mixed $where, mixed $field, mixed $page, mixed $limit)
* @method getAdminByName(string $username, array|null $withoutScopes)
*/
class AdminService extends BaseService
{
public function __construct(AdminDao $dao)
{
$this->dao = $dao;
}
/**
* save
*
* @param array $data
*
* @return Admin|null
* @throws \core\exception\handler\AdminException
*/
public function save(array $data): Admin|null
{
try {
return $this->transaction(function () use ($data) {
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
$roles = $this->normalizeRelationIds($data['role_id_list'] ?? []);
$posts = $this->normalizeRelationIds($data['post_id_list'] ?? []);
$depts = $this->normalizeRelationIds($data['dept_id_list'] ?? []);
$mainDeptId = $data['main_dept_id'] ?? null;
$mainPosId = $data['main_post_id'] ?? null;
unset($data['role_id_list'], $data['post_id_list'], $data['dept_id_list'], $data['main_dept_id'], $data['main_post_id']);
$model = $this->dao->save($data);
$this->updateModel($model, $data, $depts, $posts, $roles);
$this->syncRoles($model, $roles);
$this->syncMainInfo($model, $mainDeptId, $mainPosId);
return $model;
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 编辑
*
* @param int|string $id
* @param array $data
*
* @return \app\model\system\Admin|null
* @throws \core\exception\handler\AdminException
*/
public function update(int|string $id, array $data): ?Admin
{
try {
return $this->transaction(function () use ($id, $data) {
$this->updatePasswordIfNeeded($data);
$roles = $this->normalizeRelationIds($data['role_id_list'] ?? []);
$posts = $this->normalizeRelationIds($data['post_id_list'] ?? []);
$depts = $this->normalizeRelationIds($data['dept_id_list'] ?? []);
$mainDeptId = $data['main_dept_id'] ?? null;
$mainPosId = $data['main_post_id'] ?? null;
unset($data['role_id_list'], $data['post_id_list'], $data['dept_id_list'], $data['main_dept_id'], $data['main_post_id']);
$model = $this->dao->getModel()
->findOrFail($id);
$this->updateModel($model, $data, $depts, $posts, $roles);
$this->syncRoles($model, $roles);
$this->syncMainInfo($model, $mainDeptId, $mainPosId);
return $model;
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
private function normalizeRelationIds(mixed $value): array
{
if (is_array($value)) {
return array_values(array_filter($value, static fn ($item) => $item !== null && $item !== ''));
}
if (is_string($value)) {
return array_values(array_filter(array_map('trim', explode(',', $value)), static fn ($item) => $item !== ''));
}
if ($value === null || $value === '') {
return [];
}
return [$value];
}
/**
* 密码处理
*
* @param array $data
*/
private function updatePasswordIfNeeded(array &$data): void
{
if (isset($data['password'])) {
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
}
}
/**
* 更新-部门职位关系
*
* @param \app\model\system\Admin $model
* @param array $data
* @param array $depts
* @param array $posts
* @param array $roles
*/
private function updateModel(Admin $model, array $data, array $depts, array $posts, array $roles): void
{
$model->fill($data);
$model->save();
$model->depts()->sync($depts);
$model->posts()->sync($posts);
$model->roles()->sync($roles);
}
/**
* 同步-角色
*
* @param \app\model\system\Admin $model
* @param array $roles
*/
private function syncRoles(Admin $model, array $roles): void
{
$model->roles()->sync($roles);
}
private function syncMainInfo(Admin $model, ?string $mainDeptId, ?string $mainPosId): void
{
$mainInfo = \app\model\system\AdminMain::updateOrCreate(
['admin_id' => (string)$model->id],
[
'main_dept_id' => $mainDeptId,
'main_post_id' => $mainPosId,
]
);
}
/**
* destroy
*
* @param $id
* @param $force
*
* @return mixed
* @throws \Exception
*/
public function destroy($id, $force): mixed
{
$ret = $this->dao->count([['id', 'in', $id], ['is_super', '=', 1]]);
if ($ret > 0) {
throw new AdminException('系统内置用户,不允许删除');
}
return $this->dao->destroy($id);
}
/**
* 用户-冻结
*
* @param array|string $id
*/
public function locked(array|string $id): void
{
try {
if (is_string($id)) {
$id = array_map('trim', explode(',', $id));
}
$ret = $this->dao->count([['id', 'in', $id], ['is_super', '=', 1]]);
if ($ret > 0) {
throw new AdminException('系统内置用户,不允许冻结');
}
$this->dao->batchUpdate($id, ['is_locked' => 1]);
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 用户-解除冻结
*
* @param array|string $id
*/
public function unLocked(array|string $id): void
{
try {
if (is_string($id)) {
$id = array_map('trim', explode(',', $id));
}
$this->dao->batchUpdate($id, ['is_locked' => 0]);
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 用户登录
*
* @param string $username
* @param string $password
* @param string $type
* @param string $grantType
* @param array $params
*
* @return array
* @throws \core\exception\handler\AdminException
*/
public function login(string $username, string $password = '', string $type = 'admin', string $grantType = 'default', array $params = []): array
{
$adminInfo = $this->getAdminByName($username, [AccessPermissionScope::class]);
$this->validateAdminStatus($adminInfo);
$decryptedPassword = $this->validateRsaKeys($params['key_id'], $password);
// $decryptedPassword= $password;//临时注释关闭RSA验证要不无法调试
$this->validatePassword($adminInfo, $decryptedPassword, $grantType);
[$userInfo, $token] = $this->generateTokenData($adminInfo, $type);
$this->emitLoginSuccessEvent(array_merge($userInfo, $token), $tenant?->id ?? null);
return $token ?? [];
}
/**
* 第三方应用登录
*
* @param mixed $acctId
* @param mixed $appId
* @param mixed $appSecret
* @param mixed $userName
* @param string $type
*
* @return array
* @throws \core\exception\handler\AdminException
*/
public function thirdPartyLogin(mixed $acctId, mixed $appId, mixed $appSecret, mixed $userName, string $type = 'admin'): array
{
$adminInfo = $this->getAdminByName($userName);
$this->validateAdminStatus($adminInfo);
$this->validateThirdPartyApp($acctId, $appId, $appSecret);
[$userInfo, $token] = $this->generateTokenData($adminInfo, $type);
$this->emitLoginSuccessEvent(array_merge($userInfo, $token), $tenant?->id ?? null);
return $token ?? [];
}
private function validateThirdPartyApp(mixed $acctId, mixed $appId, mixed $appSecret)
{
if ($acctId !== config('app.acct_id')) {
throw new AdminException('第三方应用不存在或已删除');
}
if ($appId !== config('app.app_id')) {
throw new AdminException('第三方应用ID错误');
}
if ($appSecret !== config('app.app_secret')) {
throw new AdminException('第三方应用ID或密钥错误');
}
}
/**
* 验证管理员状态
*
* @param \app\model\system\Admin|null $adminInfo
*
* @throws \core\exception\handler\AdminException
*/
private function validateAdminStatus(?Admin $adminInfo): void
{
if (!$adminInfo) {
throw new AdminException('账号或密码错误,请重新输入!');
}
if ($adminInfo->enabled === 0) {
throw new AdminException('您已被禁止登录!');
}
if ($adminInfo->is_locked === 1) {
throw new AdminException('您的账号已被锁定,禁止登录!');
}
}
/**
* 验证密码
*
* @param \app\model\system\Admin $adminInfo
* @param string $password
* @param string $grantType
*
* @throws \core\exception\handler\AdminException
*/
private function validatePassword(Admin $adminInfo, string $password, string $grantType): void
{
if (!in_array($grantType, ['sms', 'refresh_token']) && !password_verify($password, $adminInfo->password)) {
$msg = '账号或密码错误,请重新输入!';
$this->emitLoginFailedEvent($adminInfo->toArray(), $msg);
throw new AdminException($msg);
}
}
/**
* token生成
*
* @param \app\model\system\Admin $adminInfo
* @param string $type
*
* @return array
*/
private function generateTokenData(Admin $adminInfo, string $type): array
{
$loginIp = request()->getRealIp();
$userAgent = request()->header('user-agent', '');
$browser = $this->getBrowser($userAgent);
$os = $this->getOs($userAgent);
$ipLocation = $this->getIpLocation($loginIp);
$adminInfo->login_time = time();
$adminInfo->login_ip = $loginIp;
$adminInfo->save();
$userInfo = $adminInfo->makeHidden([
'password',
'backend_setting',
'created_by',
'updated_by',
'created_at',
'deleted_at',
'remark',
'created_date',
'updated_date',
])->toArray();
// 追加扩展数据到 userInfo(用于 JWT extra)
$userInfo['ip'] = $loginIp;
$userInfo['ip_location'] = $ipLocation;
$userInfo['browser'] = $browser;
$userInfo['os'] = $os;
// 使用新的 JwtToken 生成 token
$jwt = new JwtToken();
$tokenObj = $jwt->generate((string)$adminInfo->id, $type, $userInfo);
$token = [
'access_token' => $tokenObj->accessToken,
'refresh_token' => $tokenObj->refreshToken,
'expires_in' => $tokenObj->expiresIn,
'client_id' => $this->generateUniqueId($loginIp),
'expires_time' => time() + $tokenObj->expiresIn
];
return [$userInfo, $token];
}
/**
* 登录成功-事件发起
*
* @param array $tokenData
* @param int|null $tenantId
*/
private function emitLoginSuccessEvent(array $tokenData, ?int $tenantId = null): void
{
$loginIp = request()->getRealIp();
$event = new LoginLogEvent(
'登录成功',
request()->app,
$loginIp,
$this->getIpLocation($loginIp),
$this->getBrowser(request()->header('user-agent', '')),
$this->getOs(request()->header('user-agent', '')),
0,
'登录成功',
$tokenData['user_name'],
$tokenData['id'],
time(),
$tokenData['access_token'],
$tokenData['expires_time']
);
$event->dispatch();
}
/**
* 登录失败-事件发起
*
* @param array $adminInfo
* @param string $message
*/
private function emitLoginFailedEvent(array $adminInfo, string $message): void
{
$loginIp = request()->getRealIp();
$event = new LoginLogEvent(
'登录失败',
request()->app,
$loginIp,
$this->getIpLocation($loginIp),
$this->getBrowser(request()->header('user-agent', '')),
$this->getOs(request()->header('user-agent', '')),
-1,
$message,
$adminInfo['user_name'],
$adminInfo['id'],
time(),
'',
time()
);
$event->dispatch();
}
/**
* 获取浏览器信息
*
* @param string $userAgent
* @return string
*/
private function getBrowser(string $userAgent): string
{
$br = 'Unknown';
if (preg_match('/MSIE/i', $userAgent)) {
$br = 'MSIE';
} elseif (preg_match('/Firefox/i', $userAgent)) {
$br = 'Firefox';
} elseif (preg_match('/Edg/i', $userAgent)) {
$br = 'Edge';
} elseif (preg_match('/OPR|Opera/i', $userAgent)) {
$br = 'Opera';
} elseif (preg_match('/Chrome/i', $userAgent)) {
$br = 'Chrome';
} elseif (preg_match('/Safari/i', $userAgent)) {
$br = 'Safari';
} else {
$br = 'Other';
}
return $br;
}
/**
* 获取操作系统信息
*
* @param string $userAgent
* @return string
*/
private function getOs(string $userAgent): string
{
$os = 'Unknown';
if (preg_match('/win/i', $userAgent)) {
$os = 'Windows';
} elseif (preg_match('/mac/i', $userAgent)) {
$os = 'Mac';
} elseif (preg_match('/linux/i', $userAgent)) {
$os = 'Linux';
} else {
$os = 'Other';
}
return $os;
}
/**
* 获取IP归属地
*
* @param string $ip
* @return string
*/
private function getIpLocation(string $ip): string
{
// 本地IP或无效IP直接返回
if (empty($ip) || in_array($ip, ['127.0.0.1', '::1', 'localhost', '0.0.0.0'])) {
return '本地';
}
// 使用新浪API获取IP归属地
try {
// 创建Guzzle HTTP客户端
$client = new \GuzzleHttp\Client([
'timeout' => 2,
'connect_timeout' => 1,
'verify' => false
]);
$url = "http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip=" . $ip;
$response = $client->get($url);
$content = $response->getBody()->getContents();
$data = json_decode($content, true);
if (isset($data['ret']) && $data['ret'] === 1 && !empty($data['city'])) {
$location = $data['city'];
if (!empty($data['province']) && strpos($data['province'], $data['city']) === false) {
$location = $data['province'] . ' ' . $location;
}
return $location;
}
} catch (\Throwable $e) {
// 忽略异常
}
return '未知';
}
/**
* 更新用户信息
*
* @param string|int $id
* @param array $data
*
* @return mixed
* @throws \Exception
*/
public function updateUserInfo(string|int $id, array $data): mixed
{
return $this->dao->update(['id' => $id], $data);
}
/**
* 更新用户头像
*
* @param string|int $id
* @param string $avatar
*
* @return mixed
* @throws \Exception
*/
public function updateAvatarUser(string|int $id, string $avatar): mixed
{
return $this->dao->update(['id' => $id], ['avatar' => $this->getPathFromUrl($avatar)]);
}
/**
* 更新个人用户密码
*
* @param string|int $id
* @param array $data
*
* @return void
* @throws \Throwable
*/
public function updateUserPwd(string|int $id, array $data): void
{
$this->transaction(function () use ($id, $data) {
$info = $this->dao->getModel()->where('id', $id)->first();
if (empty($info)) {
throw new AdminException('用户不存在或被删除');
}
if (!password_verify($data['old_password'], $info->password)) {
throw new AdminException('旧密码错误,请重新输入!');
}
return $this->dao->update(['id' => $id], ['password' => $this->passwordHash($data['new_password'])]);
});
}
/**
* 更新个人偏好设置
*
* @throws \Throwable
*/
public function updateUserPreferences(string|int $id, array $data = []): void
{
$this->transaction(function () use ($id, $data) {
unset($data['id']);
return $this->dao->update(['id' => $id], ['backend_setting' => $data]);
});
}
/**
* 强制下线
*
* @param $token
*
* @throws \Throwable
*/
public function kickoutByTokenValueUser($token): void
{
$this->transaction(function () use ($token) {
/** @var LoginLogService $systemLoginLogService */
$systemLoginLogService = Container::make(LoginLogService::class);
// 使用 firstOrFail 避免空值检查
$loginLog = $systemLoginLogService->getModel()
->where('key', $token)
->firstOrFail();
// 批量更新字段
$loginLog->update([
'expires_at' => time(), // 使用 Carbon 时间
'remark' => '强制下线',
'updated_at' => time(), // 确保更新时间戳
]);
// 添加Token 致黑名单
$result = JwtToken::addToBlacklist($token, true);
if (!$result) {
throw new AdminException('操作失败');
}
});
}
/**
* 删除关联源-并清理残留关联数据
*
* @param array $ids
*
* @return array
* @throws \core\exception\handler\AdminException
*/
public function batchDelete(array $ids): array
{
try {
return $this->transaction(function () use ($ids) {
// 1. 验证:禁止删除超级管理员
$superUserCount = $this->dao->getModel()->whereIn('id', $ids)
->where('is_super', 1)
->count();
if ($superUserCount > 0) {
throw new AdminException('系统内置用户不允许删除');
}
// 2. 关联同步:通过模型关联清理角色中间表数据
$admins = $this->dao->getModel()->whereIn('id', $ids)->get();
foreach ($admins as $admin) {
// 解除角色关联
$admin->roles()->detach();
$admin->depts()->detach();
}
// 4. 执行管理员数据删除
$deleteCount = $this->dao->destroy($ids);
if ($deleteCount <= 0) {
throw new AdminException('删除失败,未找到有效用户');
}
return ['id' => $ids];
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 生成唯一UUID
*
* @param string|null $currentIp
*
* @return string
*/
private function generateUniqueId(string|null $currentIp = null): string
{
if (empty($currentIp)) {
$currentIp = request()->getRemoteIp();
}
return uniqid($currentIp . '-', true);
}
/**
* 入参移除url
*
* @param string $url
*
* @return string
*/
private function getPathFromUrl(string $url): string
{
// 检测协议头存在性
if (!preg_match('#^https?://#i', $url)) {
return $url; // 非http(s)协议直接原样返回
}
// 支持子域名和端口号
$pattern = '#^https?://
(?:[^/@]+@)? // 忽略用户认证信息
(?:[^/:]+|\[[^\]]+\]) // 匹配域名或IPv6地址
(?::\d+)? // 忽略端口号
(/[^\?#]*) // 精确捕获路径部分
#x';
preg_match($pattern, $url, $matches);
return $matches[1] ?? '';
}
/**
* 校验密钥
*
* @param $keyId
* @param $encryptedPassword
*
* @return string
* @throws AdminException
*/
private function validateRsaKeys($keyId, $encryptedPassword): string
{
$cache = Container::make(CacheService::class, []);
$privateKey = $cache->get("rsa_private_key_$keyId");
if (!$privateKey) {
throw new AdminException('私钥不存在或已过期,请刷新页面重试');
}
return RSAService::decrypt($encryptedPassword, $privateKey);
}
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\adminapi\CurrentUser;
use app\dao\system\AdminDao;
use core\base\BaseService;
use Illuminate\Database\Eloquent\Collection;
use support\Container;
class AuthService extends BaseService
{
public function __construct(AdminDao $dao)
{
$this->dao = $dao;
}
/**
* 获取菜单
*
* @param \app\adminapi\CurrentUser $currentUser
* @param bool $includeButtons
*
* @return \Illuminate\Database\Eloquent\Collection|null
* @throws \Exception
*/
public function getMenusByUserRoles(CurrentUser $currentUser, bool $includeButtons = false): ?Collection
{
$adminModel = $currentUser->admin();
if (!$adminModel) {
return new Collection();
}
$isSuperAdmin = boolval($adminModel->getAttribute('is_super'));
/** @var MenuService $menuService */
$menuService = Container::make(MenuService::class);
$map1 = $includeButtons ? ['type' => [1, 2, 3, 4]] : ['type' => [1, 2]];
if ($isSuperAdmin) {
// 顶级管理员返回全部菜单
return $menuService->selectList($map1, '*', 0, 0, 'sort', [], true);
}
// 普通成员 - 通过角色获取菜单
$menuIds = $this->getUserMenuIds($adminModel);
return $this->getMenusByIds($menuService, array_unique($menuIds), $includeButtons);
}
/**
* 获取用户拥有的菜单ID列表
*
* @param \app\model\system\Admin $adminModel
* @return array 菜单ID数组
*/
private function getUserMenuIds($adminModel): array
{
$menuIds = [];
// 通过角色获取菜单
$roles = $adminModel->roles()->with('menus')->get();
foreach ($roles as $role) {
foreach ($role->menus as $menu) {
$menuIds[] = $menu->id;
}
}
return array_unique($menuIds);
}
/**
* 根据IDS输出菜单
*
* @param \app\service\admin\system\MenuService $menuService
* @param array $ids
* @param bool $includeButtons
*
* @return \Illuminate\Database\Eloquent\Collection
* @throws \Exception
*/
private function getMenusByIds(MenuService $menuService, array $ids, bool $includeButtons = false): Collection
{
if (empty($ids)) {
return new Collection();
}
$menuModel = $menuService->dao->getModel();
$chunkSize = 200;
$chunks = array_chunk($ids, $chunkSize);
$typeFilter = function ($query) use ($includeButtons) {
$types = $includeButtons ? [1, 2, 3, 4] : [1, 2];
$query->whereIn('type', $types);
};
// 分块查询 + 批量合并(减少内存占用)
$allResults = new Collection();
foreach ($chunks as $chunk) {
$results = $menuModel
->whereIn('id', $chunk)
->where('enabled', 1)
->where($typeFilter)
->orderBy('sort')
->get();
$allResults = $allResults->merge($results);
}
return $allResults;
}
/**
* 获取用户角色-权限码
*
* @param \app\adminapi\CurrentUser $currentUser
*
* @return array
*/
public function getCodesByUserRoles(CurrentUser $currentUser): array
{
$adminModel = $currentUser->admin();
if (!$adminModel) {
return [];
}
$isSuperAdmin = boolval($adminModel->getAttribute('is_super'));
if ($isSuperAdmin) {
// 顶级管理员
return ['admin'];
}
// 普通成员 - 通过角色获取权限码
return $currentUser->getPermissions();
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: https://madong.tech
*/
namespace app\service\admin\system;
use app\enum\system\PolicyPrefix;
use core\base\BaseService;
use core\casbin\Permission;
use core\exception\handler\AdminException;
class CasbinService extends BaseService
{
public function __construct()
{
$this->dao = null;
}
/**
* 更新casbin 策略
*
* @param array $currentPermissions
* @param array $incomingPolicies
*
* @throws \core\exception\handler\AdminException
*/
public function updateCasbinPolicies(array $currentPermissions, array $incomingPolicies): void
{
// 计算需要删除的权限:当前有但传入没有的
$permissionsToDelete = array_filter($currentPermissions, function ($currentPolicy) use ($incomingPolicies) {
foreach ($incomingPolicies as $incomingPolicy) {
if (
(string)$currentPolicy[0] === (string)$incomingPolicy[0] &&
(string)$currentPolicy[1] === (string)$incomingPolicy[1] &&
(string)$currentPolicy[2] === (string)$incomingPolicy[2] &&
(string)$currentPolicy[5] === (string)$incomingPolicy[5]
) {
return false; // 找到了匹配的,当前权限不需要删除
}
}
return true; // 没有找到匹配的,当前权限需要删除
});
// 计算需要添加的权限:传入有但当前没有的
$permissionsToAdd = array_filter($incomingPolicies, function ($incomingPolicy) use ($currentPermissions) {
foreach ($currentPermissions as $currentPolicy) {
if (
(string)$incomingPolicy[0] === (string)$currentPolicy[0] &&
(string)$incomingPolicy[1] === (string)$currentPolicy[1] &&
(string)$incomingPolicy[2] === (string)$currentPolicy[2] &&
(string)$incomingPolicy[5] === (string)$currentPolicy[5]
) {
return false; // 找到了匹配的,传入权限不需要添加
}
}
return true; // 没有找到匹配的,传入权限需要添加
});
// 删除不需要的权限
foreach ($permissionsToDelete as $policy) {
Permission::removeFilteredPolicy("0", $policy[0], $policy[1], '', '', '', $policy[5]);
}
// 添加新的权限
foreach ($permissionsToAdd as $policy) {
$res = Permission::addPolicy(...$policy);
if (!$res) {
throw new AdminException('添加失败:' . $policy[5]);
}
}
}
/**
* 根据菜单ID删除关联Casbin策略
*
* @param int|string $menuId 菜单ID
*
* @throws AdminException
*/
public function deletePoliciesByMenuId(int|string $menuId): void
{
try {
$policyIdentifier = PolicyPrefix::MENU->value . (string)$menuId;
Permission::removeFilteredPolicy("0", '', '', '', '', '', $policyIdentifier);
} catch (\Throwable $e) {
throw new AdminException("删除菜单关联策略失败:{$e->getMessage()}");
}
}
}
@@ -0,0 +1,721 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\ConfigDao;
use app\scope\global\AccessPermissionScope;
use core\base\BaseService;
class ConfigService extends BaseService
{
public function __construct(ConfigDao $dao)
{
$this->dao = $dao;
}
/**
* 获取配置项
*
* @param string $code 配置code
* @param mixed $default 默认值
* @param array $options 选项,如 ['group_code' => '...']
*
* @return mixed
* @throws \Exception
*/
public function config(string $code, mixed $default = [], array $options = []): mixed
{
$map = ['code' => $code];
// 如果选项中有分组,则添加到查询条件
if (!empty($options['group_code'])) {
$map['group_code'] = $options['group_code'];
}
// 查询配置
$configModel = $this->dao->get($map, ['*'], [], '', [AccessPermissionScope::class]);
// 如果配置不存在,自动创建
if (!$configModel) {
$configData = [
'code' => $code,
'name' => $options['name'] ?? $code,
'group_code' => $options['group_code'] ?? 'default',
'content' => is_array($default) ? json_encode($default, JSON_UNESCAPED_UNICODE) : $default,
'type' => $options['type'] ?? (is_array($default) ? 'json' : 'string'),
'enabled' => 1,
'sort' => 0,
'remark' => $options['remark'] ?? '',
];
try {
$this->dao->save($configData);
} catch (\Exception $e) {
}
return $default;
}
$content = $configModel->getOriginal('content', null);
// 如果content是数组/JSON格式,返回整个数组;否则返回原值
if (is_string($content) && !empty($content)) {
$decoded = json_decode($content, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
}
return $content ?? $default;
}
/**
* 获取配置分组详情
*
* @param string $groupCode 配置分组
* @param array $defaults 默认配置项
* @param array $options 选项参数
* - enabled_only: bool 是否只获取启用的配置项,默认true
* - with_metadata: bool 是否包含配置元数据,默认false
*
* @return array
* @throws \ReflectionException
*/
public function getByGroup(string $groupCode, array $defaults = [], array $options = []): array
{
// 验证groupCode参数,防止SQL注入
if (empty($groupCode)) {
return $defaults;
}
$enabledOnly = $options['enabled_only'] ?? true;
$withMetadata = $options['with_metadata'] ?? false;
$map = ['group_code' => $groupCode];
if ($enabledOnly) {
$map['enabled'] = 1;
}
if ($withMetadata) {
// 获取完整配置信息
$configs = $this->dao->getList($map);
$processedData = [];
foreach ($configs as $config) {
$content = $config['content'] ?? null;
if (is_string($content) && !empty($content)) {
$decoded = json_decode($content, true);
$config['content'] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
}
if (isset($options['key_by']) && $options['key_by'] === 'code') {
$processedData[$config['code']] = $config;
} else {
$processedData[] = $config;
}
}
return $processedData;
} else {
// 只获取配置内容
$data = $this->dao->getColumn($map, 'content', 'code');
// 处理每个配置项的内容,如果是JSON格式则解码
$processedData = [];
foreach ($data as $code => $content) {
if (is_string($content) && !empty($content)) {
$decoded = json_decode($content, true);
$processedData[$code] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
} else {
$processedData[$code] = $content;
}
}
// 合并默认配置
return array_merge($defaults, $processedData);
}
}
/**
* 获取所有配置并按分组整理
*
* @param array $options 选项参数
* - enabled_only: bool 是否只获取启用的配置项,默认true
* - group_filter: array 分组过滤,只返回指定分组的配置
* - with_metadata: bool 是否包含配置项的完整元数据,默认false
* - key_by: string 按指定字段作为键名('code' 或 'id'),默认null
* - sort_groups: bool 是否对分组进行排序,默认false
* - sort_configs: bool 是否对配置项进行排序,默认false
*
* @return array
* @throws \Exception
*/
public function getAllGrouped(array $options = []): array
{
$enabledOnly = $options['enabled_only'] ?? true;
$groupFilter = $options['group_filter'] ?? [];
$withMetadata = $options['with_metadata'] ?? false;
$keyBy = $options['key_by'] ?? null;
$sortGroups = $options['sort_groups'] ?? false;
$sortConfigs = $options['sort_configs'] ?? false;
$map = [];
if ($enabledOnly) {
$map['enabled'] = 1;
}
if (!empty($groupFilter)) {
$map['group_code'] = $groupFilter;
}
$configs = $this->dao->getList($map);
$grouped = [];
foreach ($configs as $config) {
$groupCode = $config['group_code'] ?? 'default';
// 处理内容
$content = $config['content'];
if (is_string($content) && !empty($content)) {
$decoded = json_decode($content, true);
$config['content'] = json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
}
// 初始化分组
if (!isset($grouped[$groupCode])) {
$grouped[$groupCode] = [];
}
// 根据选项决定返回的数据结构
if ($withMetadata) {
// 返回完整配置信息
if ($keyBy && isset($config[$keyBy])) {
$grouped[$groupCode][$config[$keyBy]] = $config;
} else {
$grouped[$groupCode][] = $config;
}
} else {
// 只返回配置内容
if ($keyBy && isset($config[$keyBy])) {
$grouped[$groupCode][$config[$keyBy]] = $config['content'];
} else {
$grouped[$groupCode][$config['code']] = $config['content'];
}
}
}
// 排序处理
if ($sortGroups) {
ksort($grouped);
}
if ($sortConfigs) {
foreach ($grouped as &$configs) {
if ($withMetadata) {
// 对元数据模式按code排序
uasort($configs, function ($a, $b) {
return strcmp($a['code'] ?? '', $b['code'] ?? '');
});
} else {
// 对内容模式按键名排序
ksort($configs);
}
}
}
return $grouped;
}
/**
* 获取配置项中的某个键值
*
* @param string $code 配置项
* @param string $key 要获取的键,支持多级键,如 key1.key2.key3
* @param mixed $default 默认值,默认null
* @param array $options 选项参数
* - group_code: string 配置分组,默认null(在所有分组中查找)
* - require_group: bool 是否必须指定分组,默认false
* - fallback_to_all: bool 当未找到分组配置时,是否在所有分组中查找,默认true
* - search_groups: array 搜索的分组列表,按顺序查找
*
* @return mixed
* @throws \Exception
*/
public function getValue(string $code, string $key, mixed $default = null, array $options = []): mixed
{
$groupCode = $options['group_code'] ?? null;
$requireGroup = $options['require_group'] ?? false;
$fallbackToAll = $options['fallback_to_all'] ?? true;
$searchGroups = $options['search_groups'] ?? [];
// 如果指定了搜索分组列表,按顺序查找
if (!empty($searchGroups) && is_array($searchGroups)) {
foreach ($searchGroups as $searchGroupCode) {
$config = $this->config($code, null, ['group_code' => $searchGroupCode]);
if (is_array($config)) {
$value = $this->getNestedValue($config, $key);
if ($value !== null) {
return $value;
}
}
}
return $default;
}
// 如果指定了分组,优先在该分组中查找
if ($groupCode !== null) {
$config = $this->config($code, null, ['group_code' => $groupCode]);
if (is_array($config)) {
$value = $this->getNestedValue($config, $key);
if ($value !== null) {
return $value;
}
}
// 如果未找到且允许回退到所有分组,则尝试在所有分组中查找
if ($fallbackToAll && !$requireGroup) {
$config = $this->config($code, null);
if (is_array($config)) {
$value = $this->getNestedValue($config, $key);
if ($value !== null) {
return $value;
}
}
}
return $default;
}
// 默认在所有分组中查找
$config = $this->config($code, null);
if (is_array($config)) {
$value = $this->getNestedValue($config, $key);
if ($value !== null) {
return $value;
}
}
return $default;
}
/**
* 设置特定配置项中的某个键值
* 支持两种调用方式:
* 1. 新版本(推荐):setConfigValue(string $code, string $key, mixed $value, array $options = [])
* 2. 旧版本(兼容):setConfigValue(string $groupCode, string $code, string $key, mixed $value)
*
* @param string $code 配置项或配置分组(旧版本)
* @param string $key 要设置的键,支持多级键,如 key1.key2.key3 或配置项(旧版本)
* @param mixed $value 要设置的值或要设置的键(旧版本)
* @param array|mixed $options 选项参数或要设置的值(旧版本)
* - group_code: string 配置分组,默认null
*
* @return bool
* @throws \Exception
*/
public function setValue(string $code, string $key, mixed $value, mixed $options = []): bool
{
// 检查是否为旧版本调用方式:setConfigValue($groupCode, $code, $key, $value)
if (func_num_args() === 4 && is_string($options)) {
// 旧版本调用,参数顺序为:$groupCode, $code, $key, $value
$groupCode = $code;
$code = $key;
$key = $value;
$value = $options;
$options = ['group_code' => $groupCode];
}
$groupCode = $options['group_code'] ?? null;
// 验证groupCode参数,防止SQL注入
if ($groupCode !== null && !preg_match('/^[a-zA-Z0-9_-]+$/', $groupCode)) {
throw new \Exception('Invalid group_code format');
}
$config = $this->config($code, [], ['group_code' => $groupCode]);
if (is_array($config)) {
// 支持多级键设置
$updatedConfig = $this->setNestedValue($config, $key, $value);
} else {
// 如果当前配置不是数组,将其转换为数组
$updatedConfig = $this->setNestedValue([], $key, $value);
}
// 更新配置,传递groupCode参数
return $this->update($code, $updatedConfig, ['group_code' => $groupCode]);
}
/**
* 根据多级键获取嵌套数组中的值
*
* @param array $array 要查找的数组
* @param string $key 多级键,如 key1.key2.key3
*
* @return mixed|null 找到的值或null
*/
private function getNestedValue(array $array, string $key): mixed
{
// 如果key不包含点号,直接返回对应值
if (!str_contains($key, '.')) {
return $array[$key] ?? null;
}
// 拆分多级键
$keys = explode('.', $key);
$value = $array;
// 逐级查找
foreach ($keys as $k) {
if (!is_array($value) || !isset($value[$k])) {
return null;
}
$value = $value[$k];
}
return $value;
}
/**
* 根据多级键设置嵌套数组中的值
*
* @param array $array 要设置值的数组
* @param string $key 多级键,如 key1.key2.key3
* @param mixed $value 要设置的值
*
* @return array 更新后的数组
*/
private function setNestedValue(array $array, string $key, mixed $value): array
{
// 如果key不包含点号,直接设置值
if (!str_contains($key, '.')) {
$array[$key] = $value;
return $array;
}
// 拆分多级键
$keys = explode('.', $key);
$lastKey = array_pop($keys);
$current = &$array;
// 逐级创建或获取子数组
foreach ($keys as $k) {
if (!isset($current[$k]) || !is_array($current[$k])) {
$current[$k] = [];
}
$current = &$current[$k];
}
// 设置最终值
$current[$lastKey] = $value;
return $array;
}
/**
* 更新配置
*
* @param string $code 配置code
* @param mixed $content 配置内容
* @param array $options 扩展选项参数
* - group_code: string 配置分组
* - name: string 配置名称
* - enabled: int 是否启用 (1=启用, 0=禁用)
*
* @return void
* @throws \Exception
*/
public function update(string $code, mixed $content, array $options = []): void
{
try {
// 处理内容:如果是数组则转换为JSON,字符串直接存储
$processedContent = $content;
if (is_array($content)) {
$processedContent = json_encode($content, JSON_UNESCAPED_UNICODE);
}
$map = ['code' => $code];
$configModel = $this->dao->get($map);
if (!empty($configModel)) {
// 更新内容
$configModel->content = $processedContent;
// 更新其他可选字段
if (isset($options['group_code'])) {
// 验证group_code格式
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
throw new \Exception('Invalid group_code format');
}
$configModel->group_code = $options['group_code'];
}
if (isset($options['name'])) {
$configModel->name = $options['name'];
}
if (isset($options['enabled'])) {
$configModel->enabled = (int)$options['enabled'];
}
$configModel->save();
} else {
// 创建新的配置项
$data = [
'code' => $code,
'content' => $processedContent,
'name' => $options['name'] ?? $code,
'enabled' => isset($options['enabled']) ? (int)$options['enabled'] : 1,
];
// 设置可选的分组
if (isset($options['group_code'])) {
// 验证group_code格式
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
throw new \Exception('Invalid group_code format');
}
$data['group_code'] = $options['group_code'];
}
$this->dao->save($data);
}
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
}
/**
* 检查配置项是否存在
*
* @param string $code 配置code
* @param array $options 扩展选项参数
* - group_code: string 配置分组
*
* @return bool
* @throws \Exception
*/
public function exists(string $code, array $options = []): bool
{
$map = ['code' => $code];
if (isset($options['group_code'])) {
// 验证group_code格式
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
throw new \Exception('Invalid group_code format');
}
$map['group_code'] = $options['group_code'];
}
$config = $this->dao->get($map);
return !empty($config);
}
/**
* 删除配置项
*
* @param string $code 配置code
* @param array $options 扩展参数
* - group_code: string 配置分组
*
* @return bool
* @throws \Exception
*/
public function delete(string $code, array $options = []): bool|int
{
$map = [['code' ,'EQ', $code]];
if (isset($options['group_code'])) {
// 验证group_code格式
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
throw new \Exception('Invalid group_code format');
}
$map[] = ['group_code','EQ',$options['group_code']];
}
$config = $this->dao->get($map);
if (!$config) {
return false;
}
// 验证是否为系统配置,系统配置不允许删除
if (isset($config->is_sys) && $config->is_sys == 1) {
throw new \Exception('系统配置不允许删除');
}
return $this->dao->delete($config['id']);
}
/**
* 启用/禁用配置项
*
* @param string $code 配置code
* @param int $enabled
* @param array $options 扩展参数
* - group_code: string 配置分组
* - enabled: bool 是否启用,默认true
*
* @return bool
* @throws \Exception
*/
public function enabled(string $code, int $enabled, array $options = []): bool
{
$map = ['code' => $code];
if (isset($options['group_code'])) {
// 验证group_code格式
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $options['group_code'])) {
throw new \Exception('Invalid group_code format');
}
$map['group_code'] = $options['group_code'];
}
$config = $this->dao->get($map);
if (!$config) {
return false;
}
$config->enabled = $enabled;
return $config->save();
}
/**
* 获取配置项列表(分页)
*
* @param int $page 页码
* @param int $pageSize 每页数量
* @param string $groupCode 分组代码
* @param string $keyword 搜索关键字
*
* @return array
* @throws \Exception
*/
public function getItems(int $page, int $pageSize, string $groupCode, string $keyword = ''): array
{
$map1 = [['is_sys', 'ne', 1]];
if (!empty($keyword)) {
$map1[] = ['code', 'eq', $keyword];
}
$list = $this->dao->getList($map1, ['*'], $page, $pageSize, 'code', [], false);
$total = $this->dao->count($map1);
// 处理每个配置项的值
foreach ($list as &$item) {
$item['value'] = $this->formatValue($item['content'], $item['type'] ?? 'string');
}
return [
'items' => $list,
'total' => $total,
];
}
/**
* 创建单个配置项
*
* @param array $data 配置数据
*
* @return void
* @throws \Exception
*/
public function createItem(array $data): void
{
// 检查配置代码是否已存在
if ($this->exists($data['code'], ['group_code' => $data['group_code']])) {
throw new \Exception('配置代码已存在');
}
$this->dao->save($data);
}
/**
* 更新单个配置项
*
* @param int $id 配置ID
* @param array $data 配置数据
*
* @return void
* @throws \Exception
*/
public function updateItem(int $id, array $data): void
{
$config = $this->dao->get(['id' => $id]);
if (!$config) {
throw new \Exception('配置不存在');
}
// 验证是否为系统配置
if (isset($config->is_sys) && $config->is_sys == 1) {
throw new \Exception('系统配置不允许修改');
}
$config->name = $data['name'];
$config->content = $data['content'];
$config->type = $data['type'] ?? 'string';
$config->description = $data['description'] ?? '';
$config->enabled = $data['enabled'] ?? 1;
$config->save();
}
/**
* 删除配置项
*
* @param int $id 配置ID
*
* @return void
* @throws \Exception
*/
public function deleteItem(int $id): void
{
$config = $this->dao->get(['id' => $id]);
if (!$config) {
throw new \Exception('配置不存在');
}
// 验证是否为系统配置
if (isset($config->is_sys) && $config->is_sys == 1) {
throw new \Exception('系统配置不允许删除');
}
$this->dao->delete($id);
}
/**
* 切换配置项启用状态
*
* @param int $id 配置ID
* @param bool $enabled 是否启用
*
* @return void
* @throws \Exception
*/
public function toggleItem(int $id, bool $enabled): void
{
$config = $this->dao->get(['id' => $id]);
if (!$config) {
throw new \Exception('配置不存在');
}
$config->enabled = $enabled ? 1 : 0;
$config->save();
}
/**
* 格式化配置值
*
* @param mixed $content 配置内容
* @param string $type 配置类型
*
* @return mixed
*/
private function formatValue(mixed $content, string $type): mixed
{
return match ($type) {
'string' => (string)$content,
'number' => (float)$content,
'boolean' => (bool)$content,
'json' => is_string($content) ? json_decode($content, true) : $content,
default => $content
};
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace app\service\admin\system;
use app\dao\system\FeedbackDao;
use core\base\BaseService;
class FeedbackService extends BaseService
{
public function __construct(FeedbackDao $dao)
{
$this->dao = $dao;
}
}
@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\MenuDao;
use app\model\system\Menu;
use core\base\BaseService;
use core\exception\handler\AdminException;
use madong\helper\Arr;
use madong\helper\Tree;
use support\Container;
/**
* @method save(array $data)
* @method selectModel(array $where, array|string $field = '*', int $page = 0, int $limit = 0, string $order = '', array $with = [], bool $search = false, ?array $withoutScopes = null)
*/
class MenuService extends BaseService
{
/**
* 缓存key
*/
const CACHE_KEY = 'menu_list';
/**
* 所有权限
*/
const CACHE_ALL_AUTHS_DATA = 'all_auths_data';
/**
* 所有菜单
*/
const CACHE_ALL_MENUS_DATA = 'all_menus_data';
public function __construct(MenuDao $dao)
{
$this->dao = $dao;
}
/**
* 输出所有权限
*
* @param array $menuIds
*
* @return array
* @throws \Exception
*/
public function getAllAuth(array $menuIds = []): array
{
// 1. 优先从缓存获取所有权限数据(自动处理缓存未命中时的数据库查询)
$allAuthItems = $this->cacheDriver()->remember(
self::CACHE_ALL_AUTHS_DATA,
function () {
// 缓存未命中时,从数据库获取数据(仅查询需要的字段)
return $this->dao->getModel()
->where('path', '<>', '')
->where('type', '=', 4)
->get(['id', 'path', 'methods'])
->toArray();
}
);
// 2. 如果传入了 $menuIds,则筛选出对应的权限项
if (!empty($menuIds)) {
$allAuthItems = array_filter($allAuthItems, function ($item) use ($menuIds) {
return in_array($item['id'], $menuIds);
});
}
// 3. 格式化输出(复用现有方法)
return $this->formatAuthDataFromItems($allAuthItems);
}
/**
* 根据授权项数组格式化输出为 ['METHOD' => ['/path1', '/path2'], ...] 的形式。
*
* @param array $allAuthItems 授权项数组,每个元素包含 'id', 'path', 'methods'
*
* @return array 格式化后的授权数据
*/
private function formatAuthDataFromItems(array $allAuthItems): array
{
$allAuth = [];
foreach ($allAuthItems as $item) {
// 假设 'methods' 是逗号分隔的字符串,如 "GET,POST"
$methodArray = explode(',', $item['methods']); // 转换为数组
$pathKey = strtolower(trim(str_replace(' ', '', $item['path'])));
foreach ($methodArray as $method) {
$methodKey = strtolower(trim($method));
$allAuth[$methodKey][] = $pathKey;
}
}
// 如果需要,可以对每个方法的路径数组进行去重
foreach ($allAuth as &$paths) {
$paths = array_unique($paths);
}
unset($paths); // 解除引用
return $allAuth;
}
/**
* 获取所有菜单,优先从缓存读取,并根据 $where 条件筛选。
*
* @param array $where 筛选条件数组,键为字段名,值为条件值或数组(用于 IN 条件)或操作符数组(如 ['>', 5])
*
* @return array 处理后的菜单树形结构
*/
// public function getAllMenus(array $where = [], $mode = 'menu', $isTree = true): array
// {
// // 1. 优先从缓存获取所有菜单数据
// $allMenus = $this->cacheDriver()->remember(
// self::CACHE_ALL_MENUS_DATA, // 缓存键名
// function () {
// $list = $this->dao->getModel()
// ->where('enabled', 1)
//// ->whereIn('type', [1, 2])// 目录 & 菜单类型 全量保存缓存条件放到缓存输出
// ->orderBy('sort', 'asc')
// ->get();
// foreach ($list as $item) {
// $item->set('name', $item->code);
// $item->set('meta', SysMenu::getMetaAttribute($item));
// }
// $list->makeVisible(['id', 'pid', 'type', 'sort', 'redirect', 'path', 'name', 'meta', 'component']);
// return $list->toArray();
// }
// );
//
// // 2. 根据 $where 条件筛选菜单数据
// $menusToProcess = Arr::filterByWhere($allMenus, $where);
//
// if ($mode == 'code') {
// return array_column($menusToProcess, 'code');
// }
// if (!$isTree) {
// return $menusToProcess;
// }
//
// // 构建树形结构
// return $this->buildMenuTree($menusToProcess);
//
// }
public function getAllMenus(array $where = [], $mode = 'menu', $isTree = true): array
{
// 1. 优先从缓存获取所有菜单数据
$allMenus = $this->cacheDriver()->remember(
self::CACHE_ALL_MENUS_DATA, // 缓存键名
function () {
return $this->dao->getModel()
->where('enabled', 1)
// ->whereIn('type', [1, 2])// 目录 & 菜单类型 全量保存缓存条件放到缓存输出
->orderBy('sort', 'asc')
->get();
// foreach ($list as $item) {
// $item->set('name', $item->code);
// $item->set('meta', SysMenu::getMetaAttribute($item));
// }
// $list->makeVisible(['id', 'pid', 'type', 'sort', 'redirect', 'path', 'name', 'meta', 'component']);
// return $list->toArray();
}
);
// 2. 根据 $where 条件筛选菜单数据
$menusToProcess = Arr::filterByWhere($allMenus, $where);
if ($mode == 'code') {
return array_column($menusToProcess, 'code');
}
if (!$isTree) {
return $menusToProcess;
}
// 构建树形结构
return $this->buildMenuTree($menusToProcess);
}
/**
* 构建菜单的树形结构。
*
* @param array $formattedMenus 已处理的菜单数据
*
* @return array 树形结构的菜单数据
*/
protected function buildMenuTree(array $formattedMenus): array
{
$tree = new Tree($formattedMenus);
return $tree->getTree();
}
/*** 重写方法***/
/**
* 重写更新方法:统一事务管理菜单更新、缓存清理、Casbin同步
*
* @param int|string $id 菜单ID
* @param array $data 更新数据
*
* @return \app\model\system\Menu 更新后的模型
* @throws \Throwable
* @throws \core\exception\handler\AdminException
*/
public function update(int|string $id, array $data): \app\model\system\Menu
{
return $this->transaction(function () use ($id, $data) {
// 1. 获取菜单模型
$model = $this->get($id);
if (!$model) {
throw new AdminException("菜单ID:{$id}不存在");
}
// 2. 更新菜单数据(使用属性复制或直接赋值)
$model->fill($data);
$model->save();
// 3. 清理缓存(原控制器中的缓存删除逻辑迁移至此)
$this->cacheDriver()->delete(self::CACHE_ALL_AUTHS_DATA);
$this->cacheDriver()->delete(self::CACHE_ALL_MENUS_DATA);
// 4. 同步Casbin策略(复用已实现的同步方法)
$this->syncPermissionCacheAfterUpdate($id);
return $model;
});
}
/**
* 批量删除菜单(含子菜单)并同步删除Casbin策略
*
* @param array $data 菜单ID数组或逗号分隔字符串
*
* @return array 被删除的所有菜单ID
* @throws \Throwable
* @throws \core\exception\handler\AdminException
*/
public function batchDelete(array $data = []): array
{
return $this->transaction(function () use ($data) {
$deletedIds = [];
foreach ($data as $id) {
/** @var Menu $item */
$item = $this->get($id);
if (!$item) {
continue;
}
// 删除菜单及子菜单,获取所有被删除的ID
$ids = $item->deleteWithAllChildren();
$deletedIds = array_merge($deletedIds, $ids);
}
// 清理角色菜单中间表关联数据
if (!empty($deletedIds)) {
/** @var RoleMenuService $roleMenuService */
$roleMenuService = Container::make(RoleMenuService::class);
/** @var RoleMenuModel $roleMenuModel */
$roleMenuModel = $roleMenuService->getModel();
$roleMenuModel->whereIn('menu_id', $deletedIds)->delete();
// 清理相关用户的权限缓存
$this->clearUserPermissionCacheByMenus($deletedIds);
}
return array_unique($deletedIds);
});
}
/**
* 菜单更新后清理相关用户权限缓存
*
* @param int|string $menuId 被更新的菜单ID
*
* @throws \core\exception\handler\AdminException
*/
private function syncPermissionCacheAfterUpdate(int|string $menuId): void
{
try {
// 1. 查询所有关联此菜单的角色ID(通过角色菜单服务)
/** @var RoleMenuService $roleMenuService */
$roleMenuService = Container::make(RoleMenuService::class);
$roleIds = $roleMenuService->getColumn(
['menu_id' => $menuId],
'role_id'
);
if (empty($roleIds)) {
return; // 无关联角色,无需同步
}
// 2. 清理拥有这些角色的用户的权限缓存
foreach ($roleIds as $roleId) {
/** @var \app\service\admin\system\AdminRoleService $adminRoleService */
$adminRoleService = Container::make(\app\service\admin\system\AdminRoleService::class);
// 获取拥有此角色的用户ID列表
$userIds = \app\model\system\AdminRole::where('role_id', $roleId)
->pluck('admin_id')
->toArray();
if (!empty($userIds)) {
// 清理这些用户的权限缓存
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
foreach ($userIds as $userId) {
$currentUser->clearCache($userId);
}
}
}
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 清理与指定菜单相关的用户权限缓存
*
* @param array $menuIds 菜单ID数组
*/
private function clearUserPermissionCacheByMenus(array $menuIds): void
{
try {
if (empty($menuIds)) {
return;
}
// 获取拥有这些菜单的角色ID
/** @var RoleMenuService $roleMenuService */
$roleMenuService = Container::make(RoleMenuService::class);
/** @var RoleMenuModel $roleMenuModel */
$roleMenuModel = $roleMenuService->getModel();
$roleIds = $roleMenuModel->whereIn('menu_id', $menuIds)
->pluck('role_id')
->unique()
->toArray();
if (!empty($roleIds)) {
// 获取拥有这些角色的用户ID
$userIds = \app\model\system\AdminRole::whereIn('role_id', $roleIds)
->pluck('admin_id')
->unique()
->toArray();
if (!empty($userIds)) {
// 清理这些用户的权限缓存
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
foreach ($userIds as $userId) {
$currentUser->clearCache($userId);
}
}
}
} catch (\Throwable $e) {
// 缓存清理失败不影响主要业务,记录日志即可
\core\logger\Logger::error("清理菜单相关用户权限缓存失败: " . $e->getMessage());
}
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\RecycleBinDao;
use app\model\system\RecycleBin;
use core\base\BaseService;
use core\exception\handler\AdminException;
use support\Db;
/**
* 回收站服务
*
* @author Mr.April
* @since 1.0
*/
class RecycleBinService extends BaseService
{
public function __construct(RecycleBinDao $dao)
{
$this->dao = $dao;
}
/**
* 数据恢复
*
* @param int $recycleId
*
* @throws \Throwable
*/
public function restoreRecycleBin(int $recycleId): void
{
$this->transaction(function () use ($recycleId) {
$record = $this->dao->get($recycleId, null, [], '');
if (empty($record)) {
throw new AdminException("回收站记录不存在");
}
// 3. 动态恢复数据
$this->restoreOriginalData($record);
// 4. 删除回收站记录
$record->delete();
});
}
/**
* 恢复原始数据
*/
protected function restoreOriginalData(RecycleBin $record): void
{
$tableName = $record->table_name;
$config = self::getTableConfig($tableName);
$storage_mode = $config['storage_mode'];
$connection = config('database.default');
$tableData = json_decode($record->getData('data'), true);
$columns = $this->getTableColumns($tableName, $connection);
$tableData = array_intersect_key($tableData, array_flip($columns));
Db::connection($connection)->table($tableName)->insert($tableData);
}
/**
* 获取还原表的数据列
*
* @param string $tableName
* @param string $connection
*
* @return array
*/
private function getTableColumns(string $tableName, string $connection = ''): array
{
return Db::connection($connection)->getSchemaBuilder()->getColumnListing($tableName);
}
/**
* 获取表配置(合并全局+表级配置)
*
* @param string $table
*
* @return array
*/
// 获取合并配置
public static function getTableConfig(string $table): array
{
return array_merge(
config('recycle_bin.default'),
config("recycle_bin.tables.{$table}", []),
['tenant' => config('recycle_bin.tenant')]
);
}
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\RoleMenuDao;
use core\base\BaseService;
use core\exception\handler\AdminException;
use support\Container;
/**
* @method getColumn(int[]|string[] $array, string $string)
*/
class RoleMenuService extends BaseService
{
public function __construct(RoleMenuDao $dao)
{
$this->dao = $dao;
}
/**
* 更新设置角色权限
*
* @param $data
*
* @throws \core\exception\handler\AdminException
*/
// public function save($data): void
// {
// try {
// $this->transaction(function () use ($data) {
// $roleId = $data['role_id'] ?? '';
// $newPermissions = $data['menu_id'] ?? [];
// if (empty($roleId)) {
// throw new AdminException('参数错误缺少role_id', ['errorCode' => -1]);
// }
//
// // 1. 获取角色模型(确保角色存在)
// /** @var SysRoleService $roleService */
// $roleService = Container::make(SysRoleService::class);
// $roleModel = $roleService->get($roleId);
// if (!$roleModel) {
// throw new AdminException('角色不存在', ['errorCode' => -1]);
// }
//
// // 2. 同步 role_menu 关联关系(自动处理新增/删除,无需手动计算差异)
// // 假设 SysRole 模型定义了 roleMenus 关联(hasMany: SysRoleMenu)
// $roleModel->menus()->sync($newPermissions);
//
// // 3. 获取同步后的菜单ID(从关联关系中直接获取,确保数据一致性)
// $syncedMenuIds = $roleModel->menus()->pluck('menu_id')->toArray();
//
// // 4. 构建 Casbin 策略(基于同步后的最新菜单权限)
// $casbinRoleId = PolicyPrefix::ROLE->value . $roleId;
// $domain = '*';
//
// // 4.1 获取菜单详情(用于构建策略)
// /** @var SysMenuService $menuService */
// $menuService = Container::make(SysMenuService::class);
// $menuDetails = $menuService->selectList(['id' => $syncedMenuIds], '*', 0, 0, 'sort', [], true)->toArray();
//
// $incomingPolicies = [];
// foreach ($menuDetails as $menu) {
// if (empty($menu['code'])) continue;
// $incomingPolicies[] = [
// $casbinRoleId, // sub (角色标识)
// $domain, // dom (租户域)
// $menu['code'], // obj (权限码)
// '*', // act (操作类型)
// $menu['methods'], // method (HTTP方法)
// PolicyPrefix::MENU->value . $menu['id'], // trace_id
// ];
// }
//
// // 4.2 更新 Casbin 策略
// $currentCasbinPolicies = Permission::getImplicitResourcesForUser($casbinRoleId, $domain);
// /** @var SysCasbinService $casbinService */
// $casbinService = Container::make(SysCasbinService::class);
// $casbinService->updateCasbinPolicies($currentCasbinPolicies, $incomingPolicies);
//
// });
// } catch (\Throwable $e) {
// throw new AdminException($e->getMessage());
// }
// }
/**
* 更新设置角色权限(含事务封装)
*
* @param $data
*
* @throws \core\exception\handler\AdminException
*/
public function save($data): void
{
try {
$this->transaction(function () use ($data) {
$this->saveWithoutTransaction($data);
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 无事务版本:核心业务逻辑(供内部/外部事务复用)
*
* @param array $data
*
* @throws \core\exception\handler\AdminException
*/
public function saveWithoutTransaction(array $data): void
{
try {
$roleId = $data['role_id'] ?? '';
$newPermissions = $data['menu_id'] ?? [];
if (empty($roleId)) {
throw new AdminException('参数错误缺少role_id');
}
// 1. 获取角色模型(确保角色存在)
/** @var RoleService $roleService */
$roleService = Container::make(RoleService::class);
$roleModel = $roleService->get($roleId);
if (!$roleModel) {
throw new AdminException('角色不存在');
}
// 2. 同步 role_menu 关联关系(自动处理新增/删除,无需手动计算差异)
$roleModel->menus()->sync($newPermissions);
// 3. 清理相关用户的权限缓存
$this->clearUserPermissionCacheByRole($roleId);
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 清理拥有此角色的用户的权限缓存
*
* @param string|int $roleId 角色ID
*/
private function clearUserPermissionCacheByRole(string|int $roleId): void
{
try {
// 获取拥有此角色的所有用户
/** @var \app\service\admin\system\AdminRoleService $adminRoleService */
$adminRoleService = Container::make(\app\service\admin\system\AdminRoleService::class);
// 获取拥有此角色的用户ID列表
$userIds = \app\model\system\AdminRole::where('role_id', $roleId)
->pluck('admin_id')
->toArray();
if (!empty($userIds)) {
// 清理这些用户的权限缓存
$currentUser = Container::make(\app\adminapi\CurrentUser::class);
foreach ($userIds as $userId) {
$currentUser->clearCache($userId);
}
}
} catch (\Throwable $e) {
// 缓存清理失败不影响主要业务,记录日志即可
\core\logger\Logger::error("清理角色{$roleId}的用户权限缓存失败: " . $e->getMessage());
}
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\RoleScopeDeptDao;
use core\base\BaseService;
/**
* @method getColumn(int[]|string[] $array, string $string)
*/
class RoleScopeDeptService extends BaseService
{
public function __construct(RoleScopeDeptDao $dao)
{
$this->dao = $dao;
}
}
@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\RoleDao;
use app\model\system\Role;
use core\base\BaseService;
use core\exception\handler\AdminException;
use madong\helper\Arr;
use madong\helper\PropertyCopier;
use support\Container;
class RoleService extends BaseService
{
/**
* 缓存key
*/
const CACHE_ALL_ROLES_DATA = 'roles_all_data';
public function __construct(RoleDao $dao)
{
$this->dao = $dao;
}
/**
* 获取所有的角色
*
* @param array $where
*
* @return array
* @throws \Exception
*/
public function getAllRoles(array $where = []): array
{
// 1. 优先从缓存获取所有角色数据
$allRoles = $this->cacheDriver()->remember(
self::CACHE_ALL_ROLES_DATA, // 缓存键名
function () {
$results = $this->dao->getModel()
->where('enabled', 1)
->orderBy('sort', 'asc')
->get(["id",
"pid",
"name",
"code",
"is_super_admin",
"role_type",
"data_scope",
"enabled",
"sort"]);
return $results->toArray();
}
);
// 2.$where 条件筛数据
return Arr::filterByWhere($allRoles, $where);
}
/**
* 传入角色id获取权限
*
* @param array $where
* @param string $mode merge|unique
*
* @return array
* @throws \Exception
*/
public function getPermissions(array $where = [], string $mode = 'merge'): array
{
// 1. 优先从缓存获取所有全量角色数据
$data = $this->cacheDriver()->remember(
self::CACHE_ALL_ROLES_DATA, // 缓存键名
function () {
return $this->dao->getRolePermissions();
}
);
//2. 条件过滤
$result = Arr::filterByWhere($data, $where);
if ($mode == 'unique') {
return $result['menu'] ?? [];
}
return [
'menu' => array_merge([], ...array_column($result, 'menu')),
'api' => array_merge([], ...array_column($result, 'api')),
'button' => array_merge([], ...array_column($result, 'button')),
];
}
/**
* 通过角色id 获取菜单权限IDS
*
* @param int|string $id
* @param string|null $tenantId
*
* @return array
*/
public function getPermissionColumns(int|string $id, string|null $tenantId = null): array
{
$role = $this->dao->get($id);
if (!$role) {
return [];
}
// 通过关联关系获取菜单ID
return $role->menus()->pluck('id')->toArray();
}
/**
* save
*
* @param array $data
*
* @return Role|null
* @throws \core\exception\handler\AdminException
*/
public function save(array $data): Role|null
{
try {
return $this->transaction(function () use ($data) {
$model = $this->dao->save($data);
$handlePermissions = isset($data['permissions']) && $data['permissions'] !== null;
// 有传权限key,才处理权限
if ($handlePermissions) {
$menus = $data['permissions'];
// 同步角色菜单关联
$model->menus()->sync($menus);
// 清理相关用户的权限缓存
$this->clearUserPermissionCacheByRole($model->id);
}
return $model;
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 编辑
*
* @param $id
* @param $data
*
* @return void
* @throws \core\exception\handler\AdminException
*/
// public function update($id, $data): void
// {
// try {
// $this->transaction(function () use ($id, $data) {
// // 获取传入的权限列表(如果存在)
// $menus = $data['permissions'] ?? [];
// // 从数据访问对象中获取模型实例
// $model = $this->dao->get($id);
// if (!$model) {
// return;
// }
// // 使用属性复制器将传入的数据复制到模型中
// PropertyCopier::copyProperties((object)$data, $model);
// $model->save();
//
// // 格式化用户标识符,用于 Casbin
// $roleId = PolicyPrefix::ROLE->value . strval($model->id);
// $domain = '*';
//
// // 获取当前用户在 Casbin 中的隐式资源权限
// $currentPolicies = Permission::getImplicitResourcesForUser($roleId, $domain);
//
// // 提取完整的当前权限(0-5)
// $currentPermissions = array_map(
// function ($policy) {
// return $policy; // 保留完整的策略数组
// },
// $currentPolicies
// );
//
// // 将传入的菜单数据转换为 Casbin 策略格式
// $menuService = new SysMenuService();
// $policyService = new SysCasbinService();
//
// $menusData = $menuService->getAllMenus(['id' => $menus], 'menu', false);
// // 构建传入的权限列表,格式与当前权限相同
// $incomingPolicies = [];
// foreach ($menusData as $item) {
// $path = $policyService->resolvePermissionIdentifier($item);
// // 构建策略数组
// $policy = [
// $roleId, // sub
// $domain, // dom
// $path, // obj
// '*', // act(根据你的业务需求调整)
// $item['methods'], // method
// PolicyPrefix::MENU->value . $item['id'], // trace_id 资源归属用于追踪
// ];
// // 保留完整的策略数组
// $incomingPolicies[] = $policy;
// }
// // 更新 Casbin 权限
// $policyService->updateCasbinPolicies($currentPermissions, $incomingPolicies);
// // 同步 Casbin 权限,确保只有授权角色保留
// $model->casbin()->sync([$roleId]);
// });
// } catch (\Throwable $e) {
// // 捕获异常并抛出自定义异常
// throw new AdminException($e->getMessage());
// }
/**
* 编辑
*
* @param $id
* @param $data
*
* @return void
* @throws \core\exception\handler\AdminException
*/
public function update($id, $data): void
{
try {
$this->transaction(function () use ($id, $data) {
$model = $this->dao->get($id);
if (!$model) {
return;
}
PropertyCopier::copyProperties((object)$data, $model);
$model->save();
// 检查 permissions 是否存在且不为 null(未定义或显式 null 时不处理权限)
$handlePermissions = isset($data['permissions']) && $data['permissions'] !== null;
if ($handlePermissions) {
$menus = $data['permissions']; // 获取权限列表
// 同步角色菜单关联
$model->menus()->sync($menus);
// 清理相关用户的权限缓存
$this->clearUserPermissionCacheByRole($model->id);
}
});
} catch (\Throwable $e) {
// 捕获异常并抛出自定义异常
throw new AdminException($e->getMessage());
}
}
/**
* 删除角色
*
* @param array $id
* @param bool $force
*
* @return mixed
* @throws \core\exception\handler\AdminException
*/
public function destroy(array $id, bool $force = false): mixed
{
try {
return $this->transaction(function () use ($id, $force) {
$data = Arr::normalize($id);
$deletedIds = [];
foreach ($data as $id) {
$item = $this->get($id);
if ($item) {
$primaryKey = $item->getPk();
// 清理相关用户的权限缓存
$this->clearUserPermissionCacheByRole($id);
$item->delete();
$deletedIds[] = $item->{$primaryKey};
}
}
return $deletedIds;
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 分配数据权限
*
* @param $id
* @param $data
*
* @throws \core\exception\handler\AdminException
*/
public function updateScope($id, $data): void
{
try {
$this->transaction(function () use ($id, $data) {
$scope = $data['scopes'] ?? [];
$model = $this->dao->get($id);
PropertyCopier::copyProperties((object)$data, $model);
if (!empty($model)) {
$model->save();
if ($model->data_scope !== 2) {
$scope = [];
}
$model->scopes()->sync($scope);
}
$this->cacheDriver()->delete(self::CACHE_ALL_ROLES_DATA);
});
} catch (\Throwable $e) {
throw new AdminException($e->getMessage());
}
}
/**
* 清理角色的用户权限缓存
*
* @param int|string $roleId
*/
private function clearUserPermissionCacheByRole(int|string $roleId): void
{
// 获取拥有此角色的所有用户
/** @var AdminRoleService $adminRoleService */
$adminRoleService = Container::make(AdminRoleService::class);
$userIds = $adminRoleService->getUserIdsByRoleId($roleId);
if (!empty($userIds)) {
// 清理每个用户的权限缓存
foreach ($userIds as $userId) {
$cacheKey = 'user_permissions_' . $userId;
$this->cacheDriver()->delete($cacheKey);
}
}
}
}
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\service\core\metadata\MetadataCollectorService;
use core\base\BaseService;
use support\Container;
/**
* 规则扫描服务
* 使用 MetadataCollectorService 扫描控制器,提供缓存机制
*/
class RuleService extends BaseService
{
/**
* 缓存键
*/
const CACHE_KEY = 'rule_data';
const CACHE_TTL = 300; // 缓存5分钟
/**
* 获取扫描的权限列表(带缓存)
*
* @return array
* @throws \Exception
*/
public function getPermissions(): array
{
return $this->cacheDriver()->remember(
self::CACHE_KEY,
function () {
$collector = Container::get(MetadataCollectorService::class);
return $collector->collect();
},
self::CACHE_TTL
);
}
/**
* 获取分类树(基于 tags)
*
* @return array
* @throws \Exception
*/
public function getCategories(): array
{
$permissions = $this->getPermissions();
$categories = [];
$categoryMap = [];
foreach ($permissions as $permission) {
// 确保 tags 是字符串数组
$tags = $permission['tags'] ?? ['未分类'];
if (!is_array($tags)) {
$tags = [$tags];
}
// 确保数组中的每个元素都是字符串
$tags = array_map(function($tag) {
if (is_string($tag)) {
return $tag;
}
if (is_object($tag) && isset($tag->name)) {
return $tag->name;
}
return (string) $tag;
}, $tags);
foreach ($tags as $tag) {
if (!isset($categoryMap[$tag])) {
$categoryId = md5($tag);
$categoryMap[$tag] = $categoryId;
$categories[] = [
'id' => $categoryId,
'pid' => 0,
'name' => $tag,
'value' => $categoryId,
'label' => $tag,
'children' => [],
];
}
}
}
return $categories;
}
/**
* 根据分类获取接口列表
*
* @param string|null $categoryId
* @param string|null $keyword
*
* @return array
* @throws \Exception
*/
public function getRoutesByCategory(?string $categoryId = null, ?string $keyword = null): array
{
$permissions = $this->getPermissions();
$routes = [];
foreach ($permissions as $permission) {
// 确保 tags 是字符串数组
$tags = $permission['tags'] ?? ['未分类'];
if (!is_array($tags)) {
$tags = [$tags];
}
// 确保数组中的每个元素都是字符串
$tags = array_map(function($tag) {
if (is_string($tag)) {
return $tag;
}
if (is_object($tag) && isset($tag->name)) {
return $tag->name;
}
return (string) $tag;
}, $tags);
// 过滤分类
if ($categoryId !== null) {
$categoryIds = array_map(fn($tag) => md5($tag), $tags);
if (!in_array($categoryId, $categoryIds)) {
continue;
}
}
// 过滤关键词
if ($keyword !== null && !empty($keyword)) {
// 构建搜索文本,只使用字符串类型的字段
$searchParts = [];
// 添加 tags
$searchParts[] = implode(' ', $tags);
// 添加其他字段(确保是字符串)
$fields = ['summary', 'description', 'controller', 'method', 'code', 'route'];
foreach ($fields as $field) {
if (isset($permission[$field]) && is_string($permission[$field])) {
$searchParts[] = $permission[$field];
}
}
$searchText = implode(' ', $searchParts);
if (stripos($searchText, $keyword) === false) {
continue;
}
}
$routes[] = [
'id' => md5($permission['route'] . '_' . $permission['method']),
'name' => $permission['summary'] ?? $permission['description'] ?? $permission['code'],
'method' => $this->extractHttpMethod($permission['method']),
'path' => $permission['route'],
'code' => $permission['code'],
'controller' => $permission['controller'],
'action' => $permission['method'],
'description' => $permission['description'] ?? '',
'tags' => $tags,
];
}
return $routes;
}
/**
* 从方法名中提取 HTTP 方法
*
* @param string $methodName
*
* @return string
*/
private function extractHttpMethod(string $methodName): string
{
$prefixes = ['get', 'post', 'put', 'delete', 'patch'];
$lowerMethod = strtolower($methodName);
foreach ($prefixes as $prefix) {
if (str_starts_with($lowerMethod, $prefix)) {
return strtoupper($prefix);
}
}
return 'GET';
}
/**
* 清除缓存
*
* @return void
*/
public function clearCache(): void
{
$this->cacheDriver()->delete(self::CACHE_KEY);
}
/**
* 手动触发扫描并刷新缓存
*
* @return array
* @throws \Exception
*/
public function refresh(): array
{
$this->clearCache();
return $this->getPermissions();
}
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\service\admin\system;
use app\dao\system\UploadDao;
use core\base\BaseService;
use core\exception\handler\AdminException;
use core\upload\UploadFile;
use madong\helper\Arr;
use support\Container;
class UploadService extends BaseService
{
public function __construct(UploadDao $dao)
{
$this->dao = $dao;
}
/**
* 远程下载图片到本地
*
* @param string $url
*
* @return mixed
* @throws \Exception
*/
public function saveNetworkImage(string $url): array
{
$config = UploadFile::config('local');
$data = file_get_contents($url);
if ($data === false) {
throw new AdminException('获取文件资源失败');
}
$image_resource = imagecreatefromstring($data);
if (!$image_resource) {
throw new AdminException('创建图片资源失败');
}
$filename = basename($url);
$file_extension = pathinfo($filename, PATHINFO_EXTENSION);
$full_dir = runtime_path() . '/resource/';
if (!is_dir($full_dir)) {
mkdir($full_dir, 0777, true);
}
$save_path = $full_dir . $filename;
$content_type = 'image/';
switch ($file_extension) {
case 'jpg':
case 'jpeg':
$content_type = 'image/jpeg';
$result = imagejpeg($image_resource, $save_path);
break;
case 'png':
$content_type = 'image/png';
$result = imagepng($image_resource, $save_path);
break;
case 'gif':
$content_type = 'image/gif';
$result = imagegif($image_resource, $save_path);
break;
default:
imagedestroy($image_resource);
throw new AdminException('文件格式错误');
}
imagedestroy($image_resource);
if (!$result) {
throw new AdminException('文件保存失败');
}
$hash = md5_file($save_path);
$size = filesize($save_path);
$result = $this->dao->get(['hash' => $hash]);
if (!empty($result)) {
unlink($save_path);
return $result->toArray();
}
/** @var ConfigService $systemConfigService */
$systemConfigService = Container::get(ConfigService::class);
$local = $systemConfigService->dao->get(['code' => 'local']);
if (empty($local)) {
throw new AdminException('缺少本地上传配置信息');
}
$root = Arr::fetchConfigValue($config, 'root');
$folder = date('Ymd');
$full_dir = base_path() . DIRECTORY_SEPARATOR . $root . $folder . DIRECTORY_SEPARATOR;
if (!is_dir($full_dir)) {
mkdir($full_dir, 0777, true);
}
$object_name = bin2hex(pack('Nn', time(), random_int(1, 65535))) . ".$file_extension";
$newPath = $full_dir . $object_name;
copy($save_path, $newPath);
unlink($save_path);
$domain = Arr::fetchConfigValue($config, 'domain');
$dirname = Arr::fetchConfigValue($config, 'dirname');
$baseUrl = $dirname . $folder . '/';
$info['platform'] = 'local';
$info['original_filename'] = $filename;
$info['filename'] = $object_name;
$info['hash'] = $hash;
$info['content_type'] = $content_type;
$info['base_path'] = $root . $folder . '/' . $object_name;
$info['ext'] = $file_extension;
$info['size'] = $size;
$info['size_info'] = formatBytes($size);
$info['url'] = $baseUrl . $object_name;//访问地
$result = $this->dao->save($info);
return $result->toArray();
}
/**
* 文件上传
*
* @param string $upload
* @param bool $isLocal
*
* @return mixed
* @throws \Throwable
*/
public function upload(string $upload = '', bool $isLocal = false): mixed
{
try {
return $this->transaction(function () use ($upload, $isLocal) {
/** @var ConfigService $systemConfigService */
$baseConfig = UploadFile::config('upload');//获取上次配置
if (empty($baseConfig)) {
throw new AdminException('缺少上传配置信息');
}
$type = Arr::fetchConfigValue($baseConfig, 'mode') ?: 'local';//上次模式默认本地
if ($isLocal) {
$type = 'local';
}
$options = [];
if (!empty($upload)) {
$options['sub_dir'] = $upload;
}
$result = UploadFile::uploadFile($options);
$data = $result[0];
$hash = $data['unique_id'];
$url = str_replace('\\', '/', $data['url']);
$path = str_replace('\\', '/', $data['save_path']);
// 检查文件是否已存在
if ($filesInfo = $this->dao->get(['hash' => $hash])) {
return $filesInfo;
}
$inData = [
'platform' => $type,
'original_filename' => $data['origin_name'] ?? '',
'filename' => $data['save_name'],
'hash' => $hash,
'content_type' => $data['mime_type'],
'base_path' => $data['base_path'],
'ext' => $data['extension'],
'size' => $data['size'],
'size_info' => formatBytes($data['size']),
'url' => full_url($url),
'path' => $path,
];
return $this->dao->save($inData);
});
} catch (\Exception $e) {
throw new AdminException($e->getMessage());
}
}
}