更新若干功能

This commit is contained in:
2026-08-19 08:16:49 +08:00
parent 4979bb83d2
commit 72bc6502eb
56 changed files with 3627 additions and 343 deletions

View File

@@ -69,7 +69,11 @@ class JWTService
$payload = [
'iat' => $issuedAt,
'exp' => $expirationTime,
'data' => $data
// sub 是标准声明,网关/旧客户端剥掉自定义 data 时还能靠它找回用户
'sub' => (string) ($data['id'] ?? ''),
// 与小程序 tokenscope=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);
}
/**

View File

@@ -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 : '');
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
/**

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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::listFilesmarker 分页)
*
* 七牛只在还有下一页时才回 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 / 没填配置」两种情况前置拦掉
*
* 必须走 HTTPSSDK 默认 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);

View File

@@ -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;
}