chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
<?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;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use ywxapp\AddonBase;
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 插件应用市场(服务端)
|
||||
*
|
||||
* 把原核心里的「插件商店服务端」整体剥离为独立插件,部署在中心站(插件服务器)。
|
||||
* 安装时由框架自动导入 install.sql 建表(appmall_addon_list / appmall_addon_submissions / appmall_developers);
|
||||
* 卸载时清理这些服务端表。
|
||||
*/
|
||||
class Addon extends addon
|
||||
{
|
||||
|
||||
public function install(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function uninstall(): bool
|
||||
{
|
||||
// 与 install.sql 表名前缀严格对齐(wxapp_appmall_*),
|
||||
// 直接复用统一前缀,避免依赖 database.prefix 配置(install.sql 为硬写前缀)。
|
||||
$prefix = 'wxapp_appmall_';
|
||||
$tables = [
|
||||
'addon_list', // 插件市场商品表
|
||||
'addon_submissions', // 提交审核表
|
||||
'developers', // 开发者表
|
||||
'addon_revenues', // 收益账本
|
||||
'addon_withdrawals', // 提现单
|
||||
'addon_licenses', // 授权表
|
||||
'addon_orders', // 购买订单表
|
||||
'addon_download_logs', // 下载日志表
|
||||
'sites', // 客户端站点(授权绑定维度)
|
||||
'framework_list', // 主框架版本表
|
||||
'framework_download_log',// 主框架下载日志表
|
||||
'throttle', // 接口频率限制表
|
||||
];
|
||||
foreach ($tables as $t) {
|
||||
try {
|
||||
Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`");
|
||||
} catch (\Exception $e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级时确保全部表存在(读 install.sql,CREATE TABLE IF NOT EXISTS 幂等建表)。
|
||||
* 单一事实源 = install.sql,避免复制 DDL 导致漂移。
|
||||
*/
|
||||
private function ensureTablesFromInstallSql(): void
|
||||
{
|
||||
$sqlFile = __DIR__ . DIRECTORY_SEPARATOR . 'install.sql';
|
||||
if (!is_file($sqlFile)) {
|
||||
return;
|
||||
}
|
||||
$content = (string) file_get_contents($sqlFile);
|
||||
$content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content);
|
||||
$stmts = array_filter(
|
||||
array_map('trim', explode(';', $content)),
|
||||
function ($s) {
|
||||
return strlen($s) > 5 && preg_match('/^CREATE\s+TABLE/i', $s);
|
||||
}
|
||||
);
|
||||
foreach ($stmts as $sql) {
|
||||
if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?/i', $sql, $m)) {
|
||||
BaseModel::ensureTable($m[1], $sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级钩子(在线升级 / 后台手动升级均会触发)。
|
||||
*
|
||||
* 在线升级(AddonService::onlineUpgrade)已重导 install.sql 补全表;
|
||||
* 但「已存在表新增列」install.sql 无力(CREATE TABLE IF NOT EXISTS 对已有表无效)。
|
||||
* 此处作为升级收敛点:确保全部表存在 + 对已知易漂移列做幂等补列兜底。
|
||||
* 未来新增列统一在 $columnFixes 登记,避免各控制器重复 ALTER 导致 DDL 漂移。
|
||||
*
|
||||
* @param string $currentVersion 升级前版本号
|
||||
*/
|
||||
public function upgrade($currentVersion = ''): bool
|
||||
{
|
||||
// 1) 确保全部表存在(幂等,单一事实源 install.sql)
|
||||
$this->ensureTablesFromInstallSql();
|
||||
|
||||
// 2) 补列兜底:'完整表名(含前缀)' => ['列名' => '列定义']
|
||||
// BaseModel::ensureColumn 先探测存在性,重复执行安全。
|
||||
$prefix = 'wxapp_appmall_';
|
||||
$columnFixes = [
|
||||
$prefix . 'addon_download_logs' => [
|
||||
'operator_id' => "int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台下载审计(0=会员自助下载)'",
|
||||
],
|
||||
];
|
||||
foreach ($columnFixes as $table => $cols) {
|
||||
foreach ($cols as $column => $def) {
|
||||
BaseModel::ensureColumn($table, $column, $def);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
// | 应用市场(appmall)后台「插件配置」表单字段定义。
|
||||
// | 仅描述「哪些字段可编辑 + 表单默认值」;真正的运行期默认以
|
||||
// | config/appmall.php 为准,数据库保存值最高优先级覆盖。
|
||||
// | 注意:radio/select 的 value 须为字符串('1'/'0')以正确预选,
|
||||
// | 代码读取处统一 (bool) 强转,本文件不负责类型。
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
return [
|
||||
// ===== 市场基础设置 =====
|
||||
[
|
||||
'name' => 'appmall_name',
|
||||
'title' => '市场名称',
|
||||
'type' => 'string',
|
||||
'value' => 'YwxApp 应用市场',
|
||||
'tip' => '显示在开发者注册页标题等位置',
|
||||
],
|
||||
[
|
||||
'name' => 'allow_register',
|
||||
'title' => '开放开发者自助注册',
|
||||
'type' => 'radio',
|
||||
'options' => ['1' => '开放', '0' => '关闭'],
|
||||
'value' => '1',
|
||||
'tip' => '关闭后仅运营手动创建开发者',
|
||||
],
|
||||
[
|
||||
'name' => 'need_approval',
|
||||
'title' => '注册后需审核',
|
||||
'type' => 'radio',
|
||||
'options' => ['1' => '需要', '0' => '注册即激活'],
|
||||
'value' => '1',
|
||||
'tip' => '需要审核时令牌经邮件下发',
|
||||
],
|
||||
[
|
||||
'name' => 'allow_upload',
|
||||
'title' => '允许后台上传发布核心包',
|
||||
'type' => 'radio',
|
||||
'options' => ['1' => '允许', '0' => '关闭'],
|
||||
'value' => '1',
|
||||
'tip' => '关闭后仅能通过数据库/命令行维护主框架版本',
|
||||
],
|
||||
|
||||
// ===== 官方市场发布(主动推送到 www.ywxapp.cn 公共市场)=====
|
||||
[
|
||||
'name' => 'developer_token',
|
||||
'title' => '官方市场开发者令牌',
|
||||
'type' => 'textarea',
|
||||
'value' => env('APPMARKET_DEV_TOKEN', ''),
|
||||
'tip' => '把插件发布到官方公共市场(www.ywxapp.cn)所需令牌,由官方运营分配;留空则后台「发布到官方市场」会提示先配置。也可在 .env 设 APPMARKET_DEV_TOKEN。',
|
||||
],
|
||||
[
|
||||
'name' => 'developer_name',
|
||||
'title' => '开发者名称',
|
||||
'type' => 'string',
|
||||
'value' => env('APPMARKET_DEV_NAME', ''),
|
||||
'tip' => '可选,开发者展示名;对应 .env 的 APPMARKET_DEV_NAME。',
|
||||
],
|
||||
|
||||
// ===== 远程市场 / 中心站客户机模式(仅当 ywxapp.api_url 指向其他服务器时生效)=====
|
||||
[
|
||||
'name' => 'remote_token',
|
||||
'title' => '远程市场共享密钥',
|
||||
'type' => 'textarea',
|
||||
'value' => env('APPMARKET_REMOTE_TOKEN', ''),
|
||||
'tip' => '「谁是中心站」由 config/ywxapp.php 的 api_url 决定(留空=本机即中心站)。仅当 api_url 指向其他服务器(本机作客户机)时需要:与中心站 appmall.remote_token 一致的共享密钥;单站点中心站模式留空即可。',
|
||||
],
|
||||
[
|
||||
'name' => 'ssl_verify',
|
||||
'title' => '远程市场 SSL 证书验证',
|
||||
'type' => 'select',
|
||||
'options' => ['1' => '严格验证(推荐)', '0' => '跳过验证(仅自托管/排障)'],
|
||||
'value' => env('APPMARKET_SSL_VERIFY', true) ? '1' : '0',
|
||||
'tip' => '连接远程市场时的 SSL 校验;跳过验证存在中间人风险;对应 .env 的 APPMARKET_SSL_VERIFY。',
|
||||
],
|
||||
[
|
||||
'name' => 'commission_rate',
|
||||
'title' => '平台抽成比例(0~1)',
|
||||
'type' => 'number',
|
||||
'value' => env('APPMARKET_COMMISSION_RATE', 0.2),
|
||||
'tip' => '开发者收益 = 订单金额 * (1 - 该比例);对应 .env 的 APPMARKET_COMMISSION_RATE。',
|
||||
],
|
||||
[
|
||||
'name' => 'security_scan',
|
||||
'title' => '提交包安全扫描',
|
||||
'type' => 'select',
|
||||
'options' => ['1' => '开启', '0' => '关闭'],
|
||||
'value' => env('APPMARKET_SECURITY_SCAN', true) ? '1' : '0',
|
||||
'tip' => '发布到官方/远程市场前对 PHP 包做语法 + 高危函数扫描;对应 .env 的 APPMARKET_SECURITY_SCAN。',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
// | 应用市场(appmall)插件【运行期配置的规范默认源】
|
||||
// |
|
||||
// | 本文件是 config('appmall.*') 的完整默认集合(代码级默认值,支持 .env 覆盖)。
|
||||
// | 后台「插件配置」表单(config.php) 仅负责「可编辑字段 + 默认值展示」,
|
||||
// | 数据库保存值按最高优先级覆盖本文件。读取一律用 config('appmall.xxx')。
|
||||
// |
|
||||
// | 约定:布尔项统一用 bool(true/false),不要写 '1'/'0'(字符串),
|
||||
// | 以免代码 if(!$v) 判定出错('0' 在 PHP 里是 truthy)。
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
return [
|
||||
// ===== 市场基础设置(仅中心站相关:运营与开发者自助)=====
|
||||
'appmall_name' => 'YwxApp 应用市场',
|
||||
'allow_register' => true, // 开放开发者自助注册
|
||||
'need_approval' => true, // 注册后需审核(审核通过才下发开发者令牌)
|
||||
'allow_upload' => true, // 允许后台上传发布核心包
|
||||
|
||||
// ===== 官方市场发布(把本机插件推送到 www.ywxapp.cn 官方公共市场)=====
|
||||
// 与「远程中心站」是两回事:这里是【主动推送】,下面是【被动拉取】。
|
||||
'developer_token' => env('APPMARKET_DEV_TOKEN', ''),
|
||||
'developer_name' => env('APPMARKET_DEV_NAME', ''),
|
||||
|
||||
// ===== 远程市场 / 中心站客户机模式(仅当 ywxapp.api_url 指向其他服务器时生效)=====
|
||||
// 「谁是中心站」由 config/ywxapp.php 的 api_url 决定(留空=本机即中心站,无需下面这些)。
|
||||
'remote_token' => env('APPMARKET_REMOTE_TOKEN', ''), // 客户机↔中心站共享密钥,须与中心站一致
|
||||
'ssl_verify' => env('APPMARKET_SSL_VERIFY', true), // 连接远程市场时 SSL 证书校验
|
||||
|
||||
// ===== 交易 / 安全 =====
|
||||
'commission_rate' => env('APPMARKET_COMMISSION_RATE', 0.2), // 平台抽成 0~1
|
||||
'security_scan' => env('APPMARKET_SECURITY_SCAN', true), // 发布前 PHP 语法+高危函数扫描
|
||||
];
|
||||
@@ -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 个工作日内完成审核,"
|
||||
. '通过后令牌会发送至本邮箱,请注意查收。';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) & 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时自愈建表:保证 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'appmall',
|
||||
'title' => '插件应用市场(服务端)',
|
||||
'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。',
|
||||
'author' => 'YwxApp',
|
||||
'website' => 'https://www.ywxapp.cn',
|
||||
'version' => '1.0.19',
|
||||
'state' => 1,
|
||||
'license' => false,
|
||||
'events' => [
|
||||
],
|
||||
'middleware' => [
|
||||
],
|
||||
'services' => [
|
||||
],
|
||||
'install_time' => 1785173040,
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'appmall',
|
||||
'title' => '插件应用市场(服务端)',
|
||||
'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。',
|
||||
'author' => 'YwxApp',
|
||||
'website' => 'https://www.ywxapp.cn',
|
||||
'version' => '1.0.19',
|
||||
'state' => 1,
|
||||
'license' => false,
|
||||
'events' => [
|
||||
],
|
||||
'middleware' => [
|
||||
],
|
||||
'services' => [
|
||||
],
|
||||
'install_time' => 1785173040,
|
||||
];
|
||||
@@ -0,0 +1,215 @@
|
||||
-- ============================================================
|
||||
-- 应用市场(appmall)插件安装表结构
|
||||
-- 事实源:addon/appmall/library/AppmallSchema.php::ensure()
|
||||
-- 与运行时自愈 DDL 一致;扩展列已合并进建表,全新安装即完整。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_addon_orders` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id',
|
||||
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '下单站点',
|
||||
`amount` decimal(10,2) NOT NULL DEFAULT 0,
|
||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款',
|
||||
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
|
||||
`pay_time` int unsigned DEFAULT 0,
|
||||
`create_at` int unsigned DEFAULT 0,
|
||||
`update_at` int unsigned DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_uid` (`uid`),
|
||||
KEY `idx_trade` (`trade_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_addon_licenses` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`uid` int unsigned NOT NULL,
|
||||
`aid` int unsigned NOT NULL,
|
||||
`license_key` varchar(64) NOT NULL COMMENT '授权码',
|
||||
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点',
|
||||
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=有效 0=已吊销',
|
||||
`expire_time` int unsigned DEFAULT 0 COMMENT '0为永久',
|
||||
`download_count` int DEFAULT 0 COMMENT '下载次数限制',
|
||||
`create_at` int DEFAULT 0,
|
||||
`update_at` int DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `license_key` (`license_key`),
|
||||
UNIQUE KEY `uk_uid_aid` (`uid`, `aid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_addon_download_logs` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`license_id` int unsigned NOT NULL DEFAULT 0,
|
||||
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||
`aid` int unsigned NOT NULL DEFAULT 0,
|
||||
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)',
|
||||
`ip` varchar(45) DEFAULT '',
|
||||
`create_at` int unsigned DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_license` (`license_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_throttle` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit)',
|
||||
`ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP)',
|
||||
`count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数',
|
||||
`expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳',
|
||||
`create_at` int unsigned DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_action_ident` (`action`, `ident`),
|
||||
KEY `idx_expire` (`expire_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_addon_review_log` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`submission_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_submissions.id',
|
||||
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
|
||||
`action` varchar(20) NOT NULL COMMENT '操作:submit/approve/reject',
|
||||
`from_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更前状态',
|
||||
`to_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更后状态',
|
||||
`reason` varchar(255) NOT NULL DEFAULT '' COMMENT '审核意见/驳回理由',
|
||||
`create_at` int unsigned NOT NULL DEFAULT 0 COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_submission` (`submission_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件审核历史日志(可视化时间线数据源)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `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) NOT NULL DEFAULT '0.00' COMMENT '可提现余额',
|
||||
`frozen_balance` decimal(10,2) NOT NULL 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='市场开发者表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `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='插件销售收益账本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `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='开发者提现单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_addon_submissions` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||
`name` varchar(50) NOT NULL COMMENT '插件标识符',
|
||||
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
|
||||
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||
`description` text COMMENT '简介',
|
||||
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||
`logo` varchar(255) DEFAULT '' COMMENT '封面',
|
||||
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||
`tags` varchar(255) DEFAULT '' COMMENT '标签',
|
||||
`file_path` varchar(255) NOT NULL COMMENT '待审包路径',
|
||||
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256',
|
||||
`changelog` text COMMENT '更新日志',
|
||||
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
|
||||
`status` tinyint DEFAULT 0 COMMENT '0待审 1通过 2驳回',
|
||||
`reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因',
|
||||
`create_at` int DEFAULT 0,
|
||||
`update_at` int DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_name_ver` (`name`, `version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件提交审核表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_addon_list` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(50) NOT NULL COMMENT '标识符',
|
||||
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||
`title` varchar(100) NOT NULL COMMENT '名称',
|
||||
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||
`description` text COMMENT '简介/描述',
|
||||
`logo` varchar(255) DEFAULT '' COMMENT '封面图 URL',
|
||||
`file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)',
|
||||
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值',
|
||||
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||
`tags` varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)',
|
||||
`screenshots` text COMMENT '截图URL(逗号分隔或JSON)',
|
||||
`rating` decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)',
|
||||
`status` tinyint DEFAULT 1 COMMENT '1上架 0下架',
|
||||
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||
`changelog` text COMMENT '更新日志',
|
||||
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
|
||||
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场插件列表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_sites` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`site_token` varchar(64) NOT NULL DEFAULT '' COMMENT '站点令牌(客户端 install 时生成/上报)',
|
||||
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '主域名',
|
||||
`owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '站点所有者用户ID',
|
||||
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1=正常 0=禁用',
|
||||
`create_at` int DEFAULT 0,
|
||||
`update_at` int DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_domain` (`domain`),
|
||||
KEY `idx_token` (`site_token`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场客户端站点(授权绑定维度)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `appmall_framework_list` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`version` varchar(20) NOT NULL COMMENT '框架版本号',
|
||||
`title` varchar(100) DEFAULT '' COMMENT '版本标题',
|
||||
`changelog` text COMMENT '更新日志',
|
||||
`file_path` varchar(255) DEFAULT '' COMMENT '整包 zip 物理路径(非 Web 可访问目录)',
|
||||
`file_hash` varchar(64) DEFAULT '' COMMENT '整包 SHA256 校验值',
|
||||
`patch_path` varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径(与整包同版本共存)',
|
||||
`patch_hash` varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值',
|
||||
`patch_from` varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本',
|
||||
`type` tinyint DEFAULT 0 COMMENT '兼容旧客户端:0=含整包 1=仅补丁',
|
||||
`from_version` varchar(20) DEFAULT '' COMMENT '兼容旧客户端:补丁基础版本镜像',
|
||||
`status` tinyint DEFAULT 0 COMMENT '0=草稿 1=已发布(可下载)',
|
||||
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_version` (`version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架版本表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `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='主框架下载日志表';
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
namespace addon\appmall\library;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 应用市场(appmall)专属表结构自愈。
|
||||
*
|
||||
* 归属原则:appmall_* 表属于 appmall 插件私有表,其建表/补列逻辑内聚在本插件,
|
||||
* 不污染核心框架(核心只在 BaseModel 保留通用自愈引擎)。
|
||||
*
|
||||
* 建表 DDL 为运行时版事实源(与核心 install.sql 口径一致),列兜底幂等。
|
||||
*/
|
||||
class AppmallSchema
|
||||
{
|
||||
/**
|
||||
* 集中创建 appmall 全部业务表(缺则建),并补齐老库扩展列。
|
||||
*
|
||||
* 中心站 / 客户端所有查询 appmall_* 表的入口(api/Market、backend/AddonReview、
|
||||
* developer/Developer、service/MarketService)都应先调用本方法,避免全新安装后
|
||||
* 走到未自愈的查询路径而触发 1146。
|
||||
*/
|
||||
public static function ensure(): void
|
||||
{
|
||||
$prefix = BaseModel::currentPrefix();
|
||||
$p = $prefix;
|
||||
$tables = [
|
||||
'appmall_addon_orders' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_orders` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id',
|
||||
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '下单站点',
|
||||
`amount` decimal(10,2) NOT NULL DEFAULT 0,
|
||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款',
|
||||
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
|
||||
`pay_time` int unsigned DEFAULT 0,
|
||||
`create_at` int unsigned DEFAULT 0,
|
||||
`update_at` int unsigned DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_uid` (`uid`),
|
||||
KEY `idx_trade` (`trade_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';",
|
||||
'appmall_addon_licenses' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_licenses` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`uid` int unsigned NOT NULL,
|
||||
`aid` int unsigned NOT NULL,
|
||||
`license_key` varchar(64) NOT NULL COMMENT '授权码',
|
||||
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点',
|
||||
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=有效 0=已吊销',
|
||||
`expire_time` int unsigned DEFAULT 0 COMMENT '0为永久',
|
||||
`download_count` int DEFAULT 0 COMMENT '下载次数限制',
|
||||
`create_at` int DEFAULT 0,
|
||||
`update_at` int DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `license_key` (`license_key`),
|
||||
UNIQUE KEY `uk_uid_aid` (`uid`, `aid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';",
|
||||
'appmall_addon_download_logs' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_download_logs` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`license_id` int unsigned NOT NULL DEFAULT 0,
|
||||
`uid` int unsigned NOT NULL DEFAULT 0,
|
||||
`aid` int unsigned NOT NULL DEFAULT 0,
|
||||
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)',
|
||||
`ip` varchar(45) DEFAULT '',
|
||||
`create_at` int unsigned DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_license` (`license_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';",
|
||||
'appmall_throttle' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_throttle` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit)',
|
||||
`ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP)',
|
||||
`count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数',
|
||||
`expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳',
|
||||
`create_at` int unsigned DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_action_ident` (`action`, `ident`),
|
||||
KEY `idx_expire` (`expire_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';",
|
||||
'appmall_addon_review_log' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_review_log` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`submission_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_submissions.id',
|
||||
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
|
||||
`action` varchar(20) NOT NULL COMMENT '操作:submit/approve/reject',
|
||||
`from_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更前状态',
|
||||
`to_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更后状态',
|
||||
`reason` varchar(255) NOT NULL DEFAULT '' COMMENT '审核意见/驳回理由',
|
||||
`create_at` int unsigned NOT NULL DEFAULT 0 COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_submission` (`submission_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件审核历史日志(可视化时间线数据源)';",
|
||||
'appmall_developers' => "CREATE TABLE IF NOT EXISTS `{$p}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 `{$p}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 `{$p}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='开发者提现单';",
|
||||
'appmall_addon_submissions' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_submissions` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||
`name` varchar(50) NOT NULL COMMENT '插件标识符',
|
||||
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
|
||||
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||
`description` text COMMENT '简介',
|
||||
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||
`logo` varchar(255) DEFAULT '' COMMENT '封面',
|
||||
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||
`tags` varchar(255) DEFAULT '' COMMENT '标签',
|
||||
`file_path` varchar(255) NOT NULL COMMENT '待审包路径',
|
||||
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256',
|
||||
`status` tinyint DEFAULT 0 COMMENT '0待审 1通过 2驳回',
|
||||
`reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因',
|
||||
`create_at` int DEFAULT 0,
|
||||
`update_at` int DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_name_ver` (`name`, `version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件提交审核表';",
|
||||
'appmall_addon_list' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_addon_list` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(50) NOT NULL COMMENT '标识符',
|
||||
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
|
||||
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
|
||||
`title` varchar(100) NOT NULL COMMENT '名称',
|
||||
`version` varchar(20) NOT NULL COMMENT '版本号',
|
||||
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||
`author` varchar(100) DEFAULT '' COMMENT '作者',
|
||||
`description` text COMMENT '简介/描述',
|
||||
`logo` varchar(255) DEFAULT '' COMMENT '封面图 URL',
|
||||
`file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)',
|
||||
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值',
|
||||
`category` varchar(50) DEFAULT '' COMMENT '分类',
|
||||
`tags` varchar(255) DEFAULT '' COMMENT '标签',
|
||||
`screenshots` text COMMENT '截图URL(逗号分隔或JSON)',
|
||||
`rating` decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)',
|
||||
`status` tinyint DEFAULT 1 COMMENT '1上架 0下架',
|
||||
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场插件列表';",
|
||||
'appmall_sites' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_sites` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`site_token` varchar(64) NOT NULL DEFAULT '' COMMENT '站点令牌(客户端 install 时生成/上报)',
|
||||
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '主域名',
|
||||
`owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '站点所有者用户ID',
|
||||
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1=正常 0=禁用',
|
||||
`create_at` int DEFAULT 0,
|
||||
`update_at` int DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_domain` (`domain`),
|
||||
KEY `idx_token` (`site_token`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场客户端站点(授权绑定维度)';",
|
||||
'appmall_framework_list' => "CREATE TABLE IF NOT EXISTS `{$p}appmall_framework_list` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`version` varchar(20) NOT NULL COMMENT '框架版本号',
|
||||
`title` varchar(100) DEFAULT '' COMMENT '版本标题',
|
||||
`changelog` text COMMENT '更新日志',
|
||||
`file_path` varchar(255) DEFAULT '' COMMENT '整包 zip 物理路径(非 Web 可访问目录)',
|
||||
`file_hash` varchar(64) DEFAULT '' COMMENT '整包 SHA256 校验值',
|
||||
`patch_path` varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径(与整包同版本共存)',
|
||||
`patch_hash` varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值',
|
||||
`patch_from` varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本',
|
||||
`type` tinyint DEFAULT 0 COMMENT '兼容旧客户端:0=含整包 1=仅补丁',
|
||||
`from_version` varchar(20) DEFAULT '' COMMENT '兼容旧客户端:补丁基础版本镜像',
|
||||
`status` tinyint DEFAULT 0 COMMENT '0=草稿 1=已发布(可下载)',
|
||||
`download_count` int DEFAULT 0 COMMENT '下载次数',
|
||||
`create_at` int DEFAULT 0 COMMENT '创建时间',
|
||||
`update_at` int DEFAULT 0 COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_version` (`version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架版本表';",
|
||||
'appmall_framework_download_log' => "CREATE TABLE IF NOT EXISTS `{$p}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) {
|
||||
BaseModel::ensureTable($prefix . $t, $sql);
|
||||
}
|
||||
// 老库扩展列兜底(幂等)
|
||||
$list = $prefix . 'appmall_addon_list';
|
||||
BaseModel::ensureColumn($list, 'category', "varchar(50) DEFAULT '' COMMENT '分类'");
|
||||
BaseModel::ensureColumn($list, 'tags', "varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)'");
|
||||
BaseModel::ensureColumn($list, 'screenshots', "text COMMENT '截图URL(逗号分隔或JSON)'");
|
||||
BaseModel::ensureColumn($list, 'rating', "decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)'");
|
||||
BaseModel::ensureColumn($list, 'changelog', "text COMMENT '更新日志'");
|
||||
BaseModel::ensureColumn($list, 'require_framework', "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'");
|
||||
BaseModel::ensureColumn($list, 'type', "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'");
|
||||
$sub = $prefix . 'appmall_addon_submissions';
|
||||
BaseModel::ensureColumn($sub, 'changelog', "text COMMENT '更新日志'");
|
||||
BaseModel::ensureColumn($sub, 'require_framework', "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'");
|
||||
BaseModel::ensureColumn($sub, 'type', "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'");
|
||||
BaseModel::ensureColumn($prefix . 'appmall_addon_download_logs', 'operator_id', "int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)'");
|
||||
BaseModel::ensureColumn($prefix . 'appmall_developers', 'balance', "decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '可提现余额'");
|
||||
BaseModel::ensureColumn($prefix . 'appmall_developers', 'frozen_balance', "decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '待结算/冻结金额'");
|
||||
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'status', "tinyint NOT NULL DEFAULT '1' COMMENT '1=有效 0=已吊销'");
|
||||
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'site_id', "int unsigned NOT NULL DEFAULT '0' COMMENT '绑定站点'");
|
||||
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'domain', "varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)'");
|
||||
BaseModel::ensureColumn($prefix . 'appmall_addon_licenses', 'expire_time', "int unsigned DEFAULT 0 COMMENT '0为永久'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"top_title": "应用中心",
|
||||
"superior": "service_center",
|
||||
"backend": [
|
||||
{
|
||||
"name": "appmall",
|
||||
"title": "应用市场",
|
||||
"icon": "fa fa-shopping-cart",
|
||||
"centerOnly": true,
|
||||
"sublist": [
|
||||
{
|
||||
"name": "addonreview",
|
||||
"title": "插件审核",
|
||||
"route": "/appmall/backend/addonreview/index"
|
||||
},
|
||||
{
|
||||
"name": "developer",
|
||||
"title": "开发者管理",
|
||||
"route": "/appmall/backend/developer/index"
|
||||
},
|
||||
{
|
||||
"name": "revenuelist",
|
||||
"title": "收益账本",
|
||||
"route": "/appmall/backend/revenue/index"
|
||||
},
|
||||
{
|
||||
"name": "withdrawallist",
|
||||
"title": "提现管理",
|
||||
"route": "/appmall/backend/revenue/withdrawals"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"icon": "fa fa-cloud-download",
|
||||
"centerOnly": true,
|
||||
"name": "frameworklist",
|
||||
"title": "框架管理",
|
||||
"route": "/appmall/backend/framework/index"
|
||||
}
|
||||
],
|
||||
"member": [],
|
||||
"frontend": [
|
||||
{
|
||||
"name": "frameworkdisplay",
|
||||
"title": "框架下载",
|
||||
"route": "/appmall/frontend/framework/index"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 插件下载日志(中心站,风控用)
|
||||
* 表:appmall_addon_download_logs
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class AddonDownloadLog extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => true,
|
||||
'name' => 'appmall_addon_download_logs',
|
||||
'autoWriteTimestamp' => 'int',
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 插件授权(中心站)
|
||||
* 表:appmall_addon_licenses
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class AddonLicense extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => true,
|
||||
'name' => 'appmall_addon_licenses',
|
||||
'autoWriteTimestamp' => 'int',
|
||||
'readonly' => ['uid'],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联插件信息(核心插件注册表)
|
||||
* @return \think\model\relation\BelongsTo
|
||||
*/
|
||||
public function addon()
|
||||
{
|
||||
return $this->belongsTo(\ywxapp\model\AddonModel::class, 'aid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成安全签名(防链接泄漏)
|
||||
* @param string $licenseKey 授权码
|
||||
* @param int $aid 插件ID
|
||||
* @param int $timestamp 时间戳
|
||||
* @param string $secret 签名密钥(仅作 HMAC key,不要拼进消息)
|
||||
* @return string
|
||||
*/
|
||||
public function generateSign(string $licenseKey, int $aid, int $timestamp, string $secret): string
|
||||
{
|
||||
$str = $licenseKey . $aid . $timestamp;
|
||||
return hash_hmac('sha256', $str, $secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 插件订单(中心站)
|
||||
* 表:appmall_addon_orders
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class AddonOrder extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => true,
|
||||
'name' => 'appmall_addon_orders',
|
||||
'autoWriteTimestamp' => 'int',
|
||||
'readonly' => ['uid'],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 开发者(中心站)
|
||||
* 表:appmall_developers
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class Developer extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => true,
|
||||
'name' => 'appmall_developers',
|
||||
'autoWriteTimestamp' => 'int',
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联收益记录
|
||||
* @return \think\model\relation\HasMany
|
||||
*/
|
||||
public function revenues()
|
||||
{
|
||||
return $this->hasMany(Revenue::class, 'developer_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联提现单
|
||||
* @return \think\model\relation\HasMany
|
||||
*/
|
||||
public function withdrawals()
|
||||
{
|
||||
return $this->hasMany(Withdrawal::class, 'developer_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 插件销售收益账本(中心站)
|
||||
* 表:appmall_addon_revenues(无 update_at,结算时间用 settle_at 业务字段)
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class Revenue extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => true,
|
||||
'name' => 'appmall_addon_revenues',
|
||||
'autoWriteTimestamp' => 'int',
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联开发者
|
||||
* @return \think\model\relation\BelongsTo
|
||||
*/
|
||||
public function developer()
|
||||
{
|
||||
return $this->belongsTo(Developer::class, 'developer_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 开发者提现单(中心站)
|
||||
* 表:appmall_addon_withdrawals
|
||||
* @mixin \think\Model
|
||||
*/
|
||||
class Withdrawal extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => true,
|
||||
'name' => 'appmall_addon_withdrawals',
|
||||
'autoWriteTimestamp' => 'int',
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联开发者
|
||||
* @return \think\model\relation\BelongsTo
|
||||
*/
|
||||
public function developer()
|
||||
{
|
||||
return $this->belongsTo(Developer::class, 'developer_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-07-21 13:36:46
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-16 11:31:01
|
||||
* @Description: 应用市场插件统一路由(对外 API + 主应用业务路由)
|
||||
* @FilePath: \ywxapp_dev\addon\appmall\route\app.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
// ============================================================
|
||||
// 应用市场插件「统一路由入口」(替代原散落的 route/api.php)。
|
||||
//
|
||||
// 插件名前缀(/appmall)由外层加载器统一补全,本文件【不要】自行写前缀:
|
||||
// 主应用:ywxapp/service/AppService::loadAddonRoutes() 包 Route::group('appmall')
|
||||
//
|
||||
// 全部路由(业务 + 对外 API)都在主应用 boot 阶段注册,形式为「相对组名」,
|
||||
// 由外层 Route::group('appmall') 补全 → /appmall/backend、/appmall/developer、
|
||||
// /appmall/api(与「插件名开头」约定一致,彻底避免多插件路由冲突)。
|
||||
// 注意:group 前缀是累加的,文件内【不要】再写 /appmall/ 或 /api/ 这样的前缀(会双重前缀)。
|
||||
// ============================================================
|
||||
|
||||
use think\facade\Route;
|
||||
use addon\appmall\controller\api\Market;
|
||||
use addon\appmall\controller\backend\AddonReview;
|
||||
use addon\appmall\controller\backend\Developer;
|
||||
use addon\appmall\controller\backend\Revenue as BackendRevenue;
|
||||
use addon\appmall\controller\Developer as DeveloperCenter;
|
||||
use addon\appmall\controller\backend\Framework;
|
||||
use addon\appmall\controller\api\Framework as ApiFramework;
|
||||
use addon\appmall\controller\Framework as FrontendFramework;
|
||||
use addon\appmall\controller\Store;
|
||||
|
||||
// ===================== 业务路由 + 对外 API(/appmall/*) =====================
|
||||
// 插件名前缀 /appmall 由 AppService::loadAddonRoutes() 的 Route::group('appmall') 补全。
|
||||
|
||||
// ---------- 后台:插件审核 / 开发者管理 / 收益与结算(统一前缀 /appmall/backend) ----------
|
||||
Route::group('backend', function () {
|
||||
// 插件审核
|
||||
Route::get('addonreview', [AddonReview::class, 'index']);
|
||||
Route::get('addonreview/index', [AddonReview::class, 'index']);
|
||||
Route::post('addonreview/approve', [AddonReview::class, 'approve']);
|
||||
Route::post('addonreview/reject', [AddonReview::class, 'reject']);
|
||||
Route::post('addonreview/updatePrice', [AddonReview::class, 'updatePrice']);
|
||||
Route::get('addonreview/download', [AddonReview::class, 'download']);
|
||||
Route::get('addonreview/detail', [AddonReview::class, 'detail']);
|
||||
Route::get('addonreview/detailview', [AddonReview::class, 'detailview']);
|
||||
Route::get('addonreview/history', [AddonReview::class, 'history']);
|
||||
|
||||
// 开发者管理
|
||||
Route::get('developer', [Developer::class, 'index']);
|
||||
Route::get('developer/index', [Developer::class, 'index']);
|
||||
Route::post('developer/approve', [Developer::class, 'approve']);
|
||||
Route::post('developer/disable', [Developer::class, 'disable']);
|
||||
Route::post('developer/resend', [Developer::class, 'resend']);
|
||||
Route::post('developer/reset', [Developer::class, 'reset']);
|
||||
|
||||
// 收益与结算
|
||||
Route::get('revenue', [BackendRevenue::class, 'index']);
|
||||
Route::get('revenue/index', [BackendRevenue::class, 'index']);
|
||||
Route::get('revenue/withdrawals', [BackendRevenue::class, 'withdrawals']);
|
||||
Route::post('revenue/settle', [BackendRevenue::class, 'settle']);
|
||||
Route::post('revenue/reject', [BackendRevenue::class, 'rejectWithdrawal']);
|
||||
|
||||
// 主框架升级(原 upgrade 插件,已合并进 market)
|
||||
Route::get('framework', [Framework::class, 'index']);
|
||||
Route::get('framework/index', [Framework::class, 'index']);
|
||||
Route::post('framework/upload', [Framework::class, 'upload']);
|
||||
Route::post('framework/publish', [Framework::class, 'publish']);
|
||||
Route::post('framework/delete', [Framework::class, 'delete']);
|
||||
Route::get('framework/download', [Framework::class, 'download']);
|
||||
});
|
||||
|
||||
// ---------- 开发者中心(独立前台应用,公开注册/令牌/收益/提现,非会员中心):统一前缀 /appmall/developer ----------
|
||||
// 仅【中心站】暴露:开发者自助注册/收益/提现依赖 appmall_developers / appmall_addon_revenues / appmall_addon_withdrawals 等
|
||||
// 运营表,客户机(ywxapp.api_url 指向其他服务器,见 is_market_client())作为中心站客户端不持有这些表,故不在路由层注册,
|
||||
// 访问即 404。配合 Developer 控制器 initialize() 的运行时兜底(即便路由缓存陈旧或被直连也能拦截)。
|
||||
// ---------- 开发者中心(独立前台,令牌认证):/appmall/Developer/* ----------
|
||||
Route::group('Developer', function () {
|
||||
Route::get('index', [DeveloperCenter::class, 'index']);
|
||||
Route::post('save', [DeveloperCenter::class, 'save']);
|
||||
Route::get('mine', [DeveloperCenter::class, 'mine']);
|
||||
Route::post('lookup', [DeveloperCenter::class, 'lookup']);
|
||||
Route::get('dashboard', [DeveloperCenter::class, 'dashboard']);
|
||||
Route::get('console', [DeveloperCenter::class, 'console']);
|
||||
Route::post('overview', [DeveloperCenter::class, 'overview']);
|
||||
Route::post('apps', [DeveloperCenter::class, 'apps']);
|
||||
Route::post('price', [DeveloperCenter::class, 'price']);
|
||||
Route::post('earnings', [DeveloperCenter::class, 'earnings']);
|
||||
Route::post('withdraw', [DeveloperCenter::class, 'withdraw']);
|
||||
});
|
||||
|
||||
// ---------- 对外 API(统一前缀 /appmall/api):远程客户机经 Remote/Addon/FrameworkService 调用 ----------
|
||||
// 由 AppService::loadAddonRoutes() 的 Route::group('appmall') 补全插件名前缀 → /appmall/api/*
|
||||
//(与 /appmall/backend、/appmall/developer 同源同构,均为「插件名开头」约定)。
|
||||
// 调用方契约(勿单侧改动):
|
||||
// RemoteAppmallService → /appmall/api/addon/*、/appmall/api/index
|
||||
// AddonService → /appmall/api/addon/valid、/appmall/api/index/index
|
||||
// FrameworkService → /appmall/api/framework/version、/appmall/api/framework/download
|
||||
Route::group('api', function () {
|
||||
Route::get('addon/lists', [Market::class, 'lists']);
|
||||
Route::get('addon/info', [Market::class, 'info']);
|
||||
Route::post('addon/valid', [Market::class, 'valid']);
|
||||
Route::post('addon/submit', [Market::class, 'submit']);
|
||||
Route::post('addon/buy', [Market::class, 'buy']);
|
||||
Route::get('addon/orderStatus', [Market::class, 'orderStatus']);
|
||||
Route::post('addon/notify', [Market::class, 'notify']);
|
||||
Route::get('addon/payResult', [Market::class, 'payResult']);
|
||||
Route::post('addon/refund', [Market::class, 'refund']);
|
||||
Route::get('addon/my', [Market::class, 'my']);
|
||||
// 插件 zip 下载(RemoteAppmallService 走 /appmall/api/index,AddonService 走 /appmall/api/index/index)
|
||||
Route::get('index', [Market::class, 'index']);
|
||||
Route::get('index/index', [Market::class, 'index']);
|
||||
// 主框架升级分发(FrameworkService 走 /appmall/api/framework/*)
|
||||
Route::get('framework/version', [ApiFramework::class, 'version']);
|
||||
Route::get('framework/download', [ApiFramework::class, 'download']);
|
||||
});
|
||||
|
||||
// ---------- 前台展示(公开,仅中心站):主框架发布/下载页 + 应用商店 ----------
|
||||
// 前台控制器直接置于 addon\appmall\controller 下(如 ForumFrontend 惯例),
|
||||
// 不套 frontend 子目录;URL 前缀为 /appmall/framework、/appmall/store。
|
||||
Route::get('framework', [FrontendFramework::class, 'index']);
|
||||
Route::get('framework/index', [FrontendFramework::class, 'index']);
|
||||
// 应用商店:上线插件列表(/appmall/store)
|
||||
Route::get('store', [Store::class, 'index'])->completeMatch();
|
||||
Route::get('store/index', [Store::class, 'index'])->completeMatch();
|
||||
// 插件详情页(/appmall/store/detail/<name>)
|
||||
Route::get('store/detail/:name', [Store::class, 'detail'])
|
||||
->pattern(['name' => '[\w\-]+']);
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\service;
|
||||
|
||||
/**
|
||||
* 市场领域业务异常:code 携带 HTTP 语义(403/404/500 等),
|
||||
* 由调用方(核心 v1/Addon 等)转换为对应的 Result 错误响应。
|
||||
*/
|
||||
class MarketException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp AppMarket [ 应用市场中心站插件 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\appmall\service;
|
||||
|
||||
use addon\appmall\model\AddonDownloadLog;
|
||||
use addon\appmall\model\AddonLicense;
|
||||
use addon\appmall\model\AddonOrder;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\Request;
|
||||
use ywxapp\model\BaseModel;
|
||||
use Yansongda\Pay\Pay;
|
||||
|
||||
/**
|
||||
* 市场中心站本地成交域服务(会员直购 / 授权 / 收益 / 下载)。
|
||||
*
|
||||
* 由核心 app/api/controller/v1/Addon.php 在「本机即中心站」模式下进程内委托调用;
|
||||
* 所有中心站领域逻辑(订单、授权、收益账本、支付回调)收敛于插件,核心不再持有。
|
||||
*
|
||||
* 约定:业务失败抛 MarketException(携带 HTTP 语义 code),由调用方转成 Result 响应。
|
||||
*/
|
||||
class MarketService
|
||||
{
|
||||
protected static ?MarketService $instance = null;
|
||||
|
||||
public static function instance(): static
|
||||
{
|
||||
if (static::$instance === null) {
|
||||
static::$instance = new static();
|
||||
// 中心站成交域首次被委托时,确保全部 appmall 表存在(覆盖 v1/Addon 等直接调用方)
|
||||
\addon\appmall\library\AppmallSchema::ensure();
|
||||
}
|
||||
return static::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时自愈建表(仅当表不存在时创建),保证购买闭环在当前库即可跑通。
|
||||
* 已收敛到 \addon\appmall\library\AppmallSchema::ensure()(建全部表 + 扩展列),此处委托。
|
||||
*/
|
||||
public function ensureTables(): void
|
||||
{
|
||||
\addon\appmall\library\AppmallSchema::ensure();
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品查询(appmall_addon_list)
|
||||
*/
|
||||
public function product(int $id): ?array
|
||||
{
|
||||
return Db::name('appmall_addon_list')->where('id', $id)->where('status', 1)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件详情(含当前用户已购状态)
|
||||
* @throws MarketException 404 插件不存在或已下架
|
||||
*/
|
||||
public function info(int $id, int $uid = 0): array
|
||||
{
|
||||
$addon = $this->product($id);
|
||||
if (!$addon) {
|
||||
throw new MarketException('插件不存在或已下架', 404);
|
||||
}
|
||||
$purchased = false;
|
||||
if ($uid > 0) {
|
||||
$purchased = AddonLicense::where('uid', $uid)->where('aid', $id)->count() > 0;
|
||||
}
|
||||
return [
|
||||
'addon' => [
|
||||
'id' => $addon['id'],
|
||||
'name' => $addon['name'],
|
||||
'title' => $addon['title'],
|
||||
'version' => $addon['version'],
|
||||
'price' => $addon['price'],
|
||||
'logo' => $addon['logo'],
|
||||
'intro' => $addon['description'] ?? '',
|
||||
'author' => $addon['author'] ?? '',
|
||||
],
|
||||
'purchased' => $purchased,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上架插件分页列表
|
||||
* @return array{items: array, total: int}
|
||||
*/
|
||||
public function lists(int $page = 1, int $perPage = 15): array
|
||||
{
|
||||
$perPage = min(max($perPage, 1), 50);
|
||||
$page = max($page, 1);
|
||||
$query = Db::name('appmall_addon_list')->where('status', 1);
|
||||
$total = (clone $query)->count();
|
||||
$items = $query->field('id,name,title,version,price,logo,description as intro,author')
|
||||
->order('id', 'desc')
|
||||
->page($page, $perPage)
|
||||
->select()
|
||||
->toArray();
|
||||
return ['items' => $items, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起购买:创建/复用待支付订单并拉起支付(或模拟支付直接签发授权)。
|
||||
* @param string $baseUrl 客户端根地址(用于拼支付回调)
|
||||
* @return array{data: array, message: string}
|
||||
* @throws MarketException
|
||||
*/
|
||||
public function buy(int $uid, int $addonId, string $method, string $baseUrl): array
|
||||
{
|
||||
$addon = $this->product($addonId);
|
||||
if (!$addon) {
|
||||
throw new MarketException('插件不存在或已下架', 404);
|
||||
}
|
||||
|
||||
// 免费插件无需购买(服务端 market valid 也直接放行)
|
||||
if ((float) $addon['price'] <= 0) {
|
||||
return ['data' => ['free' => true], 'message' => '该插件免费,可直接安装'];
|
||||
}
|
||||
|
||||
// 幂等:已购直接返回授权
|
||||
$existLicense = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find();
|
||||
if ($existLicense) {
|
||||
return ['data' => ['license_key' => $existLicense->license_key], 'message' => '您已购买该插件'];
|
||||
}
|
||||
|
||||
// 防重复下单:复用 15 分钟内待支付订单
|
||||
$order = AddonOrder::where('uid', $uid)
|
||||
->where('aid', $addonId)
|
||||
->where('status', 0)
|
||||
->where('create_at', '>', time() - 900)
|
||||
->find();
|
||||
if (!$order) {
|
||||
$order = AddonOrder::create([
|
||||
'uid' => $uid,
|
||||
'aid' => $addonId,
|
||||
'amount' => $addon['price'],
|
||||
'status' => 0,
|
||||
'trade_no' => date('YmdHis') . $uid . $addonId . random_int(100000, 999999),
|
||||
]);
|
||||
}
|
||||
|
||||
// 模拟支付:无真实商户号时直接完成并签发授权(仅本地/自托管调试用)
|
||||
if (config('pay.mock_enable')
|
||||
&& empty(config('pay.alipay.app_id'))
|
||||
&& empty(config('pay.wechat.mch_id'))) {
|
||||
$this->completeOrder($order, $addonId, $uid);
|
||||
$license = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find();
|
||||
return [
|
||||
'data' => ['license_key' => $license?->license_key, 'mock' => true],
|
||||
'message' => '(模拟支付)购买成功',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$base = rtrim($baseUrl, '/');
|
||||
$payParams = [
|
||||
'out_trade_no' => $order->trade_no,
|
||||
'total_amount' => $order->amount,
|
||||
'subject' => '购买插件:' . $addon['title'],
|
||||
];
|
||||
if ($method === 'wechat') {
|
||||
$wconfig = config('pay.wechat');
|
||||
if (empty($wconfig['mch_id']) || empty($wconfig['app_id'])) {
|
||||
throw new \Exception('微信支付未配置,请联系管理员');
|
||||
}
|
||||
$wconfig['notify_url'] = $base . '/v1/addon/notify';
|
||||
$result = Pay::wechat($wconfig)->h5($payParams);
|
||||
return [
|
||||
'data' => ['order_no' => $order->trade_no, 'pay_params' => $result->getBody()->getContents()],
|
||||
'message' => '支付发起成功',
|
||||
];
|
||||
}
|
||||
$aconfig = config('pay.alipay');
|
||||
if (empty($aconfig['app_id']) || empty($aconfig['private_key'])) {
|
||||
throw new \Exception('支付未配置,请联系管理员');
|
||||
}
|
||||
$aconfig['notify_url'] = $base . '/v1/addon/notify';
|
||||
$aconfig['return_url'] = $base . '/v1/addon/payResult';
|
||||
$result = Pay::alipay($aconfig)->web($payParams);
|
||||
return [
|
||||
'data' => ['order_no' => $order->trade_no, 'pay_params' => $result->getBody()->getContents()],
|
||||
'message' => '支付发起成功',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
throw new MarketException('支付发起失败: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单支付成功后:置已付 + 幂等签发授权 + 写入开发者收益账本(分红)
|
||||
*/
|
||||
public function completeOrder($order, int $aid, int $uid): void
|
||||
{
|
||||
if ($order->status == 0) {
|
||||
$order->status = 1;
|
||||
$order->pay_time = time();
|
||||
$order->save();
|
||||
}
|
||||
$exist = AddonLicense::where('uid', $uid)->where('aid', $aid)->find();
|
||||
if (!$exist) {
|
||||
try {
|
||||
AddonLicense::create([
|
||||
'uid' => $uid,
|
||||
'aid' => $aid,
|
||||
'license_key' => $this->generateLicenseKey($uid, $aid),
|
||||
'expire_time' => 0,
|
||||
'download_count' => 0,
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
if (stripos($e->getMessage(), 'duplicate') === false
|
||||
&& stripos($e->getMessage(), '1062') === false) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 写入收益账本(仅当该商品归属某开发者)
|
||||
try {
|
||||
$product = Db::name('appmall_addon_list')->where('id', $aid)->find();
|
||||
$devId = (int) ($product['developer_id'] ?? 0);
|
||||
if ($devId > 0 && !Db::name('appmall_addon_revenues')->where('order_id', $order->id)->find()) {
|
||||
$rate = (float) config('appmall.commission_rate', 0.2);
|
||||
$commission = round((float) $order->amount * $rate, 2);
|
||||
$income = round((float) $order->amount - $commission, 2);
|
||||
Db::name('appmall_addon_revenues')->insert([
|
||||
'developer_id' => $devId,
|
||||
'order_id' => $order->id,
|
||||
'aid' => $aid,
|
||||
'uid' => $uid,
|
||||
'amount' => $order->amount,
|
||||
'commission' => $commission,
|
||||
'income' => $income,
|
||||
'status' => 0,
|
||||
'create_at' => time(),
|
||||
]);
|
||||
// 计入开发者可提现余额(提现申请时再转入冻结待打款)
|
||||
Db::name('appmall_developers')->where('id', $devId)->inc('balance', $income)->update();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 收益记账失败不影响已完成的购买
|
||||
Log::warning('收益记账失败:' . $e->getMessage());
|
||||
}
|
||||
// 钩子点:订单支付完成(已签发授权 + 写入分红账本)后触发,
|
||||
// 插件可在 info.php['events']['listen']['order_paid'] 监听,用于发货 / 通知等联动。
|
||||
event('order_paid', ['order' => $order, 'aid' => $aid, 'uid' => $uid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付结果查询(前端轮询用)
|
||||
* @throws MarketException 404 订单不存在
|
||||
*/
|
||||
public function orderStatus(int $uid, string $tradeNo): array
|
||||
{
|
||||
$order = AddonOrder::where('trade_no', $tradeNo)->where('uid', $uid)->find();
|
||||
if (!$order) {
|
||||
throw new MarketException('订单不存在', 404);
|
||||
}
|
||||
$data = ['status' => $order->status];
|
||||
if ($order->status === 1) {
|
||||
$license = AddonLicense::where('uid', $uid)->where('aid', $order->aid)->find();
|
||||
$data['license_key'] = $license?->license_key;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付完成落地页状态(支付宝 return_url 跳转,无需登录,仅展示状态)
|
||||
* @return array{data: array, message: string}
|
||||
*/
|
||||
public function payResult(string $tradeNo): array
|
||||
{
|
||||
if ($tradeNo === '') {
|
||||
return ['data' => [], 'message' => '支付流程结束'];
|
||||
}
|
||||
$order = AddonOrder::where('trade_no', $tradeNo)->find();
|
||||
if (!$order) {
|
||||
return ['data' => ['status' => 0], 'message' => '订单不存在'];
|
||||
}
|
||||
return ['data' => ['status' => $order->status], 'message' => '获取成功'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款 / 吊销授权(买家或运营)
|
||||
* @return array{data: array, message: string}
|
||||
* @throws MarketException
|
||||
*/
|
||||
public function refund(int $uid, string $tradeNo, int $orderId): array
|
||||
{
|
||||
$order = null;
|
||||
if ($tradeNo !== '') {
|
||||
$order = AddonOrder::where('trade_no', $tradeNo)->where('uid', $uid)->find();
|
||||
} elseif ($orderId > 0) {
|
||||
$order = AddonOrder::where('id', $orderId)->where('uid', $uid)->find();
|
||||
}
|
||||
if (!$order) {
|
||||
throw new MarketException('订单不存在', 404);
|
||||
}
|
||||
if ($order->status === 2) {
|
||||
return ['data' => ['status' => 2], 'message' => '该订单已退款'];
|
||||
}
|
||||
if ($order->status !== 1) {
|
||||
throw new MarketException('仅已支付订单可申请退款', 400);
|
||||
}
|
||||
|
||||
Db::transaction(function () use ($order, $uid) {
|
||||
$order->status = 2;
|
||||
$order->save();
|
||||
// 吊销授权(status=0 且过期,download/valid 双重拦截)
|
||||
AddonLicense::where('uid', $uid)->where('aid', $order->aid)
|
||||
->update(['status' => 0, 'expire_time' => time()]);
|
||||
// 收益冲正
|
||||
$rev = Db::name('appmall_addon_revenues')->where('order_id', $order->id)->find();
|
||||
if ($rev) {
|
||||
if ((int) $rev['status'] === 0) {
|
||||
Db::name('appmall_developers')->where('id', $rev['developer_id'])
|
||||
->dec('balance', (float) $rev['income'])->update();
|
||||
}
|
||||
Db::name('appmall_addon_revenues')->where('id', $rev['id'])->update(['status' => 2]);
|
||||
}
|
||||
});
|
||||
|
||||
return ['data' => ['status' => 2], 'message' => '退款成功,授权已吊销'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的已购插件(买家视角):列出已购付费插件 + 授权状态 + 是否可退款
|
||||
*/
|
||||
public function mine(int $uid): array
|
||||
{
|
||||
$rows = Db::name('appmall_addon_licenses')
|
||||
->alias('l')
|
||||
->join('appmall_addon_list a', 'a.id = l.aid')
|
||||
->where('l.uid', $uid)
|
||||
->where('a.price', '>', 0)
|
||||
->field('a.name,a.title,a.version,a.price,l.status as license_status,l.expire_time,l.domain,l.aid')
|
||||
->order('l.id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$order = Db::name('appmall_addon_orders')->where('uid', $uid)->where('aid', $r['aid'])
|
||||
->order('id', 'desc')->find();
|
||||
$orderStatus = $order ? (int) $order['status'] : 0;
|
||||
$refundable = ((int) $r['license_status'] === 1) && $orderStatus === 1;
|
||||
$list[] = [
|
||||
'name' => $r['name'],
|
||||
'title' => $r['title'],
|
||||
'version' => $r['version'],
|
||||
'price' => $r['price'],
|
||||
'license_status' => (int) $r['license_status'],
|
||||
'expire_time' => (int) $r['expire_time'],
|
||||
'domain' => $r['domain'] ?? '',
|
||||
'order_id' => $order ? (int) $order['id'] : 0,
|
||||
'trade_no' => $order['trade_no'] ?? '',
|
||||
'refundable' => $refundable,
|
||||
];
|
||||
}
|
||||
return ['list' => $list];
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付宝/微信异步回调:验签 -> 校验金额 -> 订单置已付 -> 生成授权
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function notify(string $method)
|
||||
{
|
||||
try {
|
||||
if ($method === 'wechat') {
|
||||
$pay = Pay::wechat(config('pay.wechat'));
|
||||
$data = $pay->callback();
|
||||
$tradeNo = $data->get('out_trade_no');
|
||||
$amount = (float) $data->get('amount') ?: (float) $data->get('total');
|
||||
} else {
|
||||
$pay = Pay::alipay(config('pay.alipay'));
|
||||
$data = $pay->verify();
|
||||
$tradeNo = $data->get('out_trade_no');
|
||||
$amount = (float) $data->get('total_amount');
|
||||
$tradeStatus = (string) $data->get('trade_status');
|
||||
if (!in_array($tradeStatus, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
|
||||
return $pay->success();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return response('fail');
|
||||
}
|
||||
|
||||
$order = AddonOrder::where('trade_no', $tradeNo)->find();
|
||||
if ($order && $order->status == 0) {
|
||||
// 防金额篡改:回调金额必须与订单一致
|
||||
if (abs((float) $order->amount - $amount) < 0.01) {
|
||||
Db::transaction(function () use ($order) {
|
||||
$order->refresh();
|
||||
if ($order->status != 0) {
|
||||
return;
|
||||
}
|
||||
$this->completeOrder($order, $order->aid, $order->uid);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return $method === 'wechat' ? response('SUCCESS') : Pay::alipay(config('pay.alipay'))->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户下载已购插件(需有效授权 + 域名/签名/次数校验)。
|
||||
* @return array{file: string, filename: string} 通过校验后返回文件路径与下载名
|
||||
* @throws MarketException
|
||||
*/
|
||||
public function download(int $uid, int $addonId, Request $request): array
|
||||
{
|
||||
$license = AddonLicense::where('uid', $uid)->where('aid', $addonId)->find();
|
||||
if (!$license) {
|
||||
throw new MarketException('您尚未购买该插件', 403);
|
||||
}
|
||||
// 授权已吊销(退款):拒绝下载
|
||||
if (isset($license->status) && (int) $license->status !== 1) {
|
||||
throw new MarketException('授权已被吊销,无法下载', 403);
|
||||
}
|
||||
if ($license->expire_time > 0 && $license->expire_time < time()) {
|
||||
throw new MarketException('授权已过期', 403);
|
||||
}
|
||||
|
||||
// 域名绑定:license.domain 非空时,仅允许绑定的域名下载
|
||||
if (!empty($license->domain)) {
|
||||
$host = $request->host();
|
||||
$hostNoPort = preg_replace('/:\d+$/', '', $host);
|
||||
$allowed = array_filter(array_map('trim', preg_split('/[,\s]+/', (string) $license->domain)));
|
||||
if (!in_array($host, $allowed, true) && !in_array($hostNoPort, $allowed, true)) {
|
||||
throw new MarketException('该授权未绑定当前域名,无法下载', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载签名校验(开关 addon_download_sign)
|
||||
$signSecret = config('ywxapp.addon_secret');
|
||||
if (config('ywxapp.addon_download_sign') && $signSecret) {
|
||||
$ts = (int) $request->get('ts', 0);
|
||||
$sign = (string) $request->get('sign', '');
|
||||
if ($ts <= 0 || time() - $ts > 600) {
|
||||
throw new MarketException('下载授权已过期,请重新获取', 403);
|
||||
}
|
||||
$expected = $license->generateSign($license->license_key, $addonId, $ts, $signSecret);
|
||||
if (!hash_equals($expected, $sign)) {
|
||||
throw new MarketException('下载授权校验失败', 403);
|
||||
}
|
||||
}
|
||||
|
||||
$addon = Db::name('appmall_addon_list')->where('id', $addonId)->where('status', 1)->find();
|
||||
if (!$addon) {
|
||||
throw new MarketException('插件不存在或已下架', 404);
|
||||
}
|
||||
|
||||
// 下载次数限制:0 表示不限
|
||||
if ($license->download_count > 0) {
|
||||
$used = AddonDownloadLog::where('license_id', $license->id)->count();
|
||||
if ($used >= $license->download_count) {
|
||||
throw new MarketException('下载次数已用完', 403);
|
||||
}
|
||||
}
|
||||
|
||||
$file = realpath($addon['file_path']);
|
||||
$root = realpath(root_path()) ?: root_path();
|
||||
if ($file === false || !is_file($file) || strpos($file, $root) !== 0) {
|
||||
throw new MarketException('插件文件暂不可用,请联系管理员', 404);
|
||||
}
|
||||
|
||||
AddonDownloadLog::create([
|
||||
'license_id' => $license->id,
|
||||
'uid' => $uid,
|
||||
'aid' => $addonId,
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
return ['file' => $file, 'filename' => $addon['name'] . '-' . $addon['version'] . '.zip'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台市场目录(中心站本机浏览):关键词/分类筛选 + 排序 + 分类下拉数据源。
|
||||
* 自愈元数据列(category/tags/screenshots/rating),与 api/Market::ensureAddonListColumns 对齐。
|
||||
* @return array{list: array, categories: array}
|
||||
*/
|
||||
public function catalog(string $keyword = '', string $category = '', string $order = 'new', string $type = ''): array
|
||||
{
|
||||
// 自愈元数据列:旧库缺 category/tags/screenshots/rating 时自动补齐
|
||||
$listTable = Db::name('appmall_addon_list')->getTable();
|
||||
$colDefs = [
|
||||
'category' => "varchar(50) DEFAULT '' COMMENT '分类'",
|
||||
'tags' => "varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)'",
|
||||
'screenshots' => "text COMMENT '截图URL(逗号分隔或JSON)'",
|
||||
'rating' => "decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0)'",
|
||||
'changelog' => "text COMMENT '更新日志'",
|
||||
'require_framework' => "varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0'",
|
||||
'type' => "varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板'",
|
||||
];
|
||||
foreach ($colDefs as $col => $def) {
|
||||
BaseModel::ensureColumn($listTable, $col, $def);
|
||||
}
|
||||
|
||||
// 探测元数据列是否存在,存在才选,保证兼容
|
||||
$hasCols = [];
|
||||
try {
|
||||
$cols = Db::query("SHOW COLUMNS FROM `{$listTable}`");
|
||||
$hasCols = array_column($cols, 'Field');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$has = fn (string $c): bool => in_array($c, $hasCols, true);
|
||||
|
||||
$q = Db::name('appmall_addon_list')->where('status', 1);
|
||||
if ($keyword !== '') {
|
||||
$q->where(function ($w) use ($keyword) {
|
||||
$w->where('name', 'like', '%' . $keyword . '%')
|
||||
->whereOr('title', 'like', '%' . $keyword . '%')
|
||||
->whereOr('author', 'like', '%' . $keyword . '%')
|
||||
->whereOr('description', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
if ($category !== '' && $has('category')) {
|
||||
$q->where('category', $category);
|
||||
}
|
||||
// 商品类型筛选(addon=插件 / template=模板;空=全部)
|
||||
if ($type !== '' && $has('type')) {
|
||||
$q->where('type', $type);
|
||||
}
|
||||
$field = 'id,name,title,description as intro,author,version,logo,price,download_count,update_at';
|
||||
foreach (['category', 'tags', 'screenshots', 'rating', 'changelog', 'require_framework', 'type'] as $mf) {
|
||||
if ($has($mf)) {
|
||||
$field .= ',' . $mf;
|
||||
}
|
||||
}
|
||||
// 排序:对齐 api/Market::lists 的 orderMap(download_count 为商品表内置列)
|
||||
$orderMap = [
|
||||
'new' => ['id', 'desc'],
|
||||
'hot' => ['download_count', 'desc'],
|
||||
'price_asc' => ['price', 'asc'],
|
||||
'price_desc' => ['price', 'desc'],
|
||||
];
|
||||
[$of, $od] = $orderMap[$order] ?? $orderMap['new'];
|
||||
$list = $q->field($field)->order($of, $od)->select()->toArray();
|
||||
// 同名插件多版本聚合:只展示最新版,其余版本收进 versions(详情弹窗版本下拉用)
|
||||
$list = self::aggregateVersions($list);
|
||||
|
||||
// 分类下拉数据源:本机全部非空分类
|
||||
$categories = [];
|
||||
if ($has('category')) {
|
||||
$categories = Db::name('appmall_addon_list')
|
||||
->where('status', 1)->where('category', '<>', '')
|
||||
->distinct(true)->order('category', 'asc')->column('category');
|
||||
}
|
||||
return ['list' => $list, 'categories' => $categories];
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营退款(后台特权):按插件名退最新「已支付」订单,
|
||||
* 吊销授权 + 订单置已退款 + 收益冲正。
|
||||
* @throws MarketException
|
||||
*/
|
||||
public function operatorRefund(string $name): void
|
||||
{
|
||||
$addon = Db::name('appmall_addon_list')->where('name', $name)->find();
|
||||
if (empty($addon)) {
|
||||
throw new MarketException('插件不存在', 404);
|
||||
}
|
||||
$order = Db::name('appmall_addon_orders')->where('aid', $addon['id'])
|
||||
->where('status', 1)->order('id', 'desc')->find();
|
||||
if (empty($order)) {
|
||||
throw new MarketException('未找到该插件的已支付订单', 404);
|
||||
}
|
||||
Db::transaction(function () use ($order, $addon) {
|
||||
Db::name('appmall_addon_orders')->where('id', $order['id'])
|
||||
->update(['status' => 2, 'update_at' => time()]);
|
||||
Db::name('appmall_addon_licenses')->where('uid', $order['uid'])
|
||||
->where('aid', $addon['id'])
|
||||
->update(['status' => 0, 'expire_time' => time(), 'update_at' => time()]);
|
||||
$rev = Db::name('appmall_addon_revenues')->where('order_id', $order['id'])->find();
|
||||
if ($rev) {
|
||||
if ((int) $rev['status'] === 0) {
|
||||
Db::name('appmall_developers')->where('id', $rev['developer_id'])
|
||||
->dec('balance', (float) $rev['income'])->update();
|
||||
}
|
||||
Db::name('appmall_addon_revenues')->where('id', $rev['id'])->update(['status' => 2]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同名插件多版本聚合:每个插件只保留最新版一条作代表行,
|
||||
* 其余版本收进代表行的 versions 数组(含最新版自身,按版本号降序),
|
||||
* 供市场详情页/弹窗做「版本下拉选择」,列表不再平铺展示多个版本。
|
||||
* 输出顺序 = 各插件在原列表中的首次出现顺序(尽量保持原排序语义)。
|
||||
*/
|
||||
public static function aggregateVersions(array $rows): array
|
||||
{
|
||||
$groups = [];
|
||||
$names = [];
|
||||
foreach ($rows as $r) {
|
||||
$n = (string) ($r['name'] ?? '');
|
||||
if (!isset($groups[$n])) {
|
||||
$groups[$n] = [];
|
||||
$names[] = $n;
|
||||
}
|
||||
$groups[$n][] = $r;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($names as $n) {
|
||||
$vers = $groups[$n];
|
||||
usort($vers, fn ($a, $b) => version_compare((string) ($b['version'] ?? '0'), (string) ($a['version'] ?? '0')));
|
||||
$main = $vers[0];
|
||||
$main['versions'] = array_map(fn ($v) => [
|
||||
'id' => $v['id'] ?? 0,
|
||||
'version' => (string) ($v['version'] ?? ''),
|
||||
'price' => $v['price'] ?? 0,
|
||||
'update_at' => (int) ($v['update_at'] ?? 0),
|
||||
], $vers);
|
||||
$out[] = $main;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改已上架插件价格(按插件名维度,同步全部版本行 + 提交记录)。
|
||||
* 后台改价传 developerId=0(不限归属);开发者中心改价须传本人 developerId(仅能改自己的应用)。
|
||||
* 改价后 bump 目录版本号,市场列表缓存立即失效。
|
||||
* @return int 受影响的版本行数
|
||||
* @throws MarketException
|
||||
*/
|
||||
public function updatePrice(string $name, float $price, int $developerId = 0): int
|
||||
{
|
||||
if ($name === '') {
|
||||
throw new MarketException('缺少插件标识', 400);
|
||||
}
|
||||
if ($price < 0 || $price > 999999) {
|
||||
throw new MarketException('价格必须在 0 ~ 999999 之间', 400);
|
||||
}
|
||||
$q = Db::name('appmall_addon_list')->where('name', $name);
|
||||
if ($developerId > 0) {
|
||||
$q->where('developer_id', $developerId);
|
||||
}
|
||||
$rows = $q->select()->toArray();
|
||||
if (empty($rows)) {
|
||||
// 尚未上架(仅有提交记录)也允许改价:只同步 submissions
|
||||
$subExist = Db::name('appmall_addon_submissions')->where('name', $name)
|
||||
->when($developerId > 0, fn ($w) => $w->where('developer_id', $developerId))
|
||||
->count();
|
||||
if ($subExist <= 0) {
|
||||
throw new MarketException($developerId > 0 ? '未找到你名下的该插件' : '插件不存在', 404);
|
||||
}
|
||||
}
|
||||
$ids = array_column($rows, 'id');
|
||||
if ($ids) {
|
||||
Db::name('appmall_addon_list')->whereIn('id', $ids)
|
||||
->update(['price' => $price, 'update_at' => time()]);
|
||||
}
|
||||
// 同步提交记录价格(保持审核台/开发者中心口径一致;仅同步同归属记录)
|
||||
try {
|
||||
$sq = Db::name('appmall_addon_submissions')->where('name', $name);
|
||||
if ($developerId > 0) {
|
||||
$sq->where('developer_id', $developerId);
|
||||
}
|
||||
$sq->update(['price' => $price, 'update_at' => time()]);
|
||||
} catch (\Throwable $e) {
|
||||
// 提交记录同步失败不影响商品改价
|
||||
}
|
||||
self::bumpCatalogVersion();
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录版本号 bump:使 api/Market::lists 的 60s 缓存立即失效(审核发布/改价后调用)。
|
||||
*/
|
||||
public static function bumpCatalogVersion(): void
|
||||
{
|
||||
try {
|
||||
Cache::set('appmall:catalog:ver', (string) microtime(true));
|
||||
} catch (\Throwable $e) {
|
||||
// 缓存不可用时静默(列表最多延迟 60s 更新)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一授权码
|
||||
*/
|
||||
protected function generateLicenseKey(int $uid, int $aid): string
|
||||
{
|
||||
do {
|
||||
$key = strtoupper(sprintf(
|
||||
'%s-%s-%s',
|
||||
substr(md5($uid . $aid . random_bytes(8)), 0, 8),
|
||||
bin2hex(random_bytes(4)),
|
||||
bin2hex(random_bytes(4))
|
||||
));
|
||||
} while (AddonLicense::where('license_key', $key)->find());
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
|
||||
<div class="layui-card" style="margin:20px;">
|
||||
<div class="layui-card-header">插件审核(开发者上架申请)</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-form layui-row" style="margin-bottom:12px;">
|
||||
<div class="layui-inline">
|
||||
<select id="statusFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="0">待审核</option>
|
||||
<option value="1">已发布</option>
|
||||
<option value="2">已驳回</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" id="searchBtn">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="toolTpl">
|
||||
{{# if(d.status == 0){ }}
|
||||
<a class="layui-btn layui-btn-xs" lay-event="audit">通过</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="reject">驳回</a>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge {{ d.status == 1 ? 'layui-bg-green' : '' }}">
|
||||
{{ d.status == 1 ? '已发布' : '已驳回' }}
|
||||
</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'jquery', 'layer'], function () {
|
||||
var table = layui.table, $ = layui.jquery, layer = layui.layer;
|
||||
table.render({
|
||||
elem: '#dataTable',
|
||||
url: '{:url("/appmall/backend/addonreview/index")}',
|
||||
page: true,
|
||||
response: { statusName: 'code', msgName: 'message', countName: 'count', dataName: 'data' },
|
||||
parseData: function (res) {
|
||||
return {
|
||||
code: res.code,
|
||||
msg: res.message,
|
||||
count: res.count || 0,
|
||||
data: res.data || []
|
||||
};
|
||||
},
|
||||
where: { status: $('#statusFilter').val() },
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 70},
|
||||
{field: 'title', title: '插件名称', width: 160},
|
||||
{field: 'name', title: '标识', width: 140},
|
||||
{field: 'version', title: '版本', width: 90},
|
||||
{field: 'developer_id', title: '开发者ID', width: 100},
|
||||
{field: 'price', title: '价格', width: 90, templet: function(d){return d.price ? d.price : '免费';}},
|
||||
{field: 'status', title: '状态', width: 90, templet: function(d){
|
||||
if(d.status==0) return '<span class="layui-badge">待审核</span>';
|
||||
if(d.status==1) return '<span class="layui-badge layui-bg-green">已发布</span>';
|
||||
return '<span class="layui-badge layui-bg-red">已驳回</span>';
|
||||
}},
|
||||
{field: 'reject_reason', title: '驳回原因', width: 160},
|
||||
{field: 'create_at', title: '提交时间', width: 170, templet: function(d){return layui.util.toDateString(d.create_at*1000);}},
|
||||
{title: '操作', width: 160, templet: '#toolTpl', fixed: 'right'}
|
||||
]]
|
||||
});
|
||||
|
||||
$('#searchBtn').on('click', function () {
|
||||
table.reload('dataTable', { where: { status: $('#statusFilter').val() }, page: { curr: 1 } });
|
||||
});
|
||||
|
||||
table.on('tool(dataTable)', function (obj) {
|
||||
var d = obj.data;
|
||||
if (obj.event === 'audit') {
|
||||
layer.confirm('确认通过该插件上架申请?', function (index) {
|
||||
$.post('{:url("/appmall/backend/addonreview/audit")}', { id: d.id }, function (r) {
|
||||
layer.msg(r.msg || '操作完成');
|
||||
if (r.code === 0) { table.reload('dataTable'); }
|
||||
layer.close(index);
|
||||
}, 'json');
|
||||
});
|
||||
} else if (obj.event === 'reject') {
|
||||
layer.prompt({ title: '填写驳回原因', formType: 2 }, function (val, index) {
|
||||
$.post('{:url("/appmall/backend/addonreview/reject")}', { id: d.id, reason: val }, function (r) {
|
||||
layer.msg(r.msg || '操作完成');
|
||||
if (r.code === 0) { table.reload('dataTable'); }
|
||||
layer.close(index);
|
||||
}, 'json');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
{block name="title"}<title>{$title | default='YwxApp'}</title>{/block}
|
||||
<meta name="renderer" content="webkit">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.layui-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layui-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.layui-table-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.layui-table-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.layui-table-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
<script src="/assets/layui/layui.js"></script>
|
||||
<script type="text/javascript">
|
||||
// 全局路径配置:仅注入部署参数,路由方法来自 layui 的 route 模块。
|
||||
// app 为 ThinkPHP 真实应用目录名(backend/member/frontend/home),对应 public/static/<app> 目录。
|
||||
layui.app= window.APP = {
|
||||
root: "{$site.root|default=''}",
|
||||
assetUrl: "/assets",
|
||||
module: "{$site.module}",
|
||||
realModule: "{$site.app}",
|
||||
routeBase: "{$route_base}",
|
||||
controller: "{$site.controller}",
|
||||
action: "{$site.action}"
|
||||
};
|
||||
</script>
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{block name="body"}{/block}
|
||||
|
||||
{block name="script"}{/block}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>开发者管理 - 应用市场管理</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
|
||||
<style>body{background:#f2f3f5;padding:15px;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="layui-form" lay-filter="filterForm">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-inline" style="width:140px;">
|
||||
<select name="status">
|
||||
<option value="">全部</option>
|
||||
<option value="0">待审核</option>
|
||||
<option value="1">正常</option>
|
||||
<option value="-1">已禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table id="dataTable" lay-filter="dataTable"></table>
|
||||
|
||||
<script type="text/html" id="statusTpl">
|
||||
{{# if(d.status == 0){ }}<span class="layui-badge layui-bg-orange">待审核</span>
|
||||
{{# } else if(d.status == 1){ }}<span class="layui-badge layui-bg-green">正常</span>
|
||||
{{# } else { }}<span class="layui-badge">已禁用</span>{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="operateTpl">
|
||||
{{# if(d.status == 0){ }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="approve">通过</a>
|
||||
{{# } }}
|
||||
{{# if(d.status == 1){ }}
|
||||
<a class="layui-btn layui-btn-xs" lay-event="resend">重发令牌</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="reset">重置令牌</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="disable">禁用</a>
|
||||
{{# } }}
|
||||
{{# if(d.status == -1){ }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="approve">启用</a>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script src="/assets/layui/layui.js"></script>
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer', 'jquery', 'http'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.jquery, http = layui.http;
|
||||
|
||||
table.render({
|
||||
elem: '#dataTable',
|
||||
url: 'index',
|
||||
page: true,
|
||||
limits: [10, 20, 50],
|
||||
limit: 10,
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 70 },
|
||||
{ field: 'username', title: '开发者', minWidth: 140 },
|
||||
{ field: 'email', title: '邮箱', minWidth: 200 },
|
||||
{ field: 'token', title: '令牌', minWidth: 320, templet: function (d) { return '<span style="word-break:break-all;">' + d.token + '</span>'; } },
|
||||
{ field: 'status', title: '状态', width: 90, templet: '#statusTpl' },
|
||||
{ field: 'created_at', title: '创建时间', width: 170, templet: function (d) { return d.created_at ? new Date(d.created_at * 1000).toLocaleString() : ''; } },
|
||||
{ title: '操作', width: 260, toolbar: '#operateTpl', fixed: 'right' }
|
||||
]],
|
||||
response: { statusName: 'code', statusCode: 0, msgName: 'message', countName: 'count', dataName: 'data' }
|
||||
});
|
||||
|
||||
form.on('submit(search)', function (data) {
|
||||
table.reload('dataTable', { where: data.field, page: { curr: 1 } });
|
||||
return false;
|
||||
});
|
||||
|
||||
table.on('tool(dataTable)', function (elem) {
|
||||
var d = elem.data, id = d.id;
|
||||
if (elem.event === 'approve') {
|
||||
layer.confirm('确认' + (d.status == 0 ? '通过该开发者并邮件下发令牌?' : '启用该开发者?'), function () {
|
||||
http.post('approve', { id: id }).then(function (r) {
|
||||
if (r.code === 0) { layer.msg('操作成功', { icon: 1 }); table.reload('dataTable'); }
|
||||
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
});
|
||||
} else if (elem.event === 'disable') {
|
||||
layer.confirm('确认禁用该开发者?其令牌将立即失效。', function () {
|
||||
http.post('disable', { id: id }).then(function (r) {
|
||||
if (r.code === 0) { layer.msg('已禁用', { icon: 1 }); table.reload('dataTable'); }
|
||||
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
});
|
||||
} else if (elem.event === 'resend') {
|
||||
http.post('resend', { id: id }).then(function (r) {
|
||||
layer.msg(r.message || (r.code === 0 ? '已重发' : '失败'), { icon: r.code === 0 ? 1 : 2 });
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
} else if (elem.event === 'reset') {
|
||||
layer.confirm('确认重置令牌?旧令牌将失效。', function () {
|
||||
http.post('reset', { id: id }).then(function (r) {
|
||||
if (r.code === 0) { layer.msg('已重置' + (r.data && r.data.token ? ',新令牌:' + r.data.token : ''), { icon: 1, time: 4000 }); table.reload('dataTable'); }
|
||||
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,144 @@
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">主框架版本管理(升级服务器)</div>
|
||||
<div class="layui-card-body">
|
||||
<blockquote class="layui-elem-quote layui-quote-nm" style="margin-bottom:12px;">
|
||||
同一版本号可先后上传「整包」「增量补丁」「完整安装包」,三者共存于同一条版本记录;
|
||||
客户端升级时自动择优(正好停在补丁基础版本走补丁,否则走整包),完整安装包供全新部署下载。重新上传任一包后需再次「发布」。
|
||||
</blockquote>
|
||||
<form class="layui-form" id="uploadForm">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">版本号</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="version" placeholder="x.y.z" class="layui-input" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">标题</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="title" placeholder="可选,如 安全修复" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">类型</label>
|
||||
<div class="layui-input-inline">
|
||||
<select name="type" id="pkgType" class="layui-input">
|
||||
<option value="0">整包(full)</option>
|
||||
<option value="1">增量补丁(patch)</option>
|
||||
<option value="2">完整安装包(install)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">基础版本</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="from_version" id="fromVersion" placeholder="仅增量补丁需要,如 1.2.0" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button type="button" class="layui-btn" id="btnUpload">
|
||||
<i class="layui-icon layui-icon-upload"></i> 上传核心包
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">更新日志</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="changelog" class="layui-textarea" placeholder="更新内容..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'upload', 'jquery', 'layer'], function () {
|
||||
var table = layui.table, upload = layui.upload, $ = layui.jquery, layer = layui.layer;
|
||||
table.render({
|
||||
elem: '#dataTable',
|
||||
url: '{:url("/appmall/backend/framework/index")}',
|
||||
page: true,
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 70},
|
||||
{field: 'version', title: '版本号', width: 120},
|
||||
{title: '包', width: 200, templet: function (d) {
|
||||
var h = '';
|
||||
h += d.file_path ? '<span class="layui-badge layui-bg-blue">整包</span> '
|
||||
: '<span class="layui-badge layui-bg-gray">无整包</span> ';
|
||||
h += d.patch_path ? '<span class="layui-badge layui-bg-orange">补丁</span> ' : '';
|
||||
h += d.install_path ? '<span class="layui-badge layui-bg-cyan">安装包</span>' : '';
|
||||
return h;
|
||||
}},
|
||||
{field: 'patch_from', title: '补丁基础版本', width: 110, templet: function (d) {
|
||||
return d.patch_from || d.from_version || '';
|
||||
}},
|
||||
{field: 'title', title: '标题'},
|
||||
{field: 'changelog', title: '更新日志'},
|
||||
{field: 'status', title: '状态', width: 90, templet: function (d) {
|
||||
return d.status == 1
|
||||
? '<span class="layui-badge layui-bg-green">已发布</span>'
|
||||
: '<span class="layui-badge">草稿</span>';
|
||||
}},
|
||||
{field: 'download_count', title: '下载数', width: 90},
|
||||
{title: '操作', width: 360, templet: function (d) {
|
||||
var h = '';
|
||||
if (d.status != 1) h += '<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="publish">发布</a>';
|
||||
if (d.file_path) h += '<a class="layui-btn layui-btn-xs" lay-event="download">下载整包</a>';
|
||||
if (d.patch_path) h += '<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="downloadPatch">下载补丁</a>';
|
||||
if (d.install_path) h += '<a class="layui-btn layui-btn-xs layui-bg-cyan" lay-event="downloadInstall">下载安装包</a>';
|
||||
h += '<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>';
|
||||
return h;
|
||||
}}
|
||||
]]
|
||||
});
|
||||
upload.render({
|
||||
elem: '#btnUpload',
|
||||
url: '{:url("/appmall/backend/framework/upload")}',
|
||||
accept: 'file',
|
||||
exts: 'zip',
|
||||
before: function () {
|
||||
if (!$('input[name="version"]').val()) {
|
||||
layer.msg('请先填写版本号'); return false;
|
||||
}
|
||||
if ($('#pkgType').val() == '1' && !$('#fromVersion').val()) {
|
||||
layer.msg('增量补丁必须填写基础版本'); return false;
|
||||
}
|
||||
},
|
||||
data: {
|
||||
version: function () { return $('input[name="version"]').val(); },
|
||||
title: function () { return $('input[name="title"]').val(); },
|
||||
changelog: function () { return $('textarea[name="changelog"]').val(); },
|
||||
type: function () { return $('#pkgType').val(); },
|
||||
from_version: function () { return $('#fromVersion').val(); }
|
||||
},
|
||||
done: function (res) {
|
||||
layer.msg(res.message || '完成');
|
||||
if (res.code === 0) table.reload('dataTable');
|
||||
},
|
||||
error: function () { layer.msg('上传失败'); }
|
||||
});
|
||||
table.on('tool(dataTable)', function (obj) {
|
||||
var d = obj.data;
|
||||
if (obj.event === 'publish') {
|
||||
$.post('{:url("/appmall/backend/framework/publish")}', {id: d.id}, function (r) {
|
||||
layer.msg(r.message); if (r.code === 0) table.reload('dataTable');
|
||||
});
|
||||
} else if (obj.event === 'download') {
|
||||
location.href = '{:url("/appmall/backend/framework/download")}?id=' + d.id + '&type=full';
|
||||
} else if (obj.event === 'downloadPatch') {
|
||||
location.href = '{:url("/appmall/backend/framework/download")}?id=' + d.id + '&type=patch';
|
||||
} else if (obj.event === 'downloadInstall') {
|
||||
location.href = '{:url("/appmall/backend/framework/download")}?id=' + d.id + '&type=install';
|
||||
} else if (obj.event === 'del') {
|
||||
layer.confirm('确定删除该版本?', function () {
|
||||
$.post('{:url("/appmall/backend/framework/delete")}', {id: d.id}, function (r) {
|
||||
layer.msg(r.message); if (r.code === 0) table.reload('dataTable');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
<div class="layui-card" style="margin:20px;">
|
||||
<div class="layui-card-header">收益账本(开发者分红)</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-row" id="summary" style="margin-bottom:12px;"></div>
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'jquery'], function () {
|
||||
var table = layui.table, $ = layui.jquery;
|
||||
var summaryData = null;
|
||||
table.render({
|
||||
elem: '#dataTable',
|
||||
url: '{:url("/appmall/backend/revenue/index")}',
|
||||
page: true,
|
||||
// Result::success 返回 code:0 / message / data:{list,summary} / count
|
||||
response: { statusName: 'code', msgName: 'message', countName: 'count', dataName: 'data' },
|
||||
parseData: function (res) {
|
||||
summaryData = (res.data && res.data.summary) ? res.data.summary : null;
|
||||
return {
|
||||
code: res.code,
|
||||
msg: res.message,
|
||||
count: res.count || 0,
|
||||
data: (res.data && res.data.list) ? res.data.list : []
|
||||
};
|
||||
},
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 70},
|
||||
{field: 'developer_id', title: '开发者ID', width: 100},
|
||||
{field: 'aid', title: '商品ID', width: 90},
|
||||
{field: 'uid', title: '购买用户', width: 90},
|
||||
{field: 'amount', title: '订单金额', width: 100},
|
||||
{field: 'commission', title: '平台抽成', width: 100},
|
||||
{field: 'income', title: '开发者收益', width: 110, templet: function(d){return '<b>'+d.income+'</b>';}},
|
||||
{field: 'status', title: '结算', width: 90, templet: function(d){return d.status==1?'<span class="layui-badge layui-bg-green">已结算</span>':'<span class="layui-badge">待结算</span>';}},
|
||||
{field: 'create_at', title: '时间', width: 170, templet: function(d){return layui.util.toDateString(d.create_at*1000);}}
|
||||
]],
|
||||
done: function () {
|
||||
if (summaryData) {
|
||||
$('#summary').html(
|
||||
'总销售额:<b>' + summaryData.total_amount + '</b> | ' +
|
||||
'平台抽成:<b>' + summaryData.total_commission + '</b> | ' +
|
||||
'开发者收益:<b style="color:#16a34a;">' + summaryData.total_income + '</b>'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
<div class="layui-card" style="margin:20px;">
|
||||
<div class="layui-card-header">提现管理(开发者分红结算)</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'jquery', 'layer'], function () {
|
||||
var table = layui.table, $ = layui.jquery, layer = layui.layer;
|
||||
table.render({
|
||||
elem: '#dataTable',
|
||||
url: '{:url("/appmall/backend/revenue/withdrawals")}',
|
||||
page: true,
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 70},
|
||||
{field: 'developer_id', title: '开发者ID', width: 100},
|
||||
{field: 'amount', title: '提现金额', width: 110},
|
||||
{field: 'channel', title: '渠道', width: 90},
|
||||
{field: 'account', title: '收款账号', width: 180},
|
||||
{field: 'status', title: '状态', width: 100, templet: function(d){
|
||||
return d.status==1?'<span class="layui-badge layui-bg-green">已打款</span>'
|
||||
: (d.status==-1?'<span class="layui-badge layui-bg-orange">已驳回</span>'
|
||||
:'<span class="layui-badge">待处理</span>');
|
||||
}},
|
||||
{field: 'create_at', title: '申请时间', width: 170, templet: function(d){return layui.util.toDateString(d.create_at*1000);}},
|
||||
{title: '操作', width: 160, templet: function(d){
|
||||
if(d.status!=0) return '-';
|
||||
return '<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="settle">打款</a>'
|
||||
+ '<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="reject">驳回</a>';
|
||||
}}
|
||||
]]
|
||||
});
|
||||
table.on('tool(dataTable)', function(obj){
|
||||
var d = obj.data;
|
||||
if(obj.event==='settle'){
|
||||
layer.confirm('确认已向开发者打款?', function(){
|
||||
$.post('{:url("revenue/settle")}', {id:d.id}, function(r){layer.msg(r.message||r.msg);if(r.code===0)table.reload('dataTable');});
|
||||
});
|
||||
} else if(obj.event==='reject'){
|
||||
layer.prompt({title:'驳回原因'}, function(val, index){
|
||||
$.post('{:url("revenue/reject")}', {id:d.id, reason:val}, function(r){layer.msg(r.message||r.msg);if(r.code===0)table.reload('dataTable');layer.close(index);});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>开发者控制台 - YwxApp 应用市场</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f2f3f5; color: #333; }
|
||||
.dev-box { max-width: 720px; margin: 40px auto; background: #fff; padding: 28px 32px; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
|
||||
.dev-box h2 { margin-bottom: 4px; }
|
||||
.dev-tip { color: #999; font-size: 13px; margin-bottom: 18px; }
|
||||
.balance-row { display: flex; gap: 16px; margin: 18px 0; }
|
||||
.balance-card { flex: 1; background: #f7f9fc; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.balance-card .label { color: #888; font-size: 13px; }
|
||||
.balance-card .val { font-size: 24px; font-weight: 700; margin-top: 6px; }
|
||||
.balance-card .val.ok { color: #18a058; }
|
||||
.balance-card .val.free { color: #d48806; }
|
||||
.rev-table { margin-top: 10px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dev-box">
|
||||
<h2>开发者控制台</h2>
|
||||
<p class="dev-tip">使用开发者令牌查看收益并发起提现(令牌可在「我的令牌」页查询)</p>
|
||||
|
||||
<form class="layui-form" id="authForm">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">开发者令牌</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="token" id="tokenInput" value="{$token|default=''}" required placeholder="粘贴你的开发者令牌" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="load">加载我的收益</button>
|
||||
<a href="{:url('index')}" class="layui-btn layui-btn-primary">返回注册</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="panel" class="hidden">
|
||||
<div class="balance-row">
|
||||
<div class="balance-card">
|
||||
<div class="label">可提现余额</div>
|
||||
<div class="val ok" id="balance">¥0.00</div>
|
||||
</div>
|
||||
<div class="balance-card">
|
||||
<div class="label">冻结中(提现待打款)</div>
|
||||
<div class="val free" id="frozen">¥0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="layui-elem-field layui-field-title" style="margin-top:18px;"><legend>发起提现</legend></fieldset>
|
||||
<form class="layui-form" id="withdrawForm">
|
||||
<input type="hidden" name="token" id="wToken">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">提现金额</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="number" name="amount" step="0.01" min="0.01" required placeholder="0.00" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">不可超过可提现余额</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">提现渠道</label>
|
||||
<div class="layui-input-inline">
|
||||
<select name="channel">
|
||||
<option value="alipay">支付宝</option>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="bank">银行卡</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">收款账号</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="account" required placeholder="支付宝/微信账号或银行卡号" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="doWithdraw">提交提现申请</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<fieldset class="layui-elem-field layui-field-title" style="margin-top:24px;"><legend>近期收益</legend></fieldset>
|
||||
<table class="layui-table rev-table">
|
||||
<thead>
|
||||
<tr><th>订单号</th><th>销售额</th><th>抽成</th><th>应得</th><th>状态</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody id="revBody">
|
||||
<tr><td colspan="6" style="text-align:center;color:#999;">暂无收益记录</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/layui/layui.js"></script>
|
||||
<script>
|
||||
layui.use(['form', 'jquery', 'layer', 'http'], function () {
|
||||
var form = layui.form, $ = layui.jquery, layer = layui.layer, http = layui.http;
|
||||
|
||||
form.on('submit(load)', function (data) {
|
||||
http.post('{:url("/appmall/developer/earnings")}', data.field).then(function (r) {
|
||||
if (r.code !== 0) { layer.msg(r.message || '加载失败', { icon: 2 }); return; }
|
||||
var d = r.data || {};
|
||||
$('#balance').text('¥' + parseFloat(d.balance || 0).toFixed(2));
|
||||
$('#frozen').text('¥' + parseFloat(d.frozen_balance || 0).toFixed(2));
|
||||
$('#wToken').val(data.field.token);
|
||||
renderRevenues(d.revenues || []);
|
||||
$('#panel').removeClass('hidden');
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
return false;
|
||||
});
|
||||
|
||||
form.on('submit(doWithdraw)', function (data) {
|
||||
var amount = parseFloat(data.field.amount);
|
||||
if (!(amount > 0)) { layer.msg('请输入正确的提现金额', { icon: 2 }); return false; }
|
||||
var avail = parseFloat($('#balance').text().replace(/[^\d.]/g, '')) || 0;
|
||||
if (amount > avail) { layer.msg('提现金额超过可提现余额', { icon: 2 }); return false; }
|
||||
http.post('{:url("/appmall/developer/withdraw")}', data.field).then(function (r) {
|
||||
layer.msg(r.message || '提交完成', { icon: r.code === 0 ? 1 : 2 });
|
||||
if (r.code === 0) { $('#withdrawForm')[0].reset(); form.render(); }
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
return false;
|
||||
});
|
||||
|
||||
function renderRevenues(list) {
|
||||
var body = $('#revBody');
|
||||
if (!list.length) {
|
||||
body.html('<tr><td colspan="6" style="text-align:center;color:#999;">暂无收益记录</td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
list.forEach(function (it) {
|
||||
var st = it.status == 1 ? '<span style="color:#18a058;">已结算</span>' : '<span style="color:#d48806;">待结算</span>';
|
||||
html += '<tr>'
|
||||
+ '<td>' + (it.order_id || '-') + '</td>'
|
||||
+ '<td>¥' + parseFloat(it.amount || 0).toFixed(2) + '</td>'
|
||||
+ '<td>¥' + parseFloat(it.commission || 0).toFixed(2) + '</td>'
|
||||
+ '<td>¥' + parseFloat(it.income || 0).toFixed(2) + '</td>'
|
||||
+ '<td>' + st + '</td>'
|
||||
+ '<td>' + (it.create_at ? new Date(it.create_at * 1000).toLocaleString() : '-') + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
body.html(html);
|
||||
}
|
||||
|
||||
// 若 URL 携带 token,自动加载
|
||||
var tok = $('#tokenInput').val();
|
||||
if (tok) { $('#authForm').find('button[lay-filter="load"]').click(); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,137 @@
|
||||
<!--
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Description: 主框架发布/下载展示页(公开,仅中心站)
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>主框架下载 - {$market_name}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #4f46e5; --brand-2: #7c3aed; --ink: #1f2329;
|
||||
--muted: #8a8f99; --line: #eceef2; --bg: #f5f6fa;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.brandbar {
|
||||
height: 56px; display: flex; align-items: center; gap: 10px;
|
||||
padding: 0 24px; background: #fff; border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
}
|
||||
.brandbar .logo {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||
display: grid; place-items: center; color: #fff; font-weight: 700;
|
||||
}
|
||||
.brandbar .name { font-weight: 600; font-size: 16px; }
|
||||
.brandbar .nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; }
|
||||
.brandbar .nav a { color: var(--muted); text-decoration: none; transition: color .2s; }
|
||||
.brandbar .nav a:hover, .brandbar .nav a.on { color: var(--brand); }
|
||||
|
||||
.hero {
|
||||
padding: 54px 24px 46px; text-align: center; color: #fff;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #9333ea 100%);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.hero::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(420px 220px at 12% 0%, rgba(255,255,255,.18), transparent 60%),
|
||||
radial-gradient(380px 200px at 88% 100%, rgba(255,255,255,.12), transparent 60%);
|
||||
}
|
||||
.hero h1 { margin: 0 0 10px; font-size: 32px; position: relative; }
|
||||
.hero p { margin: 0; opacity: .92; font-size: 15px; position: relative; }
|
||||
|
||||
.wrap { max-width: 960px; margin: 36px auto 60px; padding: 0 24px; }
|
||||
.ver-card {
|
||||
background: #fff; border: 1px solid var(--line); border-radius: 16px;
|
||||
padding: 22px 26px; margin-bottom: 18px;
|
||||
transition: transform .22s, box-shadow .22s, border-color .22s;
|
||||
}
|
||||
.ver-card:hover { transform: translateY(-4px); box-shadow: 0 16px 36px rgba(79,70,229,.14); border-color: transparent; }
|
||||
.ver-top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||
.ver-title { font-size: 20px; font-weight: 600; }
|
||||
.ver-title .v { color: #16a34a; }
|
||||
.ver-date { color: var(--muted); font-size: 13px; }
|
||||
.ver-sub { color: #6b7280; font-size: 14px; margin: 10px 0 14px; }
|
||||
.ver-sub b { color: var(--ink); }
|
||||
.ver-log {
|
||||
background: #f7f8fa; border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 12px 14px; font-size: 13px; color: #555; white-space: pre-wrap;
|
||||
margin-bottom: 16px; max-height: 160px; overflow: auto;
|
||||
}
|
||||
.dl-btns { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.dl-btns a {
|
||||
text-decoration: none; font-size: 13px; color: #fff; padding: 9px 16px; border-radius: 9px;
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-2)); transition: opacity .2s, transform .1s;
|
||||
}
|
||||
.dl-btns a:hover { opacity: .9; }
|
||||
.dl-btns a:active { transform: scale(.97); }
|
||||
.dl-btns a.install { background: linear-gradient(135deg, #722ed1, #9254de); }
|
||||
.empty { text-align: center; color: var(--muted); padding: 60px 0; }
|
||||
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 26px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brandbar">
|
||||
<div class="logo">Y</div>
|
||||
<div class="name">{$market_name}</div>
|
||||
<div class="nav">
|
||||
<a href="/appmall/store">应用市场</a>
|
||||
<a href="/appmall/framework" class="on">主框架</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<h1>YwxApp 主框架下载</h1>
|
||||
<p>完整安装包用于全新部署,整包 / 补丁用于已安装站点的在线升级。</p>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
{if empty($list)}
|
||||
<div class="empty">暂未发布任何框架版本。</div>
|
||||
{else}
|
||||
{foreach $list as $item}
|
||||
<div class="ver-card">
|
||||
<div class="ver-top">
|
||||
<div class="ver-title">{$item.title|default='主框架'} <span class="v">v{$item.version}</span></div>
|
||||
<div class="ver-date">发布于 {$item.create_at_fmt} · 下载 {$item.download_count} 次</div>
|
||||
</div>
|
||||
{if $item.patch_from}
|
||||
<div class="ver-sub">增量补丁适用基础版本:<b>{$item.patch_from}</b></div>
|
||||
{/if}
|
||||
{if $item.changelog}
|
||||
<div class="ver-log">{$item.changelog}</div>
|
||||
{/if}
|
||||
<div class="dl-btns">
|
||||
{if $item.has_full}
|
||||
<a href="/appmall/api/framework/download?version={$item.version}&type=full">
|
||||
<i class="layui-icon layui-icon-download-circle"></i> 下载整包(升级)
|
||||
</a>
|
||||
{/if}
|
||||
{if $item.has_patch}
|
||||
<a href="/appmall/api/framework/download?version={$item.version}&type=patch">
|
||||
<i class="layui-icon layui-icon-download-circle"></i> 下载补丁(升级)
|
||||
</a>
|
||||
{/if}
|
||||
{if $item.has_install}
|
||||
<a class="install" href="/appmall/api/framework/download?version={$item.version}&type=install">
|
||||
<i class="layui-icon layui-icon-download-circle"></i> 下载完整安装包(全新部署)
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/foreach}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer>© {:date('Y')} {$market_name} · 由 YwxApp 框架强力驱动</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,257 @@
|
||||
<!--
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Description: 插件详情页(公开,仅中心站)· 参考 FastAdmin 插件市场详情版式
|
||||
* 布局:Hero 标题区 → 左主内容(功能介绍/预览截图/更新日志) + 右 sticky 信息栏(价格/目录)
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{$addon.title} - 应用市场</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #4f46e5; --brand-2: #7c3aed; --ink: #1f2329;
|
||||
--muted: #8a8f99; --line: #eceef2; --bg: #f5f6fa;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased; }
|
||||
a { text-decoration: none; }
|
||||
.brandbar { height: 56px; display: flex; align-items: center; gap: 10px;
|
||||
padding: 0 24px; background: #fff; border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 30; }
|
||||
.brandbar .logo { width: 30px; height: 30px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-2)); display: grid; place-items: center; color: #fff; font-weight: 700; }
|
||||
.brandbar .name { font-weight: 600; font-size: 16px; }
|
||||
.brandbar .nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; }
|
||||
.brandbar .nav a { color: var(--muted); transition: color .2s; }
|
||||
.brandbar .nav a:hover, .brandbar .nav a.on { color: var(--brand); }
|
||||
|
||||
.crumb { max-width: 1120px; margin: 16px auto 0; padding: 0 24px; font-size: 13px; color: var(--muted); }
|
||||
.crumb a { color: var(--muted); }
|
||||
.crumb a:hover { color: var(--brand); }
|
||||
|
||||
/* Hero */
|
||||
.hero { max-width: 1120px; margin: 14px auto 0; padding: 30px 36px; background: #fff; border: 1px solid var(--line); border-radius: 18px;
|
||||
display: flex; gap: 24px; align-items: center; position: relative; overflow: hidden; }
|
||||
.hero::before { content: ""; position: absolute; right: -60px; top: -60px; width: 240px; height: 240px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(124,58,237,.10), transparent 70%); }
|
||||
.hero .ava { width: 88px; height: 88px; border-radius: 20px; flex: none; overflow: hidden; background: #f0f1f5;
|
||||
display: grid; place-items: center; color: var(--brand); font-size: 34px; font-weight: 700; box-shadow: 0 8px 22px rgba(79,70,229,.18); }
|
||||
.hero .ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.hero .h-main { min-width: 0; flex: 1; }
|
||||
.hero .h-title { font-size: 26px; font-weight: 700; display: flex; align-items: center; gap: 12px; }
|
||||
.hero .h-title .ver { font-size: 13px; color: #16a34a; background: rgba(22,163,74,.1); border-radius: 6px; padding: 3px 9px; font-weight: 500; }
|
||||
.hero .h-desc { color: #5b606b; font-size: 14px; margin-top: 8px; line-height: 1.7; }
|
||||
.hero .h-actions { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; color: #fff; font-size: 14px; font-weight: 600;
|
||||
padding: 11px 22px; border-radius: 11px; background: linear-gradient(135deg, var(--brand), var(--brand-2)); transition: .2s, transform .1s; }
|
||||
.btn-primary:hover { opacity: .92; }
|
||||
.btn-primary:active { transform: scale(.98); }
|
||||
.btn-ghost { display: inline-flex; align-items: center; gap: 6px; color: var(--brand); font-size: 14px; font-weight: 600;
|
||||
padding: 11px 22px; border-radius: 11px; background: #fff; border: 1px solid var(--brand); transition: .2s; }
|
||||
.btn-ghost:hover { background: rgba(79,70,229,.06); }
|
||||
|
||||
/* 主体双栏 */
|
||||
.main { max-width: 1120px; margin: 24px auto 60px; padding: 0 24px; display: grid; gap: 24px;
|
||||
grid-template-columns: 1fr 320px; align-items: start; }
|
||||
.col-right { position: sticky; top: 76px; display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
.panel { background: #fff; border: 1px solid var(--line); border-radius: 16px; padding: 24px; }
|
||||
.panel + .panel { margin-top: 24px; }
|
||||
.panel h3 { margin: 0 0 16px; font-size: 17px; display: flex; align-items: center; gap: 9px; }
|
||||
.panel h3::before { content: ""; width: 4px; height: 17px; border-radius: 2px; background: linear-gradient(var(--brand), var(--brand-2)); }
|
||||
.prose { color: #4b515c; font-size: 14px; line-height: 1.9; white-space: pre-wrap; }
|
||||
|
||||
/* 截图 */
|
||||
.cover { width: 100%; aspect-ratio: 16/9; border-radius: 14px; overflow: hidden; background: #f0f1f5;
|
||||
display: grid; place-items: center; color: var(--brand); font-size: 36px; font-weight: 700; }
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.thumbs { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.thumbs img { width: 104px; height: 64px; object-fit: cover; border-radius: 9px; border: 2px solid transparent; cursor: pointer; transition: .18s; }
|
||||
.thumbs img:hover, .thumbs img.on { border-color: var(--brand); }
|
||||
|
||||
/* 更新日志时间线 */
|
||||
.timeline { position: relative; padding-left: 22px; }
|
||||
.timeline::before { content: ""; position: absolute; left: 5px; top: 6px; bottom: 6px; width: 2px; background: var(--line); }
|
||||
.tl-item { position: relative; padding-bottom: 22px; }
|
||||
.tl-item:last-child { padding-bottom: 0; }
|
||||
.tl-item::before { content: ""; position: absolute; left: -21px; top: 5px; width: 12px; height: 12px; border-radius: 50%;
|
||||
background: #fff; border: 3px solid var(--brand); }
|
||||
.tl-item.first::before { background: var(--brand); box-shadow: 0 0 0 4px rgba(79,70,229,.18); }
|
||||
.tl-head { display: flex; align-items: center; gap: 10px; }
|
||||
.tl-ver { font-weight: 700; font-size: 14px; }
|
||||
.tl-badge { font-size: 11px; color: #16a34a; background: rgba(22,163,74,.1); border-radius: 6px; padding: 2px 8px; }
|
||||
.tl-date { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
.tl-note { color: #5b606b; font-size: 13px; line-height: 1.7; margin-top: 6px; white-space: pre-wrap; }
|
||||
|
||||
/* 右栏:价格卡 + 信息 + 目录 */
|
||||
.price-card { background: linear-gradient(135deg, rgba(79,70,229,.07), rgba(124,58,237,.07));
|
||||
border: 1px solid rgba(124,58,237,.18); border-radius: 14px; padding: 18px 20px; }
|
||||
.price-card .price { font-size: 30px; font-weight: 800; color: var(--brand); }
|
||||
.price-card .price.free { color: #16a34a; }
|
||||
.price-card .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.buy-btn { display: block; text-align: center; color: #fff; font-weight: 600; font-size: 15px; padding: 13px;
|
||||
border-radius: 11px; background: linear-gradient(135deg, var(--brand), var(--brand-2)); margin-top: 14px; transition: .2s, transform .1s; }
|
||||
.buy-btn:hover { opacity: .92; }
|
||||
.buy-btn:active { transform: scale(.98); }
|
||||
.buy-btn.ghost { background: #fff; color: var(--brand); border: 1px solid var(--brand); margin-top: 10px; }
|
||||
|
||||
.kv { font-size: 13px; }
|
||||
.kv .row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px dashed var(--line); }
|
||||
.kv .row:last-child { border-bottom: 0; }
|
||||
.kv .k { color: var(--muted); }
|
||||
.kv .v { color: var(--ink); text-align: right; }
|
||||
.stars { color: #f5a623; letter-spacing: 1px; }
|
||||
.stars .off { color: #e2e4ea; }
|
||||
.tags { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.tags span { font-size: 12px; color: var(--brand); background: rgba(79,70,229,.1); border-radius: 6px; padding: 4px 10px; }
|
||||
|
||||
.toc { font-size: 13px; }
|
||||
.toc .t { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
|
||||
.toc a { display: block; color: #5b606b; padding: 7px 0; border-left: 2px solid transparent; padding-left: 12px; transition: .18s; }
|
||||
.toc a:hover { color: var(--brand); border-left-color: var(--brand); }
|
||||
|
||||
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 26px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.main { grid-template-columns: 1fr; }
|
||||
.col-right { position: static; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brandbar">
|
||||
<div class="logo">Y</div>
|
||||
<div class="name">{$market_name}</div>
|
||||
<div class="nav">
|
||||
<a href="/appmall/store" class="on">应用市场</a>
|
||||
<a href="/appmall/framework">主框架</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crumb">
|
||||
<a href="/appmall/store">应用市场</a> / <span>{$addon.title}</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="hero">
|
||||
<div class="ava">
|
||||
{if $addon.logo}<img src="{$addon.logo}" alt="{$addon.title}">{else}{$addon.title|substr=0,1}{/if}
|
||||
</div>
|
||||
<div class="h-main">
|
||||
<div class="h-title">{$addon.title}<span class="ver">v{$addon.version}</span></div>
|
||||
<div class="h-desc">{if $addon.author}by {$addon.author} · {/if}{$addon.category|default='未分类'}</div>
|
||||
<div class="h-actions">
|
||||
<a class="btn-primary" href="#"><i class="layui-icon layui-icon-play"></i> 立即体验</a>
|
||||
<a class="btn-ghost" href="#"><i class="layui-icon layui-icon-cart"></i> 立即购买</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- 左主内容 -->
|
||||
<div class="col-left">
|
||||
<div class="panel" id="intro">
|
||||
<h3>功能介绍</h3>
|
||||
<div class="prose">{$addon.intro|default='暂无介绍'}</div>
|
||||
</div>
|
||||
|
||||
{if $addon.screenshots}
|
||||
<div class="panel" id="shots">
|
||||
<h3>预览截图</h3>
|
||||
<div class="cover" id="cover">
|
||||
<img src="{$addon.screenshots.0}" alt="{$addon.title}">
|
||||
</div>
|
||||
{if count($addon.screenshots) > 1}
|
||||
<div class="thumbs" id="thumbs">
|
||||
{foreach $addon.screenshots as $s}
|
||||
<img src="{$s}" class="{if $s == $addon.screenshots.0}on{/if}" onclick="swap('{$s}', this)">
|
||||
{/foreach}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="panel" id="log">
|
||||
<h3>更新日志</h3>
|
||||
{if empty($addon.versions)}
|
||||
<div class="prose" style="color:var(--muted)">暂无版本记录</div>
|
||||
{else}
|
||||
<div class="timeline">
|
||||
{foreach $addon.versions as $k=>$v}
|
||||
<div class="tl-item {if $k == 0}first{/if}">
|
||||
<div class="tl-head">
|
||||
<span class="tl-ver">v{$v.version}</span>
|
||||
{if $k == 0}<span class="tl-badge">最新版本</span>{/if}
|
||||
<span class="tl-date">{$v.update_fmt}</span>
|
||||
</div>
|
||||
<div class="tl-note">本版本定价:{$v.price_text}</div>
|
||||
</div>
|
||||
{/foreach}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右 sticky 信息栏 -->
|
||||
<div class="col-right">
|
||||
<div class="panel">
|
||||
<div class="kv">
|
||||
<div class="row"><span class="k">当前版本</span><span class="v">v{$addon.version}</span></div>
|
||||
<div class="row"><span class="k">更新时间</span><span class="v">{$addon.update_fmt}</span></div>
|
||||
<div class="row"><span class="k">下载量</span><span class="v">{$addon.downloads}</span></div>
|
||||
<div class="row"><span class="k">评分</span>
|
||||
<span class="v">
|
||||
<span class="stars">
|
||||
{for start="1" end="6" name="i"}
|
||||
{if $addon.rating >= $i}<i class="layui-icon layui-icon-rate-solid"></i>{else/}<i class="layui-icon layui-icon-rate off"></i>{/if}
|
||||
{/for}
|
||||
</span>
|
||||
{$addon.rating}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row"><span class="k">开发者</span><span class="v">{$addon.author|default='官方'}</span></div>
|
||||
<div class="row"><span class="k">分类</span><span class="v">{$addon.category|default='未分类'}</span></div>
|
||||
</div>
|
||||
{if $addon.tags}
|
||||
<div class="tags">
|
||||
{foreach explode(',', $addon.tags) as $t}
|
||||
<span>{$t}</span>
|
||||
{/foreach}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="toc">
|
||||
<div class="t">本页目录</div>
|
||||
<a href="#intro">功能介绍</a>
|
||||
{if $addon.screenshots}<a href="#shots">预览截图</a>{/if}
|
||||
<a href="#log">更新日志</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>© {:date('Y')} {$market_name} · 由 YwxApp 框架强力驱动</footer>
|
||||
|
||||
<script>
|
||||
function swap(src, el) {
|
||||
document.querySelector('#cover img').src = src;
|
||||
document.querySelectorAll('#thumbs img').forEach(function (im) { im.classList.remove('on'); });
|
||||
el.classList.add('on');
|
||||
}
|
||||
// 锚点平滑滚动 + 目录高亮
|
||||
document.querySelectorAll('.toc a').forEach(function (a) {
|
||||
a.addEventListener('click', function (e) {
|
||||
var id = this.getAttribute('href');
|
||||
var target = document.querySelector(id);
|
||||
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,230 @@
|
||||
<!--
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Description: 应用商店前台(公开,仅中心站)
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>应用市场 - {$market_name}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #4f46e5;
|
||||
--brand-2: #7c3aed;
|
||||
--ink: #1f2329;
|
||||
--muted: #8a8f99;
|
||||
--line: #eceef2;
|
||||
--bg: #f5f6fa;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.brandbar {
|
||||
height: 56px; display: flex; align-items: center; gap: 10px;
|
||||
padding: 0 24px; background: #fff; border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
}
|
||||
.brandbar .logo {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||
display: grid; place-items: center; color: #fff; font-weight: 700;
|
||||
}
|
||||
.brandbar .name { font-weight: 600; font-size: 16px; }
|
||||
.brandbar .nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; }
|
||||
.brandbar .nav a { color: var(--muted); text-decoration: none; transition: color .2s; }
|
||||
.brandbar .nav a:hover, .brandbar .nav a.on { color: var(--brand); }
|
||||
|
||||
.hero {
|
||||
padding: 54px 24px 46px; text-align: center; color: #fff;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #9333ea 100%);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.hero::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(420px 220px at 12% 0%, rgba(255,255,255,.18), transparent 60%),
|
||||
radial-gradient(380px 200px at 88% 100%, rgba(255,255,255,.12), transparent 60%);
|
||||
}
|
||||
.hero h1 { margin: 0 0 10px; font-size: 32px; letter-spacing: .5px; position: relative; }
|
||||
.hero p { margin: 0; opacity: .92; font-size: 15px; position: relative; }
|
||||
|
||||
.toolbar {
|
||||
max-width: 1180px; margin: -26px auto 0; padding: 0 24px; position: relative; z-index: 5;
|
||||
}
|
||||
.toolbar .inner {
|
||||
background: #fff; border-radius: 14px; box-shadow: 0 8px 30px rgba(31,35,41,.08);
|
||||
padding: 14px 18px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.search {
|
||||
flex: 1; min-width: 220px; display: flex; align-items: center; gap: 8px;
|
||||
background: #f5f6fa; border-radius: 10px; padding: 9px 14px; color: var(--muted);
|
||||
}
|
||||
.search input {
|
||||
border: 0; background: transparent; outline: none; flex: 1; font-size: 14px; color: var(--ink);
|
||||
}
|
||||
.cats { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.cat {
|
||||
border: 1px solid var(--line); background: #fff; color: var(--muted);
|
||||
border-radius: 999px; padding: 6px 14px; font-size: 13px; cursor: pointer; transition: .18s;
|
||||
}
|
||||
.cat:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.cat.on { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
|
||||
.grid {
|
||||
max-width: 1180px; margin: 28px auto 60px; padding: 0 24px;
|
||||
display: grid; gap: 20px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 16px; border: 1px solid var(--line);
|
||||
padding: 20px; display: flex; flex-direction: column; gap: 12px;
|
||||
transition: transform .22s ease, box-shadow .22s ease, border-color .22s;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 18px 40px rgba(79,70,229,.16);
|
||||
border-color: transparent;
|
||||
}
|
||||
.card .head { display: flex; align-items: center; gap: 12px; }
|
||||
.card .ava {
|
||||
width: 52px; height: 52px; border-radius: 14px; flex: none;
|
||||
background: #f0f1f5; object-fit: cover; display: grid; place-items: center;
|
||||
font-weight: 700; color: var(--brand); font-size: 20px; overflow: hidden;
|
||||
}
|
||||
.card .ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.card .titles { min-width: 0; }
|
||||
.card .titles .t { font-weight: 600; font-size: 16px; line-height: 1.3; }
|
||||
.card .titles .a { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.card .desc {
|
||||
color: #5b606b; font-size: 13px; line-height: 1.6; min-height: 42px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.card .meta { display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--muted); }
|
||||
.stars { color: #f5a623; letter-spacing: 1px; }
|
||||
.stars .off { color: #e2e4ea; }
|
||||
.dl { display: flex; align-items: center; gap: 5px; }
|
||||
.card .foot { display: flex; align-items: center; justify-content: space-between; margin-top: auto; }
|
||||
.price {
|
||||
font-weight: 700; font-size: 16px; color: var(--brand);
|
||||
}
|
||||
.price.free { color: #16a34a; }
|
||||
.tag {
|
||||
font-size: 11px; color: var(--brand); background: rgba(79,70,229,.1);
|
||||
border-radius: 6px; padding: 3px 8px;
|
||||
}
|
||||
.card .btn {
|
||||
text-decoration: none; font-size: 13px; color: #fff;
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||
padding: 8px 16px; border-radius: 9px; transition: opacity .2s, transform .1s;
|
||||
}
|
||||
.card .btn:hover { opacity: .9; }
|
||||
.card .btn:active { transform: scale(.97); }
|
||||
|
||||
.empty { grid-column: 1 / -1; text-align: center; padding: 70px 0; color: var(--muted); }
|
||||
.empty .ic { font-size: 48px; opacity: .5; }
|
||||
.empty p { margin-top: 12px; }
|
||||
|
||||
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 26px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brandbar">
|
||||
<div class="logo">Y</div>
|
||||
<div class="name">{$market_name}</div>
|
||||
<div class="nav">
|
||||
<a href="/appmall/store" class="on">应用市场</a>
|
||||
<a href="/appmall/framework">主框架</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<h1>发现优质扩展,一键点亮你的站点</h1>
|
||||
<p>插件、模板、工具,覆盖建站全场景 · 由 {$market_name} 官方精选</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="inner">
|
||||
<div class="search">
|
||||
<i class="layui-icon layui-icon-search"></i>
|
||||
<input type="text" placeholder="搜索插件名称、作者或功能…" oninput="filterCards(this.value)">
|
||||
</div>
|
||||
<div class="cats" id="cats">
|
||||
<span class="cat on" data-cat="">全部</span>
|
||||
{foreach $categories as $c}
|
||||
<span class="cat" data-cat="{$c}">{$c}</span>
|
||||
{/foreach}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="grid">
|
||||
{if empty($list)}
|
||||
<div class="empty">
|
||||
<div class="ic"><i class="layui-icon layui-icon-template-1"></i></div>
|
||||
<p>市场暂无插件,敬请期待。</p>
|
||||
</div>
|
||||
{else}
|
||||
{foreach $list as $p}
|
||||
<div class="card" data-name="{$p.name|lower}" data-author="{$p.author|lower}" data-cat="{$p.category}">
|
||||
<div class="head">
|
||||
<div class="ava">
|
||||
{if $p.logo}
|
||||
<img src="{$p.logo}" alt="{$p.title}">
|
||||
{else}
|
||||
{$p.title|substr=0,1}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="titles">
|
||||
<div class="t">{$p.title}{if $p.version} <span style="font-weight:400;color:#aab;font-size:12px;">v{$p.version}</span>{/if}</div>
|
||||
<div class="a">by {$p.author|default='官方'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc">{$p.desc|default='暂无描述'}</div>
|
||||
<div class="meta">
|
||||
<span class="stars" title="评分 {$p.rating}">
|
||||
{for start="1" end="6" name="i"}
|
||||
{if $p.rating >= $i}<i class="layui-icon layui-icon-rate-solid"></i>{else/}<i class="layui-icon layui-icon-rate off"></i>{/if}
|
||||
{/for}
|
||||
</span>
|
||||
<span class="dl"><i class="layui-icon layui-icon-download-circle"></i>{$p.downloads} 下载</span>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<div>
|
||||
<span class="price {if $p.price==0}free{/if}">{$p.price_text}</span>
|
||||
{if $p.category}<span class="tag">{$p.category}</span>{/if}
|
||||
</div>
|
||||
<a class="btn" href="/appmall/store/detail/{$p.name}">查看详情</a>
|
||||
</div>
|
||||
</div>
|
||||
{/foreach}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer>© {:date('Y')} {$market_name} · 由 YwxApp 框架强力驱动</footer>
|
||||
|
||||
<script>
|
||||
function filterCards(kw) {
|
||||
kw = (kw || '').toLowerCase().trim();
|
||||
document.querySelectorAll('#grid .card').forEach(function (el) {
|
||||
var hit = !kw || el.dataset.name.indexOf(kw) > -1 || el.dataset.author.indexOf(kw) > -1;
|
||||
el.style.display = hit ? '' : 'none';
|
||||
});
|
||||
}
|
||||
document.getElementById('cats').addEventListener('click', function (e) {
|
||||
var cat = e.target.closest('.cat');
|
||||
if (!cat) return;
|
||||
document.querySelectorAll('#cats .cat').forEach(function (c) { c.classList.remove('on'); });
|
||||
cat.classList.add('on');
|
||||
var v = cat.dataset.cat;
|
||||
document.querySelectorAll('#grid .card').forEach(function (el) {
|
||||
el.style.display = (!v || el.dataset.cat === v) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user