// +---------------------------------------------------------------------- namespace ywxapp\model; use think\model\concern\SoftDelete; use ywxapp\model\BaseModel; /** * Backend 类 * * @author ywxapp */ 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); } } }