AI代码生成工具

This commit is contained in:
李琦
2026-08-10 18:32:06 +08:00
parent ab417bd0f2
commit cab69b4193
26 changed files with 1891 additions and 263 deletions

112
app/Enum/OssDriverEnum.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace App\Enum;
/**
* OSS 存储驱动枚举:管理端卡片展示与上传分发共用
*/
enum OssDriverEnum: string
{
case Local = 'local';
case Aliyun = 'aliyun';
case Qcloud = 'qcloud';
case Qiniu = 'qiniu';
case Huawei = 'huawei';
case Aws = 'aws';
case Minio = 'minio';
case Baidu = 'baidu';
/**
* 驱动编码(与库表 driver 一致)
*/
public function code(): string
{
return $this->value;
}
/**
* 中文展示名
*/
public function label(): string
{
return match ($this) {
self::Local => '本地存储',
self::Aliyun => '阿里云 OSS',
self::Qcloud => '腾讯云 COS',
self::Qiniu => '七牛云',
self::Huawei => '华为云 OBS',
self::Aws => 'AWS S3',
self::Minio => 'MinIO',
self::Baidu => '百度云 BOS',
};
}
/**
* 卡片说明文案
*/
public function description(): string
{
return match ($this) {
self::Local => '文件保存到应用服务器本地磁盘',
self::Aliyun => '阿里云对象存储 OSS',
self::Qcloud => '腾讯云对象存储 COS',
self::Qiniu => '七牛云对象存储 Kodo',
self::Huawei => '华为云对象存储 OBS',
self::Aws => '亚马逊 S3 对象存储',
self::Minio => 'S3 兼容的私有化对象存储',
self::Baidu => '百度智能云对象存储 BOS',
};
}
/**
* 前端卡片图标iconify
*/
public function icon(): string
{
return match ($this) {
self::Local => 'lucide:hard-drive',
self::Aliyun => 'simple-icons:alibabadotcom',
self::Qcloud => 'simple-icons:tencentqq',
self::Qiniu => 'lucide:cloud',
self::Huawei => 'simple-icons:huawei',
self::Aws => 'simple-icons:amazonaws',
self::Minio => 'simple-icons:minio',
self::Baidu => 'simple-icons:baidu',
};
}
/**
* 是否需要 AccessKey / SecretKey
*/
public function needsSecret(): bool
{
return $this !== self::Local;
}
/**
* 前端驱动选项列表(含图标,供卡片展示)
*/
public static function options(): array
{
$list = [];
foreach (self::cases() as $case) {
$list[] = [
'code' => $case->code(),
'name' => $case->label(),
'label' => $case->label(),
'description' => $case->description(),
'icon' => $case->icon(),
'needs_secret' => $case->needsSecret() ? 1 : 0,
];
}
return $list;
}
/**
* 校验驱动编码是否合法
*/
public static function isValid(string $code): bool
{
return self::tryFrom($code) !== null;
}
}

View File

