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:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+52
View File
@@ -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';
}
}
+360
View File
@@ -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 个工作日内完成审核,"
. '通过后令牌会发送至本邮箱,请注意查收。';
}
}
+87
View File
@@ -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
}
}
}
+275
View File
@@ -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;
}
}
+203
View File
@@ -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
}
}
}
+136
View File
@@ -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());
}
}
}