Files
MrleeAdmin/backend/app/service/admin/plugin/PluginDevelopService.php
T
2026-08-31 11:33:59 +08:00

923 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: https://madong.tech
*/
namespace app\service\admin\plugin;
use app\dao\plugin\PluginDao;
use app\model\plugin\Plugin;
use app\process\Monitor;
use core\base\BaseService;
use core\exception\handler\AdminException;
use core\uuid\Snowflake;
use support\Container;
use Workerman\Timer;
/**
* Plugin服务层
*
* @author Mr.April
* @since 1.0
*/
class PluginDevelopService extends BaseService
{
private const MONITOR_RESUME_DELAY = 3;
private string $pluginDir;
private string $adminDir;
private string $webDir;
public function __construct(PluginDao $dao)
{
$this->dao = $dao;
$basePath = base_path();
$projectPath = dirname($basePath);
$frontendPath = $projectPath . '/frontend';
$this->pluginDir = $basePath . '/plugin';
$this->adminDir = $frontendPath . '/admin/src/apps';
$this->webDir = $frontendPath . '/web/app/apps';
}
/**
* 获取插件列表(扫描插件目录并同步到数据库)
*
* @param array $where 查询条件(标准filters格式)
* @param int $page 页码
* @param int $limit 每页数量
*
* @return array
* @throws \Exception
*/
public function getList(array $where = [], int $page = 1, int $limit = 15): array
{
// 先扫描插件目录,将新插件同步到数据库
$this->syncPlugins();
return $this->dao->getList($where, $page, $limit);
}
/**
* 扫描插件目录并同步到数据库
*
* @return void
* @throws \Exception
*/
private function syncPlugins(): void
{
// 获取插件根目录下所有子目录
if (!is_dir($this->pluginDir)) {
return;
}
$dirs = scandir($this->pluginDir);
$pluginsToInsert = [];
$existingPluginKeys = [];
foreach ($dirs as $dir) {
// 跳过.和..目录
if ($dir === '.' || $dir === '..') {
continue;
}
// 优先读取 info.php 获取插件基本信息
$infoPath = $this->pluginDir . '/' . $dir . '/config/info.php';
if (!file_exists($infoPath)) {
continue;
}
// 加载插件 info 配置
$infoConfig = include $infoPath;
if (!is_array($infoConfig)) {
continue;
}
// 检查插件归属类型:madong(官方)、third(第三方)、custom(自定义)
$pluginType = $infoConfig['type'] ?? 'madong';
// 检查是否为madong类型的插件
if (!str_starts_with($pluginType, 'madong:') && $pluginType !== 'madong') {
continue;
}
$pluginKey = $infoConfig['name'] ?? $dir;
$existingPluginKeys[] = $pluginKey;
// 检查 installed.php 判断是否已安装
$installedPath = $this->pluginDir . '/' . $dir . '/config/installed.php';
$isInstalled = file_exists($installedPath);
$installedAt = 0;
$status = 0; // 默认未安装
if ($isInstalled) {
$installedConfig = include $installedPath;
if (is_array($installedConfig) && !empty($installedConfig['installed_at'])) {
$installedAt = strtotime($installedConfig['installed_at']);
} else {
$installedAt = time();
}
$status = 1; // 已安装
}
// 检查插件是否已存在于数据库
$existingPlugin = $this->dao->findByKey($pluginKey);
if ($existingPlugin) {
// 如果数据库存在但安装状态不同,同步更新
if ($existingPlugin->installed_at != $installedAt || $existingPlugin->status != $status) {
$existingPlugin->installed_at = $installedAt;
$existingPlugin->status = $status;
$existingPlugin->save();
}
continue;
}
// 读取icon和cover图片(base64或路径)
$icon = $this->getPluginImageBase64($dir, 'icon');
$cover = $this->getPluginImageBase64($dir, 'cover');
// 使用 info.php 的字段
$pluginsToInsert[] = [
'id' => Snowflake::generate(),
'title' => $infoConfig['description'] ?? $pluginKey,
'key' => $pluginKey,
'desc' => $infoConfig['description'] ?? '',
'author' => $infoConfig['author'] ?? '',
'version' => $infoConfig['version'] ?? '1.0.0',
'type' => $pluginType,
'icon' => $icon,
'cover' => $cover,
'status' => $status,
'support_app' => 'admin',
'created_at' => time(),
'updated_at' => time(),
'installed_at' => $installedAt,
];
}
// 批量插入新插件
if (!empty($pluginsToInsert)) {
$this->dao->batchInsert($pluginsToInsert);
}
// 移除数据库中已不存在的插件
$this->removeNonExistingPlugins($existingPluginKeys);
}
/**
* 移除数据库中已不存在的插件
*
* @param array $existingPluginKeys 现有的插件key列表
*
* @return void
* @throws \Exception
*/
private function removeNonExistingPlugins(array $existingPluginKeys): void
{
$allDbPluginKeys = $this->dao->getAllKeys();
$keysToDelete = array_diff($allDbPluginKeys, $existingPluginKeys);
if (!empty($keysToDelete)) {
// 批量删除不存在的插件
foreach ($keysToDelete as $key) {
$plugin = $this->dao->findByKey($key);
if ($plugin) {
$plugin->delete();
}
}
}
}
/**
* 获取插件图片的base64编码
*
* @param string $pluginName 插件名称
* @param string $imageName 图片名称(icon或cover)
*
* @return string|null
*/
private function getPluginImageBase64(string $pluginName, string $imageName): ?string
{
$imagePath = $this->pluginDir . '/' . $pluginName . '/public/' . $imageName . '.png';
if (!file_exists($imagePath)) {
// 尝试查找resource目录下的图片
$resourcePath = $this->pluginDir . '/' . $pluginName . '/resource/public/' . $imageName . '.png';
if (!file_exists($resourcePath)) {
return null;
}
$imagePath = $resourcePath;
}
$imageData = file_get_contents($imagePath);
if ($imageData === false) {
return null;
}
return 'data:image/png;base64,' . base64_encode($imageData);
}
/**
* 获取插件详情
*
* @param string|int $id 插件ID
*
* @return Plugin|null
* @throws \Exception
*/
public function read(string|int $id): ?Plugin
{
$plugin = $this->dao->get($id);
if (!$plugin) {
return null;
}
$pluginConfigPath = $this->pluginDir . '/' . $plugin->key . '/config/app.php';
if (file_exists($pluginConfigPath)) {
$config = include $pluginConfigPath;
if (is_array($config)) {
// 更新插件信息
$plugin->title = $config['title'] ?? $plugin->title;
$plugin->desc = $config['description'] ?? $plugin->desc;
$plugin->author = $config['author'] ?? $plugin->author;
$plugin->version = $config['version'] ?? $plugin->version;
$plugin->type = $config['type'] ?? $plugin->type;
$plugin->icon = $this->getPluginImageBase64($plugin->key, 'icon');
$plugin->cover = $this->getPluginImageBase64($plugin->key, 'cover');
}
}
return $plugin;
}
/**
* 创建插件
*
* @param array $data
*
* @return void
* @throws \Throwable
* @throws \core\exception\handler\AdminException
*/
public function store(array $data): void
{
$this->transaction(function () use ($data) {
try {
if (empty($data['icon'])) {
$data['icon'] = $this->generateDefaultIcon($data['title'] ?? 'Plugin');
}
if (empty($data['cover'])) {
$data['cover'] = $this->generateDefaultCover($data['title'] ?? 'Plugin');
}
$model = $this->dao->save($data);
if (empty($model)) {
throw new AdminException('插件创建失败');
}
$monitor_support_pause = method_exists(Monitor::class, 'pause');
if ($monitor_support_pause) {
Monitor::pause();
}
//生成插件模板
/**@var \app\service\core\plugin\PluginDevelopService $service */
$service = Container::make(\app\service\core\plugin\PluginDevelopService::class);
$pluginName = $model->key;
$pluginTitle = $model->title;
$pluginDescription = $model->desc ?? '';
$frontendType = $data['frontend_type'] ?? 'admin';
$result = $service->generatePluginTemplate($pluginName, $pluginTitle, $pluginDescription, $frontendType);
if ($result['code'] !== 200) {
throw new AdminException($result['message']);
}
// 生成插件 info.php 配置文件
$this->createPluginInfoConfig($pluginName, $data);
//添加图片到对应插件位置
$publicPath = $this->pluginDir . '/' . $pluginName . '/public';
// 确保public目录存在
if (!is_dir($publicPath)) {
mkdir($publicPath, 0755, true);
}
$iconPath = $publicPath . '/icon.png';
$coverPath = $publicPath . '/cover.png';
$this->saveBase64Image($data['icon'], $iconPath);
$this->saveBase64Image($data['cover'], $coverPath);
if ($monitor_support_pause) {
Timer::add(self::MONITOR_RESUME_DELAY, static function () {
Monitor::resume();
}, null, false);
}
} catch (\Exception $e) {
throw new AdminException($e->getMessage());
}
});
}
/**
* 打包插件
*
* @param string $pluginKey 插件标识
* @param bool $updateTemplate 是否更新前端模板到resource/template目录(默认true)
*
* @return array
* @throws \core\exception\handler\AdminException
*/
public function buildPlugin(string $pluginKey, bool $updateTemplate = true): array
{
try {
// 查询插件信息
$plugin = $this->dao->findByKey($pluginKey);
if (!$plugin) {
throw new AdminException('插件不存在');
}
// 定义路径
$backendPath = $this->pluginDir . '/' . $pluginKey;
$adminPath = $this->adminDir . '/' . $pluginKey;
$webPath = $this->webDir . '/' . $pluginKey;
$resourcePath = $backendPath . '/resource/template';
// 验证后端插件目录是否存在
if (!is_dir($backendPath)) {
throw new AdminException("插件后端目录不存在: {$backendPath}");
}
// 是否更新前端模板到 resource/template 目录
if ($updateTemplate) {
// 清理并创建template目录
if (is_dir($resourcePath)) {
$this->removeDirectory($resourcePath);
}
$resourceCount = 0;
// 复制后台前端到template目录(如果存在)
if (is_dir($adminPath)) {
$this->copyDirectory($adminPath, $resourcePath . '/admin');
$resourceCount++;
}
// 复制前台前端到resource目录(如果存在)
if (is_dir($webPath)) {
$this->copyDirectory($webPath, $resourcePath . '/web');
$resourceCount++;
}
if ($resourceCount === 0) {
// 没有前端资源,清空resource目录
if (is_dir($resourcePath)) {
rmdir($resourcePath);
}
}
} else {
// 不更新时检查是否已有模板
$resourceCount = (is_dir($resourcePath . '/admin') || is_dir($resourcePath . '/web')) ? 1 : 0;
}
// 调用核心服务的 build 方法打包插件
$pluginsDir = base_path() . '/runtime/plugins';
/** @var \app\service\core\plugin\PluginDevelopService $coreService */
$coreService = Container::make(\app\service\core\plugin\PluginDevelopService::class);
$result = $coreService->build($pluginKey, $pluginsDir);
if ($result['code'] !== 200) {
throw new AdminException($result['message']);
}
return [
'plugin_key' => $pluginKey,
'zip_path' => $result['data']['zip_file_path'],
'has_frontend' => $resourceCount > 0,
];
} catch (\Exception $e) {
throw new AdminException('插件打包失败: ' . $e->getMessage());
}
}
/**
* 复制目录
*
* @param string $source 源目录
* @param string $target 目标目录
*
* @return void
*/
private function copyDirectory(string $source, string $target): void
{
if (!is_dir($source)) {
return;
}
if (!is_dir($target)) {
mkdir($target, 0755, true);
}
$files = scandir($source);
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$sourceFile = $source . '/' . $file;
$targetFile = $target . '/' . $file;
if (is_dir($sourceFile)) {
$this->copyDirectory($sourceFile, $targetFile);
} else {
copy($sourceFile, $targetFile);
}
}
}
/**
* 删除目录及其内容
*
* @param string $dir 目录路径
*
* @return void
*/
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$files = scandir($dir);
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->removeDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* 更新插件信息
*
* @param int|string $id 插件ID
* @param array $data 更新数据
*
* @return void
* @throws \Throwable
* @throws \core\exception\handler\AdminException
*/
public function update(int|string $id, array $data): void
{
$this->transaction(function () use ($id, $data) {
$model = $this->dao->get($id);
if (!$model) {
throw new AdminException('插件不存在');
}
$pluginKey = $model->getAttribute('key');
// 更新数据库 - 排除key字段,不允许修改插件标识
$updateData = $data;
unset($updateData['key']);
$model->fill($updateData);
$model->save();
// 更新插件配置文件 app.php
$this->updatePluginConfig($pluginKey, $data);
// 添加图片到对应插件位置(仅当提供了新的base64图片时)
if (isset($data['icon']) || isset($data['cover'])) {
$pluginDirName = $this->findPluginDirectory($pluginKey);
if ($pluginDirName) {
$publicPath = $this->pluginDir . '/' . $pluginDirName . '/public';
// 确保public目录存在
if (!is_dir($publicPath)) {
mkdir($publicPath, 0755, true);
}
if (!empty($data['icon'])) {
$iconPath = $publicPath . '/icon.png';
$this->saveBase64Image($data['icon'], $iconPath);
}
if (!empty($data['cover'])) {
$coverPath = $publicPath . '/cover.png';
$this->saveBase64Image($data['cover'], $coverPath);
}
}
}
});
}
/**
* 根据插件key查找实际的目录名
*
* @param string $pluginKey 插件key
*
* @return string|null
*/
private function findPluginDirectory(string $pluginKey): ?string
{
if (!is_dir($this->pluginDir)) {
return null;
}
$dirs = scandir($this->pluginDir);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
// 优先使用 info.php 查找插件key
$infoPath = $this->pluginDir . '/' . $dir . '/config/info.php';
if (file_exists($infoPath)) {
$infoConfig = include $infoPath;
if (is_array($infoConfig) && isset($infoConfig['name']) && $infoConfig['name'] === $pluginKey) {
return $dir;
}
}
// 如果 info.php 不存在或找不到,尝试从 app.php 查找(旧版本兼容)
$configPath = $this->pluginDir . '/' . $dir . '/config/app.php';
if (!file_exists($configPath)) {
continue;
}
$config = include $configPath;
if (!is_array($config)) {
continue;
}
if (($config['name'] ?? '') === $pluginKey) {
return $dir;
}
}
return null;
}
/**
* 更新插件配置文件
*
* @param string $pluginKey 插件标识
* @param array $data 更新数据
*
* @return void
* @throws \core\exception\handler\AdminException
*/
private function updatePluginConfig(string $pluginKey, array $data): void
{
// 查找实际的插件目录名
$pluginDirName = $this->findPluginDirectory($pluginKey);
if (!$pluginDirName) {
throw new AdminException('插件目录不存在: ' . $pluginKey);
}
$configPath = $this->pluginDir . '/' . $pluginDirName . '/config/app.php';
// 检查配置文件是否存在
if (!file_exists($configPath)) {
throw new AdminException('插件配置文件不存在: ' . $configPath);
}
// 加载当前配置
$config = include $configPath;
if (!is_array($config)) {
throw new AdminException('插件配置文件格式错误');
}
// 更新配置项
$fieldMapping = [
'title' => 'title',
'desc' => 'description',
'author' => 'author',
'version' => 'version',
'type' => 'type',
'status' => 'enable',
];
foreach ($fieldMapping as $dbField => $configField) {
if (isset($data[$dbField])) {
if ($dbField === 'status') {
// status字段转换为enable布尔值
$config[$configField] = (bool)$data[$dbField];
} else {
$config[$configField] = $data[$dbField];
}
}
}
// 生成PHP配置文件内容
$phpContent = "<?php\n\nreturn " . $this->arrayToPhpCode($config) . ";\n";
// 写入配置文件
file_put_contents($configPath, $phpContent);
// 同时更新 info.php 配置文件
$this->updatePluginInfoConfig($pluginDirName, $data);
}
/**
* 创建插件 info.php 配置文件
*
* @param string $pluginKey 插件标识
* @param array $data 插件数据
*
* @return void
* @throws \core\exception\handler\AdminException
*/
private function createPluginInfoConfig(string $pluginKey, array $data): void
{
$pluginDir = $this->pluginDir . '/' . $pluginKey;
$configDir = $pluginDir . '/config';
// 确保 config 目录存在
if (!is_dir($configDir)) {
mkdir($configDir, 0755, true);
}
$infoConfig = [
'name' => $pluginKey,
'identifier' => $pluginKey,
'type' => $data['type'] ?? 'madong', // 插件归属类型:madong(官方)、third(第三方)、custom(自定义)
'version' => $data['version'] ?? '1.0.0',
'description' => $data['desc'] ?? '',
'author' => $data['author'] ?? '',
'author_email' => $data['author_email'] ?? '',
'website' => $data['website'] ?? '',
'uninstall' => [
'drop_tables' => false,
'remove_dependencies' => false,
],
];
$phpContent = "<?php\n\n/**\n * 插件信息配置\n */\n\nreturn " . $this->arrayToPhpCode($infoConfig) . ";\n";
file_put_contents($configDir . '/info.php', $phpContent);
}
/**
* 更新插件 info.php 配置文件
*
* @param string $pluginDirName 插件目录名
* @param array $data 更新数据
*
* @return void
* @throws \core\exception\handler\AdminException
*/
private function updatePluginInfoConfig(string $pluginDirName, array $data): void
{
$infoPath = $this->pluginDir . '/' . $pluginDirName . '/config/info.php';
// 如果 info.php 不存在,则创建
if (!file_exists($infoPath)) {
$this->createPluginInfoConfig($pluginDirName, $data);
return;
}
// 加载当前配置
$infoConfig = include $infoPath;
if (!is_array($infoConfig)) {
$infoConfig = [];
}
// 更新配置项
$fieldMapping = [
'type' => 'type',
'version' => 'version',
'desc' => 'description',
'author' => 'author',
'author_email' => 'author_email',
'website' => 'website',
];
foreach ($fieldMapping as $dbField => $infoField) {
if (isset($data[$dbField])) {
$infoConfig[$infoField] = $data[$dbField];
}
}
// 确保必要的字段存在
if (!isset($infoConfig['name'])) {
$infoConfig['name'] = $pluginDirName;
}
if (!isset($infoConfig['identifier'])) {
$infoConfig['identifier'] = $pluginDirName;
}
if (!isset($infoConfig['type'])) {
$infoConfig['type'] = 'madong'; // 默认归属 madong
}
if (!isset($infoConfig['uninstall'])) {
$infoConfig['uninstall'] = [
'drop_tables' => false,
'remove_dependencies' => false,
];
}
// 生成PHP配置文件内容
$phpContent = "<?php\n\n/**\n * 插件信息配置\n */\n\nreturn " . $this->arrayToPhpCode($infoConfig) . ";\n";
// 写入配置文件
file_put_contents($infoPath, $phpContent);
}
/**
* 将数组转换为PHP代码格式
*
* @param array $array 数组
* @param int $level 缩进级别
*
* @return string
*/
private function arrayToPhpCode(array $array, int $level = 1): string
{
$indent = str_repeat(' ', $level);
$nextIndent = str_repeat(' ', $level + 1);
$result = "[\n";
foreach ($array as $key => $value) {
if (is_numeric($key)) {
$result .= $nextIndent;
} else {
$result .= $nextIndent . "'{$key}' => ";
}
if (is_array($value)) {
$result .= $this->arrayToPhpCode($value, $level + 1);
} elseif (is_bool($value)) {
$result .= $value ? 'true' : 'false';
} elseif (is_null($value)) {
$result .= 'null';
} elseif (is_string($value)) {
// 转义单引号
$escaped = str_replace("'", "\\'", $value);
$result .= "'{$escaped}'";
} else {
$result .= $value;
}
$result .= ",\n";
}
$result .= $indent . ']';
return $result;
}
/**
* 保存base64图片到文件
*
* @param string $base64 base64编码的图片
* @param string $targetPath 目标路径
*
* @return void
*/
private function saveBase64Image(string $base64, string $targetPath): void
{
// 提取base64数据
if (preg_match('/^data:image\/(\w+);base64,/', $base64, $matches)) {
$imageData = substr($base64, strpos($base64, ',') + 1);
$imageData = base64_decode($imageData);
if ($imageData !== false) {
file_put_contents($targetPath, $imageData);
}
}
}
/**
* 生成默认图标(64x64)
*
* @param string $text 文字
*
* @return string base64编码的图片
*/
private function generateDefaultIcon(string $text): string
{
// 提取首字母
$firstChar = mb_substr($text, 0, 1, 'UTF-8');
// 创建64x64的图像
$size = 64;
$image = imagecreatetruecolor($size, $size);
// 分配颜色
$bgColor = imagecolorallocate($image, 64, 158, 255); // 蓝色背景 #409EFF
$textColor = imagecolorallocate($image, 255, 255, 255); // 白色文字
// 填充背景
imagefill($image, 0, 0, $bgColor);
// 绘制圆角矩形
$radius = 12;
$corners = [
[0, 0], [$size - 1, 0],
[0, $size - 1], [$size - 1, $size - 1],
];
foreach ($corners as $corner) {
imagefilledarc($image, $corner[0] + $radius, $corner[1] + $radius, $radius * 2, $radius * 2, 0, 360, $bgColor, IMG_ARC_PIE);
}
// 添加文字
$fontSize = 24;
$fontFile = $this->getFontFile();
if ($fontFile && file_exists($fontFile)) {
$bbox = imagettfbbox($fontSize, 0, $fontFile, $firstChar);
$textWidth = $bbox[2] - $bbox[0];
$textHeight = $bbox[7] - $bbox[1];
$x = ($size - $textWidth) / 2 - $bbox[0];
$y = ($size - $textHeight) / 2 - $bbox[1];
imagettftext($image, $fontSize, 0, $x, $y, $textColor, $fontFile, $firstChar);
} else {
// 如果没有字体文件,使用内置字体
imagestring($image, 5, ($size - imagefontwidth(5)) / 2, ($size - imagefontheight(5)) / 2, $firstChar, $textColor);
}
// 转换为base64
ob_start();
imagepng($image);
$imageData = ob_get_clean();
imagedestroy($image);
return 'data:image/png;base64,' . base64_encode($imageData);
}
/**
* 生成默认封面(200x120)
*
* @param string $text 文字
*
* @return string base64编码的图片
*/
private function generateDefaultCover(string $text): string
{
// 创建200x120的图像
$width = 200;
$height = 120;
$image = imagecreatetruecolor($width, $height);
// 分配颜色
$bgColor = imagecolorallocate($image, 240, 242, 245); // 浅灰色背景 #F0F2F5
$lineColor = imagecolorallocate($image, 220, 223, 230); // 线条颜色 #DCDFE6
$textColor = imagecolorallocate($image, 144, 147, 153); // 文字颜色 #909399
// 填充背景
imagefill($image, 0, 0, $bgColor);
// 绘制圆角
$radius = 8;
$corners = [
[0, 0], [$width - 1, 0],
[0, $height - 1], [$width - 1, $height - 1],
];
foreach ($corners as $corner) {
imagefilledarc($image, $corner[0] + $radius, $corner[1] + $radius, $radius * 2, $radius * 2, 0, 360, $bgColor, IMG_ARC_PIE);
}
// 绘制虚线边框
imagesetthickness($image, 1);
imagerectangle($image, 1, 1, $width - 2, $height - 2, $lineColor);
// 绘制中间的+号
$plusSize = 24;
$centerX = $width / 2;
$centerY = $height / 2;
imagesetthickness($image, 2);
imageline($image, $centerX - $plusSize, $centerY, $centerX + $plusSize, $centerY, $textColor);
imageline($image, $centerX, $centerY - $plusSize, $centerX, $centerY + $plusSize, $textColor);
// 转换为base64
ob_start();
imagepng($image);
$imageData = ob_get_clean();
imagedestroy($image);
return 'data:image/png;base64,' . base64_encode($imageData);
}
/**
* 获取字体文件路径
*
* @return string|null
*/
private function getFontFile(): ?string
{
$fontPaths = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', // Linux
'C:/Windows/Fonts/msyh.ttc', // Windows 微软雅黑
'C:/Windows/Fonts/simhei.ttf', // Windows 黑体
'/System/Library/Fonts/PingFang.ttc', // macOS
];
foreach ($fontPaths as $path) {
if (file_exists($path)) {
return $path;
}
}
return null;
}
}