This commit is contained in:
2025-08-13 18:31:52 +08:00
parent 17ad88608b
commit 275713f893
77 changed files with 10658 additions and 178 deletions

View File

@@ -0,0 +1,203 @@
<?php
namespace app\common\controller;
use app\common\library\Auth;
use app\common\library\token\TokenExpirationException;
use support\Request;
use HttpResponseException;
use support\Response;
abstract class BaseController
{
/**
* 无需登录的方法
* 访问本控制器的此方法,无需会员登录
* @var array
*/
protected array $noNeedLogin = [];
/**
* 默认响应输出类型,支持json/xml/jsonp
* @var string
*/
protected string $responseType = 'application/json';
/**
* 无需鉴权的方法
* @var array
*/
protected array $noNeedPermission = [];
protected Request $request;
/**
* 权限类实例
* @var Auth
*/
protected Auth $auth;
public function __construct()
{
$this->request = request();
$needLogin = !action_in_arr($this->noNeedLogin);
// 初始化会员鉴权实例
$this->auth = Auth::instance();
$token = request()->header('token');
if ($token) {
if(!$this->auth->init($token)) {
$this->error('Token expiration', [], 409);
}
};
if ($needLogin) {
$this->error('Token expiration', [], 409);
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
$this->error('请登录', [
'type' => $this->auth::NEED_LOGIN
], $this->auth::LOGIN_RESPONSE_CODE);
}
}
}
/**
* 操作成功
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function success(string $msg = '', mixed $data = null, int $code = 0, string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options);
}
protected function successWithData(mixed $data = null, string $msg = '', int $code = 0, string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options);
}
/**
* 操作失败
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function error(string $msg = '', mixed $data = null, int $code = -1, string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options);
}
protected function errorMsg(string $msg = '', mixed $data = null, int $code = -1, string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options, 'data', 'msg');
}
/**
* 操作成功
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function n_success(array $data, string $msg = 'ok', int $code = 0, string $type = null, array $header = [], array $options = []): void
{
$this->resultApi($data, $msg, $code, $type, $header, $options);
}
/**
* 操作失败
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function n_error(string $msg, array $data = [], int $code = -1, string $type = null, array $header = [], array $options = []): void
{
$this->resultApi($data, $msg, $code, $type, $header, $options);
}
/**
* 多种操作结果
*/
public function resultApi(array $data = [], string $msg = 'ok', int $code = 0, string $type = null, array $header = [], array $options = [])
{
$data['code'] = $code;
$data['message'] = $msg;
$data['time'] = $this->request->server('REQUEST_TIME');
if(isset($data['data']['records'])) {
$data['data']['records'] = apiconvertToCamelCase($data['data']['records']);
}
if(isset($data['page']['list'])) {
$data['page']['list'] = apiconvertToCamelCase($data['page']['list']);
}
if(isset($data['data']['list'])) {
$data['data']['list'] = apiconvertToCamelCase($data['data']['list']);
}
if(isset($data['data']['userEntity'])) {
$data['data']['userEntity'] = apiconvertToCamelCase($data['data']['userEntity']);
}
$result = $data;
$type = $type ?: $this->responseType;
$response = new Response();
$response->header('Content-Type', $type);
$response->withBody($result);
return $response;
}
/**
* 返回 API 数据
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
public function result(string $msg, mixed $data = null, int $code = 0, string $type = null, array $header = [], array $options = [], string $dataKey = 'data', string $msgKey = 'message')
{
if(isset($data['records'])) {
$data['records'] = apiconvertToCamelCase($data['records']);
}
if(isset($data['page'])) {
$data['page'] = apiconvertToCamelCase($data['page']);
}
if(isset($data['list'])) {
$data['list'] = apiconvertToCamelCase($data['list']);
}
$result = [
'code' => $code,
'message' => $msg,
'time' => time(),
$dataKey => $data,
];
throw new \app\exception\HttpResponseException(json($result));
}
public function ApiDataReturn($data)
{
return json($data);
}
}

651
app/common/library/Auth.php Normal file
View File

@@ -0,0 +1,651 @@
<?php
namespace app\common\library;
use app\api\model\TbUser;
use app\api\model\TbUserBlacklist;
use app\api\model\UserInfo;
use app\common\model\Common;
use app\common\model\SysUser;
use app\utils\JwtUtils;
use think\facade\Cache;
use Throwable;
use ba\Random;
use think\facade\Db;
use think\facade\Event;
use think\facade\Config;
use app\common\model\User;
use think\facade\Validate;
use app\common\facade\Token;
/**
* 公共权限类(会员权限类)
* @property int $id 会员ID
* @property string $username 会员用户名
* @property string $nickname 会员昵称
* @property string $email 会员邮箱
* @property string $mobile 会员手机号
* @property string $password 密码密文
* @property string $salt 密码盐
*/
class Auth
{
/**
* 需要登录时/无需登录时的响应状态代码
*/
public const LOGIN_RESPONSE_CODE = 401;
/**
* 需要登录标记 - 前台应清理 token、记录当前路由 path、跳转到登录页
*/
public const NEED_LOGIN = 'need login';
/**
* 已经登录标记 - 前台应跳转到基础路由
*/
public const LOGGED_IN = 'logged in';
/**
* token 入库 type
*/
public const TOKEN_TYPE = 'user';
/**
* 是否登录
* @var bool
*/
protected bool $loginEd = false;
/**
* 错误消息
* @var string
*/
protected string $error = '';
/**
* Model实例
* @var ?User
*/
protected $model = null;
protected $user_id_db_connect = null;
/**
* 令牌
* @var string
*/
protected string $token = '';
/**
* 刷新令牌
* @var string
*/
protected string $refreshToken = '';
/**
* 令牌默认有效期
* 可在 config/buildadmin.php 内修改默认值
* @var int
*/
protected int $keepTime = 0;
/**
* 刷新令牌有效期
* @var int
*/
protected int $refreshTokenKeepTime = 0;
/**
* 允许输出的字段
* @var array
*/
protected array $allowFields = ['user_id', 'username', 'nickname', 'email', 'mobile', 'avatar', 'gender', 'birthday', 'money', 'score', 'join_time', 'motto', 'last_login_time', 'last_login_ip'];
public function __construct(array $config = [])
{
// parent::__construct(array_merge([
// 'auth_group' => 'user_group', // 用户组数据表名
// 'auth_group_access' => '', // 用户-用户组关系表(关系字段)
// 'auth_rule' => 'user_rule', // 权限规则表
// ], $config));
//
// $this->setKeepTime((int)config('buildadmin.user_token_keep_time'));
}
/**
* 魔术方法-会员信息字段
* @param $name
* @return mixed 字段信息
*/
public function __get($name): mixed
{
return $this->model[$name];
}
/**
* 初始化
* @access public
* @param array $options 传递给 /ba/Auth 的参数
* @return Auth
*/
public static function instance(array $options = []): Auth
{
$request = request();
if (!isset($request->userAuth)) {
$request->userAuth = new static($options);
}
return $request->userAuth;
}
/**
* 根据Token初始化会员登录态
* @param $token
* @return bool
* @throws Throwable
*/
public function init($token): bool
{
$jwtUtil = (new JwtUtils());
$tokenData = $jwtUtil->getClaimByToken($token);
if ($tokenData) {
/**
* 过期检查,过期则抛出 @see TokenExpirationException
*/
$userId = $tokenData->sub;
if (!isset($tokenData->type) || ($tokenData->type == self::TOKEN_TYPE && $userId > 0)) {
$where = $sale = ['user_id' => $userId];
$user_id_db_name = DatabaseRoute::getConnection('tb_user', $sale);
$this->model = Db::connect($user_id_db_name)->name('tb_user')->where($where)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model['status'] != 1) {
$this->setError('Account disabled');
return false;
}
$this->token = $token;
$this->newloginSuccessful();
return true;
}
}
$this->setError('Token login failed');
$this->reset();
return false;
}
/**
* 会员注册,可使用关键词参数方式调用:$auth->register('u18888888888', email: 'test@qq.com')
* @param string $username
* @param string $password
* @param string $mobile
* @param string $email
* @param int $group 会员分组 ID 号
* @param array $extend 扩展数据,如 ['status' => 'disable']
* @return bool
*/
public function register(string $username, string $password = '', string $mobile = '', string $email = '', int $group = 1, array $extend = []): bool
{
$validate = Validate::rule([
'email|' . __('Email') => 'email|unique:user',
'mobile|' . __('Mobile') => 'mobile|unique:user',
'username|' . __('Username') => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:user',
'password|' . __('Password') => 'regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
]);
$params = [
'username' => $username,
'password' => $password,
'mobile' => $mobile,
'email' => $email,
];
if (!$validate->check($params)) {
$this->setError($validate->getError());
return false;
}
// 按需生成随机密码
if (!$password) {
$password = Random::build();
}
// 用户昵称
$nickname = preg_replace_callback('/1[3-9]\d{9}/', function ($matches) {
// 对 username 中出现的所有手机号进行脱敏处理
$mobile = $matches[0];
return substr($mobile, 0, 3) . '****' . substr($mobile, 7);
}, $username);
$ip = request()->ip();
$time = time();
$data = [
'group_id' => $group,
'nickname' => $nickname,
'join_ip' => $ip,
'join_time' => $time,
'last_login_ip' => $ip,
'last_login_time' => $time,
'status' => 'enable', // 状态:enable=启用,disable=禁用,使用 string 存储可以自定义其他状态
];
$data = array_merge($params, $data);
$data = array_merge($data, $extend);
Db::startTrans();
try {
$this->model = User::create($data);
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
Db::commit();
$this->model->resetPassword($this->model->id, $password);
Event::trigger('userRegisterSuccess', $this->model);
} catch (Throwable $e) {
$this->setError($e->getMessage());
Db::rollback();
return false;
}
return true;
}
/**
* 会员登录
* @param string $username 用户名
* @param string $password 密码
* @param bool $keep 是否保持登录
* @return bool
* @throws Throwable
*/
public function login(string $username, string $password, bool $keep): bool
{
// 判断账户类型
$accountType = false;
$validate = Validate::rule([
'mobile' => 'mobile',
'email' => 'email',
'username' => 'regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$',
]);
if ($validate->check(['mobile' => $username])) $accountType = 'mobile';
if ($validate->check(['email' => $username])) $accountType = 'email';
if ($validate->check(['username' => $username])) $accountType = 'username';
if (!$accountType) {
$this->setError('Account not exist');
return false;
}
$this->model = User::where($accountType, $username)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status == 'disable') {
$this->setError('Account disabled');
return false;
}
// 登录失败重试检查
$userLoginRetry = Config::get('buildadmin.user_login_retry');
if ($userLoginRetry && $this->model->last_login_time) {
// 重置失败次数
if ($this->model->login_failure > 0 && time() - $this->model->last_login_time >= 86400) {
$this->model->login_failure = 0;
$this->model->save();
// 重获模型实例,避免单实例多次更新
$this->model = User::where($accountType, $username)->find();
}
if ($this->model->login_failure >= $userLoginRetry) {
$this->setError('Please try again after 1 day');
return false;
}
}
// 密码检查
if (!verify_password($password, $this->model->password, ['salt' => $this->model->salt])) {
$this->loginFailed();
$this->setError('Password is incorrect');
return false;
}
// 清理 token
if (Config::get('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
if ($keep) {
$this->setRefreshToken($this->refreshTokenKeepTime);
}
$this->loginSuccessful();
return true;
}
/**
* 会员登录
* @param string $username 用户名
* @param string $password 密码
* @param bool $keep 是否保持登录
* @return bool
* @throws Throwable
*/
public function newlogin(string $phone, string $password, bool $keep): bool
{
$this->model = TbUser::GetByusername($phone);
if (!$this->model) {
$this->setError('未注册, 请先注册');
return false;
}
if ($this->model['status'] == 0) {
$this->setError('账号已被禁用,请联系客服处理!');
return false;
}
if(empty($this->model['password'])) {
$this->setError('当前账号未绑定密码,请前往忘记密码中进行重置!');
return false;
}
// 验证密码
if(TbUser::CheckPassword($this->model['password'], $password)) {
// 实名认证信息
$userInfo = new UserInfo();
$where = ['user_id' => $this->model['user_id']];
$userInfo->setConnection($userInfo::findbefore($userInfo, $where));
$userInfo = $userInfo->where($where)->find();
if($userInfo && $userInfo->cert_no) {
// 继续查黑名单表
$userBlack = TbUserBlacklist::where(['id_card_no' => $userInfo->cert_no])->find();
if($userBlack) {
$this->setError('系统正在维护中,请稍后再试!');
return false;
}
}
// if ($keep) {
// $this->setRefreshToken($this->refreshTokenKeepTime);
// }
$this->newloginSuccessful();
return true;
}else {
$this->setError('账号或密码不正确!');
return false;
}
}
/**
* 直接登录会员账号
* @param int $userId 用户ID
* @return bool
* @throws Throwable
*/
public function direct(int $userId): bool
{
$this->model = User::find($userId);
if (!$this->model) return false;
if (Config::get('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
return $this->loginSuccessful();
}
/**
* 直接登录会员账号 new
* @param int $userId 用户ID
* @return bool
* @throws Throwable
*/
public function newdirect(int $userId): bool
{
if (Config::get('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
return $this->newloginSuccessful();
}
/**
* 登录成功 new
* @return bool
*/
public function newloginSuccessful(): bool
{
if (!$this->model) {
return false;
}
// 新增登录时间
$db = Db::connect(DatabaseRoute::getConnection('tb_user', ['user_id' => $this->model['user_id']], true));
$db->name('tb_user')->where(['user_id' => $this->model['user_id']])->update(['on_line_time' => date('Y-m-d H:i:s')]);
$this->loginEd = true;
if (!$this->token) {
$this->token = (new JwtUtils())->generateToken($this->model['user_id'], 'user');
}
return true;
}
/**
* 检查旧密码是否正确
* @param $password
* @return bool
* @deprecated 请使用 verify_password 公共函数代替
*/
public function checkPassword($password): bool
{
return verify_password($password, $this->model->password, ['salt' => $this->model->salt]);
}
/**
* 登录成功
* @return bool
*/
public function loginSuccessful(): bool
{
if (!$this->model) {
return false;
}
$this->model->startTrans();
try {
$this->model->login_failure = 0;
$this->model->last_login_time = time();
$this->model->last_login_ip = request()->ip();
$this->model->save();
$this->loginEd = true;
if (!$this->token) {
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return true;
}
/**
* 登录失败
* @return bool
*/
public function loginFailed(): bool
{
if (!$this->model) return false;
$this->model->startTrans();
try {
$this->model->login_failure++;
$this->model->last_login_time = time();
$this->model->last_login_ip = request()->ip();
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return $this->reset();
}
public function get_user_id_db_connect()
{
return $this->user_id_db_connect;
}
/**
* 退出登录
* @return bool
*/
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
/**
* 是否登录
* @return bool
*/
public function isLogin(): bool
{
return $this->loginEd;
}
/**
* 获取会员模型
* @return User
*/
public function getUser()
{
return $this->model;
}
/**
* 获取会员Token
* @return string
*/
public function getToken(): string
{
return $this->token;
}
/**
* 设置刷新Token
* @param int $keepTime
* @return void
*/
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
/**
* 获取会员刷新Token
* @return string
*/
public function getRefreshToken(): string
{
return $this->refreshToken;
}
/**
* 获取会员信息 - 只输出允许输出的字段
* @return array
*/
public function getUserInfo(): array
{
if (!$this->model) return [];
$info = $this->model;
$info = array_intersect_key($info, array_flip($this->getAllowFields()));
$info['token'] = $this->getToken();
$info['refresh_token'] = $this->getRefreshToken();
return $info;
}
/**
* 获取允许输出字段
* @return array
*/
public function getAllowFields(): array
{
return $this->allowFields;
}
/**
* 设置允许输出字段
* @param $fields
* @return void
*/
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
/**
* 设置Token有效期
* @param int $keepTime
* @return void
*/
public function setKeepTime(int $keepTime = 0): void
{
$this->keepTime = $keepTime;
}
/**
* 设置错误消息
* @param string $error
* @return Auth
*/
public function setError(string $error): Auth
{
$this->error = $error;
return $this;
}
/**
* 获取错误消息
* @return string
*/
public function getError(): string
{
return $this->error ? __($this->error) : '';
}
/**
* 属性重置(注销、登录失败、重新初始化等将单例数据销毁)
*/
protected function reset(bool $deleteToken = true): bool
{
if ($deleteToken && $this->token) {
// Token::delete($this->token);
}
$this->token = '';
$this->loginEd = false;
$this->model = null;
$this->refreshToken = '';
$this->setError('');
$this->setKeepTime((int)Config('buildadmin.user_token_keep_time'));
return true;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace app\common\library;
use Throwable;
use think\facade\Lang;
use PHPMailer\PHPMailer\PHPMailer;
/**
* 邮件类
* 继承PHPMailer并初始化好了站点系统配置中的邮件配置信息
*/
class Email extends PHPMailer
{
/**
* 是否已在管理后台配置好邮件服务
* @var bool
*/
public bool $configured = false;
/**
* 默认配置
* @var array
*/
public array $options = [
'charset' => 'utf-8', //编码格式
'debug' => true, //调式模式
'lang' => 'zh_cn',
];
/**
* 构造函数
* @param array $options
* @throws Throwable
*/
public function __construct(array $options = [])
{
$this->options = array_merge($this->options, $options);
parent::__construct($this->options['debug']);
$langSet = Lang::getLangSet();
if ($langSet == 'zh-cn' || !$langSet) $langSet = 'zh_cn';
$this->options['lang'] = $this->options['lang'] ?: $langSet;
$this->setLanguage($this->options['lang'], root_path() . 'vendor' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR);
$this->CharSet = $this->options['charset'];
$sysMailConfig = get_sys_config('', 'mail');
$this->configured = true;
foreach ($sysMailConfig as $item) {
if (!$item) {
$this->configured = false;
}
}
if ($this->configured) {
$this->Host = $sysMailConfig['smtp_server'];
$this->SMTPAuth = true;
$this->Username = $sysMailConfig['smtp_user'];
$this->Password = $sysMailConfig['smtp_pass'];
$this->SMTPSecure = $sysMailConfig['smtp_verification'] == 'SSL' ? self::ENCRYPTION_SMTPS : self::ENCRYPTION_STARTTLS;
$this->Port = $sysMailConfig['smtp_port'];
$this->setFrom($sysMailConfig['smtp_sender_mail'], $sysMailConfig['smtp_user']);
}
}
public function setSubject($subject): void
{
$this->Subject = "=?utf-8?B?" . base64_encode($subject) . "?=";
}
}

156
app/common/library/Menu.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
namespace app\common\library;
use Throwable;
use app\admin\model\AdminRule;
use app\admin\model\UserRule;
/**
* 菜单规则管理类
*/
class Menu
{
/**
* @param array $menu
* @param int|string $parent 父级规则name或id
* @param string $mode 添加模式(规则重复时):cover=覆盖旧菜单,rename=重命名新菜单,ignore=忽略
* @param string $position 位置:backend=后台,frontend=前台
* @return void
* @throws Throwable
*/
public static function create(array $menu, int|string $parent = 0, string $mode = 'cover', string $position = 'backend'): void
{
$pid = 0;
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$parentRule = $model->where((is_numeric($parent) ? 'id' : 'name'), $parent)->find();
if ($parentRule) {
$pid = $parentRule['id'];
}
foreach ($menu as $item) {
if (!self::requiredAttrCheck($item)) {
continue;
}
// 属性
$item['status'] = 1;
if (!isset($item['pid'])) {
$item['pid'] = $pid;
}
$sameOldMenu = $model->where('name', $item['name'])->find();
if ($sameOldMenu) {
// 存在相同名称的菜单规则
if ($mode == 'cover') {
$sameOldMenu->save($item);
} elseif ($mode == 'rename') {
$count = $model->where('name', $item['name'])->count();
$item['name'] = $item['name'] . '-CONFLICT-' . $count;
$item['path'] = $item['path'] . '-CONFLICT-' . $count;
$item['title'] = $item['title'] . '-CONFLICT-' . $count;
$sameOldMenu = $model->create($item);
} elseif ($mode == 'ignore') {
// 忽略同名菜单时,当前 pid 下没有同名菜单,则创建同名新菜单,以保证所有新增菜单的上下级结构
$sameOldMenu = $model
->where('name', $item['name'])
->where('pid', $item['pid'])
->find();
if (!$sameOldMenu) {
$sameOldMenu = $model->create($item);
}
}
} else {
$sameOldMenu = $model->create($item);
}
if (!empty($item['children'])) {
self::create($item['children'], $sameOldMenu['id'], $mode, $position);
}
}
}
/**
* 删菜单
* @param string|int $id 规则name或id
* @param bool $recursion 是否递归删除子级菜单、是否删除自身,是否删除上级空菜单
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
public static function delete(string|int $id, bool $recursion = false, string $position = 'backend'): bool
{
if (!$id) {
return true;
}
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return true;
}
$children = $model->where('pid', $menuRule['id'])->select()->toArray();
if ($recursion && $children) {
foreach ($children as $child) {
self::delete($child['id'], true, $position);
}
}
if (!$children || $recursion) {
$menuRule->delete();
self::delete($menuRule->pid, false, $position);
}
return true;
}
/**
* 启用菜单
* @param string|int $id 规则name或id
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
public static function enable(string|int $id, string $position = 'backend'): bool
{
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return false;
}
$menuRule->status = 1;
$menuRule->save();
return true;
}
/**
* 禁用菜单
* @param string|int $id 规则name或id
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
public static function disable(string|int $id, string $position = 'backend'): bool
{
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return false;
}
$menuRule->status = 0;
$menuRule->save();
return true;
}
public static function requiredAttrCheck($menu): bool
{
$attrs = ['type', 'title', 'name'];
foreach ($attrs as $attr) {
if (!array_key_exists($attr, $menu)) {
return false;
}
if (!$menu[$attr]) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace app\common\library;
/**
* 雪花ID生成类
*/
class SnowFlake
{
/**
* 起始时间戳
* @var int
*/
private const EPOCH = 1672502400000;
/**
* @var int
*/
private const max41bit = 1099511627775;
/**
* 机器节点 10bit
* @var int
*/
protected static int $machineId = 1;
/**
* 序列号
* @var int
*/
protected static int $count = 0;
/**
* 最后一次生成ID的时间偏移量
* @var int
*/
protected static int $last = 0;
/**
* 设置机器节点
* @param int $mId 机器节点id
* @return void
*/
public static function setMachineId(int $mId): void
{
self::$machineId = $mId;
}
/**
* 生成雪花ID
* @return float|int
*/
public static function generateParticle(): float|int
{
// 当前时间 42bit
$time = (int)floor(microtime(true) * 1000);
// 时间偏移量
$time -= self::EPOCH;
// 起始时间戳加上时间偏移量并转为二进制
$base = decbin(self::max41bit + $time);
// 追加节点机器id
if (!is_null(self::$machineId)) {
$machineId = str_pad(decbin(self::$machineId), 10, "0", STR_PAD_LEFT);
$base .= $machineId;
}
// 序列号
if ($time == self::$last) {
self::$count++;
} else {
self::$count = 0;
}
// 追加序列号部分
$sequence = str_pad(decbin(self::$count), 12, "0", STR_PAD_LEFT);
$base .= $sequence;
// 保存生成ID的时间偏移量
self::$last = $time;
// 返回64bit二进制数的十进制标识
return bindec($base);
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace app\common\library;
use think\helper\Arr;
use think\helper\Str;
use think\facade\Config;
use InvalidArgumentException;
use app\common\library\token\TokenExpirationException;
/**
* Token 管理类
*/
class Token
{
/**
* Token 实例
* @var array
* @uses Token 数组项
*/
public array $instance = [];
/**
* token驱动类句柄
* @var ?object
*/
public ?object $handler = null;
/**
* 驱动类命名空间
* @var string
*/
protected string $namespace = '\\app\\common\\library\\token\\driver\\';
/**
* 获取驱动句柄
* @param string|null $name
* @return object
*/
public function getDriver(string $name = null): object
{
if (!is_null($this->handler)) {
return $this->handler;
}
$name = $name ?: $this->getDefaultDriver();
if (is_null($name)) {
throw new InvalidArgumentException(
sprintf(
'Unable to resolve NULL driver for [%s].',
static::class
)
);
}
return $this->createDriver($name);
}
/**
* 创建驱动句柄
* @param string $name
* @return object
*/
protected function createDriver(string $name): object
{
$type = $this->resolveType($name);
$method = 'create' . Str::studly($type) . 'Driver';
$params = $this->resolveParams($name);
if (method_exists($this, $method)) {
return $this->$method(...$params);
}
$class = $this->resolveClass($type);
if (isset($this->instance[$type])) {
return $this->instance[$type];
}
return new $class(...$params);
}
/**
* 默认驱动
* @return string
*/
protected function getDefaultDriver(): string
{
return $this->getConfig('default');
}
/**
* 获取驱动配置
* @param string|null $name 要获取的配置项不传递获取完整token配置
* @param null $default
* @return array|string
*/
protected function getConfig(string $name = null, $default = null): array|string
{
if (!is_null($name)) {
return Config::get('buildadmin.token.' . $name, $default);
}
return Config::get('buildadmin.token');
}
/**
* 获取驱动配置参数
* @param $name
* @return array
*/
protected function resolveParams($name): array
{
$config = $this->getStoreConfig($name);
return [$config];
}
/**
* 获取驱动类
* @param string $type
* @return string
*/
protected function resolveClass(string $type): string
{
if ($this->namespace || str_contains($type, '\\')) {
$class = str_contains($type, '\\') ? $type : $this->namespace . Str::studly($type);
if (class_exists($class)) {
return $class;
}
}
throw new InvalidArgumentException("Driver [$type] not supported.");
}
/**
* 获取驱动配置
* @param string $store
* @param string|null $name
* @param null $default
* @return array|string
*/
protected function getStoreConfig(string $store, string $name = null, $default = null): array|string
{
if ($config = $this->getConfig("stores.$store")) {
return Arr::get($config, $name, $default);
}
throw new InvalidArgumentException("Store [$store] not found.");
}
/**
* 获取驱动类型
* @param string $name
* @return string
*/
protected function resolveType(string $name): string
{
return $this->getStoreConfig($name, 'type', 'Mysql');
}
/**
* 设置token
* @param string $token
* @param string $type
* @param int $user_id
* @param int|null $expire
* @return bool
*/
public function set(string $token, string $type, int $user_id, int $expire = null): bool
{
return $this->getDriver()->set($token, $type, $user_id, $expire);
}
/**
* 获取token
* @param string $token
* @param bool $expirationException
* @return array
*/
public function get(string $token, bool $expirationException = true): array
{
return $this->getDriver()->get($token, $expirationException);
}
/**
* 检查token
* @param string $token
* @param string $type
* @param int $user_id
* @param bool $expirationException
* @return bool
*/
public function check(string $token, string $type, int $user_id, bool $expirationException = true): bool
{
return $this->getDriver()->check($token, $type, $user_id, $expirationException);
}
/**
* 删除token
* @param string $token
* @return bool
*/
public function delete(string $token): bool
{
return $this->getDriver()->delete($token);
}
/**
* 清理指定用户token
* @param string $type
* @param int $user_id
* @return bool
*/
public function clear(string $type, int $user_id): bool
{
return $this->getDriver()->clear($type, $user_id);
}
/**
* Token过期检查
* @throws TokenExpirationException
*/
public function tokenExpirationCheck(array $token): void
{
if (isset($token['expire_time']) && $token['expire_time'] <= time()) {
throw new TokenExpirationException();
}
}
}

View File

@@ -0,0 +1,341 @@
<?php
namespace app\common\library;
use Throwable;
use ba\Random;
use ba\Filesystem;
use think\Exception;
use think\helper\Str;
use think\facade\Config;
use think\facade\Validate;
use think\file\UploadedFile;
use InvalidArgumentException;
use think\validate\ValidateRule;
use app\common\model\Attachment;
use app\common\library\upload\Driver;
/**
* 上传
*/
class Upload
{
/**
* 上传配置
*/
protected array $config = [];
/**
* 被上传文件
*/
protected ?UploadedFile $file = null;
/**
* 是否是图片
*/
protected bool $isImage = false;
/**
* 文件信息
*/
protected array $fileInfo;
/**
* 上传驱动
*/
protected array $driver = [
'name' => 'local', // 默认驱动:local=本地
'handler' => [], // 驱动句柄
'namespace' => '\\app\\common\\library\\upload\\driver\\', // 驱动类的命名空间
];
/**
* 存储子目录
*/
protected string $topic = 'default';
/**
* 构造方法
* @param ?UploadedFile $file 上传的文件
* @param array $config 配置
* @throws Throwable
*/
public function __construct(?UploadedFile $file = null, array $config = [])
{
$upload = Config::get('upload');
$this->config = array_merge($upload, $config);
if ($file) {
$this->setFile($file);
}
}
/**
* 设置上传文件
* @param ?UploadedFile $file
* @return Upload
* @throws Throwable
*/
public function setFile(?UploadedFile $file): Upload
{
if (empty($file)) {
throw new Exception(__('No files were uploaded'));
}
$suffix = strtolower($file->extension());
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$fileInfo['suffix'] = $suffix;
$fileInfo['type'] = $file->getMime();
$fileInfo['size'] = $file->getSize();
$fileInfo['name'] = $file->getOriginalName();
$fileInfo['sha1'] = $file->sha1();
$this->file = $file;
$this->fileInfo = $fileInfo;
return $this;
}
/**
* 设置上传驱动
*/
public function setDriver(string $driver): Upload
{
$this->driver['name'] = $driver;
return $this;
}
/**
* 获取上传驱动句柄
* @param ?string $driver 驱动名称
* @param bool $noDriveException 找不到驱动是否抛出异常
* @return bool|Driver
*/
public function getDriver(?string $driver = null, bool $noDriveException = true): bool|Driver
{
if (is_null($driver)) {
$driver = $this->driver['name'];
}
if (!isset($this->driver['handler'][$driver])) {
$class = $this->resolveDriverClass($driver);
if ($class) {
$this->driver['handler'][$driver] = new $class();
} elseif ($noDriveException) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
}
return $this->driver['handler'][$driver] ?? false;
}
/**
* 获取驱动类
*/
protected function resolveDriverClass(string $driver): bool|string
{
if ($this->driver['namespace'] || str_contains($driver, '\\')) {
$class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . Str::studly($driver);
if (class_exists($class)) {
return $class;
}
}
return false;
}
/**
* 设置存储子目录
*/
public function setTopic(string $topic): Upload
{
$this->topic = $topic;
return $this;
}
/**
* 检查是否是图片并设置好相关属性
* @return bool
* @throws Throwable
*/
protected function checkIsImage(): bool
{
if (in_array($this->fileInfo['type'], ['image/gif', 'image/jpg', 'image/jpeg', 'image/bmp', 'image/png', 'image/webp']) || in_array($this->fileInfo['suffix'], ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'])) {
$imgInfo = getimagesize($this->file->getPathname());
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
throw new Exception(__('The uploaded image file is not a valid image'));
}
$this->fileInfo['width'] = $imgInfo[0];
$this->fileInfo['height'] = $imgInfo[1];
$this->isImage = true;
return true;
}
return false;
}
/**
* 上传的文件是否为图片
* @return bool
*/
public function isImage(): bool
{
return $this->isImage;
}
/**
* 获取文件后缀
* @return string
*/
public function getSuffix(): string
{
return $this->fileInfo['suffix'] ?: 'file';
}
/**
* 获取文件保存路径和名称
* @param ?string $saveName
* @param ?string $filename
* @param ?string $sha1
* @return string
*/
public function getSaveName(?string $saveName = null, ?string $filename = null, ?string $sha1 = null): string
{
if ($filename) {
$suffix = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
} else {
$suffix = $this->fileInfo['suffix'];
}
$filename = $filename ?: $this->fileInfo['name'];
$sha1 = $sha1 ?: $this->fileInfo['sha1'];
$replaceArr = [
'{topic}' => $this->topic,
'{year}' => date("Y"),
'{mon}' => date("m"),
'{day}' => date("d"),
'{hour}' => date("H"),
'{min}' => date("i"),
'{sec}' => date("s"),
'{random}' => Random::build(),
'{random32}' => Random::build('alnum', 32),
'{fileName}' => $this->getFileNameSubstr($filename, $suffix),
'{suffix}' => $suffix,
'{.suffix}' => $suffix ? '.' . $suffix : '',
'{fileSha1}' => $sha1,
];
$saveName = $saveName ?: $this->config['save_name'];
return Filesystem::fsFit(str_replace(array_keys($replaceArr), array_values($replaceArr), $saveName));
}
/**
* 验证文件是否符合上传配置要求
* @throws Throwable
*/
public function validates(): void
{
if (empty($this->file)) {
throw new Exception(__('No files have been uploaded or the file size exceeds the upload limit of the server'));
}
$size = Filesystem::fileUnitToByte($this->config['max_size']);
$mime = $this->checkConfig($this->config['allowed_mime_types']);
$suffix = $this->checkConfig($this->config['allowed_suffixes']);
// 文件大小
$fileValidateRule = ValidateRule::fileSize($size, __('The uploaded file is too large (%sMiB), Maximum file size:%sMiB', [
round($this->fileInfo['size'] / pow(1024, 2), 2),
round($size / pow(1024, 2), 2)
]));
// 文件后缀
if ($suffix) {
$fileValidateRule->fileExt($suffix, __('The uploaded file format is not allowed'));
}
// 文件 MIME 类型
if ($mime) {
$fileValidateRule->fileMime($mime, __('The uploaded file format is not allowed'));
}
// 图片文件利用tp内置规则做一些额外检查
if ($this->checkIsImage()) {
$fileValidateRule->image("{$this->fileInfo['width']},{$this->fileInfo['height']}", __('The uploaded image file is not a valid image'));
}
Validate::failException()
->rule([
'file' => $fileValidateRule,
'topic' => ValidateRule::is('alphaDash', __('Topic format error')),
'driver' => ValidateRule::is('alphaDash', __('Driver %s not supported', [$this->driver['name']])),
])
->check([
'file' => $this->file,
'topic' => $this->topic,
'driver' => $this->driver['name'],
]);
}
/**
* 上传文件
* @param ?string $saveName
* @param int $adminId
* @param int $userId
* @return array
* @throws Throwable
*/
public function upload(?string $saveName = null, int $adminId = 0, int $userId = 0): array
{
$this->validates();
$driver = $this->getDriver();
$saveName = $saveName ?: $this->getSaveName();
$params = [
'topic' => $this->topic,
'admin_id' => $adminId,
'user_id' => $userId,
'url' => $driver->url($saveName, false),
'width' => $this->fileInfo['width'] ?? 0,
'height' => $this->fileInfo['height'] ?? 0,
'name' => $this->getFileNameSubstr($this->fileInfo['name'], $this->fileInfo['suffix'], 100) . ".{$this->fileInfo['suffix']}",
'size' => $this->fileInfo['size'],
'mimetype' => $this->fileInfo['type'],
'storage' => $this->driver['name'],
'sha1' => $this->fileInfo['sha1']
];
// 附件数据入库 - 不依赖模型新增前事件,确保入库前文件已经移动完成
$attachment = Attachment::where('sha1', $params['sha1'])
->where('topic', $params['topic'])
->where('storage', $params['storage'])
->find();
if ($attachment && $driver->exists($attachment->url)) {
$attachment->quote++;
$attachment->last_upload_time = time();
} else {
$driver->save($this->file, $saveName);
$attachment = new Attachment();
$attachment->data(array_filter($params));
}
$attachment->save();
return $attachment->toArray();
}
/**
* 获取文件名称字符串的子串
*/
public function getFileNameSubstr(string $fileName, string $suffix, int $length = 15): string
{
// 对 $fileName 中不利于传输的字符串进行过滤
$pattern = "/[\s:@#?&\/=',+]+/u";
$fileName = str_replace(".$suffix", '', $fileName);
$fileName = preg_replace($pattern, '', $fileName);
return mb_substr(htmlspecialchars(strip_tags($fileName)), 0, $length);
}
/**
* 检查配置项,将 string 类型的配置转换为 array并且将所有字母转换为小写
*/
protected function checkConfig($configItem): array
{
if (is_array($configItem)) {
return array_map('strtolower', $configItem);
} else {
return explode(',', strtolower($configItem));
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace app\common\library\token;
use think\facade\Config;
/**
* Token 驱动抽象类
*/
abstract class Driver
{
/**
* 具体驱动的句柄 Mysql|Redis
* @var object
*/
protected object $handler;
/**
* @var array 配置数据
*/
protected array $options = [];
/**
* 设置 token
* @param string $token Token
* @param string $type Type
* @param int $userId 用户ID
* @param ?int $expire 过期时间
* @return bool
*/
abstract public function set(string $token, string $type, int $userId, int $expire = null): bool;
/**
* 获取 token 的数据
* @param string $token Token
* @return array
*/
abstract public function get(string $token): array;
/**
* 检查token是否有效
* @param string $token
* @param string $type
* @param int $userId
* @return bool
*/
abstract public function check(string $token, string $type, int $userId): bool;
/**
* 删除一个token
* @param string $token
* @return bool
*/
abstract public function delete(string $token): bool;
/**
* 清理一个用户的所有token
* @param string $type
* @param int $userId
* @return bool
*/
abstract public function clear(string $type, int $userId): bool;
/**
* 返回句柄对象
* @access public
* @return object|null
*/
public function handler(): ?object
{
return $this->handler;
}
/**
* @param string $token
* @return string
*/
protected function getEncryptedToken(string $token): string
{
$config = Config::get('buildadmin.token');
return hash_hmac($config['algo'], $token, $config['key']);
}
/**
* @param int $expireTime
* @return int
*/
protected function getExpiredIn(int $expireTime): int
{
return $expireTime ? max(0, $expireTime - time()) : 365 * 86400;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace app\common\library\token;
use think\Exception;
/**
* Token过期异常
*/
class TokenExpirationException extends Exception
{
public function __construct(protected $message = '', protected $code = 409, protected $data = [])
{
parent::__construct($message, $code);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace app\common\library\token\driver;
use Throwable;
use think\facade\Db;
use think\facade\Cache;
use app\common\library\token\Driver;
/**
* @see Driver
*/
class Mysql extends Driver
{
/**
* 默认配置
* @var array
*/
protected array $options = [];
/**
* 构造函数
* @access public
* @param array $options 参数
*/
public function __construct(array $options = [])
{
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
if ($this->options['name']) {
$this->handler = Db::connect($this->options['name'])->name($this->options['table']);
} else {
$this->handler = Db::name($this->options['table']);
}
}
/**
* @throws Throwable
*/
public function set(string $token, string $type, int $userId, int $expire = null): bool
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$token = $this->getEncryptedToken($token);
$this->handler->insert([
'token' => $token,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
]);
// 每隔48小时清理一次过期Token
$time = time();
$lastCacheCleanupTime = Cache::get('last_cache_cleanup_time');
if (!$lastCacheCleanupTime || $lastCacheCleanupTime < $time - 172800) {
Cache::set('last_cache_cleanup_time', $time);
$this->handler->where('expire_time', '<', time())->where('expire_time', '>', 0)->delete();
}
return true;
}
/**
* @throws Throwable
*/
public function get(string $token): array
{
$data = $this->handler->where('token', $this->getEncryptedToken($token))->find();
if (!$data) {
return [];
}
$data['token'] = $token; // 返回未加密的token给客户端使用
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0); // 返回剩余有效时间
return $data;
}
/**
* @throws Throwable
*/
public function check(string $token, string $type, int $userId): bool
{
$data = $this->get($token);
if (!$data || ($data['expire_time'] && $data['expire_time'] <= time())) return false;
return $data['type'] == $type && $data['user_id'] == $userId;
}
/**
* @throws Throwable
*/
public function delete(string $token): bool
{
$this->handler->where('token', $this->getEncryptedToken($token))->delete();
return true;
}
/**
* @throws Throwable
*/
public function clear(string $type, int $userId): bool
{
$this->handler->where('type', $type)->where('user_id', $userId)->delete();
return true;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace app\common\library\token\driver;
use Throwable;
use BadFunctionCallException;
use app\common\library\token\Driver;
/**
* @see Driver
*/
class Redis extends Driver
{
/**
* 默认配置
* @var array
*/
protected array $options = [];
/**
* Token 过期后缓存继续保留的时间(s)
*/
protected int $expiredHold = 60 * 60 * 24 * 2;
/**
* 构造函数
* @access public
* @param array $options 参数
* @throws Throwable
*/
public function __construct(array $options = [])
{
if (!extension_loaded('redis')) {
throw new BadFunctionCallException('未安装redis扩展');
}
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
$this->handler = new \Redis();
if ($this->options['persistent']) {
$this->handler->pconnect($this->options['host'], $this->options['port'], $this->options['timeout'], 'persistent_id_' . $this->options['select']);
} else {
$this->handler->connect($this->options['host'], $this->options['port'], $this->options['timeout']);
}
if ('' != $this->options['password']) {
$this->handler->auth($this->options['password']);
}
if (false !== $this->options['select']) {
$this->handler->select($this->options['select']);
}
}
/**
* @throws Throwable
*/
public function set(string $token, string $type, int $userId, int $expire = null): bool
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$token = $this->getEncryptedToken($token);
$tokenInfo = [
'token' => $token,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
];
$tokenInfo = json_encode($tokenInfo, JSON_UNESCAPED_UNICODE);
if ($expire) {
$expire += $this->expiredHold;
$result = $this->handler->setex($token, $expire, $tokenInfo);
} else {
$result = $this->handler->set($token, $tokenInfo);
}
$this->handler->sAdd($this->getUserKey($type, $userId), $token);
return $result;
}
/**
* @throws Throwable
*/
public function get(string $token): array
{
$key = $this->getEncryptedToken($token);
$data = $this->handler->get($key);
if (is_null($data) || false === $data) {
return [];
}
$data = json_decode($data, true);
$data['token'] = $token; // 返回未加密的token给客户端使用
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0); // 过期时间
return $data;
}
/**
* @throws Throwable
*/
public function check(string $token, string $type, int $userId): bool
{
$data = $this->get($token);
if (!$data || ($data['expire_time'] && $data['expire_time'] <= time())) return false;
return $data['type'] == $type && $data['user_id'] == $userId;
}
/**
* @throws Throwable
*/
public function delete(string $token): bool
{
$data = $this->get($token);
if ($data) {
$key = $this->getEncryptedToken($token);
$this->handler->del($key);
$this->handler->sRem($this->getUserKey($data['type'], $data['user_id']), $key);
}
return true;
}
/**
* @throws Throwable
*/
public function clear(string $type, int $userId): bool
{
$userKey = $this->getUserKey($type, $userId);
$keys = $this->handler->sMembers($userKey);
$this->handler->del($userKey);
$this->handler->del($keys);
return true;
}
/**
* 获取会员的key
* @param $type
* @param $userId
* @return string
*/
protected function getUserKey($type, $userId): string
{
return $this->options['prefix'] . $type . '-' . $userId;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace app\common\library\upload;
use think\file\UploadedFile;
/**
* 上传驱动抽象类
*/
abstract class Driver
{
/**
* @var array 配置数据
*/
protected array $options = [];
/**
* 保存文件
* @param UploadedFile $file
* @param string $saveName
* @return bool
*/
abstract public function save(UploadedFile $file, string $saveName): bool;
/**
* 删除文件
* @param string $saveName
* @return bool
*/
abstract public function delete(string $saveName): bool;
/**
* 获取资源 URL 地址;
* @param string $saveName 资源保存名称
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
abstract public function url(string $saveName, string|bool $domain = true, string $default = ''): string;
/**
* 文件是否存在
* @param string $saveName
* @return bool
*/
abstract public function exists(string $saveName): bool;
}

View File

@@ -0,0 +1,148 @@
<?php
namespace app\common\library\upload\driver;
use ba\Filesystem;
use think\facade\Config;
use think\file\UploadedFile;
use think\exception\FileException;
use app\common\library\upload\Driver;
/**
* 上传到本地磁盘的驱动
* @see Driver
*/
class Local extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
$this->options = Config::get('filesystem.disks.public');
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
}
/**
* 保存文件
* @param UploadedFile $file
* @param string $saveName
* @return bool
*/
public function save(UploadedFile $file, string $saveName): bool
{
$savePathInfo = pathinfo($saveName);
$saveFullPath = $this->getFullPath($saveName);
// cgi 直接 move
if (request()->isCgi()) {
$file->move($saveFullPath, $savePathInfo['basename']);
return true;
}
set_error_handler(function ($type, $msg) use (&$error) {
$error = $msg;
});
// 建立文件夹
if (!is_dir($saveFullPath) && !mkdir($saveFullPath, 0755, true)) {
restore_error_handler();
throw new FileException(sprintf('Unable to create the "%s" directory (%s)', $saveFullPath, strip_tags($error)));
}
// cli 使用 rename
$saveName = $this->getFullPath($saveName, true);
if (!rename($file->getPathname(), $saveName)) {
restore_error_handler();
throw new FileException(sprintf('Could not move the file "%s" to "%s" (%s)', $file->getPathname(), $saveName, strip_tags($error)));
}
restore_error_handler();
@chmod($saveName, 0666 & ~umask());
return true;
}
/**
* 删除文件
* @param string $saveName
* @return bool
*/
public function delete(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
if ($this->exists($saveFullName)) {
@unlink($saveFullName);
}
Filesystem::delEmptyDir(dirname($saveFullName));
return true;
}
/**
* 获取资源 URL 地址
* @param string $saveName 资源保存名称
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
public function url(string $saveName, string|bool $domain = true, string $default = ''): string
{
$saveName = $this->clearRootPath($saveName);
if ($domain === true) {
$domain = '//' . request()->host();
} elseif ($domain === false) {
$domain = '';
}
$saveName = $saveName ?: $default;
if (!$saveName) return $domain;
$regex = "/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i";
if (preg_match('/^http(s)?:\/\//', $saveName) || preg_match($regex, $saveName) || $domain === false) {
return $saveName;
}
return str_replace('\\', '/', $domain . $saveName);
}
/**
* 文件是否存在
* @param string $saveName
* @return bool
*/
public function exists(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
return file_exists($saveFullName);
}
/**
* 获取文件的完整存储路径
* @param string $saveName
* @param bool $baseName 是否包含文件名
* @return string
*/
public function getFullPath(string $saveName, bool $baseName = false): string
{
$savePathInfo = pathinfo($saveName);
$root = $this->getRootPath();
$dirName = $savePathInfo['dirname'] . '/';
// 以 root 路径开始时单独返回,避免重复调用此方法时造成 $dirName 的错误拼接
if (str_starts_with($saveName, $root)) {
return Filesystem::fsFit($baseName || !isset($savePathInfo['extension']) ? $saveName : $dirName);
}
return Filesystem::fsFit($root . $dirName . ($baseName ? $savePathInfo['basename'] : ''));
}
public function clearRootPath(string $saveName): string
{
return str_replace($this->getRootPath(), '', Filesystem::fsFit($saveName));
}
public function getRootPath(): string
{
return Filesystem::fsFit(str_replace($this->options['url'], '', $this->options['root']));
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace app\common\model;
use Throwable;
use think\Model;
use think\facade\Event;
use app\admin\model\Admin;
use app\common\library\Upload;
use think\model\relation\BelongsTo;
/**
* Attachment模型
* @property string url 文件物理路径
* @property int quote 上传(引用)次数
* @property int last_upload_time 最后上传时间
*/
class Attachment extends Model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
protected $append = [
'suffix',
'full_url'
];
/**
* 上传类实例,可以通过它调用上传文件驱动,且驱动类具有静态缓存
*/
protected static Upload $upload;
protected static function init(): void
{
self::$upload = new Upload();
}
public function getSuffixAttr($value, $row): string
{
if ($row['name']) {
$suffix = strtolower(pathinfo($row['name'], PATHINFO_EXTENSION));
return $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
}
return 'file';
}
public function getFullUrlAttr($value, $row): string
{
$driver = self::$upload->getDriver($row['storage'], false);
return $driver ? $driver->url($row['url']) : full_url($row['url']);
}
/**
* 新增前
* @throws Throwable
*/
protected static function onBeforeInsert($model): bool
{
$repeat = $model->where([
['sha1', '=', $model->sha1],
['topic', '=', $model->topic],
['storage', '=', $model->storage],
])->find();
if ($repeat) {
$driver = self::$upload->getDriver($repeat->storage, false);
if ($driver && !$driver->exists($repeat->url)) {
$repeat->delete();
return true;
} else {
$repeat->quote++;
$repeat->last_upload_time = time();
$repeat->save();
return false;
}
}
return true;
}
/**
* 新增后
*/
protected static function onAfterInsert($model): void
{
Event::trigger('AttachmentInsert', $model);
if (!$model->last_upload_time) {
$model->quote = 1;
$model->last_upload_time = time();
$model->save();
}
}
/**
* 删除后
*/
protected static function onAfterDelete($model): void
{
Event::trigger('AttachmentDel', $model);
$driver = self::$upload->getDriver($model->storage, false);
if ($driver && $driver->exists($model->url)) {
$driver->delete($model->url);
}
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace app\common\model;
use app\common\library\DatabaseRoute;
use think\db\BaseQuery;
use think\facade\Db;
use think\facade\Log;
use think\Model;
use think\db\Query;
class BaseModel extends Model
{
/**
* 查询前处理数据库连接
*/
public static function findbefore($model, $where):string
{
return DatabaseRoute::getConnection($model->getTable(), $where, false);
}
/**
* 写入数据前处理数据库连接
* @param array $data 写入的数据
* @return void
*/
protected static function onBeforeInsert($data)
{
$connection = DatabaseRoute::getConnection($data->getTable(), $data, true);
print_r($connection);
$data->setConnection($connection);
}
/**
* 更新数据前处理数据库连接
* @param array $data 更新的数据
* @return void
*/
protected static function onBeforeUpdate($data)
{
$connection = DatabaseRoute::getConnection($data->getTable(), $data->getWhere(), true);
$data->setConnection($connection);
}
/**
* 从查询选项中提取分库键的值(深度解析版)
*/
private static function extractKeyFromOptions(array $options, string $keyField)
{
$dbKey = null;
// 1. 处理顶级 where 条件
if (isset($options['where'])) {
$dbKey = static::parseWhereCondition($options['where'], $keyField);
if ($dbKey !== null) return $dbKey;
}
// 2. 处理 whereOr 条件
if (isset($options['whereOr'])) {
$dbKey = static::parseWhereCondition($options['whereOr'], $keyField);
if ($dbKey !== null) return $dbKey;
}
// 3. 处理软删除条件
if (isset($options['soft_delete']) && is_array($options['soft_delete'])) {
if ($options['soft_delete'][0] === $keyField) {
$dbKey = $options['soft_delete'][2] ?? null;
Log::info("条件类型3: 软删除条件中匹配到 {$keyField}={$dbKey}");
return $dbKey;
}
}
// 4. 处理字段绑定bind
if (isset($options['bind'][$keyField])) {
$dbKey = $options['bind'][$keyField];
Log::info("条件类型4: 绑定参数中匹配到 {$keyField}={$dbKey}");
return $dbKey;
}
// 5. 处理查询参数param
if (isset($options['param'][$keyField])) {
$dbKey = $options['param'][$keyField];
Log::info("条件类型5: 查询参数中匹配到 {$keyField}={$dbKey}");
return $dbKey;
}
// 6. 处理数据data- 适用于更新操作
if (isset($options['data'][$keyField])) {
$dbKey = $options['data'][$keyField];
Log::info("条件类型6: 数据中匹配到 {$keyField}={$dbKey}");
return $dbKey;
}
// 未找到分库键
Log::warning("条件解析失败: 未找到 {$keyField} 的值");
return null;
}
/**
* 解析 where 条件(处理 AND/OR 逻辑)
*/
private static function parseWhereCondition($where, string $keyField)
{
$dbKey = null;
// 处理数组形式的 where 条件
if (is_array($where)) {
foreach ($where as $index => $whereItem) {
// 1. 处理 AND/OR 逻辑分组
if (is_string($index) && in_array(strtoupper($index), ['AND', 'OR', 'NOT'])) {
Log::info("条件类型1: 发现 {$index} 逻辑分组,开始递归解析");
// 递归解析逻辑分组中的条件
$dbKey = static::parseWhereCondition($whereItem, $keyField);
if ($dbKey !== null) {
Log::info("条件类型1: {$index} 分组中匹配到 {$keyField}={$dbKey}");
return $dbKey;
}
} // 2. 处理 ['user_id', '=', 123] 形式
elseif (is_array($whereItem) && count($whereItem) >= 3) {
if ($whereItem[0] === $keyField) {
$dbKey = $whereItem[2];
Log::info("条件类型2: 直接匹配 {$keyField}={$dbKey}");
return $dbKey;
}
} // 3. 处理闭包条件: ['user_id', function($query){...}]
elseif (is_array($whereItem) && isset($whereItem[1]) && $whereItem[1] instanceof \Closure) {
Log::info("条件类型3: 发现闭包条件,开始递归解析");
// 创建临时查询对象执行闭包
$subQuery = new Query();
$whereItem[1]($subQuery);
// 递归解析闭包中的条件
$dbKey = static::extractKeyFromOptions($subQuery->getOptions(), $keyField);
if ($dbKey !== null) {
Log::info("条件类型3: 闭包中匹配到 {$keyField}={$dbKey}");
return $dbKey;
}
}
}
return null;
}
}
}

125
app/common/model/Common.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
namespace app\common\model;
use app\api\model\CommonInfo;
use app\common\library\DatabaseRoute;
use app\czg\model\SysCaptcha;
use think\cache\driver\Redis;
use think\facade\Db;
use think\Model;
use support\think\Cache;
class Common extends Model
{
// 统一处理分表表新增编辑查询(主表中没有的表)
public static function saveDbData($table, $sale, $data, $operate = 'insertGetId', $where = [])
{
if(in_array($operate, ['insertGetId', 'insert']) || $operate == 'update') {
$connect_name = DatabaseRoute::getConnection($table, $sale, true);
}else {
$connect_name = DatabaseRoute::getConnection($table, $sale);
}
$db = Db::connect($connect_name);
if($operate == 'insert' || $operate == 'insertGetId') {
return $db->name($table)->insertGetId($data);
}elseif($operate == 'update') {
return $db->name($table)->where($where)->update($data);
}elseif ($operate == 'select') {
return $db->name($table)->where($where)->select();
}elseif ($operate == 'find') {
return $db->name($table)->where($where)->find();
}
}
public static function db_count($table, $sale, $where)
{
$connect_name = DatabaseRoute::getConnection($table, $sale);
$db = Db::connect($connect_name);
return $db->name($table)->where($where)->count();
}
public static function getAppUseKv()
{
$info = Db::connect(config('database.search_library'))->name('common_info')->where(['is_app_use' => 1])->field('id, value')->select();
$data = [];
foreach ($info as $k => $v) {
$data[$v['id']] = $v['value'];
}
return returnSuccessData($data);
}
/**
* 检查是否允许访问基于Redis的频率限制
* @param string $id 唯一标识如用户ID、IP等
* @param string $key 操作类型(如"updateWeekCourseView"
* @param int $count 允许的访问次数
* @param int $seconds 时间窗口(秒)
* @return bool 是否允许访问
*/
public static function isAccessAllowed($id, $key, $count, $seconds, $sys_data = false)
{
if($sys_data) {
$redisKey = 'sys:data:' . $key . ':' . $id;
}else {
$redisKey = generateRedisKey($key, $id);
}
// 获取当前访问次数
$currentCount = Cache::get($redisKey);
if ($currentCount === null) {
// 首次访问:初始化计数器并设置过期时间
Cache::set($redisKey, $seconds, 1);
return true;
}
if ((int)$currentCount < $count) {
// 未超过限制:增加计数
Cache::set($redisKey,$currentCount + 1);
return true;
}
// 已超过限制
return false;
}
/**
* @param int $courseId 课程ID
* @return int 周播放量
*/
public static function getCourseWeekViewCount($courseId)
{
$key = "course:viewCount:{$courseId}";
// 从Redis获取周播放量
$viewCount = Cache::get($key);
if (empty($viewCount)) {
// 计算下周一的时间戳
$now = time();
$dayOfWeek = date('N', $now); // 1-71表示周一
$daysToMonday = $dayOfWeek === 1 ? 7 : (1 - $dayOfWeek + 7) % 7;
$nextMonday = $now + $daysToMonday * 86400;
// 计算剩余秒数并设置缓存
$seconds = $nextMonday - $now;
Cache::set($key, 1, $seconds);
return 1;
}
// 播放量递增并返回
$newCount = Cache::set($key, $viewCount + 1, -1);
return $newCount;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace app\common\model;
use Throwable;
use think\Model;
use app\admin\model\Config as adminConfigModel;
class Config extends Model
{
/**
* 添加系统配置分组
* @throws Throwable
*/
public static function addConfigGroup(string $key, string $value): bool
{
return self::addArrayItem('config_group', $key, $value);
}
/**
* 删除系统配置分组
* @throws Throwable
*/
public static function removeConfigGroup(string $key): bool
{
if (adminConfigModel::where('group', $key)->find()) return false;
return self::removeArrayItem('config_group', $key);
}
/**
* 添加系统快捷配置入口
* @throws Throwable
*/
public static function addQuickEntrance(string $key, string $value): bool
{
return self::addArrayItem('config_quick_entrance', $key, $value);
}
/**
* 删除系统快捷配置入口
* @throws Throwable
*/
public static function removeQuickEntrance(string $key): bool
{
return self::removeArrayItem('config_quick_entrance', $key);
}
/**
* 为Array类型的配置项添加元素
* @throws Throwable
*/
public static function addArrayItem(string $name, string $key, string $value): bool
{
$configRow = adminConfigModel::where('name', $name)->find();
foreach ($configRow->value as $item) {
if ($item['key'] == $key) {
return false;
}
}
$configRow->value = array_merge($configRow->value, [['key' => $key, 'value' => $value]]);
$configRow->save();
return true;
}
/**
* 删除Array类型配置项的一个元素
* @throws Throwable
*/
public static function removeArrayItem(string $name, string $key): bool
{
$configRow = adminConfigModel::where('name', $name)->find();
$configRowValue = $configRow->value;
foreach ($configRowValue as $iKey => $item) {
if ($item['key'] == $key) {
unset($configRowValue[$iKey]);
}
}
$configRow->value = $configRowValue;
$configRow->save();
return true;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace app\common\model;
use app\czg\model\SysCaptcha;
use think\facade\Cache;
class CourseCollect extends BaseModel
{
}

View File

@@ -0,0 +1,80 @@
<?php
namespace app\common\model;
use app\common\library\DatabaseRoute;
use app\czg\model\SysCaptcha;
use ba\Random;
use think\facade\Cache;
use think\facade\Db;
class SysUser extends BaseModel
{
public static function adda()
{
SysUser::create([
'user_id' => rand(000000, 999999),
'username' => '哎呀' . rand(000000, 999999),
]);
}
// 查询username
public static function GetByusername($username)
{
// 全表扫描username
$dbmap = config('database.db_map');
foreach ($dbmap as $dbname) {
if(!in_array($dbname, config('database.unset_db_map'))) {
$connect = Db::connect($dbname);
$data = $connect->name('sys_user')->where(['username' => $username])->find();
if($data) {
// 如果查到直接跳出循环并保存缓存
Cache::set('admin_info_' . $username, $data['user_id']);
break;
}
}
}
return $data;
}
public static function GetByQrcode($qd_rcode)
{
// 全表扫描username
return DatabaseRoute::getAllDbData('sys_user', function ($query) use ($qd_rcode) {
return $query->where([
'qd_code' => $qd_rcode
]);
})->find();
}
public static function updateSysMoney($userId, $money, $type)
{
$count = Db::name('sys_user_money')->where([
'user_id' => $userId
])->count();
if (!$count) {
Db::name('sys_user_money')->where([
'user_id' => $userId
])->insert([
'id' => Random::generateRandomPrefixedId(19),
'user_id' => $userId,
'money' => 0
]);
}
$model = Db::name('sys_user_money');
if ($type) {
$model->inc('money', floatval($money))->where([
'user_id' => $userId
])->update();
}else{
$model->dec('money', floatval($money))->where([
'user_id' => $userId
])->update();
}
}
}

51
app/common/model/User.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 会员公共模型
* @property int $id 会员ID
* @property string $password 密码密文
* @property string $salt 密码盐(废弃待删)
* @property int $login_failure 登录失败次数
* @property string $last_login_time 上次登录时间
* @property string $last_login_ip 上次登录IP
* @property string $email 会员邮箱
* @property string $mobile 会员手机号
* @property string $status 状态:enable=启用,disable=禁用,...(string存储可自定义其他)
*/
class User extends Model
{
protected $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
{
return full_url($value, false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function resetPassword($uid, $newPassword): int|User
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
public function getMoneyAttr($value): string
{
return bcdiv($value, 100, 2);
}
/**
* 用户的余额是不可以直接进行修改的,请通过 UserMoneyLog 模型插入记录来实现自动修改余额
* 此处定义上 money 的修改器仅为防止直接对余额的修改造成数据错乱
*/
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace app\common\model;
use think\model;
class UserMoneyLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
public function getMoneyAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getBeforeAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setBeforeAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getAfterAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setAfterAttr($value): string
{
return bcmul($value, 100, 2);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace app\common\model;
use think\model;
class UserScoreLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
}