Files

310 lines
10 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
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\model;
use think\model\concern\SoftDelete;
use ywxapp\model\BaseModel;
/**
* Backend 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class BackendAdmin extends BaseModel
{
use SoftDelete;
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'backend_admin',
'autoWriteTimestamp' => 'int',
'createTime' => 'create_at',
'updateTime' => 'update_at',
'deleteTime' => 'delete_at',
'defaultSoftDelete' => 0,
// 'dateFormat' => 'Y-m-d H:i:s',
'append' => ['status_text', 'last_login'],
'hidden' => ['password', 'delete_at'],
'readonly' => ['id'],
];
}
/**
* 管理员角色关联
*/
public function roles()
{
return $this->belongsToMany(BackendRole::class, BackendRoleAccess::class, 'role_id', 'admin_id');
}
// 密码字段自动加密
public function setPasswordAttr($value)
{
// 使用PHP内置的password_hash,无需salt字段
if (! password_get_info($value)['algo']) {
// 如果不是已哈希的密码,则进行哈希处理
return password_hash($value, PASSWORD_DEFAULT);
}
return $value;
}
// 验证密码
public function checkPassword($password)
{
return password_verify($password, $this->password);
}
// 获取状态文本
public function getStatusTextAttr()
{
$statusMap = [0 => '禁用', 1 => '正常', 2 => '锁定'];
return $statusMap[$this->status] ?? '未知';
}
// 获取最后登录描述
public function getLastLoginAttr()
{
if (empty($this->login_time)) {
return '从未登录';
}
$diff = time() - strtotime($this->login_time);
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return floor($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return floor($diff / 3600) . '小时前';
} else {
return date('Y-m-d', strtotime($this->login_time));
}
}
// 检查账户是否被锁定
public function isLocked()
{
if ($this->status == 0) {
return true; // 已禁用
}
if ($this->fail_count >= 5 && ! empty($this->lock_time)) {
$lockUntil = strtotime($this->lock_time) + 1800; // 锁定30分钟
return time() < $lockUntil;
}
return false;
}
// 重置密码
public function resetPassword($newPassword)
{
$this->password = $newPassword; // 会触发setPasswordAttr自动加密
$this->need_reset = 0; // 标记无需重置
return $this->save();
}
// 记录登录失败(累计失败次数,超过阈值锁定)
public function recordLoginFail($ip)
{
$this->fail_count += 1;
if ($this->fail_count >= 5) {
$this->lock_time = date('Y-m-d H:i:s');
}
$this->save();
}
// 记录登录成功(重置失败计数与锁定)
public function recordLoginSuccess()
{
$this->login_time = time();
$this->fail_count = 0;
$this->lock_time = null;
$this->save();
}
// public function getAvatarAttr($value, $data)
// {
// return '/static/common/images/avatar.jpg';
// }
/**
* 获取用户有权访问的菜单树
*/
public function getAccessibleMenus()
{
$roleIds = $this->roles->column('id');
$roleNames = $this->roles->column('name');
// 超级管理员:直接可见全部后台菜单(与 getPermissionNames 的 * 语义保持一致)
$superRoleId = config('ywxapp.superAdmin', 1);
if (in_array('superadmin', $roleNames, true) || in_array($superRoleId, $roleIds, true)) {
$flatMenus = BackendPower::field('id,title,name,pid,sort,route,icon,type,addon')
->where('type', '<', '3')
->order('sort', 'asc')
->select()
->toArray();
foreach ($flatMenus as &$menu) {
// 依据权限表 addon 字段判定:空=核心后台菜单(拼 /admin/ 前缀),非空=插件菜单(完整路径直出)。
$menu['route'] = $this->buildMenuUrl($menu['route'], $menu['addon'] ?? null);
}
return BackendPower::buildTree($flatMenus);
}
if (empty($roleIds)) {
return [];
}
$permissions = BackendRolePower::where('role_id', 'in', $roleIds)
->distinct(true)
->column('power_id');
if (empty($permissions)) {
return [];
}
$flatMenus = BackendPower::field('id,title,name,pid,sort,route,icon,type,addon')
->whereIn('id', array_values($permissions))
->where('type', '<', '3') // 只获取菜单权限
->order('sort', 'asc')
->select()
->toArray();
foreach ($flatMenus as &$menu) {
// 依据权限表 addon 字段判定:空=核心后台菜单(直接拼 URL),非空=插件菜单(走 url 反转)。
$menu['route'] = $this->buildMenuUrl($menu['route'], $menu['addon'] ?? null);
}
return BackendPower::buildTree($flatMenus);
}
/**
* 获取用户所有权限标识(字符串数组)
*/
public function getAllPermissions(): array
{
$roleIds = $this->roles->column('id');
if (empty($roleIds)) {
return [];
}
return BackendRolePower::where('role_id', 'in', $roleIds)
->distinct(true)
->column('power_id');
}
/**
* 获取用户的所有权限(通过角色)
*/
public function permissions()
{
return $this->belongsToMany(BackendPower::class, BackendRolePower::class, 'power_id', 'role_id')->via('roles'); // 通过 roles 关联自动关联权限
}
/**
* 获取权限标识数组(name)
* 通过 角色→权限 关联可靠地取出当前管理员拥有的权限 key
*/
public function getPermissionNames()
{
$roleIds = $this->roles->column('id');
if (empty($roleIds)) {
return [];
}
// 拥有超级管理员角色:权限恒为 *(全部权限),校验/展示均以 * 表示
$superRoleId = config('ywxapp.superAdmin', 1);
$roleNames = $this->roles->column('name');
if (in_array('superadmin', $roleNames, true) || in_array($superRoleId, $roleIds, true)) {
return ['*'];
}
$powerIds = BackendRolePower::where('role_id', 'in', $roleIds)
->distinct(true)
->column('power_id');
if (empty($powerIds)) {
return [];
}
return BackendPower::whereIn('id', $powerIds)->column('name');
}
/**
* 检查是否有某权限
*/
public function can($permissionName)
{
$names = $this->getPermissionNames();
// 权限集合含 * 表示拥有全部权限
return in_array('*', $names, true) || in_array($permissionName, $names);
}
/**
* 菜单 URL 生成
*
* 依据权限表 addon 字段判定归属:
* - addon 为空:核心后台应用内菜单,直接按 app_map 反查 backend 应用前缀拼接
* `/{prefix}/{route}.html`,不调用 url() 反转,彻底规避插件路由抢注与
* IP/域名访问差异导致的生成异常。
* - addon 非空:插件后台菜单,走 url() 反转(全限定 `addon/backend/...`
* 不会被插件路由同名规则抢注)。
* - 外部链接(http://、https://、//)原样返回;空 route 返回 #。
*
* @param string|null $route 菜单路由(如 links/index、framework/index、haonav/backend/links
* @param string|null $addon 权限表 addon 字段(核心菜单为 null/空,插件菜单为插件名)
*/
protected function buildMenuUrl(?string $route, ?string $addon = null): string
{
if (! $route) {
return '#';
}
if (preg_match('#^(https?://|//)#i', $route)) {
return $route;
}
$route = ltrim($route, '/');
// 插件菜单:route 字段已是完整可访问路径(如 /appmall/backend/developer/index
// 与插件 menu.json 约定一致),直接拼后缀输出即可。
// 切勿用 url() 反转:url() 会将该字符串当成 MVC 控制器地址解析,导致被误映射到
// 核心后台 /admin/ 前缀(如 /admin/developer/index.html),而非插件真实路径。
if (! empty($addon)) {
$suffix = config('route.url_html_suffix');
$u = '/' . $route;
if ($suffix && $suffix !== '' && ! preg_match('/\.' . preg_quote(ltrim($suffix, '.'), '/') . '$/i', $u)) {
$u .= '.' . ltrim($suffix, '.');
}
return $u;
}
// 核心后台菜单:按 app_map 反查 backend 应用前缀,直接拼接,避免 url() 反转歧义
$appMap = (array) config('app.app_map');
$appPrefix = array_search('backend', $appMap, true);
if (! $appPrefix) {
$appPrefix = 'admin';
}
return '/' . $appPrefix . '/' . $route . '.html';
}
/**
* 表结构自愈:建表 + 关键列兜底(install.sql 为事实源)。
* 查询/写入前调用,避免老库缺表导致 1146。
*/
public static function ensureSchema(): void
{
$prefix = BaseModel::currentPrefix();
foreach (['backend_admin', 'backend_log', 'backend_power', 'backend_role', 'backend_role_access', 'backend_role_power'] as $t) {
BaseModel::ensureTableFromInstall($prefix, $t);
}
}
}