// +---------------------------------------------------------------------- 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//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 模板引擎渲染, * 禁止 open($zipPath) !== true) { return [false, '模板包无法打开,无法扫描']; } $phpFiles = []; $hits = []; $danger = [ '<\?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); } }