chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+388
View File
@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
namespace addon\appmall\controller;
use think\facade\Db;
use ywxapp\controller\FrontendBase;
use ywxapp\library\Mailer;
use ywxapp\model\BaseModel;
use addon\appmall\library\AppmallSchema;
/**
* 开发者中心(独立前台应用,非会员中心、非后台)
* 开发者自助注册 / 查询令牌 / 收益查询 / 提现;完全公开(noNeedLogin=*),
* 以开发者令牌(token)而非会员登录态认证。
* 路由前缀:/appmall/developer/*(由 AppService::loadAddonRoutes 在 boot 阶段包 Route::group('appmall') 注册到全局路由表,不隶属于任何 thinkphp 应用)。
* 视图:addon/appmall/view/frontend/developer/。
*
* 注:AddonFrontend 基类 _initialize 把 view_path 设成【主应用】view/frontend/(与插件目录不符),
* 故此处必须在 initialize() 显式把 view_path 锁回插件目录,否则 fetch 找不到模板。
*/
class Developer extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
/**
* 锁定视图路径到插件目录(修复 AddonFrontend 设成主应用目录的坑)
*/
protected function initialize()
{
// 确保 appmall 运营表存在(开发者中心只读开发者/收益/提现等表,避免全新安装 1146)
AppmallSchema::ensure();
// 仅中心站暴露:客户机(ywxapp.api_url 指向其他服务器,见 is_market_client())作为中心站客户端,
// 不持有 appmall_developers / appmall_addon_revenues / appmall_addon_withdrawals 等运营表,
// 访问开发者中心直接 404。即便路由层未拦住(如路由缓存陈旧、被直连)也在此兜底。
if (is_market_client()) {
abort(404, '开发者中心仅在中心站(市场服务端)可用');
}
$this->view->config([
'view_path' => ADDON_PATH . 'appmall' . DIRECTORY_SEPARATOR
. 'view' . DIRECTORY_SEPARATOR . 'frontend' . DIRECTORY_SEPARATOR,
]);
// 供前端 <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('developer/register');
}
/**
* 提交注册(生成令牌,按配置决定是否需要审核)
*/
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('请填写开发者名称');
}
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');
}
/**
* 根据邮箱取回令牌(已激活才返回,并重发邮件)
*/
public function lookup()
{
if (! $this->request->isPost()) {
$this->result->error('访问错误');
}
$email = trim($this->request->param('email', ''));
$dev = Db::name('appmall_developers')->where('email', $email)->find();
if (! $dev) {
$this->result->error('未找到该邮箱的开发者记录');
}
$status = (int) $dev['status'];
if ($status === 0) {
$this->result->error('您的申请正在审核中,请耐心等待');
}
if ($status === -1) {
$this->result->error('开发者账号已被禁用,请联系运营');
}
Mailer::send($email, '您的开发者令牌', $this->tokenMail($dev['username'], $dev['token']));
$this->result->success(
['token' => $dev['token'], 'username' => $dev['username']],
'令牌如下(已重新发送至邮箱)'
);
}
/**
* 我的收益(令牌认证):返回余额与近期收益账本
*/
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 {
\addon\appmall\service\MarketService::instance()
->updatePrice($name, $price, (int) $dev['id']);
} catch (\addon\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();
}
/**
* 运行时自愈建表(仅当表不存在时创建),保证开发者收益/提现闭环在当前库即可跑通。
* 字段与 install.sql、中心站 Market::ensureTables 保持一致。
*/
private function ensureTables(): void
{
$prefix = Db::getConfig('prefix') ?: '';
$tables = [
'appmall_developers' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_developers` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL COMMENT '开发者名称',
`email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)',
`token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)',
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用',
`balance` decimal(10,2) DEFAULT '0.00' COMMENT '可提现余额',
`frozen_balance` decimal(10,2) DEFAULT '0.00' COMMENT '待结算/冻结金额',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`),
UNIQUE KEY `uk_token` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场开发者表';",
'appmall_addon_revenues' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_revenues` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者',
`order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_orders.id',
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id',
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
`commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成',
`income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得',
`status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算',
`create_at` int DEFAULT 0,
`settle_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件销售收益账本';",
'appmall_addon_withdrawals' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_addon_withdrawals` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额',
`channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道',
`account` varchar(100) DEFAULT '' COMMENT '收款账号',
`status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回',
`remark` varchar(255) DEFAULT '',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者提现单';",
];
foreach ($tables as $t => $sql) {
BaseModel::ensureTable($prefix . $t, $sql);
}
}
/**
* 开发者令牌邮件正文
*/
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 个工作日内完成审核,"
. '通过后令牌会发送至本邮箱,请注意查收。';
}
}
+93
View File
@@ -0,0 +1,93 @@
<?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 addon\appmall\controller;
use think\facade\Db;
use ywxapp\controller\FrontendBase;
/**
* 主框架发布/下载展示页(公开,仅中心站)
*
* 列出已发布的主框架版本,提供「整包 / 增量补丁 / 完整安装包」下载入口。
* 下载走对外 API /appmall/api/framework/download(无需登录,可选签名)。
* 路由前缀:/appmall/framework/*(由 AppService::loadAddonRoutes 外层包 Route::group('appmall'))。
* 视图:addon/appmall/view/frontend/framework/。
*/
class Framework extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
/**
* 锁定视图路径到插件目录(AddonFrontend 默认指向主应用 view/frontend/,须覆盖)。
* 仅中心站暴露:客户机不持有 framework_list 发布数据,访问即 404。
*/
protected function initialize()
{
if (is_market_client()) {
abort(404, '框架下载页仅在中心站(市场服务端)可用');
}
$this->view->config([
'view_path' => ADDON_PATH . 'appmall' . DIRECTORY_SEPARATOR
. 'view' . DIRECTORY_SEPARATOR . 'frontend' . DIRECTORY_SEPARATOR,
]);
}
/**
* 发布列表展示页
*/
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
}
}
}
+163
View File
@@ -0,0 +1,163 @@
<?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 addon\appmall\controller;
use ywxapp\controller\FrontendBase;
use addon\appmall\service\MarketService;
/**
* 应用商店前台展示页(公开,仅中心站)
*
* 列出应用市场上线的插件 / 模板,卡片展示 logo、价格、评分、下载量等。
* 数据来自中心站本地库(appmall_addon_list),复用 MarketService::catalog()。
* 路由前缀:/appmall/store/*(由 AppService::loadAddonRoutes 外层包 Route::group('appmall'))。
* 视图:addon/appmall/view/frontend/store/。
*/
class Store extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
/**
* 锁定视图路径到插件目录;仅中心站暴露(客户机不持有市场列表数据,访问即 404)。
*/
protected function initialize()
{
if (is_market_client()) {
abort(404, '应用商店仅在中心站(市场服务端)可用');
}
$this->view->config([
'view_path' => ADDON_PATH . 'appmall' . DIRECTORY_SEPARATOR
. 'view' . DIRECTORY_SEPARATOR . 'frontend' . DIRECTORY_SEPARATOR,
]);
}
/**
* 商店首页:上线插件列表
*/
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'] ?? []),
];
$this->view->assign('addon', $row);
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
return $this->view->fetch('store/detail');
}
/**
* 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;
}
}
+235
View File
@@ -0,0 +1,235 @@
<?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 addon\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 插件功能,已合并进 market(应用服务中心)。
*/
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 {
$defs = [
'type' => "tinyint DEFAULT 0 COMMENT '0=整包 1=补丁'",
'from_version' => "varchar(20) DEFAULT '' COMMENT '补丁基础版本'",
'patch_path' => "varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径'",
'patch_hash' => "varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值'",
'patch_from' => "varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本'",
'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}");
}
}
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) & ts5 分钟有效期)。
*/
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);
}
/**
* 运行时自愈建表:保证 appmall_framework_download_log 存在(早期安装的中心站可能缺失)。
*/
private function ensureTables(): void
{
$prefix = \ywxapp\model\BaseModel::currentPrefix();
$tables = [
'appmall_framework_download_log' => "CREATE TABLE IF NOT EXISTS `{$prefix}appmall_framework_download_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`version` varchar(20) NOT NULL COMMENT '下载的版本号',
`uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)',
`ip` varchar(45) DEFAULT '' COMMENT '下载来源IP',
`create_at` int DEFAULT 0 COMMENT '下载时间',
PRIMARY KEY (`id`),
KEY `idx_version` (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架下载日志表';",
];
foreach ($tables as $t => $sql) {
try {
if (empty(Db::query("SHOW TABLES LIKE '{$prefix}{$t}'"))) {
Db::execute($sql);
}
} catch (\Throwable $e) {
// 忽略(如权限不足),由后续业务报错暴露
}
}
}
/**
* 下载签名校验
*/
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,92 @@
<?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 addon\appmall\controller\backend;
use think\facade\Db;
use think\facade\View;
use Exception;
use ywxapp\controller\BackendBase;
/**
* 插件审核(运营后台,中心站)
*
* 审核开发者通过 Market::submit 提交的插件上架申请(appmall_addon_submissions)。
* status: 0=待审核 1=已发布 2=已驳回。
*/
class AddonReview extends BackendBase
{
/**
* 审核列表 / 页面
*/
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());
}
}
}
@@ -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 addon\appmall\controller\backend;
use think\facade\Db;
use think\facade\View;
use Exception;
use ywxapp\controller\BackendBase;
use ywxapp\library\Mailer;
/**
* 开发者管理(运营后台,含审核)
* 由原核心 app/backend/controller/Developer.php 剥离到 market 插件。
*/
class Developer extends BackendBase
{
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,296 @@
<?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 addon\appmall\controller\backend;
use think\facade\Db;
use think\facade\View;
use Exception;
use ywxapp\controller\BackendBase;
use ywxapp\library\StreamZipResponse;
/**
* 主框架版本管理(运营后台,中心站)
*
* 上传核心包 zip → 入库(草稿)→ 发布(status=1)→ 客户端可见可下载。
* 原 upgrade 插件功能,已合并进 market(应用服务中心)。
*/
class Framework extends BackendBase
{
/**
* 版本列表 / 页面
*/
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 {
$defs = [
'patch_path' => "varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径'",
'patch_hash' => "varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值'",
'patch_from' => "varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本'",
'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}");
}
}
// 历史数据迁移:旧「补丁行」(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 addon\appmall\controller\backend;
use think\facade\Db;
use think\facade\View;
use Exception;
use ywxapp\controller\BackendBase;
/**
* 收益与结算管理(运营后台,中心站)
*
* - 收益账本:每笔已支付订单按平台抽成记账,归属对应开发者;
* - 提现单:开发者发起(或运营代发)后在此审核打款/驳回。
*/
class Revenue extends BackendBase
{
/**
* 收益账本列表
*/
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());
}
}
}