Files
YwxAppThink/app/api/controller/v1/Addon.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

435 lines
16 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 [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\Api\Controller\V1;
use ywxapp\controller\ApiBase;
use ywxapp\service\RemoteService;
use think\Request;
/**
* 插件商店接口(瘦分发器,不持有市场领域逻辑):
*
* - 客户机模式(is_market_client()=true,ywxapp.api_url 指向中心站):
* 经 RemoteService 代理中心站 /appmall/api/addon/*。
* - 中心站模式(api_url 为空/指向本机):
* 进程内委托 appmall 应用(app/appmall)的 MarketService(订单/授权/收益/支付回调全部在应用侧)。
* - appmall 应用缺席且非客户机:市场功能不可用,返回明确提示。
*
* 中心站领域数据(appmall_* 表)已全部收敛到 app/appmall,核心不再直接读写。
*/
class Addon extends ApiBase
{
// notify/payResult 由支付平台回调/跳转,无需登录;info 公开
protected $noNeedLogin = ['info', 'notify', 'payResult'];
protected $needRight = ['*'];
/**
* 中心站模式下委托的插件市场服务
* @var \app\appmall\service\MarketService|null
*/
protected $market = null;
public function initialize()
{
if (!is_market_client() && class_exists(\app\appmall\service\MarketService::class)) {
$this->market = \app\appmall\service\MarketService::instance();
$this->market->ensureTables();
}
}
/**
* 本机为中心站但未安装市场插件时的统一出口
*/
protected function marketUnavailable(): \think\response\Json
{
return $this->result->error('市场服务不可用:本机未配置中心站地址(ywxapp.api_url),也未安装 appmall 插件', 503);
}
/**
* 将插件领域异常转换为 Result 错误响应
*/
protected function marketError(\Throwable $e): \think\response\Json
{
$code = (int) $e->getCode();
$code = $code >= 400 && $code < 600 ? $code : 500;
if ($code === 404) {
return $this->result->setStatusCode(404)->error($e->getMessage(), 404);
}
return $this->result->error($e->getMessage(), $code);
}
/**
* 获取插件信息(支持列表和详情)
*/
public function info(Request $request): \think\response\Json
{
if (is_market_client()) {
return $this->remoteInfo($request);
}
if (!$this->market) {
return $this->marketUnavailable();
}
$id = (int) $request->get('id', 0);
if ($id > 0) {
try {
$data = $this->market->info($id, $this->getLoginUid($request) ?? 0);
} catch (\Throwable $e) {
return $this->marketError($e);
}
$data['installed'] = is_dir(ADDON_PATH . ($data['addon']['name'] ?? '') . DIRECTORY_SEPARATOR);
return $this->result->success($data, '获取成功');
}
$r = $this->market->lists(
max((int) $request->get('page', 1), 1),
(int) $request->get('per_page', 15)
);
$this->result->setCount($r['total']);
return $this->result->success(['data' => $r['items']], '获取成功');
}
/**
* 轻量解析请求中的 JWT,返回用户ID(不触发完整登录副作用)
*/
protected function getLoginUid(Request $request): ?int
{
$auth = $request->header('authorization');
if (!$auth || !str_starts_with($auth, 'Bearer ')) {
return null;
}
try {
$token = \ywxapp\service\JwtService::instance()->parseAndValidate(substr($auth, 7));
$uid = $token->claims()->get('uid');
return $uid ? (int) $uid : null;
} catch (\Throwable) {
return null;
}
}
/**
* 发起购买:创建/复用待支付订单并拉起支付(或模拟支付直接签发授权)
*/
public function buy(Request $request)
{
if (is_market_client()) {
return $this->remoteBuy($request);
}
if (!$this->market) {
return $this->marketUnavailable();
}
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录后再购买');
}
try {
$r = $this->market->buy(
(int) $userId,
(int) $request->post('addon_id'),
(string) $request->post('method', 'alipay'),
(string) $request->root(true)
);
} catch (\Throwable $e) {
return $this->marketError($e);
}
return $this->result->success($r['data'], $r['message']);
}
/**
* 支付结果查询(前端轮询用)
*/
public function orderStatus(Request $request): \think\response\Json
{
if (is_market_client()) {
return $this->remoteOrderStatus($request);
}
if (!$this->market) {
return $this->marketUnavailable();
}
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
try {
$data = $this->market->orderStatus((int) $userId, (string) $request->get('trade_no'));
} catch (\Throwable $e) {
return $this->marketError($e);
}
return $this->result->success($data, '获取成功');
}
/**
* 支付完成落地页(支付宝 return_url 跳转,无需登录,仅展示状态)
*/
public function payResult(Request $request): \think\response\Json
{
if (is_market_client()) {
$r = RemoteService::instance()->payResult($request->get());
return $this->result->success($r['data'] ?? ['status' => 0], $r['message'] ?: '获取成功');
}
if (!$this->market) {
return $this->marketUnavailable();
}
// method 用于本地 select 查单(alipay / wechat);远程模式随 GET 参数整体透传
$r = $this->market->payResult(
(string) $request->get('out_trade_no', ''),
(string) $request->get('method', 'alipay')
);
return $this->result->success($r['data'], $r['message']);
}
/**
* 退款 / 吊销授权(买家或运营)
* POST /api/v1/addon/refund {trade_no 或 order_id}
*/
public function refund(Request $request): \think\response\Json
{
if (is_market_client()) {
return $this->remoteRefund($request);
}
if (!$this->market) {
return $this->marketUnavailable();
}
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
try {
$r = $this->market->refund(
(int) $userId,
(string) $request->post('trade_no', ''),
(int) $request->post('order_id', 0)
);
} catch (\Throwable $e) {
return $this->marketError($e);
}
return $this->result->success($r['data'], $r['message']);
}
/**
* 我的已购插件(买家视角):列出已购付费插件 + 授权状态 + 是否可退款
*/
public function mine(Request $request): \think\response\Json
{
if (is_market_client()) {
return $this->remoteMine($request);
}
if (!$this->market) {
return $this->marketUnavailable();
}
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
return $this->result->success($this->market->mine((int) $userId), '获取成功');
}
/**
* 支付宝/微信异步回调:验签 -> 校验金额 -> 订单置已付 -> 生成授权
*/
public function notify()
{
if (is_market_client()) {
return response(RemoteService::instance()->notify(input()));
}
if (!$this->market) {
return response('fail');
}
return $this->market->notify((string) input('method', 'alipay'));
}
/**
* 用户下载已购插件(需登录 + 有效授权 + 次数限制)
*/
public function download(Request $request)
{
if (is_market_client()) {
return $this->remoteDownload($request);
}
if (!$this->market) {
return $this->marketUnavailable();
}
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
try {
$r = $this->market->download((int) $userId, (int) $request->get('addon_id'), $request);
} catch (\Throwable $e) {
return $this->marketError($e);
}
return download($r['file'], $r['filename']);
}
// ============ 客户机模式(ywxapp.api_url 指向其他服务器,见 is_market_client())下的代理实现 ============
/**
* 远程:插件详情 / 列表(代理中心站)
*/
protected function remoteInfo(Request $request): \think\response\Json
{
$id = (int) $request->get('id', 0);
$uid = $this->getLoginUid($request) ?? 0;
if ($id > 0) {
$r = RemoteService::instance()->info($id, $uid);
if (!$r['success']) {
return $this->result->setStatusCode(404)->error($r['message'] ?: '获取失败', 404);
}
$addon = $r['data']['addon'] ?? [];
$installed = is_dir(ADDON_PATH . ($addon['name'] ?? '') . DIRECTORY_SEPARATOR);
return $this->result->success([
'addon' => $addon,
'purchased' => $r['data']['purchased'] ?? false,
'installed' => $installed,
], '获取成功');
}
// 透传分页/筛选参数给中心站,由服务端完成分页(避免全量拉回后本地切片)
$params = [
'keyword' => $request->get('keyword', ''),
'category' => $request->get('category', ''),
'order' => $request->get('order', ''),
'page' => max((int) $request->get('page', 1), 1),
'page_size' => min(max((int) $request->get('per_page', 15), 1), 50),
];
$r = RemoteService::instance()->lists($params);
if (!$r['success']) {
return $this->result->error($r['message'] ?: '获取失败');
}
// 优先消费中心站服务端分页(Market::lists 在 page>0 时返回 data.list/data.total);
// 兼容历史 data.count 键;老中心站未分页时降级为本地切片。
if (isset($r['data']['list']) && (isset($r['data']['total']) || isset($r['data']['count']))) {
$items = $r['data']['list'];
$total = (int) ($r['data']['total'] ?? $r['data']['count']);
} else {
$rows = $r['data']['list'] ?? [];
$perPage = min(max((int) $request->get('per_page', 15), 1), 50);
$page = max((int) $request->get('page', 1), 1);
$total = count($rows);
$items = array_slice($rows, ($page - 1) * $perPage, $perPage);
}
$this->result->setCount($total);
return $this->result->success(['data' => $items], '获取成功');
}
/**
* 远程:发起购买(代理中心站)
*/
protected function remoteBuy(Request $request): \think\response\Json
{
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录后再购买');
}
$addonId = (int) $request->post('addon_id');
$method = $request->post('method', 'alipay');
$r = RemoteService::instance()->buy($addonId, $userId, $method);
if (!$r['success']) {
return $this->result->error($r['message'] ?: '购买失败');
}
return $this->result->success($r['data'] ?? [], $r['message'] ?: '操作成功');
}
/**
* 远程:订单状态(代理中心站)
*/
protected function remoteOrderStatus(Request $request): \think\response\Json
{
$tradeNo = $request->get('trade_no');
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
$r = RemoteService::instance()->orderStatus($tradeNo, $userId);
if (!$r['success']) {
return $this->result->error($r['message'] ?: '查询失败');
}
return $this->result->success($r['data'] ?? [], '获取成功');
}
/**
* 远程:下载已购插件(代理中心站 /appmall/api/index,付费插件需已购买)
*/
protected function remoteDownload(Request $request)
{
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
$addonId = (int) $request->get('addon_id');
$r = RemoteService::instance()->info($addonId, $userId);
if (!$r['success']) {
return $this->result->error($r['message'] ?: '插件信息获取失败');
}
$addon = $r['data']['addon'] ?? [];
$name = $addon['name'] ?? '';
$version = $addon['version'] ?? '';
if ($name === '' || $version === '') {
return $this->result->error('插件信息不完整', 404);
}
if (!empty($addon['price']) && (float) $addon['price'] > 0 && empty($r['data']['purchased'])) {
return $this->result->error('您尚未购买该插件', 403);
}
try {
$domain = (string) $request->domain();
$srcFile = RemoteService::instance()->downloadBinary($name, $version, $userId, $domain);
} catch (\Exception $e) {
return $this->result->error($e->getMessage(), 404);
}
$tmpDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon_download' . DIRECTORY_SEPARATOR;
if (!is_dir($tmpDir)) {
@mkdir($tmpDir, 0755, true);
}
$tmpFile = $tmpDir . $name . '-' . $version . '.zip';
if (is_file($srcFile)) {
copy($srcFile, $tmpFile);
@unlink($srcFile);
} else {
return $this->result->error('下载失败:未获取到文件', 404);
}
return new \ywxapp\library\StreamZipResponse($tmpFile, $name . '-' . $version . '.zip');
}
/**
* 远程:退款 / 吊销授权(代理中心站)
*/
protected function remoteRefund(Request $request): \think\response\Json
{
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
$r = RemoteService::instance()->refund(
(string) $request->post('trade_no', ''),
(int) $request->post('order_id', 0),
$userId
);
if (!$r['success']) {
return $this->result->error($r['message'] ?: '退款失败');
}
return $this->result->success($r['data'] ?? [], $r['message'] ?: '退款成功');
}
/**
* 远程:我的已购插件(代理中心站)
*/
protected function remoteMine(Request $request): \think\response\Json
{
$userId = $this->auth->model->uid ?? null;
if (empty($userId)) {
return $this->result->setStatusCode(401)->error('请先登录');
}
$r = RemoteService::instance()->my($userId);
if (!$r['success']) {
return $this->result->error($r['message'] ?: '获取失败');
}
return $this->result->success($r['data'] ?? ['list' => []], $r['message'] ?: '获取成功');
}
}