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

View File

@@ -17,7 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
// 操作日志:按 nl_api_endpoint.is_log 落库
$middleware->api(append: [
\App\Http\Middleware\ApiOpLogMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e) {

View File

@@ -67,10 +67,10 @@ return [
'domain' => env('OSS_DOMAIN'),
],
'qiniu' => [
'access_key' => 'RPQlZUd2yrL3kR8CC4BpTGV6FhGr11npBzf6IuCT',
'secret_key' => 'b-796uVY1InOKSVecpY8VBpO7Cdubo1Tv4RcOfMY',
'bucket' => 'ccspa',
'domain' => 'http://o-spa.nailaoyun.cn',
'access_key' => env('QINIU_ACCESS_KEY', ''),
'secret_key' => env('QINIU_SECRET_KEY', ''),
'bucket' => env('QINIU_BUCKET', ''),
'domain' => env('QINIU_DOMAIN', ''),
]
],
/*
@@ -124,17 +124,23 @@ return [
* 日志配置
*/
'log' => [
// 不添加返回记录的api接口
// 不添加返回记录的api接口(路径不含 /api/ 前缀,与中间件规范化后一致)
'no_insert' => [
'api/admin/auth/login',
'api/admin/auth/send-verification-code',
'api/admin/auth/my-info',
'api/admin/auth/codes',
'api/admin/auth/menu',
'api/admin/log/api-list',
'api/admin/log/ledger-list',
'api/admin/log/op-list',
'api/admin/log/prescription-list',
'login',
'register',
'admin/my-info',
'admin/codes',
'admin/menu',
'admin/login-log',
'admin/op-log',
'api-endpoint/list',
'oss-config/runtime-options',
'oss-config/driver-options',
'oss-config/list',
'ai-config/runtime-options',
'ai-config/key-list',
'ai-config/platform-option',
'ai-config/generation-list',
]
],
'app' => [

View File

@@ -352,8 +352,8 @@ CREATE TABLE `nl_ai_api_key` (
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI平台API密钥' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_ai_api_key` VALUES
(1, 1, '李琦-免费', '', '李琦-免费测试key', 1, 1, 0, 1786329600, 0, 0),
(2, 2, 'DeepSeek-v4', '', 'DeepSeek-v4', 1, 1, 0, 1786329600, 0, 0);
(1, 1, '李琦-免费', 'nl_ase_256_3Oy8Dxp82N0PgU2rXLrhVADcfwkqG+rzTwWboUpdYnX9Sd6acEtx95BAXVehSaGHWiDq7Zgr/SRcjHDNhPfg4Q==', '李琦-免费测试key', 1, 1, 0, 1786329600, 0, 0),
(2, 2, 'DeepSeek-v4', 'nl_ase_256_K5ycIstKd4nalXpGqN79TggP7t+NywXffr+gN8uZcLh1T09CWI4nLPZkAgosrUoDaDCOmgIthhLvQsqHxWLtQA==', 'DeepSeek-v4', 1, 1, 0, 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_ai_generation
@@ -389,4 +389,164 @@ CREATE TABLE `nl_ai_generation` (
INDEX `idx_api_key_created`(`api_key_id` ASC, `created_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI生成历史' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for nl_oss_config
-- ----------------------------
DROP TABLE IF EXISTS `nl_oss_config`;
CREATE TABLE `nl_oss_config` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`driver` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'local' COMMENT '驱动local/aliyun/qcloud/qiniu/huawei/aws/minio/baidu',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '配置别名',
`access_key` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'AccessKey库内AES加密',
`secret_key` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'SecretKey库内AES加密',
`endpoint` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Endpoint/服务地址',
`region` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '地域,如 ap-guangzhou',
`bucket` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Bucket 名称',
`domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '对外访问域名(含协议)',
`path_prefix` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '对象路径前缀',
`extra_json` json NULL COMMENT '扩展配置 JSON',
`is_active` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否当前启用 0否1是全局仅一套',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用0禁用',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '备注',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序,越大越靠前',
`created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间',
`deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间0表示未删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_driver_status`(`driver` ASC, `status` ASC) USING BTREE,
INDEX `idx_is_active`(`is_active` ASC, `deleted_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 9 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'OSS存储配置' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_oss_config` VALUES
(1, 'local', '本地存储', '', '', '', '', '', '', 'uploads', NULL, 1, 1, '默认本地磁盘,文件落到 storage/app', 100, 1786329600, 0, 0),
(2, 'aliyun', '阿里云 OSS', '', '', 'oss-cn-hangzhou.aliyuncs.com', 'cn-hangzhou', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/Secret、Bucket、访问域名Endpoint 可按地域修改', 90, 1786329600, 0, 0),
(3, 'qcloud', '腾讯云 COS', '', '', '', 'ap-guangzhou', '', '', 'uploads', NULL, 0, 1, '请填写 SecretId/SecretKey、Bucket、Region、访问域名', 80, 1786329600, 0, 0),
(4, 'qiniu', '七牛云 Kodo', '', '', '', '', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/SecretKey、Bucket、外链域名含协议', 70, 1786329600, 0, 0),
(5, 'huawei', '华为云 OBS', '', '', 'https://obs.cn-north-4.myhuaweicloud.com', 'cn-north-4', '', '', 'uploads', NULL, 0, 1, '请填写 AK/SK、Bucket、Endpoint、访问域名', 60, 1786329600, 0, 0),
(6, 'aws', 'AWS S3', '', '', 'https://s3.amazonaws.com', 'us-east-1', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/Secret、Bucket、Region、访问域名', 50, 1786329600, 0, 0),
(7, 'minio', 'MinIO', '', '', 'http://127.0.0.1:9000', 'us-east-1', '', '', 'uploads', NULL, 0, 1, '私有化 S3 兼容;请填写 Key、Bucket、Endpoint', 40, 1786329600, 0, 0),
(8, 'baidu', '百度云 BOS', '', '', 'https://bj.bcebos.com', 'bj', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/SecretKey、Bucket、Endpoint、访问域名', 30, 1786329600, 0, 0);
INSERT INTO `nl_system_config` (`key`, `value`, `remark`, `created_at`, `updated_at`, `deleted_at`) VALUES
('oss_active_config_id', '1', '当前启用的 OSS 配置 ID', 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_api_endpoint
-- ----------------------------
DROP TABLE IF EXISTS `nl_api_endpoint`;
CREATE TABLE `nl_api_endpoint` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '接口路径,如 admin/list不含前缀 /api/',
`method` tinyint(1) NOT NULL DEFAULT 0 COMMENT '请求方式 0ANY 1GET 2POST',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作名',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '接口说明',
`controller` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '所属控制器',
`is_log` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否记录操作日志 0否1是',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用0禁用',
`created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间',
`deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间0表示未删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_url_method`(`url` ASC, `method` ASC) USING BTREE,
INDEX `idx_is_log`(`is_log` ASC, `status` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '接口注册表' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_api_endpoint` (`url`, `method`, `name`, `description`, `controller`, `is_log`, `status`, `created_at`, `updated_at`, `deleted_at`) VALUES
('login', 2, '登录', '账号密码登录', 'LoginController', 0, 1, 1786329600, 0, 0),
('register', 2, '注册', '注册管理员账号', 'LoginController', 0, 1, 1786329600, 0, 0),
('logout', 0, '退出登录', '退出登录', 'LoginController', 0, 1, 1786329600, 0, 0),
('codes', 0, '权限码', '获取权限码(登录前)', 'LoginController', 0, 1, 1786329600, 0, 0),
('admin/list', 1, '管理员列表', '分页查询管理员', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/option', 1, '管理员下拉', '管理员下拉选项', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/detail', 1, '管理员详情', '管理员详情', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/create', 2, '创建管理员', '新增管理员', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/update', 2, '更新管理员', '编辑管理员', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/delete', 2, '删除管理员', '删除管理员', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/change-password', 2, '修改密码', '当前用户修改密码', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/reset-password', 2, '重置密码', '管理员重置他人密码', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/my-info', 1, '我的信息', '获取当前登录用户信息', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/update-profile', 2, '更新个人资料', '个人中心编辑资料/头像', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/menu', 1, '我的菜单', '按角色获取菜单树', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/codes', 1, '我的权限码', '获取当前用户权限码', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/login-log', 1, '我的登录日志', '个人中心登录日志', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/op-log', 1, '我的操作日志', '个人中心操作日志', 'AdminController', 0, 1, 1786329600, 0, 0),
('role/list', 1, '角色列表', '分页查询角色', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/option', 1, '角色下拉', '角色下拉选项', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/detail', 1, '角色详情', '角色详情', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/create', 2, '创建角色', '新增角色', 'RoleController', 1, 1, 1786329600, 0, 0),
('role/update', 2, '更新角色', '编辑角色', 'RoleController', 1, 1, 1786329600, 0, 0),
('role/delete', 2, '删除角色', '删除角色', 'RoleController', 1, 1, 1786329600, 0, 0),
('role/get-menu-ids-by-role-ids', 1, '角色菜单ID', '按角色获取已绑菜单', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/save-role-menu', 2, '保存角色菜单', '绑定角色菜单权限', 'RoleController', 1, 1, 1786329600, 0, 0),
('menu/list', 1, '菜单列表', '分页查询菜单', 'MenuController', 0, 1, 1786329600, 0, 0),
('menu/option', 1, '菜单下拉', '菜单下拉选项', 'MenuController', 0, 1, 1786329600, 0, 0),
('menu/detail', 1, '菜单详情', '菜单详情', 'MenuController', 0, 1, 1786329600, 0, 0),
('menu/create', 2, '创建菜单', '新增菜单', 'MenuController', 1, 1, 1786329600, 0, 0),
('menu/update', 2, '更新菜单', '编辑菜单', 'MenuController', 1, 1, 1786329600, 0, 0),
('menu/delete', 2, '删除菜单', '删除菜单', 'MenuController', 1, 1, 1786329600, 0, 0),
('menu/tree-option', 1, '菜单树', '菜单树形选项', 'MenuController', 0, 1, 1786329600, 0, 0),
('upload/image', 2, '上传图片', '上传图片文件', 'UploadController', 1, 1, 1786329600, 0, 0),
('upload/video', 2, '上传视频', '上传视频文件', 'UploadController', 1, 1, 1786329600, 0, 0),
('database/list-tables', 1, '库表列表', '列出数据库表', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('database/get-table-info', 1, '表结构', '获取表结构信息', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('database/get-table-data', 2, '表数据查询', '查询表数据', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('database/update-table-data', 2, '更新表数据', '修改表行数据', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/delete-table-data', 2, '删除表数据', '删除表行数据', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/batch-delete-table-data', 2, '批量删除表数据', '批量删除表行', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/insert-table-data', 2, '插入表数据', '新增表行数据', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/update-table-comment', 2, '更新表注释', '修改表注释', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/update-column-comment', 2, '更新字段注释', '修改字段注释', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/add-index', 2, '添加索引', '创建表索引', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/drop-index', 2, '删除索引', '删除表索引', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/update-table-structure', 2, '改表结构', '修改表结构', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/execute-sql-query', 2, '执行SQL', '执行自定义 SQL', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/get-change-log', 1, '库变更日志', '数据库变更记录', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('ai-config/runtime-options', 1, 'AI运行时选项', 'AI平台/模型/密钥选项', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/save-runtime', 2, '保存AI运行时', '保存默认AI组合', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/key-list', 1, 'AI密钥列表', 'API Key 卡片列表', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/platform-option', 1, 'AI平台下拉', 'AI平台选项', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/create-key', 2, '创建AI密钥', '新增API Key', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/update-key', 2, '更新AI密钥', '编辑API Key', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/delete-key', 2, '删除AI密钥', '删除API Key', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/generation-list', 1, 'AI生成列表', 'AI生成历史列表', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/generation-detail', 1, 'AI生成详情', 'AI生成历史详情', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('code/generation', 2, '代码生成', '按模块生成代码', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/batch-generation', 2, '批量代码生成', '批量生成代码', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/download', 0, '下载代码包', '下载生成结果', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/download-info', 0, '下载信息', '代码包下载信息', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/get-tables', 1, '代码生成表列表', '可选数据表', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/get-table-structure', 1, '代码生成表结构', '表结构供生成', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/generate-from-table', 2, '从表生成', '按表结构生成代码', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/ai-generate', 2, 'AI代码生成', 'AI辅助代码生成', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/ai-requirement-prompt', 2, 'AI需求提示词', '生成需求提示词', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('oss-config/driver-options', 1, 'OSS驱动选项', '可选存储驱动列表', 'OssConfigController', 0, 1, 1786329600, 0, 0),
('oss-config/runtime-options', 1, 'OSS运行时选项', '配置卡片+当前启用', 'OssConfigController', 0, 1, 1786329600, 0, 0),
('oss-config/save-runtime', 2, '启用OSS配置', '切换当前存储驱动', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('oss-config/list', 1, 'OSS配置列表', '存储配置列表', 'OssConfigController', 0, 1, 1786329600, 0, 0),
('oss-config/create', 2, '创建OSS配置', '新增存储配置', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('oss-config/update', 2, '更新OSS配置', '编辑存储配置', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('oss-config/delete', 2, '删除OSS配置', '删除存储配置', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('api-endpoint/list', 1, '接口列表', '接口注册表分页', 'ApiEndpointController', 0, 1, 1786329600, 0, 0),
('api-endpoint/update', 2, '更新接口', '编辑接口说明/是否记日志', 'ApiEndpointController', 1, 1, 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_login_log
-- ----------------------------
DROP TABLE IF EXISTS `nl_login_log`;
CREATE TABLE `nl_login_log` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int NOT NULL DEFAULT 0 COMMENT '管理员ID失败时可能为0',
`phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '登录账号/手机号',
`nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '昵称快照',
`ip` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '登录IP',
`equipment` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作系统',
`browser` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '浏览器',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0成功1失败',
`message` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '结果说明',
`created_at` int NOT NULL DEFAULT 0 COMMENT '登录时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_created`(`user_id` ASC, `created_at` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '登录日志' ROW_FORMAT = DYNAMIC;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -34,6 +34,8 @@ Route::group([], function () {
'upload' => \App\Http\Controllers\Api\UploadController::class, // 上传文件
'database' => \App\Http\Controllers\Api\DatabaseController::class, // 数据库管理
'ai-config' => \App\Http\Controllers\Api\AiConfigController::class, // AI 配置(平台/模型/密钥)
'oss-config' => \App\Http\Controllers\Api\OssConfigController::class, // OSS 存储配置
'api-endpoint' => \App\Http\Controllers\Api\ApiEndpointController::class, // 接口注册表
// 需要登录的路由生成地址
]);
});