'YUANHE_BASE_URL', 'customer_id' => 'YUANHE_CUSTOMERID', 'auth_key' => 'YUANHE_AUTHKEY'] as $key => $label) { if (trim((string) config('yuanhe.'.$key)) === '') $missing[] = $label; } if ($missing) return '元禾接口尚未配置:'.implode('、', $missing).'。请填写后端 .env 并重启采集进程。'; $url = config('yuanhe.base_url'); if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''), ['https', 'http'], true)) { return 'YUANHE_BASE_URL 必须是完整的 HTTP/HTTPS 地址'; } return null; } public function start(?int $actor, ?int $retryOf = null): object { if ($error = $this->configurationError()) throw new RuntimeException($error); return DB::transaction(function () use ($actor, $retryOf) { $control = DB::table('company_news_control')->where('id', 1)->lockForUpdate()->first(); if ($control->active_batch_id) { throw new RuntimeException('已有采集批次运行,请等待完成后再获取更新'); } $query = Company::query()->whereHas('qccAccount', fn ($q) => $q->whereIn('status', ['selected', 'occupied']))->orderBy('id'); if ($retryOf !== null) { if (!DB::table('company_news_batches')->where('id', $retryOf)->exists()) { throw new RuntimeException('原采集批次不存在'); } $query->whereIn('id', DB::table('company_news_tasks')->where('batch_id', $retryOf)->where('status', 'failed')->select('company_id')); } $companies = $query->get(['id', 'company_name', 'credit_code']); $now = now(); $id = DB::table('company_news_batches')->insertGetId([ 'created_by' => $actor, 'retry_of' => $retryOf, 'total' => $companies->count(), 'status' => $companies->isEmpty() ? 'completed' : 'running', 'finished_at' => $companies->isEmpty() ? $now : null, 'created_at' => $now, 'updated_at' => $now, ]); foreach ($companies->chunk(200) as $chunk) { DB::table('company_news_tasks')->insert($chunk->map(fn ($c) => [ 'batch_id' => $id, 'company_id' => $c->id, 'company_name' => $c->company_name, 'credit_code' => $c->credit_code, 'status' => 'pending', 'created_at' => $now, 'updated_at' => $now, ])->all()); } DB::table('company_news_control')->where('id', 1)->update(['active_batch_id' => $companies->isEmpty() ? null : $id]); return $this->summary($id); }); } public function summary(int $id): object { $batch = DB::table('company_news_batches')->find($id); if (!$batch) { throw new RuntimeException('批次不存在'); } $tasks = DB::table('company_news_tasks')->where('batch_id', $id); $counts = (clone $tasks)->selectRaw('status, COUNT(*) as count')->groupBy('status')->pluck('count', 'status'); $batch->success = (int) ($counts['success'] ?? 0); $batch->failed = (int) ($counts['failed'] ?? 0); $batch->processed = $batch->success + $batch->failed; $batch->inserted = (int) (clone $tasks)->sum('inserted_count'); $batch->invalid = (int) (clone $tasks)->sum('invalid_count'); $batch->progress = $batch->total ? (int) floor($batch->processed * 100 / $batch->total) : 100; $batch->current_company = (clone $tasks)->where('status', 'processing')->value('company_name'); return $batch; } public function runOne(): bool { $claim = DB::transaction(function () { $control = DB::table('company_news_control')->where('id', 1)->lockForUpdate()->first(); if (!$control->active_batch_id || ($control->lease_until && $control->lease_until > now()->toDateTimeString())) { return null; } $batchId = $control->active_batch_id; // A dead worker may already have called the provider. Do not silently repeat that request. DB::table('company_news_tasks')->where('batch_id', $batchId)->where('status', 'processing')->update([ 'status' => 'failed', 'error' => '执行进程中断,外部请求结果不确定;请核查后手动重试', 'finished_at' => now(), 'updated_at' => now(), ]); $task = DB::table('company_news_tasks')->where('batch_id', $batchId)->where('status', 'pending')->orderBy('company_id')->first(); if (!$task) { $this->finishBatch($batchId); return null; } $token = (string) Str::uuid(); DB::table('company_news_control')->where('id', 1)->update(['worker_token' => $token, 'lease_until' => now()->addMinutes(5)]); DB::table('company_news_tasks')->where('id', $task->id)->update([ 'status' => 'processing', 'started_at' => now(), 'request_ref' => 'news-'.$task->id.'-'.$token, 'updated_at' => now(), ]); return [$task, $token]; }); if (!$claim) { return false; } [$task, $token] = $claim; $response = null; $error = null; try { $company = Company::find($task->company_id); if (!$company || !$company->qccAccount || !in_array($company->qccAccount->status, ['selected', 'occupied'], true)) { throw new RuntimeException('企业已不属于候选或已占额企业,本次跳过请求'); } if (!trim((string) $company->credit_code)) { throw new RuntimeException('企业缺少统一社会信用代码'); } // Deliberately bypass QccCallService: this operation must NEVER change account status. $response = app(YuanheRepository::class)->enterprisePackInfo([ 'creditCode' => $company->credit_code, 'enterpriseName' => $company->company_name, ], 60); if (!in_array($response['code'] ?? null, [200, '200'], true)) { throw new RuntimeException('元禾业务失败:'.mb_substr(json_encode($response['msg'] ?? $response['code'] ?? '缺少 code', JSON_UNESCAPED_UNICODE), 0, 1000)); } if (!isset($response['data']) || !is_array($response['data']) || !array_key_exists('news', $response['data'])) { throw new RuntimeException('响应缺少 data.news'); } $news = $response['data']['news']; if ($news !== null && (!is_array($news) || array_values($news) !== $news)) { throw new RuntimeException('data.news 必须为数组或 null'); } } catch (Throwable $e) { $error = mb_substr($e->getMessage(), 0, 2000); } try { $this->persist($task, $token, $response, $error); } catch (Throwable $e) { // The import transaction rolled back; record a failure without calling the provider again. $this->persist($task, $token, $response, '本地保存失败:'.mb_substr($e->getMessage(), 0, 1800)); } return true; } private function persist(object $task, string $token, ?array $response, ?string $error): void { DB::transaction(function () use ($task, $token, $response, $error) { $control = DB::table('company_news_control')->where('id', 1)->lockForUpdate()->first(); if ($control->worker_token !== $token) { return; // A stale worker must not commit after another worker reclaimed its lease. } $inserted = 0; $invalid = 0; $issues = []; $items = $error ? [] : ($response['data']['news'] ?? []); foreach ($items as $index => $item) { try { if (!is_array($item)) { throw new InvalidArgumentException('新闻不是对象'); } $inserted += (int) app(NewsImporter::class)->store($task, $item); } catch (InvalidArgumentException $e) { $invalid++; if (count($issues) < 10) { $issues[] = '第'.($index + 1).'条:'.$e->getMessage(); } } } $code = $response['code'] ?? null; DB::table('company_news_tasks')->where('id', $task->id)->update([ 'status' => ($error || $invalid) ? 'failed' : 'success', 'response_code' => is_scalar($code) ? mb_substr((string) $code, 0, 40) : null, 'news_count' => count($items), 'inserted_count' => $inserted, 'invalid_count' => $invalid, 'error' => $error ?: ($invalid ? implode(';', $issues) : null), 'finished_at' => now(), 'updated_at' => now(), ]); DB::table('company_news_control')->where('id', 1)->update(['worker_token' => null, 'lease_until' => null]); if (!DB::table('company_news_tasks')->where('batch_id', $task->batch_id)->where('status', 'pending')->exists()) { $this->finishBatch($task->batch_id); } }); } private function finishBatch(int $batchId): void { DB::table('company_news_batches')->where('id', $batchId)->update(['status' => 'completed', 'finished_at' => now(), 'updated_at' => now()]); DB::table('company_news_control')->where('id', 1)->update(['active_batch_id' => null, 'worker_token' => null, 'lease_until' => null]); } }