You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

189 lines
9.9 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services\CompanyNews;
use App\Models\Company;
use App\Repositories\YuanheRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
class NewsCollection
{
public function configurationError(): ?string
{
$missing = [];
foreach (['base_url' => '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]);
}
}