Files
YwxAppThink/app/appmall/controller/api/Market.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

1275 lines
56 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 app\appmall\controller\api;
use app\appmall\library\LicensePolicy;
use app\appmall\model\AddonOrder;
use app\appmall\model\AddonReview;
use app\appmall\service\MarketService;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Db;
use think\facade\Env;
use think\facade\Log;
use think\facade\Request;
use ywxapp\library\StreamZipResponse;
/**
* 插件市场服务端(应用市场后端)
*
* 由原核心 app/api/controller/Market.php 剥离而来,部署在中心站(市场服务端)的 appmall 应用内。
* 接口契约对齐 ywxapp 客户端 ywxapp\service\AddonService:
* - GET /api/addon/lists 插件列表(Addons::market 使用)
* - POST /api/addon/valid 安装前授权校验(AddonService::valid 使用,multipart)
* - GET /api/index 下载插件 zip(AddonService::download 使用,/api/index/index 规范化)
* - POST /api/addon/submit 开发者提交插件(待审核)
*
* 数据表:wxapp_appmall_addon_list(商品)、wxapp_appmall_addon_licenses(授权,由核心 install.sql 建)
*
* 重要契约:本控制器所有响应以 code=1 表示成功、code=0 表示失败(msg 为原因),
* 与框架统一 Result 契约(code=0 成功)【相反】。客户端 RemoteAppmallService::parse()
* 已按 code===1 解析。修改响应码时务必保持两边一致,勿误改为 Result 约定。
*/
class Market
{
/**
* 1) 插件列表(应用市场)
* 支持 keyword(名称/标题/作者/描述模糊)、category(精确)、order(new/hot/price_asc/price_desc)、分页 page/page_size。
* 无 page 参数时返回全部(保持旧客户端兼容);有 page 时返回分页元数据。
*/
public function lists()
{
$this->ensureTables();
$keyword = trim((string) Request::param('keyword', ''));
$category = trim((string) Request::param('category', ''));
$type = trim((string) Request::param('type', ''));
$orderBy = Request::param('order', 'new');
$page = (int) Request::param('page', 0);
$pageSize = (int) Request::param('page_size', 12);
// 读多写少:目录缓存 60s,显著降低 DB/磁盘压力。
// 缓存键混入目录版本号(catalog:ver):审核发布/改价等写操作 bump 版本号后立即失效,无需等 60s。
$catalogVer = (string) Cache::get('appmall:catalog:ver', '0');
$cacheKey = 'appmall:lists:' . $catalogVer . ':' . md5(json_encode([$keyword, $category, $type, $orderBy, $page, $pageSize]));
if (($cached = Cache::get($cacheKey)) !== null && is_array($cached)) {
return json($cached);
}
$query = Db::name('appmall_addon_list')->where('status', 1);
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('name', 'like', '%' . $keyword . '%')
->whereOr('title', 'like', '%' . $keyword . '%')
->whereOr('author', 'like', '%' . $keyword . '%')
->whereOr('description', 'like', '%' . $keyword . '%');
});
}
if ($category !== '') {
$query->where('category', $category);
}
if ($type !== '') {
$query->where('type', $type);
}
$orderMap = [
'new' => ['id', 'desc'],
'hot' => ['download_count', 'desc'],
'price_asc' => ['price', 'asc'],
'price_desc' => ['price', 'desc'],
];
[$of, $od] = $orderMap[$orderBy] ?? $orderMap['new'];
$query->order($of, $od);
// 动态拼装字段:元数据列可能尚未存在,存在才选,保证兼容
$prefix = \ywxapp\model\BaseModel::currentPrefix();
$existCols = array_column(Db::query("SHOW COLUMNS FROM `{$prefix}appmall_addon_list`"), 'Field');
$field = 'id,name,title,description as intro,author,version,logo,price,download_count,update_at,changelog,require_framework';
foreach (['category', 'tags', 'screenshots', 'rating', 'type'] as $mf) {
if (in_array($mf, $existCols, true)) {
$field .= ',' . $mf;
}
}
try {
// 同名插件多版本聚合:列表只展示最新版一条,其余版本收进 versions 数组
//(详情页/弹窗用版本下拉切换安装,避免市场平铺展示多个版本)。
$rows = $query->field($field)->select()->toArray();
$rows = \app\appmall\service\MarketService::aggregateVersions($rows);
if ($page > 0) {
$total = count($rows);
$lastPage = max(1, (int) ceil($total / max(1, $pageSize)));
$rows = array_slice($rows, ($page - 1) * $pageSize, $pageSize);
$result = [
'code' => 1, 'msg' => 'success',
'data' => [
'list' => $rows,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'last_page' => $lastPage,
],
];
} else {
$result = [
'code' => 1,
'msg' => 'success',
'data' => ['list' => $rows],
];
}
} catch (\Exception $e) {
return json(['code' => 0, 'msg' => '市场列表读取失败:' . $e->getMessage()]);
}
Cache::set($cacheKey, $result, 60);
return json($result);
}
/**
* 2) 授权校验(安装前)
* 客户端 AddonService::valid() 以 multipart 发送:
* name / version / md5 / yversion / uid / token / install_user / install_ip ...
* 响应 {"code":0,"msg":"ok"} 表示通过;非 0 表示拒绝(msg 作为拒绝原因)。
*/
public function valid()
{
$this->ensureTables();
$name = Request::param('name', '');
$version = Request::param('version', '');
$category = trim((string) Request::param('category', ''));
$tags = trim((string) Request::param('tags', ''));
$uid = (int) Request::param('uid', 0);
$domain = trim((string) Request::param('domain', ''));
$operatorId = (int) Request::param('operator_id', 0);
$addon = Db::name('appmall_addon_list')
->where('name', $name)
->where('version', $version)
->find();
if (empty($addon)) {
return json(['code' => 1, 'msg' => '插件不存在或版本不符']);
}
// 免费插件直接放行
if (empty($addon['price']) || (float) $addon['price'] <= 0) {
return json(['code' => 0, 'msg' => 'ok']);
}
// 付费插件:统一走 LicensePolicy 判定。
// 授权按 addon_name 跨版本匹配 —— 这是「买了旧版能否装新版」的关键:
// 之前按 aid(版本行)匹配,导致插件一发新版就查不到授权,等于每个版本都卖一遍。
if ($uid <= 0) {
// 文案必须与 LicensePolicy::checkAccess() 的「无授权」区分开:
// 两者之前一字不差,排障时无法判断是「uid 没传过来」还是「没买/授权失效」。
return json(['code' => 1, 'msg' => '该插件为付费插件:安装请求未携带授权账号(uid),无法校验授权']);
}
$license = LicensePolicy::findLicense($uid, (string) $addon['name'], (int) $addon['id']);
$deny = LicensePolicy::checkAccess($addon, $license, (string) $addon['version']);
if ($deny !== null) {
return json(['code' => 1, 'msg' => $deny]);
}
// 站点/域名绑定(对齐 Discuz! 授权绑站点):首次安装绑定,之后仅允许已绑定域名。
// 多站点套餐:一份授权可绑 seats 个站点,超出需购买座席。
if ($domain !== '' && $license !== null) {
$boundAuthorized = LicensePolicy::checkDomain($license, $domain);
if ($boundAuthorized !== null) {
return json(['code' => 1, 'msg' => $boundAuthorized]);
}
if (trim((string) ($license['domain'] ?? '')) === '') {
LicensePolicy::bindDomain($license, $domain);
}
}
return json(['code' => 0, 'msg' => 'ok']);
}
/**
* 4) 开发者提交插件(进入待审核,运营审核通过后才进入 appmall_addon_list)
*/
public function submit()
{
// 1) 开发者令牌校验
$token = Request::param('token', '');
$dev = Db::name('appmall_developers')->where('token', $token)->find();
if (empty($dev)) {
return json(['code' => 0, 'msg' => '开发者令牌无效']);
}
if ((int) $dev['status'] === 0) {
return json(['code' => 0, 'msg' => '开发者令牌待审核,暂不可使用']);
}
if ((int) $dev['status'] === -1) {
return json(['code' => 0, 'msg' => '开发者令牌已被禁用']);
}
$this->ensureTables();
// 提交频率限制(按开发者令牌,1 小时内最多 10 次)
if (!$this->throttle('submit', $token, 10, 3600)) {
return json(['code' => 0, 'msg' => '提交过于频繁,请稍后再试']);
}
// 2) 基础字段校验
$name = Request::param('name', '');
$version = Request::param('version', '');
$category = trim((string) Request::param('category', ''));
$tags = trim((string) Request::param('tags', ''));
// 商品类型:addon=插件(默认,兼容旧客户端不传)/ template=模板(Discuz 式卖模板)
$type = strtolower(trim((string) Request::param('type', 'addon')));
if (!in_array($type, ['addon', 'template'], true)) {
return json(['code' => 0, 'msg' => '商品类型不正确(仅支持 addon / template)']);
}
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
return json(['code' => 0, 'msg' => '插件标识格式不正确']);
}
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
return json(['code' => 0, 'msg' => '插件版本号格式不正确(需 x.y.z)']);
}
// 3) 文件校验:zip + 类型标志文件
// addon 包:根目录(或一级目录)须含 info.php
// template 包:须含 templates/<name>/template.json(scripts/package_template.php 产物规范),
// 兼容根目录 template.json
$file = Request::file('file');
if (empty($file)) {
return json(['code' => 0, 'msg' => '请上传插件 zip 包']);
}
if (strtolower(substr($file->getOriginalName(), -4)) !== '.zip') {
return json(['code' => 0, 'msg' => '仅支持 zip 格式插件包']);
}
$tmp = $file->getRealPath();
$zip = new \ZipArchive();
if ($zip->open($tmp) !== true) {
return json(['code' => 0, 'msg' => 'zip 包无法打开']);
}
$hasFlag = false;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nm = $zip->statIndex($i)['name'];
if ($type === 'template') {
if ($nm === 'template.json' || preg_match('#^templates/[^/]+/template\.json$#', $nm)) {
$hasFlag = true;
break;
}
} elseif ($nm === 'info.php' || preg_match('#^[^/]+/info\.php$#', $nm)) {
$hasFlag = true;
break;
}
}
$zip->close();
if (!$hasFlag) {
return json(['code' => 0, 'msg' => $type === 'template'
? '模板包缺少 template.json(须为 templates/<名称>/template.json 结构)'
: '插件包根目录缺少 info.php']);
}
// 安全扫描:PHP 语法检查 + 高危函数扫描,防恶意包上架
list($scanOk, $scanMsg) = $this->scanAddonZip($tmp);
if (!$scanOk) {
return json(['code' => 0, 'msg' => $scanMsg]);
}
// 模板包附加扫描:视图 .html 经 ThinkPHP 模板引擎渲染,禁止夹带可执行 PHP
if ($type === 'template') {
list($tScanOk, $tScanMsg) = $this->scanTemplateZip($tmp);
if (!$tScanOk) {
return json(['code' => 0, 'msg' => $tScanMsg]);
}
}
// 4) 同版本去重:待审核/已通过不可重复提交;驳回后可重新提交
$exist = Db::name('appmall_addon_submissions')->where('name', $name)->where('version', $version)->find();
if ($exist && (int) $exist['status'] !== 2) {
$state = $exist['status'] == 0 ? '待审核' : '已发布';
return json(['code' => 0, 'msg' => '该版本已提交(' . $state . '),请勿重复提交']);
}
// 5) 落盘到待审目录
$dir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'submissions' . DIRECTORY_SEPARATOR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$saveName = $name . '-' . $version . '.zip';
$file->move($dir, $saveName);
$absPath = realpath($dir . $saveName);
if (!$absPath || !is_file($absPath)) {
return json(['code' => 0, 'msg' => '插件包保存失败']);
}
$hash = hash_file('sha256', $absPath);
$data = [
'developer_id' => $dev['id'],
'name' => $name,
'type' => $type,
'title' => Request::param('title', $name),
'author' => Request::param('author', ''),
'description' => Request::param('description', ''),
'changelog' => Request::param('changelog', ''),
'require_framework' => trim((string) Request::param('require_framework', '')),
'version' => $version,
'price' => (float) Request::param('price', 0),
'logo' => Request::param('logo', ''),
'category' => $category,
'tags' => $tags,
'file_path' => $absPath,
'file_hash' => $hash,
'status' => 0,
'create_at' => time(),
'update_at' => time(),
];
if ($exist && (int) $exist['status'] === 2) {
Db::name('appmall_addon_submissions')->where('id', $exist['id'])
->update(array_merge($data, ['reject_reason' => '', 'status' => 0]));
$id = $exist['id'];
} else {
$id = Db::name('appmall_addon_submissions')->insertGetId($data);
}
return json([
'code' => 1,
'msg' => '提交成功,等待官方审核',
'data' => ['id' => $id, 'status' => 'pending'],
]);
}
/**
* 5) 插件详情(买家视角)
* GET /api/addon/info?id= (需 rtoken + uid 才能返回 purchased)
*/
public function info()
{
$this->ensureTables();
if (!$this->authRemote()) {
return json(['code' => 0, 'msg' => '服务间鉴权失败']);
}
$id = (int) Request::param('id', 0);
if ($id <= 0) {
return json(['code' => 0, 'msg' => '缺少插件ID']);
}
$addon = Db::name('appmall_addon_list')->where('id', $id)->where('status', 1)->find();
if (empty($addon)) {
return json(['code' => 0, 'msg' => '插件不存在或已下架']);
}
$uid = (int) Request::param('uid', 0);
$license = $uid > 0 ? LicensePolicy::findLicense($uid, (string) $addon['name'], $id) : null;
return json([
'code' => 1,
'msg' => 'success',
'data' => [
'purchased' => $license !== null,
'license' => LicensePolicy::summary($addon, $license),
'addon' => [
'id' => $addon['id'],
'name' => $addon['name'],
'title' => $addon['title'],
'version' => $addon['version'],
'price' => $addon['price'],
'logo' => $addon['logo'],
'intro' => $addon['description'] ?? '',
'author' => $addon['author'] ?? '',
// 售卖策略(前端据此决定按钮是「安装」「试用」「续费」还是「升级」)
'license_period' => (int) ($addon['license_period'] ?? 0),
'trial_days' => (int) ($addon['trial_days'] ?? 0),
'upgrade_policy' => (int) ($addon['upgrade_policy'] ?? 0),
'upgrade_price' => (float) ($addon['upgrade_price'] ?? 0),
'seats' => (int) ($addon['seats'] ?? 1),
],
],
]);
}
/**
* 6) 发起购买(买家,运行在中心站市场库)
* POST /api/addon/buy {addon_id, method?} + rtoken + uid
*/
public function buy()
{
if (!$this->authRemote()) {
return json(['code' => 0, 'msg' => '服务间鉴权失败']);
}
$this->ensureTables();
$uid = (int) Request::param('uid', 0);
$addonId = (int) Request::param('addon_id', 0);
if ($uid <= 0) {
return json(['code' => 0, 'msg' => '请先登录']);
}
// 购买频率限制(按 uid,60 秒最多 10 次)
if (!$this->throttle('buy', (string) $uid, 10, 60)) {
return json(['code' => 0, 'msg' => '操作过于频繁,请稍后再试']);
}
$addon = Db::name('appmall_addon_list')->where('id', $addonId)->where('status', 1)->find();
if (empty($addon)) {
return json(['code' => 0, 'msg' => '插件不存在或已下架']);
}
if ((float) $addon['price'] <= 0) {
return json(['code' => 1, 'msg' => '该插件免费,可直接安装', 'data' => ['free' => true]]);
}
// 已购 / 试用 / 待续费 / 待升级:由 LicensePolicy 统一判定是否还要付钱
$license = LicensePolicy::findLicense($uid, (string) $addon['name'], $addonId);
$decided = LicensePolicy::decide($addon, $license, (string) $addon['version']);
if (!$decided['pay']) {
return json([
'code' => 1,
'msg' => $decided['action'] === LicensePolicy::TRIALING
? $decided['message']
: '您已购买该插件,可直接安装或升级',
'data' => [
'license_key' => $license['license_key'] ?? '',
'action' => $decided['action'],
'owned' => true,
],
]);
}
$order = Db::name('appmall_addon_orders')->where('uid', $uid)->where('aid', $addonId)
->where('status', 0)
->where('type', $decided['type'])
->where('major', $decided['major'])
->where('create_at', '>', time() - 900)->find();
if (!$order) {
$orderId = Db::name('appmall_addon_orders')->insertGetId([
'uid' => $uid,
'aid' => $addonId,
'amount' => $decided['amount'],
'status' => 0,
'type' => $decided['type'],
'major' => $decided['major'],
'trade_no' => date('YmdHis') . $uid . $addonId . random_int(100000, 999999),
'create_at' => time(),
'update_at' => time(),
]);
$order = Db::name('appmall_addon_orders')->where('id', $orderId)->find();
}
// 模拟支付(中心站):仅在三重防御全通过时(mock_enable + APP_DEBUG + 无真实商户号)才允许,
// 否则一律走真实支付链路。统一判断收敛于 MarketService::isMockAllowed()。
if (MarketService::isMockAllowed()) {
$this->completeOrder($order, $addonId, $uid);
$lic = LicensePolicy::findLicense($uid, (string) $addon['name'], $addonId);
Log::info('appmall.buy.mock', [
'uid' => $uid, 'aid' => $addonId, 'order_id' => $order['id'] ?? 0,
'amount' => $order['amount'] ?? 0, 'env' => Env::get('app_debug') ? 'debug' : 'production',
]);
return json(['code' => 1, 'msg' => '(模拟支付)购买成功', 'data' => [
'license_key' => $lic['license_key'] ?? '', 'mock' => true,
]]);
}
// 拉起支付交给「支付中心」插件(addon/paycenter):本控制器不再持有任何渠道细节,
// 与 MarketService::buy() 走同一条链路,避免两处各写一遍(历史 bug 来源)。
if (!class_exists(\addon\paycenter\service\Payment::class)) {
return json(['code' => 0, 'msg' => '未安装(或未启用)支付中心插件 paycenter,无法发起支付']);
}
try {
$method = (string) Request::param('method', 'alipay');
$base = rtrim(Request::root(true), '/');
$pay = \addon\paycenter\service\Payment::create($method, [
'trade_no' => (string) $order['trade_no'],
'amount' => (float) $order['amount'],
'subject' => AddonOrder::typeName((int) ($order['type'] ?? 0)) . '插件:' . $addon['title'],
'biz' => 'appmall',
'biz_id' => (int) $order['id'],
'attach' => ['aid' => $addonId, 'uid' => $uid, 'type' => (int) ($order['type'] ?? 0)],
], '', [
'notify' => $base . '/paycenter/api/notify?channel=' . $method,
'return' => $base . '/api/addon/payResult',
]);
return json(['code' => 1, 'msg' => '支付发起成功', 'data' => [
'order_no' => $order['trade_no'],
'pay_type' => $pay['type'],
'pay_params' => $pay['content'],
]]);
} catch (\Throwable $e) {
return json(['code' => 0, 'msg' => '支付发起失败:' . $e->getMessage()]);
}
}
/**
* 7) 订单状态查询(买家轮询)
*/
public function orderStatus()
{
$this->ensureTables();
if (!$this->authRemote()) {
return json(['code' => 0, 'msg' => '服务间鉴权失败']);
}
$tradeNo = Request::param('trade_no', '');
$uid = (int) Request::param('uid', 0);
$order = Db::name('appmall_addon_orders')->where('trade_no', $tradeNo)->where('uid', $uid)->find();
if (!$order) {
return json(['code' => 0, 'msg' => '订单不存在']);
}
$data = ['status' => $order['status']];
if ($order['status'] == 1) {
$addon = Db::name('appmall_addon_list')->where('id', (int) $order['aid'])->find();
$lic = $addon ? LicensePolicy::findLicense($uid, (string) $addon['name'], (int) $addon['id']) : null;
$data['license_key'] = $lic['license_key'] ?? '';
}
return json(['code' => 1, 'msg' => 'success', 'data' => $data]);
}
/**
* 8) 支付异步回调(运行在中心站,由支付平台直接回调)
*/
public function notify()
{
$this->ensureTables();
// 异步回调限流(按 IP,宽松阈值防刷;超限返回 fail 让网关后续重试)
if (!$this->throttle('notify', Request::ip(), 120, 60)) {
return response('fail');
}
// 验签 / 应答由支付插件完成,发货走 PayPaid 事件 → app\appmall\listener\OrderPaid
$method = (string) Request::param('method', 'alipay');
if (!class_exists(\addon\paycenter\service\Payment::class)) {
Log::warning('收到支付回调,但支付中心插件(paycenter)未安装/未启用');
return response('fail');
}
return \addon\paycenter\service\Payment::notify($method);
}
/**
* 9) 支付完成落地页(支付宝 return_url 跳转)
*/
public function payResult()
{
$this->ensureTables();
$tradeNo = trim((string) Request::param('out_trade_no', ''));
$method = (string) Request::param('method', 'alipay');
if ($tradeNo === '') {
return json(['code' => 1, 'msg' => '支付流程结束', 'data' => ['status' => 0]]);
}
$order = Db::name('appmall_addon_orders')->where('trade_no', $tradeNo)->find();
if (!$order) {
return json(['code' => 0, 'msg' => '订单不存在', 'data' => ['status' => 0]]);
}
$status = (int) $order['status'];
// 回调到不了时兜底(localhost / 内网 / 网关回调丢失):
// 用户付款后必然跳回本页,此刻主动查单确认,已付即发货,不必依赖 notify 先到。
if ($status === 0 && MarketService::instance()->isTradePaid($tradeNo, $method)) {
Db::transaction(function () use ($order) {
$this->completeOrder($order, (int) $order['aid'], (int) $order['uid']);
});
$status = (int) Db::name('appmall_addon_orders')->where('trade_no', $tradeNo)->value('status');
}
return json(['code' => 1, 'msg' => 'success', 'data' => ['status' => $status]]);
}
/**
* 10) 退款 / 吊销授权(买家或运营发起)
* POST /api/addon/refund {trade_no 或 order_id, uid} + rtoken
* 幂等:已退款订单直接返回成功。
*/
public function refund()
{
if (!$this->authRemote()) {
return json(['code' => 0, 'msg' => '服务间鉴权失败']);
}
$this->ensureTables();
$uid = (int) Request::param('uid', 0);
$tradeNo = trim((string) Request::param('trade_no', ''));
$orderId = (int) Request::param('order_id', 0);
if ($uid <= 0) {
return json(['code' => 0, 'msg' => '请先登录']);
}
$order = null;
if ($tradeNo !== '') {
$order = Db::name('appmall_addon_orders')->where('trade_no', $tradeNo)->where('uid', $uid)->find();
} elseif ($orderId > 0) {
$order = Db::name('appmall_addon_orders')->where('id', $orderId)->where('uid', $uid)->find();
}
if (empty($order)) {
return json(['code' => 0, 'msg' => '订单不存在']);
}
if ((int) $order['status'] === 2) {
return json(['code' => 1, 'msg' => '该订单已退款', 'data' => ['status' => 2]]);
}
if ((int) $order['status'] !== 1) {
return json(['code' => 0, 'msg' => '仅已支付订单可申请退款']);
}
Db::transaction(function () use ($order) {
// 1) 订单置已退款
Db::name('appmall_addon_orders')->where('id', $order['id'])
->update(['status' => 2, 'update_at' => time()]);
// 2) 吊销授权(status=0 且过期,download/valid 双重拦截)
Db::name('appmall_addon_licenses')->where('uid', $order['uid'])
->where('aid', $order['aid'])
->update(['status' => 0, 'expire_time' => time(), 'update_at' => time()]);
// 3) 收益冲正:未结算的扣回开发者余额,并标记账本为退款
$rev = Db::name('appmall_addon_revenues')->where('order_id', $order['id'])->find();
if ($rev) {
if ((int) $rev['status'] === 0) {
Db::name('appmall_developers')->where('id', $rev['developer_id'])
->dec('balance', (float) $rev['income'])->update();
}
Db::name('appmall_addon_revenues')->where('id', $rev['id'])->update(['status' => 2]);
}
});
return json(['code' => 1, 'msg' => '退款成功,授权已吊销', 'data' => ['status' => 2]]);
}
/**
* 11) 我的已购插件(买家视角,运行在中心站市场库)
* GET /api/addon/my {uid} + rtoken
* 返回该用户已购买的付费插件列表(含授权状态、绑定域名、最近订单、是否可退款)。
*/
public function my()
{
if (!$this->authRemote()) {
return json(['code' => 0, 'msg' => '服务间鉴权失败']);
}
$this->ensureTables();
$uid = (int) Request::param('uid', 0);
if ($uid <= 0) {
return json(['code' => 0, 'msg' => '请先登录']);
}
$rows = Db::name('appmall_addon_licenses')
->where('uid', $uid)
->order('id', 'desc')
->select()
->toArray();
$list = [];
foreach ($rows as $r) {
$name = (string) ($r['addon_name'] ?? '');
if ($name === '') {
// 老授权(无 addon_name):从购买时的版本行反查插件标识
$name = (string) Db::name('appmall_addon_list')->where('id', (int) $r['aid'])->value('name');
}
if ($name === '') {
continue;
}
// 展示该插件的「最新版本」,让用户看见有没有新版可升;能否升级由 max_major 决定
$newest = Db::name('appmall_addon_list')->where('name', $name)->order('id', 'desc')->find();
if (!$newest) {
continue;
}
$order = Db::name('appmall_addon_orders')
->where('uid', $uid)->where('aid', (int) $newest['id'])
->order('id', 'desc')->find();
$orderStatus = $order ? (int) $order['status'] : 0;
$refundable = ((int) ($r['status'] ?? 1) === 1) && $orderStatus === 1;
$summary = LicensePolicy::summary($newest, $r);
$summary['installed_version'] = (string) (Db::name('appmall_addon_list')
->where('id', (int) $r['aid'])->value('version') ?: '');
$list[] = array_merge($summary, [
'name' => $name,
'title' => $newest['title'],
'version' => $newest['version'],
'price' => $newest['price'],
'license_status' => (int) ($r['status'] ?? 1),
'order_id' => $order ? (int) $order['id'] : 0,
'trade_no' => $order['trade_no'] ?? '',
'order_type' => $order ? (int) ($order['type'] ?? 0) : 0,
'refundable' => $refundable,
'upgradable' => version_compare(
(string) $newest['version'],
(string) ($summary['installed_version'] ?: '0'),
'>'
),
]);
}
return json(['code' => 1, 'msg' => 'success', 'data' => ['list' => $list]]);
}
/**
* 12) 开通免费试用(商品需配置 trial_days)
* POST /api/addon/trial {addon_id, uid} + rtoken
* 一个用户对同一插件只能试用一次;试用期内可直接安装/升级该(大版本内的)版本。
*/
public function trial()
{
if (!$this->authRemote()) {
return json(['code' => 0, 'msg' => '服务间鉴权失败']);
}
$this->ensureTables();
$uid = (int) Request::param('uid', 0);
$addonId = (int) Request::param('addon_id', 0);
if ($uid <= 0) {
return json(['code' => 0, 'msg' => '请先登录']);
}
$addon = Db::name('appmall_addon_list')->where('id', $addonId)->where('status', 1)->find();
if (empty($addon)) {
return json(['code' => 0, 'msg' => '插件不存在或已下架']);
}
if ((float) $addon['price'] <= 0) {
return json(['code' => 1, 'msg' => '该插件免费,可直接安装', 'data' => ['free' => true]]);
}
try {
$license = LicensePolicy::startTrial($uid, $addon);
} catch (\Throwable $e) {
return json(['code' => 0, 'msg' => $e->getMessage()]);
}
return json([
'code' => 1,
'msg' => sprintf('试用已开通,%s 到期', date('Y-m-d', (int) $license['trial_end'])),
'data' => [
'license_key' => $license['license_key'] ?? '',
'trial_end' => (int) $license['trial_end'],
'trial_days' => (int) ($addon['trial_days'] ?? 0),
],
]);
}
/**
* 运行时自愈建表:缺表建表 + 补列(幂等,进程内只探测一次)。
* 唯一事实源是 app/appmall/install.sql,由 AppmallSchema 解析执行,本类不再内联 DDL。
*/
private function ensureTables(): void
{
\app\appmall\library\AppmallSchema::ensure();
}
/**
* 服务间鉴权:校验共享密钥 rtoken(与客户端 appmall_remote_token 一致)
*/
private function authRemote(): bool
{
$token = Request::param('rtoken', '');
$cfg = config('appmall.remote_token', '');
if ($cfg === '' || !hash_equals($cfg, $token)) {
return false;
}
return true;
}
/**
* 订单完成后:置已付 + 签发授权 + 写入开发者收益账本(与客户端逻辑一致,运行在中心库)
*/
private function completeOrder($order, int $aid, int $uid): void
{
if ($order['status'] == 0) {
Db::name('appmall_addon_orders')->where('id', $order['id'])->update([
'status' => 1, 'pay_time' => time(), 'update_at' => time(),
]);
}
// 签发 / 变更授权:新购、续费延长订阅期限、升级抬高版本上限,全部由 LicensePolicy 处理。
// 授权以 addon_name 为准跨版本生效,升级到新版本不会重复签发第二份。
$addon = Db::name('appmall_addon_list')->where('id', $aid)->find();
if ($addon) {
$orderType = (int) ($order['type'] ?? AddonOrder::TYPE_NEW);
$orderMajor = (int) ($order['major'] ?? 0);
try {
LicensePolicy::grant($uid, $addon, $orderType, $orderMajor, $orderType === AddonOrder::TYPE_SEAT ? 1 : 0);
} catch (\Throwable $e) {
\think\facade\Log::error('中心站授权签发失败:' . $e->getMessage());
}
}
try {
$product = Db::name('appmall_addon_list')->where('id', $aid)->find();
$devId = (int) ($product['developer_id'] ?? 0);
if ($devId > 0 && !Db::name('appmall_addon_revenues')->where('order_id', $order['id'])->find()) {
$rate = (float) config('appmall.commission_rate', 0.2);
$commission = round((float) $order['amount'] * $rate, 2);
$income = round((float) $order['amount'] - $commission, 2);
Db::name('appmall_addon_revenues')->insert([
'developer_id' => $devId, 'order_id' => $order['id'], 'aid' => $aid,
'uid' => $uid, 'amount' => $order['amount'], 'commission' => $commission,
'income' => $income, 'status' => 0, 'create_at' => time(),
]);
Db::name('appmall_developers')->where('id', $devId)->inc('balance', $income)->update();
}
} catch (\Exception $e) {
\think\facade\Log::warning('中心站收益记账失败:' . $e->getMessage());
}
}
/**
* 生成唯一授权码
*/
private function generateLicenseKey(int $uid, int $aid): string
{
do {
$key = strtoupper(sprintf('%s-%s-%s',
substr(md5($uid . $aid . random_bytes(8)), 0, 8),
bin2hex(random_bytes(4)), bin2hex(random_bytes(4))));
} while (Db::name('appmall_addon_licenses')->where('license_key', $key)->find());
return $key;
}
/**
* 3) 下载插件 zip
* 客户端 AddonService::download() GET ?name=&version= :
* - 直接返回 zip 二进制(首字节非 '{');
* 可选签名校验:config('appmall.addon_download_sign') = true 时,
* 请求需带 sign=md5(name.version.ts.secret) & ts(5 分钟有效期)。
*/
public function index()
{
$name = Request::param('name', '');
$version = Request::param('version', '');
$category = trim((string) Request::param('category', ''));
$tags = trim((string) Request::param('tags', ''));
$uid = (int) Request::param('uid', 0);
$domain = trim((string) Request::param('domain', ''));
$operatorId = (int) Request::param('operator_id', 0);
$this->ensureTables();
if (Config::get('ywxapp.addon_download_sign', false)) {
$sign = Request::param('sign', '');
$ts = (int) Request::param('ts', 0);
if (!$this->verifySign($name, $version, $ts, $sign)) {
return json(['code' => 0, 'message' => '签名校验失败']);
}
}
$addon = Db::name('appmall_addon_list')
->where('name', $name)
->where('version', $version)
->find();
if (empty($addon) || empty($addon['file_path']) || !is_file($addon['file_path'])) {
return json(['code' => 0, 'message' => '插件不存在或文件缺失']);
}
// 包完整性校验:损坏/被篡改即拒,避免分发坏包(file_hash 由 submit/approve 写入)
$integrityErr = $this->verifyPackageIntegrity($addon['file_path'], $addon['file_hash'] ?? '');
if ($integrityErr !== null) {
return json(['code' => 0, 'message' => $integrityErr]);
}
// 纵深防御:带 uid 时校验授权(对齐 valid())。不传 uid 维持原白名单(sign 模式/旧兼容)。
$license = null;
if ($uid > 0) {
$price = (float) ($addon['price'] ?? 0);
if ($price > 0) {
// 授权跨版本查找 + 统一口径校验(买断 / 试用 / 订阅到期 / 大版本上限)
$license = LicensePolicy::findLicense($uid, (string) $addon['name'], (int) $addon['id']);
$deny = LicensePolicy::checkAccess($addon, $license, (string) $addon['version']);
if ($deny !== null) {
return json(['code' => 0, 'message' => $deny]);
}
if ($license !== null) {
// 站点绑定校验(只读,不修改绑定;绑定在 valid() 完成)
$domainDeny = $domain !== '' ? LicensePolicy::checkDomain($license, $domain) : null;
// 未绑定过域名的老授权在此不自动绑定:valid() 是绑定入口,下载保持只读语义
if ($domainDeny !== null && trim((string) ($license['domain'] ?? '')) !== '') {
return json(['code' => 0, 'message' => $domainDeny]);
}
}
// 下载次数限制:license.download_count > 0 表示上限(0 为不限)
$limit = (int) ($license['download_count'] ?? 0);
if ($limit > 0) {
$used = Db::name('appmall_addon_download_logs')
->where('license_id', $license['id'])->count();
if ($used >= $limit) {
return json(['code' => 0, 'message' => '该授权下载次数已达上限(' . $limit . ' 次)']);
}
}
}
}
// 接口限流(按 IP,60 秒内最多 30 次下载)
if (!$this->throttle('download', Request::ip(), 30, 60)) {
return json(['code' => 0, 'message' => '下载过于频繁,请稍后再试']);
}
// 记录下载次数与日志(列/表缺失时忽略,不影响下载)
try {
Db::name('appmall_addon_list')->where('id', $addon['id'])->inc('download_count')->update();
if ((float) ($addon['price'] ?? 0) > 0) {
// 会员下载(带 uid)已在上文授权校验块内通过:按授权记录,计入下载限额。
if ($uid > 0 && !empty($license)) {
Db::name('appmall_addon_download_logs')->insert([
'license_id' => (int) $license['id'],
'uid' => $uid,
'aid' => $addon['id'],
'ip' => Request::ip(),
'create_at' => time(),
]);
} elseif ($operatorId > 0) {
// 运营后台一键安装(operator_id):特权放行,不消耗会员下载额度,仅留审计记录。
Db::name('appmall_addon_download_logs')->insert([
'license_id' => 0,
'uid' => 0,
'aid' => $addon['id'],
'operator_id' => $operatorId,
'ip' => Request::ip(),
'create_at' => time(),
]);
}
}
} catch (\Exception $e) {
// ignore
}
// 下发包指纹:客户端 AddonService::download() 读取该响应头留存,
// 安装/升级前用 verifyPackageIntegrity() 比对落盘文件(覆盖「服务端文件被换 / 传输被劫持」)。
// 服务端已在上文 verifyPackageIntegrity 校验过机房内的包,这里是给传输链路再上一道锁。
return new StreamZipResponse($addon['file_path'], $name . '.zip', 200, [
'X-Addon-Sha256' => strtolower((string) ($addon['file_hash'] ?? '')),
'X-Addon-Version' => (string) ($addon['version'] ?? ''),
]);
}
/**
* 4) 插件评价列表
*
* GET /appmall/api/addon/reviews?name=demo&page=1&page_size=10
* 只返回已审核通过(status=1)的评价,并附带汇总后的评分与条数。
* 契约同本控制器其它接口:code=1 成功。
*/
public function reviews()
{
$this->ensureTables();
$name = trim((string) Request::param('name', ''));
$aid = $name !== ''
? (int) Db::name('appmall_addon_list')->where('name', $name)->value('id')
: 0;
if ($aid <= 0) {
return json(['code' => 0, 'message' => '插件不存在或已下架']);
}
$page = max(1, (int) Request::param('page', 1));
$size = min(50, max(1, (int) Request::param('page_size', 10)));
$base = Db::name('appmall_addon_reviews')
->where('aid', $aid)
->where('status', AddonReview::STATUS_PUBLISHED);
$total = (int) (clone $base)->count();
$rows = (clone $base)->order('create_at', 'desc')
->page($page, $size)
->field('id,rating,content,version,create_at,uid,site_id')
->select()->toArray();
$list = [];
foreach ($rows as $it) {
$list[] = [
'id' => (int) $it['id'],
'rating' => (int) $it['rating'],
'content' => (string) $it['content'],
'version' => (string) $it['version'],
'date' => date('Y-m-d', (int) $it['create_at']),
// 评价者可能来自任意客户机(中心站未必有对应会员记录),统一脱敏展示
'user' => $it['uid'] > 0
? '用户' . substr(md5((string) $it['site_id'] . ':' . $it['uid']), 0, 6)
: '匿名用户',
];
}
$addon = Db::name('appmall_addon_list')->where('id', $aid)
->field('rating,review_count')->find();
return json([
'code' => 1,
'data' => [
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $size,
'rating' => round((float) ($addon['rating'] ?? 0), 1),
'count' => (int) ($addon['review_count'] ?? 0),
],
]);
}
/**
* 5) 提交插件评价
*
* POST /appmall/api/addon/review {name, rating, content, version, uid, site_id}
* - 同一用户对同一插件只保留一条(更新而非新增);匿名(uid=0)按 IP 去重
* - 新提交一律 status=0 待审,不直接公开展示(与 Discuz 应用中心的先审后发一致)
*/
public function review()
{
$this->ensureTables();
$name = trim((string) Request::param('name', ''));
$rating = (int) Request::param('rating', 5);
$content = trim((string) Request::param('content', ''));
$version = trim((string) Request::param('version', ''));
$uid = (int) Request::param('uid', 0);
$siteId = (int) Request::param('site_id', 0);
if ($name === '') {
return json(['code' => 0, 'message' => '缺少插件标识']);
}
if ($rating < 1 || $rating > 5) {
return json(['code' => 0, 'message' => '评分需在 1~5 之间']);
}
if (function_exists('mb_strlen') ? mb_strlen($content) > 500 : strlen($content) > 1500) {
return json(['code' => 0, 'message' => '评价内容过长(最多 500 字)']);
}
$aid = (int) Db::name('appmall_addon_list')->where('name', $name)->value('id');
if ($aid <= 0) {
return json(['code' => 0, 'message' => '插件不存在或已下架']);
}
// 风控:同一 IP 60 秒内最多 3 条
if (!$this->throttle('review', Request::ip(), 3, 60)) {
return json(['code' => 0, 'message' => '评价过于频繁,请稍后再试']);
}
$exist = Db::name('appmall_addon_reviews')->where('aid', $aid)->where('site_id', $siteId);
if ($uid > 0) {
$exist->where('uid', $uid);
} else {
$exist->where('uid', 0)->where('ip', Request::ip());
}
$row = $exist->find();
$now = time();
$data = [
'aid' => $aid,
'site_id' => $siteId,
'uid' => $uid,
'rating' => $rating,
'content' => $content,
'version' => $version,
'status' => AddonReview::STATUS_PENDING,
'ip' => Request::ip(),
'update_at' => $now,
];
try {
if ($row) {
Db::name('appmall_addon_reviews')->where('id', (int) $row['id'])->update($data);
$message = '评价已更新,等待审核后展示';
} else {
$data['create_at'] = $now;
Db::name('appmall_addon_reviews')->insert($data);
$message = '评价已提交,等待审核后展示';
}
} catch (\Throwable $e) {
return json(['code' => 0, 'message' => '评价保存失败:' . $e->getMessage()]);
}
return json(['code' => 1, 'message' => $message]);
}
/**
* 插件包完整性校验:zip 可正常打开 + (若记录 file_hash)sha256 比对一致。
* 返回 null 表示通过,否则返回错误文案(供下载端点拒绝分发坏包)。
*/
private function verifyPackageIntegrity(string $filePath, string $expectedHash): ?string
{
$zip = new \ZipArchive();
if ($zip->open($filePath, \ZipArchive::RDONLY) !== true) {
return '插件包已损坏,无法打开(zip 完整性校验失败)';
}
$zip->close();
if ($expectedHash !== '' && is_file($filePath)) {
$actual = hash_file('sha256', $filePath);
if ($actual === false || !hash_equals($expectedHash, $actual)) {
return '插件包校验值不匹配,可能已被篡改或损坏';
}
}
return null;
}
/**
* 下载签名校验
*/
private function verifySign(string $name, string $version, int $ts, string $sign): bool
{
if ($sign === '' || abs(time() - $ts) > 300) {
return false;
}
$secret = Config::get('ywxapp.addon_secret', 'ywxapp-addon-secret-change-me');
$expect = md5($name . $version . $ts . $secret);
return hash_equals($expect, $sign);
}
/**
* 简单固定窗口限流:返回 true 表示放行,false 表示触发限制。
* action 区分业务,ident 为 uid 或 IP;窗口 seconds 内最多 max 次。
*/
private function throttle(string $action, string $ident, int $max, int $seconds): bool
{
if ($ident === '') {
return true;
}
$now = time();
$row = Db::name('appmall_throttle')->where('action', $action)->where('ident', $ident)->find();
try {
if (empty($row) || $row['expire_at'] < $now) {
Db::name('appmall_throttle')->where('action', $action)->where('ident', $ident)->delete();
Db::name('appmall_throttle')->insert([
'action' => $action,
'ident' => $ident,
'count' => 1,
'expire_at' => $now + $seconds,
'create_at' => $now,
]);
return true;
}
if ((int) $row['count'] >= $max) {
return false;
}
Db::name('appmall_throttle')->where('id', $row['id'])->inc('count')->update();
return true;
} catch (\Throwable $e) {
// 限流表异常不阻断业务
return true;
}
}
/**
* 提交安全扫描:PHP 语法检查 + 高危函数扫描。返回 [ok, message]。
* 受 config('appmall.security_scan', true) 控制,可关闭。
*/
private function scanAddonZip(string $zipPath): array
{
if (!(bool) Config::get('appmall.security_scan', true)) {
return [true, ''];
}
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) {
return [false, '插件包无法打开,无法扫描'];
}
$tmpDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'am_scan_' . uniqid('', true);
@mkdir($tmpDir, 0755, true);
$danger = [
'eval\s*\(' => 'eval',
'assert\s*\(' => 'assert',
'create_function\s*\(' => 'create_function',
'proc_open\s*\(' => 'proc_open',
'exec\s*\(' => 'exec',
'shell_exec\s*\(' => 'shell_exec',
'system\s*\(' => 'system',
'passthru\s*\(' => 'passthru',
'popen\s*\(' => 'popen',
'pcntl_exec\s*\(' => 'pcntl_exec',
'posix_kill\s*\(' => 'posix_kill',
'\bdl\s*\(' => 'dl',
// 混淆/免杀特征(仅 deny 明确恶意模式,避免误伤正常插件):
'eval\s*\([^;{]*base64_decode' => 'eval+base64混淆',
'eval\s*\([^;{]*gzinflate' => 'eval+gzinflate混淆',
'phar://' => 'phar流包装器',
'__halt_compiler' => '__halt_compiler',
];
$lintFail = [];
$hits = [];
$canLint = function_exists('shell_exec');
for ($i = 0; $i < $zip->numFiles; $i++) {
$nm = $zip->statIndex($i)['name'];
if (substr($nm, -4) !== '.php') {
continue;
}
$zip->extractTo($tmpDir, $nm);
$abs = $tmpDir . DIRECTORY_SEPARATOR . $nm;
if (!is_file($abs)) {
continue;
}
if ($canLint) {
$out = @shell_exec('php -l ' . escapeshellarg($abs) . ' 2>&1');
if ($out === null || $out === '') {
// php 不可用,跳过 lint
} elseif (stripos($out, 'not recognized') !== false || stripos($out, 'command not found') !== false) {
$canLint = false; // 后续文件也不再 lint
} elseif (stripos($out, 'No syntax errors') === false) {
$lintFail[] = $nm . ': ' . trim(preg_replace('/\s+/', ' ', $out));
}
}
$code = (string) @file_get_contents($abs);
// 用 # 作分隔符:危险模式里含 /(如 phar://),若用 / 包裹会变成 /phar:///i
// 导致「Unknown modifier '/'」。所有 $danger 模式均不含 #,故安全。
foreach ($danger as $pat => $label) {
if (preg_match('#' . $pat . '#i', $code)) {
$hits[$label][] = $nm;
}
}
}
$zip->close();
$this->delTree($tmpDir);
if ($lintFail) {
return [false, '插件包存在 PHP 语法错误:' . implode('; ', array_slice($lintFail, 0, 3))];
}
if ($hits) {
$msg = [];
foreach ($hits as $label => $files) {
$msg[] = $label . '(' . implode(',', array_slice(array_unique($files), 0, 3)) . ')';
}
return [false, '插件包检测到高危函数,禁止上架:' . implode('; ', $msg)];
}
return [true, ''];
}
/**
* 模板包安全扫描:模板包本不该含 .php 文件;.html 视图经 ThinkPHP 模板引擎渲染,
* 禁止 <?php / <?= / {php} / {:call} 危险调用等可执行代码。返回 [ok, message]。
* 受 config('appmall.security_scan', true) 控制(与 scanAddonZip 同开关)。
*/
private function scanTemplateZip(string $zipPath): array
{
if (!(bool) Config::get('appmall.security_scan', true)) {
return [true, ''];
}
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) {
return [false, '模板包无法打开,无法扫描'];
}
$phpFiles = [];
$hits = [];
$danger = [
'<\?php' => '<?php 代码块',
'<\?=' => '<?= 短标签',
'\{php\}' => '{php} 模板标签',
'\{:\s*(eval|exec|system|shell_exec|passthru|popen|proc_open|assert|call_user_func)' => '危险函数调用',
];
for ($i = 0; $i < $zip->numFiles; $i++) {
$nm = $zip->statIndex($i)['name'];
if (substr($nm, -4) === '.php') {
$phpFiles[] = $nm;
continue;
}
if (substr($nm, -5) !== '.html') {
continue;
}
$code = (string) $zip->getFromIndex($i);
foreach ($danger as $pat => $label) {
if (preg_match('#' . $pat . '#i', $code)) {
$hits[$label][] = $nm;
}
}
}
$zip->close();
if ($phpFiles) {
return [false, '模板包不允许包含 PHP 文件:' . implode(',', array_slice($phpFiles, 0, 3))];
}
if ($hits) {
$msg = [];
foreach ($hits as $label => $files) {
$msg[] = $label . '(' . implode(',', array_slice(array_unique($files), 0, 3)) . ')';
}
return [false, '模板包检测到可执行代码,禁止上架:' . implode('; ', $msg)];
}
return [true, ''];
}
/**
* 递归删除目录(扫描临时文件清理)
*/
private function delTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $it) {
if ($it === '.' || $it === '..') {
continue;
}
$p = $dir . DIRECTORY_SEPARATOR . $it;
if (is_dir($p)) {
$this->delTree($p);
} else {
@unlink($p);
}
}
@rmdir($dir);
}
}