Files

1237 lines
57 KiB
PHP
Raw Permalink 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 addon\appmall\controller\api;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Db;
use think\facade\Request;
use ywxapp\model\BaseModel;
use ywxapp\library\StreamZipResponse;
/**
* 插件市场服务端(应用市场后端)
*
* 由原核心 app/api/controller/Market.php 剥离而来,部署在「插件服务器(中心站)」的 market 插件内。
* 接口契约对齐 ywxapp 客户端 ywxapp\service\AddonService
* - GET /api/addon/lists 插件列表(Addons::market 使用)
* - POST /api/addon/valid 安装前授权校验(AddonService::valid 使用,multipart
* - GET /api/index 下载插件 zipAddonService::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(精确)、ordernew/hot/price_asc/price_desc)、分页 page/page_size。
* 无 page 参数时返回全部(保持旧客户端兼容);有 page 时返回分页元数据。
*/
public function lists()
{
$this->ensureTables();
$this->ensureAddonListColumns();
$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 = \addon\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']);
}
// 付费插件:校验授权(uid 是否购买且未过期)
if ($uid <= 0) {
return json(['code' => 1, 'msg' => '该插件为付费插件,请先购买']);
}
$license = Db::name('appmall_addon_licenses')
->where('uid', $uid)
->where('aid', $addon['id'])
->find();
if (empty($license)) {
return json(['code' => 1, 'msg' => '未购买该插件或授权无效']);
}
// 已吊销(退款):拒绝安装
if ((int) ($license['status'] ?? 1) === 0) {
return json(['code' => 1, 'msg' => '该授权已退款/吊销,无法安装']);
}
if (!empty($license['expire_time']) && $license['expire_time'] < time()) {
return json(['code' => 1, 'msg' => '授权已过期']);
}
// 站点/域名绑定(对齐 Discuz! 授权绑站点):首次安装绑定,之后仅允许同域名
if ($domain !== '') {
$licDomain = (string) ($license['domain'] ?? '');
if ($licDomain === '') {
Db::name('appmall_addon_licenses')->where('id', $license['id'])
->update(['domain' => $domain, 'update_at' => time()]);
} elseif ($licDomain !== $domain) {
return json(['code' => 1, 'msg' => '该授权已绑定其他域名,无法在当前站点安装']);
}
}
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.jsonscripts/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);
$purchased = $uid > 0 && Db::name('appmall_addon_licenses')->where('uid', $uid)->where('aid', $id)->find();
return json([
'code' => 1,
'msg' => 'success',
'data' => [
'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'] ?? '',
],
'purchased' => (bool) $purchased,
],
]);
}
/**
* 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]]);
}
$existLicense = Db::name('appmall_addon_licenses')->where('uid', $uid)->where('aid', $addonId)->find();
if ($existLicense) {
return json(['code' => 1, 'msg' => '您已购买该插件', 'data' => ['license_key' => $existLicense['license_key']]]);
}
$order = Db::name('appmall_addon_orders')->where('uid', $uid)->where('aid', $addonId)
->where('status', 0)->where('create_at', '>', time() - 900)->find();
if (!$order) {
$orderId = Db::name('appmall_addon_orders')->insertGetId([
'uid' => $uid,
'aid' => $addonId,
'amount' => $addon['price'],
'status' => 0,
'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();
}
// 模拟支付(中心站未配置商户号时直接完成)
if (config('pay.mock_enable')
&& empty(config('pay.alipay.app_id'))
&& empty(config('pay.wechat.mch_id'))) {
$this->completeOrder($order, $addonId, $uid);
$lic = Db::name('appmall_addon_licenses')->where('uid', $uid)->where('aid', $addonId)->find();
return json(['code' => 1, 'msg' => '(模拟支付)购买成功', 'data' => [
'license_key' => $lic['license_key'] ?? '', 'mock' => true,
]]);
}
try {
$method = Request::param('method', 'alipay');
$base = rtrim(Request::root(true), '/');
$payParams = [
'out_trade_no' => $order['trade_no'],
'total_amount' => $order['amount'],
'subject' => '购买插件:' . $addon['title'],
];
if ($method === 'wechat') {
$wconfig = config('pay.wechat');
if (empty($wconfig['mch_id']) || empty($wconfig['app_id'])) {
return json(['code' => 0, 'msg' => '微信支付未配置']);
}
$wconfig['notify_url'] = $base . '/api/addon/notify';
$result = \Yansongda\Pay\Pay::wechat($wconfig)->h5($payParams);
return json(['code' => 1, 'msg' => '支付发起成功', 'data' => [
'order_no' => $order['trade_no'], 'pay_params' => $result->getBody()->getContents(),
]]);
}
$aconfig = config('pay.alipay');
if (empty($aconfig['app_id']) || empty($aconfig['private_key'])) {
return json(['code' => 0, 'msg' => '支付未配置']);
}
$aconfig['notify_url'] = $base . '/api/addon/notify';
$aconfig['return_url'] = $base . '/api/addon/payResult';
$result = \Yansongda\Pay\Pay::alipay($aconfig)->web($payParams);
return json(['code' => 1, 'msg' => '支付发起成功', 'data' => [
'order_no' => $order['trade_no'], 'pay_params' => $result->getBody()->getContents(),
]]);
} 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) {
$lic = Db::name('appmall_addon_licenses')->where('uid', $uid)->where('aid', $order['aid'])->find();
$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');
}
try {
$method = Request::param('method', 'alipay');
if ($method === 'wechat') {
$pay = \Yansongda\Pay\Pay::wechat(config('pay.wechat'));
$data = $pay->callback();
$tradeNo = $data->get('out_trade_no');
$amount = (float) $data->get('amount') ?: (float) $data->get('total');
} else {
$pay = \Yansongda\Pay\Pay::alipay(config('pay.alipay'));
$data = $pay->verify();
$tradeNo = $data->get('out_trade_no');
$amount = (float) $data->get('total_amount');
$tradeStatus = (string) $data->get('trade_status');
if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
return $pay->success();
}
}
} catch (\Throwable) {
return response('fail');
}
$order = Db::name('appmall_addon_orders')->where('trade_no', $tradeNo)->find();
if ($order && $order['status'] == 0 && abs((float) $order['amount'] - $amount) < 0.01) {
Db::transaction(function () use ($order) {
$o = Db::name('appmall_addon_orders')->where('id', $order['id'])->find();
if ($o && $o['status'] == 0) {
$this->completeOrder($o, $o['aid'], $o['uid']);
}
});
}
return $method === 'wechat' ? response('SUCCESS') : \Yansongda\Pay\Pay::alipay(config('pay.alipay'))->success();
}
/**
* 9) 支付完成落地页(支付宝 return_url 跳转)
*/
public function payResult()
{
$this->ensureTables();
$tradeNo = Request::param('out_trade_no', '');
if (!$tradeNo) {
return json(['code' => 1, 'msg' => '支付流程结束', 'data' => ['status' => 0]]);
}
$order = Db::name('appmall_addon_orders')->where('trade_no', $tradeNo)->find();
return json(['code' => 1, 'msg' => 'success', 'data' => ['status' => $order ? $order['status'] : 0]]);
}
/**
* 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')
->alias('l')
->join('appmall_addon_list a', 'a.id = l.aid')
->where('l.uid', $uid)
->where('a.price', '>', 0)
->field('a.name,a.title,a.version,a.price,l.status as license_status,l.expire_time,l.domain,l.aid')
->order('l.id', 'desc')
->select()
->toArray();
$list = [];
foreach ($rows as $r) {
$order = Db::name('appmall_addon_orders')
->where('uid', $uid)->where('aid', $r['aid'])
->order('id', 'desc')->find();
$orderStatus = $order ? (int) $order['status'] : 0;
$refundable = ((int) $r['license_status'] === 1) && $orderStatus === 1;
$list[] = [
'name' => $r['name'],
'title' => $r['title'],
'version' => $r['version'],
'price' => $r['price'],
'license_status' => (int) $r['license_status'],
'expire_time' => (int) $r['expire_time'],
'domain' => $r['domain'] ?? '',
'order_id' => $order ? (int) $order['id'] : 0,
'trade_no' => $order['trade_no'] ?? '',
'refundable' => $refundable,
];
}
return json(['code' => 1, 'msg' => 'success', 'data' => ['list' => $list]]);
}
/**
* 运行时自愈建表(仅当表不存在时创建),保证购买闭环在当前库即可跑通。
* 与 docs/appmall_schema.sql、客户端 Addon.php::ensureTables() 字段一致。
*/
private function ensureTables(): void
{
// 收敛到 \addon\appmall\library\AppmallSchema::ensure():集中建全部 appmall 表 + 扩展列,
// 避免各控制器重复实现导致 1146 / 列缺失漂移。
\addon\appmall\library\AppmallSchema::ensure();
}
private function ensureTablesLegacy(): void
{
$prefix = \ywxapp\model\BaseModel::currentPrefix();
$tables = [
'appmall_addon_orders' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_orders` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT 0,
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id',
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '下单站点',
`amount` decimal(10,2) NOT NULL DEFAULT 0,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款',
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
`pay_time` int unsigned DEFAULT 0,
`create_at` int unsigned DEFAULT 0,
`update_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`),
KEY `idx_trade` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';",
'appmall_addon_licenses' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_licenses` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL,
`aid` int unsigned NOT NULL,
`license_key` varchar(64) NOT NULL COMMENT '授权码',
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点',
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=有效 0=已吊销',
`expire_time` int unsigned DEFAULT 0 COMMENT '0为永久',
`download_count` int DEFAULT 0 COMMENT '下载次数限制',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `license_key` (`license_key`),
UNIQUE KEY `uk_uid_aid` (`uid`, `aid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';",
'appmall_addon_download_logs' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_download_logs` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`license_id` int unsigned NOT NULL DEFAULT 0,
`uid` int unsigned NOT NULL DEFAULT 0,
`aid` int unsigned NOT NULL DEFAULT 0,
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)',
`ip` varchar(45) DEFAULT '',
`create_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_license` (`license_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';",
'appmall_throttle' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_throttle` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit',
`ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP',
`count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数',
`expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳',
`create_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_action_ident` (`action`, `ident`),
KEY `idx_expire` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';",
'appmall_developers' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_developers` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL COMMENT '开发者名称',
`email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)',
`token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)',
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用',
`balance` decimal(10,2) DEFAULT '0.00' COMMENT '可提现余额',
`frozen_balance` decimal(10,2) DEFAULT '0.00' COMMENT '待结算/冻结金额',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`),
UNIQUE KEY `uk_token` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场开发者表';",
'appmall_addon_revenues' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_revenues` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者',
`order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_orders.id',
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id',
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
`commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成',
`income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得',
`status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算',
`create_at` int DEFAULT 0,
`settle_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件销售收益账本';",
'appmall_addon_withdrawals' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_withdrawals` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额',
`channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道',
`account` varchar(100) DEFAULT '' COMMENT '收款账号',
`status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回',
`remark` varchar(255) DEFAULT '',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者提现单';",
'appmall_addon_submissions' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_submissions` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
`name` varchar(50) NOT NULL COMMENT '插件标识符',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
`author` varchar(100) DEFAULT '' COMMENT '作者',
`description` text COMMENT '简介',
`version` varchar(20) NOT NULL COMMENT '版本号',
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
`logo` varchar(255) DEFAULT '' COMMENT '封面',
`category` varchar(50) DEFAULT '' COMMENT '分类',
`tags` varchar(255) DEFAULT '' COMMENT '标签',
`file_path` varchar(255) NOT NULL COMMENT '待审包路径',
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256',
`status` tinyint DEFAULT 0 COMMENT '0待审 1通过 2驳回',
`reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name_ver` (`name`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件提交审核表';",
'appmall_addon_list' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_list` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '标识符',
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
`title` varchar(100) NOT NULL COMMENT '名称',
`version` varchar(20) NOT NULL COMMENT '版本号',
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
`author` varchar(100) DEFAULT '' COMMENT '作者',
`description` text COMMENT '简介/描述',
`logo` varchar(255) DEFAULT '' COMMENT '封面图 URL',
`file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)',
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值',
`status` tinyint DEFAULT 1 COMMENT '1上架 0下架',
`download_count` int DEFAULT 0 COMMENT '下载次数',
`create_at` int DEFAULT 0 COMMENT '创建时间',
`update_at` int DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场插件列表';",
];
foreach ($tables as $t => $sql) {
BaseModel::ensureTable($prefix . $t, $sql);
}
// 旧库(install.sql 早期版本)可能缺 operator_id 列:下载日志审计用,缺失则 ALTER 补齐
BaseModel::ensureColumn(
$prefix . 'appmall_addon_download_logs',
'operator_id',
"int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)'"
);
}
/**
* 服务间鉴权:校验共享密钥 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(),
]);
}
if (!Db::name('appmall_addon_licenses')->where('uid', $uid)->where('aid', $aid)->find()) {
Db::name('appmall_addon_licenses')->insert([
'uid' => $uid,
'aid' => $aid,
'license_key' => $this->generateLicenseKey($uid, $aid),
'expire_time' => 0,
'download_count' => 0,
'create_at' => time(),
]);
}
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) & ts5 分钟有效期)。
*/
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 = Db::name('appmall_addon_licenses')
->where('uid', $uid)
->where('aid', $addon['id'])
->find();
if (empty($license)) {
return json(['code' => 0, 'message' => '您尚未购买该插件,无法下载']);
}
if ((int) ($license['status'] ?? 1) === 0) {
return json(['code' => 0, 'message' => '授权已退款/吊销,无法下载']);
}
if (!empty($license['expire_time']) && $license['expire_time'] < time()) {
return json(['code' => 0, 'message' => '授权已过期,无法下载']);
}
// 域名绑定(只读校验,不修改绑定;绑定在 valid() 完成)
if ($domain !== '' && !empty($license['domain']) && $license['domain'] !== $domain) {
return json(['code' => 0, 'message' => '该授权未绑定当前域名,无法下载']);
}
// 下载次数限制: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
}
return new StreamZipResponse($addon['file_path'], $name . '.zip');
}
/**
* 插件包完整性校验:zip 可正常打开 + (若记录 file_hashsha256 比对一致。
* 返回 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);
}
/**
* 商品表元数据列自愈:早期安装的库缺 category/tags/screenshots/rating 时自动补齐。
*/
private function ensureAddonListColumns(): void
{
$table = Db::name('appmall_addon_list')->getTable();
$defs = [
'category' => "varchar(50) DEFAULT '' COMMENT '分类'",
'tags' => "varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)'",
'screenshots' => "text COMMENT '截图URL(逗号分隔或JSON'",
'rating' => "decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0'",
'changelog' => "text COMMENT '更新日志'",
'require_framework' => "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'",
'type' => "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'",
];
foreach ($defs as $col => $def) {
BaseModel::ensureColumn($table, $col, $def);
}
}
/**
* 简单固定窗口限流:返回 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);
}
}