更新若干功能
This commit is contained in:
@@ -69,7 +69,11 @@ class JWTService
|
||||
$payload = [
|
||||
'iat' => $issuedAt,
|
||||
'exp' => $expirationTime,
|
||||
'data' => $data
|
||||
// sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
|
||||
'sub' => (string) ($data['id'] ?? ''),
|
||||
// 与小程序 token(scope=wx)区分,两边共用 JWT_SECRET 但不能互认
|
||||
'scope' => 'admin',
|
||||
'data' => $data,
|
||||
];
|
||||
RedisService::getInstance()->init(config('nl.redis.jwt'))->set($data['id'], json_encode($data));
|
||||
return JWT::encode($payload, $this->secretKey, 'HS256');
|
||||
@@ -83,9 +87,14 @@ class JWTService
|
||||
public function parseToken(): ?object
|
||||
{
|
||||
try {
|
||||
if (empty($this->token)) return UtilsService::getInstance()->notAuth('请先登录');
|
||||
if (empty($this->token)) {
|
||||
return UtilsService::getInstance()->notAuth('请先登录');
|
||||
}
|
||||
return JWT::decode($this->token, new Key($this->secretKey, 'HS256'));
|
||||
} catch (Exception $e) {
|
||||
if ((int) $e->getCode() === 401) {
|
||||
throw $e;
|
||||
}
|
||||
return UtilsService::getInstance()->notAuth('【1】Token解析失败!请重新登录:'. $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -100,14 +109,59 @@ class JWTService
|
||||
{
|
||||
try {
|
||||
$jwt = $this->parseToken();
|
||||
$user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($jwt->data->id);
|
||||
if (empty($user)) return UtilsService::getInstance()->notAuth('登录状态过期');
|
||||
return json_decode($user, true);
|
||||
} catch (Exception $e) {
|
||||
$payload = json_decode(json_encode($jwt), true);
|
||||
if (is_array($payload) && ($payload['scope'] ?? '') === 'wx') {
|
||||
return UtilsService::getInstance()->notAuth('请使用后台账号登录');
|
||||
}
|
||||
$data = $this->extractUserData($jwt);
|
||||
$userId = (int) ($data['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return UtilsService::getInstance()->notAuth('Token 无效,请重新登录');
|
||||
}
|
||||
$user = RedisService::getInstance()->init(config('nl.redis.jwt'))->get($userId);
|
||||
if (empty($user)) {
|
||||
// Redis 会话丢了但签名有效:用 payload 顶住,避免刚登录就被踢
|
||||
return $data;
|
||||
}
|
||||
$decoded = json_decode($user, true);
|
||||
return is_array($decoded) ? $decoded : $data;
|
||||
} catch (\Throwable $e) {
|
||||
if ((int) $e->getCode() === 401) {
|
||||
throw $e;
|
||||
}
|
||||
return UtilsService::getInstance()->notAuth('【2】Token解析失败!请重新登录:'. $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从解码后的 JWT 取出用户数据
|
||||
*
|
||||
* 本系统签发的是 { data: { id, ... } };有的网关/旧 token 会把字段摊到顶层,
|
||||
* 或只留 sub。这里都认,避免再出现 Undefined property::$data。
|
||||
*/
|
||||
public function extractUserData(?object $jwt): array
|
||||
{
|
||||
if (!is_object($jwt)) {
|
||||
return [];
|
||||
}
|
||||
$payload = json_decode(json_encode($jwt), true);
|
||||
if (!is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
$data = $payload['data'] ?? null;
|
||||
if (is_array($data) && $data !== []) {
|
||||
return $data;
|
||||
}
|
||||
if (is_object($data)) {
|
||||
return (array) $data;
|
||||
}
|
||||
$id = $payload['id'] ?? $payload['sub'] ?? 0;
|
||||
if ((int) $id > 0) {
|
||||
return array_merge($payload, ['id' => (int) $id]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 续签 JWT Token
|
||||
* @return string|null 新的 JWT 字符串或者 null 如果原 token 已过期或无效
|
||||
@@ -116,11 +170,12 @@ class JWTService
|
||||
public function refreshToken(): ?string
|
||||
{
|
||||
$decoded = $this->parseToken();
|
||||
if ($decoded === null || !property_exists($decoded, 'data')) {
|
||||
$data = $this->extractUserData($decoded);
|
||||
if (($data['id'] ?? 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->generateToken((array)$decoded->data);
|
||||
return $this->generateToken($data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,7 +75,14 @@ class MediaUrlService
|
||||
public function firstOf(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = $value[0] ?? '';
|
||||
$first = $value[0] ?? '';
|
||||
if (is_array($first)) {
|
||||
$value = $first['url'] ?? $first['uid'] ?? '';
|
||||
} elseif (isset($value['url'])) {
|
||||
$value = $value['url'];
|
||||
} else {
|
||||
$value = $first;
|
||||
}
|
||||
}
|
||||
return $this->toStorage(is_string($value) ? $value : '');
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ class OssRuntimeConfigService extends BaseService
|
||||
'id' => (int) $row->id,
|
||||
'driver' => (string) $row->driver,
|
||||
'name' => (string) $row->name,
|
||||
'access_key' => $enc->decryptFromStorage((string) ($row->access_key ?? ''), true),
|
||||
'secret_key' => $enc->decryptFromStorage((string) ($row->secret_key ?? ''), true),
|
||||
'access_key' => $this->decryptSecret($enc, (string) ($row->access_key ?? ''), 'AccessKey'),
|
||||
'secret_key' => $this->decryptSecret($enc, (string) ($row->secret_key ?? ''), 'SecretKey'),
|
||||
'endpoint' => (string) ($row->endpoint ?? ''),
|
||||
'region' => (string) ($row->region ?? ''),
|
||||
'bucket' => (string) ($row->bucket ?? ''),
|
||||
@@ -121,4 +121,21 @@ class OssRuntimeConfigService extends BaseService
|
||||
'extra_json' => $extra,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密库内密钥;密文在但解不开时必须说清楚,不能静默变空串
|
||||
*
|
||||
* 以前 silentFail=true,解密失败后驱动只看到空 AccessKey,
|
||||
* 素材拉取就会报「七牛云配置不完整」,运营以为没填,其实是 ENCRYPT_KEY 对不上。
|
||||
*/
|
||||
private function decryptSecret(FieldEncryptService $enc, string $raw, string $label): string
|
||||
{
|
||||
$plain = $enc->decryptFromStorage($raw, true);
|
||||
if ($raw !== '' && $plain === '' && $enc->isEncrypted($raw)) {
|
||||
$this->utils->errorThrow(
|
||||
'存储配置的' . $label . '解密失败,请到「系统配置 → 存储」重新填写密钥后保存'
|
||||
);
|
||||
}
|
||||
return $plain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ interface OssStorageInterface
|
||||
* 素材库靠 marker 一页一页往回补:bucket 上万对象时一次拉全量必然打穿
|
||||
* PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。
|
||||
*
|
||||
* @param string $prefix 只列举该前缀,留空时退回配置里的 path_prefix
|
||||
* @param string $prefix 只列举该前缀,留空表示整个 Bucket(不套 path_prefix)
|
||||
* @param string $marker 上一页返回的 next_marker,首页传空串
|
||||
* @param int $limit 单页条数
|
||||
* @return array{items: array<int, array{key:string,size:int,hash:string,last_modified:int,url:string}>, next_marker: string, finished: bool}
|
||||
@@ -48,7 +48,7 @@ interface OssStorageInterface
|
||||
/**
|
||||
* 删除对象
|
||||
*
|
||||
* @param string $key 对象键(缺 path_prefix 时由实现补齐)
|
||||
* @param string $key 对象键(素材库存的是列举/上传后的真实路径,不再补 path_prefix)
|
||||
*/
|
||||
public function deleteObject(string $key): bool;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
|
||||
|
||||
$items = [];
|
||||
foreach ($page as $file) {
|
||||
$key = $this->normalizeObjectKey($file);
|
||||
$key = $this->normalizeListedKey($file);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,26 @@ namespace App\Service\common\upload;
|
||||
/**
|
||||
* 对象键规整
|
||||
*
|
||||
* 五个驱动在列举 / 删除时对 key 的处理完全一致(补 path_prefix、压斜杠、拼 domain),
|
||||
* 抽出来免得同一段逻辑在五个文件里各写一遍、各改一半。
|
||||
* 上传用 normalizeObjectKey(可补 path_prefix),
|
||||
* 列举 / 删除用 normalizeListedKey(只压斜杠,用服务端真实 key)。
|
||||
* 使用方需要有 $this->config(由 OssRuntimeConfigService 注入,含 path_prefix / domain)。
|
||||
*/
|
||||
trait ObjectKeyNormalizeTrait
|
||||
{
|
||||
/**
|
||||
* 补上 path_prefix 并压平重复斜杠
|
||||
* 压平重复斜杠,不补 path_prefix
|
||||
*
|
||||
* 列举结果必须走这里:OSS 回的 key 就是对象真实路径。
|
||||
* 若再套一层 path_prefix,根目录的历史文件(如 b_xxx.jpg)会被改写成
|
||||
* uploads/b_xxx.jpg,素材库入库地址全 404。
|
||||
*/
|
||||
protected function normalizeListedKey(string $key): string
|
||||
{
|
||||
return ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 / 删除用:缺 path_prefix 时补上,并压平重复斜杠
|
||||
*
|
||||
* 为什么必须压平:uploads//spa/a.jpg 与 uploads/spa/a.jpg 在 OSS 上是两个对象,
|
||||
* 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引,
|
||||
@@ -20,7 +32,7 @@ trait ObjectKeyNormalizeTrait
|
||||
*/
|
||||
protected function normalizeObjectKey(string $key): string
|
||||
{
|
||||
$key = ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/');
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
@@ -32,19 +44,16 @@ trait ObjectKeyNormalizeTrait
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举前缀:调用方没给就退回 path_prefix
|
||||
* 列举前缀:只认调用方传入的值,留空 = 扫整个 Bucket
|
||||
*
|
||||
* 同一个 bucket 常常被多个项目共用,不加这层兜底会把别人的对象也拉进素材库,
|
||||
* 之后走回收流程就等于跨项目删文件。
|
||||
* 以前空前缀会偷偷套配置里的 path_prefix。种子数据把七牛写成 uploads,
|
||||
* 而本站历史文件都在根目录(b_*.jpg),一拉就是空列表。
|
||||
* 素材库文案也是「留空表示整个 Bucket」,这里必须跟文案一致;
|
||||
* 要收窄范围请在同步抽屉里显式填前缀。
|
||||
*/
|
||||
protected function scopedListPrefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
if ($prefix !== '') {
|
||||
return $this->normalizeObjectKey($prefix);
|
||||
}
|
||||
$base = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
return $base === '' ? '' : $base . '/';
|
||||
return $this->normalizeListedKey($prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ trait ObjectListXmlTrait
|
||||
$rawKey = (string) $node->Key;
|
||||
// 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值
|
||||
$lastRawKey = $rawKey;
|
||||
$key = $this->normalizeObjectKey($rawKey);
|
||||
$key = $this->normalizeListedKey($rawKey);
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
// 以 / 结尾的是控制台建目录留下的占位对象,不是素材
|
||||
continue;
|
||||
|
||||
@@ -67,7 +67,7 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
'path_prefix' => '',
|
||||
];
|
||||
|
||||
/** 同一次请求里复用,避免每页列举都去 UC 查一遍区域 */
|
||||
private ?\Qiniu\Storage\BucketManager $cachedBucketMgr = null;
|
||||
|
||||
private string $cachedBucketMgrKey = '';
|
||||
|
||||
/**
|
||||
* 注入解密后的运行时配置
|
||||
*/
|
||||
public function withConfig(array $config): static
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
$this->cachedBucketMgr = null;
|
||||
$this->cachedBucketMgrKey = '';
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -54,14 +61,17 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
* 列举空间对象(BucketManager::listFiles,marker 分页)
|
||||
*
|
||||
* 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。
|
||||
* 列举结果的 key 必须用服务端原值,不能再套 path_prefix,
|
||||
* 否则根目录历史文件会被改写成 uploads/b_xxx.jpg。
|
||||
*/
|
||||
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
|
||||
{
|
||||
$bucketMgr = $this->bucketManager();
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$listPrefix = $this->scopedListPrefix($prefix);
|
||||
[$ret, $err] = $bucketMgr->listFiles(
|
||||
$bucket,
|
||||
$this->scopedListPrefix($prefix),
|
||||
$listPrefix !== '' ? $listPrefix : null,
|
||||
$marker !== '' ? $marker : null,
|
||||
$this->boundedLimit($limit)
|
||||
);
|
||||
@@ -69,9 +79,19 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err));
|
||||
}
|
||||
|
||||
$ret = is_array($ret) ? $ret : [];
|
||||
$rawItems = $ret['items'] ?? $ret['Items'] ?? [];
|
||||
// 个别 SDK 版本成功时直接回文件数组,而不是 {items: [...]}
|
||||
if ($rawItems === [] && isset($ret[0]) && is_array($ret[0])) {
|
||||
$rawItems = $ret;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ((array) ($ret['items'] ?? []) as $row) {
|
||||
$key = $this->normalizeObjectKey((string) ($row['key'] ?? ''));
|
||||
foreach ((array) $rawItems as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->normalizeListedKey((string) ($row['key'] ?? ''));
|
||||
if ($key === '' || str_ends_with($key, '/')) {
|
||||
continue;
|
||||
}
|
||||
@@ -81,7 +101,7 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
'hash' => (string) ($row['hash'] ?? ''),
|
||||
// putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳
|
||||
'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000),
|
||||
'url' => $this->publicUrlOf($key, $key),
|
||||
'url' => $this->publicUrlOf($key),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,7 +115,7 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
return $this->deleteFile($this->normalizeObjectKey($key));
|
||||
return $this->deleteFile($this->normalizeListedKey($key));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,30 +155,50 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
|
||||
private function deleteFile(string $key): bool
|
||||
{
|
||||
if (!class_exists(\Qiniu\Auth::class)) {
|
||||
return false;
|
||||
}
|
||||
$auth = new \Qiniu\Auth($this->config['access_key'], $this->config['secret_key']);
|
||||
$bucketMgr = new \Qiniu\Storage\BucketManager($auth);
|
||||
$err = $bucketMgr->delete($this->config['bucket'], $key);
|
||||
$err = $this->bucketManager()->delete((string) $this->config['bucket'], $key);
|
||||
return $err === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉
|
||||
*
|
||||
* 必须走 HTTPS:SDK 默认 HTTP,部分机房出网只放行 443,列举会直接空失败。
|
||||
*/
|
||||
private function bucketManager(): \Qiniu\Storage\BucketManager
|
||||
{
|
||||
if (!class_exists(\Qiniu\Auth::class)) {
|
||||
UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖');
|
||||
}
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '') {
|
||||
UtilsService::getInstance()->errorThrow('七牛云配置不完整');
|
||||
$accessKey = trim((string) ($this->config['access_key'] ?? ''));
|
||||
$secretKey = trim((string) ($this->config['secret_key'] ?? ''));
|
||||
$bucket = trim((string) ($this->config['bucket'] ?? ''));
|
||||
$missing = [];
|
||||
if ($accessKey === '') {
|
||||
$missing[] = 'AccessKey';
|
||||
}
|
||||
return new \Qiniu\Storage\BucketManager(new \Qiniu\Auth($accessKey, $secretKey));
|
||||
if ($secretKey === '') {
|
||||
$missing[] = 'SecretKey';
|
||||
}
|
||||
if ($bucket === '') {
|
||||
$missing[] = 'Bucket';
|
||||
}
|
||||
if ($missing !== []) {
|
||||
UtilsService::getInstance()->errorThrow(
|
||||
'七牛云配置不完整,缺少:' . implode('、', $missing) . '。请到「系统配置 → 存储」重新填写后保存'
|
||||
);
|
||||
}
|
||||
$cacheKey = $accessKey . "\0" . $bucket;
|
||||
if ($this->cachedBucketMgr !== null && $this->cachedBucketMgrKey === $cacheKey) {
|
||||
return $this->cachedBucketMgr;
|
||||
}
|
||||
$sdkConfig = new \Qiniu\Config();
|
||||
$sdkConfig->useHTTPS = true;
|
||||
$this->cachedBucketMgr = new \Qiniu\Storage\BucketManager(
|
||||
new \Qiniu\Auth($accessKey, $secretKey),
|
||||
$sdkConfig
|
||||
);
|
||||
$this->cachedBucketMgrKey = $cacheKey;
|
||||
return $this->cachedBucketMgr;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,7 +207,13 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf
|
||||
private function errorText(mixed $err): string
|
||||
{
|
||||
if (is_object($err) && method_exists($err, 'message')) {
|
||||
return (string) $err->message();
|
||||
$text = trim((string) $err->message());
|
||||
if ($text !== '') {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
if (is_object($err) && method_exists($err, 'code')) {
|
||||
return 'HTTP ' . $err->code();
|
||||
}
|
||||
if (is_object($err) || is_array($err)) {
|
||||
return (string) json_encode($err, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
@@ -64,7 +64,7 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
public function deleteObject(string $key): bool
|
||||
{
|
||||
$this->assertConfig();
|
||||
$key = $this->normalizeObjectKey($key);
|
||||
$key = $this->normalizeListedKey($key);
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user