toDateTimeString(); } public function baseDir(int $userId): string { return $this->rootDir() . '/' . $userId; } public function rootDir(): string { return storage_path('app/account-bills-exports'); } public function jsonPath(array $task): string { return $this->baseDir((int) $task['user_id']) . '/' . $task['token'] . '.json'; } public function csvPath(array $task): string { return $this->baseDir((int) $task['user_id']) . '/' . $task['token'] . '.csv'; } /** * 新建任务(只落状态,真正的导出由后台进程执行)。 * * @param array $filters * @param list $columns */ public function create(int $userId, string $tab, string $tabLabel, array $filters, array $columns, int $total): array { $task = [ 'token' => (string) Str::uuid(), 'user_id' => $userId, 'tab' => $tab, 'tab_label' => $tabLabel, 'type' => self::TYPE_LABEL, 'status' => 'queued', 'rows' => 0, 'total' => max(0, $total), 'filters' => $filters, 'columns' => $columns, 'error' => null, 'planned_at' => self::beijingNow(), 'started_at' => null, 'finished_at' => null, 'size' => 0, 'cancel' => false, ]; $this->write($task); return $task; } public function write(array $task): void { $dir = $this->baseDir((int) $task['user_id']); if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) { throw new RuntimeException('无法创建导出目录:' . $dir); } file_put_contents($this->jsonPath($task), json_encode($task, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); } public function patch(array $task, array $patch): array { $task = array_merge($task, $patch); $this->write($task); return $task; } public function find(int $userId, string $token): ?array { if (preg_match('/^[A-Za-z0-9_-]{8,64}$/', $token) !== 1) { return null; } $path = $this->baseDir($userId) . '/' . $token . '.json'; if (! is_file($path)) { return null; } $task = json_decode((string) file_get_contents($path), true); return is_array($task) ? $task : null; } /** * 当前用户的导出任务,按计划时间倒序。 * * @return list> */ public function all(int $userId, int $limit = 50): array { $dir = $this->baseDir($userId); if (! is_dir($dir)) { return []; } $tasks = []; $expireBefore = Carbon::now()->subDays(7)->getTimestamp(); foreach (glob($dir . '/*.json') ?: [] as $path) { $task = json_decode((string) file_get_contents($path), true); if (! is_array($task) || ! isset($task['token'])) { continue; } // 只保留最近 7 天,避免任务文件无限堆积。 if (($task['status'] ?? '') !== 'running' && filemtime($path) !== false && filemtime($path) < $expireBefore) { @unlink($path); $csv = $dir . '/' . $task['token'] . '.csv'; if (is_file($csv)) { @unlink($csv); } continue; } $tasks[] = $task; } usort($tasks, static fn (array $a, array $b): int => strcmp((string) ($b['planned_at'] ?? ''), (string) ($a['planned_at'] ?? ''))); return array_slice($tasks, 0, $limit); } /** 列表页展示用的字段(去掉 filters/columns 这类大字段)。 */ public function present(array $task): array { return [ 'token' => $task['token'], 'type' => $task['type'] ?? self::TYPE_LABEL, 'tab' => $task['tab'] ?? '', 'tab_label' => $task['tab_label'] ?? '', 'status' => $task['status'] ?? 'queued', 'rows' => (int) ($task['rows'] ?? 0), 'total' => (int) ($task['total'] ?? 0), 'size' => (int) ($task['size'] ?? 0), 'error' => $task['error'] ?? null, 'cancel' => (bool) ($task['cancel'] ?? false), 'planned_at' => $task['planned_at'] ?? null, 'started_at' => $task['started_at'] ?? null, 'finished_at' => $task['finished_at'] ?? null, ]; } /** 后台执行导出(脱离当前请求)。 */ public function dispatch(array $task): void { // Production consumes the durable file queue from the limited batch profile. if (config('upstream_pipeline.batch_worker_enabled', false)) return; $php = PHP_BINDIR . '/php'; if (! is_file($php)) { $php = 'php'; } $artisan = base_path('artisan'); $command = escapeshellcmd($php) . ' ' . escapeshellarg($artisan) . ' account-bills:exports:run --max-jobs=0' . ' > /dev/null 2>&1 &'; try { if (function_exists('exec')) { exec($command); } } catch (Throwable) { // 起不了后台进程时保持 queued,页面会提示重试。 } } public function nextQueued(): ?array { $next = null; foreach (glob($this->rootDir() . '/*/*.json') ?: [] as $path) { $task = json_decode((string) file_get_contents($path), true); if (! is_array($task) || ($task['status'] ?? '') !== 'queued') continue; if ($next === null || strcmp((string) $task['planned_at'], (string) $next['planned_at']) < 0) $next = $task; } return $next; } }