// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\appmall\service; use addon\appmall\model\AddonDownloadLog; use addon\appmall\model\AddonLicense; use addon\appmall\model\AddonOrder; use think\facade\Cache; use think\facade\Db; use think\facade\Log; use think\Request; use ywxapp\model\BaseModel; use Yansongda\Pay\Pay; /** * 市场中心站本地成交域服务(会员直购 / 授权 / 收益 / 下载)。 * * 由核心 app/api/controller/v1/Addon.php 在「本机即中心站」模式下进程内委托调用; * 所有中心站领域逻辑(订单、授权、收益账本、支付回调)收敛于插件,核心不再持有。 * * 约定:业务失败抛 MarketException(携带 HTTP 语义 code),由调用方转成 Result 响应。 */ class MarketService { protected static ?MarketService $instance = null; public static function instance(): static { if (static::$instance === null) { static::$instance = new static(); // 中心站成交域首次被委托时,确保全部 appmall 表存在(覆盖 v1/Addon 等直接调用方) \addon\appmall\library\AppmallSchema::ensure(); } return static::$instance; } /** * 运行时自愈建表(仅当表不存在时创建),保证购买闭环在当前库即可跑通。 * 已收敛到 \addon\appmall\library\AppmallSchema::ensure()(建全部表 + 扩展列),此处委托。 */ public function ensureTables(): void { \addon\appmall\library\AppmallSchema::ensure(); } /** * 商品查询(appmall_addon_list) */ public function product(int $id): ?array { return Db::name('appmall_addon_list')->where('id', $id)->where('status', 1)->find(); } /** * 插件详情(含当前用户已购状态) * @throws MarketException 404 插件不存在或已下架 */ public function info(int $id, int $uid = 0): array { $addon = $this->product($id); if (!$addon) { throw new MarketException('插件不存在或已下架', 404); } $purchased = false; if ($uid > 0) { $purchased = AddonLicense::where('uid', $uid)->where('aid', $id)->count() > 0; } return [ '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' => $purchased, ]; } /** * 上架插件分页列表 * @return array{items: array, total: int} */ public function lists(int $page = 1, int $perPage = 15): array { $perPage = min(max($perPage, 1), 50); $page = max($page, 1); $query = Db::name('appmall_addon_list')->where('status', 1); $total = (clone $query)->count(); $items = $query->field('id,name,title,version,price,logo,description as intro,author') ->order('id', 'desc') ->page($page, $perPage) ->select() ->toArray(); return ['items' => $items, 'total' => $total]; } /** * 发起购买:创建/复用待支付订单并拉起支付(或模拟支付直接签发授权)。 * @param string $baseUrl 客户端根地址(用于拼支付回调) * @return array{data: array, message: string} * @throws MarketException */ public function buy(int $uid, int $addonId, string $method, string $baseUrl): array { $addon = $this->product($addonId); if (!$addon) { throw new MarketException('插件不存在或已下架', 404); } // 免费插件无需购买(服务端 market valid 也直接放行) if ((float) $addon['price'] <= 0) { return ['data' => ['free' => true], 'message' => '该插件免费,可直接安装']; } // 幂等:已购直接返回授权 $existLicense = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find(); if ($existLicense) { return ['data' => ['license_key' => $existLicense->license_key], 'message' => '您已购买该插件']; } // 防重复下单:复用 15 分钟内待支付订单 $order = AddonOrder::where('uid', $uid) ->where('aid', $addonId) ->where('status', 0) ->where('create_at', '>', time() - 900) ->find(); if (!$order) { $order = AddonOrder::create([ 'uid' => $uid, 'aid' => $addonId, 'amount' => $addon['price'], 'status' => 0, 'trade_no' => date('YmdHis') . $uid . $addonId . random_int(100000, 999999), ]); } // 模拟支付:无真实商户号时直接完成并签发授权(仅本地/自托管调试用) if (config('pay.mock_enable') && empty(config('pay.alipay.app_id')) && empty(config('pay.wechat.mch_id'))) { $this->completeOrder($order, $addonId, $uid); $license = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find(); return [ 'data' => ['license_key' => $license?->license_key, 'mock' => true], 'message' => '(模拟支付)购买成功', ]; } try { $base = rtrim($baseUrl, '/'); $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'])) { throw new \Exception('微信支付未配置,请联系管理员'); } $wconfig['notify_url'] = $base . '/v1/addon/notify'; $result = Pay::wechat($wconfig)->h5($payParams); return [ 'data' => ['order_no' => $order->trade_no, 'pay_params' => $result->getBody()->getContents()], 'message' => '支付发起成功', ]; } $aconfig = config('pay.alipay'); if (empty($aconfig['app_id']) || empty($aconfig['private_key'])) { throw new \Exception('支付未配置,请联系管理员'); } $aconfig['notify_url'] = $base . '/v1/addon/notify'; $aconfig['return_url'] = $base . '/v1/addon/payResult'; $result = Pay::alipay($aconfig)->web($payParams); return [ 'data' => ['order_no' => $order->trade_no, 'pay_params' => $result->getBody()->getContents()], 'message' => '支付发起成功', ]; } catch (\Throwable $e) { throw new MarketException('支付发起失败: ' . $e->getMessage(), 500); } } /** * 订单支付成功后:置已付 + 幂等签发授权 + 写入开发者收益账本(分红) */ public function completeOrder($order, int $aid, int $uid): void { if ($order->status == 0) { $order->status = 1; $order->pay_time = time(); $order->save(); } $exist = AddonLicense::where('uid', $uid)->where('aid', $aid)->find(); if (!$exist) { try { AddonLicense::create([ 'uid' => $uid, 'aid' => $aid, 'license_key' => $this->generateLicenseKey($uid, $aid), 'expire_time' => 0, 'download_count' => 0, ]); } catch (\PDOException $e) { if (stripos($e->getMessage(), 'duplicate') === false && stripos($e->getMessage(), '1062') === false) { throw $e; } } } // 写入收益账本(仅当该商品归属某开发者) 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) { // 收益记账失败不影响已完成的购买 Log::warning('收益记账失败:' . $e->getMessage()); } // 钩子点:订单支付完成(已签发授权 + 写入分红账本)后触发, // 插件可在 info.php['events']['listen']['order_paid'] 监听,用于发货 / 通知等联动。 event('order_paid', ['order' => $order, 'aid' => $aid, 'uid' => $uid]); } /** * 支付结果查询(前端轮询用) * @throws MarketException 404 订单不存在 */ public function orderStatus(int $uid, string $tradeNo): array { $order = AddonOrder::where('trade_no', $tradeNo)->where('uid', $uid)->find(); if (!$order) { throw new MarketException('订单不存在', 404); } $data = ['status' => $order->status]; if ($order->status === 1) { $license = AddonLicense::where('uid', $uid)->where('aid', $order->aid)->find(); $data['license_key'] = $license?->license_key; } return $data; } /** * 支付完成落地页状态(支付宝 return_url 跳转,无需登录,仅展示状态) * @return array{data: array, message: string} */ public function payResult(string $tradeNo): array { if ($tradeNo === '') { return ['data' => [], 'message' => '支付流程结束']; } $order = AddonOrder::where('trade_no', $tradeNo)->find(); if (!$order) { return ['data' => ['status' => 0], 'message' => '订单不存在']; } return ['data' => ['status' => $order->status], 'message' => '获取成功']; } /** * 退款 / 吊销授权(买家或运营) * @return array{data: array, message: string} * @throws MarketException */ public function refund(int $uid, string $tradeNo, int $orderId): array { $order = null; if ($tradeNo !== '') { $order = AddonOrder::where('trade_no', $tradeNo)->where('uid', $uid)->find(); } elseif ($orderId > 0) { $order = AddonOrder::where('id', $orderId)->where('uid', $uid)->find(); } if (!$order) { throw new MarketException('订单不存在', 404); } if ($order->status === 2) { return ['data' => ['status' => 2], 'message' => '该订单已退款']; } if ($order->status !== 1) { throw new MarketException('仅已支付订单可申请退款', 400); } Db::transaction(function () use ($order, $uid) { $order->status = 2; $order->save(); // 吊销授权(status=0 且过期,download/valid 双重拦截) AddonLicense::where('uid', $uid)->where('aid', $order->aid) ->update(['status' => 0, 'expire_time' => time()]); // 收益冲正 $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 ['data' => ['status' => 2], 'message' => '退款成功,授权已吊销']; } /** * 我的已购插件(买家视角):列出已购付费插件 + 授权状态 + 是否可退款 */ public function mine(int $uid): array { $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 ['list' => $list]; } /** * 支付宝/微信异步回调:验签 -> 校验金额 -> 订单置已付 -> 生成授权 * @return \think\Response */ public function notify(string $method) { try { if ($method === 'wechat') { $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 = 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 = AddonOrder::where('trade_no', $tradeNo)->find(); if ($order && $order->status == 0) { // 防金额篡改:回调金额必须与订单一致 if (abs((float) $order->amount - $amount) < 0.01) { Db::transaction(function () use ($order) { $order->refresh(); if ($order->status != 0) { return; } $this->completeOrder($order, $order->aid, $order->uid); }); } } return $method === 'wechat' ? response('SUCCESS') : Pay::alipay(config('pay.alipay'))->success(); } /** * 用户下载已购插件(需有效授权 + 域名/签名/次数校验)。 * @return array{file: string, filename: string} 通过校验后返回文件路径与下载名 * @throws MarketException */ public function download(int $uid, int $addonId, Request $request): array { $license = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find(); if (!$license) { throw new MarketException('您尚未购买该插件', 403); } // 授权已吊销(退款):拒绝下载 if (isset($license->status) && (int) $license->status !== 1) { throw new MarketException('授权已被吊销,无法下载', 403); } if ($license->expire_time > 0 && $license->expire_time < time()) { throw new MarketException('授权已过期', 403); } // 域名绑定:license.domain 非空时,仅允许绑定的域名下载 if (!empty($license->domain)) { $host = $request->host(); $hostNoPort = preg_replace('/:\d+$/', '', $host); $allowed = array_filter(array_map('trim', preg_split('/[,\s]+/', (string) $license->domain))); if (!in_array($host, $allowed, true) && !in_array($hostNoPort, $allowed, true)) { throw new MarketException('该授权未绑定当前域名,无法下载', 403); } } // 下载签名校验(开关 addon_download_sign) $signSecret = config('ywxapp.addon_secret'); if (config('ywxapp.addon_download_sign') && $signSecret) { $ts = (int) $request->get('ts', 0); $sign = (string) $request->get('sign', ''); if ($ts <= 0 || time() - $ts > 600) { throw new MarketException('下载授权已过期,请重新获取', 403); } $expected = $license->generateSign($license->license_key, $addonId, $ts, $signSecret); if (!hash_equals($expected, $sign)) { throw new MarketException('下载授权校验失败', 403); } } $addon = Db::name('appmall_addon_list')->where('id', $addonId)->where('status', 1)->find(); if (!$addon) { throw new MarketException('插件不存在或已下架', 404); } // 下载次数限制:0 表示不限 if ($license->download_count > 0) { $used = AddonDownloadLog::where('license_id', $license->id)->count(); if ($used >= $license->download_count) { throw new MarketException('下载次数已用完', 403); } } $file = realpath($addon['file_path']); $root = realpath(root_path()) ?: root_path(); if ($file === false || !is_file($file) || strpos($file, $root) !== 0) { throw new MarketException('插件文件暂不可用,请联系管理员', 404); } AddonDownloadLog::create([ 'license_id' => $license->id, 'uid' => $uid, 'aid' => $addonId, 'ip' => $request->ip(), ]); return ['file' => $file, 'filename' => $addon['name'] . '-' . $addon['version'] . '.zip']; } /** * 后台市场目录(中心站本机浏览):关键词/分类筛选 + 排序 + 分类下拉数据源。 * 自愈元数据列(category/tags/screenshots/rating),与 api/Market::ensureAddonListColumns 对齐。 * @return array{list: array, categories: array} */ public function catalog(string $keyword = '', string $category = '', string $order = 'new', string $type = ''): array { // 自愈元数据列:旧库缺 category/tags/screenshots/rating 时自动补齐 $listTable = Db::name('appmall_addon_list')->getTable(); $colDefs = [ '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 ($colDefs as $col => $def) { BaseModel::ensureColumn($listTable, $col, $def); } // 探测元数据列是否存在,存在才选,保证兼容 $hasCols = []; try { $cols = Db::query("SHOW COLUMNS FROM `{$listTable}`"); $hasCols = array_column($cols, 'Field'); } catch (\Throwable $e) { } $has = fn (string $c): bool => in_array($c, $hasCols, true); $q = Db::name('appmall_addon_list')->where('status', 1); if ($keyword !== '') { $q->where(function ($w) use ($keyword) { $w->where('name', 'like', '%' . $keyword . '%') ->whereOr('title', 'like', '%' . $keyword . '%') ->whereOr('author', 'like', '%' . $keyword . '%') ->whereOr('description', 'like', '%' . $keyword . '%'); }); } if ($category !== '' && $has('category')) { $q->where('category', $category); } // 商品类型筛选(addon=插件 / template=模板;空=全部) if ($type !== '' && $has('type')) { $q->where('type', $type); } $field = 'id,name,title,description as intro,author,version,logo,price,download_count,update_at'; foreach (['category', 'tags', 'screenshots', 'rating', 'changelog', 'require_framework', 'type'] as $mf) { if ($has($mf)) { $field .= ',' . $mf; } } // 排序:对齐 api/Market::lists 的 orderMap(download_count 为商品表内置列) $orderMap = [ 'new' => ['id', 'desc'], 'hot' => ['download_count', 'desc'], 'price_asc' => ['price', 'asc'], 'price_desc' => ['price', 'desc'], ]; [$of, $od] = $orderMap[$order] ?? $orderMap['new']; $list = $q->field($field)->order($of, $od)->select()->toArray(); // 同名插件多版本聚合:只展示最新版,其余版本收进 versions(详情弹窗版本下拉用) $list = self::aggregateVersions($list); // 分类下拉数据源:本机全部非空分类 $categories = []; if ($has('category')) { $categories = Db::name('appmall_addon_list') ->where('status', 1)->where('category', '<>', '') ->distinct(true)->order('category', 'asc')->column('category'); } return ['list' => $list, 'categories' => $categories]; } /** * 运营退款(后台特权):按插件名退最新「已支付」订单, * 吊销授权 + 订单置已退款 + 收益冲正。 * @throws MarketException */ public function operatorRefund(string $name): void { $addon = Db::name('appmall_addon_list')->where('name', $name)->find(); if (empty($addon)) { throw new MarketException('插件不存在', 404); } $order = Db::name('appmall_addon_orders')->where('aid', $addon['id']) ->where('status', 1)->order('id', 'desc')->find(); if (empty($order)) { throw new MarketException('未找到该插件的已支付订单', 404); } Db::transaction(function () use ($order, $addon) { Db::name('appmall_addon_orders')->where('id', $order['id']) ->update(['status' => 2, 'update_at' => time()]); Db::name('appmall_addon_licenses')->where('uid', $order['uid']) ->where('aid', $addon['id']) ->update(['status' => 0, 'expire_time' => time(), 'update_at' => time()]); $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]); } }); } /** * 同名插件多版本聚合:每个插件只保留最新版一条作代表行, * 其余版本收进代表行的 versions 数组(含最新版自身,按版本号降序), * 供市场详情页/弹窗做「版本下拉选择」,列表不再平铺展示多个版本。 * 输出顺序 = 各插件在原列表中的首次出现顺序(尽量保持原排序语义)。 */ public static function aggregateVersions(array $rows): array { $groups = []; $names = []; foreach ($rows as $r) { $n = (string) ($r['name'] ?? ''); if (!isset($groups[$n])) { $groups[$n] = []; $names[] = $n; } $groups[$n][] = $r; } $out = []; foreach ($names as $n) { $vers = $groups[$n]; usort($vers, fn ($a, $b) => version_compare((string) ($b['version'] ?? '0'), (string) ($a['version'] ?? '0'))); $main = $vers[0]; $main['versions'] = array_map(fn ($v) => [ 'id' => $v['id'] ?? 0, 'version' => (string) ($v['version'] ?? ''), 'price' => $v['price'] ?? 0, 'update_at' => (int) ($v['update_at'] ?? 0), ], $vers); $out[] = $main; } return $out; } /** * 修改已上架插件价格(按插件名维度,同步全部版本行 + 提交记录)。 * 后台改价传 developerId=0(不限归属);开发者中心改价须传本人 developerId(仅能改自己的应用)。 * 改价后 bump 目录版本号,市场列表缓存立即失效。 * @return int 受影响的版本行数 * @throws MarketException */ public function updatePrice(string $name, float $price, int $developerId = 0): int { if ($name === '') { throw new MarketException('缺少插件标识', 400); } if ($price < 0 || $price > 999999) { throw new MarketException('价格必须在 0 ~ 999999 之间', 400); } $q = Db::name('appmall_addon_list')->where('name', $name); if ($developerId > 0) { $q->where('developer_id', $developerId); } $rows = $q->select()->toArray(); if (empty($rows)) { // 尚未上架(仅有提交记录)也允许改价:只同步 submissions $subExist = Db::name('appmall_addon_submissions')->where('name', $name) ->when($developerId > 0, fn ($w) => $w->where('developer_id', $developerId)) ->count(); if ($subExist <= 0) { throw new MarketException($developerId > 0 ? '未找到你名下的该插件' : '插件不存在', 404); } } $ids = array_column($rows, 'id'); if ($ids) { Db::name('appmall_addon_list')->whereIn('id', $ids) ->update(['price' => $price, 'update_at' => time()]); } // 同步提交记录价格(保持审核台/开发者中心口径一致;仅同步同归属记录) try { $sq = Db::name('appmall_addon_submissions')->where('name', $name); if ($developerId > 0) { $sq->where('developer_id', $developerId); } $sq->update(['price' => $price, 'update_at' => time()]); } catch (\Throwable $e) { // 提交记录同步失败不影响商品改价 } self::bumpCatalogVersion(); return count($ids); } /** * 目录版本号 bump:使 api/Market::lists 的 60s 缓存立即失效(审核发布/改价后调用)。 */ public static function bumpCatalogVersion(): void { try { Cache::set('appmall:catalog:ver', (string) microtime(true)); } catch (\Throwable $e) { // 缓存不可用时静默(列表最多延迟 60s 更新) } } /** * 生成唯一授权码 */ protected 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 (AddonLicense::where('license_key', $key)->find()); return $key; } }