Files
MrleeAdmin/backend/app/service/admin/system/AdminService.php
T

724 lines
22 KiB
PHP
Raw Normal View History

2026-08-31 11:33:59 +08:00
<?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);
}
}