@@ -73,6 +73,33 @@ class AdminController extends BaseController
);
}
/**
* 个人中心更新资料
* @Method POST
*/
public function updateProfile(): JsonResponse
{
return jok($this->service->updateProfile(request()->post()), '保存成功');
}
/**
* 个人中心:登录日志
* @Method GET
*/
public function loginLog(): JsonResponse
{
return jok($this->service->loginLogList(), '获取成功');
}
/**
* 个人中心:操作日志
* @Method GET
*/
public function opLog(): JsonResponse
{
return jok($this->service->opLogList(), '获取成功');
}
/**
* 获取菜单列表
* @Method GET

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\ApiEndpointService;
use Illuminate\Http\JsonResponse;
/**
* 接口注册表控制器:仅开放 list / update
*/
class ApiEndpointController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = ApiEndpointService::getInstance();
}
/**
* 分页列表
* @Method GET
*/
public function list(): JsonResponse
{
return jok($this->service->list(), '获取成功');
}
/**
* 更新接口说明 / 是否记日志 / 状态
* @Method POST
*/
public function update(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->update($id, $params), '更新成功');
}
/**
* @Method NO
*/
public function option(): mixed
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function detail(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function create(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function delete(): JsonResponse
{
return jerr('不支持');
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\OssConfigService;
use Illuminate\Http\JsonResponse;
/**
* OSS 存储配置控制器:驱动选项 / 启用切换 / 配置 CRUD
*/
class OssConfigController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = OssConfigService::getInstance();
}
/**
* 配置列表(卡片,无密钥明文)
* @Method GET
*/
public function list(): JsonResponse
{
return jok($this->service->listConfigs(), '获取成功');
}
/**
* @Method NO
*/
public function option(): mixed
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function detail(): JsonResponse
{
return jerr('不支持');
}
/**
* 新增配置(覆盖基类,走加密逻辑)
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->createConfig(request()->post()), '创建成功');
}
/**
* 更新配置
* @Method POST
*/
public function update(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->updateConfig($id, $params), '更新成功');
}
/**
* 删除配置
* @Method POST
*/
public function delete(): JsonResponse
{
$ids = request()->post('ids');
if (empty($ids)) {
return jerr('参数错误');
}
if (!is_array($ids)) {
$ids = [$ids];
}
return jok($this->service->deleteConfigs($ids), '删除成功');
}
/**
* 驱动枚举选项
* @Method GET
*/
public function driverOptions(): JsonResponse
{
return jok($this->service->driverOptions(), '获取成功');
}
/**
* 运行时选项(卡片 + 当前启用)
* @Method GET
*/
public function runtimeOptions(): JsonResponse
{
return jok($this->service->getRuntimeOptions(), '获取成功');
}
/**
* 启用指定配置
* @Method POST
*/
public function saveRuntime(): JsonResponse
{
return jok($this->service->saveRuntime(request()->post()), '保存成功');
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Middleware;
use App\Models\ApiEndpointModel;
use App\Models\ApiOpLogModel;
use App\Service\common\UserAgentService;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API 操作日志中间件:响应结束后按接口注册表决定是否落库
*/
class ApiOpLogMiddleware
{
/**
* 透传请求;真正写日志在 terminate避免拖慢接口响应
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
/**
* 响应结束后写操作日志
* 匹配:去掉 /api/ 前缀的 url method=0(ANY) 或等于请求方法is_log=1、status=1
*/
public function terminate(Request $request, Response $response): void
{
try {
$path = $this->normalizePath($request->path());
if ($path === '') {
return;
}
$noInsert = config('nl.log.no_insert', []);
if (is_array($noInsert)) {
$full = 'api/' . $path;
if (in_array($path, $noInsert, true)
|| in_array($full, $noInsert, true)
|| in_array('/' . $full, $noInsert, true)
) {
return;
}
}
$methodCode = $this->mapMethod($request->method());
$endpoint = ApiEndpointModel::where('url', $path)
->where('deleted_at', 0)
->where('status', 1)
->where('is_log', 1)
->where(function ($q) use ($methodCode) {
$q->where('method', 0)->orWhere('method', $methodCode);
})
->orderByDesc('method')
->first();
if (!$endpoint) {
return;
}
$userId = $this->resolveUserId($request);
$body = $response->getContent();
$result = json_decode((string) $body, true);
if (!is_array($result)) {
$result = ['raw' => mb_substr((string) $body, 0, 2000)];
}
$code = $result['code'] ?? null;
$type = ((int) $code === 0) ? 0 : 1;
$ua = UserAgentService::getInstance();
ApiOpLogModel::insert([
'user_id' => $userId,
'url' => $path,
'method' => $methodCode,
'controller' => (string) ($endpoint->controller ?? ''),
'ip' => (string) get_ip(),
'param' => json_encode($this->collectParams($request), JSON_UNESCAPED_UNICODE),
'result' => json_encode($result, JSON_UNESCAPED_UNICODE),
'type' => $type,
'result_code' => (string) ($code ?? ''),
'platform_type' => 0,
'belong_id' => 0,
'user_type' => 0,
'equipment' => $ua->parseEquipment(),
'browser' => $ua->parseBrowser(),
'created_at' => time(),
]);
} catch (\Throwable $e) {
// 日志失败不影响主流程
}
}
/**
* 规范化路径:去掉 api/ 前缀
*/
private function normalizePath(string $path): string
{
$path = trim($path, '/');
if (str_starts_with($path, 'api/')) {
$path = substr($path, 4);
}
return trim($path, '/');
}
/**
* HTTP 方法映射GET=1 POST=2 其他=0
*/
private function mapMethod(string $method): int
{
return match (strtoupper($method)) {
'GET' => 1,
'POST' => 2,
default => 0,
};
}
/**
* Bearer JWT 解析 user_id失败返回 0(不抛鉴权异常)
*/
private function resolveUserId(Request $request): int
{
try {
$token = $request->bearerToken();
if (empty($token)) {
return 0;
}
$secret = (string) config('nl.jwt.secret', '');
if (strlen($secret) < 32) {
$secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret');
}
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
return (int) ($decoded->data->id ?? 0);
} catch (\Throwable $e) {
return 0;
}
}
/**
* 收集请求参数并脱敏密码类字段
*
* @return array<string,mixed>
*/
private function collectParams(Request $request): array
{
$all = array_merge($request->query(), $request->request->all());
$maskKeys = [
'password', 'old_password', 'new_password', 'confirm_password',
'access_key', 'secret_key', 'api_key', 'token',
];
foreach ($maskKeys as $k) {
if (array_key_exists($k, $all) && $all[$k] !== '' && $all[$k] !== null) {
$all[$k] = '******';
}
}
return $all;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 接口注册表模型:维护 URL / 操作名 / 是否记入操作日志
*/
class ApiEndpointModel extends BaseModel
{
protected $table = 'api_endpoint';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 登录日志模型(仅 created_at无软删
*/
class LoginLogModel extends BaseModel
{
protected $table = 'login_log';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\oss;
use App\BaseApp\BaseModel;
/**
* OSS 存储配置表模型(仅表名与字段约定,查询逻辑放 Service
*/
class OssConfigModel extends BaseModel
{
protected $table = 'oss_config';
protected $guarded = [];
}

View File

@@ -148,7 +148,7 @@ class AdminService extends BaseService
}
$result = $this->model::where('id', $this->userId)->update([
'password' => password_hash($newPassword, PASSWORD_DEFAULT),
'last_reset_password_at' => get_time()
'updated_at' => get_time(),
]);
if (!$result) {
return $this->utils->errorThrow('重置密码失败');
@@ -178,7 +178,7 @@ class AdminService extends BaseService
}
$result = $this->model::where('id', $id)->update([
'password' => password_hash($newPassword, PASSWORD_DEFAULT),
'last_reset_password_at' => get_time()
'updated_at' => get_time(),
]);
if (!$result) {
return $this->utils->errorThrow('重置密码失败');
@@ -187,7 +187,7 @@ class AdminService extends BaseService
}
/**
* 获取用户信息
* 获取用户信息(本人完整字段,供个人中心回填,不做手机号脱敏)
* @return mixed
* @throws Exception
*/
@@ -196,12 +196,121 @@ class AdminService extends BaseService
$this->with = [
'role'
];
$result = $this->getDetail($this->userId);
$result = $this->getDetail($this->userId);
$result['created_day_at'] = format_time(strtotime($result['created_at']), 'Y-m-d');
$result['phone'] = desensitization($result['phone']);
// 兼容前端 Profile 组件字段
$result['realName'] = $result['nick_name'] ?? '';
$result['username'] = $result['phone'] ?? '';
return $result;
}
/**
* 个人中心更新资料(昵称/头像/邮箱/备注/手机)
* 为什么单独接口:不走管理员 CRUD避免越权改角色等字段
*/
public function updateProfile(array $params): mixed
{
$allow = ['nick_name', 'avatar', 'email', 'desc', 'phone'];
$data = [];
foreach ($allow as $field) {
if (array_key_exists($field, $params)) {
$data[$field] = is_string($params[$field]) ? trim($params[$field]) : $params[$field];
}
}
if (isset($data['nick_name']) && $data['nick_name'] === '') {
$this->utils->errorThrow('昵称不能为空');
}
if (isset($data['phone']) && $data['phone'] !== '') {
$exists = $this->model::where('phone', $data['phone'])
->where('id', '<>', $this->userId)
->where('deleted_at', 0)
->exists();
if ($exists) {
$this->utils->errorThrow('手机号已被占用');
}
}
if (isset($data['email']) && $data['email'] !== '') {
$exists = $this->model::where('email', $data['email'])
->where('id', '<>', $this->userId)
->where('deleted_at', 0)
->exists();
if ($exists) {
$this->utils->errorThrow('邮箱已被占用');
}
}
if (empty($data)) {
return $this->myInfo();
}
$data['updated_at'] = get_time();
$this->model::where('id', $this->userId)->update($data);
return $this->myInfo();
}
/**
* 当前用户登录日志分页
*/
public function loginLogList(): array
{
$page = max(1, (int) request()->get('page', 1));
$size = max(1, min(100, (int) request()->get('pageSize', request()->get('size', 10))));
$query = \App\Models\LoginLogModel::where('user_id', $this->userId)->orderByDesc('id');
$total = (clone $query)->count();
$items = $query->forPage($page, $size)->get()->toArray();
return [
'page' => $page,
'size' => $size,
'page_count' => (int) ceil($total / max($size, 1)),
'total' => $total,
'items' => $items,
];
}
/**
* 当前用户操作日志分页(附带接口操作名)
*/
public function opLogList(): array
{
$page = max(1, (int) request()->get('page', 1));
$size = max(1, min(100, (int) request()->get('pageSize', request()->get('size', 10))));
$query = \App\Models\ApiOpLogModel::where('user_id', $this->userId)->orderByDesc('id');
$total = (clone $query)->count();
$items = $query->forPage($page, $size)->get()->toArray();
$urls = array_values(array_unique(array_column($items, 'url')));
$endpointMap = [];
if (!empty($urls)) {
$endpointMap = \App\Models\ApiEndpointModel::whereIn('url', $urls)
->where('deleted_at', 0)
->get(['url', 'name', 'description'])
->keyBy('url')
->toArray();
}
foreach ($items as &$item) {
$ep = $endpointMap[$item['url'] ?? ''] ?? null;
$item['name'] = $ep['name'] ?? '';
$item['description'] = $ep['description'] ?? '';
$item['method_text'] = match ((int) ($item['method'] ?? 0)) {
1 => 'GET',
2 => 'POST',
default => 'ANY',
};
$item['type_text'] = ((int) ($item['type'] ?? 0) === 0) ? '成功' : '失败';
if (is_string($item['param'] ?? null)) {
$item['param'] = json_decode($item['param'], true) ?: [];
}
if (is_string($item['result'] ?? null)) {
$item['result'] = json_decode($item['result'], true) ?: [];
}
}
unset($item);
return [
'page' => $page,
'size' => $size,
'page_count' => (int) ceil($total / max($size, 1)),
'total' => $total,
'items' => $items,
];
}
/**
* 获取菜单列表
* @return array

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\ApiEndpointModel;
/**
* 接口注册表 Service分页查询 + 更新是否记日志等元信息
*/
class ApiEndpointService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = ApiEndpointModel::class;
$this->selectField = [
'id', 'url', 'method', 'name', 'description', 'controller',
'is_log', 'status', 'created_at', 'updated_at',
];
$this->queryField = [
'url' => 'like',
'name' => 'like',
'is_log' => '=',
'method' => '=',
'status' => '=',
];
$this->orderBy = [
'name' => 'id',
'sort' => 'desc',
];
}
/**
* 分页列表;支持按 url/name/is_log/method 筛选
*/
public function list(): array
{
return $this->getPageList();
}
/**
* 更新接口元信息:仅允许改 name/description/is_log/status
* 为什么不开放改 url/method路由由代码决定随意改会导致日志匹配错乱
*
* @param int $id 接口 ID
* @param array $params 可更新字段
*/
public function update(int $id, array $params): mixed
{
$info = ApiEndpointModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('接口不存在');
}
$data = [];
if (array_key_exists('name', $params)) {
$name = trim((string) $params['name']);
if ($name === '') {
$this->utils->errorThrow('操作名不能为空');
}
$data['name'] = $name;
}
if (array_key_exists('description', $params)) {
$data['description'] = trim((string) $params['description']);
}
if (array_key_exists('is_log', $params)) {
$data['is_log'] = (int) $params['is_log'] ? 1 : 0;
}
if (array_key_exists('status', $params)) {
$data['status'] = (int) $params['status'] ? 1 : 0;
}
if (empty($data)) {
return true;
}
$data['updated_at'] = time();
return ApiEndpointModel::where('id', $id)->where('deleted_at', 0)->update($data);
}
}

View File

@@ -4,50 +4,84 @@ namespace App\Service;
use App\BaseApp\BaseNotAuthService;
use App\Models\AdminModel;
use App\Models\LoginLogModel;
use App\Service\common\JWTService;
use App\Service\common\UserAgentService;
use App\Service\common\UtilsService;
use Exception;
use Illuminate\Support\Str;
/**
* 登录 / 注册服务:成功与失败均写登录日志,成功时更新 last_login_time
*/
class LoginService extends BaseNotAuthService
{
/**
* 登录
* @param $phone
* @param $password
* @return array
* 账号密码登录
* 失败时先写日志再抛错,保证审计完整;成功始终更新 last_login_time
*
* @param string $phone 手机号/账号
* @param string $password 明文密码
* @return array 用户信息 + token
* @throws Exception
*/
public function login($phone, $password): array
{
$ua = UserAgentService::getInstance();
$equipment = $ua->parseEquipment();
$browser = $ua->parseBrowser();
$userModel = AdminModel::with(['role:id,name,value'])->where('phone', $phone)->first();
if (empty($userModel)) {
$this->writeLoginLog(0, (string) $phone, '', 1, '用户或密码错误', $equipment, $browser);
UtilsService::getInstance()->errorThrow('用户或密码错误!');
// return $this->register($phone, $password);
}
if (!password_verify($password, $userModel->password)) {
UtilsService::getInstance()->errorThrow('用户或密码错误2');
if (!password_verify($password, $userModel->password)) {
$this->writeLoginLog(
(int) $userModel->id,
(string) $phone,
(string) $userModel->nick_name,
1,
'用户或密码错误',
$equipment,
$browser
);
UtilsService::getInstance()->errorThrow('用户或密码错误!');
}
$updateData = [
'last_login_time' => get_time(),
'updated_at' => get_time(),
];
if ($userModel->ip !== get_ip()) {
$ipTable = json_decode($userModel->ip_table, true);
if (!in_array(get_ip(), $ipTable?? [])) {
if (!in_array(get_ip(), $ipTable ?? [])) {
$ipTable[] = get_ip();
}
$update = AdminModel::where('id', $userModel->id)->update([
'ip' => get_ip(),
'ip_table' => json_encode($ipTable),
'updated_at' => get_time()
]);
if (!$update) {
UtilsService::getInstance()->errorThrow('更新失败!');
}
$userModel = AdminModel::where('id', $userModel->id)->first();
$updateData['ip'] = get_ip();
$updateData['ip_table'] = json_encode($ipTable);
}
$update = AdminModel::where('id', $userModel->id)->update($updateData);
if (!$update) {
$this->writeLoginLog(
(int) $userModel->id,
(string) $phone,
(string) $userModel->nick_name,
1,
'更新登录信息失败',
$equipment,
$browser
);
UtilsService::getInstance()->errorThrow('更新失败!');
}
$userModel = AdminModel::with(['role:id,name,value'])->where('id', $userModel->id)->first();
$this->writeLoginLog(
(int) $userModel->id,
(string) $userModel->phone,
(string) $userModel->nick_name,
0,
'登录成功',
$equipment,
$browser
);
$result = [
'id' => $userModel->id,
'phone' => $userModel->phone,
@@ -66,11 +100,12 @@ class LoginService extends BaseNotAuthService
}
/**
* 注册
* @param $phone
* @param $password
* @param $email
* @param $code
* 注册管理员账号
*
* @param string $phone 手机号
* @param string $password 密码
* @param string $email 邮箱
* @param mixed $code 验证码(预留)
* @return array
* @throws Exception
*/
@@ -80,31 +115,27 @@ class LoginService extends BaseNotAuthService
if ($userModel) {
UtilsService::getInstance()->errorThrow('账号已被占用!');
}
$createModel = AdminModel::create([
'phone' => $phone,
'email' => $email,
'password' => password_hash($password, PASSWORD_DEFAULT),
'nick_name' => '新用户'. Str::random(),
'nick_name' => '新用户' . Str::random(),
'avatar' => 'https://pic.rmb.bdstatic.com/bjh/80852bfe7c321988191838517ba64e309354.jpeg@h_1280',
'role_id' => 2,
'ip' => get_ip(),
'ip_table' => json_encode([get_ip()]),
'created_at' => get_time()
'created_at' => get_time(),
]);
if (!$createModel) {
UtilsService::getInstance()->errorThrow('注册失败!');
}
$userInfo = AdminModel::with(['role:id,name,value'])->where('id', $createModel->id)->first();
$result = [
'id' => $userInfo->id,
'phone' => $userInfo->phone,
'nick_name' => $userInfo->nick_name,
'avatar' => $userInfo->avatar,
'email' => $userInfo->email?? '',
'email' => $userInfo->email ?? '',
'role_name' => $userInfo->role->name,
'role_value' => $userInfo->role->value,
'ip' => $userInfo->ip,
@@ -112,7 +143,43 @@ class LoginService extends BaseNotAuthService
];
$token = JWTService::getInstance()->generateToken($result);
$result['token'] = $token;
return $result;
}
/**
* 写入登录日志(失败也尽量落库,内部吞异常避免掩盖业务错误)
*
* @param int $userId 管理员 ID失败未知用户可为 0
* @param string $phone 登录账号
* @param string $nickName 昵称快照
* @param int $status 0成功 1失败
* @param string $message 结果说明
* @param string $equipment 操作系统
* @param string $browser 浏览器
*/
private function writeLoginLog(
int $userId,
string $phone,
string $nickName,
int $status,
string $message,
string $equipment,
string $browser
): void {
try {
LoginLogModel::insert([
'user_id' => $userId,
'phone' => $phone,
'nick_name' => $nickName,
'ip' => (string) get_ip(),
'equipment' => $equipment,
'browser' => $browser,
'status' => $status,
'message' => $message,
'created_at' => time(),
]);
} catch (\Throwable $e) {
// 日志失败不影响登录主流程
}
}
}

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Enum\OssDriverEnum;
use App\Models\oss\OssConfigModel;
use App\Service\common\FieldEncryptService;
/**
* OSS 配置管理:驱动选项、启用切换、配置 CRUD密钥入库加密
*/
class OssConfigService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = OssConfigModel::class;
$this->selectField = [
'id', 'driver', 'name', 'endpoint', 'region', 'bucket', 'domain',
'path_prefix', 'is_active', 'status', 'remark', 'sort', 'created_at', 'updated_at',
];
$this->queryField = [
'driver' => '=',
'name' => 'like',
'status' => '=',
'is_active' => '=',
];
$this->orderBy = [
'name' => 'sort',
'sort' => 'desc',
];
}
/**
* 驱动枚举选项(前端表单 / 卡片文案)
*/
public function driverOptions(): array
{
return OssDriverEnum::options();
}
/**
* 运行时页数据:配置卡片 + 当前启用 ID不回显密钥明文
*/
public function getRuntimeOptions(): array
{
$items = $this->formatConfigRows(
OssConfigModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderByDesc('id')
->get()
->toArray()
);
$activeId = 0;
foreach ($items as $item) {
if ((int) ($item['is_active'] ?? 0) === 1) {
$activeId = (int) $item['id'];
break;
}
}
if ($activeId <= 0) {
$activeId = (int) SystemConfigService::getInstance()->getValue('oss_active_config_id', 0);
}
return [
'drivers' => OssDriverEnum::options(),
'items' => $items,
'active_id' => $activeId,
];
}
/**
* 切换当前启用的 OSS 配置
* 为什么双写 is_active + system_config上传链路优先读 kv列表页靠 is_active 展示徽章
*/
public function saveRuntime(array $params): array
{
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
$this->utils->errorThrow('请选择要启用的配置');
}
$info = OssConfigModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('配置不存在');
}
if ((int) $info->status !== 1) {
$this->utils->errorThrow('该配置已禁用,无法启用');
}
$now = time();
OssConfigModel::where('deleted_at', 0)->update(['is_active' => 0, 'updated_at' => $now]);
OssConfigModel::where('id', $id)->update(['is_active' => 1, 'updated_at' => $now]);
SystemConfigService::getInstance()->setValue('oss_active_config_id', (string) $id, '当前启用的 OSS 配置 ID');
return ['id' => $id];
}
/**
* 配置列表(卡片,无密钥明文)
*/
public function listConfigs(): array
{
$items = $this->formatConfigRows(
OssConfigModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderByDesc('id')
->get()
->toArray()
);
return [
'page' => 1,
'size' => count($items),
'page_count' => 1,
'total' => count($items),
'items' => $items,
];
}
/**
* 新增 OSS 配置;云驱动要求填写密钥
*/
public function createConfig(array $params): mixed
{
$driver = trim((string) ($params['driver'] ?? ''));
$name = trim((string) ($params['name'] ?? ''));
if (!OssDriverEnum::isValid($driver)) {
$this->utils->errorThrow('不支持的存储驱动');
}
if ($name === '') {
$this->utils->errorThrow('请填写配置别名');
}
$enum = OssDriverEnum::from($driver);
$accessKey = trim((string) ($params['access_key'] ?? ''));
$secretKey = trim((string) ($params['secret_key'] ?? ''));
if ($enum->needsSecret() && ($accessKey === '' || $secretKey === '')) {
$this->utils->errorThrow('该驱动需要填写 AccessKey 与 SecretKey');
}
$encrypt = FieldEncryptService::getInstance();
$isActive = (int) ($params['is_active'] ?? 0);
$now = time();
if ($isActive === 1) {
OssConfigModel::where('deleted_at', 0)->update(['is_active' => 0, 'updated_at' => $now]);
}
$id = OssConfigModel::insertGetId([
'driver' => $driver,
'name' => $name,
'access_key' => $accessKey !== '' ? $encrypt->encryptForStorage($accessKey) : '',
'secret_key' => $secretKey !== '' ? $encrypt->encryptForStorage($secretKey) : '',
'endpoint' => trim((string) ($params['endpoint'] ?? '')),
'region' => trim((string) ($params['region'] ?? '')),
'bucket' => trim((string) ($params['bucket'] ?? '')),
'domain' => trim((string) ($params['domain'] ?? '')),
'path_prefix' => trim((string) ($params['path_prefix'] ?? '')),
'extra_json' => null,
'is_active' => $isActive,
'status' => (int) ($params['status'] ?? 1),
'remark' => trim((string) ($params['remark'] ?? '')),
'sort' => (int) ($params['sort'] ?? 0),
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
if ($isActive === 1) {
SystemConfigService::getInstance()->setValue('oss_active_config_id', (string) $id, '当前启用的 OSS 配置 ID');
}
return ['id' => $id];
}
/**
* 更新配置access_key/secret_key 空串表示不改密钥
*/
public function updateConfig(int $id, array $params): mixed
{
$info = OssConfigModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('配置不存在');
}
$data = [];
if (array_key_exists('driver', $params)) {
$driver = trim((string) $params['driver']);
if (!OssDriverEnum::isValid($driver)) {
$this->utils->errorThrow('不支持的存储驱动');
}
$data['driver'] = $driver;
}
if (array_key_exists('name', $params)) {
$name = trim((string) $params['name']);
if ($name === '') {
$this->utils->errorThrow('配置别名不能为空');
}
$data['name'] = $name;
}
foreach (['endpoint', 'region', 'bucket', 'domain', 'path_prefix', 'remark'] as $field) {
if (array_key_exists($field, $params)) {
$data[$field] = trim((string) $params[$field]);
}
}
if (array_key_exists('status', $params)) {
$data['status'] = (int) $params['status'];
}
if (array_key_exists('sort', $params)) {
$data['sort'] = (int) $params['sort'];
}
$encrypt = FieldEncryptService::getInstance();
$accessKey = trim((string) ($params['access_key'] ?? ''));
if ($accessKey !== '') {
$data['access_key'] = $encrypt->encryptForStorage($accessKey);
}
$secretKey = trim((string) ($params['secret_key'] ?? ''));
if ($secretKey !== '') {
$data['secret_key'] = $encrypt->encryptForStorage($secretKey);
}
$now = time();
if (array_key_exists('is_active', $params) && (int) $params['is_active'] === 1) {
OssConfigModel::where('deleted_at', 0)->where('id', '<>', $id)->update(['is_active' => 0, 'updated_at' => $now]);
$data['is_active'] = 1;
SystemConfigService::getInstance()->setValue('oss_active_config_id', (string) $id, '当前启用的 OSS 配置 ID');
} elseif (array_key_exists('is_active', $params)) {
$data['is_active'] = (int) $params['is_active'];
}
if (empty($data)) {
return true;
}
$data['updated_at'] = $now;
return OssConfigModel::where('id', $id)->where('deleted_at', 0)->update($data);
}
/**
* 软删除;若删的是当前启用项则回落到本地配置,避免上传无可用驱动
*/
public function deleteConfigs(array $ids): mixed
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
$activeId = (int) SystemConfigService::getInstance()->getValue('oss_active_config_id', 0);
$deletingActive = ($activeId > 0 && in_array($activeId, $ids, true))
|| OssConfigModel::whereIn('id', $ids)->where('is_active', 1)->where('deleted_at', 0)->exists();
$fallbackId = 0;
if ($deletingActive) {
$local = OssConfigModel::where('driver', 'local')
->where('deleted_at', 0)
->where('status', 1)
->whereNotIn('id', $ids)
->orderBy('id')
->first();
if (!$local) {
$this->utils->errorThrow('不能删除当前启用的配置:请先切换,或保留至少一条本地存储');
}
$fallbackId = (int) $local->id;
}
$ok = OssConfigModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
'is_active' => 0,
]);
if ($fallbackId > 0) {
$this->saveRuntime(['id' => $fallbackId]);
}
return $ok;
}
/**
* 行格式化:补驱动名、密钥是否已配置标记
*/
private function formatConfigRows(array $rows): array
{
foreach ($rows as &$row) {
$driver = (string) ($row['driver'] ?? '');
$enum = OssDriverEnum::tryFrom($driver);
$row['driver_name'] = $enum?->label() ?? $driver;
$row['driver_desc'] = $enum?->description() ?? '';
$row['has_access_key'] = trim((string) ($row['access_key'] ?? '')) !== '';
$row['has_secret_key'] = trim((string) ($row['secret_key'] ?? '')) !== '';
unset($row['access_key'], $row['secret_key']);
}
unset($row);
return $rows;
}
}

