Files
YwxAppThink/ywxapp/model/BackendAdmin.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

330 lines
11 KiB
PHP
Raw 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,
'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';
// }
/**
* 是否 is_super 标记的超管(backend_admin.is_super = 1)
*
* 兜底场景:角色数据(backend_role / backend_role_access)被清空时,
* 仅按角色判定会让超管账号登录后菜单与权限全空、后台完全不可用,
* 因此这里同时认可 is_super 字段(与角色判定是「或」关系)。
*/
protected function isSuperFlag(): bool
{
return (int) ($this->getData('is_super') ?? 0) === 1;
}
/**
* 获取用户有权访问的菜单树
*/
public function getAccessibleMenus()
{
$roleIds = $this->roles->column('id');
$roleNames = $this->roles->column('name');
// 超级管理员:直接可见全部后台菜单(与 getPermissionNames 的 * 语义保持一致)
$superRoleId = config('ywxapp.superAdmin', 1);
if ($this->isSuperFlag()
|| in_array('superadmin', $roleNames, true)
|| in_array($superRoleId, $roleIds, true)) {
// type: 1目录 2菜单 5分组标题(组内小标题,不可点击)
$flatMenus = BackendPower::field('id,title,name,pid,sort,route,icon,type,addon')
->whereIn('type', [1, 2, BackendPower::TYPE_GROUP_TITLE])
->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))
// 只获取菜单权限(含 type=5 分组标题,缺了它侧边栏就少一行分组名)
->whereIn('type', [1, 2, BackendPower::TYPE_GROUP_TITLE])
->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()
{
// is_super=1 的账号即使没有角色关联也拥有全部权限(兜底同上)
if ($this->isSuperFlag()) {
return ['*'];
}
$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);
}
}
}