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

+191 -33
View File
@@ -17,6 +17,7 @@ use think\exception\HttpException;
use think\facade\Config;
use think\facade\Request;
use think\facade\View;
use think\facade\Cache;
use ywxapp\exception\AddonException;
use ywxapp\service\AddonService;
use ywxapp\service\AddonDevService;
@@ -45,6 +46,8 @@ class Addons extends BackendBase
if ($this->request->isAjax()) {
$results = scandir(ADDON_PATH);
$list = [];
// 市场最新版本:用于列表内「可升级」提醒(市场不可达时静默降级为空)
$latestMap = $this->latestVersions();
foreach ($results as $name) {
if ($name === '.' or $name === '..' or is_file(ADDON_PATH . $name)) {
continue;
@@ -65,8 +68,12 @@ class Addons extends BackendBase
$info['id'] = $info['name'];
$info['description'] = $info['intro'] ?? '';
$info['status'] = $info['state'] ?? 0;
$info['hasConfig'] = is_file($addonDir . 'config.php');
$list[] = $info;
$info['hasConfig'] = is_file($addonDir . 'config.php');
$latest = (string) ($latestMap[$info['name']] ?? '');
$info['latest_version'] = $latest;
$info['upgradable'] = $latest !== ''
&& version_compare($latest, (string) ($info['version'] ?? '0'), '>');
$list[] = $info;
}
// 关键字搜索
@@ -96,6 +103,46 @@ class Addons extends BackendBase
return View::fetch('addon/index');
}
/**
* 市场最新版本号映射(插件标识 => 版本号),5 分钟缓存
*
* 用于已装列表的「可升级」提醒。市场不可达 / 未配置 / 未安装 appmall 时返回空数组,
* 列表退化为不显示升级标记,不影响插件管理主流程。
*/
private function latestVersions(): array
{
try {
return Cache::remember('addon_market_latest_versions', function () {
$list = [];
try {
if (is_market_client()) {
$resp = (new RemoteService())->lists([]);
$list = $resp['data']['list'] ?? [];
} elseif (class_exists(\app\appmall\service\MarketService::class)) {
$list = \app\appmall\service\MarketService::instance()
->catalog('', '', 'new', 'addon')['list'] ?? [];
}
} catch (\Throwable $e) {
return [];
}
$map = [];
foreach ((array) $list as $it) {
if (($it['type'] ?? 'addon') !== 'addon') {
continue;
}
$n = (string) ($it['name'] ?? '');
$v = (string) ($it['version'] ?? '');
if ($n !== '' && $v !== '') {
$map[$n] = $v;
}
}
return $map;
}, 300);
} catch (\Throwable $e) {
return [];
}
}
/**
* 应用市场(Discuz 式远程插件市场,核心后台「插件管理」)
*
@@ -145,13 +192,13 @@ class Addons extends BackendBase
} else {
$error = '未配置市场地址(请在后台配置 ywxapp.api_url)';
}
} elseif (!class_exists(\addon\appmall\service\MarketService::class)) {
} elseif (!class_exists(\app\appmall\service\MarketService::class)) {
// 本机即中心站但未安装 appmall 插件:市场目录不可用
$error = '本机未安装 appmall 插件:请安装插件以启用市场目录,或配置 ywxapp.api_url 指向中心站';
} else {
// 服务中心(本机):委托 appmall 插件读取本地市场目录(中心域逻辑全部在插件侧)
try {
$r = \addon\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
$r = \app\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
$list = $r['list'];
$categories = $r['categories'];
if (empty($list)) {
@@ -275,14 +322,14 @@ class Addons extends BackendBase
if (is_market_client()) {
return $this->result->error('远程模式订单存于中心站,请于会员中心申请退款');
}
if (!class_exists(\addon\appmall\service\MarketService::class)) {
if (!class_exists(\app\appmall\service\MarketService::class)) {
return $this->result->error('本机未安装 appmall 插件,无法执行退款');
}
try {
\addon\appmall\service\MarketService::instance()
\app\appmall\service\MarketService::instance()
->operatorRefund((string) $request->post('name', ''));
} catch (\Throwable $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
return $this->result->success([], '退款成功,授权已吊销');
}
@@ -303,7 +350,7 @@ class Addons extends BackendBase
}
$token = config('ywxapp.developer_token', '');
if (!$token) {
return $this->result->error('未配置开发者令牌:请在 .env 设置 DEVELOPER_TOKEN');
return $this->result->error('未配置开发者令牌:请在 .env 设置 APPMARKET_DEV_TOKEN');
}
$AddonService = AddonService::instance($name);
if (!$AddonService->isInstalled()) {
@@ -354,9 +401,9 @@ class Addons extends BackendBase
}
return $this->result->error('提交到官方市场失败:' . $msg);
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -375,7 +422,7 @@ class Addons extends BackendBase
$zipFile = $AddonService->package();
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -400,7 +447,11 @@ class Addons extends BackendBase
];
$info = AddonService::instance()->local($file, $extend);
} catch (AddonException $e) {
$this->result->error(LANG($e->getMessage(), $e->getCode()));
$this->result->error(
LANG($e->getMessage(), $e->getCode()),
$e->getCode() ?: 1,
$e->getData() ?: null
);
} catch (\Exception $e) {
$this->result->error(lang($e->getMessage()));
}
@@ -426,14 +477,20 @@ class Addons extends BackendBase
$extend = [
'install_user' => session('user_id'),
'install_ip' => Request::ip(),
'install_time' => time()
'install_time' => time(),
// 中心站 Market::valid() 读的是 **uid**(不是 install_user):
// 离线上传付费插件时若不带 uid,服务端收到 uid=0 会直接判
// 「该插件为付费插件,请先购买」,导致后台永远装不上自己买的插件。
// 这里的 uid 指「市场会员 ID」;后台管理员 ID 只在「本机即中心站、
// 且管理员与市场会员同一套账号」时等价,跨机器场景请走会员登录态。
'uid' => (int) (session('user_id') ?? 0),
];
$info = $AddonService->local($file, $extend);
return $this->result->success($info, '插件安装成功');
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
/**
@@ -447,15 +504,21 @@ class Addons extends BackendBase
if (!$addonName || !preg_match('/^[a-zA-Z0-9_]+$/', $addonName)) {
return json(['code' => 0, 'msg' => '插件名称格式不正确']);
}
// 卸载选项(前端勾选,缺省沿用服务层默认:备份 + 删除)
// backup_data=0 不备份;delete_data=0 保留插件数据表
$options = [
'backup_data' => (bool) input('backup_data', 1),
'delete_data' => (bool) input('delete_data', 1),
];
$AddonService = AddonService::instance($addonName);
$result = $AddonService->uninstall();
$result = $AddonService->uninstall($options);
if ($result) {
$this->result->success([], '插件卸载成功');
} else {
$this->result->error('插件卸载失败');
}
} catch (AddonException $e) {
$this->result->error('插件卸载失败:' . $e->getMessage());
$this->result->error('插件卸载失败:' . $e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
$this->result->error('插件卸载失败:' . $e->getMessage());
}
@@ -499,7 +562,7 @@ class Addons extends BackendBase
$zipFile = $AddonService->package();
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -534,9 +597,9 @@ class Addons extends BackendBase
$info = $AddonService->local($file, $extend);
return $this->result->success($info, '下载并安装成功');
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -562,9 +625,9 @@ class Addons extends BackendBase
'升级成功:' . ($res['from'] ?? '') . ' → ' . ($res['to'] ?? '')
);
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -585,10 +648,28 @@ class Addons extends BackendBase
$post = input('post.');
$data = [];
foreach ($fields as $f) {
$n = $f['name'] ?? '';
if ($n !== '' && array_key_exists($n, $post)) {
$data[$n] = $post[$n];
$n = $f['name'] ?? '';
$type = $f['type'] ?? 'text';
if ($n === '') {
continue;
}
if (array_key_exists($n, $post)) {
$v = $post[$n];
} else {
// 未提交:开关视为关闭、多选视为空数组;其余字段不覆盖已有值
if ($type === 'switch') {
$v = 0;
} elseif ($type === 'checkbox') {
$v = [];
} else {
continue;
}
}
// array 型:textarea 的「键=值」多行文本转数组存储
if ($type === 'array' && is_string($v)) {
$v = self::parseKvText($v);
}
$data[$n] = $v;
}
AddonService::config($addon, $data);
AddonService::clearConfigCache($addon);
@@ -596,8 +677,30 @@ class Addons extends BackendBase
}
$saved = AddonService::config($addon);
foreach ($fields as &$f) {
if (isset($saved[$f['name']])) {
$f['value'] = $saved[$f['name']];
$n = $f['name'] ?? '';
$type = $f['type'] ?? 'text';
if ($n !== '' && isset($saved[$n])) {
$val = $saved[$n];
// array 型:数组还原成「键=值」文本供 textarea 编辑
if ($type === 'array' && is_array($val)) {
$val = implode(PHP_EOL, array_map(
function ($k, $v) {
return $k . '=' . (is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
},
array_keys($val),
$val
));
}
$f['value'] = $val;
}
// 缺省值兜底:无已存值时用 config.php 的 default,避免模板取到未定义变量
if (! isset($f['value'])) {
$f['value'] = $f['default'] ?? ($type === 'checkbox' ? [] : ($type === 'switch' ? 0 : ''));
}
// options 容错:正则化写法。select/radio/checkbox 的 options 既可以是数组,
// 也可以写字符串('0:否,1:是' 或 '北京,上海'),省得插件作者写一堆嵌套数组
if (isset($f['options'])) {
$f['options'] = self::normalizeOptions($f['options']);
}
}
unset($f);
@@ -605,6 +708,61 @@ class Addons extends BackendBase
return View::fetch('addon/setting');
}
/**
* 解析「键=值」多行文本为数组(array 型插件配置用)
*
* 每行一条:`key=value` 写入关联键,无等号的整行作为列表项。
*/
private static function parseKvText(string $text): array
{
$arr = [];
foreach (preg_split('/\r\n|\r|\n/', $text) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
if (strpos($line, '=') !== false) {
[$k, $v] = explode('=', $line, 2);
$arr[trim($k)] = trim($v);
} else {
$arr[] = $line;
}
}
return $arr;
}
/**
* 归一化 select/radio/checkbox 的 options
*
* 支持三种写法(最终统一成 值=>标题 数组):
* [1 => '开启', 0 => '关闭'] 数组(原样返回)
* '0:关闭,1:开启' 键值对字符串
* '北京,上海' 纯列表(值与标题相同)
*/
private static function normalizeOptions($options): array
{
if (is_array($options)) {
return $options;
}
if (! is_string($options) || trim($options) === '') {
return [];
}
$out = [];
foreach (explode(',', $options) as $item) {
$item = trim($item);
if ($item === '') {
continue;
}
if (str_contains($item, ':')) {
[$k, $v] = explode(':', $item, 2);
$out[trim($k)] = trim($v);
} else {
$out[$item] = $item;
}
}
return $out;
}
// ==================== 开发模式:插件设计器(抄 Discuz!) ====================
/**
@@ -645,7 +803,7 @@ class Addons extends BackendBase
$info = AddonDevService::instance($name)->createSkeleton($meta);
return $this->result->success($info, '插件骨架创建成功');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -665,7 +823,7 @@ class Addons extends BackendBase
$data = AddonDevService::instance($name)->readAll();
return $this->result->success($data);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -711,7 +869,7 @@ class Addons extends BackendBase
}
return $this->result->success([], '保存成功');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -734,7 +892,7 @@ class Addons extends BackendBase
$rel = ltrim(str_replace(ADDON_PATH . $name . DIRECTORY_SEPARATOR, '', $file), DIRECTORY_SEPARATOR);
return $this->result->success(['file' => $rel], '生成成功:' . $rel);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -754,7 +912,7 @@ class Addons extends BackendBase
$info = AddonDevService::instance($name)->developInstall();
return $this->result->success($info, '开发模式安装成功,菜单已注入并启用');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -774,7 +932,7 @@ class Addons extends BackendBase
AddonDevService::instance($name)->remove();
return $this->result->success([], '插件目录已删除');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
}