安全(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(市场 / 开发者中心 / 审核 / 分润)
1275 lines
56 KiB
PHP
1275 lines
56 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 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);
|
||
}
|
||
}
|