安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
260 lines
9.1 KiB
PHP
260 lines
9.1 KiB
PHP
<?php
|
|
// +----------------------------------------------------------------------
|
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
|
// +----------------------------------------------------------------------
|
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
|
// +----------------------------------------------------------------------
|
|
// | Author: ywxapp<admin@ywxapp.cn>
|
|
// +----------------------------------------------------------------------
|
|
namespace ywxapp\service;
|
|
|
|
use think\Exception;
|
|
use think\facade\Config;
|
|
use think\facade\Event;
|
|
use think\facade\Log;
|
|
use think\file\UploadedFile;
|
|
use ywxapp\model\CommonAttachment;
|
|
use ywxapp\utils\storage\StorageInterface;
|
|
|
|
/**
|
|
* FileStorageService 类
|
|
*
|
|
* @author ywxapp <admin@ywxapp.cn>
|
|
*/
|
|
class FileStorageService
|
|
{
|
|
// 驱动常量
|
|
const DRIVER_LOCAL = 'local';
|
|
const DRIVER_ALIOSS = 'alioss';
|
|
const DRIVER_QCOS = 'qcos';
|
|
|
|
protected $config;
|
|
protected $storage;
|
|
|
|
/**
|
|
* 构造函数,读取配置并初始化驱动
|
|
*/
|
|
public function __construct()
|
|
{
|
|
// 读取配置文件
|
|
$this->config = Config::get('filesystem', []);
|
|
// 获取当前配置的驱动名称
|
|
$driverName = $this->config['default'] ?? self::DRIVER_LOCAL;
|
|
// 实例化对应的驱动
|
|
$this->initDriver($driverName);
|
|
}
|
|
|
|
/**
|
|
* 初始化存储驱动
|
|
*/
|
|
protected function initDriver(string $driverName): void
|
|
{
|
|
$drivers = $this->config['disks'] ?? [];
|
|
if (! isset($drivers[$driverName])) {
|
|
throw new Exception("存储驱动配置不存在: {$driverName}");
|
|
}
|
|
$config = $drivers[$driverName];
|
|
$class = $config['class'];
|
|
if (! class_exists($class)) {
|
|
throw new Exception("存储驱动类不存在: {$class}");
|
|
}
|
|
$this->storage = new $class($config);
|
|
if (! $this->storage instanceof StorageInterface) {
|
|
throw new Exception("存储驱动必须实现 StorageInterface 接口");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 上传文件(对外调用的方法)
|
|
*
|
|
* 提供两个事件扩展点:
|
|
* - FileUpload 前置分发事件:插件监听并返回结果数组即接管上传(OSS / FTP / 自定义存储)
|
|
* - FileUploaded 后置通知事件:上传成功后触发,供缩略图、水印、审计等扩展
|
|
*
|
|
* @param UploadedFile $file 上传的文件对象
|
|
* @param string $path 存储路径,留空自动生成
|
|
* @param array $options ['module' => 模块标识, 'rules' => 自定义校验规则(空数组=不校验)]
|
|
* @return array
|
|
*/
|
|
public function upload(UploadedFile $file, string $path = '', array $options = []): array
|
|
{
|
|
// 1. 基础验证(文件大小、类型,可用 options.rules 自定义)
|
|
$this->validateFile($file, $options);
|
|
// 2. 未指定路径时生成默认路径
|
|
if (empty($path)) {
|
|
$path = $this->generatePath($file);
|
|
}
|
|
|
|
// 3. 前置事件 FileUpload:插件可接管上传,返回结果数组即接管成功
|
|
$handled = Event::trigger('FileUpload', [
|
|
'file' => $file,
|
|
'path' => $path,
|
|
'options' => $options,
|
|
], true);
|
|
if ($handled) {
|
|
$data = $this->buildMeta($file, $handled, $options);
|
|
return $this->finalize($data, $options);
|
|
}
|
|
|
|
// 4. 调用内置存储驱动上传
|
|
list($name, $path, $url) = $this->storage->upload($file, $path, $options);
|
|
$data = $this->buildMeta($file, [
|
|
'name' => $name,
|
|
'path' => $path,
|
|
'url' => $url,
|
|
'storage' => $this->config['default'] ?? self::DRIVER_LOCAL,
|
|
], $options);
|
|
|
|
// 5. 写入附件记录 + 后置事件 FileUploaded
|
|
return $this->finalize($data, $options);
|
|
}
|
|
|
|
/**
|
|
* 上传收尾:写入附件表记录、触发 FileUploaded 事件、返回带 id 的数据
|
|
*/
|
|
protected function finalize(array $data, array $options): array
|
|
{
|
|
$data = $this->recordAttachment($data, $options);
|
|
Event::trigger('FileUploaded', $data);
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* 写入附件表记录(落库失败不影响上传主流程)
|
|
*/
|
|
protected function recordAttachment(array $data, array $options): array
|
|
{
|
|
try {
|
|
$row = CommonAttachment::create([
|
|
'uid' => (int) ($options['uid'] ?? 0),
|
|
'module' => (string) ($data['module'] ?? 'index'),
|
|
'path' => (string) ($data['path'] ?? ''),
|
|
'url' => (string) ($data['url'] ?? ''),
|
|
'original' => (string) ($data['original'] ?? ''),
|
|
'name' => (string) ($data['name'] ?? ''),
|
|
'size' => (int) ($data['size'] ?? 0),
|
|
'ext' => (string) ($data['ext'] ?? ''),
|
|
'mime' => (string) ($data['mime'] ?? ''),
|
|
'storage' => (string) ($data['storage'] ?? self::DRIVER_LOCAL),
|
|
'driver_info' => $data['driver_info'] ?? [],
|
|
'is_image' => (int) ($data['is_image'] ?? 0),
|
|
'width' => (int) ($data['width'] ?? 0),
|
|
'height' => (int) ($data['height'] ?? 0),
|
|
'upload_ip' => (string) ($data['upload_ip'] ?? ''),
|
|
]);
|
|
$data['id'] = (int) $row->id;
|
|
} catch (\Throwable $e) {
|
|
Log::error('附件落库失败: ' . $e->getMessage());
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* 组装上传元数据
|
|
*
|
|
* @param UploadedFile $file 上传的文件对象
|
|
* @param array $extra 附加数据(name / path / url / storage 等),覆盖默认元数据
|
|
* @param array $options 上传选项
|
|
* @return array
|
|
*/
|
|
protected function buildMeta(UploadedFile $file, array $extra, array $options): array
|
|
{
|
|
$data = [
|
|
'module' => $options['module'] ?? 'index', // 模块标识
|
|
'original' => $file->getOriginalName(),
|
|
'size' => $file->getSize(),
|
|
'ext' => $file->extension(),
|
|
'mime' => $file->getMime(),
|
|
'is_image' => (int) str_starts_with($file->getMime(), 'image/'),
|
|
'upload_ip' => request()->ip(),
|
|
];
|
|
// 图片时尝试获取尺寸
|
|
if ($data['is_image'] && function_exists('getimagesize')) {
|
|
// 注意:如果是云存储,这里可能需要从临时文件获取尺寸
|
|
$tempPath = $file->getRealPath();
|
|
if (file_exists($tempPath)) {
|
|
$imgInfo = getimagesize($tempPath);
|
|
$data['width'] = $imgInfo[0] ?? 0;
|
|
$data['height'] = $imgInfo[1] ?? 0;
|
|
}
|
|
}
|
|
return array_merge($data, $extra);
|
|
}
|
|
|
|
/**
|
|
* 删除文件
|
|
*
|
|
* 前置事件 FileDelete:插件监听并返回 true 即接管物理删除(OSS / FTP 删远端文件);
|
|
* 无监听时走内置存储驱动。删除成功触发 FileDeleted 事件。
|
|
*/
|
|
public function delete(string $path, array $options = []): bool
|
|
{
|
|
$handled = Event::trigger('FileDelete', [
|
|
'path' => $path,
|
|
'options' => $options,
|
|
], true);
|
|
if ($handled) {
|
|
Event::trigger('FileDeleted', ['path' => $path, 'handled' => true]);
|
|
return true;
|
|
}
|
|
$ok = $this->storage->delete($path);
|
|
if ($ok) {
|
|
Event::trigger('FileDeleted', ['path' => $path, 'handled' => false]);
|
|
}
|
|
return $ok;
|
|
}
|
|
|
|
/**
|
|
* 获取URL
|
|
*/
|
|
public function getUrl(string $path): string
|
|
{
|
|
return $this->storage->getUrl($path);
|
|
}
|
|
|
|
/**
|
|
* 基础文件验证,可用 options.rules 自定义规则,空数组表示不校验
|
|
*/
|
|
protected function validateFile(UploadedFile $file, array $options): void
|
|
{
|
|
$rules = $options['rules'] ?? [
|
|
// 限制文件大小(单位b),这里限制为4M
|
|
'fileSize' => 4 * 1024 * 1024,
|
|
// 限制文件后缀,多个后缀以英文逗号分割
|
|
'fileExt' => 'gif,jpg,png',
|
|
];
|
|
if (empty($rules)) {
|
|
return;
|
|
}
|
|
validate(
|
|
['file' => $rules],
|
|
[
|
|
'file.fileSize' => '文件太大',
|
|
'file.fileExt' => '不支持的文件后缀',
|
|
]
|
|
)->check(['file' => $file]);
|
|
}
|
|
|
|
/**
|
|
* 生成唯一文件路径
|
|
*/
|
|
protected function generatePath(UploadedFile $file): string
|
|
{
|
|
$ext = $file->getClientOriginalExtension();
|
|
$date = date('Y/m/d');
|
|
$filename = date('His') . '_' . uniqid() . ".{$ext}";
|
|
return "{$date}/{$filename}";
|
|
}
|
|
|
|
/**
|
|
* 魔术方法,允许直接调用驱动的其他特有方法
|
|
*/
|
|
public function __call($method, $parameters)
|
|
{
|
|
if (method_exists($this->storage, $method)) {
|
|
return call_user_func_array([$this->storage, $method], $parameters);
|
|
}
|
|
throw new \BadMethodCallException("Method {$method} does not exist.");
|
|
}
|
|
}
|