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

5 days ago
<?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]);
}
}