From 381d5e6e4949d324606deb83a9931f5ddadf70af Mon Sep 17 00:00:00 2001 From: tradewind Date: Fri, 16 Jan 2026 12:14:43 +0800 Subject: [PATCH] #feature: add project&cronjob management --- .../Controllers/Admin/ProjectController.php | 260 +++++++ .../Admin/ScheduledTaskController.php | 55 ++ app/Models/Project.php | 89 +++ app/Services/CodeContextService.php | 25 +- app/Services/GitMonitorService.php | 227 ++++-- app/Services/JiraService.php | 3 + app/Services/ProjectService.php | 302 ++++++++ app/Services/ScheduledTaskService.php | 133 ++++ ...026_01_15_154022_create_projects_table.php | 47 ++ ...rate_project_configs_to_projects_table.php | 103 +++ ...245_add_is_important_to_projects_table.php | 22 + ...reate_release_branch_to_projects_table.php | 28 + .../js/components/admin/AdminDashboard.vue | 24 +- resources/js/components/admin/AdminLayout.vue | 61 +- .../js/components/admin/ProjectManagement.vue | 653 ++++++++++++++++++ .../js/components/admin/ScheduledTasks.vue | 141 ++++ routes/api.php | 18 + routes/console.php | 48 +- routes/web.php | 2 + 19 files changed, 2157 insertions(+), 84 deletions(-) create mode 100644 app/Http/Controllers/Admin/ProjectController.php create mode 100644 app/Http/Controllers/Admin/ScheduledTaskController.php create mode 100644 app/Models/Project.php create mode 100644 app/Services/ProjectService.php create mode 100644 app/Services/ScheduledTaskService.php create mode 100644 database/migrations/2026_01_15_154022_create_projects_table.php create mode 100644 database/migrations/2026_01_15_154113_migrate_project_configs_to_projects_table.php create mode 100644 database/migrations/2026_01_15_163245_add_is_important_to_projects_table.php create mode 100644 database/migrations/2026_01_16_103851_add_auto_create_release_branch_to_projects_table.php create mode 100644 resources/js/components/admin/ProjectManagement.vue create mode 100644 resources/js/components/admin/ScheduledTasks.vue diff --git a/app/Http/Controllers/Admin/ProjectController.php b/app/Http/Controllers/Admin/ProjectController.php new file mode 100644 index 0000000..12c6016 --- /dev/null +++ b/app/Http/Controllers/Admin/ProjectController.php @@ -0,0 +1,260 @@ +projectService->getAllProjects(); + $projectsPath = $this->projectService->getProjectsPath(); + + // 为每个项目添加状态信息 + $projectsWithStatus = $projects->map(function (Project $project) use ($projectsPath) { + $data = $project->toArray(); + $data['path_valid'] = $project->isPathValid($projectsPath); + $data['full_path'] = $project->getFullPath($projectsPath); + return $data; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'projects' => $projectsWithStatus, + 'projects_path' => $projectsPath, + ], + ]); + } + + /** + * 获取单个项目 + */ + public function show(string $slug): JsonResponse + { + $project = $this->projectService->getBySlug($slug); + + if (!$project) { + return response()->json([ + 'success' => false, + 'message' => '项目不存在', + ], 404); + } + + $status = $this->projectService->getProjectStatus($project); + + return response()->json([ + 'success' => true, + 'data' => [ + 'project' => $project, + 'status' => $status, + ], + ]); + } + + /** + * 创建项目 + */ + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'slug' => ['required', 'string', 'max:100', 'unique:projects,slug', 'regex:/^[a-z0-9\-_]+$/'], + 'name' => ['required', 'string', 'max:255'], + 'directory' => ['nullable', 'string', 'max:255'], + 'absolute_path' => ['nullable', 'string', 'max:500'], + 'jira_project_code' => ['nullable', 'string', 'max:50'], + 'git_monitor_enabled' => ['nullable', 'boolean'], + 'auto_create_release_branch' => ['nullable', 'boolean'], + 'is_important' => ['nullable', 'boolean'], + 'log_app_names' => ['nullable', 'array'], + 'log_app_names.*' => ['string', 'max:100'], + 'log_env' => ['nullable', 'string', 'max:50'], + ]); + + $project = $this->projectService->create($data); + + return response()->json([ + 'success' => true, + 'data' => [ + 'project' => $project, + ], + ]); + } + + /** + * 更新项目 + */ + public function update(Request $request, string $slug): JsonResponse + { + $project = $this->projectService->getBySlug($slug); + + if (!$project) { + return response()->json([ + 'success' => false, + 'message' => '项目不存在', + ], 404); + } + + $data = $request->validate([ + 'slug' => [ + 'sometimes', + 'string', + 'max:100', + 'regex:/^[a-z0-9\-_]+$/', + Rule::unique('projects', 'slug')->ignore($project->id), + ], + 'name' => ['sometimes', 'string', 'max:255'], + 'directory' => ['nullable', 'string', 'max:255'], + 'absolute_path' => ['nullable', 'string', 'max:500'], + 'jira_project_code' => ['nullable', 'string', 'max:50'], + 'git_monitor_enabled' => ['nullable', 'boolean'], + 'auto_create_release_branch' => ['nullable', 'boolean'], + 'is_important' => ['nullable', 'boolean'], + 'log_app_names' => ['nullable', 'array'], + 'log_app_names.*' => ['string', 'max:100'], + 'log_env' => ['nullable', 'string', 'max:50'], + ]); + + $project = $this->projectService->update($project, $data); + + return response()->json([ + 'success' => true, + 'data' => [ + 'project' => $project, + ], + ]); + } + + /** + * 删除项目 + */ + public function destroy(string $slug): JsonResponse + { + $project = $this->projectService->getBySlug($slug); + + if (!$project) { + return response()->json([ + 'success' => false, + 'message' => '项目不存在', + ], 404); + } + + $this->projectService->delete($project); + + return response()->json([ + 'success' => true, + ]); + } + + /** + * 获取项目状态 + */ + public function status(string $slug): JsonResponse + { + $project = $this->projectService->getBySlug($slug); + + if (!$project) { + return response()->json([ + 'success' => false, + 'message' => '项目不存在', + ], 404); + } + + $status = $this->projectService->getProjectStatus($project); + + return response()->json([ + 'success' => true, + 'data' => [ + 'status' => $status, + ], + ]); + } + + /** + * 同步 Git 信息 + */ + public function sync(string $slug): JsonResponse + { + $project = $this->projectService->getBySlug($slug); + + if (!$project) { + return response()->json([ + 'success' => false, + 'message' => '项目不存在', + ], 404); + } + + $project = $this->projectService->syncGitInfo($project); + + return response()->json([ + 'success' => true, + 'data' => [ + 'project' => $project, + ], + ]); + } + + /** + * 发现新项目 + */ + public function discover(): JsonResponse + { + $discovered = $this->projectService->discoverProjects(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'discovered' => $discovered, + ], + ]); + } + + /** + * 批量添加发现的项目 + */ + public function addDiscovered(Request $request): JsonResponse + { + $data = $request->validate([ + 'slugs' => ['required', 'array', 'min:1'], + 'slugs.*' => ['string', 'max:100'], + ]); + + $added = $this->projectService->addDiscoveredProjects($data['slugs']); + + return response()->json([ + 'success' => true, + 'data' => [ + 'added' => $added, + ], + ]); + } + + /** + * 一键同步所有项目的 Git 信息 + */ + public function syncAll(): JsonResponse + { + $results = $this->projectService->syncAllGitInfo(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'synced' => $results['synced'], + 'failed' => $results['failed'], + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/ScheduledTaskController.php b/app/Http/Controllers/Admin/ScheduledTaskController.php new file mode 100644 index 0000000..145ffbe --- /dev/null +++ b/app/Http/Controllers/Admin/ScheduledTaskController.php @@ -0,0 +1,55 @@ +json([ + 'success' => true, + 'data' => [ + 'tasks' => $this->taskService->getAllTasks(), + ], + ]); + } + + /** + * 切换定时任务启用状态 + */ + public function toggle(Request $request, string $name): JsonResponse + { + $validated = $request->validate([ + 'enabled' => 'required|boolean', + ]); + + try { + $this->taskService->setTaskEnabled($name, $validated['enabled']); + + return response()->json([ + 'success' => true, + 'data' => [ + 'enabled' => $validated['enabled'], + ], + 'message' => $validated['enabled'] ? '任务已启用' : '任务已禁用', + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..68067aa --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,89 @@ + 'boolean', + 'auto_create_release_branch' => 'boolean', + 'is_important' => 'boolean', + 'log_app_names' => 'array', + 'git_version_cached_at' => 'datetime', + ]; + + /** + * 获取项目的完整路径 + */ + public function getFullPath(string $projectsPath): string + { + if (!empty($this->absolute_path)) { + return rtrim($this->absolute_path, '/'); + } + + $directory = $this->directory ?? $this->slug; + return rtrim($projectsPath, '/') . '/' . ltrim($directory, '/'); + } + + /** + * 检查项目路径是否有效 + */ + public function isPathValid(string $projectsPath): bool + { + $path = $this->getFullPath($projectsPath); + return is_dir($path); + } + + /** + * 检查是否是有效的 Git 仓库 + */ + public function isGitRepository(string $projectsPath): bool + { + $path = $this->getFullPath($projectsPath); + return is_dir($path . DIRECTORY_SEPARATOR . '.git'); + } + + /** + * 根据 slug 查找项目 + */ + public static function findBySlug(string $slug): ?self + { + return static::query()->where('slug', $slug)->first(); + } + + /** + * 获取所有启用 Git 监控的项目 + */ + public static function getGitMonitorEnabled(): \Illuminate\Database\Eloquent\Collection + { + return static::query()->where('git_monitor_enabled', true)->get(); + } + + /** + * 根据 app 名称查找项目 + */ + public static function findByAppName(string $appName): ?self + { + return static::query() + ->whereJsonContains('log_app_names', $appName) + ->first(); + } +} diff --git a/app/Services/CodeContextService.php b/app/Services/CodeContextService.php index bb9518f..592f7a8 100644 --- a/app/Services/CodeContextService.php +++ b/app/Services/CodeContextService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Project; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; @@ -22,6 +23,24 @@ class CodeContextService */ public function getRepoPath(string $appName): ?string { + // 优先从 Project 模型查找 + $project = Project::findByAppName($appName); + + if ($project) { + $env = $project->log_env ?? 'production'; + try { + $envContent = $this->envService->getEnvContent($project->slug, $env); + $repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH'); + + if ($repoPath && is_dir($repoPath)) { + return $repoPath; + } + } catch (\Exception $e) { + // 忽略错误,继续尝试旧配置 + } + } + + // 回退到旧的配置方式(兼容迁移前的情况) $appEnvMap = $this->configService->get('log_analysis.app_env_map', []); if (!isset($appEnvMap[$appName])) { @@ -29,15 +48,15 @@ class CodeContextService } $mapping = $appEnvMap[$appName]; - $project = $mapping['project'] ?? null; + $projectSlug = $mapping['project'] ?? null; $env = $mapping['env'] ?? null; - if (!$project || !$env) { + if (!$projectSlug || !$env) { return null; } try { - $envContent = $this->envService->getEnvContent($project, $env); + $envContent = $this->envService->getEnvContent($projectSlug, $env); $repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH'); if ($repoPath && is_dir($repoPath)) { diff --git a/app/Services/GitMonitorService.php b/app/Services/GitMonitorService.php index 2b3ee31..36450d0 100644 --- a/app/Services/GitMonitorService.php +++ b/app/Services/GitMonitorService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Project; use Carbon\Carbon; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; @@ -10,9 +11,8 @@ use Symfony\Component\Process\Process; class GitMonitorService { - private const RELEASE_CACHE_KEY = 'git_monitor.current_versions'; - private const LAST_CHECKED_KEY = 'git_monitor.last_checked_commits'; private const DEVELOP_BRANCH = 'develop'; + private const RELEASE_CACHE_KEY = 'git-monitor.release_cache'; /** * 项目配置(只包含允许巡检的项目) @@ -30,12 +30,6 @@ class GitMonitorService ) { $this->projectsPath = $this->resolveProjectsPath(); $this->projects = $this->resolveProjects(); - - $enabled = config('git-monitor.enabled_projects', []); - if (!empty($enabled)) { - $this->projects = array_intersect_key($this->projects, array_flip($enabled)); - } - $this->commitScanLimit = (int) config('git-monitor.commit_scan_limit', 30); $this->gitTimeout = (int) config('git-monitor.git_timeout', 180); } @@ -46,10 +40,11 @@ class GitMonitorService return []; } + // 检查是否需要刷新缓存 if (!$force) { - $cached = $this->configService->get(self::RELEASE_CACHE_KEY); - if (!empty($cached) && !$this->shouldRefreshCache($cached)) { - return $cached; + $anyProject = Project::query()->whereNotNull('git_version_cached_at')->first(); + if ($anyProject && !$this->shouldRefreshCache($anyProject->git_version_cached_at)) { + return $this->buildCachePayload(); } } @@ -72,12 +67,29 @@ class GitMonitorService // 根据当前版本号获取下一个版本 $version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion); if ($version) { + $branch = 'release/' . $version['version']; $payload['repositories'][$repoKey] = [ 'version' => $version['version'], + 'description' => $version['description'] ?? null, 'release_date' => $version['release_date'], - 'branch' => 'release/' . $version['version'], + 'branch' => $branch, 'current_version' => $currentVersion, ]; + + // 更新 Project 模型 + $project = Project::findBySlug($repoKey); + if ($project) { + $project->update([ + 'git_current_version' => $currentVersion, + 'git_release_branch' => $branch, + 'git_version_cached_at' => now(), + ]); + + // 如果启用了自动创建 release 分支,检查并创建 + if ($project->auto_create_release_branch) { + $this->ensureReleaseBranchExists($repoKey, $repoConfig, $branch, $version['description']); + } + } } } catch (\Throwable $e) { Log::error('Failed to fetch release version from Jira', [ @@ -87,24 +99,44 @@ class GitMonitorService } } - $this->configService->set( - self::RELEASE_CACHE_KEY, - $payload, - 'Cached release versions fetched from Jira' - ); - return $payload; } public function ensureReleaseCache(): array { - $cached = $this->configService->get(self::RELEASE_CACHE_KEY); + $anyProject = Project::query()->whereNotNull('git_version_cached_at')->first(); - if (empty($cached) || $this->shouldRefreshCache($cached)) { + if (!$anyProject || $this->shouldRefreshCache($anyProject->git_version_cached_at)) { return $this->refreshReleaseCache(true); } - return $cached; + return $this->buildCachePayload(); + } + + /** + * 从 Project 模型构建缓存数据 + */ + private function buildCachePayload(): array + { + $projects = Project::query() + ->where('git_monitor_enabled', true) + ->whereNotNull('git_release_branch') + ->get(); + + $payload = [ + 'cached_at' => $projects->first()?->git_version_cached_at?->toDateTimeString() ?? now()->toDateTimeString(), + 'repositories' => [], + ]; + + foreach ($projects as $project) { + $payload['repositories'][$project->slug] = [ + 'version' => $project->git_current_version, + 'branch' => $project->git_release_branch, + 'current_version' => $project->git_current_version, + ]; + } + + return $payload; } public function checkRepositories(bool $refreshCacheIfNeeded = true): array @@ -151,15 +183,15 @@ class GitMonitorService return $results; } - private function shouldRefreshCache(array $cached): bool + private function shouldRefreshCache(Carbon|\DateTimeInterface|string|null $cachedAt): bool { - $cachedAt = Arr::get($cached, 'cached_at'); if (empty($cachedAt)) { return true; } try { - return Carbon::parse($cachedAt)->lt(Carbon::now()->startOfDay()); + $cachedTime = $cachedAt instanceof Carbon ? $cachedAt : Carbon::parse($cachedAt); + return $cachedTime->lt(Carbon::now()->startOfDay()); } catch (\Throwable) { return true; } @@ -177,7 +209,9 @@ class GitMonitorService $remoteBranch = 'origin/' . $branch; $head = $this->runGit($path, ['git', 'rev-parse', $remoteBranch]); - $lastChecked = $this->configService->getNested(self::LAST_CHECKED_KEY, $repoKey); + // 从 Project 模型获取 lastChecked + $project = Project::findBySlug($repoKey); + $lastChecked = $project?->git_last_checked_commit; $commits = $this->collectCommits($path, $remoteBranch, $lastChecked); $issues = [ @@ -378,14 +412,10 @@ class GitMonitorService private function updateLastChecked(string $repoKey, string $sha): void { - $current = $this->configService->get(self::LAST_CHECKED_KEY, []); - $current[$repoKey] = $sha; - - $this->configService->set( - self::LAST_CHECKED_KEY, - $current, - 'Last scanned release commits' - ); + $project = Project::findBySlug($repoKey); + if ($project) { + $project->update(['git_last_checked_commit' => $sha]); + } } private function getCommitMetadata(string $repoPath, string $commit): array @@ -478,22 +508,37 @@ class GitMonitorService */ private function resolveProjects(): array { - $projects = $this->configService->get('workspace.repositories'); + // 优先从 Project 模型获取启用 Git 监控的项目 + $projectModels = Project::query() + ->where('git_monitor_enabled', true) + ->get(); - if ($projects === null) { - $fallback = $this->buildFallbackProjects(config('git-monitor.enabled_projects', [])); - if (!empty($fallback)) { - Log::warning('configs 表未设置 workspace.repositories,已使用 git-monitor.enabled_projects。'); + if ($projectModels->isNotEmpty()) { + $projects = []; + foreach ($projectModels as $project) { + $projects[$project->slug] = [ + 'jira_project' => $project->jira_project_code, + 'directory' => $project->directory ?? $project->slug, + 'path' => $project->absolute_path, + 'display' => $project->name, + ]; } - return $fallback; + return $projects; } - if (!is_array($projects)) { - Log::warning('configs 表 workspace.repositories 配置格式不正确,已降级使用 git-monitor.enabled_projects。'); - return $this->buildFallbackProjects(config('git-monitor.enabled_projects', [])); + // 回退到旧的配置方式(兼容迁移前的情况) + $configProjects = $this->configService->get('workspace.repositories'); + + if ($configProjects !== null && is_array($configProjects)) { + $enabled = config('git-monitor.enabled_projects', []); + if (!empty($enabled)) { + return array_intersect_key($configProjects, array_flip($enabled)); + } + return $configProjects; } - return $projects; + // 最后回退到环境变量配置 + return $this->buildFallbackProjects(config('git-monitor.enabled_projects', [])); } /** @@ -554,4 +599,100 @@ class GitMonitorService return null; } } + + /** + * 检查远程是否存在指定分支 + */ + private function remoteBranchExists(string $path, string $branch): bool + { + try { + $this->runGit($path, ['git', 'fetch', 'origin']); + $this->runGit($path, ['git', 'ls-remote', '--exit-code', '--heads', 'origin', $branch]); + return true; + } catch (ProcessFailedException) { + return false; + } + } + + /** + * 确保 release 分支存在,如果不存在则从 master 创建并推送 + */ + private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void + { + $path = $this->resolveProjectPath($repoKey, $repoConfig); + + if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) { + Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]); + return; + } + + // 检查远程分支是否已存在 + if ($this->remoteBranchExists($path, $branch)) { + Log::info('Release branch already exists on remote', ['repository' => $repoKey, 'branch' => $branch]); + return; + } + + // 从分支名中提取版本号(release/2.63.0.0 -> 2.63.0.0) + $version = str_replace('release/', '', $branch); + + try { + // 从 origin/master 创建新分支 + $this->runGit($path, ['git', 'fetch', 'origin', 'master']); + + // 创建本地分支(基于 origin/master) + try { + // 先尝试删除可能存在的本地分支 + $this->runGit($path, ['git', 'branch', '-D', $branch]); + } catch (ProcessFailedException) { + // 忽略,分支可能不存在 + } + + $this->runGit($path, ['git', 'checkout', '-b', $branch, 'origin/master']); + + // 修改 version.txt 文件 + $versionFile = $path . DIRECTORY_SEPARATOR . 'version.txt'; + if (!file_put_contents($versionFile, $version)) { + throw new \RuntimeException("Failed to write version.txt"); + } + + // 添加并提交更改 + $this->runGit($path, ['git', 'add', 'version.txt']); + + // 构建提交信息:分支名 + 空格 + Jira 描述 + $commitMessage = $branch . ($description ? ' ' . $description : ''); + $this->runGit($path, ['git', 'commit', '-m', $commitMessage]); + + // 推送到远程 + $this->runGit($path, ['git', 'push', '-u', 'origin', $branch]); + + Log::info('Created and pushed release branch', [ + 'repository' => $repoKey, + 'branch' => $branch, + 'version' => $version, + 'message' => $commitMessage, + ]); + + // 发送钉钉通知 + $this->dingTalkService->sendText(sprintf( + "【Release 分支创建】\n项目: %s\n分支: %s\n版本: %s\n描述: %s", + $repoConfig['display'] ?? $repoKey, + $branch, + $version, + $description ?? '无' + )); + } catch (ProcessFailedException $e) { + Log::error('Failed to create release branch', [ + 'repository' => $repoKey, + 'branch' => $branch, + 'error' => $e->getMessage(), + ]); + } finally { + // 切回 develop 分支,避免影响后续操作 + try { + $this->runGit($path, ['git', 'checkout', self::DEVELOP_BRANCH]); + } catch (ProcessFailedException) { + // 忽略 + } + } + } } diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php index bd3b3e5..d0e6227 100644 --- a/app/Services/JiraService.php +++ b/app/Services/JiraService.php @@ -798,6 +798,7 @@ class JiraService return [ 'version' => $candidate->name, + 'description' => $candidate->description ?? null, 'release_date' => !empty($candidate->releaseDate) ? Carbon::parse($candidate->releaseDate)->toDateString() : null, @@ -826,6 +827,7 @@ class JiraService return [ 'version' => $candidate->name, + 'description' => $candidate->description ?? null, 'release_date' => !empty($candidate->releaseDate) ? Carbon::parse($candidate->releaseDate)->toDateString() : null, @@ -844,6 +846,7 @@ class JiraService return [ 'version' => $candidate->name, + 'description' => $candidate->description ?? null, 'release_date' => !empty($candidate->releaseDate) ? Carbon::parse($candidate->releaseDate)->toDateString() : null, diff --git a/app/Services/ProjectService.php b/app/Services/ProjectService.php new file mode 100644 index 0000000..00514e0 --- /dev/null +++ b/app/Services/ProjectService.php @@ -0,0 +1,302 @@ +projectsPath = $this->resolveProjectsPath(); + } + + private function resolveProjectsPath(): string + { + $path = $this->configService->get('workspace.projects_path'); + + if (empty($path)) { + throw new RuntimeException('configs 表未设置 workspace.projects_path。'); + } + + return rtrim($path, '/'); + } + + /** + * 获取项目根目录路径 + */ + public function getProjectsPath(): string + { + return $this->projectsPath; + } + + /** + * 获取所有项目 + */ + public function getAllProjects(): Collection + { + return Project::query()->orderBy('name')->get(); + } + + /** + * 根据 slug 获取项目 + */ + public function getBySlug(string $slug): ?Project + { + return Project::findBySlug($slug); + } + + /** + * 创建项目 + */ + public function create(array $data): Project + { + return Project::query()->create($data); + } + + /** + * 更新项目 + */ + public function update(Project $project, array $data): Project + { + $project->update($data); + return $project->refresh(); + } + + /** + * 删除项目 + */ + public function delete(Project $project): bool + { + return $project->delete(); + } + + /** + * 获取项目状态 + */ + public function getProjectStatus(Project $project): array + { + $fullPath = $project->getFullPath($this->projectsPath); + $pathValid = $project->isPathValid($this->projectsPath); + $isGitRepo = $pathValid && $project->isGitRepository($this->projectsPath); + + $status = [ + 'path_valid' => $pathValid, + 'full_path' => $fullPath, + 'is_git_repo' => $isGitRepo, + 'current_branch' => null, + 'has_uncommitted_changes' => null, + ]; + + if ($isGitRepo) { + try { + $status['current_branch'] = $this->getCurrentBranch($fullPath); + $status['has_uncommitted_changes'] = $this->hasUncommittedChanges($fullPath); + } catch (\Exception $e) { + // 忽略 Git 命令错误 + } + } + + return $status; + } + + /** + * 发现新项目(扫描 projects_path 目录) + */ + public function discoverProjects(): array + { + if (!File::exists($this->projectsPath)) { + return []; + } + + $existingSlugs = Project::query()->pluck('slug')->toArray(); + $discovered = []; + + $directories = File::directories($this->projectsPath); + + foreach ($directories as $directory) { + $dirName = basename($directory); + + // 跳过已存在的项目 + if (in_array($dirName, $existingSlugs, true)) { + continue; + } + + // 跳过隐藏目录 + if (str_starts_with($dirName, '.')) { + continue; + } + + $isGitRepo = is_dir($directory . DIRECTORY_SEPARATOR . '.git'); + + $discovered[] = [ + 'slug' => $dirName, + 'name' => ucfirst(str_replace(['-', '_'], ' ', $dirName)), + 'directory' => $dirName, + 'path' => $directory, + 'is_git_repo' => $isGitRepo, + ]; + } + + return $discovered; + } + + /** + * 批量添加发现的项目 + */ + public function addDiscoveredProjects(array $slugs): array + { + $discovered = $this->discoverProjects(); + $added = []; + + foreach ($discovered as $project) { + if (in_array($project['slug'], $slugs, true)) { + $created = Project::query()->create([ + 'slug' => $project['slug'], + 'name' => $project['name'], + 'directory' => $project['directory'], + ]); + $added[] = $created; + } + } + + return $added; + } + + /** + * 同步项目的 Git 信息 + */ + public function syncGitInfo(Project $project): Project + { + $fullPath = $project->getFullPath($this->projectsPath); + + if (!$project->isGitRepository($this->projectsPath)) { + return $project; + } + + try { + // 获取当前版本(从 version.txt) + $version = $this->getMasterVersion($fullPath); + if ($version !== null) { + $project->git_current_version = $version; + } + + // 获取当前分支 + $branch = $this->getCurrentBranch($fullPath); + if ($branch !== null) { + $project->git_release_branch = $branch; + } + + $project->git_version_cached_at = now(); + $project->save(); + } catch (\Exception $e) { + // 忽略错误 + } + + return $project->refresh(); + } + + /** + * 获取 master 分支的版本号 + */ + private function getMasterVersion(string $path): ?string + { + try { + $this->runGit($path, ['git', 'fetch', 'origin', 'master']); + $version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']); + return trim($version) ?: null; + } catch (ProcessFailedException $e) { + return null; + } + } + + /** + * 获取当前分支 + */ + private function getCurrentBranch(string $path): ?string + { + try { + $branch = $this->runGit($path, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']); + return trim($branch) ?: null; + } catch (ProcessFailedException $e) { + return null; + } + } + + /** + * 检查是否有未提交的更改 + */ + private function hasUncommittedChanges(string $path): bool + { + try { + $output = $this->runGit($path, ['git', 'status', '--porcelain']); + return !empty(trim($output)); + } catch (ProcessFailedException $e) { + return false; + } + } + + /** + * 运行 Git 命令 + */ + private function runGit(string $cwd, array $command): string + { + $process = new Process($command, $cwd); + $process->setTimeout(60); + $process->mustRun(); + + return $process->getOutput(); + } + + /** + * 获取所有启用 Git 监控的项目 + */ + public function getGitMonitorEnabledProjects(): Collection + { + return Project::getGitMonitorEnabled(); + } + + /** + * 根据 app 名称查找项目 + */ + public function findByAppName(string $appName): ?Project + { + return Project::findByAppName($appName); + } + + /** + * 一键同步所有项目的 Git 信息 + */ + public function syncAllGitInfo(): array + { + $projects = $this->getAllProjects(); + $synced = []; + $failed = []; + + foreach ($projects as $project) { + try { + if ($project->isGitRepository($this->projectsPath)) { + $this->syncGitInfo($project); + $synced[] = $project->slug; + } + } catch (\Exception $e) { + $failed[] = [ + 'slug' => $project->slug, + 'error' => $e->getMessage(), + ]; + } + } + + return [ + 'synced' => $synced, + 'failed' => $failed, + ]; + } +} diff --git a/app/Services/ScheduledTaskService.php b/app/Services/ScheduledTaskService.php new file mode 100644 index 0000000..e02cedf --- /dev/null +++ b/app/Services/ScheduledTaskService.php @@ -0,0 +1,133 @@ +get(self::CONFIG_KEY, []); + return $enabled[$name] ?? false; + } catch (\Exception $e) { + return false; + } + } + + /** + * 获取所有任务(从 Schedule 实例动态获取) + */ + public function getAllTasks(): array + { + $this->ensureTasksLoaded(); + + $enabledTasks = $this->configService->get(self::CONFIG_KEY, []); + $tasks = []; + + foreach ($this->schedule->events() as $event) { + $name = $this->getEventName($event); + $tasks[] = [ + 'name' => $name, + 'command' => $this->getEventCommand($event), + 'description' => $event->description ?: $name, + 'frequency' => $this->getFrequencyLabel($event->expression), + 'cron' => $event->expression, + 'enabled' => $enabledTasks[$name] ?? false, + ]; + } + + return $tasks; + } + + /** + * 设置任务启用状态 + */ + public function setTaskEnabled(string $name, bool $enabled): void + { + $this->ensureTasksLoaded(); + + // 验证任务是否存在 + $exists = false; + foreach ($this->schedule->events() as $event) { + if ($this->getEventName($event) === $name) { + $exists = true; + break; + } + } + + if (!$exists) { + throw new \InvalidArgumentException("未知任务: {$name}"); + } + + $tasks = $this->configService->get(self::CONFIG_KEY, []); + $tasks[$name] = $enabled; + + $this->configService->set(self::CONFIG_KEY, $tasks, '定时任务启用状态'); + } + + /** + * 确保 console.php 中的任务已加载 + */ + private function ensureTasksLoaded(): void + { + if (count($this->schedule->events()) === 0) { + require_once base_path('routes/console.php'); + } + } + + private function getEventName($event): string + { + if (property_exists($event, 'mutexName') && $event->mutexName) { + return $event->mutexName; + } + return $this->getEventCommand($event); + } + + private function getEventCommand($event): string + { + if (property_exists($event, 'command') && $event->command) { + $command = $event->command; + if (str_contains($command, 'artisan')) { + $command = preg_replace('/^.*artisan\s+/', '', $command); + } + return trim(str_replace("'", '', $command)); + } + return 'closure'; + } + + private function getFrequencyLabel(string $expression): string + { + $map = [ + '* * * * *' => '每分钟', + '*/5 * * * *' => '每 5 分钟', + '*/10 * * * *' => '每 10 分钟', + '*/15 * * * *' => '每 15 分钟', + '*/30 * * * *' => '每 30 分钟', + '0 * * * *' => '每小时', + '0 */2 * * *' => '每 2 小时', + '0 */4 * * *' => '每 4 小时', + '0 */6 * * *' => '每 6 小时', + '0 */12 * * *' => '每 12 小时', + '0 0 * * *' => '每天凌晨 0:00', + '0 2 * * *' => '每天凌晨 2:00', + '0 0 * * 0' => '每周日凌晨', + '0 0 1 * *' => '每月 1 日凌晨', + ]; + return $map[$expression] ?? $expression; + } +} diff --git a/database/migrations/2026_01_15_154022_create_projects_table.php b/database/migrations/2026_01_15_154022_create_projects_table.php new file mode 100644 index 0000000..0b67e31 --- /dev/null +++ b/database/migrations/2026_01_15_154022_create_projects_table.php @@ -0,0 +1,47 @@ +id(); + $table->string('slug', 100)->unique()->comment('项目唯一标识,如 portal-be'); + $table->string('name', 255)->comment('显示名称'); + $table->string('directory', 255)->nullable()->comment('相对于 projects_path 的目录名'); + $table->string('absolute_path', 500)->nullable()->comment('绝对路径覆盖(可选)'); + $table->string('jira_project_code', 50)->nullable()->comment('JIRA 项目代码'); + + // Git 监控相关 + $table->boolean('git_monitor_enabled')->default(false)->comment('是否启用 Git 监控'); + $table->string('git_last_checked_commit', 64)->nullable()->comment('最后检查的 commit SHA'); + $table->string('git_current_version', 50)->nullable()->comment('当前版本号'); + $table->string('git_release_branch', 255)->nullable()->comment('Release 分支名'); + $table->timestamp('git_version_cached_at')->nullable()->comment('版本缓存时间'); + + // 日志分析相关 + $table->json('log_app_names')->nullable()->comment('关联的 App 名称列表'); + $table->string('log_env', 50)->default('production')->comment('日志分析环境'); + + $table->timestamps(); + + $table->index('jira_project_code', 'idx_jira_project_code'); + $table->index('git_monitor_enabled', 'idx_git_monitor_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('projects'); + } +}; diff --git a/database/migrations/2026_01_15_154113_migrate_project_configs_to_projects_table.php b/database/migrations/2026_01_15_154113_migrate_project_configs_to_projects_table.php new file mode 100644 index 0000000..cbfd308 --- /dev/null +++ b/database/migrations/2026_01_15_154113_migrate_project_configs_to_projects_table.php @@ -0,0 +1,103 @@ +where('key', 'workspace.repositories')->first()?->value ?? []; + $lastChecked = Config::query()->where('key', 'git_monitor.last_checked_commits')->first()?->value ?? []; + $currentVersions = Config::query()->where('key', 'git_monitor.current_versions')->first()?->value ?? []; + $appEnvMap = Config::query()->where('key', 'log_analysis.app_env_map')->first()?->value ?? []; + $enabledProjects = config('git-monitor.enabled_projects', []); + + // 2. 为每个仓库创建 Project 记录 + foreach ($repositories as $slug => $repo) { + if (!is_string($slug) || $slug === '') { + continue; + } + + // 检查是否已存在 + if (Project::query()->where('slug', $slug)->exists()) { + continue; + } + + Project::query()->create([ + 'slug' => $slug, + 'name' => $repo['display'] ?? ucfirst(str_replace('-', ' ', $slug)), + 'directory' => $repo['directory'] ?? $slug, + 'absolute_path' => $repo['path'] ?? null, + 'jira_project_code' => $repo['jira_project'] ?? null, + 'git_monitor_enabled' => in_array($slug, $enabledProjects, true), + 'git_last_checked_commit' => $lastChecked[$slug] ?? null, + 'git_current_version' => $currentVersions['repositories'][$slug]['current_version'] ?? null, + 'git_release_branch' => $currentVersions['repositories'][$slug]['branch'] ?? null, + 'git_version_cached_at' => isset($currentVersions['cached_at']) + ? Carbon::parse($currentVersions['cached_at']) + : null, + 'log_app_names' => $this->findAppNamesForProject($appEnvMap, $slug), + 'log_env' => $this->findEnvForProject($appEnvMap, $slug), + ]); + } + + // 3. 删除旧配置 + Config::query()->whereIn('key', [ + 'workspace.repositories', + 'git_monitor.current_versions', + 'git_monitor.last_checked_commits', + 'log_analysis.app_env_map', + ])->delete(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 不支持回滚,因为数据结构已经改变 + // 如需回滚,请手动恢复数据 + } + + /** + * 从 appEnvMap 中找出映射到指定项目的所有 app 名称 + */ + private function findAppNamesForProject(array $appEnvMap, string $projectSlug): ?array + { + $appNames = []; + + foreach ($appEnvMap as $appName => $mapping) { + if (isset($mapping['project']) && $mapping['project'] === $projectSlug) { + $appNames[] = $appName; + } + } + + return empty($appNames) ? null : $appNames; + } + + /** + * 从 appEnvMap 中找出指定项目的环境 + */ + private function findEnvForProject(array $appEnvMap, string $projectSlug): string + { + foreach ($appEnvMap as $mapping) { + if (isset($mapping['project']) && $mapping['project'] === $projectSlug) { + return $mapping['env'] ?? 'production'; + } + } + + return 'production'; + } +}; diff --git a/database/migrations/2026_01_15_163245_add_is_important_to_projects_table.php b/database/migrations/2026_01_15_163245_add_is_important_to_projects_table.php new file mode 100644 index 0000000..c67c03d --- /dev/null +++ b/database/migrations/2026_01_15_163245_add_is_important_to_projects_table.php @@ -0,0 +1,22 @@ +boolean('is_important')->default(false)->after('git_monitor_enabled')->comment('是否为重要项目'); + }); + } + + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropColumn('is_important'); + }); + } +}; diff --git a/database/migrations/2026_01_16_103851_add_auto_create_release_branch_to_projects_table.php b/database/migrations/2026_01_16_103851_add_auto_create_release_branch_to_projects_table.php new file mode 100644 index 0000000..b8590c0 --- /dev/null +++ b/database/migrations/2026_01_16_103851_add_auto_create_release_branch_to_projects_table.php @@ -0,0 +1,28 @@ +boolean('auto_create_release_branch')->default(false)->after('git_monitor_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropColumn('auto_create_release_branch'); + }); + } +}; diff --git a/resources/js/components/admin/AdminDashboard.vue b/resources/js/components/admin/AdminDashboard.vue index 770f69c..2274373 100644 --- a/resources/js/components/admin/AdminDashboard.vue +++ b/resources/js/components/admin/AdminDashboard.vue @@ -60,6 +60,12 @@ + + + + + + @@ -76,6 +82,8 @@ import LogAnalysis from '../log-analysis/LogAnalysis.vue'; import SystemSettings from './SystemSettings.vue'; import OperationLogs from './OperationLogs.vue'; import IpUserMappings from './IpUserMappings.vue'; +import ProjectManagement from './ProjectManagement.vue'; +import ScheduledTasks from './ScheduledTasks.vue'; export default { name: 'AdminDashboard', @@ -91,7 +99,9 @@ export default { LogAnalysis, SystemSettings, OperationLogs, - IpUserMappings + IpUserMappings, + ProjectManagement, + ScheduledTasks }, data() { return { @@ -121,7 +131,7 @@ export default { } }, handleMenuChange(menu) { - if (menu === 'ip-mappings' && !this.isAdmin) { + if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) { this.redirectToDefault(); return; } @@ -140,7 +150,9 @@ export default { 'log-analysis': 'SLS 日志分析', 'settings': '系统设置', 'logs': '操作日志', - 'ip-mappings': 'IP 用户映射' + 'ip-mappings': 'IP 用户映射', + 'projects': '项目配置管理', + 'scheduled-tasks': '定时任务管理' }; this.pageTitle = titles[menu] || '环境配置管理'; @@ -172,9 +184,13 @@ export default { page = 'logs'; } else if (path === '/ip-mappings') { page = 'ip-mappings'; + } else if (path === '/projects') { + page = 'projects'; + } else if (path === '/scheduled-tasks') { + page = 'scheduled-tasks'; } - if (page === 'ip-mappings' && !this.isAdmin) { + if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks') && !this.isAdmin) { this.redirectToDefault(); return; } diff --git a/resources/js/components/admin/AdminLayout.vue b/resources/js/components/admin/AdminLayout.vue index d8c1685..ee5a09d 100644 --- a/resources/js/components/admin/AdminLayout.vue +++ b/resources/js/components/admin/AdminLayout.vue @@ -20,6 +20,58 @@