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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,274 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user