Files
YwxAppThink/ywxapp/command/AddonManage.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

280 lines
12 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
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\command;
use Exception;
use think\console\Command;
use think\console\Input;
use think\console\input\Argument;
use think\console\input\Option;
use think\console\Output;
use think\File;
use ywxapp\exception\AddonException;
use ywxapp\service\AddonService;
use ywxapp\service\AddonHotReload;
use think\facade\Env;
/**
* 插件统一管理命令(合并 develop / install / reload)
*
* 用法:
* php think addon:manage <插件名> -a develop
* php think addon:manage <插件名> -a install [--local=<zip>] [--uid=<会员ID>] [--force]
* php think addon:manage [<插件名>] -a reload [--force] [--status]
*
* 说明:
* - develop / install / reload 逻辑内联于此;
* - 菜单重载 / 数据库自愈 / 配置补全已由 addon:repair 统一负责(含 --menu 单项),
* 故本命令不再保留 refresh-menu;
* - health 逻辑体量大且复用 AddonHealth,故委托 ywxapp\command\AddonHealth 执行(长选项形式传参,规避短选项冲突)。
*/
class AddonManage extends Command
{
/**
* 配置命令
*/
protected function configure()
{
$this->setName('addon:manage')
->addArgument('addon', Argument::OPTIONAL, '插件名(部分动作需要;留空语义见各动作)')
->addOption('action', 'a', Option::VALUE_REQUIRED, '动作: develop|install|reload')
// install
->addOption('local', 'l', Option::VALUE_REQUIRED, '离线 zip 路径(install 离线安装)')
// 离线安装付费插件时的授权校验身份:中心站 Market::valid() 按 uid 查授权,
// CLI 下无会话,必须显式指定,否则 uid=0 会被判「请先购买」。
->addOption('uid', 'u', Option::VALUE_REQUIRED, '授权校验用市场会员 ID(离线装付费插件时需携带,默认 0)')
->addOption('force', 'f', Option::VALUE_NONE, '强制(install 在线强制覆盖 / reload 强制重载)')
// reload
->addOption('status', 's', Option::VALUE_NONE, '查看重载状态(reload)')
->setDescription('插件统一管理:开发安装 / 安装 / 重导菜单 / 热重载');
}
/**
* 执行命令:按 -a 分发
*/
protected function execute(Input $input, Output $output): int
{
$action = strtolower(trim((string) $input->getOption('action')));
if ($action === '') {
$output->writeln('<error>请通过 -a/--action 指定动作:develop|install|reload</error>');
return 1;
}
switch ($action) {
case 'develop':
return $this->doDevelop($input, $output);
case 'install':
return $this->doInstall($input, $output);
case 'reload':
return $this->doReload($input, $output);
default:
$output->writeln('<error>未知动作: ' . $action . '(支持 develop|install|reload;菜单重载请用 addon:repair --menu)</error>');
return 1;
}
}
/**
* 开发者模式安装(源码已在 addon/<name>,原地建表/注入菜单/启用,免打包)
*/
private function doDevelop(Input $input, Output $output): int
{
$name = trim((string) $input->getArgument('addon'));
if ($name === '') {
$output->writeln('<error>请指定插件名,例如:php think addon:manage demo -a develop</error>');
return 1;
}
try {
$info = AddonService::instance($name)->developInstall();
$version = $info['version'] ?? '';
$output->writeln("<info>开发模式安装成功:{$name} v{$version}</info>");
$output->writeln('<info>已建表、注入菜单并启用,可直接测试。</info>');
} catch (AddonException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
} catch (\Throwable $e) {
// \Throwable 兜底:\Error(如类型错误)不是 \Exception 子类,必须单独捕获,
// 否则会漏到框架层导致无友好提示。
$output->writeln('<error>开发模式安装失败:' . $e->getMessage() . '</error>');
if (Env::get('app_debug')) {
$output->writeln('<error>FILE: ' . $e->getFile() . ':' . $e->getLine() . '</error>');
$output->writeln('<error>TRACE: ' . $e->getTraceAsString() . '</error>');
}
return 1;
}
return 0;
}
/**
* 安装插件:在线下载安装(默认)或离线 zip 安装
*/
private function doInstall(Input $input, Output $output): int
{
$name = trim((string) $input->getArgument('addon'));
if ($name === '' || !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
$output->writeln('<error>请提供合法的插件名,例如:php think addon:manage demo -a install</error>');
return 1;
}
// 离线安装付费插件时,中心站 Market::valid() 按 uid 查授权;
// CLI 没有会话,install_user 恒为 0,必须先从 --uid 取身份再一起透传。
$uid = (int) $input->getOption('uid');
$extend = [
'install_user' => $uid,
'install_ip' => 'cli',
'install_time' => time(),
'uid' => $uid,
];
try {
$localOpt = (string) $input->getOption('local');
if ($localOpt !== '') {
$local = realpath($localOpt);
if ($local === false || !is_file($local)) {
$output->writeln("<error>Local zip not found: {$localOpt}</error>");
return 1;
}
$output->writeln("<comment>Offline install from '{$local}' (addon='{$name}')...</comment>");
$file = new File($local);
AddonService::instance($name)->local($file, $extend);
$output->writeln("<info>Addon '{$name}' offline-installed successfully.</info>");
return 0;
}
$force = (bool) $input->getOption('force');
$output->writeln("<comment>Online install '{$name}' (force=" . ($force ? 'yes' : 'no') . ")...</comment>");
AddonService::instance($name)->install($force, $extend);
$output->writeln("<info>Addon '{$name}' online-installed successfully.</info>");
return 0;
} catch (AddonException $e) {
$output->writeln('<error>Install failed: ' . $e->getMessage() . '</error>');
return 1;
} catch (Exception $e) {
$output->writeln('<error>Install failed: ' . $e->getMessage() . '</error>');
return 1;
}
}
/**
* 插件热重载(需 app_debug)
*/
private function doReload(Input $input, Output $output): int
{
if (!config('app.app_debug')) {
$output->error('热重载功能仅在开发环境可用');
return 1;
}
$addon = trim((string) $input->getArgument('addon'));
$force = (bool) $input->getOption('force');
$showStatus = (bool) $input->getOption('status');
if ($showStatus) {
return $this->reloadStatus($addon, $output);
}
if ($addon) {
return $this->reloadSingle($addon, $force, $output);
}
return $this->reloadAll($output);
}
private function reloadSingle(string $addon, bool $force, Output $output): int
{
$output->writeln("<info>正在重载插件: {$addon}</info>");
try {
$reloaded = AddonHotReload::reloadAddon($addon, $force);
if ($reloaded) {
$output->writeln("<info>插件 {$addon} 重载成功</info>");
$status = AddonHotReload::getReloadStatus($addon);
if (isset($status['files_changed'])) {
$output->writeln('变更文件: ' . implode(', ', $status['files_changed']));
}
} else {
$output->writeln("<comment>插件 {$addon} 无文件变更,无需重载</comment>");
}
return 0;
} catch (\Exception $e) {
$output->error('重载失败: ' . $e->getMessage());
return 1;
}
}
private function reloadAll(Output $output): int
{
$output->writeln('<info>正在重载所有已启用插件...</info>');
try {
$results = AddonHotReload::reloadAlladdon();
$successCount = 0;
$reloadedCount = 0;
$failedCount = 0;
foreach ($results as $addon => $result) {
if ($result['success']) {
$successCount++;
if ($result['reloaded']) {
$reloadedCount++;
$output->writeln("<info>✓ {$addon} - 已重载</info>");
} else {
$output->writeln("<comment>- {$addon} - 无变更</comment>");
}
} else {
$failedCount++;
$output->writeln("<error>✗ {$addon} - {$result['error']}</error>");
}
}
$output->writeln('');
$output->writeln('<info>重载完成:</info>');
$output->writeln(" 成功: {$successCount}");
$output->writeln(" 实际重载: {$reloadedCount}");
$output->writeln(" 失败: {$failedCount}");
return $failedCount > 0 ? 1 : 0;
} catch (\Exception $e) {
$output->error('重载失败: ' . $e->getMessage());
return 1;
}
}
private function reloadStatus(string $addon, Output $output): int
{
if ($addon) {
$status = AddonHotReload::getReloadStatus($addon);
$output->writeln("<info>插件 {$addon} 重载状态:</info>");
$output->writeln(' 状态: ' . ($status['status'] ?? 'unknown'));
if (isset($status['reload_time'])) {
$output->writeln(' 最后重载时间: ' . date('Y-m-d H:i:s', $status['reload_time']));
}
if (isset($status['error'])) {
$output->writeln(' 错误信息: ' . $status['error']);
}
if (isset($status['files_changed'])) {
$output->writeln(' 变更文件: ' . implode(', ', $status['files_changed']));
}
} else {
$stats = AddonHotReload::getReloadStatistics();
$output->writeln('<info>插件热重载统计:</info>');
$output->writeln(' 总重载次数: ' . ($stats['total_reloads'] ?? 0));
$output->writeln(' 成功次数: ' . ($stats['successful_reloads'] ?? 0));
$output->writeln(' 失败次数: ' . ($stats['failed_reloads'] ?? 0));
if (!empty($stats['addon'])) {
$output->writeln('');
$output->writeln('<info>各插件重载状态:</info>');
foreach ($stats['addon'] as $name => $status) {
$icon = ($status['status'] ?? '') === 'success' ? '✓' : '✗';
$color = ($status['status'] ?? '') === 'success' ? 'info' : 'error';
$output->writeln("<{$color}>{$icon} {$name}</{$color}>");
if (isset($status['reload_time'])) {
$output->writeln(' 最后重载: ' . date('Y-m-d H:i:s', $status['reload_time']));
}
}
}
}
return 0;
}
}