安全(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(市场 / 开发者中心 / 审核 / 分润)
840 lines
37 KiB
PHP
840 lines
37 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 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;
|
||
}
|
||
}
|