1237 lines
57 KiB
PHP
1237 lines
57 KiB
PHP
<?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 插件列表(addon::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();
|
||
$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.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);
|
||
$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) & 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 = 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_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);
|
||
}
|
||
|
||
/**
|
||
* 商品表元数据列自愈:早期安装的库缺 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);
|
||
}
|
||
}
|