Files
YwxAppThink/addon/paycenter/command/Check.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

275 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace addon\paycenter\command;
use addon\paycenter\library\PayConfig;
use addon\paycenter\library\PayFactory;
use addon\paycenter\library\Schema;
use addon\paycenter\service\Payment;
use think\console\Command;
use think\console\input\Option;
use think\console\Input;
use think\console\Output;
use Yansongda\Pay\Pay;
/**
* 支付配置自检(由 app\appmall\command\PayCheck 迁来,覆盖两个渠道)。
*
* 为什么需要:证书模式的配置项有 6 个(含三份证书文件),任一项缺失/错填
* 都只会在「下单那一瞬间」抛 InvalidConfigException,排查成本高。这里在没有订单、
* 没有页面的情况下一次性把问题列出来。
*
* 用法:
* php think paycenter:check 检查全部渠道
* php think paycenter:check --channel=alipay 只看支付宝
* php think paycenter:check --ping 额外向网关发一次查单,验证签名是否被接受
*
* 注意:回调验签依赖 alipay_public_cert_path,缺它验签会直接失败;
* 而「证书与私钥是否配对」只有真正向网关发一次请求才能验证,
* 因此 --ping 是唯一能在没有买家付款的前提下端到端确认配置的方式。
*/
class Check extends Command
{
protected function configure(): void
{
$this->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;
}
}