setName('paycenter:check') ->setDescription('检查支付配置可用性(SDK / mode / 证书 / 沙箱连通性)') ->addOption('ping', null, Option::VALUE_NONE, '向网关发一次真实查单,验证签名是否被接受') ->addOption('channel', null, Option::VALUE_OPTIONAL, '检查的通道:alipay / wechat / all', 'all'); } protected function execute(Input $input, Output $output): int { $channel = strtolower((string) $input->getOption('channel') ?: 'all'); $output->writeln('== 支付配置自检(paycenter)=='); // 0) 插件表 Schema::ensure(); $output->writeln('[ OK ] 流水表已就绪(' . Schema::prefix() . 'paycenter_trade)'); // 1) SDK if (!PayFactory::available()) { $output->writeln('[FAIL] 未安装支付 SDK:composer require yansongda/pay'); return 1; } $output->writeln('[ OK ] 支付 SDK:yansongda/pay 已加载'); // 2) mock 开关(开着的话真实支付链路根本不会执行) $mock = (bool) PayConfig::mockEnabled(); $output->writeln($mock ? '[WARN] PAY_MOCK=true:购买会直接签发授权,不会走沙箱/正式支付' : '[ OK ] PAY_MOCK=false:购买将走真实支付链路'); $channels = $channel === 'all' ? ['alipay', 'wechat'] : [$channel]; $fail = 0; foreach ($channels as $code) { $output->writeln(sprintf('-- 渠道 %s(可用:%s)--', $code, Payment::enabled($code) ? '是' : '否')); $fail += $code === 'wechat' ? $this->checkWechat($output) : $this->checkAlipay($output); } // 4) 端到端 ping if ($input->getOption('ping')) { foreach ($channels as $code) { if (!Payment::enabled($code)) { $output->writeln(sprintf('[SKIP] %s 未配置/未启用,跳过查单', $code)); continue; } $fail += $this->ping($output, $code); } } if ($fail > 0) { $output->writeln(sprintf('== 存在 %d 项问题,请先修复再下单 ==', $fail)); return 1; } $output->writeln('== 配置检查通过 =='); return 0; } private function checkAlipay(Output $output): int { $cfg = PayConfig::get('alipay'); $fail = 0; // mode 必须是整数:字符串会在拼网关 URL 时数组越界 $mode = $cfg['mode'] ?? null; if (!is_int($mode)) { $output->writeln(sprintf('[FAIL] alipay.mode 必须是整数(当前 %s)', var_export($mode, true))); ++$fail; } else { $name = [Pay::MODE_NORMAL => '正式', Pay::MODE_SANDBOX => '沙箱', Pay::MODE_SERVICE => '服务商'][$mode] ?? '未知'; $output->writeln(sprintf('[ OK ] alipay.mode=%d(%s)→ %s', $mode, $name, \Yansongda\Pay\Provider\Alipay::URL[$mode] ?? '未知网关')); } if (empty($cfg['app_id'])) { $output->writeln('[FAIL] alipay.app_id 未配置'); ++$fail; } else { $output->writeln('[ OK ] alipay.app_id=' . $cfg['app_id']); } // 应用私钥:需能被 openssl 解析,且与证书配对(配对关系只能靠 ping 验证) $secret = (string) ($cfg['app_secret_cert'] ?? ''); if ($secret === '') { $output->writeln('[FAIL] alipay.app_secret_cert 未配置(应用私钥)'); ++$fail; } else { // 关键:要按 SDK 的 get_private_cert() 规则还原后【真的签一次名】才算通过。 // 只调 openssl_pkey_get_private() 不够——PEM 头尾标签写错时它照样返回对象, // 直到 openssl_sign() 才炸 "Supplied key param cannot be coerced into a private key"。 $pem = PayConfig::toPem($secret); $key = openssl_pkey_get_private($pem); $sign = ''; $signed = $key !== false && @openssl_sign('paycheck', $sign, $key, OPENSSL_ALGO_SHA256); if ($signed) { $output->writeln('[ OK ] 应用私钥可解析'); // 本地配对校验:私钥的模数必须与「应用公钥证书」里的公钥模数一致。 // 能提前拦下最常见的坑 —— 在开放平台换过证书后没换对应的私钥。 $certPath = PayConfig::resolvePath((string) ($cfg['app_public_cert_path'] ?? '')); if ($certPath !== '' && is_file($certPath)) { $certKey = openssl_pkey_get_public(openssl_x509_read((string) file_get_contents($certPath))); $privMod = openssl_pkey_get_details($key)['rsa']['n'] ?? ''; $certMod = $certKey !== false ? (openssl_pkey_get_details($certKey)['rsa']['n'] ?? '') : ''; if ($privMod !== '' && $certMod !== '' && $privMod !== $certMod) { $output->writeln('[FAIL] 私钥与应用公钥证书【不是同一对】:请确认 .env 的私钥就是申请该证书时生成的那把,'); $output->writeln(' 开放平台重新切换「接口加签方式」会换发新证书,旧私钥随之失效'); ++$fail; } else { $output->writeln('[ OK ] 私钥与应用公钥证书为同一对'); } } } else { // 最常见的填错方式:把「应用公钥」当私钥贴进来 —— 两者都是长 base64,肉眼无法分辨 $stripped = (string) preg_replace('/-----[^-]+-----|\s+/', '', $pem); $asPublic = "-----BEGIN PUBLIC KEY-----\n" . wordwrap($stripped, 64, "\n", true) . "\n-----END PUBLIC KEY-----"; if (openssl_pkey_get_public($asPublic) !== false) { $output->writeln('[FAIL] 填的是【应用公钥】而不是私钥:请到开放平台取「应用私钥」,'); $output->writeln(' 其为约 1600+ 字符的裸 base64,而非 392 字符的公钥'); } else { $output->writeln('[FAIL] 应用私钥无法解析:请确认未混入空格、未遗漏中间字符'); } ++$fail; } } $certs = [ 'app_public_cert_path' => '应用公钥证书 appCertPublicKey', 'alipay_public_cert_path' => '支付宝公钥证书 alipayCertPublicKey(回调验签用)', 'alipay_root_cert_path' => '支付宝根证书 alipayRootCert', ]; foreach ($certs as $key => $desc) { $path = (string) ($cfg[$key] ?? ''); if ($path === '') { $output->writeln(sprintf('[FAIL] alipay.%s 未配置(%s)', $key, $desc)); ++$fail; continue; } $real = PayConfig::resolvePath($path); if (!is_file($real)) { $output->writeln(sprintf('[FAIL] %s 文件不存在:%s', $desc, $real)); ++$fail; continue; } $parsed = openssl_x509_parse((string) file_get_contents($real)); if ($parsed === false) { $output->writeln(sprintf('[FAIL] %s 无法解析为 X.509 证书:%s', $desc, $real)); ++$fail; continue; } $output->writeln(sprintf('[ OK ] %s(序列号 %s)', $desc, strtoupper((string) ($parsed['serialNumberHex'] ?? '')))); } return $fail; } private function checkWechat(Output $output): int { $cfg = PayConfig::get('wechat'); $fail = 0; foreach (['mch_id' => '商户号', 'app_id' => '应用 ID', 'mch_secret_key' => 'APIv3 密钥'] as $key => $desc) { if (empty($cfg[$key])) { $output->writeln(sprintf('[FAIL] wechat.%s 未配置(%s)', $key, $desc)); ++$fail; } else { $output->writeln(sprintf('[ OK ] wechat.%s 已配置(%s)', $key, $desc)); } } foreach (['mch_secret_cert' => '商户 API 私钥', 'mch_public_cert_path' => '商户 API 证书'] as $key => $desc) { if (empty($cfg[$key])) { $output->writeln(sprintf('[FAIL] wechat.%s 未配置(%s)', $key, $desc)); ++$fail; continue; } if ($key === 'mch_secret_cert') { // 与支付宝同理:必须真的签一次名才算通过 $keyRes = openssl_pkey_get_private(PayConfig::toPem((string) $cfg[$key])); $sign = ''; if ($keyRes === false || !@openssl_sign('paycheck', $sign, $keyRes, OPENSSL_ALGO_SHA256)) { $output->writeln('[FAIL] 商户 API 私钥无法解析:请填 PEM 全文、裸 base64 或私钥文件路径'); ++$fail; continue; } $output->writeln('[ OK ] 商户 API 私钥可解析(已签名验证)'); continue; } $real = PayConfig::resolvePath((string) $cfg[$key]); if (!is_file($real)) { $output->writeln(sprintf('[FAIL] %s 文件不存在:%s', $desc, $real)); ++$fail; continue; } $serial = strtoupper((string) (openssl_x509_parse((string) file_get_contents($real))['serialNumberHex'] ?? '')); $output->writeln(sprintf('[ OK ] %s 已配置(证书序列号 %s)', $desc, $serial)); } $output->writeln('[WARN] 微信没有官方沙箱,测试只能走 mock 或真实小额支付后退款'); return $fail; } /** * 向网关查一个注定不存在的订单: * 签名/证书有问题 → 抛 InvalidSignException(证书与私钥不配对) * 配置正确 → 支付宝返回 ACQ.TRADE_NOT_EXIST;微信返回 ORDER_NOT_EXIST */ private function ping(Output $output, string $channel): int { $output->writeln(sprintf('== 发起 %s 查单验证(out_trade_no 为自检专用,不影响业务)==', $channel)); try { $result = $channel === 'wechat' ? PayFactory::wechat()->query(['out_trade_no' => 'PAYCHECK' . date('YmdHis')]) : PayFactory::alipay()->query(['out_trade_no' => 'PAYCHECK' . date('YmdHis')]); $subCode = (string) $result->get('sub_code', '') ?: (string) $result->get('code', ''); } catch (\Throwable $e) { $msg = $e->getMessage(); // 微信把「订单不存在」当错误抛出,此处属于预期结果(说明签名已被接受) if ($channel === 'wechat' && (str_contains($msg, 'ORDER_NOT_EXIST') || str_contains($msg, 'ORDERNOTEXIST'))) { $output->writeln('[ OK ] 网关已接受签名(返回订单不存在,符合预期)'); return 0; } $output->writeln('[FAIL] 查单异常:' . get_class($e) . ' ' . $msg . ' @ ' . $e->getFile() . ':' . $e->getLine()); return 1; } if ($subCode === 'isv.invalid-signature') { $output->writeln('[FAIL] 网关拒绝了该签名:私钥与证书不匹配(最常见:平台已换发证书,.env 里还是旧私钥)'); return 1; } if (in_array($subCode, ['ACQ.TRADE_NOT_EXIST', 'ORDER_NOT_EXIST', 'ORDERNOTEXIST'], true)) { $output->writeln('[ OK ] 网关已接受签名(返回交易不存在,符合预期)→ 证书与私钥配对正确'); return 0; } $output->writeln(sprintf('[FAIL] 网关返回异常:%s %s', $subCode, (string) $result->get('sub_msg', '') ?: (string) $result->get('message', ''))); return 1; } }