diff --git a/.env.example b/.env.example index c91c8cd..713058a 100644 --- a/.env.example +++ b/.env.example @@ -103,3 +103,6 @@ MONO_TIMEOUT=30 # Git Monitor Configuration GIT_MONITOR_PROJECTS="service,portal-be,agent-be" + +# Admin IP whitelist (comma separated, supports wildcard: 192.168.* or 192.168.1.*) +TOOLBOX_ADMIN_IPS= diff --git a/app/Http/Controllers/Admin/AdminMetaController.php b/app/Http/Controllers/Admin/AdminMetaController.php new file mode 100644 index 0000000..dbf81e8 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminMetaController.php @@ -0,0 +1,24 @@ +ip(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'is_admin' => IpAccess::isAdmin($ipAddress), + 'ip' => $ipAddress, + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/IpUserMappingController.php b/app/Http/Controllers/Admin/IpUserMappingController.php new file mode 100644 index 0000000..bf88b76 --- /dev/null +++ b/app/Http/Controllers/Admin/IpUserMappingController.php @@ -0,0 +1,92 @@ +orderBy('ip_address') + ->get(); + + $unmappedIps = OperationLog::query() + ->select( + 'operation_logs.ip_address', + DB::raw('COUNT(*) as logs_count'), + DB::raw('MAX(operation_logs.created_at) as last_seen_at') + ) + ->leftJoin('ip_user_mappings', 'operation_logs.ip_address', '=', 'ip_user_mappings.ip_address') + ->whereNull('ip_user_mappings.id') + ->where('operation_logs.ip_address', '!=', '') + ->groupBy('operation_logs.ip_address') + ->orderByDesc('last_seen_at') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'mappings' => $mappings, + 'unmapped_ips' => $unmappedIps, + ], + ]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'ip_address' => ['required', 'ip', 'max:64', 'unique:ip_user_mappings,ip_address'], + 'user_name' => ['required', 'string', 'max:128'], + 'remark' => ['nullable', 'string', 'max:255'], + ]); + + $mapping = IpUserMapping::query()->create($data); + + return response()->json([ + 'success' => true, + 'data' => [ + 'mapping' => $mapping, + ], + ]); + } + + public function update(Request $request, IpUserMapping $mapping): JsonResponse + { + $data = $request->validate([ + 'ip_address' => [ + 'required', + 'ip', + 'max:64', + Rule::unique('ip_user_mappings', 'ip_address')->ignore($mapping->id), + ], + 'user_name' => ['required', 'string', 'max:128'], + 'remark' => ['nullable', 'string', 'max:255'], + ]); + + $mapping->update($data); + + return response()->json([ + 'success' => true, + 'data' => [ + 'mapping' => $mapping->refresh(), + ], + ]); + } + + public function destroy(IpUserMapping $mapping): JsonResponse + { + $mapping->delete(); + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Http/Controllers/Admin/OperationLogController.php b/app/Http/Controllers/Admin/OperationLogController.php new file mode 100644 index 0000000..24cc09d --- /dev/null +++ b/app/Http/Controllers/Admin/OperationLogController.php @@ -0,0 +1,67 @@ +validate([ + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + 'q' => ['nullable', 'string', 'max:255'], + 'method' => ['nullable', 'string', 'max:10'], + ]); + + $query = OperationLog::query()->orderByDesc('id'); + $isAdmin = IpAccess::isAdmin($request->ip()); + + $search = trim((string) ($data['q'] ?? '')); + if ($search !== '') { + $query->where(function ($builder) use ($search, $isAdmin): void { + $builder->where('ip_address', 'like', "%{$search}%") + ->orWhere('path', 'like', "%{$search}%") + ->orWhere('route_name', 'like', "%{$search}%"); + + if ($isAdmin) { + $builder->orWhere('user_label', 'like', "%{$search}%"); + } + }); + } + + $method = strtoupper((string) ($data['method'] ?? '')); + if ($method !== '') { + $query->where('method', $method); + } + + $perPage = (int) ($data['per_page'] ?? 50); + $logs = $query->paginate($perPage); + $items = collect($logs->items())->map(function (OperationLog $log) use ($isAdmin): array { + $payload = $log->toArray(); + if (!$isAdmin) { + $payload['user_label'] = null; + } + + return $payload; + })->all(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'logs' => $items, + 'pagination' => [ + 'current_page' => $logs->currentPage(), + 'last_page' => $logs->lastPage(), + 'per_page' => $logs->perPage(), + 'total' => $logs->total(), + ], + ], + ]); + } +} diff --git a/app/Http/Controllers/SqlGeneratorController.php b/app/Http/Controllers/SqlGeneratorController.php new file mode 100644 index 0000000..d0b55de --- /dev/null +++ b/app/Http/Controllers/SqlGeneratorController.php @@ -0,0 +1,66 @@ +validate([ + 'case_codes' => 'required|array|min:1', + 'case_codes.*' => 'required|string|max:255', + ]); + + $caseCodes = array_values(array_unique(array_filter(array_map('trim', $request->input('case_codes'))))); + + if (empty($caseCodes)) { + return response()->json([ + 'success' => false, + 'message' => '请提供有效的 case_id 列表' + ], 400); + } + + $existingCaseCodes = []; + foreach (array_chunk($caseCodes, 1000) as $chunk) { + $results = DB::connection('agentslave') + ->table('case_extras') + ->where('source', 'ob') + ->where('field', 'OB Case ID') + ->whereIn('case_code', $chunk) + ->pluck('case_code') + ->all(); + + $existingCaseCodes = array_merge($existingCaseCodes, $results); + } + + $existingCaseCodes = array_values(array_unique(array_map('strval', $existingCaseCodes))); + + return response()->json([ + 'success' => true, + 'data' => [ + 'existing_case_codes' => $existingCaseCodes, + ], + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'message' => '请求参数验证失败', + 'errors' => $e->errors(), + ], 422); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '查询 case_extras 失败: ' . $e->getMessage(), + ], 500); + } + } +} diff --git a/app/Http/Middleware/AdminIpMiddleware.php b/app/Http/Middleware/AdminIpMiddleware.php new file mode 100644 index 0000000..fe76a97 --- /dev/null +++ b/app/Http/Middleware/AdminIpMiddleware.php @@ -0,0 +1,30 @@ +ip())) { + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json([ + 'success' => false, + 'message' => '无权限访问', + ], 403); + } + + abort(403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/OperationLogMiddleware.php b/app/Http/Middleware/OperationLogMiddleware.php new file mode 100644 index 0000000..472d1ad --- /dev/null +++ b/app/Http/Middleware/OperationLogMiddleware.php @@ -0,0 +1,129 @@ +recordLog($request, null, $startAt, 500); + throw $exception; + } + + $this->recordLog($request, $response, $startAt, $response->getStatusCode()); + + return $response; + } + + private function shouldLog(Request $request): bool + { + $methods = config('toolbox.operation_log.methods', []); + if (!in_array($request->method(), $methods, true)) { + return false; + } + + return $request->is('api/*'); + } + + private function recordLog(Request $request, ?Response $response, float $startAt, int $statusCode): void + { + if (!$this->shouldLog($request)) { + return; + } + + try { + $ipAddress = $request->ip() ?? ''; + $mapping = $ipAddress !== '' + ? IpUserMapping::query()->where('ip_address', $ipAddress)->first() + : null; + + OperationLog::query()->create([ + 'ip_address' => $ipAddress, + 'user_label' => $mapping?->user_name, + 'method' => $request->method(), + 'path' => '/' . ltrim($request->path(), '/'), + 'route_name' => $request->route()?->getName(), + 'status_code' => $statusCode, + 'duration_ms' => (int) round((microtime(true) - $startAt) * 1000), + 'request_payload' => $this->buildPayload($request), + 'user_agent' => substr((string) $request->userAgent(), 0, 255), + ]); + } catch (Throwable $exception) { + Log::warning('Failed to record operation log.', [ + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * @return array + */ + private function buildPayload(Request $request): array + { + $payload = $request->all(); + if (empty($payload)) { + return []; + } + + $redactKeys = array_map('strtolower', config('toolbox.operation_log.redact_keys', [])); + $payload = $this->redactPayload($payload, $redactKeys); + + $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + return ['_unserializable' => true]; + } + + $maxLength = (int) config('toolbox.operation_log.max_payload_length', 2000); + if (strlen($encoded) <= $maxLength) { + return $payload; + } + + return [ + '_truncated' => true, + 'preview' => substr($encoded, 0, $maxLength), + ]; + } + + /** + * @param array $payload + * @param array $redactKeys + * @return array + */ + private function redactPayload(array $payload, array $redactKeys): array + { + $redacted = []; + + foreach ($payload as $key => $value) { + $lowerKey = strtolower((string) $key); + if (in_array($lowerKey, $redactKeys, true)) { + $redacted[$key] = '[redacted]'; + continue; + } + + if (is_array($value)) { + $redacted[$key] = $this->redactPayload($value, $redactKeys); + continue; + } + + $redacted[$key] = $value; + } + + return $redacted; + } +} diff --git a/app/Models/IpUserMapping.php b/app/Models/IpUserMapping.php new file mode 100644 index 0000000..cf72de8 --- /dev/null +++ b/app/Models/IpUserMapping.php @@ -0,0 +1,17 @@ + 'array', + ]; +} diff --git a/app/Support/IpAccess.php b/app/Support/IpAccess.php new file mode 100644 index 0000000..be1de7f --- /dev/null +++ b/app/Support/IpAccess.php @@ -0,0 +1,50 @@ + + */ + public static function adminIps(): array + { + return config('toolbox.admin_ips', []); + } + + public static function isAdmin(?string $ip): bool + { + if (!is_string($ip) || $ip === '') { + return false; + } + + $adminIps = self::adminIps(); + if (empty($adminIps)) { + return false; + } + + foreach ($adminIps as $pattern) { + if ($pattern === $ip) { + return true; + } + + if (str_contains($pattern, '*') && self::matchWildcard($pattern, $ip)) { + return true; + } + } + + return false; + } + + private static function matchWildcard(string $pattern, string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + return false; + } + + $escaped = preg_quote($pattern, '#'); + $regex = str_replace('\*', '[0-9.]*', $escaped); + + return (bool) preg_match('#^' . $regex . '$#', $ip); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 3467fe7..e1f558b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,12 +13,19 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'admin.ip' => \App\Http\Middleware\AdminIpMiddleware::class, + ]); + + $middleware->appendToGroup('api', \App\Http\Middleware\OperationLogMiddleware::class); + // 为 API 路由添加 CSRF 豁免 $middleware->validateCsrfTokens(except: [ 'api/jira/*', 'api/env/*', 'api/message-sync/*', - 'api/message-dispatch/*' + 'api/message-dispatch/*', + 'api/admin/*', ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/toolbox.php b/config/toolbox.php new file mode 100644 index 0000000..5cd89d8 --- /dev/null +++ b/config/toolbox.php @@ -0,0 +1,19 @@ + array_values(array_filter(array_map( + static fn(string $ip): string => trim($ip), + explode(',', (string) env('TOOLBOX_ADMIN_IPS', '')) + ))), + 'operation_log' => [ + 'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'], + 'max_payload_length' => 2000, + 'redact_keys' => [ + 'content', + 'password', + 'token', + 'authorization', + 'api_token', + ], + ], +]; diff --git a/database/migrations/2025_12_11_000000_create_ip_user_mappings_table.php b/database/migrations/2025_12_11_000000_create_ip_user_mappings_table.php new file mode 100644 index 0000000..3d93a69 --- /dev/null +++ b/database/migrations/2025_12_11_000000_create_ip_user_mappings_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('ip_address', 64)->unique(); + $table->string('user_name', 128); + $table->string('remark', 255)->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ip_user_mappings'); + } +}; diff --git a/database/migrations/2025_12_11_000001_create_operation_logs_table.php b/database/migrations/2025_12_11_000001_create_operation_logs_table.php new file mode 100644 index 0000000..4aa3fed --- /dev/null +++ b/database/migrations/2025_12_11_000001_create_operation_logs_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('ip_address', 64); + $table->string('user_label', 128)->nullable(); + $table->string('method', 10); + $table->string('path', 255); + $table->string('route_name', 255)->nullable(); + $table->unsignedSmallInteger('status_code'); + $table->unsignedInteger('duration_ms')->default(0); + $table->json('request_payload')->nullable(); + $table->string('user_agent', 255)->nullable(); + $table->timestamps(); + + $table->index(['ip_address', 'created_at']); + $table->index(['user_label', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('operation_logs'); + } +}; diff --git a/resources/js/components/admin/AdminDashboard.vue b/resources/js/components/admin/AdminDashboard.vue index 32914bc..dc1f281 100644 --- a/resources/js/components/admin/AdminDashboard.vue +++ b/resources/js/components/admin/AdminDashboard.vue @@ -1,6 +1,7 @@ @@ -59,11 +61,14 @@ import AdminLayout from './AdminLayout.vue'; import EnvManagement from '../env/EnvManagement.vue'; import WeeklyReport from '../jira/WeeklyReport.vue'; +import SqlGenerator from '../tools/SqlGenerator.vue'; import JiraWorklog from '../jira/JiraWorklog.vue'; import MessageSync from '../message-sync/MessageSync.vue'; import EventConsumerSync from '../message-sync/EventConsumerSync.vue'; import MessageDispatch from '../message-sync/MessageDispatch.vue'; import SystemSettings from './SystemSettings.vue'; +import OperationLogs from './OperationLogs.vue'; +import IpUserMappings from './IpUserMappings.vue'; export default { name: 'AdminDashboard', @@ -71,38 +76,62 @@ export default { AdminLayout, EnvManagement, WeeklyReport, + SqlGenerator, JiraWorklog, MessageSync, EventConsumerSync, MessageDispatch, - SystemSettings + SystemSettings, + OperationLogs, + IpUserMappings }, data() { return { currentPage: 'env', - pageTitle: '环境配置管理' + pageTitle: '环境配置管理', + isAdmin: false, + currentIp: '' } }, - mounted() { - console.log('AdminDashboard mounted'); - - // 根据 URL 路径设置初始页面 + async mounted() { + await this.loadAdminMeta(); this.setCurrentPageFromPath(); }, methods: { + async loadAdminMeta() { + try { + const response = await fetch('/api/admin/meta'); + const data = await response.json(); + + if (data.success) { + this.isAdmin = Boolean(data.data.is_admin); + this.currentIp = data.data.ip || ''; + } + } catch (error) { + this.isAdmin = false; + this.currentIp = ''; + } + }, handleMenuChange(menu) { + if (menu === 'ip-mappings' && !this.isAdmin) { + this.redirectToDefault(); + return; + } + this.currentPage = menu; // 更新页面标题 const titles = { 'env': '环境配置管理', 'weekly-report': '生成周报', + 'sql-generator': '生成SQL', 'worklog': 'JIRA 工时查询', 'message-sync': '消息同步', 'event-consumer-sync': '事件消费者同步对比', 'message-dispatch': '消息分发异常查询', 'settings': '系统设置', - 'logs': '操作日志' + 'logs': '操作日志', + 'ip-mappings': 'IP 用户映射' }; this.pageTitle = titles[menu] || '环境配置管理'; @@ -114,6 +143,8 @@ export default { if (path === '/') { page = 'env'; + } else if (path === '/sql-generator') { + page = 'sql-generator'; } else if (path === '/weekly-report') { page = 'weekly-report'; } else if (path === '/worklog') { @@ -128,10 +159,25 @@ export default { page = 'settings'; } else if (path === '/logs') { page = 'logs'; + } else if (path === '/ip-mappings') { + page = 'ip-mappings'; + } + + if (page === 'ip-mappings' && !this.isAdmin) { + this.redirectToDefault(); + return; } this.currentPage = page; this.handleMenuChange(page); + }, + + redirectToDefault() { + this.currentPage = 'env'; + this.pageTitle = '环境配置管理'; + if (window.location.pathname !== '/') { + window.history.replaceState({}, '', '/'); + } } } } diff --git a/resources/js/components/admin/AdminLayout.vue b/resources/js/components/admin/AdminLayout.vue index 61f1902..8609511 100644 --- a/resources/js/components/admin/AdminLayout.vue +++ b/resources/js/components/admin/AdminLayout.vue @@ -44,6 +44,30 @@ 环境配置管理 + + + + + 生成SQL + + 操作日志 + + + + + + IP 用户映射 + @@ -244,6 +293,10 @@ export default { pageTitle: { type: String, default: '环境配置管理' + }, + isAdmin: { + type: Boolean, + default: false } }, data() { @@ -251,6 +304,11 @@ export default { activeMenu: 'env' } }, + watch: { + isAdmin() { + this.setActiveMenuFromPath(); + } + }, mounted() { // 根据 URL 路径设置初始菜单 this.setActiveMenuFromPath(); @@ -263,6 +321,10 @@ export default { }, methods: { setActiveMenu(menu) { + if (menu === 'ip-mappings' && !this.isAdmin) { + return; + } + this.activeMenu = menu; // 更新 URL 路径 const path = menu === 'env' ? '/' : `/${menu}`; @@ -276,6 +338,8 @@ export default { if (path === '/') { menu = 'env'; + } else if (path === '/sql-generator') { + menu = 'sql-generator'; } else if (path === '/weekly-report') { menu = 'weekly-report'; } else if (path === '/worklog') { @@ -290,6 +354,12 @@ export default { menu = 'settings'; } else if (path === '/logs') { menu = 'logs'; + } else if (path === '/ip-mappings') { + menu = 'ip-mappings'; + } + + if (menu === 'ip-mappings' && !this.isAdmin) { + menu = 'env'; } this.activeMenu = menu; @@ -314,9 +384,5 @@ export default { transform: translateX(-100%); transition: transform 0.3s ease-in-out; } - - .admin-layout .fixed.w-64.open { - transform: translateX(0); - } } diff --git a/resources/js/components/admin/IpUserMappings.vue b/resources/js/components/admin/IpUserMappings.vue new file mode 100644 index 0000000..32bc956 --- /dev/null +++ b/resources/js/components/admin/IpUserMappings.vue @@ -0,0 +1,342 @@ + + + diff --git a/resources/js/components/admin/OperationLogs.vue b/resources/js/components/admin/OperationLogs.vue new file mode 100644 index 0000000..7198e1f --- /dev/null +++ b/resources/js/components/admin/OperationLogs.vue @@ -0,0 +1,216 @@ + + + diff --git a/resources/js/components/tools/SqlGenerator.vue b/resources/js/components/tools/SqlGenerator.vue new file mode 100644 index 0000000..27d3606 --- /dev/null +++ b/resources/js/components/tools/SqlGenerator.vue @@ -0,0 +1,447 @@ + + + + + diff --git a/routes/api.php b/routes/api.php index 3cc0b2a..6e16f72 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,10 @@ use App\Http\Controllers\EnvController; use App\Http\Controllers\JiraController; use App\Http\Controllers\MessageSyncController; use App\Http\Controllers\MessageDispatchController; +use App\Http\Controllers\SqlGeneratorController; +use App\Http\Controllers\Admin\AdminMetaController; +use App\Http\Controllers\Admin\IpUserMappingController; +use App\Http\Controllers\Admin\OperationLogController; // 环境管理API路由 Route::prefix('env')->group(function () { @@ -21,6 +25,11 @@ Route::prefix('env')->group(function () { Route::delete('/projects/{project}/envs/{env}', [EnvController::class, 'deleteEnv']); }); +// SQL 生成器 API 路由 +Route::prefix('sql-generator')->group(function () { + Route::post('/ob-external-id/check', [SqlGeneratorController::class, 'checkObExternalId']); +}); + // JIRA API路由 Route::prefix('jira')->group(function () { Route::get('/config', [JiraController::class, 'getConfig']); @@ -48,3 +57,17 @@ Route::prefix('message-dispatch')->group(function () { Route::get('/abnormal', [MessageDispatchController::class, 'getAbnormalDispatches']); Route::post('/batch-update', [MessageDispatchController::class, 'batchUpdateDispatch']); }); + +// 操作日志(所有人可查看,用户名字段对非管理员隐藏) +Route::get('/operation-logs', [OperationLogController::class, 'index']); + +// 管理员信息(不受白名单限制) +Route::get('/admin/meta', [AdminMetaController::class, 'show']); + +// 管理员IP白名单限定的后台接口 +Route::prefix('admin')->middleware('admin.ip')->group(function () { + Route::get('/ip-user-mappings', [IpUserMappingController::class, 'index']); + Route::post('/ip-user-mappings', [IpUserMappingController::class, 'store']); + Route::put('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'update']); + Route::delete('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'destroy']); +}); diff --git a/routes/web.php b/routes/web.php index f1f33bd..5e8b09a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ Route::get('/', [AdminController::class, 'index'])->name('home'); // 前端路由 - 所有页面都通过admin框架显示 Route::get('/env', [AdminController::class, 'index'])->name('admin.env'); +Route::get('/sql-generator', [AdminController::class, 'index'])->name('admin.sql-generator'); Route::get('/weekly-report', [AdminController::class, 'index'])->name('admin.weekly-report'); Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog'); Route::get('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync'); @@ -15,3 +16,4 @@ Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('adm Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch'); Route::get('/settings', [AdminController::class, 'index'])->name('admin.settings'); Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs'); +Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');