master
lion 5 days ago
parent 372a06af25
commit 3f10341c63

@ -114,6 +114,11 @@ class NewsCollection
}
$batchId = $control->active_batch_id;
// A dead worker may already have called the provider. Do not silently repeat that request.
DB::table('company_qcc_accounts')->whereIn('company_id',
DB::table('company_news_tasks')->where('batch_id', $batchId)->where('status', 'processing')->select('company_id')
)->where('status', 'selected')->update([
'status' => 'unknown', 'unknown_note' => '执行进程中断,外部请求结果不确定;请人工确认', 'updated_at' => now(),
]);
DB::table('company_news_tasks')->where('batch_id', $batchId)->where('status', 'processing')->update([
'status' => 'failed', 'error' => '执行进程中断,外部请求结果不确定;请核查后手动重试',
'finished_at' => now(), 'updated_at' => now(),
@ -137,6 +142,8 @@ class NewsCollection
[$task, $token] = $claim;
$response = null;
$error = null;
$requestStarted = false;
$indeterminate = false;
try {
$company = Company::find($task->company_id);
if (!$company || !$company->qccAccount || !in_array($company->qccAccount->status, ['selected', 'occupied'], true)) {
@ -146,9 +153,11 @@ class NewsCollection
throw new RuntimeException('企业缺少统一社会信用代码');
}
// Fetch directly: occupation is recorded only for a successful response containing business data.
$requestStarted = true;
$response = app(YuanheRepository::class)->enterprisePackInfo([
'creditCode' => $company->credit_code, 'enterpriseName' => $company->company_name,
], 60);
$indeterminate = !isset($response['code']) || !is_scalar($response['code']) || trim((string) $response['code']) === '';
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));
}
@ -163,7 +172,12 @@ class NewsCollection
$error = mb_substr($e->getMessage(), 0, 2000);
}
// Provider success is independent from local news import success.
if ($this->hasBusinessData($response)) $this->markOccupied($task, $token);
if ($this->hasBusinessData($response)) {
$this->markOccupied($task, $token);
} elseif ($requestStarted && ($response === null || $indeterminate)) {
$error = $error ?: '外部请求结果不确定';
$this->markUnknown($task, $token, $error);
}
try {
$this->persist($task, $token, $response, $error);
} catch (Throwable $e) {
@ -198,6 +212,17 @@ class NewsCollection
});
}
private function markUnknown(object $task, string $token, string $reason): void
{
DB::transaction(function () use ($task, $token, $reason) {
$control = DB::table('company_news_control')->where('id', 1)->lockForUpdate()->first();
if ($control->worker_token !== $token) return;
DB::table('company_qcc_accounts')->where('company_id', $task->company_id)
->where('status', 'selected')
->update(['status' => 'unknown', 'unknown_note' => mb_substr($reason, 0, 2000), 'updated_at' => now()]);
});
}
private function persist(object $task, string $token, ?array $response, ?string $error): void
{
DB::transaction(function () use ($task, $token, $response, $error) {

@ -7,7 +7,7 @@
- 点击“获取更新”创建一个全量候选和已占额企业快照,不受新闻列表筛选和分页影响;仅 `company_qcc_accounts.status` 为 `selected` 或 `occupied` 的企业进入批次,按公司 ID 顺序执行。
- 执行前再次检查企业是否仍为候选或已占额;不符合的任务记录失败/跳过,不调用元禾。
- 单次请求使用元禾 `enterprise/packInfo`,连接超时 10 秒、总超时 60 秒;只有外层 `code=200` 且 `data.news` 是数组或 null 才进行处理。null/空数组记成功、0 新增;缺字段、非数组记失败。
- 本功能直接调用 Repository:code=200 且 data 中存在非空业务内容(新闻、股东等)时,仅将 selected 转为 occupied,记录首次成功时间、来源和引用;已占额及首次成功记录不重复改写。空数据、失败、超时不改状态。占额记录独立于新闻入库事务,避免本地保存失败丢失上游成功事实。`qcc:verify-pack-info` 诊断命令仅查询候选、已占额或待人工确认(unknown)企业,按 code=200 判断成功,不修改企业状态,也不导入新闻。默认超时 60 秒,可用 `--timeout=120` 指定秒数;始终输出完整响应,保留 `--raw` 兼容旧用法。例如:`php artisan qcc:verify-pack-info 2280 --timeout=120 --raw`。成功退出码为 0,业务失败、超时或参数校验失败为 1。
- 本功能直接调用 Repository:code=200 且 data 中存在非空业务内容(新闻、股东等)时,仅将 selected 转为 occupied,记录首次成功时间、来源和引用;已占额及首次成功记录不重复改写。明确业务失败和空数据保持候选;请求超时、网关非 JSON 响应、缺少 code 等结果不确定时,候选转为 unknown 并记录原因;已占额不降级。占额记录独立于新闻入库事务,避免本地保存失败丢失上游成功事实。`qcc:verify-pack-info` 诊断命令仅查询候选、已占额或待人工确认(unknown)企业,按 code=200 判断成功,不修改企业状态,也不导入新闻。默认超时 60 秒,可用 `--timeout=120` 指定秒数;始终输出完整响应,保留 `--raw` 兼容旧用法。例如:`php artisan qcc:verify-pack-info 2280 --timeout=120 --raw`。成功退出码为 0,业务失败、超时或参数校验失败为 1。
- 按企业、数据提供方及去重键建立唯一约束。去重键按 NewsId、Id、原文 URL 的优先级生成 SHA-256;来源标识必须跨次稳定。同一新闻关联不同公司分别保存。
- 重复采集只更新来源字段、原始 JSON 与最近采集信息,不覆盖标题、人工正文和人工留存结论;自动判断的记录会按本批次分类设置重新判断,始终不展示。不留存是逻辑状态,不删除记录。
- 原文 HTML 使用现有 TinyMCE 组件编辑,并通过服务端 HTMLPurifier 清理。来源 Content 独立保存,接口正文为空时不会伪造原文。

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -83,7 +83,7 @@ class CompanyNewsWorkflowTest extends TestCase
$this->assertEquals('occupied', DB::table('company_qcc_accounts')->value('status'));
$this->assertEquals(2, DB::table('company_news_logs')->count());
}
public function test_failure_does_not_stop_other_companies_or_change_account_states(): void
public function test_uncertain_failure_marks_candidate_unknown_and_continues_other_companies(): void
{
$this->company(1); $this->company(2); $this->company(3, 'occupied'); $this->company(4, 'unknown'); $this->company(5, 'unregistered'); $this->company(6, 'non_occupying');
$this->provider([new RuntimeException('请求超时'), $this->response(null), $this->response([])]);
@ -92,7 +92,7 @@ class CompanyNewsWorkflowTest extends TestCase
$summary = $service->summary($batch->id);
$this->assertEquals(2, $summary->success); $this->assertEquals(1, $summary->failed);
$this->assertEquals(100, $summary->progress); $this->assertEquals('completed', $summary->status);
$this->assertEquals(['selected', 'selected', 'occupied', 'unknown', 'unregistered', 'non_occupying'], DB::table('company_qcc_accounts')->orderBy('company_id')->pluck('status')->all());
$this->assertEquals(['unknown', 'selected', 'occupied', 'unknown', 'unregistered', 'non_occupying'], DB::table('company_qcc_accounts')->orderBy('company_id')->pluck('status')->all());
}
public function test_invalid_news_is_recorded_while_valid_news_is_imported(): void
{
@ -124,6 +124,7 @@ class CompanyNewsWorkflowTest extends TestCase
$this->assertFalse($service->runOne()); Carbon::setTestNow(now()->addMinutes(6)); $this->assertTrue($service->runOne());
$this->assertEquals(1, $service->summary($batch->id)->failed); $this->assertEquals(1, $service->summary($batch->id)->success);
$this->assertStringContainsString('结果不确定', DB::table('company_news_tasks')->where('company_id', 1)->value('error'));
$this->assertEquals('unknown', DB::table('company_qcc_accounts')->where('company_id', 1)->value('status'));
}
public function test_candidate_removed_after_snapshot_is_not_called(): void
{
@ -402,4 +403,13 @@ class CompanyNewsWorkflowTest extends TestCase
$this->assertEquals(0, DB::table('company_news_tasks')->count());
$this->artisan('qcc:collect-news')->assertExitCode(1);
}
public function test_explicit_business_failure_stays_selected_missing_code_becomes_unknown(): void
{
$this->company(1); $this->company(2); $this->company(3, 'occupied');
$this->provider([['code' => 500], ['data' => []], new RuntimeException('504')]);
$service = new NewsCollection(); $service->start(1);
$service->runOne(); $service->runOne(); $service->runOne();
$this->assertEquals(['selected', 'unknown', 'occupied'], DB::table('company_qcc_accounts')->orderBy('company_id')->pluck('status')->all());
}
}

Loading…
Cancel
Save