diff --git a/app/Enum/OssDriverEnum.php b/app/Enum/OssDriverEnum.php new file mode 100644 index 00000000..6c6d45c7 --- /dev/null +++ b/app/Enum/OssDriverEnum.php @@ -0,0 +1,112 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/AdminController.php b/app/Http/Controllers/Api/AdminController.php index 93c7c4e9..26f2016f 100644 --- a/app/Http/Controllers/Api/AdminController.php +++ b/app/Http/Controllers/Api/AdminController.php @@ -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 diff --git a/app/Http/Controllers/Api/ApiEndpointController.php b/app/Http/Controllers/Api/ApiEndpointController.php new file mode 100644 index 00000000..1324e54a --- /dev/null +++ b/app/Http/Controllers/Api/ApiEndpointController.php @@ -0,0 +1,75 @@ +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('不支持'); + } +} diff --git a/app/Http/Controllers/Api/OssConfigController.php b/app/Http/Controllers/Api/OssConfigController.php new file mode 100644 index 00000000..e26c25da --- /dev/null +++ b/app/Http/Controllers/Api/OssConfigController.php @@ -0,0 +1,111 @@ +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()), '保存成功'); + } +} diff --git a/app/Http/Middleware/ApiOpLogMiddleware.php b/app/Http/Middleware/ApiOpLogMiddleware.php new file mode 100644 index 00000000..bca83ecd --- /dev/null +++ b/app/Http/Middleware/ApiOpLogMiddleware.php @@ -0,0 +1,156 @@ +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 + */ + 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; + } +} diff --git a/app/Models/ApiEndpointModel.php b/app/Models/ApiEndpointModel.php new file mode 100644 index 00000000..e90f00d1 --- /dev/null +++ b/app/Models/ApiEndpointModel.php @@ -0,0 +1,15 @@ +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 diff --git a/app/Service/ApiEndpointService.php b/app/Service/ApiEndpointService.php new file mode 100644 index 00000000..cb7ed8aa --- /dev/null +++ b/app/Service/ApiEndpointService.php @@ -0,0 +1,78 @@ +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); + } +} diff --git a/app/Service/LoginService.php b/app/Service/LoginService.php index da7c18ca..248c50d2 100644 --- a/app/Service/LoginService.php +++ b/app/Service/LoginService.php @@ -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) { + // 日志失败不影响登录主流程 + } + } } diff --git a/app/Service/OssConfigService.php b/app/Service/OssConfigService.php new file mode 100644 index 00000000..930f0b69 --- /dev/null +++ b/app/Service/OssConfigService.php @@ -0,0 +1,279 @@ +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; + } +} diff --git a/app/Service/common/UploadService.php b/app/Service/common/UploadService.php index 5047aa8a..f00cc502 100644 --- a/app/Service/common/UploadService.php +++ b/app/Service/common/UploadService.php @@ -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; } - } diff --git a/app/Service/common/UserAgentService.php b/app/Service/common/UserAgentService.php new file mode 100644 index 00000000..b01b096f --- /dev/null +++ b/app/Service/common/UserAgentService.php @@ -0,0 +1,57 @@ +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 => '其他', + }; + } +} diff --git a/app/Service/common/oss/OssRuntimeConfigService.php b/app/Service/common/oss/OssRuntimeConfigService.php new file mode 100644 index 00000000..26492f75 --- /dev/null +++ b/app/Service/common/oss/OssRuntimeConfigService.php @@ -0,0 +1,97 @@ +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, + ]; + } +} diff --git a/app/Service/common/oss/OssStorageFactory.php b/app/Service/common/oss/OssStorageFactory.php new file mode 100644 index 00000000..884e52d3 --- /dev/null +++ b/app/Service/common/oss/OssStorageFactory.php @@ -0,0 +1,47 @@ +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), + }; + } +} diff --git a/app/Service/common/oss/OssStorageInterface.php b/app/Service/common/oss/OssStorageInterface.php new file mode 100644 index 00000000..8be3acaf --- /dev/null +++ b/app/Service/common/oss/OssStorageInterface.php @@ -0,0 +1,34 @@ +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]; + } +} diff --git a/app/Service/common/upload/LocalhostStorageService.php b/app/Service/common/upload/LocalhostStorageService.php index b880371c..ada89ec3 100644 --- a/app/Service/common/upload/LocalhostStorageService.php +++ b/app/Service/common/upload/LocalhostStorageService.php @@ -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); diff --git a/app/Service/common/upload/QcloudStorageService.php b/app/Service/common/upload/QcloudStorageService.php new file mode 100644 index 00000000..fdaca01d --- /dev/null +++ b/app/Service/common/upload/QcloudStorageService.php @@ -0,0 +1,81 @@ +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]; + } +} diff --git a/app/Service/common/upload/QiniuStorageService.php b/app/Service/common/upload/QiniuStorageService.php index 4c477508..4d608eac 100644 --- a/app/Service/common/upload/QiniuStorageService.php +++ b/app/Service/common/upload/QiniuStorageService.php @@ -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; } } diff --git a/app/Service/common/upload/S3CompatibleStorageService.php b/app/Service/common/upload/S3CompatibleStorageService.php new file mode 100644 index 00000000..39d5b773 --- /dev/null +++ b/app/Service/common/upload/S3CompatibleStorageService.php @@ -0,0 +1,90 @@ +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]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 34e6bdd4..52e782db 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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) { diff --git a/config/nl.php b/config/nl.php index 889c6de3..bb275d92 100755 --- a/config/nl.php +++ b/config/nl.php @@ -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' => [ diff --git a/public/nl_admin.sql b/public/nl_admin.sql index 317a053c..4ba3a703 100644 --- a/public/nl_admin.sql +++ b/public/nl_admin.sql @@ -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; diff --git a/routes/api.php b/routes/api.php index 1df08366..348f1073 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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, // 接口注册表 // 需要登录的路由生成地址 ]); });