Files
miniapi/app/service/OssService.php
gaofeng 81ade70944 提交
2026-06-17 09:23:18 +08:00

302 lines
9.5 KiB
PHP

<?php
namespace app\service;
use AlibabaCloud\Oss\V2 as Oss;
use Exception;
final class OssService
{
private Oss\Client $client;
private string $bucket;
private string $region;
private ?string $endpoint;
/**
* @param array{
* region: string,
* bucket: string,
* endpoint?: string,
* access_key_id?: string,
* access_key_secret?: string,
* session_token?: string
* } $config
*/
public function __construct(array $config)
{
$this->region = $config['region'] ?? '';
$this->bucket = $config['bucket'] ?? '';
$this->endpoint = $config['endpoint'] ?? null;
if ($this->region === '' || $this->bucket === '') {
throw new Exception('OSS config missing: region/bucket');
}
if (!empty($config['access_key_id']) && !empty($config['access_key_secret'])) {
$credentialsProvider = new FixedCredentialsProvider(
(string)$config['access_key_id'],
(string)$config['access_key_secret'],
$config['session_token'] ?? null
);
} else {
$credentialsProvider = new Oss\Credentials\EnvironmentVariableCredentialsProvider();
}
$cfg = Oss\Config::loadDefault();
$cfg->setCredentialsProvider($credentialsProvider);
$cfg->setRegion($this->region);
if ($this->endpoint) {
$cfg->setEndpoint($this->endpoint);
if ($this->shouldUseCname($this->endpoint)) {
$cfg->setUseCname(true);
}
}
$this->client = new Oss\Client($cfg);
$this->assertCredentialsVisible($config);
}
public function upload(string $source, string $key, ?string $bucket = null, array $options = []): array
{
if ($this->isBase64DataUri($source)) {
return $this->uploadFromBase64($source, $key, $bucket, $options);
}
if ($this->isUrl($source)) {
return $this->uploadFromUrl($source, $key, $bucket, $options);
}
return $this->uploadFromFile($source, $key, $bucket, $options);
}
public function uploadFromBase64(string $base64, string $key, ?string $bucket = null, array $options = []): array
{
$contentType = null;
$payload = $base64;
if (preg_match('/^data:([a-zA-Z0-9.+\\/-]+);base64,(.*)$/s', $base64, $m)) {
$contentType = strtolower((string)$m[1]);
$payload = (string)$m[2];
}
$payload = preg_replace('/\\s+/', '', $payload ?? '');
if ($payload === '') {
throw new Exception('Base64 payload is empty');
}
$binary = base64_decode($payload, true);
if ($binary === false) {
throw new Exception('Invalid base64 payload');
}
if ($contentType && empty($options['contentType'])) {
$options['contentType'] = $contentType;
}
return $this->uploadFromBinary($binary, $key, $bucket, $options);
}
public function uploadFromBinary(string $binary, string $key, ?string $bucket = null, array $options = []): array
{
if ($binary === '') {
throw new Exception('Binary payload is empty');
}
$stream = fopen('php://temp', 'wb+');
if ($stream === false) {
throw new Exception('Unable to create temporary stream for binary upload');
}
fwrite($stream, $binary);
rewind($stream);
return $this->putObjectFromResource($stream, $key, $bucket, $options);
}
public function uploadFromFile(string $filePath, string $key, ?string $bucket = null, array $options = []): array
{
if (!is_file($filePath)) {
throw new Exception("Local file not found: {$filePath}");
}
$fp = fopen($filePath, 'rb');
if ($fp === false) {
throw new Exception("Unable to open local file: {$filePath}");
}
return $this->putObjectFromResource($fp, $key, $bucket, $options);
}
public function uploadFromUrl(string $url, string $key, ?string $bucket = null, array $options = []): array
{
$fp = @fopen($url, 'rb');
if ($fp !== false) {
return $this->putObjectFromResource($fp, $key, $bucket, $options);
}
$tmp = tmpfile();
if ($tmp === false) {
throw new Exception('Unable to create tmpfile() for remote download');
}
$this->downloadToStreamByCurl($url, $tmp);
rewind($tmp);
return $this->putObjectFromResource($tmp, $key, $bucket, $options);
}
public function buildObjectUrl(string $key, ?string $bucket = null, bool $useHttps = true): string
{
$bucket = $bucket ?: $this->bucket;
$key = ltrim($key, '/');
$endpoint = $this->endpoint ?: sprintf('oss-%s.aliyuncs.com', $this->region);
$host = $this->extractEndpointHost($endpoint);
$scheme = $useHttps ? 'https' : 'http';
$encodedKey = implode('/', array_map('rawurlencode', explode('/', $key)));
if ($this->shouldUseCname($endpoint)) {
return sprintf('%s://%s/%s', $scheme, $host, $encodedKey);
}
return sprintf('%s://%s.%s/%s', $scheme, $bucket, $host, $encodedKey);
}
private function putObjectFromResource($resource, string $key, ?string $bucket = null, array $options = []): array
{
$bucket = $bucket ?: $this->bucket;
$key = ltrim($key, '/');
$metadata = $options['metadata'] ?? [];
$contentType = $options['contentType'] ?? null;
$acl = $options['acl'] ?? null;
$body = Oss\Utils::streamFor($resource);
$request = new Oss\Models\PutObjectRequest(
bucket: $bucket,
key: $key,
metadata: $metadata ?: null,
contentType: $contentType,
objectAcl: $acl
);
$request->body = $body;
$result = $this->client->putObject($request);
return [
'statusCode' => $result->statusCode,
'requestId' => $result->requestId,
'etag' => $result->etag,
'bucket' => $bucket,
'key' => $key,
];
}
private function downloadToStreamByCurl(string $url, $destStream): void
{
if (!function_exists('curl_init')) {
throw new Exception('cURL extension is required when allow_url_fopen is disabled.');
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $destStream,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
CURLOPT_FAILONERROR => true,
CURLOPT_USERAGENT => 'OssService/1.0',
]);
$ok = curl_exec($ch);
if ($ok !== true) {
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
throw new Exception("Failed to download remote file. HTTP={$code}, error={$err}");
}
curl_close($ch);
}
private function isUrl(string $value): bool
{
return (bool)filter_var($value, FILTER_VALIDATE_URL);
}
private function isBase64DataUri(string $value): bool
{
return (bool)preg_match('/^data:[a-zA-Z0-9.+\\/-]+;base64,/i', $value);
}
private function assertCredentialsVisible(array $config): void
{
if (!empty($config['access_key_id']) && !empty($config['access_key_secret'])) {
return;
}
$ak = getenv('OSS_ACCESS_KEY_ID');
$sk = getenv('OSS_ACCESS_KEY_SECRET');
if (empty($ak) || empty($sk)) {
throw new Exception(
"OSS credentials missing in PHP process env. " .
"getenv('OSS_ACCESS_KEY_ID')/getenv('OSS_ACCESS_KEY_SECRET') is empty. " .
"Fix: make sure the PHP runtime process can see env vars, or pass access_key_id/access_key_secret into OssService config."
);
}
}
private function shouldUseCname(string $endpoint): bool
{
$host = strtolower($this->extractEndpointHost($endpoint));
if ($host === '' || str_contains($host, 'aliyuncs.com') || str_contains($host, '.oss-')) {
return false;
}
return true;
}
private function extractEndpointHost(string $endpoint): string
{
$normalized = preg_match('#^https?://#i', $endpoint) ? $endpoint : 'https://' . ltrim($endpoint, '/');
return (string)(parse_url($normalized, PHP_URL_HOST) ?: '');
}
public function presignGetObjectUrl(string $key, ?string $bucket = null, int $expireSeconds = 900): array
{
$bucket = $bucket ?: $this->bucket;
$key = ltrim($key, '/');
$request = new Oss\Models\GetObjectRequest(bucket: $bucket, key: $key);
$result = $this->client->presign($request, [
'expires' => new \DateInterval("PT{$expireSeconds}S"),
]);
return [
'url' => $result->url,
'expire' => $expireSeconds,
'bucket' => $bucket,
'key' => $key,
];
}
}
final class FixedCredentialsProvider implements Oss\Credentials\CredentialsProvider
{
private string $ak;
private string $sk;
private ?string $token;
public function __construct(string $ak, string $sk, ?string $token = null)
{
$this->ak = $ak;
$this->sk = $sk;
$this->token = $token;
}
public function getCredentials(): Oss\Credentials\Credentials
{
return new Oss\Credentials\Credentials($this->ak, $this->sk, $this->token);
}
}