项目初始化

This commit is contained in:
2026-03-18 15:54:43 +08:00
commit dfcd762e23
601 changed files with 57883 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace app\common\controller;
use app\support\BaseController;
use support\Response;
use support\think\Db;
use think\db\exception\PDOException;
use Webman\Http\Request as WebmanRequest;
/**
* API 控制器基类
* 迁移自 app/common/controller/Api.php适配 Webman
*/
class Api extends BaseController
{
/**
* 默认响应输出类型
*/
protected string $responseType = 'json';
/**
* 是否开启系统站点配置数据库检查、ip_check、set_timezone
*/
protected bool $useSystemSettings = true;
/**
* API 初始化(需在控制器方法开头调用)
* @param WebmanRequest $request
* @return Response|null 若需直接返回(如 ip 禁止、数据库错误)则返回 Response否则 null
*/
public function initializeApi(WebmanRequest $request): ?Response
{
$this->setRequest($request);
if ($this->useSystemSettings) {
// 检查数据库连接
try {
Db::execute('SELECT 1');
} catch (PDOException $e) {
return $this->error(mb_convert_encoding($e->getMessage(), 'UTF-8', 'UTF-8,GBK,GB2312,BIG5'));
}
// IP 检查
$ipCheckResponse = ip_check(null, $request);
if ($ipCheckResponse !== null) {
return $ipCheckResponse;
}
// 时区设定
set_timezone();
}
// 语言包由 LoadLangPack 中间件加载
return null;
}
/**
* 从 Request 或路由解析控制器路径(如 user/user
* 优先从 $request->controller 解析,否则从 path 解析
*/
protected function getControllerPath(WebmanRequest $request): ?string
{
return get_controller_path($request);
}
/**
* 从 Request 解析应用名api 或 admin
*/
protected function getAppFromRequest(WebmanRequest $request): string
{
$path = trim($request->path(), '/');
$parts = explode('/', $path);
return $parts[0] ?? 'api';
}
}

View File