View File

@@ -3,87 +3,72 @@
namespace App\Service\common;
use App\BaseApp\BaseService;
use App\Models\ProjectModel;
use App\Models\RoleModel;
use App\Models\AdminModel;
use App\Service\common\upload\LocalhostStorageService;
use App\Service\common\upload\QiniuStorageService;
use App\Service\common\oss\OssRuntimeConfigService;
use App\Service\common\oss\OssStorageFactory;
use App\Service\common\oss\OssStorageInterface;
use App\Service\FileService;
use Exception;
use Illuminate\Support\Str;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 统一上传入口:按数据库启用的 OSS 配置,经工厂分发到对应存储实现
*/
class UploadService extends BaseService
{
private $uploadService;
private OssStorageInterface $uploadService;
public function __construct()
{
parent::__construct();
$this->model = RoleModel::class;
switch (env('ECS')) {
case 'aliyun':
// $this->uploadService = AliyunStorageService::getInstance();
break;
case 'qcloud':
// $this->uploadService = QcloudStorageService::getInstance();
break;
case 'qiniu':
$this->uploadService = QiniuStorageService::getInstance();
break;
default:
$this->uploadService = LocalhostStorageService::getInstance();
break;
try {
$config = OssRuntimeConfigService::getInstance()->getActiveConfig();
$this->uploadService = OssStorageFactory::getInstance()->make($config);
} catch (\Throwable $e) {
// 配置异常时回退本地,避免整站上传不可用
$this->uploadService = OssStorageFactory::getInstance()->make([
'driver' => 'local',
'path_prefix' => 'uploads',
'domain' => '',
]);
}
}
/**
* 上传OSS图片
* @param $file
* @return array|bool
* @throws Exception
* 上传图片到当前启用的存储
*
* @param mixed $file 上传文件对象
*/
public function uploadImage($file): array|bool
{
// 获取文件后缀
$ext = $file->getClientOriginalExtension();
$key = 'spa/image/'. date('Ymd') . '/' . 'cc_upload_'. Str::random(). uniqid() . '.' . $ext;
$key = 'spa/image/' . date('Ymd') . '/' . 'cc_upload_' . Str::random() . uniqid() . '.' . $ext;
$result = $this->uploadService->uploadVideo($file, $key);
// $result = [
// 'key' => $key,
// 'url' => 'https://img2.baidu.com/it/u=1629222614,1358629025&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500&a='. rand(1111111, 9999999)
// ];
if (!$result) {
$this->utils->errorThrow('图片上传失败');
}
FileService::getInstance()->create([
'user_id' => $this->userId,
'url' => $result['url'],
]);
return $result;
}
/**
* 上传OSS视频
* @param $file
* @return array|bool
* @throws Exception
* 上传视频到当前启用的存储
*
* @param mixed $file 上传文件对象
*/
public function uploadVideo($file): array|bool
{
// 获取文件后缀
$ext = $file->getClientOriginalExtension();
$key = 'spa/video/'. date('Ymd') . '/' . 'cc_upload_'. Str::random(). uniqid() . '.' . $ext;
$key = 'spa/video/' . date('Ymd') . '/' . 'cc_upload_' . Str::random() . uniqid() . '.' . $ext;
$result = $this->uploadService->uploadVideo($file, $key);
// $result = [
// 'key' => $key,
// 'url' => 'http://d-jy.nailaoyun.cn//storage/video//20250327/2cc1abf9bd69410ce7c69660daf54260.mp4'
// ];
if (!$result) {
$this->utils->errorThrow('视频上传失败');
}
FileService::getInstance()->create([
'user_id' => $this->userId,
'url' => $result['url'],
]);
return $result;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Service\common;
use App\BaseApp\BaseNotAuthService;
/**
* User-Agent 解析:登录日志 / 操作日志共用
*/
class UserAgentService extends BaseNotAuthService
{
/**
* UA 推断操作系统名称
* 为什么允许空参:中间件/登录处直接取当前请求头,避免到处传 UA
*
* @param string|null $ua 为空时取当前请求 User-Agent
*/
public function parseEquipment(?string $ua = null): string
{
$ua = $ua ?? (string) request()->header('User-Agent', '');
if ($ua === '') {
return '未知';
}
return match (true) {
str_contains($ua, 'Windows NT 10') => 'Windows 10/11',
str_contains($ua, 'Windows') => 'Windows',
str_contains($ua, 'Macintosh') || str_contains($ua, 'Mac OS') => 'macOS',
str_contains($ua, 'Android') => 'Android',
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
str_contains($ua, 'Linux') => 'Linux',
default => '其他',
};
}
/**
* UA 推断浏览器名称
* Edge 需先于 Chrome 匹配,避免被 Chrome/ 抢先
*
* @param string|null $ua 为空时取当前请求 User-Agent
*/
public function parseBrowser(?string $ua = null): string
{
$ua = $ua ?? (string) request()->header('User-Agent', '');
if ($ua === '') {
return '未知';
}
return match (true) {
str_contains($ua, 'Edg/') => 'Edge',
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
str_contains($ua, 'Chrome/') && !str_contains($ua, 'Edg/') => 'Chrome',
str_contains($ua, 'Firefox/') => 'Firefox',
str_contains($ua, 'Safari/') && !str_contains($ua, 'Chrome/') => 'Safari',
str_contains($ua, 'MSIE') || str_contains($ua, 'Trident/') => 'IE',
default => '其他',
};
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Service\common\oss;
use App\BaseApp\BaseService;
use App\Models\oss\OssConfigModel;
use App\Service\common\FieldEncryptService;
use App\Service\SystemConfigService;
/**
* OSS 运行时配置:解析当前启用的存储配置并解密密钥,供上传工厂使用
*/
class OssRuntimeConfigService extends BaseService
{
public const CFG_ACTIVE_ID = 'oss_active_config_id';
public function __construct()
{
// 上传链路可能在无登录上下文触发,不强制鉴权
$this->isAuth = false;
parent::__construct();
}
/**
* 获取当前启用的明文配置
* 为什么要解密:上传客户端需要明文 access/secret库内仅存 AES 密文
*
* @return array{
* id:int,driver:string,name:string,access_key:string,secret_key:string,
* endpoint:string,region:string,bucket:string,domain:string,
* path_prefix:string,extra_json:mixed
* }
*/
public function getActiveConfig(): array
{
$sys = SystemConfigService::getInstance();
$activeId = (int) $sys->getValue(self::CFG_ACTIVE_ID, '0');
$row = null;
if ($activeId > 0) {
$row = OssConfigModel::where('id', $activeId)
->where('deleted_at', 0)
->where('status', 1)
->first();
}
if (!$row) {
// 回落 is_active 标记,避免仅写了表未写 system_config 时上传失败
$row = OssConfigModel::where('is_active', 1)
->where('deleted_at', 0)
->where('status', 1)
->orderByDesc('id')
->first();
}
if (!$row) {
// 最终回落本地默认行或内存默认
$row = OssConfigModel::where('driver', 'local')
->where('deleted_at', 0)
->where('status', 1)
->orderByDesc('is_active')
->orderBy('id')
->first();
}
if (!$row) {
return [
'id' => 0,
'driver' => 'local',
'name' => '本地存储',
'access_key' => '',
'secret_key' => '',
'endpoint' => '',
'region' => '',
'bucket' => '',
'domain' => '',
'path_prefix' => 'uploads',
'extra_json' => null,
];
}
$enc = FieldEncryptService::getInstance();
$extra = $row->extra_json;
if (is_string($extra) && $extra !== '') {
$decoded = json_decode($extra, true);
$extra = is_array($decoded) ? $decoded : $extra;
}
return [
'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),
'endpoint' => (string) ($row->endpoint ?? ''),
'region' => (string) ($row->region ?? ''),
'bucket' => (string) ($row->bucket ?? ''),
'domain' => (string) ($row->domain ?? ''),
'path_prefix' => (string) ($row->path_prefix ?? ''),
'extra_json' => $extra,
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Service\common\oss;
use App\BaseApp\BaseNotAuthService;
use App\Enum\OssDriverEnum;
use App\Service\common\upload\AliyunStorageService;
use App\Service\common\upload\LocalhostStorageService;
use App\Service\common\upload\QcloudStorageService;
use App\Service\common\upload\QiniuStorageService;
use App\Service\common\upload\S3CompatibleStorageService;
use App\Service\common\UtilsService;
/**
* OSS 存储工厂:按 OssDriverEnum 分发到对应实现(工厂 + 策略)
* 覆盖local / aliyun / qcloud / qiniu / huawei / aws / minio / baidu
*/
class OssStorageFactory extends BaseNotAuthService
{
/**
* 根据明文运行时配置构建上传客户端
* 为什么集中在工厂UploadService 不关心具体 SDK/签名差异,只拿接口用
*
* @param array $config getActiveConfig() 返回值(含 driver
*/
public function make(array $config): OssStorageInterface
{
$driverCode = strtolower(trim((string) ($config['driver'] ?? OssDriverEnum::Local->value)));
$driver = OssDriverEnum::tryFrom($driverCode);
if ($driver === null) {
UtilsService::getInstance()->errorThrow(
'不支持的存储驱动:' . $driverCode . ',请改用本地存储或完善配置'
);
}
// 按驱动枚举创建策略实现S3 兼容族共用一套 SigV4 客户端
return match ($driver) {
OssDriverEnum::Local => LocalhostStorageService::getInstance()->withConfig($config),
OssDriverEnum::Aliyun => AliyunStorageService::getInstance()->withConfig($config),
OssDriverEnum::Qcloud => QcloudStorageService::getInstance()->withConfig($config),
OssDriverEnum::Qiniu => QiniuStorageService::getInstance()->withConfig($config),
OssDriverEnum::Huawei,
OssDriverEnum::Aws,
OssDriverEnum::Minio,
OssDriverEnum::Baidu => S3CompatibleStorageService::getInstance()->withConfig($config),
};
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Service\common\oss;
/**
* OSS 存储驱动统一契约:工厂按 driver 返回实现类UploadService 只依赖本接口
*/
interface OssStorageInterface
{
/**
* 注入本请求的运行时配置(密钥已解密)
*
* @param array $config OssRuntimeConfigService::getActiveConfig()
*/
public function withConfig(array $config): static;
/**
* 上传图片对象
*
* @param mixed $filePath 本地路径或 UploadedFile
* @param string $key 对象键
* @return array{key:string,url:string}|false
*/
public function uploadImage($filePath, string $key): bool|array;
/**
* 上传视频/通用文件对象
*
* @param mixed $filePath 本地路径或 UploadedFile
* @param string $key 对象键
* @return array{key:string,url:string}|false
*/
public function uploadVideo($filePath, string $key): bool|array;
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Service\common\upload;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* 阿里云 OSSREST PutObjectAuthorization 签名)
*/
class AliyunStorageService extends BaseNotAuthService implements OssStorageInterface
{
protected array $config = [];
public function withConfig(array $config): static
{
$this->config = $config;
return $this;
}
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 使用 OSS V1 签名上传对象
*/
private function uploadFile($filePath, string $key): bool|array
{
$accessKey = (string) ($this->config['access_key'] ?? '');
$secretKey = (string) ($this->config['secret_key'] ?? '');
$bucket = (string) ($this->config['bucket'] ?? '');
$endpoint = (string) ($this->config['endpoint'] ?? '');
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint');
}
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$content = file_get_contents($path);
$contentType = 'application/octet-stream';
$date = gmdate('D, d M Y H:i:s \G\M\T');
$resource = '/' . $bucket . '/' . $key;
$stringToSign = "PUT\n\n{$contentType}\n{$date}\n{$resource}";
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $secretKey, true));
$host = preg_replace('#^https?://#', '', rtrim($endpoint, '/'));
// 支持传入 oss-cn-xxx.aliyuncs.com 或带 bucket 的域名
if (!str_starts_with($host, $bucket . '.')) {
$host = $bucket . '.' . $host;
}
$url = 'https://' . $host . '/' . $key;
$response = Http::withHeaders([
'Date' => $date,
'Content-Type' => $contentType,
'Authorization' => 'OSS ' . $accessKey . ':' . $signature,
])->withBody($content, $contentType)->put($url);
if (!$response->successful()) {
UtilsService::getInstance()->errorThrow('阿里云上传失败:' . $response->body());
}
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
return ['key' => $key, 'url' => $publicUrl];
}
}

View File

@@ -2,105 +2,72 @@
namespace App\Service\common\upload;
use App\BaseApp\BaseService;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use Illuminate\Support\Facades\Storage;
class LocalhostStorageService extends BaseService
/**
* 本地磁盘上传;支持 domain / path_prefix 覆盖
*/
class LocalhostStorageService extends BaseNotAuthService implements OssStorageInterface
{
/**
* @var mixed 单例实例,确保该类只有一个全局实例
*/
protected static mixed $_instance;
protected array $config = [
'domain' => '',
'path_prefix' => '',
];
public function __construct()
/**
* 注入运行时配置(链式)
*/
public function withConfig(array $config): static
{
parent::__construct();
$this->config = array_merge($this->config, $config);
return $this;
}
/**
* 获取实例
* @return null|static
*/
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 上传图片
*
* @param string $filePath 文件本地路径
* @param string $key 上传到本地存储的文件名
* @return array|bool
*/
public function uploadImage($filePath, $key): bool|array
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 上传视频
*
* @param string $filePath 文件本地路径
* @param string $key 上传到本地存储的文件名
* @return array|bool
*/
public function uploadVideo($filePath, $key): bool|array
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 删除图片
*
* @param string $key 本地存储的文件名
* @return bool
*/
public function deleteImage($key): bool
{
return $this->deleteFile($key);
}
/**
* 删除视频
*
* @param string $key 本地存储的文件名
* @return bool
*/
public function deleteVideo($key): bool
{
return $this->deleteFile($key);
}
/**
* 上传文件
*
* @param string $filePath 文件本地路径
* @param string $key 上传到本地存储的文件名
* @return array|bool
* 写入本地 storage兼容 UploadedFile 与路径字符串
*/
private function uploadFile(string $filePath, string $key): bool|array
private function uploadFile(mixed $filePath, string $key): bool|array
{
if (Storage::put($key, file_get_contents($filePath))) {
return [
'key' => $key,
'url' => asset('/storage/' . $key)
];
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
return false;
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
if (!Storage::put($key, file_get_contents($path))) {
return false;
}
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
$url = $domain !== '' ? ($domain . '/' . $key) : asset('/storage/' . $key);
return [
'key' => $key,
'url' => $url,
];
}
/**
* 删除文件
*
* @param string $key 本地存储的文件名
* @return bool
*/
private function deleteFile(string $key): bool
{
return Storage::delete($key);

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Service\common\upload;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* 腾讯云 COS简易 PUTAuthorization 签名 v5
*/
class QcloudStorageService extends BaseNotAuthService implements OssStorageInterface
{
protected array $config = [];
public function withConfig(array $config): static
{
$this->config = $config;
return $this;
}
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* COS 对象上传Sign Algorithm=sha1
*/
private function uploadFile($filePath, string $key): bool|array
{
$secretId = (string) ($this->config['access_key'] ?? '');
$secretKey = (string) ($this->config['secret_key'] ?? '');
$bucket = (string) ($this->config['bucket'] ?? '');
$region = (string) ($this->config['region'] ?? '');
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
if ($secretId === '' || $secretKey === '' || $bucket === '' || $region === '') {
UtilsService::getInstance()->errorThrow('腾讯云 COS 配置不完整(需要 SecretId/SecretKey/Bucket/Region');
}
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$content = file_get_contents($path);
$host = $bucket . '.cos.' . $region . '.myqcloud.com';
$urlPath = '/' . ltrim($key, '/');
$now = time();
$keyTime = $now . ';' . ($now + 600);
$signKey = hash_hmac('sha1', $keyTime, $secretKey);
$httpString = strtolower('put') . "\n" . $urlPath . "\n\nhost=" . strtolower($host) . "\n";
$stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n";
$signature = hash_hmac('sha1', $stringToSign, $signKey);
$authorization = 'q-sign-algorithm=sha1'
. '&q-ak=' . $secretId
. '&q-sign-time=' . $keyTime
. '&q-key-time=' . $keyTime
. '&q-header-list=host'
. '&q-url-param-list='
. '&q-signature=' . $signature;
$url = 'https://' . $host . $urlPath;
$response = Http::withHeaders([
'Host' => $host,
'Authorization' => $authorization,
'Content-Type' => 'application/octet-stream',
])->withBody($content, 'application/octet-stream')->put($url);
if (!$response->successful()) {
UtilsService::getInstance()->errorThrow('腾讯云上传失败:' . $response->body());
}
$publicUrl = $domain !== '' ? ($domain . $urlPath) : $url;
return ['key' => $key, 'url' => $publicUrl];
}
}

View File

@@ -2,131 +2,95 @@
namespace App\Service\common\upload;
use App\BaseApp\BaseService;
use Exception;
use Qiniu\Auth;
use Qiniu\Storage\BucketManager;
use Qiniu\Storage\UploadManager;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
class QiniuStorageService extends BaseService
/**
* 七牛云上传;优先使用官方 SDK未安装时给出明确提示
*/
class QiniuStorageService extends BaseNotAuthService implements OssStorageInterface
{
/**
* @var mixed 单例实例,确保该类只有一个全局实例
*/
protected static mixed $_instance;
private $accessKey;
private $secretKey;
private $bucket;
private $domain;
protected array $config = [
'access_key' => '',
'secret_key' => '',
'bucket' => '',
'domain' => '',
'path_prefix' => '',
];
public function __construct()
/**
* 注入解密后的运行时配置
*/
public function withConfig(array $config): static
{
parent::__construct();
$this->accessKey = config('nl.oss.qiniu.access_key');
$this->secretKey = config('nl.oss.qiniu.secret_key');
$this->bucket = config('nl.oss.qiniu.bucket');
$this->domain = config('nl.oss.qiniu.domain');
$this->config = array_merge($this->config, $config);
return $this;
}
/**
* 获取实例
* @return null|static
*/
public static function getInstance(): null|static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
}
return self::$_instance[$name];
}
/**
* 上传图片
*
* @param string $filePath 文件本地路径
* @param string $key 上传到七牛云的文件名
* @return array|bool
* @throws Exception
*/
public function uploadImage($filePath, $key): bool|array
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 上传视频
*
* @param string $filePath 文件本地路径
* @param string $key 上传到七牛云的文件名
* @return array|bool
*/
public function uploadVideo($filePath, $key): bool|array
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 删除图片
*
* @param string $key 七牛云存储的文件名
* @return bool
*/
public function deleteImage($key): bool
{
return $this->deleteFile($key);
}
/**
* 删除视频
*
* @param string $key 七牛云存储的文件名
* @return bool
*/
public function deleteVideo($key): bool
{
return $this->deleteFile($key);
}
/**
* 上传文件
*
* @param string $filePath 文件本地路径
* @param string $key 上传到七牛云的文件名
* @return array|bool
* @throws Exception
* 上传到七牛;无 SDK 时抛业务异常引导安装或改本地
*/
private function uploadFile(string $filePath, string $key): bool|array
private function uploadFile($filePath, string $key): bool|array
{
$auth = new Auth($this->accessKey, $this->secretKey);
$token = $auth->uploadToken($this->bucket);
$uploadMgr = new UploadManager();
list($ret, $err) = $uploadMgr->putFile($token, $key, $filePath);
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'] ?? '');
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
if ($accessKey === '' || $secretKey === '' || $bucket === '') {
UtilsService::getInstance()->errorThrow('七牛云配置不完整');
}
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$auth = new \Qiniu\Auth($accessKey, $secretKey);
$token = $auth->uploadToken($bucket);
$uploadMgr = new \Qiniu\Storage\UploadManager();
list($ret, $err) = $uploadMgr->putFile($token, $key, $path);
if ($err !== null) {
return false;
} else {
return [
'key' => $ret['key'],
'url' => $this->domain . '/' . $ret['key']
];
}
return [
'key' => $ret['key'],
'url' => $domain . '/' . $ret['key'],
];
}
/**
* 删除文件
*
* @param string $key 七牛云存储的文件名
* @return bool
*/
private function deleteFile(string $key): bool
{
$auth = new Auth($this->accessKey, $this->secretKey);
$bucketMgr = new BucketManager($auth);
$err = $bucketMgr->delete($this->bucket, $key);
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);
return $err === null;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Service\common\upload;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* S3 兼容上传AWS / MinIO / 华为 OBS / 百度 BOS
* 使用 AWS Signature Version 4 PUT Object
*/
class S3CompatibleStorageService extends BaseNotAuthService implements OssStorageInterface
{
protected array $config = [];
public function withConfig(array $config): static
{
$this->config = $config;
return $this;
}
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* SigV4 PUTendpoint 必填(如 https://s3.amazonaws.com MinIO 地址)
*/
private function uploadFile($filePath, string $key): bool|array
{
$accessKey = (string) ($this->config['access_key'] ?? '');
$secretKey = (string) ($this->config['secret_key'] ?? '');
$bucket = (string) ($this->config['bucket'] ?? '');
$region = (string) ($this->config['region'] ?? 'us-east-1');
$endpoint = rtrim((string) ($this->config['endpoint'] ?? ''), '/');
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
$driver = (string) ($this->config['driver'] ?? 'aws');
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
UtilsService::getInstance()->errorThrow(strtoupper($driver) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint');
}
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$payload = file_get_contents($path);
$host = parse_url($endpoint, PHP_URL_HOST) ?: preg_replace('#^https?://#', '', $endpoint);
// path-style: endpoint/bucket/key
$canonicalUri = '/' . rawurlencode($bucket) . '/' . str_replace('%2F', '/', rawurlencode($key));
// 简化:多数 MinIO/OBS 接受 path-style
$url = $endpoint . '/' . $bucket . '/' . $key;
$amzDate = gmdate('Ymd\THis\Z');
$dateStamp = gmdate('Ymd');
$payloadHash = hash('sha256', $payload);
$canonicalHeaders = "host:{$host}\nx-amz-content-sha256:{$payloadHash}\nx-amz-date:{$amzDate}\n";
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
$canonicalRequest = "PUT\n{$canonicalUri}\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
$service = $driver === 'huawei' ? 's3' : 's3';
$credentialScope = "{$dateStamp}/{$region}/{$service}/aws4_request";
$stringToSign = "AWS4-HMAC-SHA256\n{$amzDate}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest);
$kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $secretKey, true);
$kRegion = hash_hmac('sha256', $region, $kDate, true);
$kService = hash_hmac('sha256', $service, $kRegion, true);
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
$authorization = "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}";
$response = Http::withHeaders([
'Authorization' => $authorization,
'x-amz-content-sha256' => $payloadHash,
'x-amz-date' => $amzDate,
'Content-Type' => 'application/octet-stream',
'Host' => $host,
])->withBody($payload, 'application/octet-stream')->put($url);
if (!$response->successful()) {
UtilsService::getInstance()->errorThrow($driver . ' 上传失败:' . $response->body());
}
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
return ['key' => $key, 'url' => $publicUrl];
}
}