feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+191 -33
View File
@@ -17,6 +17,7 @@ use think\exception\HttpException;
use think\facade\Config;
use think\facade\Request;
use think\facade\View;
use think\facade\Cache;
use ywxapp\exception\AddonException;
use ywxapp\service\AddonService;
use ywxapp\service\AddonDevService;
@@ -45,6 +46,8 @@ class Addons extends BackendBase
if ($this->request->isAjax()) {
$results = scandir(ADDON_PATH);
$list = [];
// 市场最新版本:用于列表内「可升级」提醒(市场不可达时静默降级为空)
$latestMap = $this->latestVersions();
foreach ($results as $name) {
if ($name === '.' or $name === '..' or is_file(ADDON_PATH . $name)) {
continue;
@@ -65,8 +68,12 @@ class Addons extends BackendBase
$info['id'] = $info['name'];
$info['description'] = $info['intro'] ?? '';
$info['status'] = $info['state'] ?? 0;
$info['hasConfig'] = is_file($addonDir . 'config.php');
$list[] = $info;
$info['hasConfig'] = is_file($addonDir . 'config.php');
$latest = (string) ($latestMap[$info['name']] ?? '');
$info['latest_version'] = $latest;
$info['upgradable'] = $latest !== ''
&& version_compare($latest, (string) ($info['version'] ?? '0'), '>');
$list[] = $info;
}
// 关键字搜索
@@ -96,6 +103,46 @@ class Addons extends BackendBase
return View::fetch('addon/index');
}
/**
* 市场最新版本号映射(插件标识 => 版本号),5 分钟缓存
*
* 用于已装列表的「可升级」提醒。市场不可达 / 未配置 / 未安装 appmall 时返回空数组,
* 列表退化为不显示升级标记,不影响插件管理主流程。
*/
private function latestVersions(): array
{
try {
return Cache::remember('addon_market_latest_versions', function () {
$list = [];
try {
if (is_market_client()) {
$resp = (new RemoteService())->lists([]);
$list = $resp['data']['list'] ?? [];
} elseif (class_exists(\app\appmall\service\MarketService::class)) {
$list = \app\appmall\service\MarketService::instance()
->catalog('', '', 'new', 'addon')['list'] ?? [];
}
} catch (\Throwable $e) {
return [];
}
$map = [];
foreach ((array) $list as $it) {
if (($it['type'] ?? 'addon') !== 'addon') {
continue;
}
$n = (string) ($it['name'] ?? '');
$v = (string) ($it['version'] ?? '');
if ($n !== '' && $v !== '') {
$map[$n] = $v;
}
}
return $map;
}, 300);
} catch (\Throwable $e) {
return [];
}
}
/**
* 应用市场(Discuz 式远程插件市场,核心后台「插件管理」)
*
@@ -145,13 +192,13 @@ class Addons extends BackendBase
} else {
$error = '未配置市场地址(请在后台配置 ywxapp.api_url)';
}
} elseif (!class_exists(\addon\appmall\service\MarketService::class)) {
} elseif (!class_exists(\app\appmall\service\MarketService::class)) {
// 本机即中心站但未安装 appmall 插件:市场目录不可用
$error = '本机未安装 appmall 插件:请安装插件以启用市场目录,或配置 ywxapp.api_url 指向中心站';
} else {
// 服务中心(本机):委托 appmall 插件读取本地市场目录(中心域逻辑全部在插件侧)
try {
$r = \addon\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
$r = \app\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
$list = $r['list'];
$categories = $r['categories'];
if (empty($list)) {
@@ -275,14 +322,14 @@ class Addons extends BackendBase
if (is_market_client()) {
return $this->result->error('远程模式订单存于中心站,请于会员中心申请退款');
}
if (!class_exists(\addon\appmall\service\MarketService::class)) {
if (!class_exists(\app\appmall\service\MarketService::class)) {
return $this->result->error('本机未安装 appmall 插件,无法执行退款');
}
try {
\addon\appmall\service\MarketService::instance()
\app\appmall\service\MarketService::instance()
->operatorRefund((string) $request->post('name', ''));
} catch (\Throwable $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
return $this->result->success([], '退款成功,授权已吊销');
}
@@ -303,7 +350,7 @@ class Addons extends BackendBase
}
$token = config('ywxapp.developer_token', '');
if (!$token) {
return $this->result->error('未配置开发者令牌:请在 .env 设置 DEVELOPER_TOKEN');
return $this->result->error('未配置开发者令牌:请在 .env 设置 APPMARKET_DEV_TOKEN');
}
$AddonService = AddonService::instance($name);
if (!$AddonService->isInstalled()) {
@@ -354,9 +401,9 @@ class Addons extends BackendBase
}
return $this->result->error('提交到官方市场失败:' . $msg);
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -375,7 +422,7 @@ class Addons extends BackendBase
$zipFile = $AddonService->package();
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -400,7 +447,11 @@ class Addons extends BackendBase
];
$info = AddonService::instance()->local($file, $extend);
} catch (AddonException $e) {
$this->result->error(LANG($e->getMessage(), $e->getCode()));
$this->result->error(
LANG($e->getMessage(), $e->getCode()),
$e->getCode() ?: 1,
$e->getData() ?: null
);
} catch (\Exception $e) {
$this->result->error(lang($e->getMessage()));
}
@@ -426,14 +477,20 @@ class Addons extends BackendBase
$extend = [
'install_user' => session('user_id'),
'install_ip' => Request::ip(),
'install_time' => time()
'install_time' => time(),
// 中心站 Market::valid() 读的是 **uid**(不是 install_user):
// 离线上传付费插件时若不带 uid,服务端收到 uid=0 会直接判
// 「该插件为付费插件,请先购买」,导致后台永远装不上自己买的插件。
// 这里的 uid 指「市场会员 ID」;后台管理员 ID 只在「本机即中心站、
// 且管理员与市场会员同一套账号」时等价,跨机器场景请走会员登录态。
'uid' => (int) (session('user_id') ?? 0),
];
$info = $AddonService->local($file, $extend);
return $this->result->success($info, '插件安装成功');
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
/**
@@ -447,15 +504,21 @@ class Addons extends BackendBase
if (!$addonName || !preg_match('/^[a-zA-Z0-9_]+$/', $addonName)) {
return json(['code' => 0, 'msg' => '插件名称格式不正确']);
}
// 卸载选项(前端勾选,缺省沿用服务层默认:备份 + 删除)
// backup_data=0 不备份;delete_data=0 保留插件数据表
$options = [
'backup_data' => (bool) input('backup_data', 1),
'delete_data' => (bool) input('delete_data', 1),
];
$AddonService = AddonService::instance($addonName);
$result = $AddonService->uninstall();
$result = $AddonService->uninstall($options);
if ($result) {
$this->result->success([], '插件卸载成功');
} else {
$this->result->error('插件卸载失败');
}
} catch (AddonException $e) {
$this->result->error('插件卸载失败:' . $e->getMessage());
$this->result->error('插件卸载失败:' . $e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
$this->result->error('插件卸载失败:' . $e->getMessage());
}
@@ -499,7 +562,7 @@ class Addons extends BackendBase
$zipFile = $AddonService->package();
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -534,9 +597,9 @@ class Addons extends BackendBase
$info = $AddonService->local($file, $extend);
return $this->result->success($info, '下载并安装成功');
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -562,9 +625,9 @@ class Addons extends BackendBase
'升级成功:' . ($res['from'] ?? '') . ' → ' . ($res['to'] ?? '')
);
} catch (AddonException $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -585,10 +648,28 @@ class Addons extends BackendBase
$post = input('post.');
$data = [];
foreach ($fields as $f) {
$n = $f['name'] ?? '';
if ($n !== '' && array_key_exists($n, $post)) {
$data[$n] = $post[$n];
$n = $f['name'] ?? '';
$type = $f['type'] ?? 'text';
if ($n === '') {
continue;
}
if (array_key_exists($n, $post)) {
$v = $post[$n];
} else {
// 未提交:开关视为关闭、多选视为空数组;其余字段不覆盖已有值
if ($type === 'switch') {
$v = 0;
} elseif ($type === 'checkbox') {
$v = [];
} else {
continue;
}
}
// array 型:textarea 的「键=值」多行文本转数组存储
if ($type === 'array' && is_string($v)) {
$v = self::parseKvText($v);
}
$data[$n] = $v;
}
AddonService::config($addon, $data);
AddonService::clearConfigCache($addon);
@@ -596,8 +677,30 @@ class Addons extends BackendBase
}
$saved = AddonService::config($addon);
foreach ($fields as &$f) {
if (isset($saved[$f['name']])) {
$f['value'] = $saved[$f['name']];
$n = $f['name'] ?? '';
$type = $f['type'] ?? 'text';
if ($n !== '' && isset($saved[$n])) {
$val = $saved[$n];
// array 型:数组还原成「键=值」文本供 textarea 编辑
if ($type === 'array' && is_array($val)) {
$val = implode(PHP_EOL, array_map(
function ($k, $v) {
return $k . '=' . (is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
},
array_keys($val),
$val
));
}
$f['value'] = $val;
}
// 缺省值兜底:无已存值时用 config.php 的 default,避免模板取到未定义变量
if (! isset($f['value'])) {
$f['value'] = $f['default'] ?? ($type === 'checkbox' ? [] : ($type === 'switch' ? 0 : ''));
}
// options 容错:正则化写法。select/radio/checkbox 的 options 既可以是数组,
// 也可以写字符串('0:否,1:是' 或 '北京,上海'),省得插件作者写一堆嵌套数组
if (isset($f['options'])) {
$f['options'] = self::normalizeOptions($f['options']);
}
}
unset($f);
@@ -605,6 +708,61 @@ class Addons extends BackendBase
return View::fetch('addon/setting');
}
/**
* 解析「键=值」多行文本为数组(array 型插件配置用)
*
* 每行一条:`key=value` 写入关联键,无等号的整行作为列表项。
*/
private static function parseKvText(string $text): array
{
$arr = [];
foreach (preg_split('/\r\n|\r|\n/', $text) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
if (strpos($line, '=') !== false) {
[$k, $v] = explode('=', $line, 2);
$arr[trim($k)] = trim($v);
} else {
$arr[] = $line;
}
}
return $arr;
}
/**
* 归一化 select/radio/checkbox 的 options
*
* 支持三种写法(最终统一成 值=>标题 数组):
* [1 => '开启', 0 => '关闭'] 数组(原样返回)
* '0:关闭,1:开启' 键值对字符串
* '北京,上海' 纯列表(值与标题相同)
*/
private static function normalizeOptions($options): array
{
if (is_array($options)) {
return $options;
}
if (! is_string($options) || trim($options) === '') {
return [];
}
$out = [];
foreach (explode(',', $options) as $item) {
$item = trim($item);
if ($item === '') {
continue;
}
if (str_contains($item, ':')) {
[$k, $v] = explode(':', $item, 2);
$out[trim($k)] = trim($v);
} else {
$out[$item] = $item;
}
}
return $out;
}
// ==================== 开发模式:插件设计器(抄 Discuz!) ====================
/**
@@ -645,7 +803,7 @@ class Addons extends BackendBase
$info = AddonDevService::instance($name)->createSkeleton($meta);
return $this->result->success($info, '插件骨架创建成功');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -665,7 +823,7 @@ class Addons extends BackendBase
$data = AddonDevService::instance($name)->readAll();
return $this->result->success($data);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -711,7 +869,7 @@ class Addons extends BackendBase
}
return $this->result->success([], '保存成功');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -734,7 +892,7 @@ class Addons extends BackendBase
$rel = ltrim(str_replace(ADDON_PATH . $name . DIRECTORY_SEPARATOR, '', $file), DIRECTORY_SEPARATOR);
return $this->result->success(['file' => $rel], '生成成功:' . $rel);
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -754,7 +912,7 @@ class Addons extends BackendBase
$info = AddonDevService::instance($name)->developInstall();
return $this->result->success($info, '开发模式安装成功,菜单已注入并启用');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
@@ -774,7 +932,7 @@ class Addons extends BackendBase
AddonDevService::instance($name)->remove();
return $this->result->success([], '插件目录已删除');
} catch (\Exception $e) {
return $this->result->error($e->getMessage());
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
}
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\backend\controller;
use think\facade\Db;
use ywxapp\controller\BackendBase;
use ywxapp\library\AdminLogger;
/**
* 管理员操作日志
*
* 日志来源:
* - 后台所有写操作:BackendBase 的 create / edit / del / destroy / restore / multi 成功后自动留痕;
* - 登录与退出:BackendBase::login() / logout();
* - 业务代码可直接 AdminLogger::write('动作', '摘要') 补充关键操作。
*
* 权限节点(首次访问自动补录):adminlog:index / adminlog:delete / adminlog:clean
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Adminlog extends BackendBase
{
use EnsuresPower;
protected $noNeedLogin = [];
protected $noNeedVerify = [];
/**
* 进程内只补录一次权限
* @var bool
*/
protected static $powerChecked = false;
protected function initialize()
{
if (!self::$powerChecked) {
self::$powerChecked = true;
$this->ensureMenus();
}
}
/**
* 列表
*/
public function index()
{
if ($this->request->isAjax()) {
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$kw = trim((string) $this->request->param('keyword', ''));
$action = trim((string) $this->request->param('action', ''));
$start = trim((string) $this->request->param('start', ''));
$end = trim((string) $this->request->param('end', ''));
$query = Db::name('backend_log')->alias('l')
->leftJoin('backend_admin a', 'a.id = l.aid')
->field('l.*, a.account, a.nickname');
if ($kw !== '') {
// 注意:本 ORM 版本的 whereLike 对带表别名字段会抛
// 「where express error」,统一用 where(field, 'like', ...)
$query->where(function ($q) use ($kw) {
$q->where('l.remark', 'like', '%' . $kw . '%')
->whereOr('l.ip', 'like', '%' . $kw . '%')
->whereOr('a.account', 'like', '%' . $kw . '%')
->whereOr('a.nickname', 'like', '%' . $kw . '%');
});
}
if ($action !== '') {
$query->where('l.action', 'like', '%' . $action . '%');
}
if ($start !== '') {
$query->where('l.create_at', '>=', strtotime($start . ' 00:00:00'));
}
if ($end !== '') {
$query->where('l.create_at', '<=', strtotime($end . ' 23:59:59'));
}
$total = (int) $query->count();
$list = $query->order('l.id', 'desc')
->page($page, $limit)
->select()
->toArray();
foreach ($list as &$row) {
$row['create_at_text'] = $row['create_at'] ? date('Y-m-d H:i:s', (int) $row['create_at']) : '-';
$row['operator'] = $row['account'] ?: ($row['aid'] ? ('#' . $row['aid']) : '系统');
}
$this->result->setCount($total)->success($list);
}
// 动作类型下拉:取表里出现过的动作,便于筛选
$actions = Db::name('backend_log')
->field('action, COUNT(*) AS total')
->group('action')
->order('total', 'desc')
->limit(50)
->select()
->toArray();
$this->assign('actions', $actions);
return $this->fetch();
}
/**
* 删除(支持逗号分隔批量)
*/
public function delete()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$ids = $this->request->param('ids', '');
$ids = is_array($ids) ? $ids : array_filter(explode(',', (string) $ids));
if (empty($ids)) {
$this->result->error('请选择要删除的日志');
}
$count = Db::name('backend_log')->whereIn('id', $ids)->delete();
AdminLogger::write('adminlog/delete', '删除日志 ' . $count . ' 条');
$this->result->success(['count' => $count], '已删除 ' . $count . ' 条');
}
/**
* 清理:按保留天数删除,days=0 表示清空
*/
public function clean()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$days = $this->request->param('days/d', 30);
if ($days < 0 || $days > 3650) {
$this->result->error('保留天数应在 0 ~ 3650 之间(0=清空)');
}
$count = AdminLogger::clean($days);
AdminLogger::write('adminlog/clean', ($days > 0 ? ('清理 ' . $days . ' 天前日志') : '清空日志') . ',共 ' . $count . ' 条');
$this->result->success(['count' => $count], $days > 0
? ('已清理 ' . $count . ' 条(保留最近 ' . $days . ' 天)')
: ('已清空 ' . $count . ' 条'));
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\backend\controller;
use think\facade\Db;
use ywxapp\controller\BackendBase;
use ywxapp\library\AdminLogger;
use ywxapp\model\CommonAttachment;
use ywxapp\service\FileStorageService;
/**
* 附件统一管理
*
* API 端(/api/v1/common/upload 等)负责收文件,后台这里做全局视角:
* 全站附件一览(不分模块/不分上传者)、按条件检索、预览、删除(物理文件 + 软删记录)、容量统计。
* 删除始终走 FileStorageService::delete(),OSS / FTP 等自定义存储由 FileDelete 事件接管,
* 后台代码无需关心文件到底存在哪。
*
* 权限节点(首次访问自动补录):attachment:index / attachment:delete
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Attachment extends BackendBase
{
use EnsuresPower;
protected $noNeedLogin = [];
protected $noNeedVerify = [];
/**
* 进程内只补录一次权限
* @var bool
*/
protected static $powerChecked = false;
protected function initialize()
{
if (!self::$powerChecked) {
self::$powerChecked = true;
$this->ensureMenus();
}
}
/**
* 列表 + 统计
*/
public function index()
{
if ($this->request->isAjax()) {
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$kw = trim((string) $this->request->param('keyword', ''));
$module = trim((string) $this->request->param('module', ''));
$onlyImg = (int) $this->request->param('only_image/d', 0);
$query = CommonAttachment::where('delete_at', 0);
if ($kw !== '') {
$query->where(function ($q) use ($kw) {
$q->where('original', 'like', '%' . $kw . '%')
->whereOr('name', 'like', '%' . $kw . '%')
->whereOr('path', 'like', '%' . $kw . '%');
});
}
if ($module !== '') {
$query->where('module', $module);
}
if ($onlyImg === 1) {
$query->where('is_image', 1);
}
$total = (int) $query->count();
$list = $query->order('id', 'desc')->page($page, $limit)->select()->toArray();
foreach ($list as &$row) {
$row['size_text'] = $this->formatBytes((int) $row['size']);
$row['create_at_text'] = $row['create_at'] ? date('Y-m-d H:i:s', (int) $row['create_at']) : '-';
}
$this->result->setCount($total)->success($list);
}
// 概览统计与模块清单(供页面筛选下拉)
$stat = [
'total' => (int) CommonAttachment::where('delete_at', 0)->count(),
'size' => (int) CommonAttachment::where('delete_at', 0)->sum('size'),
'image' => (int) CommonAttachment::where('delete_at', 0)->where('is_image', 1)->count(),
];
$stat['size_text'] = $this->formatBytes($stat['size']);
$modules = Db::name('common_attachment')
->where('delete_at', 0)
->field('module, COUNT(*) AS total')
->group('module')
->select()
->toArray();
$this->assign('stat', $stat);
$this->assign('modules', $modules);
return $this->fetch();
}
/**
* 删除:物理删文件(可由事件接管)+ 软删记录
*/
public function delete()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$ids = $this->request->param('ids', '');
$ids = is_array($ids) ? $ids : array_filter(explode(',', (string) $ids));
if (empty($ids)) {
$this->result->error('请选择要删除的附件');
}
$storage = new FileStorageService();
$ok = 0;
$fail = 0;
foreach ($ids as $id) {
$row = CommonAttachment::find((int) $id);
if (! $row) {
$fail++;
continue;
}
// 文件删不掉也要删记录,避免磁盘残留变成无法再管的孤儿
$storage->delete((string) $row->path);
$row->delete();
$ok++;
}
AdminLogger::write('attachment/delete', '删除附件 ' . $ok . ' 个' . ($fail ? "({$fail} 个不存在)" : ''));
$this->result->success(['ok' => $ok, 'fail' => $fail], '已删除 ' . $ok . ' 个附件');
}
protected function formatBytes(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB'];
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
}
}
+212
View File
@@ -12,6 +12,7 @@
declare (strict_types = 1);
namespace app\backend\controller;
use app\backend\library\SystemInfo;
use think\Request;
use think\facade\View;
use think\facade\Db;
@@ -51,10 +52,221 @@ class Console extends BackendBase
*/
public function index()
{
// 控制台首页带一份系统概览(与「系统信息」页共用 SystemInfo,避免两套实现)
if ($this->request->isAjax()) {
$this->result->success(SystemInfo::collect());
}
View::assign('title', '控制台');
View::assign('sys', SystemInfo::collect());
// 控制台内的「框架升级」卡片用(与 Upgrade 页同源:config('ywxapp.version'))
View::assign('version', (string) config('ywxapp.version'));
// 首页下半区:真实统计(替换 layuiAdmin 模板自带的演示数据)
View::assign('stats', $this->collectStats());
return View::fetch();
}
/**
* 清理缓存:cache / temp / all(不动 log)
*
* 由原 System::clean() 迁来(/admin/system/clean → /admin/console/clean):
* 「系统信息」页已随 System 控制器一起下线,但首页顶部的三个清理按钮要留着,
* 所以能力搬来这里;清理实现仍在 SystemInfo::cleanRuntime(),权限节点同步改成 console:clean。
*
* @return void
*/
public function clean()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$type = trim((string) $this->request->param('type', 'cache'));
try {
$files = SystemInfo::cleanRuntime($type);
} catch (\Throwable $e) {
$this->result->error($e->getMessage());
}
$this->result->success(['files' => $files], '已清理 ' . $files . ' 项缓存');
}
/* ====================== 首页统计(真实数据,替换演示数据) ====================== */
/**
* 采集首页下半区要用的统计
*
* 数据源只有三张核心表:会员 member_user / 后台日志 backend_log / 蜘蛛统计 common_spider_stat。
* 任意一张缺失或查询失败都退化为 0——首页是最容易被人访问的页面,
* 不能因为某张插件表没建就整体 500。
*
* @return array
*/
protected function collectStats(): array
{
$todayStart = strtotime('today');
$todayEnd = $todayStart + 86399;
$weekStart = strtotime('-6 days', $todayStart);
// 近 7 天先铺好 0 值骨架:某天没有数据也不会把曲线挤错位
$bucket = [];
$dates = [];
for ($i = 6; $i >= 0; $i--) {
$ts = strtotime("-{$i} days", $todayStart);
$key = date('Y-m-d', $ts);
$bucket[$key] = ['member' => 0, 'log' => 0];
$dates[] = date('m-d', $ts);
}
// 近 7 天:一次取回时间戳列,在 PHP 侧按天归桶(省掉 14 次 COUNT)
foreach ($this->safeColumn('member_user', 'create_at', $weekStart, $todayEnd) as $ts) {
$key = date('Y-m-d', (int) $ts);
if (isset($bucket[$key])) {
$bucket[$key]['member']++;
}
}
foreach ($this->safeColumn('backend_log', 'create_at', $weekStart, $todayEnd) as $ts) {
$key = date('Y-m-d', (int) $ts);
if (isset($bucket[$key])) {
$bucket[$key]['log']++;
}
}
$memberWeek = 0;
foreach ($bucket as $row) {
$memberWeek += $row['member'];
}
return [
'dates' => $dates,
'series' => [
'member' => array_column($bucket, 'member'),
'log' => array_column($bucket, 'log'),
],
'today' => [
'member' => SystemInfo::safeCount('member_user', function ($q) use ($todayStart, $todayEnd) {
$q->where('create_at', '>=', $todayStart)->where('create_at', '<=', $todayEnd);
}),
'log' => SystemInfo::safeCount('backend_log', function ($q) use ($todayStart, $todayEnd) {
$q->where('create_at', '>=', $todayStart)->where('create_at', '<=', $todayEnd);
}),
// 蜘蛛按日聚合表:今日那行的 count 就是当天抓取量
'spider' => $this->safeSum('common_spider_stat', 'count', function ($q) use ($todayStart) {
$q->where('stat_date', date('Y-m-d', $todayStart));
}),
],
'total' => [
'member' => SystemInfo::safeCount('member_user'),
'member_week' => $memberWeek,
'log' => SystemInfo::safeCount('backend_log'),
'spider' => $this->safeSum('common_spider_stat', 'count'),
],
'members' => $this->latestMembers(),
'logs' => $this->latestLogs(),
];
}
/**
* 最新注册的会员
*
* @return array
*/
protected function latestMembers(): array
{
try {
return Db::name('member_user')
->field('uid, account, nickname, create_at, update_at')
->order('uid', 'desc')
->limit(8)
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
/**
* 最近的后台操作日志(附带管理员昵称)
*
* @return array
*/
protected function latestLogs(): array
{
$admins = [];
try {
$admins = Db::name('backend_admin')->column('nickname', 'id');
} catch (\Throwable $e) {
$admins = [];
}
try {
$rows = Db::name('backend_log')
->field('id, aid, action, ip, create_at')
->order('id', 'desc')
->limit(8)
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
$list = [];
foreach ($rows as $row) {
$list[] = [
'id' => (int) $row['id'],
'admin' => $admins[$row['aid']] ?? ('#' . $row['aid']),
'action' => (string) $row['action'],
'ip' => (string) $row['ip'],
'time' => (int) $row['create_at'],
];
}
return $list;
}
/**
* 安全取单列:表缺失 / 查询异常时返回空数组
*
* @param string $table
* @param string $field
* @param int $since
* @param int|null $until
* @return array
*/
protected function safeColumn(string $table, string $field, int $since, int $until = null): array
{
try {
$q = Db::name($table)->where($field, '>=', $since);
if ($until !== null) {
$q->where($field, '<=', $until);
}
return $q->column($field);
} catch (\Throwable $e) {
return [];
}
}
/**
* 安全求和:表缺失 / 查询异常时返回 0
*
* @param string $table
* @param string $field
* @param callable|null $where
* @return int
*/
protected function safeSum(string $table, string $field, callable $where = null): int
{
try {
$q = Db::name($table);
if ($where) {
$where($q);
}
return (int) $q->sum($field);
} catch (\Throwable $e) {
return 0;
}
}
/**
* 热门统计(真实数据)
+202
View File
@@ -0,0 +1,202 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-09-28 00:00:00
* @Description: 数据库备份 / 还原 / 优化
* @FilePath: \ywxapp_dev\app\backend\controller\Database.php
*/
declare(strict_types=1);
namespace app\backend\controller;
use think\facade\Db;
use ywxapp\controller\BackendBase;
use ywxapp\library\DbBackup;
/**
* 数据库管理
*
* 权限节点(首次访问自动补录到 backend_power,挂到「系统管理」下):
* database:index / database:backup / database:recovery / database:delete / database:optimize
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Database extends BackendBase
{
use EnsuresPower; // 权限节点自愈:首次访问自动补录 backend_power
protected $noNeedLogin = [];
protected $noNeedVerify = [];
/**
* @var DbBackup
*/
protected $bak;
/**
* 进程内只补录一次权限
* @var bool
*/
protected static $powerChecked = false;
protected function initialize()
{
$this->bak = new DbBackup();
if (!self::$powerChecked) {
self::$powerChecked = true;
$this->ensureMenus();
}
}
/**
* 列表:数据表 + 备份记录
*/
public function index()
{
if ($this->request->isAjax()) {
$type = $this->request->param('type', 'table');
if ($type === 'backup') {
$list = $this->bak->backups();
$this->result->setCount(count($list))->success(array_map(function ($item) {
$item['size_text'] = $this->formatBytes($item['size']);
$item['time_text'] = date('Y-m-d H:i:s', $item['time']);
return $item;
}, $list));
}
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
$kw = trim((string) $this->request->param('keyword', ''));
$tables = $this->bak->tables();
if ($kw !== '') {
$tables = array_values(array_filter($tables, function ($t) use ($kw) {
return stripos($t['name'], $kw) !== false || stripos($t['comment'], $kw) !== false;
}));
}
$total = count($tables);
$slice = array_slice($tables, ($page - 1) * $limit, $limit);
foreach ($slice as &$t) {
$t['size_text'] = $this->formatBytes($t['size']);
}
$this->result->setCount($total)->success($slice);
}
return $this->fetch();
}
/**
* 备份
*/
public function backup()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$tables = $this->request->param('tables/a', []);
$size = $this->request->param('part_size/d', 2097152);
$size = max(65536, min($size, 52428800)); // 64KB ~ 50MB
set_time_limit(0);
try {
$res = $this->bak->backup(array_values(array_filter((array) $tables)), null);
} catch (\Throwable $e) {
\think\facade\Log::error('数据库备份失败:' . $e->getMessage());
$this->result->error('备份失败:' . $e->getMessage());
}
$res['size_text'] = $this->formatBytes($res['size']);
$this->result->success($res, '备份完成:' . $res['tables'] . ' 张表 / ' . $res['rows'] . ' 行');
}
/**
* 还原
*
* 注意:基类 BackendBase 已有 restore($ids='')(回收站恢复),此处不能用同名,
* 否则签名不兼容会直接 500,故命名为 recovery。
*/
public function recovery()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$name = (string) $this->request->param('name', '');
if ($name === '') {
$this->result->error('请选择备份');
}
set_time_limit(0);
try {
$res = $this->bak->restore($name);
} catch (\Throwable $e) {
\think\facade\Log::error('数据库还原失败:' . $e->getMessage());
$this->result->error('还原失败:' . $e->getMessage());
}
$this->result->success($res, '还原完成:' . $res['files'] . ' 个文件 / ' . $res['sql'] . ' 条语句');
}
/**
* 删除备份
*/
public function delete()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$name = (string) $this->request->param('name', '');
if ($name === '') {
$this->result->error('请选择备份');
}
$this->result->success(['ok' => $this->bak->delete($name)], '已删除');
}
/**
* 下载备份文件
*/
public function download()
{
$name = (string) $this->request->param('name', '');
$file = (string) $this->request->param('file', '');
$path = $this->bak->fileOf($name, $file);
if (! $path || ! is_file($path)) {
$this->result->error('备份文件不存在', 404);
}
return response(file_get_contents($path), 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . basename($path) . '"',
'Content-Length' => filesize($path),
]);
}
/**
* 优化表
*/
public function optimize()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
if (! $tables) {
$this->result->error('请选择要优化的表');
}
$res = $this->bak->optimize($tables);
$this->result->success($res, '已优化 ' . count($res) . ' 张表');
}
/**
* 权限节点补录:核心后台菜单即权限记录,缺失时自动补上,
* 避免手工在「权限菜单」里一条条加。
*/
protected function formatBytes(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB'];
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
}
}
+858
View File
@@ -0,0 +1,858 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\backend\controller;
use think\facade\Db;
use ywxapp\controller\BackendBase;
use ywxapp\library\AdminLogger;
/**
* 数据表管理(可视化建表 / 查看结构 / 优化 / 删表)
*
* 与「数据库备份」分开成两个控制器:那边是数据层面的备份还原,
* 这边是结构层面的 DDL。
*
* 安全约束(DDL 不可逆,这里比 CRUD 更保守):
* 1. 表名 / 字段名 / 类型 / 引擎 / 字符集全部走白名单,用户输入的字符串
* 一个字符都不会拼进 SQL(注释与默认值只做转义,不参与语法结构);
* 2. 建表只允许「新建不存在的表」,已存在直接拒绝,绝不覆盖(不做 DROP+CREATE);
* 3. 删表:核心表(backend_admin / backend_power 等)列入保护名单,
* 且要求前端回传表全名做二次确认;
* 4. 所有写操作进 AdminLogger 留痕。
*
* 权限节点(首次访问自动补录):datatable:index / datatable:create /
* datatable:drop / datatable:optimize / datatable:structure
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Datatable extends BackendBase
{
use EnsuresPower;
protected $noNeedLogin = [];
protected $noNeedVerify = [];
/**
* 进程内只补录一次权限
* @var bool
*/
protected static $powerChecked = false;
/**
* install.sql 索引缓存:[裸表名 => install.sql 相对路径]
* @var array|null
*/
protected static $installMap = null;
/**
* 允许的数据类型(白名单)
* @var array
*/
protected const TYPES = [
'tinyint', 'smallint', 'mediumint', 'int', 'bigint',
'decimal', 'float', 'double',
'char', 'varchar', 'text', 'mediumtext', 'longtext',
'date', 'datetime', 'timestamp', 'time', 'year',
'json', 'blob', 'longblob',
];
/**
* 数值类型(可 unsigned / 可自增)
* @var array
*/
protected const NUMERIC = [
'tinyint', 'smallint', 'mediumint', 'int', 'bigint', 'decimal', 'float', 'double',
];
/**
* 需要长度定义的类型
* @var array
*/
protected const NEED_LEN = ['char', 'varchar', 'decimal', 'float', 'double'];
/**
* 允许的引擎
* @var array
*/
protected const ENGINES = ['InnoDB', 'MyISAM', 'MEMORY', 'Aria'];
/**
* 允许的字符集
* @var array
*/
protected const CHARSETS = ['utf8mb4', 'utf8', 'gbk', 'latin1'];
/**
* 允许的排序规则(与上面的字符集配对)
* @var array
*/
protected const COLLATIONS = [
'utf8mb4_general_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin',
'utf8_general_ci', 'utf8_bin',
'gbk_chinese_ci', 'latin1_swedish_ci',
];
/**
* 禁止删除的核心表(不含前缀)
* @var array
*/
protected const PROTECTED = [
'backend_admin', 'backend_power', 'backend_role', 'backend_group',
'backend_log', 'backend_menu',
];
protected function initialize()
{
if (!self::$powerChecked) {
self::$powerChecked = true;
$this->ensureMenus();
}
}
/**
* 列表:全部数据表
*/
public function index()
{
if ($this->request->isAjax()) {
$page = $this->request->param('page/d', 1);
$limt = $this->request->param('limit/d', 15);
$kw = trim((string) $this->request->param('keyword', ''));
// 只看「库里有、但没登记进任何 install.sql」的表
$onlyUnregistered = (int) $this->request->param('unregistered/d', 0) === 1;
$prefix = $this->prefix();
$map = $this->installMap();
$rows = [];
try {
$rows = Db::query('SHOW TABLE STATUS');
} catch (\Throwable $e) {
$this->result->error('读取表状态失败:' . $e->getMessage());
}
$list = [];
foreach ($rows as $row) {
$row = array_change_key_case($row, CASE_LOWER);
$name = (string) ($row['name'] ?? '');
$short = $prefix !== '' && str_starts_with($name, $prefix)
? substr($name, strlen($prefix))
: $name;
if ($onlyUnregistered && $this->archiveInfo($short, $map)[0]) {
continue;
}
if ($kw !== ''
&& stripos($name, $kw) === false
&& stripos((string) ($row['comment'] ?? ''), $kw) === false) {
continue;
}
$archive = $this->archiveInfo($short, $map);
$list[] = [
'name' => $name,
'short' => $short,
'engine' => (string) ($row['engine'] ?? ''),
// 是否已在某个 install.sql / 模型自愈里登记(决定全新安装 / 自愈能否补出来)
'registered' => $archive[0],
'target' => $archive[1],
'rows' => (int) ($row['rows'] ?? 0),
'size' => (int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0)),
'size_text' => $this->formatBytes((int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0))),
'collation' => (string) ($row['collation'] ?? ''),
'comment' => (string) ($row['comment'] ?? ''),
'protected' => in_array($short, self::PROTECTED, true),
];
}
$total = count($list);
$slice = array_slice($list, ($page - 1) * $limt, $limt);
$this->result->setCount($total)->success($slice);
}
$this->assign('prefix', $this->prefix());
$this->assign('charset', $this->charset());
return $this->fetch();
}
/**
* 查看表结构:字段 + 索引 + 建表语句
*/
public function structure()
{
$table = $this->safeTable((string) $this->request->param('table', ''));
if ($table === '') {
$this->result->error('参数错误:table');
}
if (! $this->tableExists($table)) {
$this->result->error('表不存在:' . $table, 404);
}
$columns = [];
foreach (Db::query('SHOW FULL COLUMNS FROM `' . $table . '`') as $c) {
$c = array_change_key_case($c, CASE_LOWER);
$columns[] = [
'field' => (string) ($c['field'] ?? ''),
'type' => (string) ($c['type'] ?? ''),
'null' => (string) ($c['null'] ?? ''),
'key' => (string) ($c['key'] ?? ''),
'default' => $c['default'] ?? null,
'extra' => (string) ($c['extra'] ?? ''),
'comment' => (string) ($c['comment'] ?? ''),
];
}
$indexes = [];
foreach (Db::query('SHOW INDEX FROM `' . $table . '`') as $i) {
$i = array_change_key_case($i, CASE_LOWER);
$indexes[] = [
'name' => (string) ($i['key_name'] ?? ''),
'column' => (string) ($i['column_name'] ?? ''),
'unique' => (int) ($i['non_unique'] ?? 1) === 0,
'seq' => (int) ($i['seq_in_index'] ?? 0),
];
}
$create = Db::query('SHOW CREATE TABLE `' . $table . '`');
$create = array_change_key_case((array) ($create[0] ?? []), CASE_LOWER);
$this->result->success([
'table' => $table,
'short' => $this->shortName($table),
'columns' => $columns,
'indexes' => $indexes,
'sql' => (string) ($create['create table'] ?? $create['create view'] ?? ''),
]);
}
/**
* 生成建表 SQL(只预览,不执行)
*/
public function preview()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
try {
$def = $this->buildDefinition();
} catch (\Throwable $e) {
$this->result->error($e->getMessage());
return;
}
$this->result->success([
'sql' => $this->createSql($def),
'table' => $def['table'],
'short' => $this->shortName($def['table']),
'command' => 'php think addon:crud <插件> -t ' . $this->shortName($def['table']),
]);
}
/**
* 执行建表
*/
public function create()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
try {
$def = $this->buildDefinition();
} catch (\Throwable $e) {
$this->result->error($e->getMessage());
return;
}
if ($this->tableExists($def['table'])) {
$this->result->error('表已存在,已中止(本页只做新建,不会覆盖或重建已有表):' . $def['table']);
}
$sql = $this->createSql($def);
try {
Db::execute($sql);
} catch (\Throwable $e) {
AdminLogger::write('datatable/create', '建表失败:' . $def['table'] . '|' . $e->getMessage());
$this->result->error('建表失败:' . $e->getMessage());
}
AdminLogger::write('datatable/create', '新建表:' . $def['table'] . '(' . count($def['fields']) . ' 个字段)');
$this->result->success([
'table' => $def['table'],
'short' => $this->shortName($def['table']),
'sql' => $sql,
'command' => 'php think addon:crud <插件> -t ' . $this->shortName($def['table']),
], '表创建成功:' . $def['table']);
}
/**
* 删除表(危险:不可逆)
*
* 必须回传 confirm=表全名,与前端输入框完全一致才放行。
*/
public function drop()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$table = $this->safeTable((string) $this->request->param('table', ''));
$confirm = (string) $this->request->param('confirm', '');
if ($table === '') {
$this->result->error('参数错误:table');
}
if ($confirm !== $table) {
$this->result->error('确认表名不一致,已中止');
}
if (in_array($this->shortName($table), self::PROTECTED, true)) {
$this->result->error('该表是系统核心表,禁止删除:' . $table);
}
if (! $this->tableExists($table)) {
$this->result->error('表不存在:' . $table, 404);
}
try {
Db::execute('DROP TABLE `' . $table . '`');
} catch (\Throwable $e) {
$this->result->error('删除失败:' . $e->getMessage());
}
AdminLogger::write('datatable/drop', '删除表:' . $table);
$this->result->success(['table' => $table], '已删除表:' . $table);
}
/**
* 优化表
*/
public function optimize()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
if (! $tables) {
$this->result->error('请选择要优化的表');
}
$ok = [];
foreach ($tables as $t) {
$table = $this->safeTable((string) $t);
if ($table === '' || ! $this->tableExists($table)) {
continue;
}
try {
Db::execute('OPTIMIZE TABLE `' . $table . '`');
$ok[] = $table;
} catch (\Throwable $e) {
// 单表失败不影响其它表
}
}
AdminLogger::write('datatable/optimize', '优化表:' . implode(', ', $ok));
$this->result->success(['tables' => $ok], '已优化 ' . count($ok) . ' 张表');
}
/**
* 导出安装 SQL(只生成文本,不写任何文件、不执行)
*
* 目的:把「运行时在页面上建出来的表」回写到项目里的 install.sql,
* 否则换库全新安装 / 老站自愈都拿不到这张表。
* 输出形式统一为 `CREATE TABLE IF NOT EXISTS `__PREFIX__xxx``,
* 与各 install.sql 现有写法一致(自愈类按 __PREFIX__ 占位符解析执行)。
*/
public function export()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
if (! $tables) {
$this->result->error('请选择要导出的表');
}
$map = $this->installMap();
$items = [];
foreach ($tables as $t) {
$table = $this->safeTable((string) $t);
if ($table === '' || ! $this->tableExists($table)) {
continue;
}
$sql = $this->exportSql($table);
if ($sql === '') {
continue;
}
$short = $this->shortName($table);
[$registered, $target] = $this->archiveInfo($short, $map);
$items[] = [
'table' => $table,
'short' => $short,
'registered' => $registered,
'target' => $target,
'sql' => $sql,
];
}
if (! $items) {
$this->result->error('没有可导出的表(表不存在或无法读取建表语句)');
}
$unregistered = array_values(array_filter($items, fn ($i) => ! $i['registered']));
AdminLogger::write('datatable/export', '导出安装 SQL:' . implode(', ', array_column($items, 'table')));
$this->result->success([
'items' => $items,
'text' => implode("\n\n", array_column($items, 'sql')),
'unregistered' => array_column($unregistered, 'short'),
], '已生成 ' . count($items) . ' 张表的安装 SQL');
}
/* ============================ 建表定义解析 ============================ */
/**
* 解析前端提交的建表参数(全部走白名单,非法直接抛错)
*
* @return array{table:string,comment:string,engine:string,charset:string,collation:string,fields:array}
* @throws \Throwable
*/
protected function buildDefinition(): array
{
$short = trim((string) $this->request->param('table', ''));
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $short)) {
throw new \InvalidArgumentException('表名不合法:只允许字母、数字、下划线,且以字母开头');
}
$table = $this->prefix() . $short;
if (strlen($table) > 64) {
throw new \InvalidArgumentException('表名过长(含前缀超过 64 字符)');
}
$engine = (string) $this->request->param('engine', 'InnoDB');
$charset = (string) $this->request->param('charset', $this->charset());
$collation = (string) $this->request->param('collation', '');
if (! in_array($engine, self::ENGINES, true)) {
throw new \InvalidArgumentException('不支持的存储引擎:' . $engine);
}
if (! in_array($charset, self::CHARSETS, true)) {
throw new \InvalidArgumentException('不支持的字符集:' . $charset);
}
if ($collation !== '' && ! in_array($collation, self::COLLATIONS, true)) {
throw new \InvalidArgumentException('不支持的排序规则:' . $collation);
}
$fields = [];
foreach ((array) $this->request->param('fields/a', []) as $f) {
$fields[] = $this->parseField((array) $f);
}
if (! $fields) {
throw new \InvalidArgumentException('至少需要一个字段');
}
// 字段名重复 / 多个自增列是 MySQL 会直接报错的情况,提前拦掉
$seen = [];
$auto = 0;
foreach ($fields as $f) {
if (isset($seen[$f['name']])) {
throw new \InvalidArgumentException('字段名重复:' . $f['name']);
}
$seen[$f['name']] = true;
if ($f['autoinc']) {
$auto++;
}
}
if ($auto > 1) {
throw new \InvalidArgumentException('只能有一个自增字段');
}
foreach ($fields as $f) {
// 自增列必须是主键(MySQL 硬性要求)
if ($f['autoinc'] && ! $f['pk']) {
throw new \InvalidArgumentException('自增字段「' . $f['name'] . '」必须同时设为主键');
}
}
return [
'table' => $table,
'comment' => trim((string) $this->request->param('comment', '')),
'engine' => $engine,
'charset' => $charset,
'collation' => $collation,
'fields' => $fields,
];
}
/**
* 解析单个字段
* @throws \Throwable
*/
protected function parseField(array $f): array
{
$name = trim((string) ($f['name'] ?? ''));
$type = strtolower(trim((string) ($f['type'] ?? '')));
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $name)) {
throw new \InvalidArgumentException('字段名不合法:' . ($name === '' ? '(空)' : $name));
}
if (! in_array($type, self::TYPES, true)) {
throw new \InvalidArgumentException('不支持的字段类型:' . $type);
}
// 长度:仅数值精度与字符长度允许,形如 10 或 10,2
$length = trim((string) ($f['length'] ?? ''));
if ($length !== '' && ! preg_match('/^\d{1,10}(,\d{1,4})?$/', $length)) {
throw new \InvalidArgumentException('字段「' . $name . '」的长度 / 精度不合法');
}
if ($length === '' && in_array($type, self::NEED_LEN, true)) {
$length = $type === 'varchar' ? '255' : ($type === 'decimal' ? '10,2' : '255');
}
if (! in_array($type, self::NEED_LEN, true)) {
$length = ''; // 整型带显示宽度在 MySQL 8 已废弃,统一不输出
}
// 默认值要区分「没传」和「传了空串」:空串是合法默认值(DEFAULT ''),
// 一律 trim 成 '' 会让 NOT NULL 的 varchar 拿不到默认值。
$hasDefault = array_key_exists('default', $f)
&& $f['default'] !== null
&& $f['default'] !== false;
$default = $hasDefault ? (string) $f['default'] : '';
$unsigned = ! empty($f['unsigned']) && in_array($type, self::NUMERIC, true);
$nullable = ! empty($f['nullable']);
$autoinc = ! empty($f['autoinc']) && in_array($type, self::NUMERIC, true)
&& ! in_array($type, ['decimal', 'float', 'double'], true);
$pk = ! empty($f['pk']);
// 自增列不能为空、不能有默认值
if ($autoinc) {
$nullable = false;
$default = '';
$hasDefault = false;
}
$index = strtolower(trim((string) ($f['index'] ?? '')));
if (! in_array($index, ['', 'index', 'unique'], true)) {
$index = '';
}
return [
'name' => $name,
'type' => $type,
'length' => $length,
'default' => $default,
'has_default' => $hasDefault,
'comment' => trim((string) ($f['comment'] ?? '')),
'unsigned' => $unsigned,
'nullable' => $nullable,
'autoinc' => $autoinc,
'pk' => $pk,
'index' => $index,
];
}
/**
* 生成 CREATE TABLE 语句
*/
protected function createSql(array $def): string
{
$lines = [];
$pks = [];
$keys = [];
foreach ($def['fields'] as $f) {
$type = $f['type'] . ($f['length'] !== '' ? '(' . $f['length'] . ')' : '');
$part = ' `' . $f['name'] . '` ' . $type;
if ($f['unsigned']) {
$part .= ' unsigned';
}
$part .= $f['nullable'] ? ' NULL' : ' NOT NULL';
// 默认值:NULL / CURRENT_TIMESTAMP / 数字字面量不加引号,其余按字符串处理
if ($f['has_default']) {
$d = strtoupper($f['default']);
if ($d === 'NULL' && $f['nullable']) {
$part .= ' DEFAULT NULL';
} elseif ($d === 'CURRENT_TIMESTAMP' || $d === 'CURRENT_TIMESTAMP()') {
$part .= ' DEFAULT CURRENT_TIMESTAMP';
} elseif ($this->isNumericLiteral($f['default'])) {
$part .= ' DEFAULT ' . $f['default'];
} else {
$part .= " DEFAULT '" . $this->escape($f['default']) . "'";
}
} elseif ($f['nullable']) {
$part .= ' DEFAULT NULL';
}
if ($f['autoinc']) {
$part .= ' AUTO_INCREMENT';
}
if ($f['comment'] !== '') {
$part .= " COMMENT '" . $this->escape($f['comment']) . "'";
}
$lines[] = $part;
if ($f['pk']) {
$pks[] = '`' . $f['name'] . '`';
}
if ($f['index'] === 'unique') {
$keys[] = ' UNIQUE KEY `' . $this->keyName('uk_' . $f['name']) . '` (`' . $f['name'] . '`)';
} elseif ($f['index'] === 'index') {
$keys[] = ' KEY `' . $this->keyName('idx_' . $f['name']) . '` (`' . $f['name'] . '`)';
}
}
if ($pks) {
$lines[] = ' PRIMARY KEY (' . implode(',', $pks) . ')';
}
$lines = array_merge($lines, $keys);
$tail = 'ENGINE=' . $def['engine'] . ' DEFAULT CHARSET=' . $def['charset'];
if ($def['collation'] !== '') {
$tail .= ' COLLATE=' . $def['collation'];
}
if ($def['comment'] !== '') {
$tail .= " COMMENT='" . $this->escape($def['comment']) . "'";
}
return 'CREATE TABLE `' . $def['table'] . "` (\n" . implode(",\n", $lines) . "\n) " . $tail . ';';
}
/* ============================ 内部方法 ============================ */
/**
* 全项目 install.sql 索引:[裸表名 => 相对项目根的 install.sql 路径]
*
* 覆盖三处:核心 public/install/install.sql、应用 app/{应用}/install.sql、
* 插件 addon/{插件}/install.sql。表名里的 __PREFIX__ 与写死的前缀都会去掉,
* 与实际的裸表名对齐。结果进程内缓存一次,避免每次列表都读一遍文件。
*/
protected function installMap(): array
{
if (self::$installMap !== null) {
return self::$installMap;
}
$root = rtrim((string) app()->getRootPath(), '/\\');
$files = array_merge(
[$root . '/public/install/install.sql'],
glob($root . '/app/*/install.sql') ?: [],
glob($root . '/addon/*/install.sql') ?: []
);
$map = [];
$prefix = $this->prefix();
foreach ($files as $file) {
if (! is_file($file) || ! is_readable($file)) {
continue;
}
$sql = file_get_contents($file);
if ($sql === false || $sql === '') {
continue;
}
if (! preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`([^`]+)`/i', $sql, $m)) {
continue;
}
$rel = ltrim(str_replace($root, '', $file), '/\\');
foreach ($m[1] as $table) {
$short = str_replace('__PREFIX__', '', $table);
if ($prefix !== '' && str_starts_with($short, $prefix)) {
$short = substr($short, strlen($prefix));
}
if (! isset($map[$short])) {
$map[$short] = $rel;
}
}
}
return self::$installMap = $map;
}
/**
* 归档状态:[是否已归档, 归档位置描述]
*
* 归档有两条合法路径,都要算「已归档」,否则会误报:
* 1. install.sql(核心 / 应用 / 插件)—— 安装与自愈都靠它;
* 2. 模型自愈 DDL(如 common_addonconf,见 BaseModel 注释)——
* 不在任何 install.sql 里,由模型 ensureSchema() 运行时补表。
*
* @param array $map installMap() 结果
* @return array{0:bool,1:string}
*/
protected function archiveInfo(string $short, array $map): array
{
if (isset($map[$short])) {
return [true, $map[$short]];
}
if ($this->selfHealed($short)) {
return [true, '模型自愈 DDL'];
}
return [false, $this->guessTarget($short)];
}
/**
* 是否被某个模型 / 服务的自愈 DDL 登记过
*
* 判定方式:模型目录里的 PHP 源码中出现该表名字符串。
* 属于宽松匹配(宁可算已归档,也不在列表里制造假警报),
* 精确事实源仍是各 install.sql 与模型 ensureSchema()。
*/
protected function selfHealed(string $short): bool
{
static $source = null;
if ($source === null) {
$root = rtrim((string) app()->getRootPath(), '/\\');
$dirs = array_merge(
[$root . '/ywxapp/model'],
glob($root . '/app/*/model') ?: [],
glob($root . '/addon/*/model') ?: []
);
$source = '';
foreach ($dirs as $dir) {
foreach (glob($dir . '/*.php') ?: [] as $file) {
$body = file_get_contents($file);
if ($body !== false) {
$source .= $body;
}
}
}
}
return $source !== '' && strpos($source, $short) !== false;
}
/**
* 单表导出语句:SHOW CREATE TABLE → __PREFIX__ 占位符 + IF NOT EXISTS
*
* 顺手去掉 AUTO_INCREMENT=当前值(自增值是运行时状态,不该进安装脚本)。
*/
protected function exportSql(string $table): string
{
try {
$rows = Db::query('SHOW CREATE TABLE `' . $table . '`');
} catch (\Throwable $e) {
return '';
}
$row = array_change_key_case((array) ($rows[0] ?? []), CASE_LOWER);
$sql = (string) ($row['create table'] ?? '');
if ($sql === '') {
return '';
}
$prefix = $this->prefix();
if ($prefix !== '') {
$sql = str_replace('`' . $prefix, '`__PREFIX__', $sql);
}
$sql = (string) preg_replace('/^CREATE TABLE `/i', 'CREATE TABLE IF NOT EXISTS `', $sql, 1);
$sql = (string) preg_replace('/AUTO_INCREMENT=\d+\s*/i', '', $sql);
return rtrim($sql, "; \t\n\r\0\x0B") . ';';
}
/**
* 未归档表的目标 install.sql 猜测(按表名前缀匹配模块)
*
* 猜不出来返回空串,由前端提示人工选择,绝不默认塞进核心 SQL。
*/
protected function guessTarget(string $short): string
{
$root = rtrim((string) app()->getRootPath(), '/\\');
foreach (glob($root . '/addon/*/install.sql') ?: [] as $file) {
$module = basename(dirname($file));
if ($module !== '' && str_starts_with($short, $module . '_')) {
return 'addon/' . $module . '/install.sql';
}
}
foreach (glob($root . '/app/*/install.sql') ?: [] as $file) {
$module = basename(dirname($file));
if ($module !== '' && str_starts_with($short, $module . '_')) {
return 'app/' . $module . '/install.sql';
}
}
return '';
}
/**
* 表名白名单过滤(去掉反引号等非法字符)
*/
protected function safeTable(string $name): string
{
$name = trim(str_replace('`', '', $name));
return preg_match('/^[a-zA-Z0-9_]{1,64}$/', $name) ? $name : '';
}
/**
* 表是否存在(参数绑定,防注入)
*/
protected function tableExists(string $table): bool
{
try {
return ! empty(Db::query('SHOW TABLES LIKE ?', [$table]));
} catch (\Throwable $e) {
return false;
}
}
/**
* 索引名(截断到 64 字符)
*/
protected function keyName(string $name): string
{
$name = preg_replace('/[^a-zA-Z0-9_]/', '_', $name) ?: 'idx';
return substr($name, 0, 64);
}
/**
* 默认值是否数字字面量(决定是否加引号)
*/
protected function isNumericLiteral(string $v): bool
{
return preg_match('/^-?\d+(\.\d+)?$/', $v) === 1;
}
/**
* 单引号转义(不依赖 mysql_real_escape_string,避免需要连接句柄)
*/
protected function escape(string $v): string
{
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
}
/**
* 表前缀
*/
protected function prefix(): string
{
try {
return (string) Db::connect()->getConfig('prefix');
} catch (\Throwable $e) {
return '';
}
}
/**
* 默认字符集
*/
protected function charset(): string
{
try {
$c = (string) Db::connect()->getConfig('charset');
} catch (\Throwable $e) {
$c = '';
}
return in_array($c, self::CHARSETS, true) ? $c : 'utf8mb4';
}
/**
* 去掉前缀的短表名
*/
protected function shortName(string $table): string
{
$prefix = $this->prefix();
return $prefix !== '' && str_starts_with($table, $prefix) ? substr($table, strlen($prefix)) : $table;
}
/**
* 字节数格式化
*/
protected function formatBytes(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB'];
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\backend\controller;
use app\backend\library\MenuRegistry;
/**
* 权限节点自愈(控制器侧入口)
*
* 核心后台的菜单就是 backend_power 里的一条记录。菜单定义集中在
* MenuRegistry(避免散落各控制器),本 trait 与 BackendSchemaMiddleware
* 共用同一个 MenuRegistry::ensure(),两处触发、一份定义:
* - 中间件:每个 /admin/* 请求补录 ⇒ 菜单「自己出现」,管理员不必手工加节点;
* - 控制器:直接访问页面时的双保险。
*
* 幂等 + 静默:已存在的节点不动(管理员可能改过名 / 排过序),
* 权限表不可用或没写权限时一律吞掉,不能阻断业务。
*
* @author ywxapp <admin@ywxapp.cn>
*/
trait EnsuresPower
{
/**
* 补录本应用的新增菜单与按钮节点
* @return void
*/
protected function ensureMenus(): void
{
MenuRegistry::ensure();
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\backend\controller;
use think\facade\Cookie;
use ywxapp\controller\BackendBase;
use ywxapp\service\HookService;
/**
* 模板钩子诊断(只读)
*
* 列出两件事,用于排查「插件启了但页面没输出」:
* 1. 模板里埋了哪些 hook() 点(扫描视图目录)
* 2. 每个点挂了什么(mount/register 可枚举;事件监听只能看到「有无」)
*/
class Hook extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = [];
public function index()
{
$points = HookService::points();
// 挂载按钩子名归并(视图里做数组下标访问不安全,这里先拼好)
$mounted = [];
foreach (HookService::mounted() as $item) {
$mounted[$item['name']][] = $item;
}
$rows = [];
foreach ($points as $name => $info) {
$rows[] = [
'name' => $name,
'files' => $info['files'],
'mounts' => $mounted[$name] ?? [],
];
}
$this->assign('rows', $rows);
$this->assign('hasListener', HookService::hasListener());
$this->assign('total', count($points));
$this->assign('devOn', HookService::devMode());
return $this->fetch();
}
/**
* 切换「钩点可视化标注」开关(Discuz 式开发者模式)
*
* 写 cookie hook_dev=1,仅在后台应用(backend)内生效,不落配置文件,
* 也不会随前台页面暴露给访客;全局模式请用 .env 的 HOOK_DEVELOPER=true。
* 访问:POST /backend/hook/devMode {on: 1|0}
*/
public function devMode()
{
$on = (int) $this->request->param('on', 0) === 1;
if ($on) {
Cookie::set(HookService::DEV_COOKIE, '1', ['expire' => 86400 * 7, 'path' => '/']);
} else {
Cookie::delete(HookService::DEV_COOKIE);
}
return json([
'code' => 0,
'msg' => $on
? '已开启:打开任意后台页面,hook() 埋点会显示蓝色虚线框与「⟡ 钩子名」标签'
: '已关闭钩点标注',
'data' => ['on' => $on],
]);
}
}
+4
View File
@@ -74,6 +74,10 @@ class Login extends BackendBase
$data = $this->request->param();
try {
validate(LoginValidate::class)->check($data);
// 注意:验证码已由 LoginValidate 的 'captcha' 规则校验;
// think-captcha 是一次性的(校验通过即删 session),
// 因此**不要**再把 captcha 传给 auth->login(),否则二次校验必失败、谁也登不上。
// auth->login() 内部的验证码校验服务于未走本验证器的入口(如 BackendBase::login)。
$this->auth->login($data['username'], $data['password']);
if ($this->auth->isLogin) {
$adminId = $this->auth->model->id ?? null;
+2 -2
View File
@@ -7,8 +7,8 @@ use think\facade\Db;
use think\db\exception\DbException;
use think\exception\ValidateException;
use ywxapp\controller\BackendBase;
use ywxapp\model\Notice as NoticeModel;
use app\backend\validate\CommonNotice as NoticeValidate;
use ywxapp\model\CommonNotice as NoticeModel;
use app\backend\validate\Notice as NoticeValidate;
/**
* 站点公告管理
+43 -7
View File
@@ -17,6 +17,7 @@ use app\backend\service\PowerService;
use app\backend\validate\Power as PowerValidate;
use think\exception\ValidateException;
use think\facade\Db;
use think\facade\Log;
use think\facade\View;
use think\Request;
use think\Response;
@@ -63,7 +64,7 @@ class Power extends BackendBase
public function index()
{
$page = $this->request->param('page', 1);
$size = $this->request->param('size', 100);
$size = $this->request->param('size',300);
if ($this->request->isAjax()) {
$data = PowerModel::cache(false)->paginate([
'page' => $page,
@@ -203,14 +204,49 @@ class Power extends BackendBase
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
$ids = is_array($ids) ? $ids : explode(',', (string) $ids);
$ids = array_values(array_filter(array_map('intval', $ids)));
if (empty($ids)) {
$this->result->error('参数错误:ids');
}
try {
Db::transaction(function () use ($ids, $force) {
if ($force) {
PowerModel::onlyTrashed()->whereIn('id', $ids)->select()->each(function ($item) {
$item->force()->delete();
});
} else {
PowerModel::destroy($ids);
// 取出待删行:软删取正常行,强制删取回收站行
$rows = $force
? PowerModel::onlyTrashed()->whereIn('id', $ids)->select()
: PowerModel::whereIn('id', $ids)->select();
if ($rows->isEmpty()) {
return;
}
// 防护①:核心后台菜单/权限节点(addon 为空)禁止删除。
// 这些节点是后台菜单树与权限校验的基础,误删会导致后台菜单消失、
// 权限节点丢失(历史上出现过整表被清空、登录后 menus 为空的事故)。
$coreNames = [];
foreach ($rows as $row) {
if (empty($row->addon)) {
$coreNames[] = (string) $row->name;
}
}
if ($coreNames) {
throw new \RuntimeException(
'核心后台菜单禁止删除(会导致后台菜单与权限节点丢失):' . implode('、', $coreNames)
);
}
// 防护②:删除前落快照日志,误删后可按日志恢复
Log::warning('[Power] 删除权限节点', [
'admin_id' => $this->auth->info['id'] ?? 0,
'account' => $this->auth->info['account'] ?? '',
'force' => (bool) $force,
'count' => count($rows),
'ip' => $this->request->ip(),
'snapshot' => $rows->toArray(),
]);
foreach ($rows as $item) {
$force ? $item->force()->delete() : $item->delete();
}
});
$this->result->success();
+12 -3
View File
@@ -116,7 +116,10 @@ class Profile extends BackendBase
*/
public function read($id)
{
$id = $id ?: ($this->auth->info['id'] ?? 0);
// 安全:个人资料只允许操作「本人」,忽略 URL 传入的 id。
// 原写法 $id ?: 当前登录ID 会让任意已登录管理员通过 /profile/update/1
// 读取并修改他人(含超级管理员)的资料与密码,属于水平越权 + 提权。
$id = (int) ($this->auth->info['id'] ?? 0);
$info = AdminModel::with(['roles'])->find($id);
if (! $info) {
$this->result->error('管理员不存在', 404);
@@ -129,7 +132,10 @@ class Profile extends BackendBase
*/
public function edit($id = null)
{
$id = $id ?: ($this->auth->info['id'] ?? 0);
// 安全:个人资料只允许操作「本人」,忽略 URL 传入的 id。
// 原写法 $id ?: 当前登录ID 会让任意已登录管理员通过 /profile/update/1
// 读取并修改他人(含超级管理员)的资料与密码,属于水平越权 + 提权。
$id = (int) ($this->auth->info['id'] ?? 0);
$info = AdminModel::with(['roles'])->find($id);
if (! $info) {
$this->result->error('管理员不存在', 404);
@@ -142,7 +148,10 @@ class Profile extends BackendBase
*/
public function update(Request $request, $id)
{
$id = $id ?: ($this->auth->info['id'] ?? 0);
// 安全:个人资料只允许操作「本人」,忽略 URL 传入的 id。
// 原写法 $id ?: 当前登录ID 会让任意已登录管理员通过 /profile/update/1
// 读取并修改他人(含超级管理员)的资料与密码,属于水平越权 + 提权。
$id = (int) ($this->auth->info['id'] ?? 0);
$info = AdminModel::find($id);
if (! $info) {
$this->result->error('管理员不存在', 404);
+61 -12
View File
@@ -8,6 +8,7 @@ use think\db\exception\DbException;
use think\exception\ValidateException;
use ywxapp\controller\BackendBase;
use ywxapp\model\MebmberScore as ScoreModel;
use ywxapp\service\ScoreService;
use app\backend\validate\Score as ScoreValidate;
/**
@@ -22,40 +23,83 @@ class Score extends BackendBase
{
$this->model = new ScoreModel();
// 触发 score_rule / score_log 自愈
ScoreModel::ensureSchema();
ScoreModel::ensureSchema();
}
// ============ 积分规则 ============
public function index()
{
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
$name = $this->request->param('name', '');
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
$title = $this->request->param('title', '');
$action = $this->request->param('action', '');
if ($this->request->isAjax()) {
$query = $this->model->newQuery();
if ($name !== '') {
$query->where('name', 'like', '%' . $name . '%');
if ($title !== '') {
$query->where('title', 'like', '%' . $title . '%');
}
if ($action !== '') {
$query->where('action', $action);
}
$list = $query->order('sort', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$typeList = ScoreModel::typeList();
$typeList = ScoreModel::typeList();
$limitTypeList = ScoreService::limitTypeList();
$items = $list->items();
foreach ($items as &$item) {
$item['type_text'] = $typeList[$item['type']] ?? '-';
$item['type_text'] = $typeList[$item['type']] ?? '-';
$item['limit_text'] = self::limitText($item, $limitTypeList);
$item['addon_text'] = empty($item['addon']) ? '核心' : $item['addon'];
}
$this->result->setCount($list->total())->success($items);
}
$this->assign('typeList', ScoreModel::typeList());
$this->assign('limitTypeList', ScoreService::limitTypeList());
$this->assign('uniqueTypeList', ScoreService::uniqueTypeList());
$this->assign('actionList', ScoreService::actions());
return $this->fetch();
}
/**
* 可用动作下拉数据(核心预置 + 插件注册 + 规则表已使用)
*/
public function actions()
{
$list = ScoreService::actions();
$data = [];
foreach ($list as $key => $item) {
$data[] = [
'action' => $key,
'title' => $item['title'],
'addon' => $item['addon'],
];
}
$this->result->success($data);
}
/**
* 限次文案:不限 / 每日 N 次 ...
*/
protected static function limitText(array $item, array $limitTypeList): string
{
$type = (int) ($item['limit_type'] ?? 0);
$times = (int) ($item['limit_times'] ?? 0);
if ($type === 0 || $times <= 0) {
return '不限';
}
return ($limitTypeList[$type] ?? '-') . ' ' . $times . ' 次';
}
public function save()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$params = $this->request->only(['name', 'action', 'type', 'value', 'sort', 'status'], 'post');
$params = $this->request->only(
['title', 'action', 'type', 'value', 'addon', 'limit_type', 'limit_times', 'unique_type', 'remark', 'sort', 'status'],
'post'
);
try {
validate(ScoreValidate::class)->check($params);
$this->model->create($params);
@@ -82,7 +126,10 @@ class Score extends BackendBase
if (! ($this->request->isAjax() && $this->request->isPut())) {
$this->result->error('请求方式错误', 405);
}
$params = $this->request->param();
$params = $this->request->only(
['id', 'title', 'action', 'type', 'value', 'addon', 'limit_type', 'limit_times', 'unique_type', 'remark', 'sort', 'status'],
'param'
);
$id = $params['id'] ?? null;
$info = $this->model->find($id);
if (! $info) {
@@ -170,10 +217,12 @@ class Score extends BackendBase
{
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
$uid = $this->request->param('uid/d', 0);
$uid = $this->request->param('uid/d', 0);
$action = $this->request->param('action', '');
$source = $this->request->param('source', '');
if ($this->request->isAjax()) {
$res = ScoreModel::logList($uid, $page, $limit);
$res = ScoreModel::logList($uid, $page, $limit, ['action' => $action, 'source' => $source]);
$typeList = ScoreModel::typeList();
foreach ($res['list'] as &$item) {
$item['type_text'] = $item['type'] == 2 ? '支出' : '收入';
-158
View File
@@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
namespace app\backend\controller;
use think\Request;
use think\facade\Db;
use think\db\exception\DbException;
use think\exception\ValidateException;
use ywxapp\controller\BackendBase;
use ywxapp\model\CommonShop as ShopModel;
use app\backend\validate\Shop as ShopValidate;
/**
* 电子商务管理
*/
class Shop extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = [];
protected function initialize()
{
$this->model = new ShopModel();
}
public function index()
{
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
$title = $this->request->param('title', '');
if ($this->request->isAjax()) {
$query = $this->model->newQuery();
if ($title !== '') {
$query->where('title', 'like', '%' . $title . '%');
}
$list = $query->order('sort', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($list->total())->success($list->items());
}
return $this->fetch();
}
public function save()
{
if (! $this->request->isPost()) {
$this->result->error('请求方式错误', 405);
}
$params = $this->request->only(['title', 'price', 'stock', 'description', 'sort', 'status'], 'post');
try {
validate(ShopValidate::class)->check($params);
$this->model->create($params);
$this->result->success('', '添加成功');
} catch (ValidateException $e) {
$this->result->error($e->getMessage());
} catch (DbException $e) {
$this->result->error('添加失败: ' . $e->getMessage(), 2);
}
}
public function edit($id = null)
{
$id = $id ?: $this->request->param('id');
$info = $this->model->find($id);
if (! $info) {
$this->result->error('记录不存在', 404);
}
$this->result->success(['info' => $info]);
}
public function update()
{
if (! ($this->request->isAjax() && $this->request->isPut())) {
$this->result->error('请求方式错误', 405);
}
$params = $this->request->param();
$id = $params['id'] ?? null;
$info = $this->model->find($id);
if (! $info) {
$this->result->error('记录不存在', 404);
}
$statusOnly = isset($params['status'])
&& count(array_diff(array_keys($params), ['id', 'status'])) === 0;
if (! $statusOnly) {
try {
validate(ShopValidate::class)->check($params);
} catch (ValidateException $e) {
$this->result->error($e->getMessage());
}
}
$info->save($params);
$this->result->success($info, '更新成功');
}
public function recyclebin()
{
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
if ($this->request->isAjax()) {
$list = $this->model->onlyTrashed()
->order('sort', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($list->total())->success($list->items());
}
return $this->fetch('shop/index');
}
public function delete()
{
if ($this->request->isAjax() && $this->request->isDelete()) {
$ids = $this->request->param('ids', '');
$force = $this->request->param('force', false);
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
$idsArray = array_filter(explode(',', $ids), 'is_numeric');
if (empty($idsArray)) {
$this->result->error('参数错误');
}
try {
Db::transaction(function () use ($idsArray, $force) {
if ($force) {
$this->model->onlyTrashed()->whereIn('id', $idsArray)->select()
->each(function ($item) { $item->force()->delete(); });
} else {
$this->model->destroy($idsArray);
}
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Exception $e) {
$this->result->error('删除失败: ' . ($e->getMessage() ?: $e->__toString()), 500);
}
}
}
public function restore($ids = '')
{
if ($this->request->isAjax() && $this->request->isPost()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->result->error('请选择要恢复的数据');
}
$idsArray = array_filter(explode(',', $ids), 'is_numeric');
Db::startTrans();
try {
$this->model->withTrashed()->where('id', 'in', $idsArray)->select()
->each(function ($item) { $item->restore(); });
Db::commit();
$this->result->success('恢复成功');
} catch (DbException $e) {
Db::rollback();
$this->result->error('恢复失败: ' . $e->getMessage());
}
}
}
}
+215 -18
View File
@@ -13,6 +13,8 @@ namespace app\backend\controller;
use think\facade\Db;
use ywxapp\controller\BackendBase;
use ywxapp\library\SpiderDetect;
use ywxapp\library\SpiderVerify;
use ywxapp\model\BaseModel;
/**
* 搜索蜘蛛统计
@@ -20,12 +22,26 @@ use ywxapp\library\SpiderDetect;
* 数据来源:全局中间件 ywxapp\middleware\SpiderStat 写入的
* spider_log(明细)与 spider_stat(按日聚合)两张表。
*
* 真伪识别:UA 只是「声明」,SpiderVerify 通过 rDNS 双向验证给出结论,
* verified 字段:0 待验证 / 1 真蜘蛛 / 2 假蜘蛛(伪造 UA)。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Spider extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = [];
// recheck 为幂等统计操作(只重新判定真伪并同步聚合,不增删业务数据),
// 免权限项校验,避免老库未注册 spider:recheck 权限时 403。
protected $noNeedVerify = ['recheck'];
/**
* 真伪文案
*/
public const VERIFY_TEXT = [
0 => '待验证',
1 => '真蜘蛛',
2 => '假蜘蛛',
];
/**
* 抓取明细列表(页面 + Ajax)
@@ -33,12 +49,13 @@ class Spider extends BackendBase
public function index()
{
if ($this->request->isAjax()) {
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
$spider = $this->request->param('spider', '');
$ip = $this->request->param('ip', '');
$url = $this->request->param('url', '');
$date = $this->request->param('date', ''); // YYYY-MM-DD
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 15);
$spider = $this->request->param('spider', '');
$ip = $this->request->param('ip', '');
$url = $this->request->param('url', '');
$date = $this->request->param('date', ''); // YYYY-MM-DD
$verified = $this->request->param('verified', ''); // ''全部 0待验证 1真 2假
$query = Db::name('common_spider_log');
if ($spider !== '') {
@@ -53,6 +70,9 @@ class Spider extends BackendBase
if ($date !== '' && ($ts = strtotime($date)) !== false) {
$query->whereBetween('create_at', [$ts, $ts + 86399]);
}
if ($verified !== '' && in_array((string) $verified, ['0', '1', '2'], true)) {
$query->where('verified', (int) $verified);
}
try {
$list = $query->order('id', 'desc')
@@ -62,6 +82,13 @@ class Spider extends BackendBase
foreach ($items as &$item) {
$item['spider_text'] = $labels[$item['spider']] ?? $item['spider'];
$item['time_text'] = date('Y-m-d H:i:s', (int) $item['create_at']);
$v = (int) ($item['verified'] ?? 0);
$item['verified'] = $v;
$item['verified_text'] = self::VERIFY_TEXT[$v] ?? '待验证';
$item['rdns'] = (string) ($item['rdns'] ?? '');
// 真伪校验依赖 UA 声明的蜘蛛是否支持 rDNS
$item['verifiable'] = SpiderDetect::verifiable((string) $item['spider']);
}
unset($item);
$this->result->setCount($list->total())->success($items);
@@ -71,11 +98,14 @@ class Spider extends BackendBase
}
}
$this->assign('verifyText', self::VERIFY_TEXT);
$this->assign('dnsDown', SpiderVerify::dnsDown());
$this->assign('spiderLabels', SpiderDetect::labels());
return $this->fetch();
}
/**
* 汇总统计:各蜘蛛 今日/7日/30日/总量 + 近30日逐日趋势(图表用)
* 汇总统计:各蜘蛛 今日/7日/30日/总量 + 真伪拆分 + 近30日逐日趋势(图表用)
*/
public function stats()
{
@@ -86,14 +116,18 @@ class Spider extends BackendBase
$summary = [];
$trend = ['dates' => [], 'series' => []];
$totals = ['count' => 0, 'fake' => 0];
try {
// 老库兜底:确保聚合表有 fake_count 列
$this->ensureStatColumns();
$rows = Db::name('common_spider_stat')
->where('stat_date', '>=', $d30)
->select()
->toArray();
$totalRows = Db::name('common_spider_stat')
->field('spider, SUM(`count`) AS total')
->field('spider, SUM(`count`) AS total, SUM(`fake_count`) AS fakes')
->group('spider')
->select()
->toArray();
@@ -101,27 +135,41 @@ class Spider extends BackendBase
// 各蜘蛛汇总
$map = [];
foreach ($totalRows as $r) {
$total = (int) $r['total'];
$fake = (int) ($r['fakes'] ?? 0);
$map[$r['spider']] = [
'spider' => $r['spider'],
'label' => $labels[$r['spider']] ?? $r['spider'],
'today' => 0,
'week' => 0,
'month' => 0,
'total' => (int) $r['total'],
'spider' => $r['spider'],
'label' => $labels[$r['spider']] ?? $r['spider'],
'verifiable' => SpiderDetect::verifiable((string) $r['spider']),
'today' => 0,
'week' => 0,
'month' => 0,
'total' => $total,
'fake' => $fake,
'real' => max(0, $total - $fake),
'fake_today' => 0,
'fake_week' => 0,
'fake_month' => 0,
];
$totals['count'] += $total;
$totals['fake'] += $fake;
}
foreach ($rows as $r) {
$key = $r['spider'];
if (! isset($map[$key])) {
if (!isset($map[$key])) {
continue;
}
$c = (int) $r['count'];
$f = (int) ($r['fake_count'] ?? 0);
$map[$key]['month'] += $c;
$map[$key]['fake_month'] += $f;
if ($r['stat_date'] >= $d7) {
$map[$key]['week'] += $c;
$map[$key]['fake_week'] += $f;
}
if ($r['stat_date'] === $today) {
$map[$key]['today'] += $c;
$map[$key]['fake_today'] += $f;
}
}
usort($map, fn ($a, $b) => $b['total'] <=> $a['total']);
@@ -149,7 +197,116 @@ class Spider extends BackendBase
// 表未创建:返回空数据
}
$this->result->success(['summary' => $summary, 'trend' => $trend]);
$this->result->success([
'summary' => $summary,
'trend' => $trend,
'totals' => [
'count' => $totals['count'],
'fake' => $totals['fake'],
'real' => max(0, $totals['count'] - $totals['fake']),
],
'dns_down' => SpiderVerify::dnsDown(),
]);
}
/**
* 重新校验历史记录的真伪
*
* 适用于:刚升级到带真伪识别的版本时,把历史「待验证」记录补校验一遍;
* 或某蜘蛛统计异常时手动重算。只处理明细中仍在保留期内的记录,
* 校验后同步修正当日聚合表。
*/
public function recheck()
{
if (!($this->request->isAjax() && $this->request->isPost())) {
$this->result->error('请求方式错误', 405);
}
$limit = (int) $this->request->param('limit/d', 200);
$limit = max(1, min(500, $limit));
try {
$this->ensureLogColumns();
// 取「待验证」的日志,按 IP 去重后逐条校验(同一 IP 只查一次 DNS)
$rows = Db::name('common_spider_log')
->field('spider, ip')
->where('verified', 0)
->order('id', 'desc')
->limit(3000)
->select()
->toArray();
} catch (\Throwable $e) {
$this->result->error('读取日志失败:' . $e->getMessage());
}
$pairs = [];
foreach ($rows as $r) {
$spider = (string) $r['spider'];
$ip = (string) $r['ip'];
if ($ip === '' || !SpiderDetect::verifiable($spider)) {
continue;
}
$pairs[$spider . '|' . $ip] = [$spider, $ip];
if (count($pairs) >= $limit) {
break;
}
}
if (empty($pairs)) {
$this->result->success(
['real' => 0, 'fake' => 0, 'pending' => 0, 'scanned' => 0],
'没有需要重新校验的记录(或该蜘蛛不支持 rDNS 验证)'
);
}
$real = $fake = $pending = 0;
$touched = [];
foreach ($pairs as [$spider, $ip]) {
SpiderVerify::forget($ip, $spider);
$verdict = SpiderVerify::verify($ip, $spider);
if ((int) $verdict['status'] === 0) {
$pending++;
continue;
}
Db::name('common_spider_log')
->where('spider', $spider)
->where('ip', $ip)
->update([
'verified' => (int) $verdict['status'],
'rdns' => (string) $verdict['rdns'],
]);
$touched[$spider] = true;
if ((int) $verdict['status'] === 1) {
$real++;
} else {
$fake++;
}
}
// 同步修正聚合表(只重算有明细的日期,历史聚合保留)
foreach (array_keys($touched) as $spider) {
$this->resyncStat((string) $spider);
}
$msg = "校验完成:真蜘蛛 {$real} 个 IP,假蜘蛛 {$fake} 个 IP";
if ($pending > 0) {
$msg .= ",{$pending} 个 IP 因 DNS 不可用暂无法判定(稍后可重试)";
}
if (SpiderVerify::dnsDown()) {
$msg .= '。注意:DNS 查询连续失败已暂时降级,请检查服务器 DNS 配置。';
}
$this->result->success([
'real' => $real,
'fake' => $fake,
'pending' => $pending,
'scanned' => count($pairs),
], $msg);
}
/**
@@ -157,7 +314,7 @@ class Spider extends BackendBase
*/
public function clear()
{
if (! ($this->request->isAjax() && $this->request->isPost())) {
if (!($this->request->isAjax() && $this->request->isPost())) {
$this->result->error('请求方式错误', 405);
}
$days = $this->request->param('days/d', 30);
@@ -171,4 +328,44 @@ class Spider extends BackendBase
$this->result->error('清理失败: ' . $e->getMessage());
}
}
/**
* 按 spider 重算聚合表(只覆盖明细仍在的日期,避免抹掉历史累计)
*/
protected function resyncStat(string $spider): void
{
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
$log = $prefix . 'common_spider_log';
$stat = $prefix . 'common_spider_stat';
Db::execute(
"INSERT INTO `{$stat}` (`stat_date`, `spider`, `count`, `fake_count`) "
. "SELECT DATE(FROM_UNIXTIME(`create_at`)) AS d, `spider`, COUNT(*), SUM(`verified` = 2) "
. "FROM `{$log}` WHERE `spider` = ? "
. 'GROUP BY d, `spider` '
. 'ON DUPLICATE KEY UPDATE `count` = VALUES(`count`), `fake_count` = VALUES(`fake_count`)',
[$spider]
);
}
/**
* 老库兜底:明细表补 verified / rdns 列
*/
protected function ensureLogColumns(): void
{
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
$log = $prefix . 'common_spider_log';
BaseModel::ensureColumn($log, 'verified', "tinyint(1) NOT NULL DEFAULT '0' COMMENT '真伪:0待验证 1真蜘蛛 2假蜘蛛'");
BaseModel::ensureColumn($log, 'rdns', "varchar(255) NOT NULL DEFAULT '' COMMENT 'rDNS反查主机名'");
}
/**
* 老库兜底:聚合表补 fake_count 列
*/
protected function ensureStatColumns(): void
{
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
$stat = $prefix . 'common_spider_stat';
BaseModel::ensureColumn($stat, 'fake_count', "int unsigned NOT NULL DEFAULT '0' COMMENT '其中假蜘蛛次数'");
}
}
+50
View File
@@ -15,6 +15,8 @@ use Exception;
use ywxapp\controller\BackendBase;
use ywxapp\library\TemplateManager;
use ywxapp\library\TemplateInstaller;
use ywxapp\library\SiteTheme;
use ywxapp\library\TemplateMenu;
/**
* 模板管理(核心后台内置模块,参考 Discuz! 后台「界面」分类)。
@@ -327,4 +329,52 @@ class Template extends BackendBase
return $this->result->error($e->getMessage());
}
}
/**
* 站点 layui 配色(整站主题变量 config/theme.php)。
*
* 与分组级皮肤的区别:这里控制的是 layui 组件的底色(按钮/表单/分页/导航…),
* 对后台、会员中心、前台与全部插件前台一律生效(由 ThemeInject 中间件注入)。
*
* GET (AJAX) → 返回当前变量;POST → 保存并即时全站生效。
*/
public function siteTheme()
{
try {
if ($this->request->isPost()) {
$raw = input('vars/a', []);
if (! is_array($raw)) {
return $this->result->error('参数格式错误');
}
// enable 是开关,不参与 CSS 变量白名单校验
if (array_key_exists('enable', $raw)) {
SiteTheme::setEnabled(! empty($raw['enable']) && $raw['enable'] !== 'false');
unset($raw['enable']);
}
$saved = SiteTheme::save($raw);
TemplateManager::clearOverlayCache();
return $this->result->success(['saved' => $saved], '已保存,全站立即生效');
}
return $this->result->success([
'vars' => SiteTheme::raw(),
'enable' => SiteTheme::isEnabled(),
'menu_ok' => TemplateMenu::exists(),
], '获取成功');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 补建「界面」菜单入口(幂等)。
* 模板中心/界面设置是内置页面,但老站点可能没有菜单入口,管理员点一次即可修复。
*/
public function syncMenu()
{
$res = TemplateMenu::sync();
return $res['ok']
? $this->result->success(['created' => $res['created']], $res['message'])
: $this->result->error($res['message']);
}
}