@@ -0,0 +1,435 @@
<?php
declare(strict_types=1);
namespace app\common\controller;
use Throwable;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
use app\admin\library\traits\Backend as BackendTrait;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 后台控制器基类
* 迁移 Auth 鉴权、权限校验、CRUD trait
*/
class Backend extends Api
{
use BackendTrait;
/**
* 无需登录的方法
*/
protected array $noNeedLogin = [];
/**
* 无需鉴权的方法
*/
protected array $noNeedPermission = [];
/**
* 权限类实例
* @var Auth|null
*/
protected ?Auth $auth = null;
/**
* 模型类实例(子类设置)
* @var object|null
*/
protected ?object $model = null;
/**
* 新增/编辑时排除字段
*/
protected array|string $preExcludeFields = [];
/**
* 权重字段
*/
protected string $weighField = 'weigh';
/**
* 默认排序
*/
protected string|array $defaultSortField = [];
/**
* 有序保证
*/
protected string|array $orderGuarantee = [];
/**
* 快速搜索字段
*/
protected string|array $quickSearchField = 'id';
/**
* 数据限制
*/
protected bool|string|int $dataLimit = false;
/**
* 数据限制字段
*/
protected string $dataLimitField = 'admin_id';
/**
* 数据限制开启时自动填充
*/
protected bool $dataLimitFieldAutoFill = true;
/**
* 查看请求返回的主表字段
*/
protected string|array $indexField = ['*'];
/**
* 是否开启模型验证
*/
protected bool $modelValidate = true;
/**
* 是否开启模型场景验证
*/
protected bool $modelSceneValidate = false;
/**
* 关联查询方法名
*/
protected array $withJoinTable = [];
/**
* 关联查询 JOIN 方式
*/
protected string $withJoinType = 'LEFT';
/**
* 输入过滤函数名(如 clean_xssCRUD 含 editor 字段时自动设置)
*/
protected string $inputFilter = '';
/**
* 后台初始化(需在控制器方法开头调用,在 initializeApi 之后)
* @return Response|null 需直接返回时返回 Response否则 null
*/
public function initializeBackend(WebmanRequest $request): ?Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
// 调用子类 initializeCRUD 生成的控制器在此设置 model
$this->initialize();
$action = $this->getActionFromPath($request->path());
$needLogin = !action_in_arr($this->noNeedLogin, $action);
$needPermission = !action_in_arr($this->noNeedPermission, $action);
try {
$this->auth = Auth::instance();
$token = get_auth_token(['ba', 'token'], $request);
if ($token) {
$this->auth->init($token);
}
} catch (TokenExpirationException) {
if ($needLogin) {
return $this->error(__('Token expiration'), [], 409);
}
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
return $this->error(__('Please login first'), [
'type' => Auth::NEED_LOGIN,
], 0, ['statusCode' => Auth::LOGIN_RESPONSE_CODE]);
}
if ($needPermission) {
$controllerPath = $this->getControllerPath($request);
$routePath = $controllerPath . '/' . $action;
if (!$this->auth->check($routePath)) {
return $this->error(__('You have no permission'), [], 401);
}
}
}
event_trigger('backendInit', $this->auth);
if (method_exists($this, 'initController')) {
$initResp = $this->initController($request);
if ($initResp !== null) {
return $initResp;
}
}
return null;
}
/**
* 子类可覆盖,用于初始化 model 等(替代原 initialize
* @return Response|null 需直接返回时返回 Response否则 null
*/
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_index();
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_add();
}
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_edit();
}
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_del();
}
public function sortable(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_sortable();
}
public function select(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$this->_select();
return $this->success();
}
/**
* 查询参数构建器
*/
public function queryBuilder(): array
{
if (empty($this->model) || !$this->request) {
return [[], [], 10, []];
}
$pk = $this->model->getPk();
$quickSearch = $this->request->get('quickSearch', '');
$limit = $this->request->get('limit', 10);
$limit = is_numeric($limit) ? intval($limit) : 10;
$search = $this->request->get('search', []);
$search = is_array($search) ? $search : [];
$initKey = $this->request->get('initKey', $pk);
$initValue = $this->request->get('initValue', '');
$initOperator = $this->request->get('initOperator', 'in');
$where = [];
$modelTable = strtolower($this->model->getTable());
$alias = [];
$alias[$modelTable] = parse_name(basename(str_replace('\\', '/', get_class($this->model))));
$mainTableAlias = $alias[$modelTable] . '.';
if ($quickSearch) {
$quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', $this->quickSearchField);
foreach ($quickSearchArr as $k => $v) {
$quickSearchArr[$k] = str_contains($v, '.') ? $v : $mainTableAlias . $v;
}
$where[] = [implode('|', $quickSearchArr), 'LIKE', '%' . str_replace('%', '\%', $quickSearch) . '%'];
}
if ($initValue) {
$where[] = [$initKey, $initOperator, $initValue];
$limit = 999999;
}
foreach ($search as $field) {
if (!is_array($field) || !isset($field['operator']) || !isset($field['field']) || !isset($field['val'])) {
continue;
}
$field['operator'] = $this->getOperatorByAlias($field['operator']);
if (str_contains($field['field'], '.')) {
$fieldNameParts = explode('.', $field['field']);
$fieldNamePartsLastKey = array_key_last($fieldNameParts);
foreach ($fieldNameParts as $fieldNamePartsKey => $fieldNamePart) {
if ($fieldNamePartsKey !== $fieldNamePartsLastKey) {
$fieldNameParts[$fieldNamePartsKey] = parse_name($fieldNamePart);
}
}
$fieldName = implode('.', $fieldNameParts);
} else {
$fieldName = $mainTableAlias . $field['field'];
}
if (isset($field['render']) && $field['render'] == 'datetime') {
if ($field['operator'] == 'RANGE') {
$datetimeArr = explode(',', $field['val']);
if (!isset($datetimeArr[1])) {
continue;
}
$datetimeArr = array_filter(array_map('strtotime', $datetimeArr));
$where[] = [$fieldName, str_replace('RANGE', 'BETWEEN', $field['operator']), $datetimeArr];
continue;
}
$where[] = [$fieldName, '=', strtotime($field['val'])];
continue;
}
if ($field['operator'] == 'RANGE' || $field['operator'] == 'NOT RANGE') {
$arr = explode(',', $field['val']);
if (!isset($arr[0]) || $arr[0] === '') {
$operator = $field['operator'] == 'RANGE' ? '<=' : '>';
$arr = $arr[1];
} elseif (!isset($arr[1]) || $arr[1] === '') {
$operator = $field['operator'] == 'RANGE' ? '>=' : '<';
$arr = $arr[0];
} else {
$operator = str_replace('RANGE', 'BETWEEN', $field['operator']);
}
$where[] = [$fieldName, $operator, $arr];
continue;
}
switch ($field['operator']) {
case '=':
case '<>':
$where[] = [$fieldName, $field['operator'], strval($field['val'])];
break;
case 'LIKE':
case 'NOT LIKE':
$where[] = [$fieldName, $field['operator'], '%' . str_replace('%', '\%', $field['val']) . '%'];
break;
case '>':
case '>=':
case '<':
case '<=':
$where[] = [$fieldName, $field['operator'], intval($field['val'])];
break;
case 'FIND_IN_SET':
if (is_array($field['val'])) {
foreach ($field['val'] as $val) {
$where[] = [$fieldName, 'find in set', $val];
}
} else {
$where[] = [$fieldName, 'find in set', $field['val']];
}
break;
case 'IN':
case 'NOT IN':
$where[] = [$fieldName, $field['operator'], is_array($field['val']) ? $field['val'] : explode(',', $field['val'])];
break;
case 'NULL':
case 'NOT NULL':
$where[] = [$fieldName, strtolower($field['operator']), ''];
break;
}
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$mainTableAlias . $this->dataLimitField, 'in', $dataLimitAdminIds];
}
return [$where, $alias, $limit, $this->queryOrderBuilder()];
}
/**
* 查询的排序参数构建器
*/
public function queryOrderBuilder(): array
{
$pk = $this->model->getPk();
$order = $this->request ? $this->request->get('order') : null;
$order = $order ?: $this->defaultSortField;
if ($order && is_string($order)) {
$order = explode(',', $order);
$order = [$order[0] => $order[1] ?? 'asc'];
}
if (!$this->orderGuarantee) {
$this->orderGuarantee = [$pk => 'desc'];
} elseif (is_string($this->orderGuarantee)) {
$orderParts = explode(',', $this->orderGuarantee);
$this->orderGuarantee = [$orderParts[0] => $orderParts[1] ?? 'asc'];
}
$orderGuaranteeKey = array_key_first($this->orderGuarantee);
if (!is_array($order) || !array_key_exists($orderGuaranteeKey, $order)) {
$order[$orderGuaranteeKey] = $this->orderGuarantee[$orderGuaranteeKey];
}
return $order;
}
/**
* 数据权限控制 - 获取有权限访问的管理员 IDs
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
return [];
}
$adminIds = [];
if ($this->dataLimit == 'parent') {
$parentGroups = $this->auth->getAdminChildGroups();
if ($parentGroups) {
$adminIds = $this->auth->getGroupAdmins($parentGroups);
}
} elseif (is_numeric($this->dataLimit) && $this->dataLimit > 0) {
$adminIds = $this->auth->getGroupAdmins([$this->dataLimit]);
return in_array($this->auth->id, $adminIds) ? [] : [$this->auth->id];
} elseif ($this->dataLimit == 'allAuth' || $this->dataLimit == 'allAuthAndOthers') {
$allAuthGroups = $this->auth->getAllAuthGroups($this->dataLimit);
$adminIds = $this->auth->getGroupAdmins($allAuthGroups);
}
$adminIds[] = $this->auth->id;
return array_unique($adminIds);
}
/**
* 从别名获取原始的逻辑运算符
*/
protected function getOperatorByAlias(string $operator): string
{
$alias = [
'ne' => '<>',
'eq' => '=',
'gt' => '>',
'egt' => '>=',
'lt' => '<',
'elt' => '<=',
];
return $alias[$operator] ?? $operator;
}
/**
* 从 path 解析 action
*/
protected function getActionFromPath(string $path): string
{
$parts = explode('/', trim($path, '/'));
return $parts[array_key_last($parts)] ?? '';
}
/**
* 从 Request 或路由解析控制器路径(如 auth/admin
* 优先从 $request->controllerWebman 路由匹配时设置)解析,否则从 path 解析
*/
protected function getControllerPath(WebmanRequest $request): string
{
return get_controller_path($request);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\controller;
use app\common\library\Auth;
use app\common\library\token\TokenExpirationException;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 前台/会员中心控制器基类
* 继承 Api增加会员鉴权
*/
class Frontend extends Api
{
protected array $noNeedLogin = [];
protected array $noNeedPermission = [];
protected ?Auth $auth = null;
/**
* 前台初始化(需在控制器方法开头调用)
* @return Response|null 若需直接返回则返回 Response否则 null
*/
public function initializeFrontend(WebmanRequest $request): ?Response
{
$response = $this->initializeApi($request);
if ($response !== null) return $response;
$this->setRequest($request);
$path = trim($request->path(), '/');
$parts = explode('/', $path);
$action = $parts[array_key_last($parts)] ?? '';
$needLogin = !action_in_arr($this->noNeedLogin, $action);
try {
$this->auth = Auth::instance(['request' => $request]);
$token = get_auth_token(['ba', 'user', 'token'], $request);
if ($token) $this->auth->init($token);
} catch (TokenExpirationException) {
if ($needLogin) return $this->error(__('Token expiration'), [], 409);
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
return $this->error(__('Please login first'), ['type' => Auth::NEED_LOGIN], Auth::LOGIN_RESPONSE_CODE);
}
if (!action_in_arr($this->noNeedPermission, $action)) {
$routePath = get_controller_path($request) . '/' . $action;
if (!$this->auth->check($routePath)) {
return $this->error(__('You have no permission'), [], 401);
}
}
}
event_trigger('frontendInit', $this->auth);
return null;
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace app\common\event;
/**
* 后台安全事件(数据回收、敏感数据记录)
* 迁移自 app/common/event/Security.php需 Admin、Token、DataRecycle 等模型支持
*/
class Security
{
protected array $listenAction = ['edit', 'del'];
/**
* @param mixed $auth Auth 实例backendInit 传入)
*/
public function handle(mixed $auth): bool
{
$request = function_exists('request') ? request() : null;
if (!$request) {
return true;
}
$action = $this->getActionFromPath($request->path());
if (!in_array($action, $this->listenAction)) {
return true;
}
if ($action === 'del' && $request->method() !== 'POST' && $request->method() !== 'DELETE') {
return true;
}
if ($action === 'edit' && $request->method() !== 'POST') {
return true;
}
if (!class_exists(\app\admin\model\DataRecycle::class) || !class_exists(\app\admin\model\SensitiveData::class)) {
return true;
}
if ($action === 'del') {
return $this->handleDel($request, $auth);
}
return $this->handleEdit($request, $auth);
}
protected function getActionFromPath(string $path): string
{
$parts = explode('/', trim($path, '/'));
return $parts[array_key_last($parts)] ?? '';
}
protected function handleDel($request, $auth): bool
{
$dataIds = $request->post('ids') ?? $request->get('ids');
if (!$dataIds) {
return true;
}
try {
$controllerPath = get_controller_path($request) ?? '';
$recycle = \app\admin\model\DataRecycle::where('status', 1)
->where('controller_as', $controllerPath)
->find();
if (!$recycle) {
return true;
}
$connection = class_exists(\ba\TableManager::class)
? \ba\TableManager::getConnection($recycle['connection'])
: null;
$db = $connection ? \support\think\Db::connect($connection) : \support\think\Db::connect();
$recycleData = $db->name($recycle['data_table'])
->whereIn($recycle['primary_key'], $dataIds)
->select()
->toArray();
$recycleDataArr = [];
$adminId = $auth && $auth->isLogin() ? $auth->id : 0;
foreach ($recycleData as $item) {
$recycleDataArr[] = [
'admin_id' => $adminId,
'recycle_id' => $recycle['id'],
'data' => json_encode($item, JSON_UNESCAPED_UNICODE),
'connection' => $recycle['connection'],
'data_table' => $recycle['data_table'],
'primary_key' => $recycle['primary_key'],
'ip' => $request->getRealIp(),
'useragent' => substr($request->header('user-agent', ''), 0, 255),
];
}
if ($recycleDataArr) {
$model = new \app\admin\model\DataRecycleLog();
$model->saveAll($recycleDataArr);
}
} catch (\Throwable $e) {
\support\Log::warning('[DataSecurity] ' . $e->getMessage());
}
return true;
}
protected function handleEdit($request, $auth): bool
{
try {
$controllerPath = get_controller_path($request) ?? '';
$sensitiveData = \app\admin\model\SensitiveData::where('status', 1)
->where('controller_as', $controllerPath)
->find();
if (!$sensitiveData) {
return true;
}
$sensitiveData = $sensitiveData->toArray();
$dataId = $request->post($sensitiveData['primary_key']) ?? $request->get($sensitiveData['primary_key']);
$connection = class_exists(\ba\TableManager::class)
? \ba\TableManager::getConnection($sensitiveData['connection'])
: null;
$db = $connection ? \support\think\Db::connect($connection) : \support\think\Db::connect();
$editData = $db->name($sensitiveData['data_table'])
->field(array_keys($sensitiveData['data_fields']))
->where($sensitiveData['primary_key'], $dataId)
->find();
if (!$editData) {
return true;
}
$newData = $request->post();
$sensitiveDataLog = [];
foreach ($sensitiveData['data_fields'] as $field => $title) {
if (isset($editData[$field]) && isset($newData[$field]) && $editData[$field] != $newData[$field]) {
$newVal = $newData[$field];
if (stripos($field, 'password') !== false && $newVal) {
$newVal = '******';
}
$sensitiveDataLog[] = [
'admin_id' => $auth && $auth->isLogin() ? $auth->id : 0,
'sensitive_id' => $sensitiveData['id'],
'connection' => $sensitiveData['connection'],
'data_table' => $sensitiveData['data_table'],
'primary_key' => $sensitiveData['primary_key'],
'data_field' => $field,
'data_comment' => $title,
'id_value' => $dataId,
'before' => $editData[$field],
'after' => $newVal,
'ip' => $request->getRealIp(),
'useragent' => substr($request->header('user-agent', ''), 0, 255),
];
}
}
if ($sensitiveDataLog) {
$model = new \app\admin\model\SensitiveDataLog();
$model->saveAll($sensitiveDataLog);
}
} catch (\Throwable $e) {
\support\Log::warning('[DataSecurity] ' . $e->getMessage());
}
return true;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\facade;
use app\common\library\Token as TokenLibrary;
/**
* Token 门面类Webman 等价实现,替代 ThinkPHP Facade
* @see TokenLibrary
* @method static array get(string $token)
* @method static bool set(string $token, string $type, int $userId, ?int $expire = null)
* @method static bool check(string $token, string $type, int $userId)
* @method static bool delete(string $token)
* @method static bool clear(string $type, int $userId)
* @method static void tokenExpirationCheck(array $token)
*/
class Token
{
private static ?TokenLibrary $instance = null;
private static function getInstance(): TokenLibrary
{
if (self::$instance === null) {
self::$instance = new TokenLibrary();
}
return self::$instance;
}
public static function get(string $token): array
{
return self::getInstance()->get($token);
}
public static function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
return self::getInstance()->set($token, $type, $userId, $expire);
}
public static function check(string $token, string $type, int $userId): bool
{
return self::getInstance()->check($token, $type, $userId);
}
public static function delete(string $token): bool
{
return self::getInstance()->delete($token);
}
public static function clear(string $type, int $userId): bool
{
return self::getInstance()->clear($type, $userId);
}
public static function tokenExpirationCheck(array $token): void
{
self::getInstance()->tokenExpirationCheck($token);
}
}

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

@@ -0,0 +1,356 @@
<?php
namespace app\common\library;
use Throwable;
use ba\Random;
use support\think\Db;
use app\common\model\User;
use app\common\facade\Token;
use Webman\Http\Request;
/**
* 公共权限类(会员权限类)
*/
class Auth extends \ba\Auth
{
public const LOGIN_RESPONSE_CODE = 303;
public const NEED_LOGIN = 'need login';
public const LOGGED_IN = 'logged in';
public const TOKEN_TYPE = 'user';
protected bool $loginEd = false;
protected string $error = '';
protected ?User $model = null;
protected string $token = '';
protected string $refreshToken = '';
protected int $keepTime = 86400;
protected int $refreshTokenKeepTime = 2592000;
protected array $allowFields = ['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', 86400));
}
public function __get($name): mixed
{
return $this->model?->$name;
}
public static function instance(array $options = []): Auth
{
$request = $options['request'] ?? (function_exists('request') ? request() : null);
unset($options['request']);
if ($request && !isset($request->userAuth)) {
$request->userAuth = new static($options);
}
return $request && isset($request->userAuth) ? $request->userAuth : new static($options);
}
public function init($token): bool
{
$tokenData = Token::get($token);
if ($tokenData) {
Token::tokenExpirationCheck($tokenData);
$userId = $tokenData['user_id'];
if ($tokenData['type'] == self::TOKEN_TYPE && $userId > 0) {
$this->model = User::where('id', $userId)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status != 'enable') {
$this->setError('Account disabled');
return false;
}
$this->token = $token;
$this->loginSuccessful();
return true;
}
}
$this->setError('Token login failed');
$this->reset();
return false;
}
public function register(string $username, string $password = '', string $mobile = '', string $email = '', int $group = 1, array $extend = []): bool
{
$request = function_exists('request') ? request() : null;
$ip = $request ? $request->getRealIp() : '0.0.0.0';
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->setError(__('Email'));
return false;
}
if ($username && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) {
$this->setError(__('Username'));
return false;
}
if (User::where('email', $email)->find() && $email) {
$this->setError(__('Email') . ' ' . __('already exists'));
return false;
}
if (User::where('username', $username)->find()) {
$this->setError(__('Username') . ' ' . __('already exists'));
return false;
}
$nickname = preg_replace_callback('/1[3-9]\d{9}/', fn($m) => substr($m[0], 0, 3) . '****' . substr($m[0], 7), $username);
$time = time();
$data = [
'group_id' => $group,
'nickname' => $nickname,
'join_ip' => $ip,
'join_time' => $time,
'last_login_ip' => $ip,
'last_login_time' => $time,
'status' => 'enable',
];
$data = array_merge(compact('username', 'password', 'mobile', 'email'), $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();
if ($password) {
$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;
}
public function login(string $username, string $password, bool $keep): bool
{
$accountType = false;
if (preg_match('/^1[3-9]\d{9}$/', $username)) $accountType = 'mobile';
elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) $accountType = 'email';
elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $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('buildadmin.user_login_retry');
if ($userLoginRetry && $this->model->last_login_time) {
$lastLoginTs = is_numeric($this->model->last_login_time) ? (int)$this->model->last_login_time : strtotime($this->model->last_login_time);
if ($this->model->login_failure > 0 && $lastLoginTs > 0 && time() - $lastLoginTs >= 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;
}
if (config('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);
return $this->loginSuccessful();
}
public function direct(int $userId): bool
{
$this->model = User::find($userId);
if (!$this->model) return false;
if (config('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
return $this->loginSuccessful();
}
public function loginSuccessful(): bool
{
if (!$this->model) return false;
$request = function_exists('request') ? request() : null;
$ip = $request ? $request->getRealIp() : '0.0.0.0';
if (!$this->token) {
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
}
$this->model->startTrans();
try {
$this->model->login_failure = 0;
$this->model->last_login_time = time();
$this->model->last_login_ip = $ip;
$this->model->save();
$this->loginEd = true;
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
if ($this->token) {
Token::delete($this->token);
$this->token = '';
}
$this->setError($e->getMessage());
return false;
}
return true;
}
public function loginFailed(): bool
{
if (!$this->model) return false;
$request = function_exists('request') ? request() : null;
$ip = $request ? $request->getRealIp() : '0.0.0.0';
$this->model->startTrans();
try {
$this->model->login_failure++;
$this->model->last_login_time = time();
$this->model->last_login_ip = $ip;
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
}
return $this->reset();
}
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
public function isLogin(): bool
{
return $this->loginEd;
}
public function getUser(): User
{
return $this->model;
}
public function getToken(): string
{
return $this->token;
}
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
public function getRefreshToken(): string
{
return $this->refreshToken;
}
public function getUserInfo(): array
{
if (!$this->model) return [];
$info = $this->model->toArray();
$info = array_intersect_key($info, array_flip($this->getAllowFields()));
$info['token'] = $this->getToken();
$info['refresh_token'] = $this->getRefreshToken();
return $info;
}
public function getAllowFields(): array
{
return $this->allowFields;
}
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
public function setKeepTime(int $keepTime = 0): void
{
$this->keepTime = $keepTime;
}
public function check(string $name, int $uid = 0, string $relation = 'or', string $mode = 'url'): bool
{
return parent::check($name, $uid ?: $this->id, $relation, $mode);
}
public function getRuleList(int $uid = 0): array
{
return parent::getRuleList($uid ?: $this->id);
}
public function getRuleIds(int $uid = 0): array
{
return parent::getRuleIds($uid ?: $this->id);
}
public function getMenus(int $uid = 0): array
{
return parent::getMenus($uid ?: $this->id);
}
public function isSuperUser(): bool
{
return in_array('*', $this->getRuleIds());
}
public function setError(string $error): Auth
{
$this->error = $error;
return $this;
}
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', 86400));
return true;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace app\common\library;
use PHPMailer\PHPMailer\PHPMailer;
/**
* 邮件类Webman 迁移版)
*/
class Email extends PHPMailer
{
public bool $configured = false;
public array $options = [
'charset' => 'utf-8',
'debug' => true,
'lang' => 'zh_cn',
];
public function __construct(array $options = [])
{
$this->options = array_merge($this->options, $options);
parent::__construct($this->options['debug']);
$langSet = function_exists('locale') ? locale() : 'zh_CN';
if ($langSet == 'zh-cn' || $langSet == 'zh_CN' || !$langSet) {
$langSet = 'zh_cn';
}
$this->options['lang'] = $this->options['lang'] ?: $langSet;
$langPath = root_path() . 'vendor' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR;
if (is_dir($langPath)) {
$this->setLanguage($this->options['lang'], $langPath);
}
$this->CharSet = $this->options['charset'];
$sysMailConfig = get_sys_config('', 'mail');
$this->configured = true;
if (is_array($sysMailConfig)) {
foreach ($sysMailConfig as $item) {
if (!$item) {
$this->configured = false;
break;
}
}
} else {
$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'] ?? 465;
$this->setFrom($sysMailConfig['smtp_sender_mail'] ?? '', $sysMailConfig['smtp_user'] ?? '');
}
}
public function setSubject($subject): void
{
$this->Subject = "=?utf-8?B?" . base64_encode($subject) . "?=";
}
}

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

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use Throwable;
use app\admin\model\AdminRule;
use app\admin\model\UserRule;
/**
* 菜单规则管理类Webman 迁移版)
*/
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') {
$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);
}
}
}
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;
}
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;
}
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,146 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use InvalidArgumentException;
use app\common\library\token\TokenExpirationException;
/**
* Token 管理类
*/
class Token
{
public array $instance = [];
public ?object $handler = null;
protected string $namespace = '\\app\\common\\library\\token\\driver\\';
public function getDriver(?string $name = null): object
{
if ($this->handler !== null) {
return $this->handler;
}
$name = $name ?: $this->getDefaultDriver();
if ($name === null) {
throw new InvalidArgumentException(sprintf('Unable to resolve NULL driver for [%s].', static::class));
}
return $this->createDriver($name);
}
protected function createDriver(string $name): object
{
$type = $this->resolveType($name);
$params = $this->resolveParams($name);
$class = $this->resolveClass($type);
if (isset($this->instance[$type])) {
return $this->instance[$type];
}
return new $class(...$params);
}
protected function getDefaultDriver(): string
{
return $this->getConfig('default');
}
protected function getConfig(?string $name = null, mixed $default = null): array|string
{
$config = config('buildadmin.token', []);
if ($name === null) {
return $config;
}
$keys = explode('.', $name);
$val = $config;
foreach ($keys as $k) {
if (!is_array($val) || !array_key_exists($k, $val)) {
return $default;
}
$val = $val[$k];
}
return $val;
}
protected function resolveParams(string $name): array
{
$config = $this->getStoreConfig($name);
return [$config];
}
protected function resolveClass(string $type): string
{
$class = str_contains($type, '\\') ? $type : $this->namespace . $this->studly($type);
if (class_exists($class)) {
return $class;
}
throw new InvalidArgumentException("Driver [{$type}] not supported.");
}
protected function getStoreConfig(string $store, ?string $name = null, mixed $default = null): array|string
{
$config = $this->getConfig("stores.{$store}");
if ($config === null) {
throw new InvalidArgumentException("Store [{$store}] not found.");
}
if ($name === null) {
return $config;
}
$keys = explode('.', $name);
$val = $config;
foreach ($keys as $k) {
if (!is_array($val) || !array_key_exists($k, $val)) {
return $default;
}
$val = $val[$k];
}
return $val;
}
protected function resolveType(string $name): string
{
return $this->getStoreConfig($name, 'type', 'Mysql');
}
private function studly(string $value): string
{
$value = ucwords(str_replace(['-', '_'], ' ', $value));
return str_replace(' ', '', $value);
}
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
return $this->getDriver()->set($token, $type, $userId, $expire);
}
public function get(string $token, bool $expirationException = true): array
{
return $this->getDriver()->get($token);
}
public function check(string $token, string $type, int $userId, bool $expirationException = true): bool
{
return $this->getDriver()->check($token, $type, $userId);
}
public function delete(string $token): bool
{
return $this->getDriver()->delete($token);
}
public function clear(string $type, int $userId): bool
{
return $this->getDriver()->clear($type, $userId);
}
/**
* 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,260 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use ba\Random;
use ba\Filesystem;
use app\common\model\Attachment;
use app\common\library\upload\Driver;
use app\common\library\upload\WebmanUploadedFile;
use InvalidArgumentException;
use RuntimeException;
/**
* 上传Webman 迁移版,使用 WebmanUploadedFile
*/
class Upload
{
protected array $config = [];
protected ?WebmanUploadedFile $file = null;
protected bool $isImage = false;
protected array $fileInfo = [];
protected array $driver = [
'name' => 'local',
'handler' => [],
'namespace' => '\\app\\common\\library\\upload\\driver\\',
];
protected string $topic = 'default';
public function __construct(?WebmanUploadedFile $file = null, array $config = [])
{
$upload = config('upload', []);
$this->config = array_merge($upload, $config);
if ($file) {
$this->setFile($file);
}
}
public function setFile(?WebmanUploadedFile $file): self
{
if (empty($file)) {
throw new RuntimeException(__('No files were uploaded'));
}
$suffix = strtolower($file->extension());
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$this->fileInfo = [
'suffix' => $suffix,
'type' => $file->getMime(),
'size' => $file->getSize(),
'name' => $file->getOriginalName(),
'sha1' => $file->sha1(),
];
$this->file = $file;
return $this;
}
public function setDriver(string $driver): self
{
$this->driver['name'] = $driver;
return $this;
}
public function getDriver(?string $driver = null, bool $noDriveException = true): Driver|false
{
$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): string|false
{
if ($this->driver['namespace'] || str_contains($driver, '\\')) {
$class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . $this->studly($driver);
if (class_exists($class)) {
return $class;
}
}
return false;
}
protected function studly(string $value): string
{
$value = ucwords(str_replace(['-', '_'], ' ', $value));
return str_replace(' ', '', $value);
}
public function setTopic(string $topic): self
{
$this->topic = $topic;
return $this;
}
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'])) {
$path = $this->file->getPathname();
$imgInfo = is_file($path) ? getimagesize($path) : false;
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
throw new RuntimeException(__('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;
}
public function isImage(): bool
{
return $this->isImage;
}
public function getSuffix(): string
{
return $this->fileInfo['suffix'] ?? 'file';
}
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));
}
public function validates(): void
{
if (empty($this->file)) {
throw new RuntimeException(__('No files have been uploaded or the file size exceeds the upload limit of the server'));
}
$size = Filesystem::fileUnitToByte($this->config['max_size'] ?? '10M');
$mime = $this->checkConfig($this->config['allowed_mime_types'] ?? []);
$suffix = $this->checkConfig($this->config['allowed_suffixes'] ?? '');
if ($this->fileInfo['size'] > $size) {
throw new RuntimeException(__('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 && !in_array(strtolower($this->fileInfo['suffix']), $suffix)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if ($mime && !in_array(strtolower($this->fileInfo['type']), $mime)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->topic)) {
throw new RuntimeException(__('Topic format error'));
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->driver['name'])) {
throw new RuntimeException(__('Driver %s not supported', [$this->driver['name']]));
}
if ($this->checkIsImage()) {
$maxW = $this->config['image_max_width'] ?? 0;
$maxH = $this->config['image_max_height'] ?? 0;
if ($maxW && $this->fileInfo['width'] > $maxW) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
if ($maxH && $this->fileInfo['height'] > $maxH) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
}
}
public function upload(?string $saveName = null, int $adminId = 0, int $userId = 0): array
{
$this->validates();
$driver = $this->getDriver();
if (!$driver) {
throw new RuntimeException(__('Driver %s not supported', [$this->driver['name']]));
}
$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
{
$pattern = "/[\s:@#?&\/=',+]+/u";
$fileName = str_replace(".$suffix", '', $fileName);
$fileName = preg_replace($pattern, '', $fileName);
return mb_substr(htmlspecialchars(strip_tags($fileName)), 0, $length);
}
protected function checkConfig(mixed $configItem): array
{
if (is_array($configItem)) {
return array_map('strtolower', $configItem);
}
return $configItem ? explode(',', strtolower((string)$configItem)) : [];
}
}

View File

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

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace app\common\library\token;
use Exception;
/**
* Token 过期异常
*/
class TokenExpirationException extends Exception
{
public function __construct(
protected string $message = '',
protected int $code = 409,
protected array $data = [],
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
public function getData(): array
{
return $this->data;
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace app\common\library\token\driver;
use support\think\Db;
use app\common\library\token\Driver;
/**
* Token Mysql 驱动
* @see Driver
*/
class Mysql extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
if (!empty($this->options['name'])) {
$this->handler = Db::connect($this->options['name'])->name($this->options['table']);
} else {
$this->handler = Db::name($this->options['table']);
}
}
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
if ($expire === null) {
$expire = $this->options['expire'] ?? 2592000;
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$encryptedToken = $this->getEncryptedToken($token);
$this->handler->insert([
'token' => $encryptedToken,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
]);
// 每隔 48 小时清理一次过期 Token
$time = time();
$lastCacheCleanupTime = $this->getLastCacheCleanupTime();
if (!$lastCacheCleanupTime || $lastCacheCleanupTime < $time - 172800) {
$this->setLastCacheCleanupTime($time);
$this->handler->where('expire_time', '<', time())->where('expire_time', '>', 0)->delete();
}
return true;
}
public function get(string $token): array
{
$data = $this->handler->where('token', $this->getEncryptedToken($token))->find();
if (!$data) {
return [];
}
$data['token'] = $token;
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0);
return $data;
}
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;
}
public function delete(string $token): bool
{
$this->handler->where('token', $this->getEncryptedToken($token))->delete();
return true;
}
public function clear(string $type, int $userId): bool
{
$this->handler->where('type', $type)->where('user_id', $userId)->delete();
return true;
}
/**
* 使用文件存储 last_cache_cleanup_time兼容无 cache 插件环境)
*/
private function getLastCacheCleanupTime(): ?int
{
$path = $this->getCleanupTimePath();
if (!is_file($path)) {
return null;
}
$v = file_get_contents($path);
return $v !== false && $v !== '' ? (int) $v : null;
}
private function setLastCacheCleanupTime(int $time): void
{
$path = $this->getCleanupTimePath();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($path, (string) $time);
}
private function getCleanupTimePath(): string
{
$base = defined('RUNTIME_PATH') ? RUNTIME_PATH : (base_path() . DIRECTORY_SEPARATOR . 'runtime');
return $base . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'token_last_cleanup.txt';
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace app\common\library\upload;
/**
* 上传驱动抽象类Webman 迁移版,支持 WebmanUploadedFile
*/
abstract class Driver
{
protected array $options = [];
/**
* 保存文件
* @param WebmanUploadedFile $file
* @param string $saveName
*/
abstract public function save(WebmanUploadedFile $file, string $saveName): bool;
/**
* 删除文件
*/
abstract public function delete(string $saveName): bool;
/**
* 获取资源 URL 地址
*/
abstract public function url(string $saveName, string|bool $domain = true, string $default = ''): string;
/**
* 文件是否存在
*/
abstract public function exists(string $saveName): bool;
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace app\common\library\upload;
use Webman\Http\UploadFile;
/**
* Webman UploadFile 适配器,提供 ThinkPHP UploadedFile 兼容接口
*/
class WebmanUploadedFile
{
public function __construct(
protected UploadFile $file
) {
}
public function extension(): string
{
$ext = $this->file->getUploadExtension();
return $ext && preg_match("/^[a-zA-Z0-9]+$/", $ext) ? $ext : 'file';
}
public function getMime(): string
{
return $this->file->getUploadMimeType() ?? '';
}
public function getSize(): int
{
$size = $this->file->getSize();
return $size !== false ? $size : 0;
}
public function getOriginalName(): string
{
return $this->file->getUploadName() ?? '';
}
public function sha1(): string
{
$path = $this->file->getPathname();
return is_file($path) ? hash_file('sha1', $path) : '';
}
public function getPathname(): string
{
return $this->file->getPathname();
}
/**
* 移动文件(兼容 ThinkPHP move($dir, $name) 与 Webman move($fullPath)
*/
public function move(string $directory, ?string $name = null): self
{
$destination = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ($name ?? basename($this->file->getUploadName()));
$this->file->move($destination);
return $this;
}
public function getUploadFile(): UploadFile
{
return $this->file;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace app\common\library\upload\driver;
use ba\Filesystem;
use app\common\library\upload\Driver;
use app\common\library\upload\WebmanUploadedFile;
use RuntimeException;
/**
* 上传到本地磁盘的驱动Webman 迁移版)
*/
class Local extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
$this->options = config('filesystem.disks.public', []);
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
}
public function save(WebmanUploadedFile $file, string $saveName): bool
{
$savePathInfo = pathinfo($saveName);
$saveFullPath = $this->getFullPath($saveName);
$destination = rtrim($saveFullPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $savePathInfo['basename'];
$saveDir = dirname($destination);
if (!is_dir($saveDir) && !@mkdir($saveDir, 0755, true)) {
throw new RuntimeException(__('Failed to create upload directory'));
}
$uploadFile = $file->getUploadFile();
$uploadFile->move($destination);
@chmod($destination, 0666 & ~umask());
return true;
}
public function delete(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
if ($this->exists($saveFullName)) {
@unlink($saveFullName);
}
Filesystem::delEmptyDir(dirname($saveFullName));
return true;
}
public function url(string $saveName, string|bool $domain = true, string $default = ''): string
{
$saveName = $this->clearRootPath($saveName);
$saveName = $saveName ? '/' . ltrim(str_replace('\\', '/', $saveName), '/') : '';
if ($domain === true) {
$req = function_exists('request') ? request() : null;
$domain = $req && method_exists($req, 'host') ? '//' . $req->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);
}
public function exists(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
return file_exists($saveFullName);
}
public function getFullPath(string $saveName, bool $baseName = false): string
{
$savePathInfo = pathinfo($saveName);
$root = $this->getRootPath();
$dirName = $savePathInfo['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
{
$root = $this->options['root'] ?? rtrim(base_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'public';
return Filesystem::fsFit($root);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace app\common\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Request;
use Webman\Http\Response;
use app\admin\model\AdminLog as AdminLogModel;
/**
* 管理员操作日志中间件Webman 迁移版)
* 仅对 /admin 路由的 POST、DELETE 请求记录日志
*/
class AdminLog implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$response = $handler($request);
$path = trim($request->path(), '/');
if (str_starts_with($path, 'admin/') && config('buildadmin.auto_write_admin_log', true)) {
$method = $request->method();
if ($method === 'POST' || $method === 'DELETE') {
try {
AdminLogModel::instance($request)->record();
} catch (\Throwable $e) {
\support\Log::warning('[AdminLog] ' . $e->getMessage());
}
}
}
return $response;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace app\common\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Request;
use Webman\Http\Response;
/**
* 跨域请求支持Webman 迁移版)
* 安全起见,只支持配置中的域名
*/
class AllowCrossDomain implements MiddlewareInterface
{
protected array $header = [
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => '*',
'Access-Control-Allow-Headers' => '*',
];
/**
* 返回 CORS 预检OPTIONS响应供路由直接调用Webman 未匹配路由时不走中间件)
*/
public static function optionsResponse(Request $request): Response
{
$header = [
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang',
];
$origin = $request->header('origin');
if (is_array($origin)) {
$origin = $origin[0] ?? '';
}
$origin = is_string($origin) ? trim($origin) : '';
if ($origin !== '') {
$info = parse_url($origin);
$host = $info['host'] ?? '';
$corsDomain = array_map('trim', explode(',', config('buildadmin.cors_request_domain', '')));
$corsDomain[] = $request->host(true);
$allowed = in_array('*', $corsDomain)
|| in_array($origin, $corsDomain)
|| in_array($host, $corsDomain)
|| ($host === 'localhost' || $host === '127.0.0.1');
if ($allowed) {
$header['Access-Control-Allow-Origin'] = $origin;
}
}
return response('', 204, $header);
}
public function process(Request $request, callable $handler): Response
{
$path = trim($request->path(), '/');
if (!str_starts_with($path, 'api/') && !str_starts_with($path, 'admin/')) {
return $handler($request);
}
$header = $this->header;
$origin = $request->header('origin');
if (is_array($origin)) {
$origin = $origin[0] ?? '';
}
$origin = is_string($origin) ? trim($origin) : '';
if ($origin !== '') {
$info = parse_url($origin);
$host = $info['host'] ?? '';
$corsDomain = array_map('trim', explode(',', config('buildadmin.cors_request_domain', '')));
$corsDomain[] = $request->host(true);
$allowed = in_array('*', $corsDomain)
|| in_array($origin, $corsDomain)
|| in_array($host, $corsDomain)
|| ($host === 'localhost' || $host === '127.0.0.1');
if ($allowed) {
$header['Access-Control-Allow-Origin'] = $origin;
}
}
if ($request->method() === 'OPTIONS') {
return response('', 204, $header);
}
$response = $handler($request);
return $response->withHeaders($header);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace app\common\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Request;
use Webman\Http\Response;
/**
* 加载控制器语言包中间件Webman 迁移版,等价 ThinkPHP LoadLangPack
* 根据当前路由加载对应控制器的语言包到 Translator
*/
class LoadLangPack implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$path = trim($request->path(), '/');
if (str_starts_with($path, 'api/') || str_starts_with($path, 'admin/')) {
$this->loadLang($request);
}
return $handler($request);
}
protected function loadLang(Request $request): void
{
// 优先从请求头 think-lang 获取前端选择的语言(与前端 axios 发送的 header 对应)
$headerLang = $request->header('think-lang');
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
if ($headerLang && in_array(str_replace('_', '-', strtolower($headerLang)), $allowLangList)) {
$langSet = str_replace('_', '-', strtolower($headerLang));
} else {
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
$langSet = str_replace('_', '-', strtolower($langSet));
}
// 设置当前请求的翻译语言,使 __() 和 trans() 使用正确的语言
if (function_exists('locale')) {
locale($langSet);
}
$path = trim($request->path(), '/');
$parts = explode('/', $path);
$app = $parts[0] ?? 'api';
$appLangDir = base_path() . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . $app . DIRECTORY_SEPARATOR . 'lang' . DIRECTORY_SEPARATOR;
if (!class_exists(\support\Translation::class)) {
return;
}
$translator = \support\Translation::instance();
// 1. 加载根级语言包zh-cn.php / en.php供 common 翻译使用
$rootLangFile = $appLangDir . $langSet . '.php';
if (is_file($rootLangFile)) {
$translator->addResource('phpfile', $rootLangFile, $langSet, 'messages');
}
// 2. 加载控制器专用语言包(如 zh-cn/auth/group.php供 get_route_remark 等使用
$controllerPath = get_controller_path($request);
if ($controllerPath) {
$controllerPathForFile = str_replace('.', '/', $controllerPath);
$controllerPathForFile = implode('/', array_map(function ($p) {
return strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $p));
}, explode('/', $controllerPathForFile)));
$controllerLangFile = $appLangDir . $langSet . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $controllerPathForFile) . '.php';
if (is_file($controllerLangFile)) {
$translator->addResource('phpfile', $controllerLangFile, $langSet, $controllerPath);
}
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
use think\model\relation\BelongsTo;
class Attachment extends Model
{
protected string $table = 'attachment';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
protected array $append = ['suffix', 'full_url'];
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
{
return full_url($row['url'] ?? '');
}
public function admin(): BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

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

@@ -0,0 +1,40 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* 会员公共模型
*/
class User extends Model
{
protected string $table = 'user';
protected string $pk = 'id';
protected bool $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)
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
public function getMoneyAttr($value): string
{
return bcdiv((string)$value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string)$value, '100', 2);
}
}

View File

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

View File

@@ -0,0 +1,13 @@
<?php
namespace app\common\model;
use support\think\Model;
class UserScoreLog extends Model
{
protected string $table = 'user_score_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
}