mirror of
https://gitee.com/zoujingli/ThinkAdmin.git
synced 2026-06-08 04:48:10 +08:00
将 v8 重构分支中残留的 ThinkAdminDeveloper 文本统一调整为 ThinkAdmin,避免迁移到主仓库后继续暴露旧开发仓库名称。 主要内容: - 更新 README 标题与项目描述。 - 统一 PHP 文件头注释中的项目标识。 - 同步调整测试、配置、插件与文档中的旧仓库名称文本。 - 保持旧包删除说明与架构边界测试语义不变,只清理品牌名称残留。
570 lines
17 KiB
PHP
570 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
/**
|
||
* +----------------------------------------------------------------------
|
||
* | ThinkAdmin Plugin
|
||
* +----------------------------------------------------------------------
|
||
* | Copyright (c) 2014~2026 ThinkAdmin [ thinkadmin.top ]
|
||
* +----------------------------------------------------------------------
|
||
* | Official Website: https://thinkadmin.top
|
||
* +----------------------------------------------------------------------
|
||
* | Licensed: https://mit-license.org
|
||
* | Disclaimer: https://thinkadmin.top/disclaimer
|
||
* | Vip Rights: https://thinkadmin.top/vip-introduce
|
||
* +----------------------------------------------------------------------
|
||
* | Gitee Repository: https://gitee.com/zoujingli/ThinkAdmin
|
||
* | Github Repository: https://github.com/zoujingli/ThinkAdmin
|
||
* +----------------------------------------------------------------------
|
||
*/
|
||
use think\admin\Exception;
|
||
use think\admin\extend\CodeToolkit;
|
||
use think\admin\extend\HttpClient;
|
||
use think\admin\helper\QueryHelper;
|
||
use think\admin\helper\ValidateHelper;
|
||
use think\admin\Library;
|
||
use think\admin\model\ModelFactory;
|
||
use think\admin\route\Url;
|
||
use think\admin\service\AppService;
|
||
use think\admin\service\CacheSession;
|
||
use think\admin\service\RuntimeService;
|
||
use think\admin\Storage;
|
||
use think\db\BaseQuery;
|
||
use think\db\Query;
|
||
use think\Model;
|
||
|
||
if (!function_exists('p')) {
|
||
/**
|
||
* 输出调试数据到运行日志文件。
|
||
*
|
||
* @param mixed $data 调试数据
|
||
* @param bool $new 是否覆盖原文件
|
||
* @param ?string $file 指定日志文件
|
||
*/
|
||
function p(mixed $data, bool $new = false, ?string $file = null): false|int
|
||
{
|
||
return AppService::putDebug($data, $new, $file);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('m')) {
|
||
/**
|
||
* 动态创建模型实例。
|
||
*
|
||
* @param string $name 模型名称
|
||
* @param array $data 初始数据
|
||
* @param string $conn 指定连接
|
||
*/
|
||
function m(string $name, array $data = [], string $conn = ''): Model
|
||
{
|
||
return ModelFactory::build($name, $data, $conn);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('_vali')) {
|
||
/**
|
||
* 快捷读取输入并执行验证。
|
||
*
|
||
* @param array $rules 验证规则
|
||
* @param array|string $type 输入源或输入数据
|
||
* @param ?callable $callable 验证失败回调
|
||
*/
|
||
function _vali(array $rules, array|string $type = '', ?callable $callable = null): array
|
||
{
|
||
return ValidateHelper::instance()->init($rules, $type, $callable);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('_query')) {
|
||
/**
|
||
* 创建快捷查询构造器。
|
||
*
|
||
* @param BaseQuery|Model|string $dbQuery 查询对象或模型名称
|
||
* @param null|array|string $input 附加输入条件
|
||
*/
|
||
function _query(BaseQuery|Model|string $dbQuery, array|string|null $input = null): QueryHelper
|
||
{
|
||
return QueryHelper::instance()->init($dbQuery, $input);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('sysvar')) {
|
||
/**
|
||
* 读写单次请求内的轻量级内存变量。
|
||
*
|
||
* 仅用于当前请求周期内的临时缓存。
|
||
* 传入空字符串 `('', '')` 时会清空全部缓存。
|
||
*
|
||
* @param ?string $name 变量名
|
||
* @param null|mixed $value 变量值
|
||
* @return mixed
|
||
*/
|
||
function sysvar(?string $name = null, mixed $value = null)
|
||
{
|
||
static $swap = [];
|
||
|
||
if ($name === '' && $value === '') {
|
||
return $swap = [];
|
||
}
|
||
if ($value === null) {
|
||
return $name === null ? $swap : ($swap[$name] ?? null);
|
||
}
|
||
|
||
return $swap[$name] = $value;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('sysuri')) {
|
||
/**
|
||
* 生成系统页面 URL。
|
||
*
|
||
* 参数与 ThinkPHP `url()` 保持一致,
|
||
* 但会在构建前先把后台页面地址标准化为短链目标。
|
||
*
|
||
* @param string $url 路由地址
|
||
* @param array $vars 路由参数
|
||
* @param bool|string $suffix 后缀配置
|
||
* @param bool|string $domain 域名配置
|
||
*/
|
||
function sysuri(string $url = '', array $vars = [], bool|string $suffix = true, bool|string $domain = false): string
|
||
{
|
||
$target = Url::normalizeWebTarget($url);
|
||
return Library::$sapp->route->buildUrl($target, $vars)->suffix($suffix)->domain($domain)->build();
|
||
}
|
||
}
|
||
|
||
if (!function_exists('apiuri')) {
|
||
/**
|
||
* 生成标准插件 API URL。
|
||
*
|
||
* 统一输出 `/api/{plugin}/{controller}/{action}` 风格地址,
|
||
* 并兼容当前插件上下文与 `controller/api/*` 的历史写法。
|
||
*
|
||
* @param string $url 路由地址
|
||
* @param array $vars 路由参数
|
||
* @param bool|string $suffix 后缀配置
|
||
* @param bool|string $domain 域名配置
|
||
*/
|
||
function apiuri(string $url = '', array $vars = [], bool|string $suffix = true, bool|string $domain = false): string
|
||
{
|
||
$target = Url::normalizeApiTarget($url);
|
||
return Library::$sapp->route->buildUrl($target, $vars)->suffix($suffix)->domain($domain)->build();
|
||
}
|
||
}
|
||
|
||
if (!function_exists('tsession')) {
|
||
/**
|
||
* 获取令牌会话服务实例。
|
||
*/
|
||
function tsession(): CacheSession
|
||
{
|
||
return CacheSession::instance();
|
||
}
|
||
}
|
||
|
||
if (!function_exists('encode')) {
|
||
/**
|
||
* 将 UTF-8 文本编码为兼容旧逻辑的短字符串。
|
||
*/
|
||
function encode(string $content): string
|
||
{
|
||
$string = CodeToolkit::text2utf8($content);
|
||
$length = strlen($string);
|
||
if ($length === 0) {
|
||
return '';
|
||
}
|
||
|
||
$chars = '';
|
||
for ($i = 0; $i < $length; ++$i) {
|
||
$chars .= str_pad(base_convert((string)ord($string[$i]), 10, 36), 2, '0', STR_PAD_LEFT);
|
||
}
|
||
|
||
return $chars;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('decode')) {
|
||
/**
|
||
* 将 `encode()` 结果还原为 UTF-8 文本。
|
||
*/
|
||
function decode(string $content): string
|
||
{
|
||
if ($content === '') {
|
||
return '';
|
||
}
|
||
|
||
$chars = '';
|
||
foreach (str_split($content, 2) as $char) {
|
||
if (strlen($char) < 2) {
|
||
continue;
|
||
}
|
||
$chars .= chr((int)base_convert($char, 36, 10));
|
||
}
|
||
|
||
return CodeToolkit::text2utf8($chars);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('str2arr')) {
|
||
/**
|
||
* 将字符串或数组标准化为数组。
|
||
*
|
||
* 字符串会按分隔符拆分;数组会递归展开。
|
||
* 返回结果会自动去空白,并按需执行 allow 白名单过滤。
|
||
*
|
||
* @param array|string $text 原始内容
|
||
* @param string $separ 分隔符
|
||
* @param ?array $allow 白名单限制
|
||
*/
|
||
function str2arr(array|string $text, string $separ = ',', ?array $allow = null): array
|
||
{
|
||
$items = [];
|
||
|
||
foreach ((array)$text as $item) {
|
||
if (is_array($item)) {
|
||
foreach (str2arr($item, $separ, $allow) as $value) {
|
||
$items[] = $value;
|
||
}
|
||
continue;
|
||
}
|
||
if (!is_scalar($item) || $item === false) {
|
||
continue;
|
||
}
|
||
if (is_string($item)) {
|
||
foreach (explode($separ, trim($item, $separ)) as $value) {
|
||
$value = trim($value);
|
||
if ($value !== '' && (!is_array($allow) || in_array($value, $allow, true))) {
|
||
$items[] = $value;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (!is_array($allow) || in_array($item, $allow, true)) {
|
||
$items[] = $item;
|
||
}
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('arr2str')) {
|
||
/**
|
||
* 将字符串或数组标准化为分隔字符串。
|
||
*
|
||
* 内部会复用 `str2arr()` 做统一归一化,
|
||
* 最终输出形如 `,a,b,c,` 的历史兼容格式。
|
||
*
|
||
* @param array|string $data 原始内容
|
||
* @param string $separ 分隔符
|
||
* @param ?array $allow 白名单限制
|
||
*/
|
||
function arr2str(array|string $data, string $separ = ',', ?array $allow = null): string
|
||
{
|
||
$items = str2arr($data, $separ, $allow);
|
||
return empty($items) ? '' : $separ . join($separ, $items) . $separ;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('format_datetime')) {
|
||
/**
|
||
* 兼容旧版时间格式化函数。
|
||
*
|
||
* @param mixed $value 原始时间值
|
||
* @param string $format 输出格式
|
||
*/
|
||
function format_datetime(mixed $value, string $format = 'Y-m-d H:i:s'): string
|
||
{
|
||
if ($value === null || $value === '' || $value === false) {
|
||
return '-';
|
||
}
|
||
if (is_numeric($value)) {
|
||
$timestamp = intval($value);
|
||
} else {
|
||
$timestamp = strtotime(strval($value)) ?: 0;
|
||
}
|
||
return $timestamp > 0 ? date($format, $timestamp) : '-';
|
||
}
|
||
}
|
||
|
||
if (!function_exists('isDebug')) {
|
||
/**
|
||
* 判断当前是否处于调试模式。
|
||
*/
|
||
function isDebug(): bool
|
||
{
|
||
return RuntimeService::isDebug();
|
||
}
|
||
}
|
||
|
||
if (!function_exists('isOnline')) {
|
||
/**
|
||
* 判断当前是否处于生产模式。
|
||
*/
|
||
function isOnline(): bool
|
||
{
|
||
return RuntimeService::isOnline();
|
||
}
|
||
}
|
||
|
||
if (!function_exists('syspath')) {
|
||
/**
|
||
* 获取系统路径(兼容 Phar 的内部路径).
|
||
*
|
||
* 直接读取打包在 Phar 包内的文件。
|
||
* - PHAR 环境:返回 phar:// 协议路径(只读)
|
||
* - 普通环境:返回实际文件系统路径
|
||
*
|
||
* 示例:syspath('config') 读取 phar 包内的 config 目录
|
||
*
|
||
* @param string $path 相对路径
|
||
* @param ?string $root 自定义根路径(一般不传)
|
||
* @return string 完整的系统路径(Phar 内部路径或文件系统路径)
|
||
*/
|
||
function syspath(string $path = '', ?string $root = null): string
|
||
{
|
||
// 如果未提供 root,自动检测运行环境
|
||
if ($root === null) {
|
||
// Phar::running(false) 返回物理路径(不含 phar://),避免出现 phar://phar:// 这种重复前缀
|
||
$phar = Phar::running(false);
|
||
$root = $phar !== '' ? "phar://{$phar}" : Library::$sapp->getRootPath();
|
||
}
|
||
|
||
$root = rtrim(strval($root), '/\\');
|
||
return $path === '' ? $root : "{$root}/" . ltrim(str_replace('\\', '/', $path), '/');
|
||
}
|
||
}
|
||
|
||
if (!function_exists('runpath')) {
|
||
/**
|
||
* 获取运行时路径(操作系统文件系统路径).
|
||
*
|
||
* 直接读取/写入操作系统的文件。
|
||
* - PHAR 环境:返回 Phar 包外部的安装目录(可写)
|
||
* - 普通环境:返回项目根目录(可写)
|
||
*
|
||
* 示例:runpath('runtime') 读取/写入操作系统文件系统的 runtime 目录
|
||
*
|
||
* @param string $path 相对路径
|
||
* @return string 完整的运行时路径(操作系统文件系统路径)
|
||
*/
|
||
function runpath(string $path = ''): string
|
||
{
|
||
$phar = Phar::running(false);
|
||
$base = rtrim($phar !== '' ? dirname($phar) : Library::$sapp->getRootPath(), '/\\');
|
||
return $path === '' ? $base : (($path === '/') ? $base : "{$base}/" . ltrim(str_replace('\\', '/', $path), '/'));
|
||
}
|
||
}
|
||
|
||
if (!function_exists('is_phar')) {
|
||
/**
|
||
* 判断当前是否运行在 PHAR 环境中。
|
||
*/
|
||
function is_phar(): bool
|
||
{
|
||
static $cache = null;
|
||
if ($cache === null) {
|
||
$cache = Phar::running() !== '';
|
||
}
|
||
return $cache;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('enbase64url')) {
|
||
/**
|
||
* Base64 URL 安全编码。
|
||
*/
|
||
function enbase64url(string $string): string
|
||
{
|
||
return CodeToolkit::enSafe64($string);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('debase64url')) {
|
||
/**
|
||
* Base64 URL 安全解码。
|
||
*/
|
||
function debase64url(string $string): string
|
||
{
|
||
return CodeToolkit::deSafe64($string);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('xss_safe')) {
|
||
/**
|
||
* 对文本执行基础 XSS 安全处理。
|
||
*
|
||
* 当前逻辑会移除 script 标签,并中和内联事件属性。
|
||
*/
|
||
function xss_safe(string $text): string
|
||
{
|
||
$rules = [
|
||
'#<script\b[^>]*>.*?</script>#is' => '',
|
||
'#(\s+)on([a-z_][\w:-]*\s*=)#i' => '$1data-on-$2',
|
||
];
|
||
|
||
return preg_replace(array_keys($rules), array_values($rules), trim($text)) ?? trim($text);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('password_mask')) {
|
||
/**
|
||
* 返回编辑态密码占位符。
|
||
*
|
||
* 默认固定为 6 个星号,前端展示统一使用该值,
|
||
* 用户保留全星号提交时可视为“不修改密码”。
|
||
*/
|
||
function password_mask(int $length = 6): string
|
||
{
|
||
return str_repeat('*', max(1, $length));
|
||
}
|
||
}
|
||
|
||
if (!function_exists('password_is_mask')) {
|
||
/**
|
||
* 判断输入是否为纯星号密码占位。
|
||
*
|
||
* 只要输入内容全部由 `*` 组成,且长度大于 0,
|
||
* 就认为它是保留旧密码的占位内容。
|
||
*/
|
||
function password_is_mask(mixed $value): bool
|
||
{
|
||
$value = trim(strval($value));
|
||
return $value !== '' && preg_match('/^\*+$/', $value) === 1;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('password_is_unchanged')) {
|
||
/**
|
||
* 判断编辑态密码输入是否表示“保持不变”。
|
||
*
|
||
* 兼容两种历史/新标准:
|
||
* - 空字符串:历史“留空不修改”
|
||
* - 纯星号:新标准“保留星号不修改”
|
||
*/
|
||
function password_is_unchanged(mixed $value): bool
|
||
{
|
||
$value = trim(strval($value));
|
||
return $value === '' || password_is_mask($value);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('http_get')) {
|
||
/**
|
||
* 发送 GET 请求。
|
||
*
|
||
* @param string $url 请求地址
|
||
* @param array|string $query 查询参数
|
||
* @param array $options 客户端配置
|
||
*/
|
||
function http_get(string $url, array|string $query = [], array $options = []): bool|string
|
||
{
|
||
return HttpClient::get($url, $query, $options);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('http_post')) {
|
||
/**
|
||
* 发送 POST 请求。
|
||
*
|
||
* @param string $url 请求地址
|
||
* @param array|string $data 提交数据
|
||
* @param array $options 客户端配置
|
||
*/
|
||
function http_post(string $url, array|string $data, array $options = []): bool|string
|
||
{
|
||
return HttpClient::post($url, $data, $options);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('data_save')) {
|
||
/**
|
||
* 按主键或条件执行增量保存。
|
||
*
|
||
* @param Model|Query|string $dbQuery 查询对象或模型
|
||
* @param array $data 保存数据
|
||
* @param string $key 主键字段
|
||
* @param null|array $where 附加条件
|
||
* @throws Exception
|
||
*/
|
||
function data_save(Model|Query|string $dbQuery, array $data, string $key = 'id', ?array $where = []): bool|int
|
||
{
|
||
return AppService::save($dbQuery, $data, $key, $where);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('down_file')) {
|
||
/**
|
||
* 下载远程文件并返回本地访问地址。
|
||
*
|
||
* @param string $source 源文件地址
|
||
* @param bool $force 是否强制重下
|
||
* @param int $expire 本地缓存秒数
|
||
*/
|
||
function down_file(string $source, bool $force = false, int $expire = 0): string
|
||
{
|
||
return Storage::down($source, $force, $expire)['url'] ?? $source;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('trace_file')) {
|
||
/**
|
||
* 将异常信息落盘到 runtime/trace 目录。
|
||
*
|
||
* @param Throwable $exception 异常对象
|
||
*/
|
||
function trace_file(Throwable $exception): bool
|
||
{
|
||
$path = rtrim(Library::$sapp->getRuntimePath(), '\/') . DIRECTORY_SEPARATOR . 'trace';
|
||
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
|
||
return false;
|
||
}
|
||
|
||
$root = strtr(rtrim(syspath(), '\/'), '\\', '/');
|
||
$source = strtr($exception->getFile(), '\\', '/');
|
||
$name = basename($source);
|
||
if ($root !== '' && str_starts_with(strtolower($source), strtolower($root . '/'))) {
|
||
$name = ltrim(substr($source, strlen($root)), '/');
|
||
}
|
||
|
||
$file = $path . DIRECTORY_SEPARATOR . date('Ymd_His_') . strtr($name, ['/' => '.', '\\' => '.']);
|
||
$json = json_encode(
|
||
$exception instanceof Exception ? $exception->getData() : [],
|
||
JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
|
||
) ?: '[]';
|
||
$class = get_class($exception);
|
||
|
||
return file_put_contents(
|
||
$file,
|
||
"[CODE] {$exception->getCode()}" . PHP_EOL
|
||
. "[INFO] {$exception->getMessage()}" . PHP_EOL
|
||
. ($exception instanceof Exception ? "[DATA] {$json}" . PHP_EOL : '')
|
||
. "[FILE] {$class} in {$name} line {$exception->getLine()}" . PHP_EOL
|
||
. '[TIME] ' . date('Y-m-d H:i:s') . PHP_EOL . PHP_EOL
|
||
. '[TRACE]' . PHP_EOL . $exception->getTraceAsString()
|
||
) !== false;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('format_bytes')) {
|
||
/**
|
||
* 将字节数格式化为可读单位。
|
||
*
|
||
* @param float|int|string $size 原始字节值
|
||
*/
|
||
function format_bytes(float|int|string $size): string
|
||
{
|
||
if (is_numeric($size)) {
|
||
$size = (float)$size;
|
||
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||
for ($i = 0; $size >= 1024 && $i < count($units) - 1; ++$i) {
|
||
$size /= 1024;
|
||
}
|
||
|
||
return round($size, 2) . ' ' . $units[$i];
|
||
}
|
||
|
||
return is_string($size) ? $size : strval($size);
|
||
}
|
||
}
|