Files
2026-08-31 11:33:59 +08:00

724 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
*+------------------
* 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);
}
}