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:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+274
View File
@@ -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;
}
}