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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,52 @@
|
||||
<?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\appmall\controller;
|
||||
|
||||
use think\App;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\library\AdminAuth;
|
||||
|
||||
/**
|
||||
* 应用市场(中心站服务端)后台基类。
|
||||
*
|
||||
* appmall 已由插件(addon/appmall)迁移为普通应用(app/appmall),对外 URL 前缀仍为 /appmall。
|
||||
* 但 BackendBase 的「插件后台」判定依赖 addon\ 命名空间前缀(static::class / getNamespace),
|
||||
* 迁移后不再命中,会导致两处行为丢失,故由本类补齐:
|
||||
* 1) 鉴权:BackendBase 构造函数原本据此把容器 auth 绑成 AdminAuth;本类显式绑定,等价插件后台地基;
|
||||
* 2) 视图:覆写 isAddonContext()/currentAddon(),让基类把视图根切到 app/appmall/view/backend/
|
||||
* 并复用「核心后台 layout」外壳(与迁移前完全一致)。
|
||||
*/
|
||||
abstract class AppmallBackend extends BackendBase
|
||||
{
|
||||
public function __construct(App $app)
|
||||
{
|
||||
// 显式绑定后台鉴权实例(插件后台由 BackendBase 的 addon\ 判定完成,此处接管)
|
||||
$app->bind('auth', AdminAuth::class);
|
||||
parent::__construct($app);
|
||||
}
|
||||
|
||||
/**
|
||||
* 视作插件上下文,复用基类的插件视图目录切换与 layout 复用逻辑。
|
||||
*/
|
||||
protected function isAddonContext(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前「插件名」,用于皮肤覆盖层解析(appmall 自带视图,无非默认皮肤时自动回退)。
|
||||
*/
|
||||
protected function currentAddon(): string
|
||||
{
|
||||
return 'appmall';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\appmall\controller;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\library\Mailer;
|
||||
use app\appmall\library\AppmallSchema;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
/**
|
||||
* 开发者中心(独立前台应用,非会员中心、非后台)
|
||||
* 开发者自助注册 / 查询令牌 / 收益查询 / 提现;完全公开(noNeedLogin=*),
|
||||
* 以开发者令牌(token)而非会员登录态认证。
|
||||
* 路由前缀:/appmall/developer/*(由 MultiApp 剥离首段 appmall 后与本应用路由对齐)。
|
||||
* 视图:app/appmall/view/frontend/developer/。
|
||||
*
|
||||
* 注:此处显式把 view_path 锁定到本应用视图目录,与 AppmallFrontend 基类的切换保持一致(幂等)。
|
||||
*/
|
||||
class Developer extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 锁定视图路径到本应用视图目录
|
||||
*/
|
||||
protected function initialize()
|
||||
{
|
||||
url('appmall/developer/index' );
|
||||
// 确保 appmall 运营表存在(开发者中心只读开发者/收益/提现等表,避免全新安装 1146)
|
||||
AppmallSchema::ensure();
|
||||
$this->view->layout(false);
|
||||
// 供前端 <script src=".../wxapp.js" module="{$site.app}"> 使用(前台应用目录名)
|
||||
$this->view->assign('site', ['app' => 'frontend']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册页
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->view->assign('register_open', (bool) config('appmall.allow_register', true));
|
||||
$this->view->assign('need_approval', (bool) config('appmall.need_approval', true));
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交注册(生成令牌,按配置决定是否需要审核)
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('访问错误');
|
||||
}
|
||||
if (! (bool) config('appmall.allow_register', true)) {
|
||||
$this->result->error('当前未开放自助注册');
|
||||
}
|
||||
|
||||
$email = trim($this->request->param('email', ''));
|
||||
$name = trim($this->request->param('username', ''));
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->result->error('邮箱格式不正确');
|
||||
}
|
||||
if ($name === '') {
|
||||
$this->result->error('请填写开发者名称');
|
||||
}
|
||||
// 防刷:重复 POST 会疯狂写库 + 发邮件
|
||||
$this->assertRateLimit('register', $email);
|
||||
|
||||
if (Db::name('appmall_developers')->where('email', $email)->find()) {
|
||||
$this->result->error('该邮箱已申请过开发者令牌');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$needApproval = (bool) config('appmall.need_approval', true);
|
||||
$status = $needApproval ? 0 : 1;
|
||||
|
||||
Db::name('appmall_developers')->insert([
|
||||
'username' => $name,
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'status' => $status,
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
|
||||
if ($needApproval) {
|
||||
Mailer::send($email, '开发者入驻申请已收到', $this->pendingMail($name));
|
||||
$this->result->success([], '注册成功,等待运营审核,通过后令牌将发送至您的邮箱');
|
||||
}
|
||||
|
||||
Mailer::send($email, '您的开发者令牌', $this->tokenMail($name, $token));
|
||||
$this->result->success(
|
||||
['token' => $token],
|
||||
'注册成功,请将下方令牌配置到开发者控制台的 APPMARKET_DEV_TOKEN'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询我的令牌
|
||||
*/
|
||||
public function mine()
|
||||
{
|
||||
return $this->view->fetch('developer/mine');
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据邮箱取回令牌(只重发邮件,不再在响应里回显明文)
|
||||
*
|
||||
* 安全说明(P0):原实现把 token 明文直接放进 JSON 响应,且用不同文案区分
|
||||
* 「邮箱不存在 / 待审核 / 已禁用」—— 等于给了一个**开发者邮箱枚举接口**,
|
||||
* 任何人拿到邮箱就能直接冒用开发者身份去发布插件、改价、提现。
|
||||
* 现在:统一文案 + 只发邮件 + IP/邮箱双维度限流 + 随机延时。
|
||||
*/
|
||||
public function lookup()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('访问错误');
|
||||
}
|
||||
$email = trim($this->request->param('email', ''));
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->result->error('邮箱格式不正确');
|
||||
}
|
||||
$this->assertRateLimit('lookup', $email);
|
||||
|
||||
$dev = Db::name('appmall_developers')->where('email', $email)->find();
|
||||
if ($dev && (int) $dev['status'] === 1) {
|
||||
Mailer::send($email, '您的开发者令牌', $this->tokenMail($dev['username'], $dev['token']));
|
||||
}
|
||||
|
||||
// 随机延时:抹平「命中/未命中」的响应耗时差异,堵住时序侧信道枚举
|
||||
usleep(random_int(150, 500) * 1000);
|
||||
|
||||
// 无论邮箱是否存在、状态如何,都返回同一句话(防枚举)
|
||||
$this->result->success([], '如果该邮箱已注册且已通过审核,令牌已发送至邮箱,请查收');
|
||||
}
|
||||
|
||||
/**
|
||||
* 频率限制(IP + 业务 key 双维度)
|
||||
*
|
||||
* 开发者中心所有接口都免登录,若不限制相当于公开的压力放大器:
|
||||
* 可被用来刷邮件(SMTP 信誉 / 配额被耗尽)与枚举开发者邮箱。
|
||||
*
|
||||
* @param string $scene 业务场景 key
|
||||
* @param string $extra 参与计数的业务值(如邮箱)
|
||||
*/
|
||||
protected function assertRateLimit(string $scene, string $extra = ''): void
|
||||
{
|
||||
$ip = (string) $this->request->ip();
|
||||
$limit = (int) config('appmall.developer_limit', 5);
|
||||
$window = (int) config('appmall.developer_window', 600);
|
||||
$key = 'appmall_dev_' . $scene . '_' . md5($ip . '|' . strtolower($extra));
|
||||
|
||||
$num = (int) \think\facade\Cache::get($key, 0);
|
||||
if ($num >= $limit) {
|
||||
\think\facade\Log::warning('[Developer] 开发者中心请求超频', [
|
||||
'scene' => $scene, 'ip' => $ip,
|
||||
]);
|
||||
$this->result->error('操作过于频繁,请 ' . (int) ($window / 60) . ' 分钟后再试');
|
||||
}
|
||||
\think\facade\Cache::set($key, $num + 1, $window);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的收益(令牌认证):返回余额与近期收益账本
|
||||
*/
|
||||
public function earnings()
|
||||
{
|
||||
$this->ensureTables();
|
||||
$token = $this->request->param('token', '');
|
||||
$dev = Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('令牌无效');
|
||||
}
|
||||
$revenues = Db::name('appmall_addon_revenues')->where('developer_id', $dev['id'])
|
||||
->order('id', 'desc')->limit(20)->select()->toArray();
|
||||
$this->result->success([
|
||||
'balance' => $dev['balance'] ?? 0,
|
||||
'frozen_balance' => $dev['frozen_balance'] ?? 0,
|
||||
'revenues' => $revenues,
|
||||
], '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起提现(令牌认证):可提现余额 → 冻结,生成提现单待运营打款
|
||||
*/
|
||||
public function withdraw()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
return $this->result->error('访问错误');
|
||||
}
|
||||
$this->ensureTables();
|
||||
$token = $this->request->param('token', '');
|
||||
$amount = (float) $this->request->param('amount', 0);
|
||||
$channel = $this->request->param('channel', 'alipay');
|
||||
$account = trim($this->request->param('account', ''));
|
||||
$dev = Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('令牌无效');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return $this->result->error('提现金额必须大于 0');
|
||||
}
|
||||
if ($amount > (float) ($dev['balance'] ?? 0)) {
|
||||
return $this->result->error('可提现余额不足');
|
||||
}
|
||||
if ($account === '') {
|
||||
return $this->result->error('请填写收款账号');
|
||||
}
|
||||
Db::transaction(function () use ($dev, $amount, $channel, $account) {
|
||||
Db::name('appmall_developers')->where('id', $dev['id'])
|
||||
->dec('balance', $amount)->inc('frozen_balance', $amount)->update();
|
||||
Db::name('appmall_addon_withdrawals')->insert([
|
||||
'developer_id' => $dev['id'],
|
||||
'amount' => $amount,
|
||||
'channel' => $channel,
|
||||
'account' => $account,
|
||||
'status' => 0,
|
||||
'create_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
});
|
||||
return $this->result->success([], '提现申请已提交,等待运营打款');
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者控制台入口(统一 DZ 风格布局):令牌认证后渲染控制台
|
||||
* 路由:GET /appmall/developer/console?token=xxx
|
||||
*/
|
||||
public function console()
|
||||
{
|
||||
$this->view->assign('token', $this->request->param('token', ''));
|
||||
return $this->view->fetch('developer/console');
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者控制台(兼容旧入口,重定向到统一控制台)
|
||||
*/
|
||||
public function dashboard()
|
||||
{
|
||||
$this->view->assign('token', $this->request->param('token', ''));
|
||||
return $this->view->fetch('developer/console');
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制台概览数据(令牌认证):应用数 / 待审数 / 总下载 / 余额 / 累计收益 / 近期提交
|
||||
* 路由:POST /appmall/developer/overview
|
||||
*/
|
||||
public function overview()
|
||||
{
|
||||
$this->ensureTables();
|
||||
$dev = $this->authDev();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('令牌无效');
|
||||
}
|
||||
$devId = (int) $dev['id'];
|
||||
$appCount = Db::name('appmall_addon_list')->where('developer_id', $devId)->where('status', 1)->count();
|
||||
$pendingCount = Db::name('appmall_addon_submissions')->where('developer_id', $devId)->where('status', 0)->count();
|
||||
$totalDownload = (int) Db::name('appmall_addon_list')->where('developer_id', $devId)->sum('download_count');
|
||||
$totalIncome = (float) Db::name('appmall_addon_revenues')->where('developer_id', $devId)->where('status', 1)->sum('income');
|
||||
$recent = Db::name('appmall_addon_submissions')->where('developer_id', $devId)
|
||||
->field('title,version,status,update_at,type')->order('id', 'desc')->limit(5)->select()->toArray();
|
||||
$this->result->success([
|
||||
'username' => $dev['username'],
|
||||
'email' => $dev['email'],
|
||||
'balance' => $dev['balance'] ?? 0,
|
||||
'frozen_balance' => $dev['frozen_balance'] ?? 0,
|
||||
'app_count' => $appCount,
|
||||
'pending_count' => $pendingCount,
|
||||
'total_downloads' => $totalDownload,
|
||||
'total_income' => $totalIncome,
|
||||
'recent' => $recent,
|
||||
], '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的应用(令牌认证):已上架/下架应用 + 提交记录(待审/驳回)合并返回
|
||||
* 路由:POST /appmall/developer/apps
|
||||
*/
|
||||
public function apps()
|
||||
{
|
||||
$this->ensureTables();
|
||||
$dev = $this->authDev();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('令牌无效');
|
||||
}
|
||||
$devId = (int) $dev['id'];
|
||||
$listed = Db::name('appmall_addon_list')->where('developer_id', $devId)
|
||||
->field('id,name,title,version,price,logo,category,download_count,status,update_at,type')
|
||||
->order('update_at', 'desc')->select()->toArray();
|
||||
$subs = Db::name('appmall_addon_submissions')->where('developer_id', $devId)
|
||||
->field('id,name,title,version,price,logo,category,status,reject_reason,update_at,type')
|
||||
->order('update_at', 'desc')->select()->toArray();
|
||||
$this->result->success(['listed' => $listed, 'submissions' => $subs], '获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改我的应用价格(令牌认证,仅能改自己名下的应用;无需重新打包/重新提审)
|
||||
* 路由:POST /appmall/developer/price {token, name, price}
|
||||
*/
|
||||
public function price()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
return $this->result->error('访问错误');
|
||||
}
|
||||
$this->ensureTables();
|
||||
$dev = $this->authDev();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('令牌无效');
|
||||
}
|
||||
$name = trim((string) $this->request->param('name', ''));
|
||||
$price = (float) $this->request->param('price', -1);
|
||||
try {
|
||||
\app\appmall\service\MarketService::instance()
|
||||
->updatePrice($name, $price, (int) $dev['id']);
|
||||
} catch (\app\appmall\service\MarketException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
return $this->result->success([], '价格已更新,市场即时生效');
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌鉴权:返回 status=1 的开发者记录,否则 null(沿用 earnings/withdraw 的契约)
|
||||
*/
|
||||
private function authDev()
|
||||
{
|
||||
$token = $this->request->param('token', '');
|
||||
return Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时自愈建表:开发者 / 收益 / 提现等运营表(幂等,进程内只探测一次)。
|
||||
* 唯一事实源是 app/appmall/install.sql,由 AppmallSchema 解析执行,本类不再内联 DDL。
|
||||
*/
|
||||
private function ensureTables(): void
|
||||
{
|
||||
AppmallSchema::ensure();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者令牌邮件正文
|
||||
*/
|
||||
private function tokenMail($name, $token): string
|
||||
{
|
||||
return "尊敬的 {$name}:<br>您的开发者令牌已生成:<br><br>"
|
||||
. "<code style=\"background:#f4f4f4;padding:4px 8px;border-radius:4px;\">{$token}</code><br><br>"
|
||||
. '请将其配置到开发者控制台的 <b>APPMARKET_DEV_TOKEN</b> 环境变量后,即可在「发布到官方市场」中使用。';
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核中邮件正文
|
||||
*/
|
||||
private function pendingMail($name): string
|
||||
{
|
||||
return "尊敬的 {$name}:<br>我们已收到您的开发者入驻申请,将在 1-3 个工作日内完成审核,"
|
||||
. '通过后令牌会发送至本邮箱,请注意查收。';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?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\appmall\controller;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
|
||||
/**
|
||||
* 主框架发布/下载展示页(公开,仅中心站)
|
||||
*
|
||||
* 列出已发布的主框架版本,提供「整包 / 增量补丁 / 完整安装包」下载入口。
|
||||
* 下载走对外 API /appmall/api/framework/download(无需登录,可选签名)。
|
||||
* 路由前缀:/appmall/framework/*(由 AppService::loadAddonRoutes 外层包 Route::group('appmall'))。
|
||||
* 视图:addons/appmall/view/frontend/framework/。
|
||||
*/
|
||||
class Framework extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 锁定视图路径到插件目录(AddonFrontend 默认指向主应用 view/frontend/,须覆盖)。
|
||||
* 仅中心站暴露:客户机不持有 framework_list 发布数据,访问即 404。
|
||||
*/
|
||||
protected function initialize()
|
||||
{
|
||||
$this->view->layout(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布列表展示页
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->ensureColumns();
|
||||
$rows = Db::name('appmall_framework_list')
|
||||
->where('status', 1)
|
||||
->order('id', 'desc')
|
||||
->field('id,version,title,changelog,file_path,patch_path,install_path,patch_from,from_version,download_count,create_at')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as &$r) {
|
||||
$r['has_full'] = !empty($r['file_path']);
|
||||
$r['has_patch'] = !empty($r['patch_path']);
|
||||
$r['has_install'] = !empty($r['install_path']);
|
||||
$r['patch_from'] = $r['patch_from'] ?: $r['from_version'];
|
||||
$r['create_at_fmt'] = $r['create_at'] ? date('Y-m-d', (int) $r['create_at']) : '';
|
||||
unset($r['file_path'], $r['patch_path'], $r['install_path']);
|
||||
}
|
||||
unset($r);
|
||||
|
||||
$this->view->assign('list', $rows);
|
||||
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
|
||||
return $this->view->fetch('framework/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时自愈:补齐 install_path / install_hash 列(早期安装的中心站可能缺失)。
|
||||
*/
|
||||
private function ensureColumns(): void
|
||||
{
|
||||
$table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list';
|
||||
try {
|
||||
$defs = [
|
||||
'install_path' => "varchar(255) DEFAULT '' COMMENT '完整安装包 zip 物理路径'",
|
||||
'install_hash' => "varchar(64) DEFAULT '' COMMENT '完整安装包 SHA256 校验值'",
|
||||
];
|
||||
foreach ($defs as $col => $def) {
|
||||
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
|
||||
if (empty($cols)) {
|
||||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$col}` {$def}");
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?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\appmall\controller;
|
||||
|
||||
use app\appmall\library\LicensePolicy;
|
||||
use app\appmall\service\MarketService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
/**
|
||||
* 应用商店前台展示页(公开,仅中心站)
|
||||
*
|
||||
* 列出应用市场上线的插件 / 模板,卡片展示 logo、价格、评分、下载量等。
|
||||
* 数据来自中心站本地库(appmall_addon_list),复用 MarketService::catalog()。
|
||||
* 路由前缀:/appmall/store/*(由 MultiApp 剥离首段 appmall 后与本应用路由对齐)。
|
||||
* 视图:app/appmall/view/frontend/store/。
|
||||
*/
|
||||
class Store extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 锁定视图路径到应用目录(app/appmall/view/frontend/);仅中心站暴露(客户机不持有市场列表数据,访问即 404)。
|
||||
*/
|
||||
protected function initialize()
|
||||
{
|
||||
|
||||
$this->view->layout(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 商店首页:上线插件列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$catalog = MarketService::instance()->catalog();
|
||||
$items = $catalog['list'] ?? [];
|
||||
$categories = $catalog['categories'] ?? [];
|
||||
|
||||
$plugins = [];
|
||||
foreach ($items as $it) {
|
||||
$price = (float) ($it['price'] ?? 0);
|
||||
$plugins[] = [
|
||||
'id' => $it['id'] ?? 0,
|
||||
'name' => $it['name'] ?? '',
|
||||
'title' => $it['title'] ?? ($it['name'] ?? ''),
|
||||
'desc' => $it['intro'] ?? '',
|
||||
'author' => $it['author'] ?? '',
|
||||
'logo' => $it['logo'] ?? '',
|
||||
'version' => $it['version'] ?? '',
|
||||
'price' => $price,
|
||||
'price_text' => $price > 0 ? '¥' . number_format($price, 2) : '免费',
|
||||
'rating' => (float) ($it['rating'] ?? 0),
|
||||
'downloads' => (int) ($it['download_count'] ?? 0),
|
||||
'category' => $it['category'] ?? '',
|
||||
'type' => $it['type'] ?? 'addon',
|
||||
];
|
||||
}
|
||||
|
||||
$this->view->assign('list', $plugins);
|
||||
$this->view->assign('categories', $categories);
|
||||
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
|
||||
return $this->view->fetch('store/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件详情页(/appmall/store/detail/<name>)
|
||||
* 参考 FastAdmin 插件市场详情版式:左封面+截图、右元信息+版本历史。
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
$name = input('name', '', 'trim');
|
||||
if ($name === '') {
|
||||
abort(404, '插件标识为空');
|
||||
}
|
||||
$catalog = MarketService::instance()->catalog();
|
||||
$hit = null;
|
||||
foreach (($catalog['list'] ?? []) as $it) {
|
||||
if (($it['name'] ?? '') === $name) {
|
||||
$hit = $it;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hit) {
|
||||
abort(404, '插件不存在或未上架');
|
||||
}
|
||||
|
||||
$price = (float) ($hit['price'] ?? 0);
|
||||
$row = [
|
||||
'id' => $hit['id'] ?? 0,
|
||||
'name' => $hit['name'] ?? '',
|
||||
'title' => $hit['title'] ?? ($hit['name'] ?? ''),
|
||||
'intro' => $hit['intro'] ?? '',
|
||||
'author' => $hit['author'] ?? '',
|
||||
'logo' => $hit['logo'] ?? '',
|
||||
'version' => $hit['version'] ?? '',
|
||||
'price' => $price,
|
||||
'price_text' => $price > 0 ? '¥' . number_format($price, 2) : '免费',
|
||||
'rating' => (float) ($hit['rating'] ?? 0),
|
||||
'downloads' => (int) ($hit['download_count'] ?? 0),
|
||||
'category' => $hit['category'] ?? '',
|
||||
'tags' => $hit['tags'] ?? '',
|
||||
'type' => $hit['type'] ?? 'addon',
|
||||
'update_at' => (int) ($hit['update_at'] ?? 0),
|
||||
'update_fmt' => $hit['update_at'] ? date('Y-m-d', (int) $hit['update_at']) : '',
|
||||
'screenshots'=> $this->parseScreenshots($hit['screenshots'] ?? ''),
|
||||
'versions' => $this->normalizeVersions($hit['versions'] ?? []),
|
||||
// 用户评价:评分与「已审核通过」的评论(submitAddon 提交后由后台审核发布)
|
||||
'review_count' => (int) ($hit['review_count'] ?? 0),
|
||||
'reviews' => $this->publishedReviews((int) ($hit['id'] ?? 0)),
|
||||
];
|
||||
|
||||
// 授权状态:登录会员能看到自己「能不能装、要不要续费、要不要升级」。
|
||||
// 授权按插件标识跨版本匹配,所以展示的是该插件最新版的可用性。
|
||||
$uid = $this->memberId();
|
||||
$product = Db::name('appmall_addon_list')->where('id', (int) ($hit['id'] ?? 0))->find() ?: $hit;
|
||||
$license = $uid > 0 ? LicensePolicy::findLicense($uid, (string) ($hit['name'] ?? ''), (int) ($hit['id'] ?? 0)) : null;
|
||||
$summary = LicensePolicy::summary($product, $license);
|
||||
|
||||
$this->view->assign('addon', $row);
|
||||
$this->view->assign('license', $summary);
|
||||
$this->view->assign('member_id', $uid);
|
||||
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
|
||||
return $this->view->fetch('store/detail');
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页「购买 / 续费 / 升级」入口。
|
||||
* POST /appmall/store/buy {id, method?}
|
||||
*
|
||||
* 返回 {code:1,data:{...}}:
|
||||
* - free / owned:直接可用(免支付的免费插件、或已购且无需再付)
|
||||
* - pay_params:支付表单 HTML,前端插入页面后自动提交即可跳转收银台
|
||||
* - license_key:模拟支付环境下直接签发
|
||||
*/
|
||||
public function buy()
|
||||
{
|
||||
$uid = $this->memberId();
|
||||
if ($uid <= 0) {
|
||||
return json(['code' => 0, 'msg' => '请先登录后再购买']);
|
||||
}
|
||||
$id = (int) input('id', 0);
|
||||
$method = (string) input('method', 'alipay');
|
||||
try {
|
||||
$res = MarketService::instance()->buy($uid, $id, $method, (string) Request::domain());
|
||||
} catch (\Throwable $e) {
|
||||
return json(['code' => 0, 'msg' => $e->getMessage()]);
|
||||
}
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => $res['message'] ?? '',
|
||||
'data' => $res['data'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页「开通试用」入口(商品需配置 trial_days)。
|
||||
* POST /appmall/store/trial {id}
|
||||
*/
|
||||
public function trial()
|
||||
{
|
||||
$uid = $this->memberId();
|
||||
if ($uid <= 0) {
|
||||
return json(['code' => 0, 'msg' => '请先登录后再试用']);
|
||||
}
|
||||
$id = (int) input('id', 0);
|
||||
try {
|
||||
$res = MarketService::instance()->trial($uid, $id);
|
||||
} catch (\Throwable $e) {
|
||||
return json(['code' => 0, 'msg' => $e->getMessage()]);
|
||||
}
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => $res['message'] ?? '',
|
||||
'data' => $res['data'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录会员 ID(未登录返回 0)
|
||||
*/
|
||||
private function memberId(): int
|
||||
{
|
||||
if ($this->auth && !empty($this->auth->isLogin)) {
|
||||
return (int) ($this->auth->info['id'] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* screenshots 可能是逗号分隔字符串 / JSON 数组,归一化为数组。
|
||||
*/
|
||||
private function parseScreenshots($raw): array
|
||||
{
|
||||
if (is_array($raw)) {
|
||||
return array_values($raw);
|
||||
}
|
||||
$raw = trim((string) $raw);
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
if (strpos($raw, '[') === 0) {
|
||||
$dec = json_decode($raw, true);
|
||||
return is_array($dec) ? array_values($dec) : [];
|
||||
}
|
||||
return array_filter(array_map('trim', explode(',', $raw)), fn ($x) => $x !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* versions 子版本归一化(version/price/update_fmt)。
|
||||
*/
|
||||
private function normalizeVersions(array $vers): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($vers as $v) {
|
||||
$out[] = [
|
||||
'version' => (string) ($v['version'] ?? ''),
|
||||
'price' => (float) ($v['price'] ?? 0),
|
||||
'price_text'=> ((float) ($v['price'] ?? 0)) > 0 ? '¥' . number_format((float) $v['price'], 2) : '免费',
|
||||
'update_fmt'=> !empty($v['update_at']) ? date('Y-m-d', (int) $v['update_at']) : '',
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某插件已审核通过的评价(详情页展示用,最多 10 条)
|
||||
*
|
||||
* 只取 status=1:用户提交后需运营审核才公开,避免详情页出现垃圾/攻击性内容。
|
||||
* 表不存在时(老站未升级 schema)返回空数组,详情页降级为「暂无评价」。
|
||||
*/
|
||||
private function publishedReviews(int $aid): array
|
||||
{
|
||||
if ($aid <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$rows = Db::name('appmall_addon_reviews')
|
||||
->where('aid', $aid)
|
||||
->where('status', \app\appmall\model\AddonReview::STATUS_PUBLISHED)
|
||||
->order('create_at', 'desc')
|
||||
->limit(10)
|
||||
->field('id,rating,content,version,create_at,uid,site_id')
|
||||
->select()->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $it) {
|
||||
$out[] = [
|
||||
'id' => (int) $it['id'],
|
||||
'rating' => (int) $it['rating'],
|
||||
'content' => (string) $it['content'],
|
||||
'version' => (string) $it['version'],
|
||||
'date' => date('Y-m-d', (int) $it['create_at']),
|
||||
// 评论者可能来自任意客户机,统一脱敏
|
||||
'user' => $it['uid'] > 0
|
||||
? '用户' . substr(md5((string) $it['site_id'] . ':' . $it['uid']), 0, 6)
|
||||
: '匿名用户',
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?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\appmall\controller\api;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 主框架升级服务 API
|
||||
*
|
||||
* 契约对齐客户端 ywxapp\service\FrameworkService:
|
||||
* - GET /api/framework/version 返回最新版本 + 历史版本(客户端检测更新)
|
||||
* - GET /api/framework/download 下载核心包 zip(可选签名校验)
|
||||
* 原 upgrade 插件功能,已合并进 appmall(应用服务中心)。
|
||||
*/
|
||||
class Framework
|
||||
{
|
||||
/**
|
||||
* 版本列表 / 最新版本
|
||||
*/
|
||||
public function version()
|
||||
{
|
||||
$this->ensureColumns();
|
||||
try {
|
||||
$rows = Db::name('appmall_framework_list')
|
||||
->where('status', 1)
|
||||
->order('id', 'desc')
|
||||
->limit(20)
|
||||
->field('id,version,title,changelog,type,from_version,patch_from,file_path,patch_path,install_path,create_at')
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Exception $e) {
|
||||
return json(['code' => 0, 'msg' => '读取版本列表失败:' . $e->getMessage()]);
|
||||
}
|
||||
|
||||
$latest = $rows[0] ?? null;
|
||||
$hasFull = !empty($latest['file_path']);
|
||||
$hasPatch = !empty($latest['patch_path']);
|
||||
$hasInstall = !empty($latest['install_path'] ?? '');
|
||||
$patchFrom = $latest['patch_from'] ?? '';
|
||||
if ($patchFrom === '' && $hasPatch) {
|
||||
$patchFrom = $latest['from_version'] ?? '';
|
||||
}
|
||||
|
||||
// 不向客户端泄露物理路径
|
||||
foreach ($rows as &$r) {
|
||||
$r['has_full'] = !empty($r['file_path']);
|
||||
$r['has_patch'] = !empty($r['patch_path']);
|
||||
$r['has_install'] = !empty($r['install_path']);
|
||||
unset($r['file_path'], $r['patch_path'], $r['install_path']);
|
||||
}
|
||||
unset($r);
|
||||
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => 'success',
|
||||
'data' => [
|
||||
'version' => $latest['version'] ?? '',
|
||||
'title' => $latest['title'] ?? '',
|
||||
'changelog' => $latest['changelog'] ?? '',
|
||||
// 兼容旧客户端:type 只表达「整包缺失时才是纯补丁」
|
||||
'type' => $hasFull ? 0 : ($hasPatch ? 1 : (int) ($latest['type'] ?? 0)),
|
||||
'from_version'=> $patchFrom,
|
||||
// 新协议:整包与补丁可同版本共存,客户端按自身版本择优
|
||||
'has_full' => $hasFull,
|
||||
'has_patch' => $hasPatch,
|
||||
'has_install' => $hasInstall,
|
||||
'patch_from' => $patchFrom,
|
||||
'release_at' => $latest['create_at'] ?? 0,
|
||||
'history' => $rows,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容已安装库:补齐 type / from_version / patch_* 列,并迁移旧「补丁行」数据
|
||||
*/
|
||||
private function ensureColumns(): void
|
||||
{
|
||||
$table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list';
|
||||
try {
|
||||
// 列兜底由 FrameworkList 模型负责(事实源 app/appmall/install.sql),此处只保留历史数据迁移
|
||||
\app\appmall\model\FrameworkList::ensureSchema();
|
||||
Db::execute(
|
||||
"UPDATE `{$table}` SET patch_path = file_path, patch_hash = file_hash, patch_from = from_version,"
|
||||
. " file_path = '', file_hash = ''"
|
||||
. " WHERE type = 1 AND (patch_path = '' OR patch_path IS NULL) AND file_path <> ''"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载核心包 zip
|
||||
* 客户端 FrameworkService::download() GET ?version= :
|
||||
* - 直接返回 zip 二进制(首字节非 '{')。
|
||||
* 可选签名校验:config('appmall.addon_download_sign') = true 时,
|
||||
* 请求需带 sign=md5('framework'.version.ts.secret) & ts(5 分钟有效期)。
|
||||
*/
|
||||
public function download()
|
||||
{
|
||||
$version = Request::param('version', '');
|
||||
// 选包:type=install/2 下安装包;type=patch/1 下补丁;type=full/0 下整包;缺省整包(缺失时回退补丁/安装包)
|
||||
$typeRaw = (string) Request::param('type', '');
|
||||
$wantPatch = in_array($typeRaw, ['patch', '1'], true);
|
||||
$wantInstall = in_array($typeRaw, ['install', '2'], true);
|
||||
|
||||
if (Config::get('ywxapp.addon_download_sign', false)) {
|
||||
$sign = Request::param('sign', '');
|
||||
$ts = (int) Request::param('ts', 0);
|
||||
if (!$this->verifySign($version, $ts, $sign)) {
|
||||
return json(['code' => 0, 'message' => '签名校验失败']);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行时自愈:已部署的中心站若早于本版本安装,可能缺下载日志表
|
||||
$this->ensureTables();
|
||||
|
||||
$query = Db::name('appmall_framework_list')->where('status', 1);
|
||||
if ($version !== '') {
|
||||
$query->where('version', $version);
|
||||
}
|
||||
$row = $query->order('id', 'desc')->find();
|
||||
if (empty($row)) {
|
||||
return json(['code' => 0, 'message' => '版本包不存在']);
|
||||
}
|
||||
|
||||
if ($wantInstall) {
|
||||
$path = $row['install_path'] ?? '';
|
||||
} elseif ($wantPatch) {
|
||||
$path = $row['patch_path'] ?? '';
|
||||
} else {
|
||||
$path = $row['file_path'] ?? '';
|
||||
// 兼容「仅补丁/仅安装包」版本行:未显式指定时整包缺失则回退
|
||||
if (($path === '' || !is_file($path)) && !empty($row['patch_path'])) {
|
||||
$path = $row['patch_path'];
|
||||
$wantPatch = true;
|
||||
}
|
||||
if (($path === '' || !is_file($path)) && !empty($row['install_path'])) {
|
||||
$path = $row['install_path'];
|
||||
$wantInstall = true;
|
||||
}
|
||||
}
|
||||
if (empty($path) || !is_file($path)) {
|
||||
$msg = $wantInstall ? '该版本无完整安装包或文件缺失'
|
||||
: ($wantPatch ? '该版本无增量补丁或文件缺失' : '版本包不存在或文件缺失');
|
||||
return json(['code' => 0, 'message' => $msg]);
|
||||
}
|
||||
|
||||
// 记录下载次数与日志(列/表缺失时忽略,不影响下载)
|
||||
try {
|
||||
Db::name('appmall_framework_list')->where('id', $row['id'])->inc('download_count')->update();
|
||||
Db::name('appmall_framework_download_log')->insert([
|
||||
'version' => $row['version'],
|
||||
'uid' => (int) Request::param('uid', 0),
|
||||
'ip' => Request::ip(),
|
||||
'create_at' => time(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
$downloadName = 'ywxapp-' . $row['version']
|
||||
. ($wantInstall ? '-install' : ($wantPatch ? '-patch' : '')) . '.zip';
|
||||
|
||||
// 流式下载响应(复用公共类):避免 ThinkPHP download() 内部 file_get_contents 将整文件读入内存,
|
||||
// 改用分块 fread 输出并支持 Range 续传;类内部已 set_time_limit(0)。
|
||||
return new \ywxapp\library\StreamZipResponse($path, $downloadName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时自愈建表:框架版本表 / 下载日志(幂等,进程内只探测一次)。
|
||||
* 唯一事实源是 app/appmall/install.sql,由 AppmallSchema 解析执行,本类不再内联 DDL。
|
||||
*/
|
||||
private function ensureTables(): void
|
||||
{
|
||||
\app\appmall\library\AppmallSchema::ensure();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载签名校验
|
||||
*/
|
||||
private function verifySign(string $version, int $ts, string $sign): bool
|
||||
{
|
||||
if ($sign === '' || abs(time() - $ts) > 300) {
|
||||
return false;
|
||||
}
|
||||
$secret = Config::get('ywxapp.addon_secret', 'ywxapp-addon-secret-change-me');
|
||||
$expect = md5('framework' . $version . $ts . $secret);
|
||||
return hash_equals($expect, $sign);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
<?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\appmall\controller\backend;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\View;
|
||||
use Exception;
|
||||
use app\appmall\controller\AppmallBackend;
|
||||
// 别名导入:本控制器类名也叫 AddonReview,直接 use 会与自身声明冲突
|
||||
use app\appmall\model\AddonReview as AddonReviewModel;
|
||||
|
||||
/**
|
||||
* 插件审核(运营后台,中心站)
|
||||
*
|
||||
* 审核开发者通过 Market::submit 提交的插件上架申请(appmall_addon_submissions)。
|
||||
* status: 0=待审核 1=已发布 2=已驳回。
|
||||
*/
|
||||
class AddonReview extends AppmallBackend
|
||||
{
|
||||
/**
|
||||
* 审核列表 / 页面
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$status = $this->request->param('status', '');
|
||||
$query = Db::name('appmall_addon_submissions')->order('id', 'desc');
|
||||
if ($status !== '') {
|
||||
$query->where('status', (int) $status);
|
||||
}
|
||||
$list = $query->paginate([
|
||||
'page' => (int) $this->request->param('page', 1),
|
||||
'list_rows' => (int) $this->request->param('limit', 10),
|
||||
]);
|
||||
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||
}
|
||||
return View::fetch('addonreview/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过:标记已发布(status=1)
|
||||
* 注意:正式上架到 appmall_addon_list 由发布流程处理,此处仅切换审核态。
|
||||
*/
|
||||
public function audit()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
$row = Db::name('appmall_addon_submissions')->where('id', $id)->find();
|
||||
if (empty($row)) {
|
||||
return $this->result->error('提交记录不存在');
|
||||
}
|
||||
if ((int) $row['status'] === 1) {
|
||||
return $this->result->error('该提交已发布');
|
||||
}
|
||||
Db::name('appmall_addon_submissions')->where('id', $id)
|
||||
->update(['status' => 1, 'reject_reason' => '', 'update_at' => time()]);
|
||||
return $this->result->success([], '已通过审核');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回:标记已驳回(status=2)并记录原因
|
||||
*/
|
||||
public function reject()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
$reason = trim(input('reason', ''));
|
||||
if ($reason === '') {
|
||||
return $this->result->error('请填写驳回原因');
|
||||
}
|
||||
$row = Db::name('appmall_addon_submissions')->where('id', $id)->find();
|
||||
if (empty($row)) {
|
||||
return $this->result->error('提交记录不存在');
|
||||
}
|
||||
Db::name('appmall_addon_submissions')->where('id', $id)
|
||||
->update(['status' => 2, 'reject_reason' => $reason, 'update_at' => time()]);
|
||||
return $this->result->success([], '已驳回');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 用户评价审核(appmall_addon_reviews) ====================
|
||||
|
||||
/**
|
||||
* 评价审核列表 / 页面
|
||||
*
|
||||
* 与上架审核(submissions)分开:这里是用户安装使用后提交的评分与评论,先审后发。
|
||||
*/
|
||||
public function comments()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$status = $this->request->param('status', '');
|
||||
$keyword = trim((string) $this->request->param('keyword', ''));
|
||||
$query = Db::name('appmall_addon_reviews')->alias('r')
|
||||
->leftJoin('appmall_addon_list l', 'r.aid = l.id')
|
||||
->field('r.*, l.title AS addon_title, l.name AS addon_name')
|
||||
->order('r.id', 'desc');
|
||||
if ($status !== '') {
|
||||
$query->where('r.status', (int) $status);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query->whereLike('l.name|l.title|r.content', '%' . $keyword . '%');
|
||||
}
|
||||
$list = $query->paginate([
|
||||
'page' => (int) $this->request->param('page', 1),
|
||||
'list_rows' => (int) $this->request->param('limit', 10),
|
||||
]);
|
||||
$items = $list->items();
|
||||
foreach ($items as &$it) {
|
||||
$it['create_fmt'] = date('Y-m-d H:i', (int) $it['create_at']);
|
||||
$it['content'] = (string) $it['content'];
|
||||
}
|
||||
$this->result->setCount($list->total())->success($items, '获取成功');
|
||||
}
|
||||
// 该模块暂无后台 HTML 视图(应用市场的运营页面已下线),这里作为数据源接口存在:
|
||||
// 后续接 UI 时补 addonreview/comments 视图即可,不再引入半成品页面。
|
||||
return json([
|
||||
'code' => 1,
|
||||
'msg' => '请以 Ajax(X-Requested-With)请求该接口获取评价列表',
|
||||
'data' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 评价通过:置为已发布并重算插件评分
|
||||
*/
|
||||
public function approveComment()
|
||||
{
|
||||
$id = (int) input('id');
|
||||
try {
|
||||
$row = Db::name('appmall_addon_reviews')->where('id', $id)->find();
|
||||
if (empty($row)) {
|
||||
return $this->result->error('评价不存在');
|
||||
}
|
||||
Db::name('appmall_addon_reviews')->where('id', $id)
|
||||
->update(['status' => AddonReviewModel::STATUS_PUBLISHED, 'update_at' => time()]);
|
||||
AddonReviewModel::syncRating((int) $row['aid']);
|
||||
return $this->result->success([], '已通过');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评价驳回:置为已驳回并重算评分(该条不再计入统计)
|
||||
*/
|
||||
public function rejectComment()
|
||||
{
|
||||
$id = (int) input('id');
|
||||
try {
|
||||
$row = Db::name('appmall_addon_reviews')->where('id', $id)->find();
|
||||
if (empty($row)) {
|
||||
return $this->result->error('评价不存在');
|
||||
}
|
||||
Db::name('appmall_addon_reviews')->where('id', $id)
|
||||
->update(['status' => AddonReviewModel::STATUS_REJECTED, 'update_at' => time()]);
|
||||
AddonReviewModel::syncRating((int) $row['aid']);
|
||||
return $this->result->success([], '已驳回');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除评价(恶意/重复/用户撤回),删除后重算评分
|
||||
*/
|
||||
public function deleteComment()
|
||||
{
|
||||
$id = (int) input('id');
|
||||
try {
|
||||
$row = Db::name('appmall_addon_reviews')->where('id', $id)->find();
|
||||
if (empty($row)) {
|
||||
return $this->result->error('评价不存在');
|
||||
}
|
||||
Db::name('appmall_addon_reviews')->where('id', $id)->delete();
|
||||
AddonReviewModel::syncRating((int) $row['aid']);
|
||||
return $this->result->success([], '已删除');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?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\appmall\controller\backend;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\View;
|
||||
use Exception;
|
||||
use app\appmall\controller\AppmallBackend;
|
||||
use ywxapp\library\Mailer;
|
||||
|
||||
/**
|
||||
* 开发者管理(运营后台,含审核)
|
||||
* 由原核心 app/backend/controller/Developer.php 剥离到 appmall 应用。
|
||||
*/
|
||||
class Developer extends AppmallBackend
|
||||
{
|
||||
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$status = $this->request->param('status', '');
|
||||
$query = Db::name('appmall_developers');
|
||||
if ($status !== '') {
|
||||
$query->where('status', (int) $status);
|
||||
}
|
||||
$list = $query->order('status', 'asc')
|
||||
->order('id', 'desc')
|
||||
->paginate([
|
||||
'page' => (int) $this->request->param('page', 1),
|
||||
'list_rows' => (int) $this->request->param('limit', 10),
|
||||
]);
|
||||
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||
}
|
||||
return View::fetch('developer/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过:激活并邮件下发令牌
|
||||
*/
|
||||
public function approve()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
$dev = Db::name('appmall_developers')->where('id', $id)->find();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('记录不存在');
|
||||
}
|
||||
Db::name('appmall_developers')->where('id', $id)->update(['status' => 1, 'update_at' => time()]);
|
||||
Mailer::send($dev['email'], '开发者申请已通过', $this->tokenMail($dev['username'], $dev['token']));
|
||||
return $this->result->success([], '已通过并发送令牌邮件');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用
|
||||
*/
|
||||
public function disable()
|
||||
{
|
||||
$id = (int) input('id');
|
||||
Db::name('appmall_developers')->where('id', $id)->update(['status' => -1, 'update_at' => time()]);
|
||||
return $this->result->success([], '已禁用');
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新发送令牌邮件(仅已激活)
|
||||
*/
|
||||
public function resend()
|
||||
{
|
||||
$id = (int) input('id');
|
||||
$dev = Db::name('appmall_developers')->where('id', $id)->find();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('记录不存在');
|
||||
}
|
||||
if ((int) $dev['status'] !== 1) {
|
||||
return $this->result->error('仅已激活账号可重发');
|
||||
}
|
||||
Mailer::send($dev['email'], '您的开发者令牌', $this->tokenMail($dev['username'], $dev['token']));
|
||||
return $this->result->success([], '已重发令牌邮件');
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置令牌(已激活则同步邮件通知)
|
||||
*/
|
||||
public function reset()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
$dev = Db::name('appmall_developers')->where('id', $id)->find();
|
||||
if (empty($dev)) {
|
||||
return $this->result->error('记录不存在');
|
||||
}
|
||||
$token = bin2hex(random_bytes(32));
|
||||
Db::name('appmall_developers')->where('id', $id)->update(['token' => $token, 'update_at' => time()]);
|
||||
if ((int) $dev['status'] === 1) {
|
||||
Mailer::send($dev['email'], '您的开发者令牌已重置', $this->tokenMail($dev['username'], $token));
|
||||
}
|
||||
return $this->result->success(['token' => $token], '已重置令牌' . ((int) $dev['status'] === 1 ? '并邮件通知' : ''));
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function tokenMail($name, $token): string
|
||||
{
|
||||
return "尊敬的 {$name}:<br>您的开发者令牌如下:<br><br>"
|
||||
. "<code style=\"background:#f4f4f4;padding:4px 8px;border-radius:4px;\">{$token}</code><br><br>"
|
||||
. '请将其配置到开发者控制台的 <b>APPMARKET_DEV_TOKEN</b> 环境变量。';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?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\appmall\controller\backend;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\View;
|
||||
use Exception;
|
||||
use app\appmall\controller\AppmallBackend;
|
||||
use ywxapp\library\StreamZipResponse;
|
||||
|
||||
/**
|
||||
* 主框架版本管理(运营后台,中心站)
|
||||
*
|
||||
* 上传核心包 zip → 入库(草稿)→ 发布(status=1)→ 客户端可见可下载。
|
||||
* 原 upgrade 插件功能,已合并进 appmall(应用服务中心)。
|
||||
*/
|
||||
class Framework extends AppmallBackend
|
||||
{
|
||||
/**
|
||||
* 版本列表 / 页面
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$this->ensurePatchColumns();
|
||||
$status = $this->request->param('status', '');
|
||||
$query = Db::name('appmall_framework_list');
|
||||
if ($status !== '') {
|
||||
$query->where('status', (int) $status);
|
||||
}
|
||||
$list = $query->order('id', 'desc')
|
||||
->paginate([
|
||||
'page' => (int) $this->request->param('page', 1),
|
||||
'list_rows' => (int) $this->request->param('limit', 10),
|
||||
]);
|
||||
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||
}
|
||||
return View::fetch('framework/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传并登记一个主框架核心包(zip),默认草稿态
|
||||
*
|
||||
* 同一版本号对应一行记录:整包(type=0)写 file_path/file_hash,
|
||||
* 增量补丁(type=1)写 patch_path/patch_hash/patch_from,二者可先后上传共存。
|
||||
*/
|
||||
public function upload()
|
||||
{
|
||||
try {
|
||||
$this->ensurePatchColumns();
|
||||
$version = trim(input('version', ''));
|
||||
$title = trim(input('title', ''));
|
||||
$changelog = input('changelog', '');
|
||||
$type = (int) input('type', 0); // 0=整包 1=补丁 2=完整安装包
|
||||
$fromVer = trim(input('from_version', ''));
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
||||
return $this->result->error('版本号格式不正确(需 x.y.z)');
|
||||
}
|
||||
if ($type === 1 && $fromVer === '') {
|
||||
return $this->result->error('增量补丁必须填写「基础版本」');
|
||||
}
|
||||
if ($type === 1 && !preg_match('/^\d+\.\d+\.\d+$/', $fromVer)) {
|
||||
return $this->result->error('基础版本号格式不正确(需 x.y.z)');
|
||||
}
|
||||
$file = $this->request->file('file');
|
||||
if (empty($file)) {
|
||||
return $this->result->error('请上传核心包 zip');
|
||||
}
|
||||
if (strtolower(substr($file->getOriginalName(), -4)) !== '.zip') {
|
||||
return $this->result->error('仅支持 zip 格式');
|
||||
}
|
||||
|
||||
// 整包需校验 ywxapp 核心结构;补丁只校验为合法 zip(含变更文件即可)
|
||||
$tmp = $file->getRealPath();
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($tmp) !== true) {
|
||||
return $this->result->error('zip 包无法打开');
|
||||
}
|
||||
if ($type === 0) {
|
||||
$hasCore = false;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nm = $zip->statIndex($i)['name'];
|
||||
if (preg_match('#^(ywxapp/)?(service|controller|library|traits)/#', $nm)) {
|
||||
$hasCore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
if (!$hasCore) {
|
||||
return $this->result->error('核心包结构不正确:应包含 ywxapp 核心目录(service/controller/...)');
|
||||
}
|
||||
} elseif ($type === 2) {
|
||||
// 完整安装包:必须含 config/ 与占位符 .env,且不得夹带 runtime/vendor/data 或真实 .env.*
|
||||
$hasConfig = false;
|
||||
$hasEnv = false;
|
||||
$bad = false;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nm = $zip->statIndex($i)['name'];
|
||||
if ($nm === 'config' || strpos($nm, 'config/') === 0) {
|
||||
$hasConfig = true;
|
||||
}
|
||||
if ($nm === '.env') {
|
||||
$hasEnv = true;
|
||||
}
|
||||
if (preg_match('#^(runtime/|vendor/|data/|\.env\.)#', $nm)) {
|
||||
$bad = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
if ($bad) {
|
||||
return $this->result->error('安装包结构不正确:不应包含 runtime/vendor/data 或真实 .env.* 文件');
|
||||
}
|
||||
if (!$hasConfig || !$hasEnv) {
|
||||
return $this->result->error('安装包结构不正确:必须包含 config/ 目录与 .env 占位符');
|
||||
}
|
||||
} else {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
$dir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'framework' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
$saveName = 'ywxapp-' . $version . ($type === 1 ? '-patch' : ($type === 2 ? '-install' : '')) . '.zip';
|
||||
$file->move($dir, $saveName);
|
||||
$absPath = realpath($dir . $saveName);
|
||||
if (!$absPath || !is_file($absPath)) {
|
||||
return $this->result->error('核心包保存失败');
|
||||
}
|
||||
$hash = hash_file('sha256', $absPath);
|
||||
|
||||
$exist = Db::name('appmall_framework_list')->where('version', $version)->find();
|
||||
// 公共字段:标题/日志仅在填写时覆盖,避免后传的包把先前信息清空
|
||||
$data = [
|
||||
'version' => $version,
|
||||
'status' => 0, // 重新上传任一包均回到草稿,需再次发布
|
||||
'update_at' => time(),
|
||||
];
|
||||
if ($title !== '' || !$exist) {
|
||||
$data['title'] = $title ?: ('主框架 ' . $version);
|
||||
}
|
||||
if (trim((string) $changelog) !== '' || !$exist) {
|
||||
$data['changelog'] = $changelog;
|
||||
}
|
||||
if ($type === 1) {
|
||||
// 增量补丁:只更新 patch 字段,不动整包
|
||||
$data['patch_path'] = $absPath;
|
||||
$data['patch_hash'] = $hash;
|
||||
$data['patch_from'] = $fromVer;
|
||||
$data['from_version'] = $fromVer; // 兼容旧客户端镜像
|
||||
} elseif ($type === 2) {
|
||||
// 完整安装包:只更新 install 字段,与整包/补丁独立共存
|
||||
$data['install_path'] = $absPath;
|
||||
$data['install_hash'] = $hash;
|
||||
} else {
|
||||
// 整包:只更新整包字段,不动补丁
|
||||
$data['file_path'] = $absPath;
|
||||
$data['file_hash'] = $hash;
|
||||
}
|
||||
if ($exist) {
|
||||
Db::name('appmall_framework_list')->where('id', $exist['id'])->update($data);
|
||||
$id = $exist['id'];
|
||||
// type 兼容标志:有整包即 0,仅补丁为 1
|
||||
$hasFull = ($type === 0) || !empty($exist['file_path']);
|
||||
Db::name('appmall_framework_list')->where('id', $id)->update(['type' => $hasFull ? 0 : 1]);
|
||||
} else {
|
||||
$data['type'] = $type;
|
||||
$data['create_at'] = time();
|
||||
$id = Db::name('appmall_framework_list')->insertGetId($data);
|
||||
}
|
||||
$tip = $type === 1 ? '增量补丁已上传' : ($type === 2 ? '完整安装包已上传' : '整包已上传');
|
||||
return $this->result->success(['id' => $id], $tip . ',请点击「发布」使其对外可下载');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布:将某版本置为已发布(status=1),客户端即可检测到
|
||||
*/
|
||||
public function publish()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
if ($id <= 0) {
|
||||
return $this->result->error('缺少版本ID');
|
||||
}
|
||||
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||||
if (empty($row)) {
|
||||
return $this->result->error('版本不存在');
|
||||
}
|
||||
if (empty($row['file_path']) && empty($row['patch_path'])) {
|
||||
return $this->result->error('该版本尚未上传任何包,无法发布');
|
||||
}
|
||||
Db::name('appmall_framework_list')->where('id', $id)->update(['status' => 1, 'update_at' => time()]);
|
||||
$tip = empty($row['file_path'])
|
||||
? (empty($row['patch_path'])
|
||||
? (empty($row['install_path']) ? '已发布' : '已发布(完整安装包,可供全新部署下载)')
|
||||
: '已发布(仅增量补丁:只有停在 ' . ($row['patch_from'] ?: $row['from_version']) . ' 的客户端可升级,建议补传整包)')
|
||||
: (empty($row['patch_path']) ? '已发布(整包)' : '已发布(整包 + 增量补丁)');
|
||||
return $this->result->success([], $tip);
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除版本(同时删除整包与补丁物理文件)
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
if ($id <= 0) {
|
||||
return $this->result->error('缺少版本ID');
|
||||
}
|
||||
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||||
foreach (['file_path', 'patch_path', 'install_path'] as $col) {
|
||||
if ($row && !empty($row[$col]) && is_file($row[$col])) {
|
||||
@unlink($row[$col]);
|
||||
}
|
||||
}
|
||||
Db::name('appmall_framework_list')->where('id', $id)->delete();
|
||||
return $this->result->success([], '已删除');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载核心包(供运营本地校验)
|
||||
* ?type=patch 下载增量补丁,默认整包
|
||||
*/
|
||||
public function download()
|
||||
{
|
||||
$id = (int) input('id');
|
||||
$kind = input('type', 'full');
|
||||
$kind = in_array($kind, ['patch', 'install'], true) ? $kind : 'full';
|
||||
$row = Db::name('appmall_framework_list')->where('id', $id)->find();
|
||||
if ($kind === 'patch') {
|
||||
$path = $row['patch_path'] ?? '';
|
||||
$suffix = '-patch';
|
||||
} elseif ($kind === 'install') {
|
||||
$path = $row['install_path'] ?? '';
|
||||
$suffix = '-install';
|
||||
} else {
|
||||
$path = $row['file_path'] ?? '';
|
||||
$suffix = '';
|
||||
}
|
||||
if (empty($row) || empty($path) || !is_file($path)) {
|
||||
return json(['code' => 0, 'msg' => '文件不存在']);
|
||||
}
|
||||
return new StreamZipResponse($path, 'ywxapp-' . $row['version'] . $suffix . '.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容已安装库:在线补齐 patch_path / patch_hash / patch_from 列
|
||||
*/
|
||||
private function ensurePatchColumns(): void
|
||||
{
|
||||
$table = \ywxapp\model\BaseModel::currentPrefix() . 'appmall_framework_list';
|
||||
try {
|
||||
// 列兜底由 FrameworkList 模型负责(事实源 app/appmall/install.sql),此处只保留历史数据迁移
|
||||
\app\appmall\model\FrameworkList::ensureSchema();
|
||||
// 历史数据迁移:旧「补丁行」(type=1 且 patch_path 为空) 把整包字段挪到补丁字段
|
||||
Db::execute(
|
||||
"UPDATE `{$table}` SET patch_path = file_path, patch_hash = file_hash, patch_from = from_version,"
|
||||
. " file_path = '', file_hash = ''"
|
||||
. " WHERE type = 1 AND (patch_path = '' OR patch_path IS NULL) AND file_path <> ''"
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?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\appmall\controller\backend;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\View;
|
||||
use Exception;
|
||||
use app\appmall\controller\AppmallBackend;
|
||||
|
||||
/**
|
||||
* 收益与结算管理(运营后台,中心站)
|
||||
*
|
||||
* - 收益账本:每笔已支付订单按平台抽成记账,归属对应开发者;
|
||||
* - 提现单:开发者发起(或运营代发)后在此审核打款/驳回。
|
||||
*/
|
||||
class Revenue extends AppmallBackend
|
||||
{
|
||||
/**
|
||||
* 收益账本列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$devId = $this->request->param('developer_id', '');
|
||||
$query = Db::name('appmall_addon_revenues')->order('id', 'desc');
|
||||
if ($devId !== '') {
|
||||
$query->where('developer_id', (int) $devId);
|
||||
}
|
||||
$list = $query->paginate([
|
||||
'page' => (int) $this->request->param('page', 1),
|
||||
'list_rows' => (int) $this->request->param('limit', 10),
|
||||
]);
|
||||
// 汇总:总销售额 / 总抽成 / 总开发者收益
|
||||
$summary = Db::name('appmall_addon_revenues')
|
||||
->field('SUM(amount) as total_amount, SUM(commission) as total_commission, SUM(income) as total_income')
|
||||
->find();
|
||||
$this->result->setCount($list->total())->success([
|
||||
'list' => $list->items(),
|
||||
'summary' => [
|
||||
'total_amount' => $summary['total_amount'] ?? 0,
|
||||
'total_commission' => $summary['total_commission'] ?? 0,
|
||||
'total_income' => $summary['total_income'] ?? 0,
|
||||
],
|
||||
], '获取成功');
|
||||
}
|
||||
return View::fetch('revenue/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提现单列表
|
||||
*/
|
||||
public function withdrawals()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$status = $this->request->param('status', '');
|
||||
$query = Db::name('appmall_addon_withdrawals')->order('id', 'desc');
|
||||
if ($status !== '') {
|
||||
$query->where('status', (int) $status);
|
||||
}
|
||||
$list = $query->paginate([
|
||||
'page' => (int) $this->request->param('page', 1),
|
||||
'list_rows' => (int) $this->request->param('limit', 10),
|
||||
]);
|
||||
$this->result->setCount($list->total())->success($list->items(), '获取成功');
|
||||
}
|
||||
return View::fetch('revenue/withdrawal');
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过:标记已打款,释放冻结金额,并将对应收益置为已结算
|
||||
*/
|
||||
public function settle()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
$w = Db::name('appmall_addon_withdrawals')->where('id', $id)->find();
|
||||
if (empty($w)) {
|
||||
return $this->result->error('提现单不存在');
|
||||
}
|
||||
if ((int) $w['status'] !== 0) {
|
||||
return $this->result->error('该单已处理');
|
||||
}
|
||||
Db::transaction(function () use ($w) {
|
||||
// 释放冻结(实际打款为线下/对接支付,此处仅结算记账)
|
||||
Db::name('appmall_developers')->where('id', $w['developer_id'])
|
||||
->dec('frozen_balance', $w['amount'])->update();
|
||||
Db::name('appmall_addon_withdrawals')->where('id', $w['id'])
|
||||
->update(['status' => 1, 'update_at' => time()]);
|
||||
// 同步将该开发者待结算收益置为已结算
|
||||
Db::name('appmall_addon_revenues')->where('developer_id', $w['developer_id'])
|
||||
->where('status', 0)
|
||||
->update(['status' => 1, 'settle_at' => time()]);
|
||||
});
|
||||
return $this->result->success([], '已结算打款');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回提现:冻结金额退回开发者可提现余额
|
||||
*/
|
||||
public function rejectWithdrawal()
|
||||
{
|
||||
try {
|
||||
$id = (int) input('id');
|
||||
$reason = input('reason', '');
|
||||
$w = Db::name('appmall_addon_withdrawals')->where('id', $id)->find();
|
||||
if (empty($w)) {
|
||||
return $this->result->error('提现单不存在');
|
||||
}
|
||||
if ((int) $w['status'] !== 0) {
|
||||
return $this->result->error('该单已处理');
|
||||
}
|
||||
Db::transaction(function () use ($w, $reason) {
|
||||
Db::name('appmall_developers')->where('id', $w['developer_id'])
|
||||
->dec('frozen_balance', $w['amount'])
|
||||
->inc('balance', $w['amount'])->update();
|
||||
Db::name('appmall_addon_withdrawals')->where('id', $w['id'])
|
||||
->update(['status' => -1, 'remark' => $reason, 'update_at' => time()]);
|
||||
});
|
||||
return $this->result->success([], '已驳回,金额已退回');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user