From 08855e66ced8fa5dda46bc9013ab8858149fe10d Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Fri, 6 Nov 2026 13:11:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/VerifyQccEnterprisePackInfo.php | 96 +++++---------- docs/company-news.md | 2 +- .../VerifyQccEnterprisePackInfoTest.php | 110 ++++++++++++++++++ 3 files changed, 138 insertions(+), 70 deletions(-) create mode 100644 tests/Feature/VerifyQccEnterprisePackInfoTest.php diff --git a/app/Console/Commands/VerifyQccEnterprisePackInfo.php b/app/Console/Commands/VerifyQccEnterprisePackInfo.php index 95c103d..c4f2318 100644 --- a/app/Console/Commands/VerifyQccEnterprisePackInfo.php +++ b/app/Console/Commands/VerifyQccEnterprisePackInfo.php @@ -6,20 +6,27 @@ use App\Exceptions\YuanhePackInfoException; use App\Models\Company; use App\Models\CompanyQccAccount; use App\Repositories\YuanheRepository; -use App\Services\QccCallService; use Illuminate\Console\Command; +use Symfony\Component\Console\Output\OutputInterface; use Throwable; class VerifyQccEnterprisePackInfo extends Command { protected $signature = 'qcc:verify-pack-info - {company_id : 已纳入候选池的本地企业 ID} - {--raw : 输出元禾完整响应 JSON}'; + {company_id : 候选或已占额企业的本地企业 ID} + {--timeout=60 : 请求超时时间(秒,正整数)} + {--raw : 兼容旧用法,默认即输出完整响应}'; - protected $description = '调用一次元禾企业户聚合接口 packInfo,验证企业户占额状态流转'; + protected $description = '诊断元禾 packInfo 接口,按 code=200 判断成功,仅输出响应,不修改企业状态'; - public function handle(): int + public function handle(YuanheRepository $yuanheRepository): int { + $timeout = filter_var($this->option('timeout'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($timeout === false) { + $this->error('timeout 必须为正整数(单位:秒)。'); + return self::FAILURE; + } + $company = Company::find($this->argument('company_id')); if (!$company) { $this->error('企业不存在。'); @@ -27,9 +34,9 @@ class VerifyQccEnterprisePackInfo extends Command } $account = $company->qccAccount; - if (!$account || $account->status !== CompanyQccAccount::STATUS_SELECTED) { + if (!$account || !in_array($account->status, [CompanyQccAccount::STATUS_SELECTED, CompanyQccAccount::STATUS_OCCUPIED], true)) { $status = $account ? $account->status : 'none'; - $this->error("企业必须处于候选状态(selected),当前状态:{$status}。"); + $this->error("企业必须处于候选(selected)或已占额(occupied)状态,当前状态:{$status}。"); return self::FAILURE; } @@ -38,74 +45,25 @@ class VerifyQccEnterprisePackInfo extends Command return self::FAILURE; } - $requestRef = sprintf('pack-info-cli-%d-%s', $company->id, now()->format('YmdHisv')); - $yuanheRepository = new YuanheRepository(); - $this->info("开始调用 packInfo:{$company->company_name}({$company->credit_code})"); - $this->line("请求引用:{$requestRef}"); - try { - $response = (new QccCallService())->call( - $company, - 'enterprise-pack-info-cli', - $requestRef, - fn () => $yuanheRepository->enterprisePackInfo([ - 'creditCode' => $company->credit_code, - 'enterpriseName' => $company->company_name, - ]), - fn (array $result) => array_key_exists('code', $result) && (int) $result['code'] === 0, - fn (array $result) => !array_key_exists('code', $result) - ? '元禾 packInfo 响应缺少业务状态码 code' - : false - ); + $response = $yuanheRepository->enterprisePackInfo([ + 'creditCode' => $company->credit_code, + 'enterpriseName' => $company->company_name, + ], $timeout); } catch (Throwable $exception) { - $this->error($exception->getMessage()); - $this->line('签名 timestamp:' . ($yuanheRepository->lastRequestTimestamp ?? '(未生成)')); - if ($yuanheRepository->lastRequest) { - $this->line('完整请求入参(不含 authKey):'); - $this->line(json_encode($yuanheRepository->lastRequest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); - } - if ($exception instanceof YuanhePackInfoException) { - $this->table( - ['HTTP 状态', 'Content-Type', '响应字节数'], - [[ - $exception->httpStatus ?? '(无响应)', - $exception->contentType ?? '(未提供)', - $exception->rawResponse === null ? 0 : strlen($exception->rawResponse), - ]] - ); - if ($this->option('raw') && $exception->rawResponse !== null) { - $this->line('原始响应:'); - $this->line($exception->rawResponse); - } - } - $this->line('调用后的企业户状态:' . $account->fresh()->status); + // 非 JSON 响应保留原文;没有响应时仅输出错误,不打印签名或请求头。 + $result = $exception instanceof YuanhePackInfoException && $exception->rawResponse !== null + ? $exception->rawResponse + : $exception->getMessage(); + $this->output->writeln($result, OutputInterface::OUTPUT_RAW); return self::FAILURE; } - $freshAccount = $account->fresh(); - $this->line('完整请求入参(不含 authKey):'); - $this->line(json_encode($yuanheRepository->lastRequest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); - $this->line('签名 timestamp:' . ($yuanheRepository->lastRequestTimestamp ?? '(未生成)')); - $this->table( - ['元禾 code', '元禾 msg', '调用后状态', '首次成功引用'], - [[ - $response['code'] ?? '(缺失)', - $response['msg'] ?? '', - $freshAccount->status, - $freshAccount->first_success_ref ?? '', - ]] + $this->output->writeln( + json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), + OutputInterface::OUTPUT_RAW ); - if ($this->option('raw')) { - $this->line(json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); - } - - if (($response['code'] ?? null) !== 0 && ($response['code'] ?? null) !== '0') { - $this->warn('元禾未返回明确成功(code !== 0);企业保持候选状态。'); - return self::FAILURE; - } - - $this->info('元禾返回明确成功,企业已标记为已占额。'); - return self::SUCCESS; + return in_array($response['code'] ?? null, [200, '200'], true) ? self::SUCCESS : self::FAILURE; } } diff --git a/docs/company-news.md b/docs/company-news.md index fc0ff20..1ac7759 100644 --- a/docs/company-news.md +++ b/docs/company-news.md @@ -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` 诊断命令仍有状态流转,不能用它代替本功能。 +- 本功能直接调用 Repository:code=200 且 data 中存在非空业务内容(新闻、股东等)时,仅将 selected 转为 occupied,记录首次成功时间、来源和引用;已占额及首次成功记录不重复改写。空数据、失败、超时不改状态。占额记录独立于新闻入库事务,避免本地保存失败丢失上游成功事实。`qcc:verify-pack-info` 诊断命令仅查询候选或已占额企业,按 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 独立保存,接口正文为空时不会伪造原文。 diff --git a/tests/Feature/VerifyQccEnterprisePackInfoTest.php b/tests/Feature/VerifyQccEnterprisePackInfoTest.php new file mode 100644 index 0000000..478a54d --- /dev/null +++ b/tests/Feature/VerifyQccEnterprisePackInfoTest.php @@ -0,0 +1,110 @@ + 'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]); + DB::purge('sqlite'); + Schema::create('companies', function (Blueprint $t) { + $t->id(); $t->string('company_name'); $t->string('credit_code')->nullable(); $t->timestamps(); $t->softDeletes(); + }); + Schema::create('company_qcc_accounts', function (Blueprint $t) { + $t->id(); $t->unsignedBigInteger('company_id'); $t->string('status'); $t->timestamps(); + $t->dateTime('first_success_at')->nullable(); $t->string('first_success_source')->nullable(); + $t->string('first_success_ref')->nullable(); $t->text('unknown_note')->nullable(); + }); + } + + protected function tearDown(): void + { + DB::disconnect('sqlite'); + parent::tearDown(); + } + + private function company(string $status): void + { + DB::table('companies')->insert(['id' => 333, 'company_name' => '测试企业', 'credit_code' => 'test-credit']); + DB::table('company_qcc_accounts')->insert([ + 'company_id' => 333, 'status' => $status, 'first_success_ref' => 'original-ref', + 'updated_at' => '2026-01-01 00:00:00', + ]); + } + + /** @dataProvider responseCases */ + public function test_response_and_timeout_without_database_writes(string $status, array $response, array $options, int $timeout, int $exit): void + { + $this->company($status); + $before = DB::table('company_qcc_accounts')->get()->toJson(); + $companies = DB::table('companies')->get()->toJson(); + $mock = Mockery::mock(YuanheRepository::class); + $mock->shouldReceive('enterprisePackInfo')->once() + ->with(['creditCode' => 'test-credit', 'enterpriseName' => '测试企业'], $timeout)->andReturn($response); + $this->app->instance(YuanheRepository::class, $mock); + $this->assertSame($exit, Artisan::call('qcc:verify-pack-info', ['company_id' => 333] + $options)); + $this->assertSame($response, json_decode(Artisan::output(), true)); + $this->assertSame($before, DB::table('company_qcc_accounts')->get()->toJson()); + $this->assertSame($companies, DB::table('companies')->get()->toJson()); + } + + public static function responseCases(): array + { + return [ + ['selected', ['code' => 200, 'data' => ['news' => [['Title' => '标题']]]], [], 60, 0], + ['occupied', ['code' => '200', 'data' => []], ['--timeout' => '120', '--raw' => true], 120, 0], + ['selected', ['code' => 0], [], 60, 1], + ['occupied', ['code' => 500, 'msg' => '失败'], [], 60, 1], + ['selected', ['data' => []], [], 60, 1], + ]; + } + + /** @dataProvider exceptionCases */ + public function test_exception_leaves_account_unchanged(?string $raw): void + { + $this->company('selected'); + $before = DB::table('company_qcc_accounts')->get()->toJson(); + $mock = Mockery::mock(YuanheRepository::class); + $mock->shouldReceive('enterprisePackInfo')->once()->andThrow(new YuanhePackInfoException('请求超时', null, null, $raw)); + $this->app->instance(YuanheRepository::class, $mock); + $this->assertSame(1, Artisan::call('qcc:verify-pack-info', ['company_id' => 333])); + $this->assertSame(($raw ?? '请求超时').PHP_EOL, Artisan::output()); + $this->assertSame($before, DB::table('company_qcc_accounts')->get()->toJson()); + } + + public static function exceptionCases(): array + { + return [[null], ['Bad Gateway']]; + } + + /** @dataProvider rejectedCases */ + public function test_invalid_input_does_not_call_api(string $status, array $options): void + { + $this->company($status); + $mock = Mockery::mock(YuanheRepository::class); + $mock->shouldNotReceive('enterprisePackInfo'); + $this->app->instance(YuanheRepository::class, $mock); + $this->assertSame(1, Artisan::call('qcc:verify-pack-info', ['company_id' => 333] + $options)); + $this->assertSame($status, DB::table('company_qcc_accounts')->value('status')); + } + + public static function rejectedCases(): array + { + return [ + ['unknown', []], ['non_occupying', []], ['selected', ['--timeout' => '0']], + ['selected', ['--timeout' => '-1']], ['selected', ['--timeout' => '1.5']], + ['selected', ['--timeout' => 'abc']], + ]; + } +}