Files
YwxAppThink/app/appmall/service/MarketService.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

840 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp AppMarket [ 应用市场中心站插件 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
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<int,array{code:string,name:string,scenes:array,scene:string}>
*/
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;
}
}