Files
TripLog/src/Storage.php
T

176 lines
5.9 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App;
/**
* 存储门面:把「存哪里」这件事收在一处,业务代码不再关心本地还是 OSS。
*
* 键的约定:**对象键不含存储后端信息**,两种后端共用同一套命名
* 本地: uploads/{key}
* OSS : {settings.oss_prefix}{key}
* 例:key = "attachments/20260922_ab12cd34.jpg"
*
* 页面里一律不直接用这里的键拼 URL —— 取文件走 media/show 代理
* (桶是私有的,直链既取不到也不该暴露)。
*/
final class Storage
{
public const DRIVER_LOCAL = 'local';
public const DRIVER_OSS = 'oss';
/**
* 当前生效的驱动。OSS 配置不全时**静默回退本地**,
* 这样即使管理员填错了配置,上传也不会整个不可用。
*/
public static function driver(): string
{
if (Settings::get('storage_driver') === self::DRIVER_OSS && Settings::ossReady()) {
return self::DRIVER_OSS;
}
return self::DRIVER_LOCAL;
}
/** 是否处于「选了 OSS 但配置不全,已回退本地」的状态(用于在界面上给出提示) */
public static function ossMisconfigured(): bool
{
return Settings::get('storage_driver') === self::DRIVER_OSS && !Settings::ossReady();
}
public static function label(?string $driver = null): string
{
return ($driver ?? self::driver()) === self::DRIVER_OSS ? '阿里云 OSS' : '本站服务器';
}
/** 生成一个不会撞的新对象键 */
public static function keyFor(string $ext, string $dir = 'attachments'): string
{
$ext = ltrim(strtolower($ext), '.');
$ext = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
return $dir . '/' . date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
}
/**
* 写入一份内容。
* @return array{storage:string, key:string, size:int}
*/
public static function put(string $key, string $body, string $mime): array
{
$driver = self::driver();
if ($driver === self::DRIVER_OSS) {
$oss = Settings::ossClient();
if ($oss === null) {
throw new \RuntimeException('OSS 未配置完整');
}
$oss->put($key, $body, $mime);
return ['storage' => self::DRIVER_OSS, 'key' => $key, 'size' => strlen($body)];
}
$path = self::localPath($key);
$dir = dirname($path);
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new \RuntimeException('无法创建上传目录:' . $dir);
}
if (@file_put_contents($path, $body) === false) {
throw new \RuntimeException('写入文件失败:' . $key);
}
return ['storage' => self::DRIVER_LOCAL, 'key' => $key, 'size' => strlen($body)];
}
/** 读取内容;找不到返回 null */
public static function get(string $storage, string $key, string $fileUrl = ''): ?string
{
if ($storage === self::DRIVER_OSS) {
try {
$oss = Settings::ossClient();
return $oss === null ? null : $oss->get($key);
} catch (\Throwable $e) {
return null;
}
}
$path = self::resolveLocal($key, $fileUrl);
if ($path === null || !is_file($path)) {
return null;
}
$data = @file_get_contents($path);
return $data === false ? null : $data;
}
public static function exists(string $storage, string $key, string $fileUrl = ''): bool
{
if ($storage === self::DRIVER_OSS) {
$oss = Settings::ossClient();
if ($oss === null) return false;
try {
return $oss->head($key) !== null;
} catch (\Throwable $e) {
return false;
}
}
$path = self::resolveLocal($key, $fileUrl);
return $path !== null && is_file($path);
}
/** 删除;返回是否确实删掉了(不存在也算成功,保持幂等) */
public static function delete(string $storage, string $key, string $fileUrl = ''): bool
{
if ($storage === self::DRIVER_OSS) {
try {
$oss = Settings::ossClient();
return $oss === null ? false : $oss->delete($key);
} catch (\Throwable $e) {
return false;
}
}
$path = self::resolveLocal($key, $fileUrl);
if ($path === null) {
return true;
}
return !is_file($path) || @unlink($path);
}
/**
* 本地文件的绝对路径。只接受 uploads/ 下的相对键,挡住 `../` 之类的路径穿越。
* $fileUrl 是历史遗留的 `/uploads/attachments/xxx.jpg` 形式,作为兼容兜底。
*/
public static function localPath(string $key): string
{
$key = ltrim(str_replace('\\', '/', $key), '/');
if (strpos($key, '..') !== false) {
throw new \RuntimeException('非法的对象键:' . $key);
}
return self::uploadsDir() . '/' . $key;
}
/**
* 解析本地文件的绝对路径(找不到/键非法返回 null)。
* public 是给「展示层算体积」这类只读场景用的,仍会挡住路径穿越。
*/
public static function resolveLocal(string $key, string $fileUrl = ''): ?string
{
$key = trim($key) !== '' ? $key : '';
if ($key !== '') {
try {
return self::localPath($key);
} catch (\Throwable $e) {
return null;
}
}
// 老数据只有 file_url(/uploads/...)
$url = trim($fileUrl);
if ($url === '' || strpos($url, '/uploads/') !== 0 || strpos($url, '..') !== false) {
return null;
}
return dirname(__DIR__) . $url;
}
public static function uploadsDir(): string
{
return dirname(__DIR__) . '/uploads';
}
}