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

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
<?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 ywxapp\library;
use think\App;
use think\exception\HttpException;
use think\facade\Db;
use think\facade\Event;
use think\facade\Validate;
use ywxapp\library\Result;
use ywxapp\model\BackendAdmin as AdminModel;
use ywxapp\service\JwtService;
use ywxapp\utils\Random;
/**
* 后台(管理员)鉴权类,继承前台 Auth,仅重写差异钩子与业务逻辑。
*
* 差异点:
* - 模型:Backend 模型(主键 id
* - 角色字段:roles;权限集:getPermissionNames()
* - 登录事件:user_login_successed
* - 登出:不清整个 session(仅清 token),保持与原 Backend 行为一致
* - 登录/注册/改密:后台业务逻辑(查 Backend 表)
*/
class AdminAuth extends Auth
{
/**
* 超级管理员
*/
protected $isSuperAdmin = false;
/**
* 超级管理员
*/
private $superAdmin = 1;
/**
* 允许查询的字段(后台)
* @var array
*/
protected $allowFields = ['id', 'account', 'nickname', 'roles', 'avatar', 'score'];
/**
* 构造方法
* @param App $app
*/
public function __construct(App $app, $options = [])
{
parent::__construct($app, $options);
$this->isAdmin = true; // 标记后台管理员上下文,供 tryLoginByToken 做 isAdmin 一致性校验
}
/**
* 返回管理员模型类(主键为 id)
*/
protected function getUserModel(): string
{
return AdminModel::class;
}
/**
* 后台角色取自 roles 字段(前台 Auth 默认取 groups,此处重写)
*/
protected function resolveRoles($info)
{
return $info->roles ?? [];
}
/**
* 管理员权限取自模型方法(后台式权限集)
*/
protected function resolvePowers($info): array
{
return $info->getPermissionNames();
}
/**
* 管理员不存在时的错误提示
*/
protected function notFoundMessage($id): string
{
return "AdminId:$id is incorrect";
}
/**
* 管理员登录成功后触发事件
*/
protected function afterLogin($info): void
{
Event::trigger('user_login_successed', $this->info);
}
/**
* 后台登出:仅清 token(不清整个 session),保持与原 Backend 行为一致
*/
protected function afterLogout(): void {}
/**
* 添加管理员(后台).
*
* @param string $username 用户名
* @param string $password 密码
* @param array $extend 扩展参数
* @return void
*/
public function register($account = '', $password = '', $email = '', $mobile = '', $extend = [])
{
AdminModel::ensureSchema(); // 后台核心表自愈(缺表/缺列兜底)
if (AdminModel::getByAccount($account)) {
Result::instance()->error('Account already exist');
}
$data = [
'password' => password_hash($password ? $password : Random::alpha(6), PASSWORD_DEFAULT, ['cost' => 12]),
'status' => 0,
't1' => $account,
];
$field = Validate::is($account, 'email') ? 'email' : (Validate::is($account, 'mobile') ? 'mobile' : 'account');
$data[$field] = $account;
$data = array_merge($data, $extend);
$params = Event::trigger('AdminBeforeRegister', $data, true);
$data = array_merge($data, $params);
Db::startTrans();
try {
$info = AdminModel::create($data);
$this->info = AdminModel::find($info->id);
Event::trigger('AdminAfterRegister', $this->info);
Db::commit();
$newClaims = [
'uid' => $info->id,
'isAdmin' => true,
'account' => $info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
} catch (\think\Exception $e) {
Db::rollback();
Result::instance()->error($e->getMessage());
}
}
/**
* 管理员登录(后台).
*
* @param string $account 账号,用户名、邮箱、手机号
* @param string $password 密码
* @return void
*/
public function login($account, $password, $isAuthPass = true)
{
AdminModel::ensureSchema(); // 后台核心表自愈(缺表/缺列兜底)
$field = Validate::is($account, 'email') ? 'email' : (Validate::regex(
$account,
'/^1\d{10}$/'
) ? 'mobile' : 'account');
$info = AdminModel::where([$field => $account])->find();
if (! $info) {
Result::instance()->error('Account is incorrect');
}
if ($info->status != 1) {
Result::instance()->error('Account is locked');
}
$info->resetPassword($password);
// 验证密码(直接使用库中存储的哈希,禁止先 resetPassword
if (! $info->checkPassword($password)) {
$info->recordLoginFail($this->app->request->ip()); // 记录失败
Result::instance()->error('密码错误', 4011);
}
$info->recordLoginSuccess();
$newClaims = [
'uid' => $info->id,
'isAdmin' => true,
'role' => 'admin',
'account' => $info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
$this->initUser($info->id);
}
/**
* 修改密码(后台)
* @param string $newpassword 新密码
* @param string $oldpassword 旧密码
* @param bool $ignoreoldpassword 忽略旧密码
* @return boolean
*/
public function changepwd($newpassword, $oldpassword = '', $ignoreoldpassword = false)
{
if (! $this->_logined) {
$this->setError('You are not logged in');
return false;
}
//判断旧密码是否正确
if ($this->_user->password == $this->getEncryptPassword($oldpassword, $this->_user->salt) || $ignoreoldpassword) {
Db::startTrans();
try {
$salt = Random::alnum();
$newpassword = $this->getEncryptPassword($newpassword, $salt);
$this->_user->save(['loginfailure' => 0, 'password' => $newpassword, 'salt' => $salt]);
Token::clear($this->_user->uid);
//修改密码成功的事件
Hook::listen("user_changepwd_successed", $this->_user);
Db::commit();
} catch (Exception $e) {
Db::rollback();
$this->setError($e->getMessage());
return false;
}
return true;
} else {
$this->setError('Password is incorrect');
return false;
}
}
}
+671
View File
@@ -0,0 +1,671 @@
<?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 ywxapp\library;
use think\App;
use think\exception\HttpException;
use think\facade\Cookie;
use think\facade\Db;
use think\facade\Session;
use think\facade\Validate;
use ywxapp\library\Result;
use ywxapp\model\BackendAdmin;
use ywxapp\model\MemberUser as UserModel;
use ywxapp\service\JwtService;
use ywxapp\utils\Random;
/**
* 前台(会员/API)鉴权类
*
* 负责会员中心的认证:按 Member 表(主键 uid)查用户、解析会员角色/权限,
* 以及登录/注册/改密等前台业务逻辑。
* 后台鉴权由 AdminAuth 继承本类,仅重写差异钩子(不同模型、权限集、登录事件)。
*/
class Auth
{
protected $app;
/**
* 是否登录
* @var bool
*/
protected $isLogin = false;
/**
* 是否管理员?
*/
protected $isAdmin = false;
/**
* 当前登录用户信息
* @var \ywxapp\model\Member
*/
protected $model;
/**
* 个人信息
* @var \ywxapp\model\Member
*/
protected $info;
/**
* 权限规则
* @var array
*/
protected $powers;
/**
* 角色
* @var array
*/
protected $roles;
/**
* 用户资料,用于临时存放一些用户数据,比如用户IP地址,需要使用后销毁
* @var array
*/
protected $profile;
/**
* 允许查询的字段(前台)
* @var array
*/
protected $allowFields = ['uid', 'account', 'nickname', 'roles', 'avatar', 'score'];
/**
* 构造方法
* @param App $app
*/
public function __construct(App $app, $options = [])
{
$this->app = $app;
}
/**
* 尝试用 access_token 解析并登录(对外可独立调用,如中间件)。
*
* 设计原则:**有 token 才解析并登录,没有 token 则静默跳过**(不报错、不登录),
* 从而天然支持「访客访问无需登录的页面」与「携带 token 自动登录」两种场景。
*
* @return bool 是否成功还原登录态
*/
public function tryInitByToken(): bool
{
// 已登录则直接返回,避免重复解析与重复 initUser
if ($this->isLogin) {
return true;
}
$tokenStr = $this->resolveAccessToken();
if (empty($tokenStr)) {
// 没有 token:不解析、不登录
return false;
}
try {
$claims = $this->parseClaims($tokenStr);
// 上下文一致性校验:token 的 isAdmin 声明必须与当前 auth 类型匹配
// (前台 isAdmin=false / 后台 AdminAuth isAdmin=true),防止前后台 token 串用越权。
if (($claims['isAdmin'] ?? false) !== $this->isAdmin) {
throw new \Exception('auth context mismatch');
}
$this->initUser($claims['uid']);
return true;
} catch (\Exception $e) {
// token 无效/过期:清除解析缓存
\think\facade\Cache::delete('auth_token_' . md5($tokenStr));
// 尝试用 refresh_token 自动续期(整页/iframe 场景也能续期,避免被踢回登录页)
$this->tryRefresh();
return $this->isLogin;
}
}
/**
* 登录态校验(供后台/前台/会员中心控制器层 verifyAuth 统一调用)
* 先尝试用 token 还原登录态,再返回是否登录。
*/
public function checkLogin(): bool
{
$this->tryInitByToken();
return (bool) $this->isLogin;
}
/**
* 鉴权前置:尝试用 token 还原登录态(兼容旧调用点 verifyAuth / checkLogin)。
*/
protected function tokenParse()
{
$this->tryInitByToken();
}
/**
* 初始化登录用户(模板方法)
* 公共流程:按主键查模型 → 登录连续性统计 → 解析角色/权限 → 标记登录态 → 触发事件。
* 与具体模型/角色/权限相关、前后台不同的部分,下沉到下方 protected 钩子,
* 从而天然适配前后台不同的主键(admin=id / user=uid)与不同的权限体系。
*
* @param mixed $id 用户主键(admin 为 iduser 为 uid
* @return static
*/
public function initUser($id)
{
$modelClass = $this->getUserModel();
$info = $modelClass::findOrEmpty($id);
if ($info->isEmpty()) {
Result::instance()->error($this->notFoundMessage($id));
}
Db::startTrans();
try {
if ($info->update_at < \ywxapp\utils\Date::unixtime('day')) {
$info->successions = $info->logintime < \ywxapp\utils\Date::unixtime(
'day',
-1
) ? 1 : $info->successions + 1;
$info->maxsuccessions = max($info->successions, $info->maxsuccessions);
}
$info->save();
$this->roles = $this->resolveRoles($info);
$this->powers = $this->resolvePowers($info);
$this->isLogin = true;
$this->info = $info->toArray();
$this->model = $info;
$this->afterLogin($info);
Db::commit();
} catch (\think\Exception $e) {
Db::rollback();
throw new HttpException(4001, $e->getMessage());
}
return $this;
}
/**
* 返回当前上下文的用户模型类(核心 PK 适配钩子)。
* 前台(Auth)默认 Member 模型(主键 uid);后台(AdminAuth)重写为 Backend 模型(主键 id)。
*/
protected function getUserModel(): string
{
return UserModel::class;
}
/**
* 根据模型实例解析角色集合。前台会员角色取自 groups 字段;后台在 AdminAuth 重写为 roles。
*/
protected function resolveRoles($info)
{
return $info->groups ?? [];
}
/**
* 根据模型实例解析权限集。前台会员默认无后台式权限集,由控制器 noNeedVerify 控制访问。
*/
protected function resolvePowers($info): array
{
return [];
}
/**
* 用户不存在时的错误提示
*/
protected function notFoundMessage($id): string
{
return "UserId:$id is incorrect";
}
/**
* 登录成功后的事件钩子(前台触发 UserLogined
*/
protected function afterLogin($info): void
{
event('UserLogined', $this->info);
}
/**
* 退出登录后的钩子。前台会员:清空整个 session 并触发退出事件(由 Auth::logout 调用)。
* 后台 AdminAuth 重写为空(仅清 token,保持与原 Backend 行为一致)。
*/
protected function afterLogout(): void
{
\think\facade\Session::clear();
event('user_logout_after', $this->model);
}
/**
* 解析 access_token 的 claims(带缓存)
* @param string $tokenStr
* @return array
*/
protected function parseClaims(string $tokenStr): array
{
$cacheKey = 'auth_token_' . md5($tokenStr);
$claims = \think\facade\Cache::get($cacheKey);
if (!$claims) {
$token = JwtService::instance()->parseAndValidate($tokenStr);
$claims = $token->claims()->all();
\think\facade\Cache::set($cacheKey, $claims, 300);
}
return $claims;
}
/**
* 从 header / Session / Cookie 中解析当前 access_token
*/
protected function resolveAccessToken(): string
{
$authHeader = $this->app->request->header('authorization');
if (!empty($authHeader) && str_starts_with($authHeader, 'Bearer ')) {
return (string) substr($authHeader, 7);
}
$token = Session::get('access_token');
if (!empty($token)) {
return (string) $token;
}
return (string) \think\facade\Cookie::get('access_token');
}
/**
* 解析可用于续期的 refresh_tokenheader / Session / Cookie
*/
protected function resolveRefreshToken(): string
{
$authHeader = $this->app->request->header('authorization_refresh');
if (!empty($authHeader) && str_starts_with($authHeader, 'Bearer ')) {
return (string) substr($authHeader, 7);
}
$token = Session::get('refresh_token');
if (!empty($token)) {
return (string) $token;
}
return (string) \think\facade\Cookie::get('refresh_token');
}
/**
* 使用 refresh_token 自动续期 access_token。
* 用于整页/iframe 等非 ajax 场景:access_token 过期但 refresh_token 仍有效时,
* 后端透明生成新 access_token 并写回 Session / Cookie,避免子页面被踢回登录页。
*/
protected function tryRefresh(): void
{
$refreshToken = $this->resolveRefreshToken();
if (empty($refreshToken)) {
return;
}
try {
$data = JwtService::instance()->refreshAccessToken($refreshToken);
$newToken = $data['access_token'] ?? '';
if (empty($newToken)) {
return;
}
Session::set('access_token', $newToken);
// 同步写入非 httpOnly cookie,便于前端与 iframe 后续请求携带
\think\facade\Cookie::set('access_token', $newToken, ['httponly' => false, 'path' => '/']);
$claims = JwtService::instance()->parseAndValidate($newToken)->claims()->all();
// 续期得到的 token 也必须与当前上下文一致,否则视为无效
if (($claims['isAdmin'] ?? false) !== $this->isAdmin) {
return;
}
$this->initUser($claims['uid']);
} catch (\Exception $e) {
// 续期失败,保持未登录,交由 verifyAuth 返回 401
}
}
/**
* 用户权限验证
*/
public function verifyAuth($noNeedLogin = [], $noNeedRight = [])
{
// 先尝试用 token 还原登录态:登录态由 tryInitByToken 写入 $this->isLogin
// 必须由本方法自行触发,不能依赖外部(核心后台靠容器单例的历史初始化、
// 插件后台 new AdminAuth 全新实例都不可靠,会导致 isLogin 恒为默认 false 而误踢登录)。
$this->tokenParse();
$request = app()->request;
$action = strtolower($request->action());
if (in_array('*', $noNeedLogin) || in_array($action, $noNeedLogin)) {
return true;
}
// 规范化权限集合,避免 initUser 未赋值导致 in_array() 报 TypeError
$this->powers = $this->powers ?? [];
if ($this->isLogin == false) {
// 整页(非 AJAX)请求未登录:直接 302 跳转到登录页;
// AJAX 请求返回 401 JSON,交给前端 kernel.js 跳转,避免整页被踢。
if (! $request->isAjax() && ! $request->isPjax()) {
// 直接发送 302 并终止,确保构造期(_initialize 中调用)也能可靠跳转
$this->redirectToLogin();
}
Result::instance()->setStatusCode(401)->error(lang('Please login first'));
}
// 超级管理员直接放行(避免权限数据缺失时锁定后台)
if (! empty($this->model) && ! empty($this->model->id)
&& $this->model->id == config('ywxapp.superAdmin', 1)) {
return true;
}
// 权限集合含通配符 * 表示拥有全部权限(如超级管理员角色),直接放行
if (in_array('*', $this->powers, true)) {
return true;
}
if (in_array('*', $noNeedRight) || in_array($action, $noNeedRight)) {
return true;
}
$controller = strtolower($request->controller());
$path = str_replace('.', ':', $controller) . ':' . $action;
// 精确匹配 或 控制器级通配(controller:*
if (in_array($path, $this->powers) || in_array($controller . ':*', $this->powers)) {
return true;
}
Result::instance()->setStatusCode(403)->error(lang('You have no permission'));
}
/**
* 跳转到登录页(生成完整绝对 URL 后 302)。
*
* 登录跳转链接使用「当前请求域名 + 子目录(root) + 登录路径」拼成绝对地址,
* 避免在子目录部署或中心站/客户机跨域分发时跳到错误位置。
* 后台路径跟随 config/app.php 的 app_map 中 backend 对应的别名(默认 admin),
* 前台为 /Login。config/ywxapp.php 的 backend_login_url / member_login_url 可强制覆盖。
*
* @return void
*/
/**
* 从 app 配置推导后台应用的 URL 别名(app_map 中 backend 对应的键)。
*
* 多应用模式下后台真实目录为 backend,对外 URL 段由 config/app.php 的
* app_map['<别名>'=>'backend'] 决定(默认 admin)。硬编码 /admin 会在别名
* 调整时失效,故反查配置,使登录跳转跟随部署配置。
*
* @return string 如 'backend'
*/
protected function UrlAlias($val): string
{
$appMap = config('app.app_map', []);
$alias = array_search($val, $appMap, true);
return $alias === false ? $val : (string) $alias;
}
public function redirectToLogin()
{
if ($this->isAdmin) {
// 优先读 config/ywxapp.php 的 backend_login_url 覆盖;留空则跟随 app_map 推导
$override = trim((string) config('ywxapp.backend_login_url', ''));
$path = $override !== ''
? $override
: '/' . $this->UrlAlias('backend') . '/Login/index';
} else {
$override = trim((string) config('ywxapp.member_login_url', ''));
$path = $override !== '' ? $override : '/' . $this->UrlAlias('member') . '/Login/index';
}
// 用 url() 助手生成完整绝对 URL(自动带域名 + 子目录 root),
// 避免在子目录部署或中心站/客户机跨域分发时跳到错误位置(原先为裸根路径)。
$loginUrl = (string) url($path, [], false, true);
// 直接发送 302 并终止,确保构造期(_initialize 中调用)也能可靠跳转
redirect($loginUrl)->send();
exit;
}
/**
* 登录成功后把 access/refresh token 持久化到共享 cookie(path=/) 与服务端 session
* 保证整页跳转(iframe、菜单点击等非 AJAX 场景,不会自动带 Authorization 头)
* 也能通过 cookie/session 还原登录态,避免被踢回登录页。
* @param array $tokens JwtService::createToken() 的返回值
*/
protected function persistTokens(array $tokens): void
{
$at = $tokens['access_token'] ?? '';
$rt = $tokens['refresh_token'] ?? '';
if ($at !== '') {
Session::set('access_token', $at);
\think\facade\Cookie::set('access_token', $at, ['httponly' => false, 'path' => '/']);
}
if ($rt !== '') {
Session::set('refresh_token', $rt);
\think\facade\Cookie::set('refresh_token', $rt, ['httponly' => false, 'path' => '/']);
}
}
/**
* 用户登出,清除 token 缓存与登录态
* @return void
*/
public function logout()
{
$tokenStr = $this->resolveAccessToken();
// 子类钩子:此时 model/info 尚未清空,可做事件触发、整 session 清除等
$this->afterLogout();
if ($tokenStr) {
\think\facade\Cache::delete('auth_token_' . md5($tokenStr));
}
// 清除服务端 session / cookie 中的 token
Session::delete('access_token');
Session::delete('refresh_token');
\think\facade\Cookie::delete('access_token');
\think\facade\Cookie::delete('refresh_token');
// 重置登录状态
$this->isLogin = false;
$this->info = null;
$this->model = null;
$this->powers = [];
$this->roles = [];
}
/**
* 魔术读取(兼容 $auth->info / $auth->model 等)
* @param mixed $name
* @return mixed
*/
public function __get($name)
{
return $this->$name;
}
/**
* 魔术赋值
* @param mixed $name
* @param mixed $value
* @return void
*/
public function __set($name, $value)
{
$this->$name = $value;
}
public static function instance($options = [])
{
return app()->auth;
}
/**
* 注册用户(前台)
*
* @param string $account 用户名
* @param string $password 密码
* @param string $email 邮箱
* @param string $mobile 手机号
* @param array $extend 扩展参数
* @return boolean
*/
public function register($account = '', $password = '', $email = '', $mobile = '', $extend = [])
{
//账号注册时需要开启事务,避免出现垃圾数据
Db::startTrans();
try {
$account = $account ?: Random::account() . mb_substr($mobile ?: strtoupper(Random::alnum(4)), -4);
$password = $password ?: Random::alnum(16);
$nickname = $extend['nickname'] ?? '用户' . mb_substr($mobile ?: strtoupper(Random::alnum(4)), -4);
// 检测用户名
if (UserModel::checkExists('account', $account)) {
Result::instance()->error('Account already exist');
Db::rollback();
return false;
}
// 检测邮箱
if ($email && UserModel::checkExists('email', $email)) {
Result::instance()->error('Email already exist');
Db::rollback();
return false;
}
// 检测手机号
if ($mobile && UserModel::checkExists('mobile', $mobile)) {
Result::instance()->error('Mobile already exist');
Db::rollback();
return false;
}
$ip = request()->ip();
$data = [
'gid' => config('fastadmin.user_default_group') ?: 0,
'account' => $account,
'password' => $password,
'email' => $email,
'mobile' => $mobile,
//'score' => config('fastadmin.user_initial_score') ?: 0,
'avatar' => '',
'nickname' => $nickname,
'create_ip' => $ip,
'update_ip' => $ip,
'status' => 1,
];
$params['password'] = $this->getEncryptPassword($password);
$params = array_merge($params, $extend);
$this->info = UserModel::create($params, true);
$newClaims = [
'uid' => $this->info->uid,
'account' => $this->info->account,
];
$tokens = JwtService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
$this->isLogin = true;
event('user_register_after', $this->info);
Db::commit();
} catch (Exception $e) {
$this->setError($e->getMessage());
Db::rollback();
return false;
}
return true;
}
/**
* 用户登录(前台)
*
* @param string $account 账号,用户名、邮箱、手机号
* @param string $password 密码
* @return boolean
*/
public function login($account, $password, $isAuthPass = true)
{
$field = Validate::checkRule($account, 'email') ? 'email' : (Validate::checkRule($account, 'mobile') ? 'mobile' : 'account');
$info = UserModel::where($field, $account)->findOrEmpty();
// 会员表查无此账号时,回退到后台管理员表校验(打通会员中心与后台同一账号登录)
if ($info->isEmpty()) {
$admin = BackendAdmin::where('account', $account)->find();
if ($admin && $admin->status == 1 && $admin->checkPassword($password)) {
$info = $this->syncFromAdmin($admin, $password);
}
}
if ($info->isEmpty()) {
Result::instance()->error('Account is incorrect');
return false;
}
if ($info->status != 1) {
Result::instance()->error('Member Account is locked');
return false;
}
if ($info->isLocked()) {
Result::instance()->error('Member Account is locked');
return false;
}
$info->resetPassword($password);
if (! $info->checkPassword($password)) {
$info->recordLoginFail($_SERVER['REMOTE_ADDR'] ?? '');
Result::instance()->error('Password is incorrect');
return false;
}
$newClaims = [
'uid' => $info->uid,
'account' => $info->account,
];
// createToken 同时生成 access/refresh;持久化到共享 cookie(path=/)+session
// 使整页跳转(iframe/菜单点击,无 Authorization 头)也能还原登录态。
$tokens = JWTService::instance()->createToken($newClaims);
$this->persistTokens($tokens);
// 钩子点:会员/API 登录成功后触发,插件可在 info.php['events']['listen'] 中监听 user_login_after
event('user_login_after', $info);
$info->recordLogin($_SERVER['REMOTE_ADDR'] ?? '');
$this->initUser($info->uid);
}
/**
* 后台管理员账号首次登录会员中心时,在会员表自动创建一条对应记录,
* 密码按会员表规则(带 salt)加密,以便后续会员中心可独立登录。
* 仅在会员表无该 account 时创建。
*
* @param \ywxapp\model\Backend $admin 后台管理员模型(已通过密码校验)
* @param string $password 明文密码(用于会员表加密存储)
* @return \ywxapp\model\Member
*/
protected function syncFromAdmin($admin, $password)
{
$user = UserModel::where('account', $admin->account)->findOrEmpty();
if (! $user->isEmpty()) {
return $user;
}
$user = UserModel::create([
'account' => $admin->account,
'nickname' => $admin->nickname ?: $admin->account,
'password' => $password, // 触发 Member 模型的 setPasswordAttr 自动加盐加密
'gid' => config('fastadmin.user_default_group') ?: 0,
'status' => 1,
'create_ip' => request()->ip(),
'update_ip' => request()->ip(),
]);
return $user;
}
/**
* 修改密码(前台)
* @param string $newpassword 新密码
* @param string $oldpassword 旧密码
* @param bool $ignoreoldpassword 忽略旧密码
* @return boolean
*/
public function changepwd($newpassword, $oldpassword = '', $ignoreoldpassword = false)
{
if (! $this->_logined) {
$this->setError('You are not logged in');
return false;
}
//判断旧密码是否正确
if ($this->_user->password == $this->getEncryptPassword($oldpassword, $this->_user->salt) || $ignoreoldpassword) {
Db::startTrans();
try {
$salt = Random::alnum();
$newpassword = $this->getEncryptPassword($newpassword, $salt);
$this->_user->save(['loginfailure' => 0, 'password' => $newpassword, 'salt' => $salt]);
Token::clear($this->_user->uid);
//修改密码成功的事件
event('user_changepwd_after', $this->_user);
Db::commit();
} catch (Exception $e) {
Db::rollback();
$this->setError($e->getMessage());
return false;
}
return true;
} else {
$this->setError('Password is incorrect');
return false;
}
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use think\facade\Event;
use ywxapp\library\Result;
use ywxapp\model\Email as EmailModel;
use ywxapp\model\MemberUser as UsersModel;
/**
* 邮箱验证码类.
*/
class Email
{
/**
* 验证码有效时长
* @var int
*/
protected $expire = 120;
/**
* 最大允许检测的次数.
* @var int
*/
protected $maxCheckNums = 10;
/**
* 获取当前对象实例.
*
* @return EmailModel
*/
public static function instance()
{
return app()->email;
}
/**
* 发送验证码
*
* @param int $email 邮箱
* @param int $code 验证码,为空时将自动生成4位数字
* @param string $event 事件
*
* @return bool
*/
public function sendEmail($emailAddr, $code = null, $event = 'default')
{
$lastEmail = EmailModel::where('email', $emailAddr)
->where('event', $event)
->order('id', 'DESC')
->find();
Event::trigger('email_get', $lastEmail, true);
if ($lastEmail && time() - $lastEmail['create_at'] < 60)
Result::instance()->error(message: ('发送频繁'));
if ($event) {
$userinfo = UsersModel::where('email', $emailAddr)->find();
if ($event == 'register' && $userinfo)
Result::instance()->error(('已被注册'));
elseif (in_array($event, ['changeemail']) && $userinfo)
Result::instance()->error(('已被占用'));
elseif (in_array($event, ['changepwd', 'resetpwd']) && !$userinfo)
Result::instance()->error(('未注册'));
}
$code = is_null($code) ? mt_rand(100000, 999999) : $code;
$time = time();
$ip = request()->ip();
$email = EmailModel::create([
'event' => $event,
'email' => $emailAddr,
'code' => $code,
'ip' => $ip,
'create_at' => $time,
]);
$result = Event::trigger('email_send', $email, true);
if (!$result)
Result::instance()->error(message: ('发送失败'));
Result::instance()->success(message: ('发送成功'));
}
/**
* 发送通知.
*
* @param mixed $email 邮箱,多个以,分隔
* @param string $msg 消息内容
* @param string $template 消息模板
*
* @return bool
*/
public function notice($email, $msg = '', $template = null)
{
$params = [
'email' => $email,
'msg' => $msg,
'template' => $template,
];
$result = Event::trigger('email_notice', $params, true);
return $result ? true : false;
}
/**
* 校验验证码
*
* @param int $email 邮箱
* @param int $code 验证码
* @param string $event 事件
*
* @return bool
*/
public function check($emailAddr, $code, $event = 'default')
{
$lastEmail = EmailModel::where('email', $emailAddr)
->where('event', $event)
->order('id', 'DESC')
->find();
if (!$lastEmail || $code != $lastEmail['code'])
Result::instance()->error(message: ('验证码不正确'));
if ($lastEmail['create_at'] > time() - $this->expire)
Result::instance()->error(message: ('验证码已经过期'));
if ($code != $lastEmail['code'])
Result::instance()->error(message: ('验证码不正确'));
$result = Event::trigger('ems_check', $lastEmail, true);
$lastEmail->status = 1;
$lastEmail->save();
return $result;
}
/**
* 清空指定邮箱验证码
*
* @param int $email 邮箱
* @param string $event 事件
*
* @return bool
*/
public static function flush($email, $event = 'default')
{
EmailModel::where(['email' => $email, 'event' => $event])->delete();
Event::trigger('email_flush');
return true;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
use think\facade\Log;
/**
* 基于 PHPMailer 的简单邮件发送封装。
* 未配置 SMTP 时静默返回 false(不阻断主流程)。
*/
class Mailer
{
/**
* 发送邮件
* @param string $to 收件人
* @param string $subject 主题
* @param string $body 正文(支持 HTML
* @param bool $isHtml 是否为 HTML 正文
* @return bool
*/
public static function send(string $to, string $subject, string $body, bool $isHtml = true): bool
{
$cfg = config('mail', []);
if (empty($cfg['host']) || empty($cfg['username']) || empty($cfg['password'])) {
Log::warning('[Mailer] 未配置 SMTP,跳过发送 -> ' . $to);
return false;
}
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = $cfg['host'];
$mail->SMTPAuth = true;
$mail->Username = $cfg['username'];
$mail->Password = $cfg['password'];
$mail->SMTPSecure = $cfg['secure'] ?? 'ssl';
$mail->Port = (int) ($cfg['port'] ?? 465);
$mail->CharSet = 'UTF-8';
$mail->setFrom($cfg['from'] ?: $cfg['username'], $cfg['from_name'] ?? 'YwxApp');
$mail->addAddress($to);
$mail->isHTML($isHtml);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->send();
return true;
} catch (PHPMailerException $e) {
Log::error('[Mailer] 发送失败 -> ' . $mail->ErrorInfo);
return false;
} catch (\Exception $e) {
Log::error('[Mailer] 异常 -> ' . $e->getMessage());
return false;
}
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
// use fast\Tree;
use think\Exception;
use think\facade\Db;
use ywxapp\model\BackendPower;
use ywxapp\service\AddonService;
/**
* Menu 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Menu
{
/**
* 创建菜单
* @param array $menu
* @param mixed $parent 父类的name或pid
*/
public static function create($menu = [], $parent = 0)
{
$old = [];
self::menuUpdate($menu, $old, $parent);
//菜单刷新处理
$info = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1];
preg_match('/addon\\\\([a-z0-9]+)\\\\/i', $info['class'], $matches);
if ($matches && isset($matches[1])) {
Menu::refresh($matches[1], $menu);
}
}
/**
* 删除菜单.
*
* @param string $name 规则name
* @return bool
*/
public static function delete($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return false;
}
BackendPower::destroy($ids);
return true;
}
/**
* 启用菜单.
*
* @param string $name
* @return bool
*/
public static function enable($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return false;
}
BackendPower::where('id', 'in', $ids)->update(['status' => 'normal']);
return true;
}
/**
* 禁用菜单.
*
* @param string $name
* @return bool
*/
public static function disable($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return false;
}
BackendPower::where('id', 'in', $ids)->update(['status' => 'hidden']);
return true;
}
/**
* 升级菜单
* @param string $name 插件名称
* @param array $menu 新菜单
* @return bool
*/
public static function upgrade($name, $menu)
{
$ids = self::getAuthRuleIdsByName($name);
$old = BackendPower::where('id', 'in', $ids)->select();
$old = $old ? $old->toArray() : [];
$old = array_column($old, null, 'name');
Db::startTrans();
try {
self::menuUpdate($menu, $old);
$ids = [];
foreach ($old as $index => $item) {
if (! isset($item['keep'])) {
$ids[] = $item['id'];
}
}
if ($ids) {
//旧版本的菜单需要做删除处理
$config = AddonService::config($name);
$menus = isset($config['menus']) ? $config['menus'] : [];
$where[] = ['id', 'in', $ids];
if ($menus) {
//必须是旧版本中的菜单,可排除用户自主创建的菜单
$where[] = ['name', 'in', $menus];
}
BackendPower::where($where)->delete();
}
Db::commit();
} catch (\PDOException $e) {
Db::rollback();
return false;
}
Menu::refresh($name, $menu);
return true;
}
/**
* 刷新插件菜单配置缓存
* @param string $name
* @param array $menu
*/
public static function refresh($name, $menu = [])
{
if (! $menu) {
// $menu为空时表示首次安装,首次安装需刷新插件菜单标识缓存
$menuIds = self::getAuthRuleIdsByName($name);
$menus = BackendPower::where('id', 'in', $menuIds)->column('name');
} else {
// 刷新新的菜单缓存
$getMenus = function ($menu) use (&$getMenus) {
$result = [];
foreach ($menu as $index => $item) {
$result[] = $item['name'];
$result = array_merge($result, isset($item['sublist']) && is_array($item['sublist']) ? $getMenus($item['sublist']) : []);
}
return $result;
};
$menus = $getMenus($menu);
}
//刷新新的插件核心菜单缓存
AddonService::config($name, ['menus' => $menus]);
}
/**
* 导出指定名称的菜单规则.
*
* @param string $name
*
* @return array
*/
public static function export($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (! $ids) {
return [];
}
$menuList = [];
$menu = BackendPower::getByName($name);
if ($menu) {
$ruleList = BackendPower::where('id', 'in', $ids)->select()->toArray();
$menuList = Tree::instance()->init($ruleList)->getTreeArray($menu['id']);
}
return $menuList;
}
/**
* 菜单升级
* @param array $newMenu
* @param array $oldMenu
* @param int $parent
* @throws Exception
*/
private static function menuUpdate($newMenu, &$oldMenu, $parent = 0)
{
if (! is_numeric($parent)) {
$parentRule = BackendPower::getByName($parent);
$pid = $parentRule ? $parentRule['id'] : 0;
} else {
$pid = $parent;
}
// 补全与 admin_power 表结构一致的字段(route/sort/type/addon),
// 否则插件跨应用菜单的链接(route)、排序(sort)、类型(type)、归属(addon)会丢失
$allow = array_flip(['file', 'name', 'title', 'icon', 'condition', 'remark', 'ismenu', 'weigh', 'route', 'sort', 'type', 'addon']);
foreach ($newMenu as $k => $v) {
$hasChild = isset($v['sublist']) && $v['sublist'] ? true : false;
$data = array_intersect_key($v, $allow);
$data['ismenu'] = isset($data['ismenu']) ? $data['ismenu'] : ($hasChild ? 1 : 0);
$data['icon'] = isset($data['icon']) ? $data['icon'] : ($hasChild ? 'fa fa-list' : 'fa fa-circle-o');
$data['pid'] = $pid;
$data['status'] = 'normal';
if (! isset($oldMenu[$data['name']])) {
$menu = BackendPower::create($data);
} else {
$menu = $oldMenu[$data['name']];
//更新旧菜单
BackendPower::update($data, ['id' => $menu['id']]);
$oldMenu[$data['name']]['keep'] = true;
}
if ($hasChild) {
self::menuUpdate($v['sublist'], $oldMenu, $menu['id']);
}
}
}
/**
* 根据名称获取规则IDS.
*
* @param string $name
*
* @return array
*/
public static function getAuthRuleIdsByName($name)
{
$ids = [];
$menu = BackendPower::getByName($name);
if ($menu) {
// 必须将结果集转换为数组
$ruleList = BackendPower::order('weigh', 'desc')->field('id,pid,name')->select()->toArray();
// 构造菜单数据
$ids = Tree::instance()->init($ruleList)->getChildrenIds($menu['id'], true);
}
return $ids;
}
}
+277
View File
@@ -0,0 +1,277 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use BcMath\Number;
use think\exception\HttpResponseException;
use think\facade\Request;
use think\Response;
class Result
{
/**
* Summary of StatusCode
* @var int
*/
protected $StatusCode = 200;
public $Message = null;
/**
* Summary of Headers
* @var array
*/
protected $Headers = [];
protected $AccessToken;
protected $AccessExpired;
protected $RefreshToken;
protected $RefreshExpired;
protected $Total = 0;
/**
* Summary of Type
* @var string
*/
protected $Type = 'json';
protected $isCout = false;
public function __construct()
{
if (! Request::isAjax()) {
$this->Type = 'html';
}
}
public function success($data = null, $message = 'success', int $code = 0)
{
$result = ['code' => 0, 'message' => $message, 'timestamp' => time()];
if ($data) {
$result['data'] = $data;
}
if ($this->Total > 0) {
$result['count'] = $this->Total;
}
if ($this->AccessToken != null) {
$result['access_token'] = $this->AccessToken;
$result['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken != null) {
$result['refresh_token'] = $this->RefreshToken;
$result['refresh_expired'] = $this->RefreshExpired;
}
$response = '';
if ('html' == strtolower($this->Type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp/view/']);
\think\facade\View::layout(false);
$result = \think\facade\View::fetch('success.html', $result);
}
$response = Response::create($result, strtolower($this->Type), $this->StatusCode)->header($this->Headers);
$this->applyTokenCookies($response);
$response->send();
app()->http->end($response);
exit;
// throw new HttpResponseException($response);
}
public function error($message = 'Error', int $code = 1, $data = null)
{
$result = ['code' => $code, 'message' => $message, 'timestamp' => time()];
if ($data) {
$result['data'] = $data;
}
if ($this->AccessToken != null) {
$result['access_token'] = $this->AccessToken;
$result['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken != null) {
$result['refresh_token'] = $this->RefreshToken;
$result['refresh_expired'] = $this->RefreshExpired;
}
$response = '';
// 若已被强制为 JSON(如插件 API 在 MultiApp 中间件里 setType('json')),
// 或请求本身是 ajax,则统一返回 JSON;否则按 HTML 渲染。
$type = ($this->Type === 'json') ? 'json' : (Request::isAjax() ? 'json' : 'html');
if ($this->StatusCode == 401) {
$result['url'] = url('login/index');
$result['code'] = $this->StatusCode;
if ('html' == strtolower($type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR]);
$result = \think\facade\View::fetch('401.html', $result);
}
$response = Response::create($result, strtolower($type), 401);
$this->applyTokenCookies($response);
} else {
if ('html' == strtolower($type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR]);
\think\facade\View::layout(false);
$result = \think\facade\View::fetch('error.html', $result);
}
$response = Response::create($result, strtolower($type), $this->StatusCode)->header($this->Headers);
$this->applyTokenCookies($response);
}
$response->send();
app()->http->end($response);
exit;
throw new HttpResponseException($response);
}
/**
* 返回封装后的 API 数据到客户端.
*
* @param mixed $data 要返回的数据
* @param int $code 返回的 code
* @param mixed $msg 提示信息
* @param string $type 返回数据格式
* @param array $header 发送的 Header 信息
*/
protected function result($data, $code = 0, $msg = '', $type = '', array $header = [])
{
$result = [
'code' => $code,
'msg' => $msg,
'time' => Request::server('REQUEST_TIME'),
'data' => $data,
];
$type = $type ?: $this->getResponseType();
$response = Response::create($result, $type)->header($header);
throw new HttpResponseException($response);
}
/**
* URL 重定向.
*
* @param string $url 跳转的 URL 表达式
* @param array|int $params 其它 URL 参数
* @param int $code http code
* @param array $with 隐式传参
*/
public function redirect($url, $code = 302, $params = [], $with = []): self
{
if (is_int($params)) {
$code = $params;
}
$response = \redirect($url);
$response->code($code)->with($with);
throw new HttpResponseException($response);
}
/**
* 设置Token
* @param string $token
* @return void
*/
public function setAccessToken($token, $expired = null): self
{
$this->AccessToken = $token;
$this->AccessExpired = $expired;
return app()->result;
}
/**
* 设置Token
* @param string $token
* @return void
*/
public function setRefreshToken($token, $expired = null): self
{
$this->RefreshToken = $token;
$this->RefreshExpired = $expired;
return $this;
}
public function setStatusCode(int $code = 200): self
{
$this->StatusCode = $code;
return $this;
}
public function setMessage(string $message = ''): self
{
$this->Message = $message;
return $this;
}
public function setHeaders(array $header = []): self
{
$this->Headers = $header;
return $this;
}
/**
* 强制设置响应类型(json/html
* 插件 API 由小程序/APP 等客户端调用,请求不带 X-Requested-With
* 在 MultiApp 中间件里统一 setType('json') 以确保返回 JSON。
* @param string $type
* @return self
*/
public function setType(string $type = 'json'): self
{
$this->Type = strtolower($type);
return app()->result;
}
/**
* 把当前持有的 access/refresh token 写入响应 Cookiepath=/,非 httponly)。
*
* 背景:登录/注册的 token 经 JwtService 存入本 Result 单例(setAccessToken/setRefreshToken),
* 但 persistTokens() 用的全局 Cookie 门面与 Response 持有的 Cookie 实例并非同一个,
* 仅靠门面 set 的 token 不会被 Response::send() 输出,导致整页跳转(菜单点击、iframe)
* 不带 token → 后端读不到 → 跳登录页。此处直接写到 Response 实例上,确保 100% 随响应输出。
*
* @param Response $response
* @return void
*/
protected function applyTokenCookies(Response $response): void
{
if ($this->AccessToken !== null && $this->AccessToken !== '') {
$expire = $this->AccessExpired ? ((int) $this->AccessExpired - time()) : 0;
$response->cookie('access_token', (string) $this->AccessToken, [
'expire' => $expire,
'path' => '/',
'httponly' => false,
]);
}
if ($this->RefreshToken !== null && $this->RefreshToken !== '') {
$expire = $this->RefreshExpired ? ((int) $this->RefreshExpired - time()) : 0;
$response->cookie('refresh_token', (string) $this->RefreshToken, [
'expire' => $expire,
'path' => '/',
'httponly' => false,
]);
}
}
public function setCount(int $total): self
{
$this->Total = $total;
return app()->result;
}
public static function instance($options = []): Result
{
return app()->result;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
/**
* EsSearch —— 轻量 ElasticSearch REST 封装(零新增依赖,复用项目已引入的 GuzzleHttp
*
* 设计原则(对应路线 C3「ES 全文检索替代 LIKE」):
* - 薄封装:ES 仅负责「按关键词返回匹配的 id 列表」,命中后由调用方 `whereIn('id', $ids)` 回表取完整行 + 关联,
* 最大限度复用现有视图与模型关联,降低改造面。
* - 可降级:本类方法在 ES 不可用时抛出异常,由上层 SearchService 捕获并回退到原生 LIKE。
* - 中文分词:依赖 ES 服务端 IK 分词插件(index 创建时指定 analyzer);若未装,ES 默认 standard 仍可按词/字匹配。
*
* 依赖:项目根 composer.json 已间接引入 guzzlehttp/guzzle(经 yansongda/pay)。
*/
declare(strict_types=1);
namespace ywxapp\library\Search;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
class EsSearch
{
/** @var Client|null 懒加载的 HTTP 客户端 */
protected static ?Client $client = null;
/**
* 获取 HTTP 客户端
*/
protected static function client(): Client
{
if (self::$client === null) {
self::$client = new Client(['timeout' => 3.0, 'connect_timeout' => 2.0]);
}
return self::$client;
}
/**
* 拼接 ES 端点 URL
*/
protected static function url(string $host, string $path): string
{
return rtrim($host, '/') . $path;
}
/**
* 确保索引存在(带中文 IK 分词器映射,缺失则创建)
* @throws \RuntimeException
*/
public static function ensureIndex(string $host, string $index, array $fields): void
{
$url = self::url($host, '/' . $index);
try {
$resp = self::client()->request('HEAD', $url);
if ($resp->getStatusCode() === 200) {
return; // 已存在
}
} catch (TransferException $e) {
throw new \RuntimeException('ES 连接失败:' . $e->getMessage());
}
// 创建索引,默认用 ik_max_word(若有 IK 插件),否则 standard
$properties = [];
foreach ($fields as $f) {
$properties[$f] = ['type' => 'text', 'analyzer' => 'ik_max_word'];
}
$body = json_encode([
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
'mappings' => ['properties' => $properties],
], JSON_UNESCAPED_UNICODE);
try {
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
} catch (TransferException $e) {
// IK 分词器不存在时降级为 standard
$properties = [];
foreach ($fields as $f) {
$properties[$f] = ['type' => 'text'];
}
$body = json_encode([
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
'mappings' => ['properties' => $properties],
], JSON_UNESCAPED_UNICODE);
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
}
}
/**
* 索引单条文档
* @throws \RuntimeException
*/
public static function indexDoc(string $host, string $index, int $id, array $body): void
{
try {
self::client()->request(
'PUT',
self::url($host, '/' . $index . '/_doc/' . $id),
['body' => json_encode($body, JSON_UNESCAPED_UNICODE), 'headers' => ['Content-Type' => 'application/json']]
);
} catch (TransferException $e) {
throw new \RuntimeException('ES 写入失败:' . $e->getMessage());
}
}
/**
* 删除文档
*/
public static function deleteDoc(string $host, string $index, int $id): void
{
try {
self::client()->request('DELETE', self::url($host, '/' . $index . '/_doc/' . $id));
} catch (TransferException $e) {
// 索引不存在或文档已删,忽略
}
}
/**
* 全文检索,返回匹配的 id 列表(按相关度排序)
* @param array $fields 检索字段,如 ['title','content']
* @return int[] 命中文档 id(相关度降序)
* @throws \RuntimeException
*/
public static function search(string $host, string $index, string $keyword, array $fields, int $limit = 50, int $from = 0): array
{
if (trim($keyword) === '') {
return [];
}
$should = [];
foreach ($fields as $f) {
$should[] = ['match' => [$f => ['query' => $keyword, 'boost' => 1.0]]];
}
$body = json_encode([
'from' => $from,
'size' => $limit,
'query' => ['bool' => ['should' => $should, 'minimum_should_match' => 1]],
'_source' => false,
], JSON_UNESCAPED_UNICODE);
try {
$resp = self::client()->request(
'POST',
self::url($host, '/' . $index . '/_search'),
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
);
} catch (TransferException $e) {
throw new \RuntimeException('ES 检索失败:' . $e->getMessage());
}
$data = json_decode((string) $resp->getBody(), true);
$ids = [];
foreach (($data['hits']['hits'] ?? []) as $hit) {
$ids[] = (int) ($hit['_id'] ?? 0);
}
return $ids;
}
/**
* 相关推荐(基于 ES more_like_this,按内容相似度返回相似文档 id)
* @param array $fields 参与相似的字段,如 ['title','content']
* @param string $likeText 源文档文本(title + content 拼接)
* @return int[] 相似文档 id(相关度降序,不含自身)
* @throws \RuntimeException
*/
public static function moreLikeThis(string $host, string $index, string $likeText, array $fields, int $excludeId, int $limit = 6): array
{
$likeText = trim($likeText);
if ($likeText === '') {
return [];
}
$body = json_encode([
'size' => $limit + 1,
'query' => [
'more_like_this' => [
'fields' => $fields,
'like' => $likeText,
'min_term_freq' => 1,
'min_doc_freq' => 1,
'minimum_should_match' => '20%',
],
],
'_source' => false,
], JSON_UNESCAPED_UNICODE);
try {
$resp = self::client()->request(
'POST',
self::url($host, '/' . $index . '/_search'),
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
);
} catch (TransferException $e) {
throw new \RuntimeException('ES 相关推荐失败:' . $e->getMessage());
}
$data = json_decode((string) $resp->getBody(), true);
$ids = [];
foreach (($data['hits']['hits'] ?? []) as $hit) {
$id = (int) ($hit['_id'] ?? 0);
if ($id !== $excludeId) {
$ids[] = $id;
}
}
return array_slice($ids, 0, $limit);
}
/**
* 全量重建索引(清空后批量写入)
* @param callable $each 迭代器回调:function(int $page, int $size): array 返回 [id=>body] 映射
*/
public static function rebuild(string $host, string $index, array $fields, callable $each, int $size = 200): void
{
// 删除旧索引重建
try {
self::client()->request('DELETE', self::url($host, '/' . $index));
} catch (TransferException $e) {
}
self::ensureIndex($host, $index, $fields);
$page = 1;
while (true) {
$batch = $each($page, $size);
if (empty($batch)) {
break;
}
foreach ($batch as $id => $doc) {
self::indexDoc($host, $index, (int) $id, $doc);
}
if (count($batch) < $size) {
break;
}
$page++;
}
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp <admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use ywxapp\library\TemplateManager;
/**
* 皮肤覆盖层(Discuz 式局部覆盖核心)。
*
* 由于 ThinkPHP 的 {extend}/{include} 由模板引擎内部用 view_path 解析,
* 无法在控制器 fetch 钩子里拦截。因此这里把「插件视图 + 皮肤覆盖」合并到
* 一个真实的覆盖层目录 runtime/skin/<addon>/<area>/
* - 先复制插件 addon/<addon>/view/<area>/ 全部文件
* - 再用模板 templates/<tpl>/view/<addon>/<area>/ 的文件覆盖(皮肤优先)
* 控制器渲染前把 View 的 view_path 指向该覆盖层,顶层页面与 extend 的
* layout/partial 均从覆盖层解析;无激活皮肤时返回 null(回退插件默认视图)。
*
* 合并结果按源目录最新修改时间缓存,源有变动才重建。
*/
class SkinOverlay
{
/**
* 解析某插件/区域的合并覆盖层目录;无激活皮肤返回 null。
*
* @param string $addon 插件名,如 blog
* @param string $area frontend / backend / member
* @return string|null
*/
/**
* 解析某插件当前生效的模板名(不构建覆盖层,供变量层等复用)。
*
* 生效层级(高→低):
* 1. 显式插件绑定(管理员设定,最高优先级)
* 2. 会员自选(界面设置开启 allow_member_select 且访客 cookie 有效)
* 3. 全站默认 '*'
* 无激活皮肤 / 被禁用 / 回退默认 时返回 null。
*/
public static function templateOf(string $addon): ?string
{
$map = config('template.active_map', []);
if (! is_array($map)) {
$map = [];
}
// 1) 显式插件绑定优先
if (isset($map[$addon]) && $map[$addon] !== '' && $map[$addon] !== 'default') {
$tpl = $map[$addon];
} else {
// 2) 会员自选(若开启且有效)
$member = self::memberChoice();
if ($member !== null) {
$tpl = $member;
} else {
// 3) 全站默认
$tpl = $map['*'] ?? 'default';
}
}
if (empty($tpl) || $tpl === 'default') {
return null;
}
// 模板被禁用时不生效(界面设置中可启用/禁用)
if (!TemplateManager::isEnabled($tpl)) {
return null;
}
return $tpl;
}
/**
* 读取访客自选模板(界面设置开启 allow_member_select 时生效)。
* 仅接受「已安装且启用」的合法模板名,否则返回 null(安全:防 cookie 注入/遍历)。
*/
protected static function memberChoice(): ?string
{
$settings = TemplateManager::getSettings();
if (empty($settings['allow_member_select'])) {
return null;
}
$name = (string) \think\facade\Request::cookie('ywx_skin', '');
if ($name === '' || $name === 'default') {
return null;
}
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
return null;
}
// 必须是真实存在且启用的模板
$dir = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
if (! is_dir($dir) || ! TemplateManager::isEnabled($name)) {
return null;
}
return $name;
}
public static function resolve(string $addon, string $area): ?string
{
$tpl = self::templateOf($addon);
if ($tpl === null) {
return null;
}
$pluginView = root_path() . 'addon' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
. $area . DIRECTORY_SEPARATOR;
$skinView = root_path() . 'templates' . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . $area . DIRECTORY_SEPARATOR;
// 该区域皮肤没有提供任何覆盖文件,无需合并,直接回退插件默认视图
if (! is_dir($skinView)) {
return null;
}
$overlay = runtime_path() . 'skin' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR . $area . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR;
self::ensure($overlay, $pluginView, $skinView);
return $overlay;
}
/**
* 确保覆盖层目录最新(按需重建)。
* 用 .built 标记文件记录源目录最新修改时间,避免空目录误判为「缓存有效」。
*/
protected static function ensure(string $overlay, string $pluginView, string $skinView): void
{
$srcMtime = 0;
if (is_dir($pluginView)) {
$srcMtime = max($srcMtime, self::dirMtime($pluginView));
}
if (is_dir($skinView)) {
$srcMtime = max($srcMtime, self::dirMtime($skinView));
}
$marker = $overlay . '.built';
if (is_dir($overlay) && is_file($marker) && (int) file_get_contents($marker) >= $srcMtime) {
return; // 缓存有效
}
if (is_dir($overlay)) {
self::delTree($overlay);
}
if (is_dir($pluginView)) {
self::copyDir($pluginView, $overlay);
} else {
@mkdir($overlay, 0755, true);
}
if (is_dir($skinView)) {
self::copyDir($skinView, $overlay);
}
@file_put_contents($marker, (string) $srcMtime);
}
protected static function copyDir(string $src, string $dst): void
{
$src = rtrim($src, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$dst = rtrim($dst, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (! is_dir($src)) {
return;
}
if (! is_dir($dst)) {
@mkdir($dst, 0755, true);
}
foreach (array_diff(scandir($src), ['.', '..']) as $item) {
$s = $src . $item;
$d = $dst . $item;
if (is_dir($s)) {
self::copyDir($s, $d);
} else {
copy($s, $d);
}
}
}
protected static function delTree(string $dir): void
{
$dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (! is_dir($dir)) {
return;
}
foreach (array_diff(scandir($dir), ['.', '..']) as $item) {
$p = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($p)) {
self::delTree($p);
} else {
@unlink($p);
}
}
@rmdir($dir);
}
protected static function dirMtime(string $dir): int
{
$mtime = 0;
$rii = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($rii as $file) {
if ($file->isFile()) {
$mtime = max($mtime, $file->getMTime());
}
}
return $mtime;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp <admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
/**
* 皮肤变量/配色层(Discuz 式风格变量,无需改 HTML)。
*
* 模板在 template.json 声明 variables(如主色/背景/字体),后台可覆盖并写入
* config/template.php 的 settings.variables[<模板名>]。渲染时由 Addon 基类的
* fetch 钩子把变量注入为 <style id="ywx-skin-vars">:root{--primary:...}</style>
* 皮肤 CSS 用 var(--primary) 即可生效,无需改动任何视图文件。
*/
class SkinVariables
{
/**
* 读取模板声明的变量定义(已归一化为 {key:{label,default,type}})。
* 支持简写:variables: { "primary": "#2d8cf0" } 等价于 { "primary": {"default":"#2d8cf0"} }。
*/
public static function definitions(string $name): array
{
$file = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR . 'template.json';
if (! is_file($file)) {
return [];
}
$meta = json_decode(file_get_contents($file), true) ?? [];
$vars = $meta['variables'] ?? [];
if (! is_array($vars)) {
return [];
}
$out = [];
foreach ($vars as $key => $def) {
if (is_string($def)) {
$def = ['default' => $def];
}
if (! is_array($def)) {
continue;
}
$out[$key] = [
'label' => $def['label'] ?? $key,
'default' => $def['default'] ?? '',
'type' => $def['type'] ?? 'color',
];
}
return $out;
}
/**
* 当前生效的变量值(默认 + 后台覆盖)。
* 返回带 -- 前缀的 CSS 变量名 => 值(仅返回非空值)。
*/
public static function values(string $name): array
{
$defs = self::definitions($name);
$settings = TemplateManager::getSettings();
$overrides = $settings['variables'][$name] ?? [];
if (! is_array($overrides)) {
$overrides = [];
}
$out = [];
foreach ($defs as $key => $d) {
$val = $overrides[$key] ?? $d['default'];
if ($val === '' || $val === null) {
continue;
}
$out['--' . ltrim($key, '-')] = (string) $val;
}
return $out;
}
/**
* 生成 :root 内的 CSS 变量声明字符串(不含 :root{} 包裹),如无变量返回空串。
*/
public static function declaration(string $name): string
{
$vals = self::values($name);
$parts = [];
foreach ($vals as $k => $v) {
$parts[] = $k . ':' . $v;
}
return implode(';', $parts);
}
/**
* 针对某插件/区域,返回可直接注入页面的 <style> 标签;无激活皮肤或无变量返回空串。
*/
public static function styleTag(string $addon, string $area): string
{
$name = SkinOverlay::templateOf($addon);
if ($name === null) {
return '';
}
$decl = self::declaration($name);
if ($decl === '') {
return '';
}
return '<style id="ywx-skin-vars">:root{' . $decl . '}</style>';
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use ywxapp\utils\Random;
use think\facade\Event;
use ywxapp\model\Sms as SmsModel;
/**
* 短信验证码类
*/
class Sms
{
/**
* 验证码有效时长
* @var int
*/
protected static $expire = 120;
/**
* 最大允许检测的次数
* @var int
*/
protected static $maxCheckNums = 10;
/**
* 获取最后一次手机发送的数据
*
* @param int $mobile 手机号
* @param string $event 事件
* @return Sms
*/
public static function get($mobile, $event = 'default')
{
$sms = SmsModel::where(['mobile' => $mobile, 'event' => $event]) ->order('id', 'DESC') ->find();
Event::trigger('SmsGet', $sms, true);
return $sms ?: null;
}
/**
* 发送验证码
*
* @param int $mobile 手机号
* @param int $code 验证码,为空时将自动生成4位数字
* @param string $event 事件
* @return boolean
*/
public static function send($mobile, $code = null, $event = 'default')
{
$code = is_null($code) ? Random::numeric(6) : $code;
$time = time();
$ip = request()->ip();
$sms = SmsModel::create(['event' => $event, 'mobile' => $mobile, 'code' => $code, 'ip' => $ip, 'create_at' => $time]);
$result = Event::trigger('SmsSend', $sms, true);
if (! $result) {
$sms->delete();
return false;
}
return true;
}
/**
* 发送通知
*
* @param mixed $mobile 手机号,多个以,分隔
* @param string $msg 消息内容
* @param string $template 消息模板
* @return boolean
*/
public static function notice($mobile, $msg = '', $template = null)
{
$params = [
'mobile' => $mobile,
'msg' => $msg,
'template' => $template,
];
$result = Event::trigger('SmsNotice', $params, true);
return (bool) $result;
}
/**
* 校验验证码
*
* @param int $mobile 手机号
* @param int $code 验证码
* @param string $event 事件
* @return boolean
*/
public static function check($mobile, $code, $event = 'default')
{
$time = time() - self::$expire;
$sms = SmsModel::where(['mobile' => $mobile, 'event' => $event]) ->order('id', 'DESC') ->find();
if ($sms) {
if ($sms['create_at'] > $time && $sms['times'] <= self::$maxCheckNums) {
$correct = $code == $sms['code'];
if (! $correct) {
$sms->times = $sms->times + 1;
$sms->save();
return false;
} else {
$result = Event::trigger('SmsCheck', $sms, true);
return $result;
}
} else {
// 过期则清空该手机验证码
self::flush($mobile, $event);
return false;
}
} else {
return false;
}
}
/**
* 清空指定手机号验证码
*
* @param int $mobile 手机号
* @param string $event 事件
* @return boolean
*/
public static function flush($mobile, $event = 'default')
{
SmsModel::where(['mobile' => $mobile, 'event' => $event])
->delete();
Event::trigger('SmsFlush');
return true;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?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 ywxapp\library;
/**
* 搜索引擎蜘蛛识别
*
* 仅按 User-Agent 特征串识别(不做 DNS 反查,保证零阻塞)。
* 新增蜘蛛只需在 SPIDERS 追加一行:标识 => [UA特征串列表, 显示名]。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class SpiderDetect
{
/**
* 蜘蛛特征表:key=入库标识,value=[UA 特征串(不区分大小写,命中任一即算), 中文显示名]
*/
public const SPIDERS = [
'baidu' => [['Baiduspider'], '百度'],
'google' => [['Googlebot', 'Google-InspectionTool', 'AdsBot-Google'], '谷歌'],
'bing' => [['bingbot', 'msnbot', 'BingPreview'], '必应'],
'sogou' => [['Sogou web spider', 'Sogou inst spider', 'Sogou Pic Spider'], '搜狗'],
'so360' => [['360Spider', 'HaoSouSpider', 'qihoobot'], '360'],
'bytedance' => [['Bytespider', 'ToutiaoSpider'], '字节跳动'],
'shenma' => [['YisouSpider'], '神马'],
'huawei' => [['PetalBot'], '华为花瓣'],
'yandex' => [['YandexBot'], 'Yandex'],
'duckduckgo' => [['DuckDuckBot', 'DuckDuckGo-Favicons-Bot'], 'DuckDuckGo'],
'yahoo' => [['Yahoo! Slurp'], '雅虎'],
'apple' => [['Applebot'], '苹果'],
'gptbot' => [['GPTBot', 'ChatGPT-User', 'OAI-SearchBot'], 'OpenAI'],
'claudebot' => [['ClaudeBot', 'anthropic-ai'], 'Anthropic'],
'semrush' => [['SemrushBot'], 'Semrush'],
'ahrefs' => [['AhrefsBot'], 'Ahrefs'],
'mj12' => [['MJ12bot'], 'Majestic'],
'facebook' => [['facebookexternalhit', 'FacebookBot'], 'Facebook'],
];
/**
* 识别 UA,命中返回蜘蛛标识(SPIDERS 的 key),未命中返回 null
*/
public static function detect(string $userAgent): ?string
{
if ($userAgent === '') {
return null;
}
foreach (self::SPIDERS as $key => [$needles]) {
foreach ($needles as $needle) {
if (stripos($userAgent, $needle) !== false) {
return $key;
}
}
}
return null;
}
/**
* 蜘蛛标识 → 中文显示名(未知标识原样返回)
*/
public static function label(string $key): string
{
return self::SPIDERS[$key][1] ?? $key;
}
/**
* 全部蜘蛛 标识=>显示名(后台下拉/报表用)
*/
public static function labels(): array
{
$out = [];
foreach (self::SPIDERS as $key => [, $label]) {
$out[$key] = $label;
}
return $out;
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
// | 流式文件下载响应(避免 ThinkPHP download() 助手将整文件读入内存导致大文件/慢网络截断)
// +----------------------------------------------------------------------
namespace ywxapp\library;
use think\Response;
/**
* 流式输出二进制文件(zip 等)的下载响应。
*
* 相对 ThinkPHP 内置 download() 助手的优势:
* - 用 fread 分块输出(非 file_get_contents 整文件入内存),大文件不再吃爆 memory_limit
* - 构造函数内 set_time_limit(0),慢网络下不会被 Web 端默认执行时限截断;
* - 支持 HTTP RangeAccept-Ranges: bytes),客户端可断点续传,进一步抵御下载中断;
* - 正确回吐 Content-Length,便于客户端做字节数完整性校验。
*
* 用法:return new StreamZipResponse($absPath, $downloadName);
*/
class StreamZipResponse extends Response
{
protected $filePath;
protected $dlName;
protected $contentType = 'application/octet-stream';
public function __construct(string $filePath, string $dlName, int $code = 200)
{
$this->filePath = $filePath;
$this->dlName = $dlName;
$this->code = $code;
$this->header = [];
// 取消脚本执行时间限制:防止大文件 / 慢网络下被 PHP 默认 max_execution_time 截断
@set_time_limit(0);
@ignore_user_abort(false);
}
public function send(): void
{
while (ob_get_level() > 0) {
ob_end_clean();
}
if (!is_file($this->filePath)) {
if (!headers_sent()) {
http_response_code(404);
}
return;
}
$size = filesize($this->filePath);
$name = rawurlencode($this->dlName);
// 解析 Range(断点续传)
$range = $this->parseRange($size);
$isRange = $range !== null;
$start = $range['start'] ?? 0;
$end = $range['end'] ?? ($size - 1);
if (!headers_sent()) {
http_response_code($isRange ? 206 : 200);
header('Pragma: public');
header('Accept-Ranges: bytes');
header('Content-Type: ' . $this->contentType);
header('Cache-control: max-age=360');
header('Content-Disposition: attachment; filename="' . $name . '"; filename* = UTF-8\'\'' . $name);
header('Content-Transfer-Encoding: binary');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 360) . ' GMT');
if ($isRange) {
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
header('Content-Length: ' . ($end - $start + 1));
} else {
header('Content-Length: ' . $size);
}
}
$this->outputRange($this->filePath, $start, $end);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
}
/**
* 解析 HTTP Range 头,返回 [start, end] 或 null(不支持/非法)
*/
protected function parseRange(int $size): ?array
{
$rangeHeader = $this->header['range'] ?? ($_SERVER['HTTP_RANGE'] ?? '');
if ($rangeHeader === '' || !preg_match('/bytes=(\d*)-(\d*)/i', (string) $rangeHeader, $m)) {
return null;
}
$start = $m[1] === '' ? 0 : (int) $m[1];
$end = $m[2] === '' ? ($size - 1) : (int) $m[2];
if ($start < 0 || $end >= $size || $start > $end) {
return null;
}
return ['start' => $start, 'end' => $end];
}
/**
* 分块输出指定区间内容
*/
protected function outputRange(string $path, int $start, int $end): void
{
$fp = fopen($path, 'rb');
if ($fp === false) {
return;
}
if ($start > 0) {
fseek($fp, $start);
}
$remaining = $end - $start + 1;
$chunk = 8192;
while ($remaining > 0 && !feof($fp)) {
$read = $remaining > $chunk ? $chunk : $remaining;
echo fread($fp, $read);
$remaining -= $read;
if (connection_status() !== 0) {
break;
}
}
fclose($fp);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* 模板皮肤安装器(后台「安装模板」/ appmall 下载后部署的接入点)。
*
* 模板包 zip 由 scripts/package_template.php 产出,内部两段前缀:
* - templates/<name>/... → 部署到 <root>/templates/<name>/
* - static/... → 部署到 <root>/public/static/templates/<name>/
*
* 模板不携带 DB 迁移,安装即文件落地;与插件(Addon)的 importsql 机制解耦。
*/
namespace ywxapp\library;
class TemplateInstaller
{
/**
* 从 zip 部署模板到项目。
*
* @param string $zipPath 模板包 zip 绝对路径
* @param string $rootDir 项目根目录(含 templates/ 与 public/
* @return array 部署后的清单 ['name','version','target_addon','tpl_dir','static_dir']
* @throws \RuntimeException
*/
public static function install(string $zipPath, string $rootDir): array
{
if (!is_file($zipPath)) {
throw new \RuntimeException("模板包不存在: {$zipPath}");
}
if (!class_exists('ZipArchive')) {
throw new \RuntimeException('ZipArchive 扩展不可用');
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new \RuntimeException("无法打开模板包: {$zipPath}");
}
// 先探测模板名(templates/<name>/ 前缀)
$name = '';
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if (preg_match('#^templates/([^/]+)/#', $entry, $m)) {
$name = $m[1];
break;
}
}
if ($name === '') {
$zip->close();
throw new \RuntimeException('zip 中未找到 templates/<name>/ 结构,可能不是合法模板包');
}
$tplTarget = rtrim($rootDir, '/\\') . '/templates/' . $name . '/';
$staticTarget = rtrim($rootDir, '/\\') . '/public/static/templates/' . $name . '/';
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === '' || $entry === null) {
continue;
}
$prefix = 'templates/' . $name . '/';
if (strpos($entry, $prefix) === 0) {
self::writeEntry($zip, $entry, $tplTarget . substr($entry, strlen($prefix)));
} elseif (strpos($entry, 'static/') === 0) {
$rel = substr($entry, strlen('static/'));
if ($rel === '') {
continue;
}
self::writeEntry($zip, $entry, $staticTarget . $rel);
}
}
$zip->close();
$metaFile = $tplTarget . 'template.json';
$meta = is_file($metaFile) ? (json_decode(file_get_contents($metaFile), true) ?? []) : [];
return [
'name' => $name,
'version' => $meta['version'] ?? '',
'title' => $meta['title'] ?? $name,
'target_addon' => $meta['target_addon'] ?? [],
'tpl_dir' => $tplTarget,
'static_dir' => $staticTarget,
];
}
/**
* 将 zip 内单个条目写出到目标路径(目录建目录,文件写内容)。
*/
protected static function writeEntry(ZipArchive $zip, string $entry, string $target): void
{
if (substr($entry, -1) === '/') {
if (!is_dir($target)) {
@mkdir($target, 0755, true);
}
return;
}
$dir = dirname($target);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$content = $zip->getFromName($entry);
if ($content === false) {
return;
}
file_put_contents($target, $content);
}
}
+418
View File
@@ -0,0 +1,418 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp <admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
/**
* 模板管理核心(运行时引擎,站点级运营能力)。
*
* 职责:
* - 扫描已安装模板(templates/<name>/template.json
* - 维护 active_map(插件 => 模板名)并热写回 config/template.php
* - 设置激活 / 卸载时清理 SkinOverlay 渲染缓存(runtime/skin/
*
* 与 TemplateInstaller 分工:Installer 负责「从 zip 落地文件」,
* 本类负责「列出 / 激活 / 卸载 / 配置持久化」。两者都不触碰 DB,
* 契合模板不携带 DB 迁移的约定。
*/
class TemplateManager
{
const FILE_HEADER = ""
. "// +----------------------------------------------------------------------\n"
. "// | YwxApp 模板激活配置\n"
. "// | 整站皮肤 + 局部覆盖 + 每插件独立选(Discuz 式卖模板)\n"
. "// | active_map: 插件名 => 模板名;'*' 为全局默认;\n"
. "// | 值为 'default' 或空 = 使用该插件自带视图(不覆盖)。\n"
. "// +----------------------------------------------------------------------\n\n";
/**
* 读取当前激活映射(插件/全局 => 模板名)。
*/
public static function getActiveMap(): array
{
$map = config('template.active_map', []);
return is_array($map) ? $map : [];
}
/**
* 列出所有已安装模板(扫描 templates/<name>/template.json)。
* 返回元素含:name/title/version/author/description/target_addon/
* target_areas/preview/preview_url/applied_to(已被哪些插件激活)
*/
public static function listInstalled(): array
{
$dir = root_path() . 'templates' . DIRECTORY_SEPARATOR;
$out = [];
if (!is_dir($dir)) {
return $out;
}
$map = self::getActiveMap();
foreach (array_diff(scandir($dir), ['.', '..']) as $name) {
$p = $dir . $name . DIRECTORY_SEPARATOR;
if (!is_dir($p)) {
continue;
}
$json = $p . 'template.json';
if (!is_file($json)) {
continue;
}
$meta = json_decode(file_get_contents($json), true) ?? [];
$meta['name'] = $name;
$meta['target_addon'] = $meta['target_addon'] ?? [];
$meta['target_areas'] = $meta['target_areas'] ?? [];
$preview = $meta['preview'] ?? 'preview.png';
$meta['preview_url'] = is_file($p . $preview)
? '/static/templates/' . $name . '/' . $preview
: '';
// 多图预览(template.json 的 previews 数组)+ 首图,去重建灯箱画廊
$previews = $meta['previews'] ?? [];
if (! is_array($previews)) {
$previews = [];
}
$all = array_values(array_unique(array_filter(array_merge([$preview], $previews))));
$urls = [];
foreach ($all as $pg) {
if ($pg !== '' && is_file($p . $pg)) {
$urls[] = '/static/templates/' . $name . '/' . $pg;
}
}
$meta['preview_urls'] = $urls;
$meta['static_url'] = '/static/templates/' . $name . '/';
// 反向推导:哪些插件当前激活了该模板
$meta['applied_to'] = array_keys(array_filter($map, static function ($v) use ($name) {
return $v === $name;
}));
$meta['enabled'] = self::isEnabled($name);
$meta['variable_defs'] = \ywxapp\library\SkinVariables::definitions($name);
$meta['variable_overrides'] = self::getSettings()['variables'][$name] ?? [];
$out[] = $meta;
}
return $out;
}
/**
* 列出可应用模板的插件(含 view 目录的 addon)。
*/
public static function listaddon(): array
{
$addonDir = root_path() . 'addon' . DIRECTORY_SEPARATOR;
$out = [];
if (!is_dir($addonDir)) {
return $out;
}
foreach (array_diff(scandir($addonDir), ['.', '..']) as $name) {
$p = $addonDir . $name;
if (!is_dir($p) || !is_dir($p . DIRECTORY_SEPARATOR . 'view')) {
continue;
}
$title = $name;
if (is_file($p . DIRECTORY_SEPARATOR . 'info.php')) {
$info = include $p . DIRECTORY_SEPARATOR . 'info.php';
$title = $info['title'] ?? $name;
}
$out[] = ['name' => $name, 'title' => $title];
}
return $out;
}
/**
* 设置某插件的激活模板('default'/空 = 回退自带视图)。
* 写回 config 文件 + 更新内存配置 + 清理该插件渲染缓存。
*/
public static function setActive(string $addon, string $template): void
{
$map = self::getActiveMap();
if ($template === 'default' || $template === '') {
unset($map[$addon]);
} else {
$map[$addon] = $template;
}
self::setActiveMap($map);
self::clearOverlayCache($addon);
}
/**
* 读取界面设置(允许会员自选 / 禁用列表 / 变量覆盖)。
*/
public static function getSettings(): array
{
$s = config('template.settings', []);
if (! is_array($s)) {
$s = [];
}
$s['disabled'] = $s['disabled'] ?? [];
$s['allow_member_select'] = $s['allow_member_select'] ?? false;
$s['variables'] = $s['variables'] ?? [];
return $s;
}
/**
* 写回 active_map + settings 到 config/template.php(保留注释头),并同步内存配置。
*/
public static function writeConfig(array $map, array $settings): void
{
$file = config_path() . 'template.php';
$content = "<?php\n" . self::FILE_HEADER
. "return [\n"
. " 'active_map' => " . self::exportMap($map) . ",\n"
. " 'settings' => " . self::exportPhp($settings) . ",\n"
. "];\n";
file_put_contents($file, $content);
// 同步内存配置,使当前请求立即生效(新请求会重新加载文件)
\think\facade\Config::set(['active_map' => $map, 'settings' => $settings], 'template');
}
/**
* 仅更新 active_map,保持 settings 不变。
*/
public static function setActiveMap(array $map): void
{
self::writeConfig($map, self::getSettings());
}
/**
* 仅更新 settings,保持 active_map 不变。
*/
public static function setSettings(array $settings): void
{
self::writeConfig(self::getActiveMap(), $settings);
}
/**
* 列出对指定插件/区域「适用」的模板(已安装 + 启用 + 提供该区域覆盖视图)。
* 供前台会员自选切换器枚举可选项。
*/
public static function listApplicable(string $addon, string $area): array
{
$out = [];
$base = root_path() . 'templates' . DIRECTORY_SEPARATOR;
if (! is_dir($base)) {
return $out;
}
foreach (array_diff(scandir($base), ['.', '..']) as $name) {
$p = $base . $name . DIRECTORY_SEPARATOR;
if (! is_dir($p) || ! self::isEnabled($name)) {
continue;
}
$view = $p . 'view' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR
. $area . DIRECTORY_SEPARATOR;
if (! is_dir($view)) {
continue;
}
$json = $p . 'template.json';
$meta = is_file($json) ? (json_decode(file_get_contents($json), true) ?? []) : [];
$out[] = [
'name' => $name,
'title' => $meta['title'] ?? $name,
'version' => $meta['version'] ?? '',
];
}
return $out;
}
/**
* 生成前台「风格切换器」浮动面板 HTML(含内联脚本)。
* 仅当 allow_member_select 开启且当前插件存在可用模板时返回非空串,否则返回 ''。
* 该面板自动读取/写入 cookie `ywx_skin`(访客级覆盖,无需登录、无需 DB)。
*/
public static function switcherHtml(string $addon, string $area): string
{
$settings = self::getSettings();
if (empty($settings['allow_member_select'])) {
return '';
}
$applicable = self::listApplicable($addon, $area);
if (empty($applicable)) {
return '';
}
$current = (string) \think\facade\Request::cookie('ywx_skin', '');
$opts = '<option value="">默认(跟随站点)</option>';
foreach ($applicable as $t) {
$sel = $t['name'] === $current ? ' selected' : '';
$opts .= '<option value="' . htmlspecialchars($t['name'], ENT_QUOTES) . '"' . $sel . '>'
. htmlspecialchars($t['title'], ENT_QUOTES) . ' (' . htmlspecialchars($t['version'], ENT_QUOTES) . ')</option>';
}
return <<<HTML
<div id="ywx-skin-switcher" style="position:fixed;right:16px;bottom:16px;z-index:99999;background:#fff;border:1px solid #e6e6e6;border-radius:10px;padding:10px 12px;box-shadow:0 4px 18px rgba(0,0,0,.15);font-size:13px;color:#333;max-width:240px;">
<div style="margin-bottom:6px;font-weight:600;color:#666;">风格切换</div>
<select id="ywx-skin-select" style="width:100%;padding:5px 6px;border:1px solid #ddd;border-radius:6px;">{$opts}</select>
</div>
<script>
(function(){
var sel = document.getElementById('ywx-skin-select');
if (!sel) return;
sel.onchange = function () {
var v = sel.value;
document.cookie = 'ywx_skin=' + encodeURIComponent(v) + ';path=/;max-age=' + (v ? 31536000 : 0) + ';samesite=lax';
location.reload();
};
})();
</script>
HTML;
}
/**
* 导出任意值(含嵌套数组/字符串/整型/布尔/null)为合法 PHP 字面量片段。
* 用于把 settings(含变量覆盖嵌套数组)持久化进 config/template.php。
*/
protected static function exportPhp($value, int $indent = 4): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (is_null($value)) {
return 'null';
}
if (is_string($value)) {
return "'" . addslashes($value) . "'";
}
if (is_array($value)) {
if (empty($value)) {
return '[]';
}
$assoc = array_keys($value) !== range(0, count($value) - 1);
$lines = [];
foreach ($value as $k => $v) {
$key = $assoc ? ("'" . addslashes((string) $k) . "' => ") : '';
$lines[] = str_repeat(' ', $indent + 4) . $key . self::exportPhp($v, $indent + 4);
}
return "[\n" . implode(",\n", $lines) . "\n" . str_repeat(' ', $indent) . "]";
}
return 'null';
}
/**
* 保存某模板的变量覆盖值(写入 settings.variables[<name>])。
* @param array $vars 形如 ['primary' => '#ff0000', 'bg' => '#fff']
*/
public static function saveVariables(string $name, array $vars): void
{
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
throw new \RuntimeException('模板标识非法');
}
$settings = self::getSettings();
// 仅保留该模板实际声明的变量键,避免脏数据
$defs = \ywxapp\library\SkinVariables::definitions($name);
$clean = [];
foreach ($defs as $key => $d) {
if (array_key_exists($key, $vars)) {
$clean[$key] = (string) $vars[$key];
}
}
$settings['variables'][$name] = $clean;
self::setSettings($settings);
}
/**
* 启用/禁用模板(禁用后即使被绑定,SkinOverlay 也不生效)。
*/
public static function setEnabled(string $name, bool $enabled): void
{
$settings = self::getSettings();
$disabled = $settings['disabled'] ?? [];
if ($enabled) {
$disabled = array_values(array_diff($disabled, [$name]));
} elseif (! in_array($name, $disabled, true)) {
$disabled[] = $name;
}
$settings['disabled'] = $disabled;
self::setSettings($settings);
}
/**
* 模板是否启用。
*/
public static function isEnabled(string $name): bool
{
$disabled = self::getSettings()['disabled'] ?? [];
return ! in_array($name, $disabled, true);
}
/**
* 卸载模板:删除 templates/<name>/ 与 public/static/templates/<name>/
* 并从 active_map 移除该模板的绑定。
*/
public static function uninstall(string $name): bool
{
$tpl = root_path() . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
$static = root_path() . 'public' . DIRECTORY_SEPARATOR . 'static'
. DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR;
if (is_dir($tpl)) {
self::delTree($tpl);
}
if (is_dir($static)) {
self::delTree($static);
}
// 从激活映射中摘除
$map = self::getActiveMap();
$changed = false;
foreach ($map as $k => $v) {
if ($v === $name) {
unset($map[$k]);
$changed = true;
}
}
if ($changed) {
self::setActiveMap($map);
}
return true;
}
/**
* 启用/禁用 / 全局默认 等状态变更后,清理相关渲染缓存。
* @param string $addon 指定插件只清该插件;'' 清全部
*/
public static function clearOverlayCache(string $addon = ''): void
{
$base = runtime_path() . 'skin' . DIRECTORY_SEPARATOR;
if ($addon === '') {
self::delTree($base);
} else {
self::delTree($base . $addon . DIRECTORY_SEPARATOR);
}
}
/**
* 关联数组导出为美观的 PHP 片段(键/值均为字符串)。
*/
protected static function exportMap(array $map): string
{
if (empty($map)) {
return '[]';
}
$lines = [];
foreach ($map as $k => $v) {
$lines[] = " '" . addslashes((string) $k) . "' => '" . addslashes((string) $v) . "',";
}
return "[\n" . implode("\n", $lines) . "\n ]";
}
/**
* 递归删除目录。
*/
protected static function delTree(string $dir): void
{
$dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (!is_dir($dir)) {
return;
}
foreach (array_diff(scandir($dir), ['.', '..']) as $item) {
$p = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($p)) {
self::delTree($p);
} else {
@unlink($p);
}
}
@rmdir($dir);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
/**
* 模板(皮肤)覆盖解析器 —— Discuz 式局部覆盖核心。
*
* 解析顺序(优先级高 → 低):
* 1. templates/<激活模板>/view/<插件>/<区域>/<页面>.html ← 用户购买的自定义皮肤
* 2. addon/<插件>/view/<区域>/<页面>.html ← 插件默认视图(回退)
*
* 激活映射见 config/template.php 的 active_map
* ['blog' => 'myblog', '*' => 'default'];值 'default'/空 = 用插件自带视图。
*
* 决策(2026-07-28):整站皮肤 + 局部覆盖 + 每插件独立选。
*/
class TemplateResolver
{
/**
* 解析模板覆盖页的绝对路径;未命中返回 null(交由默认视图回退)。
*
* @param string $addon 插件名,如 blog
* @param string $area 区域:frontend / backend / member
* @param string $template 模板名,如 article/detail (与控制器 fetch 入参一致)
* @return string|null
*/
public static function resolve(string $addon, string $area, string $template): ?string
{
// 空模板或跨应用语法(含 @)不处理,直接回退
if ($template === '' || strpos($template, '@') !== false) {
return null;
}
$map = config('template.active_map', []);
if (!is_array($map)) {
return null;
}
$tpl = $map[$addon] ?? ($map['*'] ?? 'default');
if (empty($tpl) || $tpl === 'default') {
return null;
}
$rel = 'templates' . DIRECTORY_SEPARATOR
. $tpl . DIRECTORY_SEPARATOR
. 'view' . DIRECTORY_SEPARATOR
. $addon . DIRECTORY_SEPARATOR
. $area . DIRECTORY_SEPARATOR
. $template . '.html';
$file = root_path() . $rel;
return is_file($file) ? $file : null;
}
}