#feature: add project&cronjob management

This commit is contained in:
2026-01-16 12:14:43 +08:00
parent bbe68839e3
commit 381d5e6e49
19 changed files with 2157 additions and 84 deletions

View File

@@ -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)) {

View File

@@ -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) {
// 忽略
}
}
}
}

View File

@@ -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,

View File

@@ -0,0 +1,302 @@
<?php
namespace App\Services;
use App\Models\Project;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class ProjectService
{
private string $projectsPath;
public function __construct(private readonly ConfigService $configService)
{
$this->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,
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Services;
use Illuminate\Console\Scheduling\Schedule;
class ScheduledTaskService
{
private const CONFIG_KEY = 'scheduled_tasks';
private static ?ConfigService $configServiceInstance = null;
public function __construct(
private ConfigService $configService,
private Schedule $schedule
) {}
/**
* 静态方法:检查任务是否启用(供 console.php 中的 when() 回调使用)
*/
public static function isEnabled(string $name): bool
{
try {
self::$configServiceInstance ??= app(ConfigService::class);
$enabled = self::$configServiceInstance->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;
}
}