// +---------------------------------------------------------------------- declare(strict_types=1); namespace app\appmall\service; use addon\paycenter\service\Payment; use app\appmall\library\LicensePolicy; use app\appmall\model\AddonDownloadLog; use app\appmall\model\AddonLicense; use app\appmall\model\AddonOrder; use think\facade\Cache; use think\facade\Db; use think\facade\Env; use think\facade\Log; use think\Request; /** * 市场中心站本地成交域服务(会员直购 / 授权 / 收益 / 下载)。 * * 由核心 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 等直接调用方) \app\appmall\library\AppmallSchema::ensure(); } return static::$instance; } /** * 运行时自愈建表(仅当表不存在时创建),保证购买闭环在当前库即可跑通。 * 已收敛到 \app\appmall\library\AppmallSchema::ensure()(建全部表 + 扩展列),此处委托。 */ public function ensureTables(): void { \app\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); } // 授权按插件标识跨版本查找:买了一版 = 该插件全部在购买范围内的版本都可用 $license = $uid > 0 ? LicensePolicy::findLicense($uid, (string) $addon['name'], $id) : null; 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' => $license !== null, // 售买状态全貌:买断/试用期/待续费/待升级,前端据此渲染按钮 'license' => LicensePolicy::summary($addon, $license), ]; } /** * 上架插件分页列表 * @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]; } /** * 是否允许走「模拟支付」分支(三重防御统一收敛点) * * 三重防御,确保生产环境即使 .env 被手误改错也不会被静默 mock 偷掉付费插件授权: * 1) config('pay.mock_enable') 必须开启(默认 false,防御性默认); * 2) APP_DEBUG 必须 true(防止生产环境误开); * 3) 阿里 / 微信商户号都未配置(避免与真实支付链路混用)。 * * 任一条件不满足即拒绝执行 mock——调用方应根据 false 走真实支付或返回错误提示。 * * @return bool */ public static function isMockAllowed(): bool { if (!config('pay.mock_enable', false)) { return false; } if (!Env::get('app_debug', false)) { return false; } if (!empty(config('pay.alipay.app_id')) || !empty(config('pay.wechat.mch_id'))) { return false; } return true; } /** * 发起购买:创建/复用待支付订单并拉起支付(或模拟支付直接签发授权)。 * @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' => '该插件免费,可直接安装']; } // 已购 / 试用 / 续费 / 大版本升级:统一由 LicensePolicy 判定要不要再付钱。 // 授权按插件标识(addon_name)跨版本匹配,因此「买了旧版本」不会被当成未购买。 $license = LicensePolicy::findLicense($uid, (string) $addon['name'], $addonId); $decided = LicensePolicy::decide($addon, $license, (string) $addon['version']); // 无需付款(已购且在有效期内 / 试用期内继续在期内):直接返回现有授权 if (!$decided['pay']) { return [ 'data' => [ 'license_key' => (string) ($license['license_key'] ?? ''), 'action' => $decided['action'], ], 'message' => $decided['action'] === LicensePolicy::TRIALING ? $decided['message'] : '您已购买该插件,可直接安装或升级', ]; } // 防重复下单:复用 15 分钟内「同类型同目标版本」的待支付订单 //(类型分开,避免续费/升级把首次购买的待付订单顶掉) $order = AddonOrder::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) { $order = AddonOrder::create([ 'uid' => $uid, 'aid' => $addonId, 'amount' => $decided['amount'], 'type' => $decided['type'], 'major' => $decided['major'], 'status' => 0, 'trade_no' => date('YmdHis') . $uid . $addonId . random_int(100000, 999999), ]); } // 模拟支付:仅在三重防御全通过时(mock_enable + APP_DEBUG + 无真实商户号)才允许, // 否则一律走真实支付链路。统一判断收敛于 self::isMockAllowed()。 if (self::isMockAllowed()) { $this->completeOrder($order, $addonId, $uid); $license = 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 [ 'data' => ['license_key' => $license?->license_key, 'mock' => true], 'message' => '(模拟支付)购买成功', ]; } // 真实支付:验签 / 下单 / 回调 / 查单全部交给「支付中心」插件(addon/paycenter), // 本服务只保留「订单 + 授权 + 发货」。插件没装就明确报错,不要静默走别的分支。 if (!class_exists(Payment::class)) { throw new MarketException('未安装(或未启用)支付中心插件 paycenter,无法发起支付', 500); } try { $base = rtrim($baseUrl, '/'); // 插件统一回调入口:付完钱由插件验签后触发 PayPaid 事件, // 本模块用 app\appmall\listener\OrderPaid 接住并发货(见 app/event.php)。 $pay = Payment::create($method, [ 'trade_no' => (string) $order->trade_no, 'amount' => (float) $order->amount, 'subject' => AddonOrder::typeName((int) $order->type) . '插件:' . $addon['title'], 'biz' => 'appmall', 'biz_id' => (int) $order->id, 'attach' => ['aid' => $addonId, 'uid' => $uid, 'type' => (int) $order->type], ], $this->payScene($method), [ 'notify' => $base . '/paycenter/api/notify?channel=' . $method, // 同步跳转仍回业务自己的落地页:那儿会主动查单兜底(内网回调到不了时不依赖 notify) 'return' => $base . '/v1/addon/payResult', ]); return [ 'data' => [ 'order_no' => $order->trade_no, // type: form(自动提交表单)/ qrcode(二维码地址)/ url(跳转)/ params(前端唤起参数) 'pay_type' => $pay['type'], 'pay_params' => $pay['content'], ], 'message' => '支付发起成功', ]; } catch (\Throwable $e) { throw new MarketException('支付发起失败: ' . $e->getMessage(), 500); } } /** * 按客户端形态选支付场景:移动端走 WAP / H5,PC 走收银台 / 扫码。 * 具体场景是否可用由插件按后台配置兜底(不支持时回落默认场景)。 */ private function payScene(string $method): string { $req = request(); $mobile = $req && method_exists($req, 'isMobile') ? (bool) $req->isMobile() : false; if ($method === 'wechat') { return $mobile ? 'h5' : 'qr'; } return $mobile ? 'wap' : 'web'; } /** * 订单支付成功后:置已付 + 幂等签发授权 + 写入开发者收益账本(分红) */ public function completeOrder($order, int $aid, int $uid): void { if ($order->status == 0) { $order->status = 1; $order->pay_time = time(); $order->save(); } // 签发 / 变更授权:按订单类型决定是「新签」「续费延长期限」还是「抬高版本上限」。 // 授权以 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); // 订单表不记座席数:增座席默认为 1 个站点,购买流程由商品 seats 价格决定数量 $orderSeats = $orderType === AddonOrder::TYPE_SEAT ? 1 : 0; try { LicensePolicy::grant($uid, $addon, $orderType, $orderMajor, $orderSeats); } catch (\PDOException $e) { // 并发重复签发:唯一键冲突可接受,其余照抛 if (stripos($e->getMessage(), 'duplicate') === false && stripos($e->getMessage(), '1062') === false) { throw $e; } } catch (\Throwable $e) { // 授权签发失败不能静默吞掉退款追踪,记录后继续记账 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) { // 收益记账失败不影响已完成的购买 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 跳转 / 前端轮询)。 * * 这里会「主动向网关查单」而不是只读本地订单: * 异步通知在 localhost、内网、回调被防火墙拦截的场景下到不了本机, * 生产环境网关回调重试失败同样是常态。用户付款后跳回本页时确认一次, * 已付款立即发货,不依赖 notify 先到。 * * @return array{data: array, message: string} */ public function payResult(string $tradeNo, string $method = 'alipay'): array { if ($tradeNo === '') { return ['data' => ['status' => 0], 'message' => '支付流程结束']; } $order = AddonOrder::where('trade_no', $tradeNo)->find(); if (!$order) { return ['data' => ['status' => 0], 'message' => '订单不存在']; } $status = (int) $order->status; // 未支付 → 查网关;仍未确认付款则维持 status=0 让前端继续轮询 if ($status === 0 && $this->isTradePaid($tradeNo, $method)) { $this->completeOrder($order, (int) $order->aid, (int) $order->uid); $status = (int) AddonOrder::where('trade_no', $tradeNo)->value('status'); } return ['data' => ['status' => $status], 'message' => '获取成功']; } /** * 向支付网关查询该笔交易是否已付款成功。 * * 查询失败一律按「未支付」处理:宁可让用户多轮询一次, * 也不能凭一次失败的响应发货(查单失败常见于网络抖动、证书配置错误)。 * * @return bool true=网关确认已付款 */ public function isTradePaid(string $tradeNo, string $method = 'alipay'): bool { if (!class_exists(Payment::class)) { return false; } try { return !empty(Payment::query($method, $tradeNo)['paid']); } catch (\Throwable $e) { Log::warning('主动查单失败:' . $e->getMessage(), ['trade_no' => $tradeNo, 'method' => $method]); return false; } } /** * 可用支付方式(供前台收银台渲染):直接透传支付插件的结果。 * 插件未安装时返回空数组(调用方据此只保留「免费 / 已购」入口)。 * * @return array */ public function payMethods(): array { return class_exists(Payment::class) ? Payment::methods() : []; } /** * 退款 / 吊销授权(买家或运营) * @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); } // 先尝试网关退款(best effort):插件按流水自己认渠道,失败只记录不阻断—— // 本地「吊销授权 + 收益冲正」才是用户真正要的结果,网关侧失败可线下补退。 if (class_exists(Payment::class) && $order->trade_no) { try { $res = Payment::refund((string) $order->trade_no); if (empty($res['ok'])) { Log::warning('网关退款失败:' . ($res['message'] ?? '未知原因'), ['trade_no' => $order->trade_no]); } } catch (\Throwable $e) { Log::warning('网关退款异常:' . $e->getMessage(), ['trade_no' => $order->trade_no]); } } 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 { $licenses = AddonLicense::where('uid', $uid)->order('id', 'desc')->select()->toArray(); $list = []; foreach ($licenses as $lic) { $name = (string) ($lic['addon_name'] ?? ''); if ($name === '') { // 老授权(无 addon_name):从购买时的版本行反查插件标识 $name = (string) Db::name('appmall_addon_list')->where('id', (int) $lic['aid'])->value('name'); } if ($name === '') { continue; } // 展示「最新版本」而非购买时的版本:让用户看见有没有新版可升 $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) ($lic['status'] ?? 1) === 1) && $orderStatus === 1; $summary = LicensePolicy::summary($newest, $lic); $summary['installed_version'] = (string) Db::name('appmall_addon_list') ->where('id', (int) $lic['aid'])->value('version') ?: ''; $list[] = array_merge($summary, [ 'name' => $name, 'title' => $newest['title'], 'version' => $newest['version'], 'price' => $newest['price'], 'license_status' => (int) ($lic['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 ['list' => $list]; } /** * 开通免费试用(商品开启 trial_days 且用户从未拥有过授权) * * @return array{data: array, message: string} * @throws MarketException */ public function trial(int $uid, int $addonId): array { $addon = $this->product($addonId); if (!$addon) { throw new MarketException('插件不存在或已下架', 404); } if ((float) $addon['price'] <= 0) { return ['data' => ['free' => true], 'message' => '该插件免费,可直接安装']; } try { $license = LicensePolicy::startTrial($uid, $addon); } catch (\Throwable $e) { throw new MarketException($e->getMessage(), 400); } return [ 'data' => [ 'license_key' => $license['license_key'] ?? '', 'trial_end' => (int) ($license['trial_end'] ?? 0), 'trial_days' => (int) ($addon['trial_days'] ?? 0), ], 'message' => sprintf('试用已开通,到期时间 %s', date('Y-m-d', (int) ($license['trial_end'] ?? 0))), ]; } /** * 支付宝/微信异步回调:验签与应答全部由支付插件完成。 * * 插件验签通过后触发 PayPaid 事件,由 app\appmall\listener\OrderPaid 完成 * 「金额校验 → 订单置已付 → 签发授权」,本方法不再重复一份验签代码。 * * @return \think\Response */ public function notify(string $method) { if (!class_exists(Payment::class)) { // 插件缺失时不能装作成功:返回 fail 让网关重试,问题会在日志里暴露 Log::warning('收到支付回调,但支付中心插件(paycenter)未安装/未启用'); return response('fail'); } return Payment::notify($method); } /** * 用户下载已购插件(需有效授权 + 域名/签名/次数校验)。 * @return array{file: string, filename: string} 通过校验后返回文件路径与下载名 * @throws MarketException */ public function download(int $uid, int $addonId, Request $request): array { $addon = Db::name('appmall_addon_list')->where('id', $addonId)->where('status', 1)->find(); if (!$addon) { throw new MarketException('插件不存在或已下架', 404); } // 授权校验统一走 LicensePolicy(买断/试用/订阅到期/大版本上限一套口径) $license = LicensePolicy::findLicense($uid, (string) $addon['name'], $addonId); if ((float) $addon['price'] > 0) { $deny = LicensePolicy::checkAccess($addon, $license, (string) $addon['version']); if ($deny !== null) { throw new MarketException($deny, 403); } } // 域名绑定:一台授权可绑 seats 个站点(多站点套餐),domain 为逗号分隔列表 $host = $request->host(); $hostNoPort = preg_replace('/:\d+$/', '', $host); $domainHit = $host; $allowed = array_filter(array_map('trim', preg_split('/[,\s]+/', (string) ($license['domain'] ?? '')))); if ($license !== null && !empty($allowed)) { if (!in_array($host, $allowed, true) && !in_array($hostNoPort, $allowed, true)) { throw new MarketException('该授权未绑定当前域名,无法下载', 403); } } elseif ($license !== null) { // 未绑定过任何域名:下载即占位绑定(受 seats 限制) $domainHit = $hostNoPort; if (!LicensePolicy::bindDomain($license, $domainHit)) { throw new MarketException(sprintf( '该授权最多绑定 %d 个站点,如需增加请购买站点座席', (int) ($license['seats'] ?? 1) ), 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 = hash_hmac('sha256', (string) ($license['license_key'] ?? '') . $addonId . $ts, $signSecret); if (!hash_equals($expected, $sign)) { throw new MarketException('下载授权校验失败', 403); } } // 下载次数限制:0 表示不限 if ((int) ($license['download_count'] ?? 0) > 0) { $used = AddonDownloadLog::where('license_id', (int) ($license['id'] ?? 0))->count(); if ($used >= (int) $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' => (int) ($license['id'] ?? 0), 'uid' => $uid, 'aid' => $addonId, 'ip' => $request->ip(), ]); return ['file' => $file, 'filename' => $addon['name'] . '-' . $addon['version'] . '.zip']; } /** * 后台市场目录(中心站本机浏览):关键词/分类筛选 + 排序 + 分类下拉数据源。 * 自愈元数据列统一走 AppmallSchema(事实源 app/appmall/install.sql),不再各控制器手写 DDL。 * @return array{list: array, categories: array} */ public function catalog(string $keyword = '', string $category = '', string $order = 'new', string $type = ''): array { // 自愈缺表/缺列(category/tags/screenshots/rating/type/changelog 等),由模型负责,幂等 \app\appmall\model\AddonList::ensureSchema(); $listTable = Db::name('appmall_addon_list')->getTable(); // 探测元数据列是否存在,存在才选,保证兼容 $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; } }