master
lion 4 days ago
parent 16c8555094
commit 08855e66ce

@ -6,20 +6,27 @@ use App\Exceptions\YuanhePackInfoException;
use App\Models\Company; use App\Models\Company;
use App\Models\CompanyQccAccount; use App\Models\CompanyQccAccount;
use App\Repositories\YuanheRepository; use App\Repositories\YuanheRepository;
use App\Services\QccCallService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable; use Throwable;
class VerifyQccEnterprisePackInfo extends Command class VerifyQccEnterprisePackInfo extends Command
{ {
protected $signature = 'qcc:verify-pack-info protected $signature = 'qcc:verify-pack-info
{company_id : 已纳入候选池的本地企业 ID} {company_id : 候选或已占额企业的本地企业 ID}
{--raw : 输出元禾完整响应 JSON}'; {--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')); $company = Company::find($this->argument('company_id'));
if (!$company) { if (!$company) {
$this->error('企业不存在。'); $this->error('企业不存在。');
@ -27,9 +34,9 @@ class VerifyQccEnterprisePackInfo extends Command
} }
$account = $company->qccAccount; $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'; $status = $account ? $account->status : 'none';
$this->error("企业必须处于候选状态(selected),当前状态:{$status}。"); $this->error("企业必须处于候选(selected)或已占额(occupied)状态,当前状态:{$status}。");
return self::FAILURE; return self::FAILURE;
} }
@ -38,74 +45,25 @@ class VerifyQccEnterprisePackInfo extends Command
return self::FAILURE; 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 { try {
$response = (new QccCallService())->call( $response = $yuanheRepository->enterprisePackInfo([
$company, 'creditCode' => $company->credit_code,
'enterprise-pack-info-cli', 'enterpriseName' => $company->company_name,
$requestRef, ], $timeout);
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
);
} catch (Throwable $exception) { } catch (Throwable $exception) {
$this->error($exception->getMessage()); // 非 JSON 响应保留原文;没有响应时仅输出错误,不打印签名或请求头。
$this->line('签名 timestamp:' . ($yuanheRepository->lastRequestTimestamp ?? '(未生成)')); $result = $exception instanceof YuanhePackInfoException && $exception->rawResponse !== null
if ($yuanheRepository->lastRequest) { ? $exception->rawResponse
$this->line('完整请求入参(不含 authKey):'); : $exception->getMessage();
$this->line(json_encode($yuanheRepository->lastRequest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); $this->output->writeln($result, OutputInterface::OUTPUT_RAW);
}
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);
return self::FAILURE; return self::FAILURE;
} }
$freshAccount = $account->fresh(); $this->output->writeln(
$this->line('完整请求入参(不含 authKey):'); json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
$this->line(json_encode($yuanheRepository->lastRequest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); OutputInterface::OUTPUT_RAW
$this->line('签名 timestamp:' . ($yuanheRepository->lastRequestTimestamp ?? '(未生成)'));
$this->table(
['元禾 code', '元禾 msg', '调用后状态', '首次成功引用'],
[[
$response['code'] ?? '(缺失)',
$response['msg'] ?? '',
$freshAccount->status,
$freshAccount->first_success_ref ?? '',
]]
); );
if ($this->option('raw')) { return in_array($response['code'] ?? null, [200, '200'], true) ? self::SUCCESS : self::FAILURE;
$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;
} }
} }

@ -7,7 +7,7 @@
- 点击“获取更新”创建一个全量候选和已占额企业快照,不受新闻列表筛选和分页影响;仅 `company_qcc_accounts.status` 为 `selected` 或 `occupied` 的企业进入批次,按公司 ID 顺序执行。 - 点击“获取更新”创建一个全量候选和已占额企业快照,不受新闻列表筛选和分页影响;仅 `company_qcc_accounts.status` 为 `selected` 或 `occupied` 的企业进入批次,按公司 ID 顺序执行。
- 执行前再次检查企业是否仍为候选或已占额;不符合的任务记录失败/跳过,不调用元禾。 - 执行前再次检查企业是否仍为候选或已占额;不符合的任务记录失败/跳过,不调用元禾。
- 单次请求使用元禾 `enterprise/packInfo`,连接超时 10 秒、总超时 60 秒;只有外层 `code=200` 且 `data.news` 是数组或 null 才进行处理。null/空数组记成功、0 新增;缺字段、非数组记失败。 - 单次请求使用元禾 `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;来源标识必须跨次稳定。同一新闻关联不同公司分别保存。 - 按企业、数据提供方及去重键建立唯一约束。去重键按 NewsId、Id、原文 URL 的优先级生成 SHA-256;来源标识必须跨次稳定。同一新闻关联不同公司分别保存。
- 重复采集只更新来源字段、原始 JSON 与最近采集信息,不覆盖标题、人工正文和人工留存结论;自动判断的记录会按本批次分类设置重新判断,始终不展示。不留存是逻辑状态,不删除记录。 - 重复采集只更新来源字段、原始 JSON 与最近采集信息,不覆盖标题、人工正文和人工留存结论;自动判断的记录会按本批次分类设置重新判断,始终不展示。不留存是逻辑状态,不删除记录。
- 原文 HTML 使用现有 TinyMCE 组件编辑,并通过服务端 HTMLPurifier 清理。来源 Content 独立保存,接口正文为空时不会伪造原文。 - 原文 HTML 使用现有 TinyMCE 组件编辑,并通过服务端 HTMLPurifier 清理。来源 Content 独立保存,接口正文为空时不会伪造原文。

@ -0,0 +1,110 @@
<?php
namespace Tests\Feature;
use App\Exceptions\YuanhePackInfoException;
use App\Repositories\YuanheRepository;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Tests\TestCase;
class VerifyQccEnterprisePackInfoTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => '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' => '<info>标题</info>']]]], [], 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], ['<html>Bad Gateway</html>']];
}
/** @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']],
];
}
}
Loading…
Cancel
Save