702 lines
29 KiB
PHP
702 lines
29 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
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;
|
||
}
|
||
}
|