AI代码生成工具
This commit is contained in:
@@ -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
|
||||
|
||||
78
app/Service/ApiEndpointService.php
Normal file
78
app/Service/ApiEndpointService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
// 日志失败不影响登录主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
279
app/Service/OssConfigService.php
Normal file
279
app/Service/OssConfigService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
57
app/Service/common/UserAgentService.php
Normal file
57
app/Service/common/UserAgentService.php
Normal 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 => '其他',
|
||||
};
|
||||
}
|
||||
}
|
||||
97
app/Service/common/oss/OssRuntimeConfigService.php
Normal file
97
app/Service/common/oss/OssRuntimeConfigService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
47
app/Service/common/oss/OssStorageFactory.php
Normal file
47
app/Service/common/oss/OssStorageFactory.php
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
34
app/Service/common/oss/OssStorageInterface.php
Normal file
34
app/Service/common/oss/OssStorageInterface.php
Normal 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;
|
||||
}
|
||||
76
app/Service/common/upload/AliyunStorageService.php
Normal file
76
app/Service/common/upload/AliyunStorageService.php
Normal 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;
|
||||
|
||||
/**
|
||||
* 阿里云 OSS:REST PutObject(Authorization 签名)
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
81
app/Service/common/upload/QcloudStorageService.php
Normal file
81
app/Service/common/upload/QcloudStorageService.php
Normal 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:简易 PUT(Authorization 签名 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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
90
app/Service/common/upload/S3CompatibleStorageService.php
Normal file
90
app/Service/common/upload/S3CompatibleStorageService.php
Normal 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 PUT;endpoint 必填(如 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user