first commit
This commit is contained in:
154
Xboard/app/Services/Auth/LoginService.php
Normal file
154
Xboard/app/Services/Auth/LoginService.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Plugin\HookManager;
|
||||
use App\Utils\CacheKey;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class LoginService
|
||||
{
|
||||
/**
|
||||
* 处理用户登录
|
||||
*
|
||||
* @param string $email 用户邮箱
|
||||
* @param string $password 用户密码
|
||||
* @return array [成功状态, 用户对象或错误信息]
|
||||
*/
|
||||
public function login(string $email, string $password): array
|
||||
{
|
||||
// 检查密码错误限制
|
||||
if ((int) admin_setting('password_limit_enable', true)) {
|
||||
$passwordErrorCount = (int) Cache::get(CacheKey::get('PASSWORD_ERROR_LIMIT', $email), 0);
|
||||
if ($passwordErrorCount >= (int) admin_setting('password_limit_count', 5)) {
|
||||
return [
|
||||
false,
|
||||
[
|
||||
429,
|
||||
__('There are too many password errors, please try again after :minute minutes.', [
|
||||
'minute' => admin_setting('password_limit_expire', 60)
|
||||
])
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
$user = User::byEmail($email)->first();
|
||||
if (!$user) {
|
||||
return [false, [400, __('Incorrect email or password')]];
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (
|
||||
!Helper::multiPasswordVerify(
|
||||
$user->password_algo,
|
||||
$user->password_salt,
|
||||
$password,
|
||||
$user->password
|
||||
)
|
||||
) {
|
||||
// 增加密码错误计数
|
||||
if ((int) admin_setting('password_limit_enable', true)) {
|
||||
$passwordErrorCount = (int) Cache::get(CacheKey::get('PASSWORD_ERROR_LIMIT', $email), 0);
|
||||
Cache::put(
|
||||
CacheKey::get('PASSWORD_ERROR_LIMIT', $email),
|
||||
(int) $passwordErrorCount + 1,
|
||||
60 * (int) admin_setting('password_limit_expire', 60)
|
||||
);
|
||||
}
|
||||
return [false, [400, __('Incorrect email or password')]];
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if ($user->banned) {
|
||||
return [false, [400, __('Your account has been suspended')]];
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
$user->last_login_at = time();
|
||||
$user->save();
|
||||
|
||||
HookManager::call('user.login.after', $user);
|
||||
return [true, $user];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理密码重置
|
||||
*
|
||||
* @param string $email 用户邮箱
|
||||
* @param string $emailCode 邮箱验证码
|
||||
* @param string $password 新密码
|
||||
* @return array [成功状态, 结果或错误信息]
|
||||
*/
|
||||
public function resetPassword(string $email, string $emailCode, string $password): array
|
||||
{
|
||||
// 检查重置请求限制
|
||||
$forgetRequestLimitKey = CacheKey::get('FORGET_REQUEST_LIMIT', $email);
|
||||
$forgetRequestLimit = (int) Cache::get($forgetRequestLimitKey);
|
||||
if ($forgetRequestLimit >= 3) {
|
||||
return [false, [429, __('Reset failed, Please try again later')]];
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
if ((string) Cache::get(CacheKey::get('EMAIL_VERIFY_CODE', $email)) !== (string) $emailCode) {
|
||||
Cache::put($forgetRequestLimitKey, $forgetRequestLimit ? $forgetRequestLimit + 1 : 1, 300);
|
||||
return [false, [400, __('Incorrect email verification code')]];
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
$user = User::byEmail($email)->first();
|
||||
if (!$user) {
|
||||
return [false, [400, __('This email is not registered in the system')]];
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
$user->password = password_hash($password, PASSWORD_DEFAULT);
|
||||
$user->password_algo = NULL;
|
||||
$user->password_salt = NULL;
|
||||
|
||||
if (!$user->save()) {
|
||||
return [false, [500, __('Reset failed')]];
|
||||
}
|
||||
|
||||
HookManager::call('user.password.reset.after', $user);
|
||||
|
||||
// 清除邮箱验证码
|
||||
Cache::forget(CacheKey::get('EMAIL_VERIFY_CODE', $email));
|
||||
|
||||
return [true, true];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成临时登录令牌和快速登录URL
|
||||
*
|
||||
* @param User $user 用户对象
|
||||
* @param string $redirect 重定向路径
|
||||
* @return string|null 快速登录URL
|
||||
*/
|
||||
public function generateQuickLoginUrl(User $user, ?string $redirect = null): ?string
|
||||
{
|
||||
if (!$user || !$user->exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = Helper::guid();
|
||||
$key = CacheKey::get('TEMP_TOKEN', $code);
|
||||
|
||||
Cache::put($key, $user->id, 60);
|
||||
|
||||
$redirect = $redirect ?: 'dashboard';
|
||||
$loginRedirect = '/#/login?verify=' . $code . '&redirect=' . rawurlencode($redirect);
|
||||
|
||||
if (admin_setting('app_url')) {
|
||||
$url = admin_setting('app_url') . $loginRedirect;
|
||||
} else {
|
||||
$url = url($loginRedirect);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
100
Xboard/app/Services/Auth/MailLinkService.php
Normal file
100
Xboard/app/Services/Auth/MailLinkService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Jobs\SendEmailJob;
|
||||
use App\Models\User;
|
||||
use App\Utils\CacheKey;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class MailLinkService
|
||||
{
|
||||
/**
|
||||
* 处理邮件链接登录逻辑
|
||||
*
|
||||
* @param string $email 用户邮箱
|
||||
* @param string|null $redirect 重定向地址
|
||||
* @return array 返回处理结果
|
||||
*/
|
||||
public function handleMailLink(string $email, ?string $redirect = null): array
|
||||
{
|
||||
if (!(int) admin_setting('login_with_mail_link_enable')) {
|
||||
return [false, [404, null]];
|
||||
}
|
||||
|
||||
if (Cache::get(CacheKey::get('LAST_SEND_LOGIN_WITH_MAIL_LINK_TIMESTAMP', $email))) {
|
||||
return [false, [429, __('Sending frequently, please try again later')]];
|
||||
}
|
||||
|
||||
$user = User::byEmail($email)->first();
|
||||
if (!$user) {
|
||||
return [true, true]; // 成功但用户不存在,保护用户隐私
|
||||
}
|
||||
|
||||
$code = Helper::guid();
|
||||
$key = CacheKey::get('TEMP_TOKEN', $code);
|
||||
Cache::put($key, $user->id, 300);
|
||||
Cache::put(CacheKey::get('LAST_SEND_LOGIN_WITH_MAIL_LINK_TIMESTAMP', $email), time(), 60);
|
||||
|
||||
$redirectUrl = '/#/login?verify=' . $code . '&redirect=' . ($redirect ? $redirect : 'dashboard');
|
||||
if (admin_setting('app_url')) {
|
||||
$link = admin_setting('app_url') . $redirectUrl;
|
||||
} else {
|
||||
$link = url($redirectUrl);
|
||||
}
|
||||
|
||||
$this->sendMailLinkEmail($user, $link);
|
||||
|
||||
return [true, $link];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件链接登录邮件
|
||||
*
|
||||
* @param User $user 用户对象
|
||||
* @param string $link 登录链接
|
||||
* @return void
|
||||
*/
|
||||
private function sendMailLinkEmail(User $user, string $link): void
|
||||
{
|
||||
SendEmailJob::dispatch([
|
||||
'email' => $user->email,
|
||||
'subject' => __('Login to :name', [
|
||||
'name' => admin_setting('app_name', 'XBoard')
|
||||
]),
|
||||
'template_name' => 'login',
|
||||
'template_value' => [
|
||||
'name' => admin_setting('app_name', 'XBoard'),
|
||||
'link' => $link,
|
||||
'url' => admin_setting('app_url')
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Token登录
|
||||
*
|
||||
* @param string $token 登录令牌
|
||||
* @return int|null 用户ID或null
|
||||
*/
|
||||
public function handleTokenLogin(string $token): ?int
|
||||
{
|
||||
$key = CacheKey::get('TEMP_TOKEN', $token);
|
||||
$userId = Cache::get($key);
|
||||
|
||||
if (!$userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::find($userId);
|
||||
|
||||
if (!$user || $user->banned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Cache::forget($key);
|
||||
|
||||
return $userId;
|
||||
}
|
||||
}
|
||||
193
Xboard/app/Services/Auth/RegisterService.php
Normal file
193
Xboard/app/Services/Auth/RegisterService.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\InviteCode;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Services\CaptchaService;
|
||||
use App\Services\Plugin\HookManager;
|
||||
use App\Services\UserService;
|
||||
use App\Utils\CacheKey;
|
||||
use App\Utils\Dict;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class RegisterService
|
||||
{
|
||||
/**
|
||||
* 验证用户注册请求
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return array [是否通过, 错误消息]
|
||||
*/
|
||||
public function validateRegister(Request $request): array
|
||||
{
|
||||
// 检查IP注册限制
|
||||
if ((int) admin_setting('register_limit_by_ip_enable', 0)) {
|
||||
$registerCountByIP = Cache::get(CacheKey::get('REGISTER_IP_RATE_LIMIT', $request->ip())) ?? 0;
|
||||
if ((int) $registerCountByIP >= (int) admin_setting('register_limit_count', 3)) {
|
||||
return [
|
||||
false,
|
||||
[
|
||||
429,
|
||||
__('Register frequently, please try again after :minute minute', [
|
||||
'minute' => admin_setting('register_limit_expire', 60)
|
||||
])
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查验证码
|
||||
$captchaService = app(CaptchaService::class);
|
||||
[$captchaValid, $captchaError] = $captchaService->verify($request);
|
||||
if (!$captchaValid) {
|
||||
return [false, $captchaError];
|
||||
}
|
||||
|
||||
// 检查邮箱白名单
|
||||
if ((int) admin_setting('email_whitelist_enable', 0)) {
|
||||
if (
|
||||
!Helper::emailSuffixVerify(
|
||||
$request->input('email'),
|
||||
admin_setting('email_whitelist_suffix', Dict::EMAIL_WHITELIST_SUFFIX_DEFAULT)
|
||||
)
|
||||
) {
|
||||
return [false, [400, __('Email suffix is not in the Whitelist')]];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查Gmail限制
|
||||
if ((int) admin_setting('email_gmail_limit_enable', 0)) {
|
||||
$prefix = explode('@', $request->input('email'))[0];
|
||||
if (strpos($prefix, '.') !== false || strpos($prefix, '+') !== false) {
|
||||
return [false, [400, __('Gmail alias is not supported')]];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否关闭注册
|
||||
if ((int) admin_setting('stop_register', 0)) {
|
||||
return [false, [400, __('Registration has closed')]];
|
||||
}
|
||||
|
||||
// 检查邀请码要求
|
||||
if ((int) admin_setting('invite_force', 0)) {
|
||||
if (empty($request->input('invite_code'))) {
|
||||
return [false, [422, __('You must use the invitation code to register')]];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查邮箱验证
|
||||
if ((int) admin_setting('email_verify', 0)) {
|
||||
if (empty($request->input('email_code'))) {
|
||||
return [false, [422, __('Email verification code cannot be empty')]];
|
||||
}
|
||||
if ((string) Cache::get(CacheKey::get('EMAIL_VERIFY_CODE', $request->input('email'))) !== (string) $request->input('email_code')) {
|
||||
return [false, [400, __('Incorrect email verification code')]];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查邮箱是否存在
|
||||
$exist = User::byEmail($request->input('email'))->first();
|
||||
if ($exist) {
|
||||
return [false, [400201, __('Email already exists')]];
|
||||
}
|
||||
|
||||
return [true, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理邀请码
|
||||
*
|
||||
* @param string $inviteCode 邀请码
|
||||
* @return int|null 邀请人ID
|
||||
*/
|
||||
public function handleInviteCode(string $inviteCode): int|null
|
||||
{
|
||||
$inviteCodeModel = InviteCode::where('code', $inviteCode)
|
||||
->where('status', InviteCode::STATUS_UNUSED)
|
||||
->first();
|
||||
|
||||
if (!$inviteCodeModel) {
|
||||
if ((int) admin_setting('invite_force', 0)) {
|
||||
throw new ApiException(__('Invalid invitation code'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(int) admin_setting('invite_never_expire', 0)) {
|
||||
$inviteCodeModel->status = InviteCode::STATUS_USED;
|
||||
$inviteCodeModel->save();
|
||||
}
|
||||
|
||||
return $inviteCodeModel->user_id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 注册用户
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return array [成功状态, 用户对象或错误信息]
|
||||
*/
|
||||
public function register(Request $request): array
|
||||
{
|
||||
// 验证注册数据
|
||||
[$valid, $error] = $this->validateRegister($request);
|
||||
if (!$valid) {
|
||||
return [false, $error];
|
||||
}
|
||||
|
||||
HookManager::call('user.register.before', $request);
|
||||
|
||||
$email = $request->input('email');
|
||||
$password = $request->input('password');
|
||||
$inviteCode = $request->input('invite_code');
|
||||
|
||||
// 处理邀请码获取邀请人ID
|
||||
$inviteUserId = null;
|
||||
if ($inviteCode) {
|
||||
$inviteUserId = $this->handleInviteCode($inviteCode);
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
$userService = app(UserService::class);
|
||||
$user = $userService->createUser([
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'invite_user_id' => $inviteUserId,
|
||||
]);
|
||||
|
||||
// 保存用户
|
||||
if (!$user->save()) {
|
||||
return [false, [500, __('Register failed')]];
|
||||
}
|
||||
|
||||
HookManager::call('user.register.after', $user);
|
||||
|
||||
// 清除邮箱验证码
|
||||
if ((int) admin_setting('email_verify', 0)) {
|
||||
Cache::forget(CacheKey::get('EMAIL_VERIFY_CODE', $email));
|
||||
}
|
||||
|
||||
// 更新最近登录时间
|
||||
$user->last_login_at = time();
|
||||
$user->save();
|
||||
|
||||
// 更新IP注册计数
|
||||
if ((int) admin_setting('register_limit_by_ip_enable', 0)) {
|
||||
$registerCountByIP = Cache::get(CacheKey::get('REGISTER_IP_RATE_LIMIT', $request->ip())) ?? 0;
|
||||
Cache::put(
|
||||
CacheKey::get('REGISTER_IP_RATE_LIMIT', $request->ip()),
|
||||
(int) $registerCountByIP + 1,
|
||||
(int) admin_setting('register_limit_expire', 60) * 60
|
||||
);
|
||||
}
|
||||
|
||||
return [true, $user];
|
||||
}
|
||||
}
|
||||
87
Xboard/app/Services/AuthService.php
Normal file
87
Xboard/app/Services/AuthService.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
private User $user;
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function generateAuthData(): array
|
||||
{
|
||||
// Create a new Sanctum token with device info
|
||||
$token = $this->user->createToken(
|
||||
Str::random(20), // token name (device identifier)
|
||||
['*'], // abilities
|
||||
now()->addYear() // expiration
|
||||
);
|
||||
|
||||
// Format token: remove ID prefix and add Bearer
|
||||
$tokenParts = explode('|', $token->plainTextToken);
|
||||
$formattedToken = 'Bearer ' . ($tokenParts[1] ?? $tokenParts[0]);
|
||||
|
||||
return [
|
||||
'token' => $this->user->token,
|
||||
'auth_data' => $formattedToken,
|
||||
'is_admin' => $this->user->is_admin,
|
||||
];
|
||||
}
|
||||
|
||||
public function getSessions(): array
|
||||
{
|
||||
return $this->user->tokens()->get()->toArray();
|
||||
}
|
||||
|
||||
public function removeSession(string $sessionId): bool
|
||||
{
|
||||
$this->user->tokens()->where('id', $sessionId)->delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
public function removeAllSessions(): bool
|
||||
{
|
||||
$this->user->tokens()->delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function findUserByBearerToken(string $bearerToken): ?User
|
||||
{
|
||||
$token = str_replace('Bearer ', '', $bearerToken);
|
||||
|
||||
$accessToken = PersonalAccessToken::findToken($token);
|
||||
|
||||
$tokenable = $accessToken?->tokenable;
|
||||
|
||||
return $tokenable instanceof User ? $tokenable : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密认证数据
|
||||
*
|
||||
* @param string $authorization
|
||||
* @return array|null 用户数据或null
|
||||
*/
|
||||
public static function decryptAuthData(string $authorization): ?array
|
||||
{
|
||||
$user = self::findUserByBearerToken($authorization);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'is_admin' => (bool)$user->is_admin,
|
||||
'is_staff' => (bool)$user->is_staff
|
||||
];
|
||||
}
|
||||
}
|
||||
112
Xboard/app/Services/CaptchaService.php
Normal file
112
Xboard/app/Services/CaptchaService.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use ReCaptcha\ReCaptcha;
|
||||
|
||||
class CaptchaService
|
||||
{
|
||||
/**
|
||||
* 验证人机验证码
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return array [是否通过, 错误消息]
|
||||
*/
|
||||
public function verify(Request $request): array
|
||||
{
|
||||
if (!(int) admin_setting('captcha_enable', 0)) {
|
||||
return [true, null];
|
||||
}
|
||||
|
||||
$captchaType = admin_setting('captcha_type', 'recaptcha');
|
||||
|
||||
return match ($captchaType) {
|
||||
'turnstile' => $this->verifyTurnstile($request),
|
||||
'recaptcha-v3' => $this->verifyRecaptchaV3($request),
|
||||
'recaptcha' => $this->verifyRecaptcha($request),
|
||||
default => [false, [400, __('Invalid captcha type')]]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Cloudflare Turnstile
|
||||
*
|
||||
* @param Request $request
|
||||
* @return array
|
||||
*/
|
||||
private function verifyTurnstile(Request $request): array
|
||||
{
|
||||
$turnstileToken = $request->input('turnstile_token');
|
||||
if (!$turnstileToken) {
|
||||
return [false, [400, __('Invalid code is incorrect')]];
|
||||
}
|
||||
|
||||
$response = Http::post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
|
||||
'secret' => admin_setting('turnstile_secret_key'),
|
||||
'response' => $turnstileToken,
|
||||
'remoteip' => $request->ip()
|
||||
]);
|
||||
|
||||
$result = $response->json();
|
||||
if (!$result['success']) {
|
||||
return [false, [400, __('Invalid code is incorrect')]];
|
||||
}
|
||||
|
||||
return [true, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Google reCAPTCHA v3
|
||||
*
|
||||
* @param Request $request
|
||||
* @return array
|
||||
*/
|
||||
private function verifyRecaptchaV3(Request $request): array
|
||||
{
|
||||
$recaptchaV3Token = $request->input('recaptcha_v3_token');
|
||||
if (!$recaptchaV3Token) {
|
||||
return [false, [400, __('Invalid code is incorrect')]];
|
||||
}
|
||||
|
||||
$recaptcha = new ReCaptcha(admin_setting('recaptcha_v3_secret_key'));
|
||||
$recaptchaResp = $recaptcha->verify($recaptchaV3Token, $request->ip());
|
||||
|
||||
if (!$recaptchaResp->isSuccess()) {
|
||||
return [false, [400, __('Invalid code is incorrect')]];
|
||||
}
|
||||
|
||||
// 检查分数阈值(如果有的话)
|
||||
$score = $recaptchaResp->getScore();
|
||||
$threshold = admin_setting('recaptcha_v3_score_threshold', 0.5);
|
||||
if ($score < $threshold) {
|
||||
return [false, [400, __('Invalid code is incorrect')]];
|
||||
}
|
||||
|
||||
return [true, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Google reCAPTCHA v2
|
||||
*
|
||||
* @param Request $request
|
||||
* @return array
|
||||
*/
|
||||
private function verifyRecaptcha(Request $request): array
|
||||
{
|
||||
$recaptchaData = $request->input('recaptcha_data');
|
||||
if (!$recaptchaData) {
|
||||
return [false, [400, __('Invalid code is incorrect')]];
|
||||
}
|
||||
|
||||
$recaptcha = new ReCaptcha(admin_setting('recaptcha_key'));
|
||||
$recaptchaResp = $recaptcha->verify($recaptchaData);
|
||||
|
||||
if (!$recaptchaResp->isSuccess()) {
|
||||
return [false, [400, __('Invalid code is incorrect')]];
|
||||
}
|
||||
|
||||
return [true, null];
|
||||
}
|
||||
}
|
||||
122
Xboard/app/Services/CouponService.php
Normal file
122
Xboard/app/Services/CouponService.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CouponService
|
||||
{
|
||||
public $coupon;
|
||||
public $planId;
|
||||
public $userId;
|
||||
public $period;
|
||||
|
||||
public function __construct($code)
|
||||
{
|
||||
$this->coupon = Coupon::where('code', $code)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
|
||||
public function use(Order $order): bool
|
||||
{
|
||||
$this->setPlanId($order->plan_id);
|
||||
$this->setUserId($order->user_id);
|
||||
$this->setPeriod($order->period);
|
||||
$this->check();
|
||||
switch ($this->coupon->type) {
|
||||
case 1:
|
||||
$order->discount_amount = $this->coupon->value;
|
||||
break;
|
||||
case 2:
|
||||
$order->discount_amount = $order->total_amount * ($this->coupon->value / 100);
|
||||
break;
|
||||
}
|
||||
if ($order->discount_amount > $order->total_amount) {
|
||||
$order->discount_amount = $order->total_amount;
|
||||
}
|
||||
if ($this->coupon->limit_use !== NULL) {
|
||||
if ($this->coupon->limit_use <= 0)
|
||||
return false;
|
||||
$this->coupon->limit_use = $this->coupon->limit_use - 1;
|
||||
if (!$this->coupon->save()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getId()
|
||||
{
|
||||
return $this->coupon->id;
|
||||
}
|
||||
|
||||
public function getCoupon()
|
||||
{
|
||||
return $this->coupon;
|
||||
}
|
||||
|
||||
public function setPlanId($planId)
|
||||
{
|
||||
$this->planId = $planId;
|
||||
}
|
||||
|
||||
public function setUserId($userId)
|
||||
{
|
||||
$this->userId = $userId;
|
||||
}
|
||||
|
||||
public function setPeriod($period)
|
||||
{
|
||||
if ($period) {
|
||||
$this->period = PlanService::getPeriodKey($period);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkLimitUseWithUser(): bool
|
||||
{
|
||||
$usedCount = Order::where('coupon_id', $this->coupon->id)
|
||||
->where('user_id', $this->userId)
|
||||
->whereNotIn('status', [0, 2])
|
||||
->count();
|
||||
if ($usedCount >= $this->coupon->limit_use_with_user)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function check()
|
||||
{
|
||||
if (!$this->coupon || !$this->coupon->show) {
|
||||
throw new ApiException(__('Invalid coupon'));
|
||||
}
|
||||
if ($this->coupon->limit_use <= 0 && $this->coupon->limit_use !== NULL) {
|
||||
throw new ApiException(__('This coupon is no longer available'));
|
||||
}
|
||||
if (time() < $this->coupon->started_at) {
|
||||
throw new ApiException(__('This coupon has not yet started'));
|
||||
}
|
||||
if (time() > $this->coupon->ended_at) {
|
||||
throw new ApiException(__('This coupon has expired'));
|
||||
}
|
||||
if ($this->coupon->limit_plan_ids && $this->planId) {
|
||||
if (!in_array($this->planId, $this->coupon->limit_plan_ids)) {
|
||||
throw new ApiException(__('The coupon code cannot be used for this subscription'));
|
||||
}
|
||||
}
|
||||
if ($this->coupon->limit_period && $this->period) {
|
||||
if (!in_array($this->period, $this->coupon->limit_period)) {
|
||||
throw new ApiException(__('The coupon code cannot be used for this period'));
|
||||
}
|
||||
}
|
||||
if ($this->coupon->limit_use_with_user !== NULL && $this->userId) {
|
||||
if (!$this->checkLimitUseWithUser()) {
|
||||
throw new ApiException(__('The coupon can only be used :limit_use_with_user per person', [
|
||||
'limit_use_with_user' => $this->coupon->limit_use_with_user
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
Xboard/app/Services/DeviceStateService.php
Normal file
187
Xboard/app/Services/DeviceStateService.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class DeviceStateService
|
||||
{
|
||||
private const PREFIX = 'user_devices:';
|
||||
private const TTL = 300; // device state ttl
|
||||
private const DB_THROTTLE = 10; // update db throttle
|
||||
|
||||
/**
|
||||
* 移除 Redis key 的前缀
|
||||
*/
|
||||
private function removeRedisPrefix(string $key): string
|
||||
{
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
return $prefix ? substr($key, strlen($prefix)) : $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置设备
|
||||
* 用于 HTTP /alive 和 WebSocket report.devices
|
||||
*/
|
||||
public function setDevices(int $userId, int $nodeId, array $ips): void
|
||||
{
|
||||
$key = self::PREFIX . $userId;
|
||||
$timestamp = time();
|
||||
|
||||
$this->removeNodeDevices($nodeId, $userId);
|
||||
|
||||
if (!empty($ips)) {
|
||||
$fields = [];
|
||||
foreach ($ips as $ip) {
|
||||
$fields["{$nodeId}:{$ip}"] = $timestamp;
|
||||
}
|
||||
Redis::hMset($key, $fields);
|
||||
Redis::expire($key, self::TTL);
|
||||
}
|
||||
|
||||
$this->notifyUpdate($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某节点的所有设备数据
|
||||
* 返回: {userId: [ip1, ip2, ...], ...}
|
||||
*/
|
||||
public function getNodeDevices(int $nodeId): array
|
||||
{
|
||||
$keys = Redis::keys(self::PREFIX . '*');
|
||||
$prefix = "{$nodeId}:";
|
||||
$result = [];
|
||||
foreach ($keys as $key) {
|
||||
$actualKey = $this->removeRedisPrefix($key);
|
||||
$uid = (int) substr($actualKey, strlen(self::PREFIX));
|
||||
$data = Redis::hgetall($actualKey);
|
||||
foreach ($data as $field => $timestamp) {
|
||||
if (str_starts_with($field, $prefix)) {
|
||||
$ip = substr($field, strlen($prefix));
|
||||
$result[$uid][] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除某节点某用户的设备
|
||||
*/
|
||||
public function removeNodeDevices(int $nodeId, int $userId): void
|
||||
{
|
||||
$key = self::PREFIX . $userId;
|
||||
$prefix = "{$nodeId}:";
|
||||
|
||||
foreach (Redis::hkeys($key) as $field) {
|
||||
if (str_starts_with($field, $prefix)) {
|
||||
Redis::hdel($key, $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除节点所有设备数据(用于节点断开连接)
|
||||
*/
|
||||
public function clearAllNodeDevices(int $nodeId): array
|
||||
{
|
||||
$oldDevices = $this->getNodeDevices($nodeId);
|
||||
$prefix = "{$nodeId}:";
|
||||
|
||||
foreach ($oldDevices as $userId => $ips) {
|
||||
$key = self::PREFIX . $userId;
|
||||
foreach (Redis::hkeys($key) as $field) {
|
||||
if (str_starts_with($field, $prefix)) {
|
||||
Redis::hdel($key, $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($oldDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* get user device count (deduplicated by IP, filter expired data)
|
||||
*/
|
||||
public function getDeviceCount(int $userId): int
|
||||
{
|
||||
$data = Redis::hgetall(self::PREFIX . $userId);
|
||||
$now = time();
|
||||
$ips = [];
|
||||
|
||||
foreach ($data as $field => $timestamp) {
|
||||
if ($now - $timestamp <= self::TTL) {
|
||||
$ips[] = substr($field, strpos($field, ':') + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return count(array_unique($ips));
|
||||
}
|
||||
|
||||
/**
|
||||
* get user device count (for alivelist interface)
|
||||
*/
|
||||
public function getAliveList(Collection $users): array
|
||||
{
|
||||
if ($users->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($users as $user) {
|
||||
$count = $this->getDeviceCount($user->id);
|
||||
if ($count > 0) {
|
||||
$result[$user->id] = $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* get devices of multiple users (for sync.devices, filter expired data)
|
||||
*/
|
||||
public function getUsersDevices(array $userIds): array
|
||||
{
|
||||
$result = [];
|
||||
$now = time();
|
||||
foreach ($userIds as $userId) {
|
||||
$data = Redis::hgetall(self::PREFIX . $userId);
|
||||
if (!empty($data)) {
|
||||
$ips = [];
|
||||
foreach ($data as $field => $timestamp) {
|
||||
if ($now - $timestamp <= self::TTL) {
|
||||
$ips[] = substr($field, strpos($field, ':') + 1);
|
||||
}
|
||||
}
|
||||
if (!empty($ips)) {
|
||||
$result[$userId] = array_unique($ips);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* notify update (throttle control)
|
||||
*/
|
||||
public function notifyUpdate(int $userId): void
|
||||
{
|
||||
$dbThrottleKey = "device:db_throttle:{$userId}";
|
||||
|
||||
// if (Redis::setnx($dbThrottleKey, 1)) {
|
||||
// Redis::expire($dbThrottleKey, self::DB_THROTTLE);
|
||||
|
||||
User::query()
|
||||
->whereKey($userId)
|
||||
->update([
|
||||
'online_count' => $this->getDeviceCount($userId),
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
// }
|
||||
}
|
||||
}
|
||||
334
Xboard/app/Services/GiftCardService.php
Normal file
334
Xboard/app/Services/GiftCardService.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Http\Resources\PlanResource;
|
||||
use App\Models\GiftCardCode;
|
||||
use App\Models\GiftCardTemplate;
|
||||
use App\Models\GiftCardUsage;
|
||||
use App\Models\Plan;
|
||||
use App\Models\TrafficResetLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GiftCardService
|
||||
{
|
||||
protected readonly GiftCardCode $code;
|
||||
protected readonly GiftCardTemplate $template;
|
||||
protected ?User $user = null;
|
||||
|
||||
public function __construct(string $code)
|
||||
{
|
||||
$this->code = GiftCardCode::where('code', $code)->first()
|
||||
?? throw new ApiException('兑换码不存在');
|
||||
|
||||
$this->template = $this->code->template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置使用用户
|
||||
*/
|
||||
public function setUser(User $user): self
|
||||
{
|
||||
$this->user = $user;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证兑换码
|
||||
*/
|
||||
public function validate(): self
|
||||
{
|
||||
$this->validateIsActive();
|
||||
|
||||
$eligibility = $this->checkUserEligibility();
|
||||
if (!$eligibility['can_redeem']) {
|
||||
throw new ApiException($eligibility['reason']);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证礼品卡本身是否可用 (不检查用户条件)
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function validateIsActive(): self
|
||||
{
|
||||
if (!$this->template->isAvailable()) {
|
||||
throw new ApiException('该礼品卡类型已停用');
|
||||
}
|
||||
|
||||
if (!$this->code->isAvailable()) {
|
||||
throw new ApiException('兑换码不可用:' . $this->code->status_name);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否满足兑换条件 (不抛出异常)
|
||||
*/
|
||||
public function checkUserEligibility(): array
|
||||
{
|
||||
if (!$this->user) {
|
||||
return [
|
||||
'can_redeem' => false,
|
||||
'reason' => '用户信息未提供'
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->template->checkUserConditions($this->user)) {
|
||||
return [
|
||||
'can_redeem' => false,
|
||||
'reason' => '您不满足此礼品卡的使用条件'
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->template->checkUsageLimit($this->user)) {
|
||||
return [
|
||||
'can_redeem' => false,
|
||||
'reason' => '您已达到此礼品卡的使用限制'
|
||||
];
|
||||
}
|
||||
|
||||
return ['can_redeem' => true, 'reason' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用礼品卡
|
||||
*/
|
||||
public function redeem(array $options = []): array
|
||||
{
|
||||
if (!$this->user) {
|
||||
throw new ApiException('未设置使用用户');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($options) {
|
||||
$actualRewards = $this->template->calculateActualRewards($this->user);
|
||||
|
||||
if ($this->template->type === GiftCardTemplate::TYPE_MYSTERY) {
|
||||
$this->code->setActualRewards($actualRewards);
|
||||
}
|
||||
|
||||
$this->giveRewards($actualRewards);
|
||||
|
||||
$inviteRewards = null;
|
||||
if ($this->user->invite_user_id && isset($actualRewards['invite_reward_rate'])) {
|
||||
$inviteRewards = $this->giveInviteRewards($actualRewards);
|
||||
}
|
||||
|
||||
$this->code->markAsUsed($this->user);
|
||||
|
||||
GiftCardUsage::createRecord(
|
||||
$this->code,
|
||||
$this->user,
|
||||
$actualRewards,
|
||||
array_merge($options, [
|
||||
'invite_rewards' => $inviteRewards,
|
||||
'multiplier' => $this->calculateMultiplier(),
|
||||
])
|
||||
);
|
||||
|
||||
return [
|
||||
'rewards' => $actualRewards,
|
||||
'invite_rewards' => $inviteRewards,
|
||||
'code' => $this->code->code,
|
||||
'template_name' => $this->template->name,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放奖励
|
||||
*/
|
||||
protected function giveRewards(array $rewards): void
|
||||
{
|
||||
$userService = app(UserService::class);
|
||||
|
||||
if (isset($rewards['balance']) && $rewards['balance'] > 0) {
|
||||
if (!$userService->addBalance($this->user->id, $rewards['balance'])) {
|
||||
throw new ApiException('余额发放失败');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($rewards['transfer_enable']) && $rewards['transfer_enable'] > 0) {
|
||||
$this->user->transfer_enable = ($this->user->transfer_enable ?? 0) + $rewards['transfer_enable'];
|
||||
}
|
||||
|
||||
if (isset($rewards['device_limit']) && $rewards['device_limit'] > 0) {
|
||||
$this->user->device_limit = ($this->user->device_limit ?? 0) + $rewards['device_limit'];
|
||||
}
|
||||
|
||||
if (isset($rewards['reset_package']) && $rewards['reset_package']) {
|
||||
if ($this->user->plan_id) {
|
||||
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_GIFT_CARD);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($rewards['plan_id'])) {
|
||||
$plan = Plan::find($rewards['plan_id']);
|
||||
if ($plan) {
|
||||
$userService->assignPlan(
|
||||
$this->user,
|
||||
$plan,
|
||||
$rewards['plan_validity_days'] ?? 0
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 只有在不是套餐卡的情况下,才处理独立的有效期奖励
|
||||
if (isset($rewards['expire_days']) && $rewards['expire_days'] > 0) {
|
||||
$userService->extendSubscription($this->user, $rewards['expire_days']);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户更改
|
||||
if (!$this->user->save()) {
|
||||
throw new ApiException('用户信息更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放邀请人奖励
|
||||
*/
|
||||
protected function giveInviteRewards(array $rewards): ?array
|
||||
{
|
||||
if (!$this->user->invite_user_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$inviteUser = User::find($this->user->invite_user_id);
|
||||
if (!$inviteUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $rewards['invite_reward_rate'] ?? 0.2;
|
||||
$inviteRewards = [];
|
||||
|
||||
$userService = app(UserService::class);
|
||||
|
||||
// 邀请人余额奖励
|
||||
if (isset($rewards['balance']) && $rewards['balance'] > 0) {
|
||||
$inviteBalance = intval($rewards['balance'] * $rate);
|
||||
if ($inviteBalance > 0) {
|
||||
$userService->addBalance($inviteUser->id, $inviteBalance);
|
||||
$inviteRewards['balance'] = $inviteBalance;
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请人流量奖励
|
||||
if (isset($rewards['transfer_enable']) && $rewards['transfer_enable'] > 0) {
|
||||
$inviteTransfer = intval($rewards['transfer_enable'] * $rate);
|
||||
if ($inviteTransfer > 0) {
|
||||
$inviteUser->transfer_enable = ($inviteUser->transfer_enable ?? 0) + $inviteTransfer;
|
||||
$inviteUser->save();
|
||||
$inviteRewards['transfer_enable'] = $inviteTransfer;
|
||||
}
|
||||
}
|
||||
|
||||
return $inviteRewards;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算倍率
|
||||
*/
|
||||
protected function calculateMultiplier(): float
|
||||
{
|
||||
return $this->getFestivalBonus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节日加成倍率
|
||||
*/
|
||||
private function getFestivalBonus(): float
|
||||
{
|
||||
$festivalConfig = $this->template->special_config ?? [];
|
||||
$now = time();
|
||||
|
||||
if (
|
||||
isset($festivalConfig['start_time'], $festivalConfig['end_time']) &&
|
||||
$now >= $festivalConfig['start_time'] &&
|
||||
$now <= $festivalConfig['end_time']
|
||||
) {
|
||||
return $festivalConfig['festival_bonus'] ?? 1.0;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取兑换码信息(不包含敏感信息)
|
||||
*/
|
||||
public function getCodeInfo(): array
|
||||
{
|
||||
$info = [
|
||||
'code' => $this->code->code,
|
||||
'template' => [
|
||||
'name' => $this->template->name,
|
||||
'description' => $this->template->description,
|
||||
'type' => $this->template->type,
|
||||
'type_name' => $this->template->type_name,
|
||||
'icon' => $this->template->icon,
|
||||
'background_image' => $this->template->background_image,
|
||||
'theme_color' => $this->template->theme_color,
|
||||
],
|
||||
'status' => $this->code->status,
|
||||
'status_name' => $this->code->status_name,
|
||||
'expires_at' => $this->code->expires_at,
|
||||
'usage_count' => $this->code->usage_count,
|
||||
'max_usage' => $this->code->max_usage,
|
||||
];
|
||||
if ($this->template->type === GiftCardTemplate::TYPE_PLAN) {
|
||||
$plan = Plan::find($this->code->template->rewards['plan_id']);
|
||||
if ($plan) {
|
||||
$info['plan_info'] = PlanResource::make($plan)->toArray(request());
|
||||
}
|
||||
}
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览奖励(不实际发放)
|
||||
*/
|
||||
public function previewRewards(): array
|
||||
{
|
||||
if (!$this->user) {
|
||||
throw new ApiException('未设置使用用户');
|
||||
}
|
||||
|
||||
return $this->template->calculateActualRewards($this->user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取兑换码
|
||||
*/
|
||||
public function getCode(): GiftCardCode
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板
|
||||
*/
|
||||
public function getTemplate(): GiftCardTemplate
|
||||
{
|
||||
return $this->template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志
|
||||
*/
|
||||
protected function logUsage(string $action, array $data = []): void
|
||||
{
|
||||
Log::info('礼品卡使用记录', [
|
||||
'action' => $action,
|
||||
'code' => $this->code->code,
|
||||
'template_id' => $this->template->id,
|
||||
'user_id' => $this->user?->id,
|
||||
'data' => $data,
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
295
Xboard/app/Services/MailService.php
Normal file
295
Xboard/app/Services/MailService.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\SendEmailJob;
|
||||
use App\Models\MailLog;
|
||||
use App\Models\User;
|
||||
use App\Utils\CacheKey;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class MailService
|
||||
{
|
||||
// Render {{key}} / {{key|default}} placeholders.
|
||||
private static function renderPlaceholders(string $template, array $vars): string
|
||||
{
|
||||
if ($template === '' || empty($vars)) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
return (string) preg_replace_callback('/\{\{\s*([a-zA-Z0-9_.-]+)(?:\|([^}]*))?\s*\}\}/', function ($m) use ($vars) {
|
||||
$key = $m[1] ?? '';
|
||||
$default = array_key_exists(2, $m) ? trim((string) $m[2]) : null;
|
||||
|
||||
if (!array_key_exists($key, $vars) || $vars[$key] === null || $vars[$key] === '') {
|
||||
return $default !== null ? $default : $m[0];
|
||||
}
|
||||
|
||||
$value = $vars[$key];
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
|
||||
}, $template);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要发送提醒的用户总数
|
||||
*/
|
||||
public function getTotalUsersNeedRemind(): int
|
||||
{
|
||||
return User::where(function ($query) {
|
||||
$query->where('remind_expire', true)
|
||||
->orWhere('remind_traffic', true);
|
||||
})
|
||||
->where('banned', false)
|
||||
->whereNotNull('email')
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分块处理用户提醒邮件
|
||||
*/
|
||||
public function processUsersInChunks(int $chunkSize, ?callable $progressCallback = null): array
|
||||
{
|
||||
$statistics = [
|
||||
'processed_users' => 0,
|
||||
'expire_emails' => 0,
|
||||
'traffic_emails' => 0,
|
||||
'errors' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
|
||||
User::select('id', 'email', 'expired_at', 'transfer_enable', 'u', 'd', 'remind_expire', 'remind_traffic')
|
||||
->where(function ($query) {
|
||||
$query->where('remind_expire', true)
|
||||
->orWhere('remind_traffic', true);
|
||||
})
|
||||
->where('banned', false)
|
||||
->whereNotNull('email')
|
||||
->chunk($chunkSize, function ($users) use (&$statistics, $progressCallback) {
|
||||
$this->processUserChunk($users, $statistics);
|
||||
|
||||
if ($progressCallback) {
|
||||
$progressCallback();
|
||||
}
|
||||
|
||||
// 定期清理内存
|
||||
if ($statistics['processed_users'] % 2500 === 0) {
|
||||
gc_collect_cycles();
|
||||
}
|
||||
});
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户块
|
||||
*/
|
||||
private function processUserChunk($users, array &$statistics): void
|
||||
{
|
||||
foreach ($users as $user) {
|
||||
try {
|
||||
$statistics['processed_users']++;
|
||||
$emailsSent = 0;
|
||||
|
||||
// 检查并发送过期提醒
|
||||
if ($user->remind_expire && $this->shouldSendExpireRemind($user)) {
|
||||
$this->remindExpire($user);
|
||||
$statistics['expire_emails']++;
|
||||
$emailsSent++;
|
||||
}
|
||||
|
||||
// 检查并发送流量提醒
|
||||
if ($user->remind_traffic && $this->shouldSendTrafficRemind($user)) {
|
||||
$this->remindTraffic($user);
|
||||
$statistics['traffic_emails']++;
|
||||
$emailsSent++;
|
||||
}
|
||||
|
||||
if ($emailsSent === 0) {
|
||||
$statistics['skipped']++;
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$statistics['errors']++;
|
||||
|
||||
Log::error('发送提醒邮件失败', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该发送过期提醒
|
||||
*/
|
||||
private function shouldSendExpireRemind(User $user): bool
|
||||
{
|
||||
if ($user->expired_at === NULL) {
|
||||
return false;
|
||||
}
|
||||
$expiredAt = $user->expired_at;
|
||||
$now = time();
|
||||
if (($expiredAt - 86400) < $now && $expiredAt > $now) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该发送流量提醒
|
||||
*/
|
||||
private function shouldSendTrafficRemind(User $user): bool
|
||||
{
|
||||
if ($user->transfer_enable <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$usedBytes = $user->u + $user->d;
|
||||
$usageRatio = $usedBytes / $user->transfer_enable;
|
||||
|
||||
// 流量使用超过80%时发送提醒
|
||||
return $usageRatio >= 0.8;
|
||||
}
|
||||
|
||||
public function remindTraffic(User $user)
|
||||
{
|
||||
if (!$user->remind_traffic)
|
||||
return;
|
||||
if (!$this->remindTrafficIsWarnValue($user->u, $user->d, $user->transfer_enable))
|
||||
return;
|
||||
$flag = CacheKey::get('LAST_SEND_EMAIL_REMIND_TRAFFIC', $user->id);
|
||||
if (Cache::get($flag))
|
||||
return;
|
||||
if (!Cache::put($flag, 1, 24 * 3600))
|
||||
return;
|
||||
|
||||
SendEmailJob::dispatch([
|
||||
'email' => $user->email,
|
||||
'subject' => __('The traffic usage in :app_name has reached 80%', [
|
||||
'app_name' => admin_setting('app_name', 'XBoard')
|
||||
]),
|
||||
'template_name' => 'remindTraffic',
|
||||
'template_value' => [
|
||||
'name' => admin_setting('app_name', 'XBoard'),
|
||||
'url' => admin_setting('app_url')
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function remindExpire(User $user)
|
||||
{
|
||||
if (!$this->shouldSendExpireRemind($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
SendEmailJob::dispatch([
|
||||
'email' => $user->email,
|
||||
'subject' => __('The service in :app_name is about to expire', [
|
||||
'app_name' => admin_setting('app_name', 'XBoard')
|
||||
]),
|
||||
'template_name' => 'remindExpire',
|
||||
'template_value' => [
|
||||
'name' => admin_setting('app_name', 'XBoard'),
|
||||
'url' => admin_setting('app_url')
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
private function remindTrafficIsWarnValue($u, $d, $transfer_enable)
|
||||
{
|
||||
$ud = $u + $d;
|
||||
if (!$ud)
|
||||
return false;
|
||||
if (!$transfer_enable)
|
||||
return false;
|
||||
$percentage = ($ud / $transfer_enable) * 100;
|
||||
if ($percentage < 80)
|
||||
return false;
|
||||
if ($percentage >= 100)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件
|
||||
*
|
||||
* @param array $params 包含邮件参数的数组,必须包含以下字段:
|
||||
* - email: 收件人邮箱地址
|
||||
* - subject: 邮件主题
|
||||
* - template_name: 邮件模板名称,例如 "welcome" 或 "password_reset"
|
||||
* - template_value: 邮件模板变量,一个关联数组,包含模板中需要替换的变量和对应的值
|
||||
* @return array 包含邮件发送结果的数组,包含以下字段:
|
||||
* - email: 收件人邮箱地址
|
||||
* - subject: 邮件主题
|
||||
* - template_name: 邮件模板名称
|
||||
* - error: 如果邮件发送失败,包含错误信息;否则为 null
|
||||
* @throws \InvalidArgumentException 如果 $params 参数缺少必要的字段,抛出此异常
|
||||
*/
|
||||
public static function sendEmail(array $params)
|
||||
{
|
||||
if (admin_setting('email_host')) {
|
||||
Config::set('mail.host', admin_setting('email_host', config('mail.host')));
|
||||
Config::set('mail.port', admin_setting('email_port', config('mail.port')));
|
||||
Config::set('mail.encryption', admin_setting('email_encryption', config('mail.encryption')));
|
||||
Config::set('mail.username', admin_setting('email_username', config('mail.username')));
|
||||
Config::set('mail.password', admin_setting('email_password', config('mail.password')));
|
||||
Config::set('mail.from.address', admin_setting('email_from_address', config('mail.from.address')));
|
||||
Config::set('mail.from.name', admin_setting('app_name', 'XBoard'));
|
||||
}
|
||||
$email = $params['email'];
|
||||
$subject = $params['subject'];
|
||||
|
||||
$templateValue = $params['template_value'] ?? [];
|
||||
$vars = is_array($templateValue) ? ($templateValue['vars'] ?? []) : [];
|
||||
$contentMode = is_array($templateValue) ? ($templateValue['content_mode'] ?? null) : null;
|
||||
|
||||
if (is_array($vars) && !empty($vars)) {
|
||||
$subject = self::renderPlaceholders((string) $subject, $vars);
|
||||
|
||||
if (is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) {
|
||||
$templateValue['content'] = self::renderPlaceholders($templateValue['content'], $vars);
|
||||
}
|
||||
}
|
||||
|
||||
// Mass mail default: treat admin content as plain text and escape.
|
||||
if ($contentMode === 'text' && is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) {
|
||||
$templateValue['content'] = e($templateValue['content']);
|
||||
}
|
||||
|
||||
$params['template_value'] = $templateValue;
|
||||
$params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $params['template_name'];
|
||||
try {
|
||||
Mail::send(
|
||||
$params['template_name'],
|
||||
$params['template_value'],
|
||||
function ($message) use ($email, $subject) {
|
||||
$message->to($email)->subject($subject);
|
||||
}
|
||||
);
|
||||
$error = null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
$log = [
|
||||
'email' => $params['email'],
|
||||
'subject' => $params['subject'],
|
||||
'template_name' => $params['template_name'],
|
||||
'error' => $error,
|
||||
'config' => config('mail')
|
||||
];
|
||||
MailLog::create($log);
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
77
Xboard/app/Services/NodeRegistry.php
Normal file
77
Xboard/app/Services/NodeRegistry.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
/**
|
||||
* In-memory registry for active WebSocket node connections.
|
||||
* Runs inside the Workerman process.
|
||||
*/
|
||||
class NodeRegistry
|
||||
{
|
||||
/** @var array<int, TcpConnection> nodeId → connection */
|
||||
private static array $connections = [];
|
||||
|
||||
public static function add(int $nodeId, TcpConnection $conn): void
|
||||
{
|
||||
// Close existing connection for this node (if reconnecting)
|
||||
if (isset(self::$connections[$nodeId])) {
|
||||
self::$connections[$nodeId]->close();
|
||||
}
|
||||
self::$connections[$nodeId] = $conn;
|
||||
}
|
||||
|
||||
public static function remove(int $nodeId): void
|
||||
{
|
||||
unset(self::$connections[$nodeId]);
|
||||
}
|
||||
|
||||
public static function get(int $nodeId): ?TcpConnection
|
||||
{
|
||||
return self::$connections[$nodeId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON message to a specific node.
|
||||
*/
|
||||
public static function send(int $nodeId, string $event, array $data): bool
|
||||
{
|
||||
$conn = self::get($nodeId);
|
||||
if (!$conn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'event' => $event,
|
||||
'data' => $data,
|
||||
'timestamp' => time(),
|
||||
]);
|
||||
|
||||
$conn->send($payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the connection for a node by ID, checking if it's still alive.
|
||||
*/
|
||||
public static function isOnline(int $nodeId): bool
|
||||
{
|
||||
$conn = self::get($nodeId);
|
||||
return $conn !== null && $conn->getStatus() === TcpConnection::STATUS_ESTABLISHED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected node IDs.
|
||||
* @return int[]
|
||||
*/
|
||||
public static function getConnectedNodeIds(): array
|
||||
{
|
||||
return array_keys(self::$connections);
|
||||
}
|
||||
|
||||
public static function count(): int
|
||||
{
|
||||
return count(self::$connections);
|
||||
}
|
||||
}
|
||||
143
Xboard/app/Services/NodeSyncService.php
Normal file
143
Xboard/app/Services/NodeSyncService.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class NodeSyncService
|
||||
{
|
||||
/**
|
||||
* Check if node has active WS connection
|
||||
*/
|
||||
public static function isNodeOnline(int $nodeId): bool
|
||||
{
|
||||
return (bool) Cache::get("node_ws_alive:{$nodeId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Push node config update
|
||||
*/
|
||||
public static function notifyConfigUpdated(int $nodeId): void
|
||||
{
|
||||
if (!self::isNodeOnline($nodeId))
|
||||
return;
|
||||
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node)
|
||||
return;
|
||||
|
||||
|
||||
self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push all users to all nodes in the group
|
||||
*/
|
||||
public static function notifyUsersUpdatedByGroup(int $groupId): void
|
||||
{
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $groupId)
|
||||
->get();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
if (!self::isNodeOnline($server->id))
|
||||
continue;
|
||||
|
||||
$users = ServerService::getAvailableUsers($server)->toArray();
|
||||
self::push($server->id, 'sync.users', ['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push user changes (add/remove) to affected nodes
|
||||
*/
|
||||
public static function notifyUserChanged(User $user): void
|
||||
{
|
||||
if (!$user->group_id)
|
||||
return;
|
||||
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $user->group_id)->get();
|
||||
foreach ($servers as $server) {
|
||||
if (!self::isNodeOnline($server->id))
|
||||
continue;
|
||||
|
||||
if ($user->isAvailable()) {
|
||||
self::push($server->id, 'sync.user.delta', [
|
||||
'action' => 'add',
|
||||
'users' => [
|
||||
[
|
||||
'id' => $user->id,
|
||||
'uuid' => $user->uuid,
|
||||
'speed_limit' => $user->speed_limit,
|
||||
'device_limit' => $user->device_limit,
|
||||
]
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
self::push($server->id, 'sync.user.delta', [
|
||||
'action' => 'remove',
|
||||
'users' => [['id' => $user->id]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push user removal from a specific group's nodes
|
||||
*/
|
||||
public static function notifyUserRemovedFromGroup(int $userId, int $groupId): void
|
||||
{
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $groupId)
|
||||
->get();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
if (!self::isNodeOnline($server->id))
|
||||
continue;
|
||||
|
||||
self::push($server->id, 'sync.user.delta', [
|
||||
'action' => 'remove',
|
||||
'users' => [['id' => $userId]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync: push config + users to a node
|
||||
*/
|
||||
public static function notifyFullSync(int $nodeId): void
|
||||
{
|
||||
if (!self::isNodeOnline($nodeId))
|
||||
return;
|
||||
|
||||
$node = Server::find($nodeId);
|
||||
if (!$node)
|
||||
return;
|
||||
|
||||
self::push($nodeId, 'sync.config', ['config' => ServerService::buildNodeConfig($node)]);
|
||||
|
||||
$users = ServerService::getAvailableUsers($node)->toArray();
|
||||
self::push($nodeId, 'sync.users', ['users' => $users]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a push command to Redis — picked up by the Workerman WS server
|
||||
*/
|
||||
public static function push(int $nodeId, string $event, array $data): void
|
||||
{
|
||||
try {
|
||||
Redis::publish('node:push', json_encode([
|
||||
'node_id' => $nodeId,
|
||||
'event' => $event,
|
||||
'data' => $data,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[NodePush] Redis publish failed: {$e->getMessage()}", [
|
||||
'node_id' => $nodeId,
|
||||
'event' => $event,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
429
Xboard/app/Services/OrderService.php
Normal file
429
Xboard/app/Services/OrderService.php
Normal file
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Jobs\OrderHandleJob;
|
||||
use App\Models\Order;
|
||||
use App\Models\Plan;
|
||||
use App\Models\TrafficResetLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Plugin\HookManager;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Carbon\Carbon;
|
||||
use App\Services\PlanService;
|
||||
|
||||
class OrderService
|
||||
{
|
||||
const STR_TO_TIME = [
|
||||
Plan::PERIOD_MONTHLY => 1,
|
||||
Plan::PERIOD_QUARTERLY => 3,
|
||||
Plan::PERIOD_HALF_YEARLY => 6,
|
||||
Plan::PERIOD_YEARLY => 12,
|
||||
Plan::PERIOD_TWO_YEARLY => 24,
|
||||
Plan::PERIOD_THREE_YEARLY => 36
|
||||
];
|
||||
public $order;
|
||||
public $user;
|
||||
|
||||
public function __construct(Order $order)
|
||||
{
|
||||
$this->order = $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an order from a request.
|
||||
*
|
||||
* @param User $user
|
||||
* @param Plan $plan
|
||||
* @param string $period
|
||||
* @param string|null $couponCode
|
||||
* @return Order
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function createFromRequest(
|
||||
User $user,
|
||||
Plan $plan,
|
||||
string $period,
|
||||
?string $couponCode = null,
|
||||
): Order {
|
||||
$userService = app(UserService::class);
|
||||
$planService = new PlanService($plan);
|
||||
|
||||
$planService->validatePurchase($user, $period);
|
||||
HookManager::call('order.create.before', [$user, $plan, $period, $couponCode]);
|
||||
|
||||
return DB::transaction(function () use ($user, $plan, $period, $couponCode, $userService) {
|
||||
$newPeriod = PlanService::getPeriodKey($period);
|
||||
|
||||
$order = new Order([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'period' => $newPeriod,
|
||||
'trade_no' => Helper::generateOrderNo(),
|
||||
'total_amount' => (int) (optional($plan->prices)[$newPeriod] * 100),
|
||||
]);
|
||||
|
||||
$orderService = new self($order);
|
||||
|
||||
if ($couponCode) {
|
||||
$orderService->applyCoupon($couponCode);
|
||||
}
|
||||
|
||||
$orderService->setVipDiscount($user);
|
||||
$orderService->setOrderType($user);
|
||||
$orderService->setInvite(user: $user);
|
||||
|
||||
if ($user->balance && $order->total_amount > 0) {
|
||||
$orderService->handleUserBalance($user, $userService);
|
||||
}
|
||||
|
||||
if (!$order->save()) {
|
||||
throw new ApiException(__('Failed to create order'));
|
||||
}
|
||||
|
||||
HookManager::call('order.create.after', $order);
|
||||
// 兼容旧钩子
|
||||
HookManager::call('order.after_create', $order);
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
public function open(): void
|
||||
{
|
||||
$order = $this->order;
|
||||
$plan = Plan::find($order->plan_id);
|
||||
|
||||
HookManager::call('order.open.before', $order);
|
||||
|
||||
|
||||
DB::transaction(function () use ($order, $plan) {
|
||||
$this->user = User::lockForUpdate()->find($order->user_id);
|
||||
|
||||
if ($order->refund_amount) {
|
||||
$this->user->balance += $order->refund_amount;
|
||||
}
|
||||
|
||||
if ($order->surplus_order_ids) {
|
||||
Order::whereIn('id', $order->surplus_order_ids)
|
||||
->update(['status' => Order::STATUS_DISCOUNTED]);
|
||||
}
|
||||
|
||||
match ((string) $order->period) {
|
||||
Plan::PERIOD_ONETIME => $this->buyByOneTime($plan),
|
||||
Plan::PERIOD_RESET_TRAFFIC => app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER),
|
||||
default => $this->buyByPeriod($order, $plan),
|
||||
};
|
||||
|
||||
$this->setSpeedLimit($plan->speed_limit);
|
||||
$this->setDeviceLimit($plan->device_limit);
|
||||
|
||||
if (!$this->user->save()) {
|
||||
throw new \RuntimeException('用户信息保存失败');
|
||||
}
|
||||
|
||||
$order->status = Order::STATUS_COMPLETED;
|
||||
if (!$order->save()) {
|
||||
throw new \RuntimeException('订单信息保存失败');
|
||||
}
|
||||
});
|
||||
|
||||
$eventId = match ((int) $order->type) {
|
||||
Order::STATUS_PROCESSING => admin_setting('new_order_event_id', 0),
|
||||
Order::TYPE_RENEWAL => admin_setting('renew_order_event_id', 0),
|
||||
Order::TYPE_UPGRADE => admin_setting('change_order_event_id', 0),
|
||||
default => 0,
|
||||
};
|
||||
|
||||
if ($eventId) {
|
||||
$this->openEvent($eventId);
|
||||
}
|
||||
|
||||
HookManager::call('order.open.after', $order);
|
||||
}
|
||||
|
||||
|
||||
public function setOrderType(User $user)
|
||||
{
|
||||
$order = $this->order;
|
||||
if ($order->period === Plan::PERIOD_RESET_TRAFFIC) {
|
||||
$order->type = Order::TYPE_RESET_TRAFFIC;
|
||||
} else if ($user->plan_id !== NULL && $order->plan_id !== $user->plan_id && ($user->expired_at > time() || $user->expired_at === NULL)) {
|
||||
if (!(int) admin_setting('plan_change_enable', 1))
|
||||
throw new ApiException('目前不允许更改订阅,请联系客服或提交工单操作');
|
||||
$order->type = Order::TYPE_UPGRADE;
|
||||
if ((int) admin_setting('surplus_enable', 1))
|
||||
$this->getSurplusValue($user, $order);
|
||||
if ($order->surplus_amount >= $order->total_amount) {
|
||||
$order->refund_amount = (int) ($order->surplus_amount - $order->total_amount);
|
||||
$order->total_amount = 0;
|
||||
} else {
|
||||
$order->total_amount = (int) ($order->total_amount - $order->surplus_amount);
|
||||
}
|
||||
} else if (($user->expired_at === null || $user->expired_at > time()) && $order->plan_id == $user->plan_id) { // 用户订阅未过期或按流量订阅 且购买订阅与当前订阅相同 === 续费
|
||||
$order->type = Order::TYPE_RENEWAL;
|
||||
} else { // 新购
|
||||
$order->type = Order::TYPE_NEW_PURCHASE;
|
||||
}
|
||||
}
|
||||
|
||||
public function setVipDiscount(User $user)
|
||||
{
|
||||
$order = $this->order;
|
||||
if ($user->discount) {
|
||||
$order->discount_amount = $order->discount_amount + ($order->total_amount * ($user->discount / 100));
|
||||
}
|
||||
$order->total_amount = $order->total_amount - $order->discount_amount;
|
||||
}
|
||||
|
||||
public function setInvite(User $user): void
|
||||
{
|
||||
$order = $this->order;
|
||||
if ($user->invite_user_id && ($order->total_amount <= 0))
|
||||
return;
|
||||
$order->invite_user_id = $user->invite_user_id;
|
||||
$inviter = User::find($user->invite_user_id);
|
||||
if (!$inviter)
|
||||
return;
|
||||
$commissionType = (int) $inviter->commission_type;
|
||||
if ($commissionType === User::COMMISSION_TYPE_SYSTEM) {
|
||||
$commissionType = (bool) admin_setting('commission_first_time_enable', true) ? User::COMMISSION_TYPE_ONETIME : User::COMMISSION_TYPE_PERIOD;
|
||||
}
|
||||
$isCommission = false;
|
||||
switch ($commissionType) {
|
||||
case User::COMMISSION_TYPE_PERIOD:
|
||||
$isCommission = true;
|
||||
break;
|
||||
case User::COMMISSION_TYPE_ONETIME:
|
||||
$isCommission = !$this->haveValidOrder($user);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$isCommission)
|
||||
return;
|
||||
if ($inviter->commission_rate) {
|
||||
$order->commission_balance = $order->total_amount * ($inviter->commission_rate / 100);
|
||||
} else {
|
||||
$order->commission_balance = $order->total_amount * (admin_setting('invite_commission', 10) / 100);
|
||||
}
|
||||
}
|
||||
|
||||
private function haveValidOrder(User $user): Order|null
|
||||
{
|
||||
return Order::where('user_id', $user->id)
|
||||
->whereNotIn('status', [Order::STATUS_PENDING, Order::STATUS_CANCELLED])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function getSurplusValue(User $user, Order $order)
|
||||
{
|
||||
if ($user->expired_at === NULL) {
|
||||
$lastOneTimeOrder = Order::where('user_id', $user->id)
|
||||
->where('period', Plan::PERIOD_ONETIME)
|
||||
->where('status', Order::STATUS_COMPLETED)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if (!$lastOneTimeOrder)
|
||||
return;
|
||||
$nowUserTraffic = Helper::transferToGB($user->transfer_enable);
|
||||
if (!$nowUserTraffic)
|
||||
return;
|
||||
$paidTotalAmount = ($lastOneTimeOrder->total_amount + $lastOneTimeOrder->balance_amount);
|
||||
if (!$paidTotalAmount)
|
||||
return;
|
||||
$trafficUnitPrice = $paidTotalAmount / $nowUserTraffic;
|
||||
$notUsedTraffic = $nowUserTraffic - Helper::transferToGB($user->u + $user->d);
|
||||
$result = $trafficUnitPrice * $notUsedTraffic;
|
||||
$order->surplus_amount = (int) ($result > 0 ? $result : 0);
|
||||
$order->surplus_order_ids = Order::where('user_id', $user->id)
|
||||
->where('period', '!=', Plan::PERIOD_RESET_TRAFFIC)
|
||||
->where('status', Order::STATUS_COMPLETED)
|
||||
->pluck('id')
|
||||
->all();
|
||||
} else {
|
||||
$orders = Order::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNotIn('period', [Plan::PERIOD_RESET_TRAFFIC, Plan::PERIOD_ONETIME])
|
||||
->where('status', Order::STATUS_COMPLETED)
|
||||
->get();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
$order->surplus_amount = 0;
|
||||
$order->surplus_order_ids = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$orderAmountSum = $orders->sum(fn($item) => $item->total_amount + $item->balance_amount + $item->surplus_amount - $item->refund_amount);
|
||||
$orderMonthSum = $orders->sum(fn($item) => self::STR_TO_TIME[PlanService::getPeriodKey($item->period)] ?? 0);
|
||||
$firstOrderAt = $orders->min('created_at');
|
||||
$expiredAt = Carbon::createFromTimestamp($firstOrderAt)->addMonths($orderMonthSum);
|
||||
|
||||
$now = now();
|
||||
$totalSeconds = $expiredAt->timestamp - $firstOrderAt;
|
||||
$remainSeconds = max(0, $expiredAt->timestamp - $now->timestamp);
|
||||
$cycleRatio = $totalSeconds > 0 ? $remainSeconds / $totalSeconds : 0;
|
||||
|
||||
$plan = Plan::find($user->plan_id);
|
||||
$totalTraffic = $plan?->transfer_enable * $orderMonthSum;
|
||||
$usedTraffic = Helper::transferToGB($user->u + $user->d);
|
||||
$remainTraffic = max(0, $totalTraffic - $usedTraffic);
|
||||
$trafficRatio = $totalTraffic > 0 ? $remainTraffic / $totalTraffic : 0;
|
||||
|
||||
$ratio = $cycleRatio;
|
||||
if (admin_setting('change_order_event_id', 0) == 1) {
|
||||
$ratio = min($cycleRatio, $trafficRatio);
|
||||
}
|
||||
|
||||
|
||||
$order->surplus_amount = (int) max(0, $orderAmountSum * $ratio);
|
||||
$order->surplus_order_ids = $orders->pluck('id')->all();
|
||||
}
|
||||
}
|
||||
|
||||
public function paid(string $callbackNo)
|
||||
{
|
||||
$order = $this->order;
|
||||
if ($order->status !== Order::STATUS_PENDING)
|
||||
return true;
|
||||
$order->status = Order::STATUS_PROCESSING;
|
||||
$order->paid_at = time();
|
||||
$order->callback_no = $callbackNo;
|
||||
if (!$order->save())
|
||||
return false;
|
||||
try {
|
||||
OrderHandleJob::dispatchSync($order->trade_no);
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancel(): bool
|
||||
{
|
||||
$order = $this->order;
|
||||
HookManager::call('order.cancel.before', $order);
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
$order->status = Order::STATUS_CANCELLED;
|
||||
if (!$order->save()) {
|
||||
throw new \Exception('Failed to save order status.');
|
||||
}
|
||||
if ($order->balance_amount) {
|
||||
$userService = new UserService();
|
||||
if (!$userService->addBalance($order->user_id, $order->balance_amount)) {
|
||||
throw new \Exception('Failed to add balance.');
|
||||
}
|
||||
}
|
||||
DB::commit();
|
||||
HookManager::call('order.cancel.after', $order);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
Log::error($e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function setSpeedLimit($speedLimit)
|
||||
{
|
||||
$this->user->speed_limit = $speedLimit;
|
||||
}
|
||||
|
||||
private function setDeviceLimit($deviceLimit)
|
||||
{
|
||||
$this->user->device_limit = $deviceLimit;
|
||||
}
|
||||
|
||||
private function buyByPeriod(Order $order, Plan $plan)
|
||||
{
|
||||
// change plan process
|
||||
if ((int) $order->type === Order::TYPE_UPGRADE) {
|
||||
$this->user->expired_at = time();
|
||||
}
|
||||
$this->user->transfer_enable = $plan->transfer_enable * 1073741824;
|
||||
// 从一次性转换到循环或者新购的时候,重置流量
|
||||
if ($this->user->expired_at === NULL || $order->type === Order::TYPE_NEW_PURCHASE)
|
||||
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
|
||||
$this->user->plan_id = $plan->id;
|
||||
$this->user->group_id = $plan->group_id;
|
||||
$this->user->expired_at = $this->getTime($order->period, $this->user->expired_at);
|
||||
}
|
||||
|
||||
private function buyByOneTime(Plan $plan)
|
||||
{
|
||||
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
|
||||
$this->user->transfer_enable = $plan->transfer_enable * 1073741824;
|
||||
$this->user->plan_id = $plan->id;
|
||||
$this->user->group_id = $plan->group_id;
|
||||
$this->user->expired_at = NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算套餐到期时间
|
||||
* @param string $periodKey
|
||||
* @param int $timestamp
|
||||
* @return int
|
||||
* @throws ApiException
|
||||
*/
|
||||
private function getTime(string $periodKey, ?int $timestamp = null): int
|
||||
{
|
||||
$timestamp = $timestamp < time() ? time() : $timestamp;
|
||||
$periodKey = PlanService::getPeriodKey($periodKey);
|
||||
|
||||
if (isset(self::STR_TO_TIME[$periodKey])) {
|
||||
$months = self::STR_TO_TIME[$periodKey];
|
||||
return Carbon::createFromTimestamp($timestamp)->addMonths($months)->timestamp;
|
||||
}
|
||||
|
||||
throw new ApiException('无效的套餐周期');
|
||||
}
|
||||
|
||||
private function openEvent($eventId)
|
||||
{
|
||||
switch ((int) $eventId) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
app(TrafficResetService::class)->performReset($this->user, TrafficResetLog::SOURCE_ORDER);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyCoupon(string $couponCode): void
|
||||
{
|
||||
$couponService = new CouponService($couponCode);
|
||||
if (!$couponService->use($this->order)) {
|
||||
throw new ApiException(__('Coupon failed'));
|
||||
}
|
||||
$this->order->coupon_id = $couponService->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of handleUserBalance
|
||||
* @param User $user
|
||||
* @param UserService $userService
|
||||
* @return void
|
||||
*/
|
||||
protected function handleUserBalance(User $user, UserService $userService): void
|
||||
{
|
||||
$remainingBalance = $user->balance - $this->order->total_amount;
|
||||
|
||||
if ($remainingBalance >= 0) {
|
||||
if (!$userService->addBalance($this->order->user_id, -$this->order->total_amount)) {
|
||||
throw new ApiException(__('Insufficient balance'));
|
||||
}
|
||||
$this->order->balance_amount = $this->order->total_amount;
|
||||
$this->order->total_amount = 0;
|
||||
} else {
|
||||
if (!$userService->addBalance($this->order->user_id, -$user->balance)) {
|
||||
throw new ApiException(__('Insufficient balance'));
|
||||
}
|
||||
$this->order->balance_amount = $user->balance;
|
||||
$this->order->total_amount = $this->order->total_amount - $user->balance;
|
||||
}
|
||||
}
|
||||
}
|
||||
135
Xboard/app/Services/PaymentService.php
Normal file
135
Xboard/app/Services/PaymentService.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\Payment;
|
||||
use App\Services\Plugin\PluginManager;
|
||||
use App\Services\Plugin\HookManager;
|
||||
|
||||
class PaymentService
|
||||
{
|
||||
public $method;
|
||||
protected $config;
|
||||
protected $payment;
|
||||
protected $pluginManager;
|
||||
protected $class;
|
||||
|
||||
public function __construct($method, $id = NULL, $uuid = NULL)
|
||||
{
|
||||
$this->method = $method;
|
||||
$this->pluginManager = app(PluginManager::class);
|
||||
|
||||
if ($method === 'temp') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
$paymentModel = Payment::find($id);
|
||||
if (!$paymentModel) {
|
||||
throw new ApiException('payment not found');
|
||||
}
|
||||
$payment = $paymentModel->toArray();
|
||||
}
|
||||
if ($uuid) {
|
||||
$paymentModel = Payment::where('uuid', $uuid)->first();
|
||||
if (!$paymentModel) {
|
||||
throw new ApiException('payment not found');
|
||||
}
|
||||
$payment = $paymentModel->toArray();
|
||||
}
|
||||
|
||||
$this->config = [];
|
||||
if (isset($payment)) {
|
||||
$this->config = is_string($payment['config']) ? json_decode($payment['config'], true) : $payment['config'];
|
||||
$this->config['enable'] = $payment['enable'];
|
||||
$this->config['id'] = $payment['id'];
|
||||
$this->config['uuid'] = $payment['uuid'];
|
||||
$this->config['notify_domain'] = $payment['notify_domain'] ?? '';
|
||||
}
|
||||
|
||||
$paymentMethods = $this->getAvailablePaymentMethods();
|
||||
if (isset($paymentMethods[$this->method])) {
|
||||
$pluginCode = $paymentMethods[$this->method]['plugin_code'];
|
||||
$paymentPlugins = $this->pluginManager->getEnabledPaymentPlugins();
|
||||
foreach ($paymentPlugins as $plugin) {
|
||||
if ($plugin->getPluginCode() === $pluginCode) {
|
||||
$plugin->setConfig($this->config);
|
||||
$this->payment = $plugin;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->payment = new $this->class($this->config);
|
||||
}
|
||||
|
||||
public function notify($params)
|
||||
{
|
||||
if (!$this->config['enable'])
|
||||
throw new ApiException('gate is not enable');
|
||||
return $this->payment->notify($params);
|
||||
}
|
||||
|
||||
public function pay($order)
|
||||
{
|
||||
// custom notify domain name
|
||||
$notifyUrl = url("/api/v1/guest/payment/notify/{$this->method}/{$this->config['uuid']}");
|
||||
if ($this->config['notify_domain']) {
|
||||
$parseUrl = parse_url($notifyUrl);
|
||||
$notifyUrl = $this->config['notify_domain'] . $parseUrl['path'];
|
||||
}
|
||||
|
||||
return $this->payment->pay([
|
||||
'notify_url' => $notifyUrl,
|
||||
'return_url' => source_base_url('/#/order/' . $order['trade_no']),
|
||||
'trade_no' => $order['trade_no'],
|
||||
'total_amount' => $order['total_amount'],
|
||||
'user_id' => $order['user_id'],
|
||||
'stripe_token' => $order['stripe_token']
|
||||
]);
|
||||
}
|
||||
|
||||
public function form()
|
||||
{
|
||||
$form = $this->payment->form();
|
||||
$result = [];
|
||||
foreach ($form as $key => $field) {
|
||||
$result[$key] = [
|
||||
'type' => $field['type'],
|
||||
'label' => $field['label'] ?? '',
|
||||
'placeholder' => $field['placeholder'] ?? '',
|
||||
'description' => $field['description'] ?? '',
|
||||
'value' => $this->config[$key] ?? $field['default'] ?? '',
|
||||
'options' => $field['select_options'] ?? $field['options'] ?? []
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的支付方式
|
||||
*/
|
||||
public function getAvailablePaymentMethods(): array
|
||||
{
|
||||
$methods = [];
|
||||
|
||||
$methods = HookManager::filter('available_payment_methods', $methods);
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支付方式名称列表(用于管理后台)
|
||||
*/
|
||||
public static function getAllPaymentMethodNames(): array
|
||||
{
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$pluginManager->initializeEnabledPlugins();
|
||||
|
||||
$instance = new self('temp');
|
||||
$methods = $instance->getAvailablePaymentMethods();
|
||||
|
||||
return array_keys($methods);
|
||||
}
|
||||
}
|
||||
194
Xboard/app/Services/PlanService.php
Normal file
194
Xboard/app/Services/PlanService.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Exceptions\ApiException;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class PlanService
|
||||
{
|
||||
public Plan $plan;
|
||||
|
||||
public function __construct(Plan $plan)
|
||||
{
|
||||
$this->plan = $plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可销售的订阅计划列表
|
||||
* 条件:show 和 sell 为 true,且容量充足
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAvailablePlans(): Collection
|
||||
{
|
||||
return Plan::where('show', true)
|
||||
->where('sell', true)
|
||||
->orderBy('sort')
|
||||
->get()
|
||||
->filter(function ($plan) {
|
||||
return $this->hasCapacity($plan);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定订阅计划的可用状态
|
||||
* 条件:renew 和 sell 为 true
|
||||
*
|
||||
* @param int $planId
|
||||
* @return Plan|null
|
||||
*/
|
||||
public function getAvailablePlan(int $planId): ?Plan
|
||||
{
|
||||
return Plan::where('id', $planId)
|
||||
->where('sell', true)
|
||||
->where('renew', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定计划是否可用于指定用户
|
||||
*
|
||||
* @param Plan $plan
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
public function isPlanAvailableForUser(Plan $plan, User $user): bool
|
||||
{
|
||||
// 如果是续费
|
||||
if ($user->plan_id === $plan->id) {
|
||||
return $plan->renew;
|
||||
}
|
||||
|
||||
// 如果是新购
|
||||
return $plan->show && $plan->sell && $this->hasCapacity($plan);
|
||||
}
|
||||
|
||||
public function validatePurchase(User $user, string $period): void
|
||||
{
|
||||
if (!$this->plan) {
|
||||
throw new ApiException(__('Subscription plan does not exist'));
|
||||
}
|
||||
|
||||
// 转换周期格式为新版格式
|
||||
$periodKey = self::getPeriodKey($period);
|
||||
$price = $this->plan->prices[$periodKey] ?? null;
|
||||
|
||||
if ($price === null) {
|
||||
throw new ApiException(__('This payment period cannot be purchased, please choose another period'));
|
||||
}
|
||||
|
||||
if ($periodKey === Plan::PERIOD_RESET_TRAFFIC) {
|
||||
$this->validateResetTrafficPurchase($user);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->plan_id !== $this->plan->id && !$this->hasCapacity($this->plan)) {
|
||||
throw new ApiException(__('Current product is sold out'));
|
||||
}
|
||||
|
||||
$this->validatePlanAvailability($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能转换周期格式为新版格式
|
||||
* 如果是新版格式直接返回,如果是旧版格式则转换为新版格式
|
||||
*
|
||||
* @param string $period
|
||||
* @return string
|
||||
*/
|
||||
public static function getPeriodKey(string $period): string
|
||||
{
|
||||
// 如果是新版格式直接返回
|
||||
if (in_array($period, self::getNewPeriods())) {
|
||||
return $period;
|
||||
}
|
||||
|
||||
// 如果是旧版格式则转换为新版格式
|
||||
return Plan::LEGACY_PERIOD_MAPPING[$period] ?? $period;
|
||||
}
|
||||
/**
|
||||
* 只能转换周期格式为旧版本
|
||||
*/
|
||||
public static function convertToLegacyPeriod(string $period): string
|
||||
{
|
||||
$flippedMapping = array_flip(Plan::LEGACY_PERIOD_MAPPING);
|
||||
return $flippedMapping[$period] ?? $period;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的新版周期格式
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getNewPeriods(): array
|
||||
{
|
||||
return array_values(Plan::LEGACY_PERIOD_MAPPING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旧版周期格式
|
||||
*
|
||||
* @param string $period
|
||||
* @return string
|
||||
*/
|
||||
public static function getLegacyPeriod(string $period): string
|
||||
{
|
||||
$flipped = array_flip(Plan::LEGACY_PERIOD_MAPPING);
|
||||
return $flipped[$period] ?? $period;
|
||||
}
|
||||
|
||||
protected function validateResetTrafficPurchase(User $user): void
|
||||
{
|
||||
if (!app(UserService::class)->isAvailable($user) || $this->plan->id !== $user->plan_id) {
|
||||
throw new ApiException(__('Subscription has expired or no active subscription, unable to purchase Data Reset Package'));
|
||||
}
|
||||
}
|
||||
|
||||
protected function validatePlanAvailability(User $user): void
|
||||
{
|
||||
if ((!$this->plan->show && !$this->plan->renew) || (!$this->plan->show && $user->plan_id !== $this->plan->id)) {
|
||||
throw new ApiException(__('This subscription has been sold out, please choose another subscription'));
|
||||
}
|
||||
|
||||
if (!$this->plan->renew && $user->plan_id == $this->plan->id) {
|
||||
throw new ApiException(__('This subscription cannot be renewed, please change to another subscription'));
|
||||
}
|
||||
|
||||
if (!$this->plan->show && $this->plan->renew && !app(UserService::class)->isAvailable($user)) {
|
||||
throw new ApiException(__('This subscription has expired, please change to another subscription'));
|
||||
}
|
||||
}
|
||||
|
||||
public function hasCapacity(Plan $plan): bool
|
||||
{
|
||||
if ($plan->capacity_limit === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$activeUserCount = User::where('plan_id', $plan->id)
|
||||
->where(function ($query) {
|
||||
$query->where('expired_at', '>=', time())
|
||||
->orWhereNull('expired_at');
|
||||
})
|
||||
->count();
|
||||
|
||||
return ($plan->capacity_limit - $activeUserCount) > 0;
|
||||
}
|
||||
|
||||
public function getAvailablePeriods(Plan $plan): array
|
||||
{
|
||||
return array_filter(
|
||||
$plan->getActivePeriods(),
|
||||
fn($period) => isset($plan->prices[$period]) && $plan->prices[$period] > 0
|
||||
);
|
||||
}
|
||||
|
||||
public function canResetTraffic(Plan $plan): bool
|
||||
{
|
||||
return $plan->reset_traffic_method !== Plan::RESET_TRAFFIC_NEVER
|
||||
&& $plan->getResetTrafficPrice() > 0;
|
||||
}
|
||||
}
|
||||
222
Xboard/app/Services/Plugin/AbstractPlugin.php
Normal file
222
Xboard/app/Services/Plugin/AbstractPlugin.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
abstract class AbstractPlugin
|
||||
{
|
||||
protected array $config = [];
|
||||
protected string $basePath;
|
||||
protected string $pluginCode;
|
||||
protected string $namespace;
|
||||
|
||||
public function __construct(string $pluginCode)
|
||||
{
|
||||
$this->pluginCode = $pluginCode;
|
||||
$this->namespace = 'Plugin\\' . Str::studly($pluginCode);
|
||||
$reflection = new \ReflectionClass($this);
|
||||
$this->basePath = dirname($reflection->getFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件代码
|
||||
*/
|
||||
public function getPluginCode(): string
|
||||
{
|
||||
return $this->pluginCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件命名空间
|
||||
*/
|
||||
public function getNamespace(): string
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件基础路径
|
||||
*/
|
||||
public function getBasePath(): string
|
||||
{
|
||||
return $this->basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
public function getConfig(?string $key = null, $default = null): mixed
|
||||
{
|
||||
$config = $this->config;
|
||||
if ($key) {
|
||||
$config = $config[$key] ?? $default;
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视图
|
||||
*/
|
||||
protected function view(string $view, array $data = [], array $mergeData = []): \Illuminate\Contracts\View\View
|
||||
{
|
||||
return view(Str::studly($this->pluginCode) . '::' . $view, $data, $mergeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册动作钩子监听器
|
||||
*/
|
||||
protected function listen(string $hook, callable $callback, int $priority = 20): void
|
||||
{
|
||||
HookManager::register($hook, $callback, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册过滤器钩子
|
||||
*/
|
||||
protected function filter(string $hook, callable $callback, int $priority = 20): void
|
||||
{
|
||||
HookManager::registerFilter($hook, $callback, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
*/
|
||||
protected function removeListener(string $hook): void
|
||||
{
|
||||
HookManager::remove($hook);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 Artisan 命令
|
||||
*/
|
||||
protected function registerCommand(string $commandClass): void
|
||||
{
|
||||
if (class_exists($commandClass)) {
|
||||
app('Illuminate\Contracts\Console\Kernel')->registerCommand(new $commandClass());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件命令目录
|
||||
*/
|
||||
public function registerCommands(): void
|
||||
{
|
||||
$commandsPath = $this->basePath . '/Commands';
|
||||
if (File::exists($commandsPath)) {
|
||||
$files = File::glob($commandsPath . '/*.php');
|
||||
foreach ($files as $file) {
|
||||
$className = pathinfo($file, PATHINFO_FILENAME);
|
||||
$commandClass = $this->namespace . '\\Commands\\' . $className;
|
||||
|
||||
if (class_exists($commandClass)) {
|
||||
$this->registerCommand($commandClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中断当前请求并返回新的响应
|
||||
*
|
||||
* @param Response|string|array $response
|
||||
* @return never
|
||||
*/
|
||||
protected function intercept(Response|string|array $response): never
|
||||
{
|
||||
HookManager::intercept($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件启动时调用
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// 插件启动时的初始化逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件安装时调用
|
||||
*/
|
||||
public function install(): void
|
||||
{
|
||||
// 插件安装时的初始化逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件卸载时调用
|
||||
*/
|
||||
public function cleanup(): void
|
||||
{
|
||||
// 插件卸载时的清理逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件更新时调用
|
||||
*/
|
||||
public function update(string $oldVersion, string $newVersion): void
|
||||
{
|
||||
// 插件更新时的迁移逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件资源URL
|
||||
*/
|
||||
protected function asset(string $path): string
|
||||
{
|
||||
return asset('plugins/' . $this->pluginCode . '/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置项
|
||||
*/
|
||||
protected function getConfigValue(string $key, $default = null)
|
||||
{
|
||||
return $this->config[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件数据库迁移路径
|
||||
*/
|
||||
protected function getMigrationsPath(): string
|
||||
{
|
||||
return $this->basePath . '/database/migrations';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件视图路径
|
||||
*/
|
||||
protected function getViewsPath(): string
|
||||
{
|
||||
return $this->basePath . '/resources/views';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件资源路径
|
||||
*/
|
||||
protected function getAssetsPath(): string
|
||||
{
|
||||
return $this->basePath . '/resources/assets';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register plugin scheduled tasks. Plugins can override this method.
|
||||
*
|
||||
* @param \Illuminate\Console\Scheduling\Schedule $schedule
|
||||
* @return void
|
||||
*/
|
||||
public function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||
{
|
||||
// Plugin can override this method to register scheduled tasks
|
||||
}
|
||||
}
|
||||
286
Xboard/app/Services/Plugin/HookManager.php
Normal file
286
Xboard/app/Services/Plugin/HookManager.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
class HookManager
|
||||
{
|
||||
/**
|
||||
* Container for storing action hooks
|
||||
*
|
||||
* Uses request() to store hook data within the cycle to avoid Octane memory leaks
|
||||
*/
|
||||
public static function getActions(): array
|
||||
{
|
||||
if (!App::has('hook.actions')) {
|
||||
App::instance('hook.actions', []);
|
||||
}
|
||||
|
||||
return App::make('hook.actions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for storing filter hooks
|
||||
*/
|
||||
public static function getFilters(): array
|
||||
{
|
||||
if (!App::has('hook.filters')) {
|
||||
App::instance('hook.filters', []);
|
||||
}
|
||||
|
||||
return App::make('hook.filters');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set action hooks
|
||||
*/
|
||||
protected static function setActions(array $actions): void
|
||||
{
|
||||
App::instance('hook.actions', $actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter hooks
|
||||
*/
|
||||
protected static function setFilters(array $filters): void
|
||||
{
|
||||
App::instance('hook.filters', $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique identifier for callback
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return string
|
||||
*/
|
||||
protected static function getCallableId(callable $callback): string
|
||||
{
|
||||
if (is_object($callback)) {
|
||||
return spl_object_hash($callback);
|
||||
}
|
||||
|
||||
if (is_array($callback) && count($callback) === 2) {
|
||||
[$class, $method] = $callback;
|
||||
|
||||
if (is_object($class)) {
|
||||
return spl_object_hash($class) . '::' . $method;
|
||||
} else {
|
||||
return (string) $class . '::' . $method;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($callback)) {
|
||||
return $callback;
|
||||
}
|
||||
|
||||
return 'callable_' . uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept response
|
||||
*
|
||||
* @param SymfonyResponse|string|array $response New response content
|
||||
* @return never
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function intercept(SymfonyResponse|string|array $response): never
|
||||
{
|
||||
if (is_string($response)) {
|
||||
$response = response($response);
|
||||
} elseif (is_array($response)) {
|
||||
$response = response()->json($response);
|
||||
}
|
||||
|
||||
throw new InterceptResponseException($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger action hook
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @param mixed $payload Data passed to hook
|
||||
* @return void
|
||||
*/
|
||||
public static function call(string $hook, mixed $payload = null): void
|
||||
{
|
||||
$actions = self::getActions();
|
||||
|
||||
if (!isset($actions[$hook])) {
|
||||
return;
|
||||
}
|
||||
|
||||
ksort($actions[$hook]);
|
||||
|
||||
foreach ($actions[$hook] as $callbacks) {
|
||||
foreach ($callbacks as $callback) {
|
||||
$callback($payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger filter hook
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @param mixed $value Value to filter
|
||||
* @param mixed ...$args Other parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public static function filter(string $hook, mixed $value, mixed ...$args): mixed
|
||||
{
|
||||
$filters = self::getFilters();
|
||||
|
||||
if (!isset($filters[$hook])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
ksort($filters[$hook]);
|
||||
|
||||
$result = $value;
|
||||
foreach ($filters[$hook] as $callbacks) {
|
||||
foreach ($callbacks as $callback) {
|
||||
$result = $callback($result, ...$args);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register action hook listener
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @param callable $callback Callback function
|
||||
* @param int $priority Priority
|
||||
* @return void
|
||||
*/
|
||||
public static function register(string $hook, callable $callback, int $priority = 20): void
|
||||
{
|
||||
$actions = self::getActions();
|
||||
|
||||
if (!isset($actions[$hook])) {
|
||||
$actions[$hook] = [];
|
||||
}
|
||||
|
||||
if (!isset($actions[$hook][$priority])) {
|
||||
$actions[$hook][$priority] = [];
|
||||
}
|
||||
|
||||
$actions[$hook][$priority][self::getCallableId($callback)] = $callback;
|
||||
|
||||
self::setActions($actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register filter hook
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @param callable $callback Callback function
|
||||
* @param int $priority Priority
|
||||
* @return void
|
||||
*/
|
||||
public static function registerFilter(string $hook, callable $callback, int $priority = 20): void
|
||||
{
|
||||
$filters = self::getFilters();
|
||||
|
||||
if (!isset($filters[$hook])) {
|
||||
$filters[$hook] = [];
|
||||
}
|
||||
|
||||
if (!isset($filters[$hook][$priority])) {
|
||||
$filters[$hook][$priority] = [];
|
||||
}
|
||||
|
||||
$filters[$hook][$priority][self::getCallableId($callback)] = $callback;
|
||||
|
||||
self::setFilters($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hook listener
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @param callable|null $callback Callback function
|
||||
* @return void
|
||||
*/
|
||||
public static function remove(string $hook, ?callable $callback = null): void
|
||||
{
|
||||
$actions = self::getActions();
|
||||
$filters = self::getFilters();
|
||||
|
||||
if ($callback === null) {
|
||||
if (isset($actions[$hook])) {
|
||||
unset($actions[$hook]);
|
||||
self::setActions($actions);
|
||||
}
|
||||
|
||||
if (isset($filters[$hook])) {
|
||||
unset($filters[$hook]);
|
||||
self::setFilters($filters);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$callbackId = self::getCallableId($callback);
|
||||
|
||||
if (isset($actions[$hook])) {
|
||||
foreach ($actions[$hook] as $priority => $callbacks) {
|
||||
if (isset($callbacks[$callbackId])) {
|
||||
unset($actions[$hook][$priority][$callbackId]);
|
||||
|
||||
if (empty($actions[$hook][$priority])) {
|
||||
unset($actions[$hook][$priority]);
|
||||
}
|
||||
|
||||
if (empty($actions[$hook])) {
|
||||
unset($actions[$hook]);
|
||||
}
|
||||
}
|
||||
}
|
||||
self::setActions($actions);
|
||||
}
|
||||
|
||||
if (isset($filters[$hook])) {
|
||||
foreach ($filters[$hook] as $priority => $callbacks) {
|
||||
if (isset($callbacks[$callbackId])) {
|
||||
unset($filters[$hook][$priority][$callbackId]);
|
||||
|
||||
if (empty($filters[$hook][$priority])) {
|
||||
unset($filters[$hook][$priority]);
|
||||
}
|
||||
|
||||
if (empty($filters[$hook])) {
|
||||
unset($filters[$hook]);
|
||||
}
|
||||
}
|
||||
}
|
||||
self::setFilters($filters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hook exists
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @return bool
|
||||
*/
|
||||
public static function hasHook(string $hook): bool
|
||||
{
|
||||
$actions = self::getActions();
|
||||
$filters = self::getFilters();
|
||||
|
||||
return isset($actions[$hook]) || isset($filters[$hook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all hooks (called when Octane resets)
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
App::instance('hook.actions', []);
|
||||
App::instance('hook.filters', []);
|
||||
}
|
||||
}
|
||||
22
Xboard/app/Services/Plugin/InterceptResponseException.php
Normal file
22
Xboard/app/Services/Plugin/InterceptResponseException.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use Exception;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InterceptResponseException extends Exception
|
||||
{
|
||||
protected Response $response;
|
||||
|
||||
public function __construct(Response $response)
|
||||
{
|
||||
parent::__construct('Response intercepted');
|
||||
$this->response = $response;
|
||||
}
|
||||
|
||||
public function getResponse(): Response
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
}
|
||||
111
Xboard/app/Services/Plugin/PluginConfigService.php
Normal file
111
Xboard/app/Services/Plugin/PluginConfigService.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class PluginConfigService
|
||||
{
|
||||
protected $pluginManager;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pluginManager = app(PluginManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return array
|
||||
*/
|
||||
public function getConfig(string $pluginCode): array
|
||||
{
|
||||
$defaultConfig = $this->getDefaultConfig($pluginCode);
|
||||
if (empty($defaultConfig)) {
|
||||
return [];
|
||||
}
|
||||
$dbConfig = $this->getDbConfig($pluginCode);
|
||||
|
||||
$result = [];
|
||||
foreach ($defaultConfig as $key => $item) {
|
||||
$result[$key] = [
|
||||
'type' => $item['type'],
|
||||
'label' => $item['label'] ?? '',
|
||||
'placeholder' => $item['placeholder'] ?? '',
|
||||
'description' => $item['description'] ?? '',
|
||||
'value' => $dbConfig[$key] ?? $item['default'],
|
||||
'options' => $item['options'] ?? []
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @param array $config
|
||||
* @return bool
|
||||
*/
|
||||
public function updateConfig(string $pluginCode, array $config): bool
|
||||
{
|
||||
$defaultConfig = $this->getDefaultConfig($pluginCode);
|
||||
if (empty($defaultConfig)) {
|
||||
throw new \Exception('插件配置结构不存在');
|
||||
}
|
||||
$values = [];
|
||||
foreach ($config as $key => $value) {
|
||||
if (!isset($defaultConfig[$key])) {
|
||||
continue;
|
||||
}
|
||||
$values[$key] = $value;
|
||||
}
|
||||
Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->update([
|
||||
'config' => json_encode($values),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件默认配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return array
|
||||
*/
|
||||
protected function getDefaultConfig(string $pluginCode): array
|
||||
{
|
||||
$configFile = $this->pluginManager->getPluginPath($pluginCode) . '/config.json';
|
||||
if (!File::exists($configFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
return $config['config'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库中的配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return array
|
||||
*/
|
||||
public function getDbConfig(string $pluginCode): array
|
||||
{
|
||||
$plugin = Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->first();
|
||||
|
||||
if (!$plugin || empty($plugin->config)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode($plugin->config, true);
|
||||
}
|
||||
}
|
||||
727
Xboard/app/Services/Plugin/PluginManager.php
Normal file
727
Xboard/app/Services/Plugin/PluginManager.php
Normal file
@@ -0,0 +1,727 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PluginManager
|
||||
{
|
||||
protected string $pluginPath;
|
||||
protected array $loadedPlugins = [];
|
||||
protected bool $pluginsInitialized = false;
|
||||
protected array $configTypesCache = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pluginPath = base_path('plugins');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件的命名空间
|
||||
*/
|
||||
public function getPluginNamespace(string $pluginCode): string
|
||||
{
|
||||
return 'Plugin\\' . Str::studly($pluginCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件的基础路径
|
||||
*/
|
||||
public function getPluginPath(string $pluginCode): string
|
||||
{
|
||||
return $this->pluginPath . '/' . Str::studly($pluginCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件类
|
||||
*/
|
||||
protected function loadPlugin(string $pluginCode): ?AbstractPlugin
|
||||
{
|
||||
if (isset($this->loadedPlugins[$pluginCode])) {
|
||||
return $this->loadedPlugins[$pluginCode];
|
||||
}
|
||||
|
||||
$pluginClass = $this->getPluginNamespace($pluginCode) . '\\Plugin';
|
||||
|
||||
if (!class_exists($pluginClass)) {
|
||||
$pluginFile = $this->getPluginPath($pluginCode) . '/Plugin.php';
|
||||
if (!File::exists($pluginFile)) {
|
||||
Log::warning("Plugin class file not found: {$pluginFile}");
|
||||
Plugin::query()->where('code', $pluginCode)->delete();
|
||||
return null;
|
||||
}
|
||||
require_once $pluginFile;
|
||||
}
|
||||
|
||||
if (!class_exists($pluginClass)) {
|
||||
Log::error("Plugin class not found: {$pluginClass}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$plugin = new $pluginClass($pluginCode);
|
||||
$this->loadedPlugins[$pluginCode] = $plugin;
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件的服务提供者
|
||||
*/
|
||||
protected function registerServiceProvider(string $pluginCode): void
|
||||
{
|
||||
$providerClass = $this->getPluginNamespace($pluginCode) . '\\Providers\\PluginServiceProvider';
|
||||
|
||||
if (class_exists($providerClass)) {
|
||||
app()->register($providerClass);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件的路由
|
||||
*/
|
||||
protected function loadRoutes(string $pluginCode): void
|
||||
{
|
||||
$routesPath = $this->getPluginPath($pluginCode) . '/routes';
|
||||
if (File::exists($routesPath)) {
|
||||
$webRouteFile = $routesPath . '/web.php';
|
||||
$apiRouteFile = $routesPath . '/api.php';
|
||||
if (File::exists($webRouteFile)) {
|
||||
Route::middleware('web')
|
||||
->namespace($this->getPluginNamespace($pluginCode) . '\\Controllers')
|
||||
->group(function () use ($webRouteFile) {
|
||||
require $webRouteFile;
|
||||
});
|
||||
}
|
||||
if (File::exists($apiRouteFile)) {
|
||||
Route::middleware('api')
|
||||
->namespace($this->getPluginNamespace($pluginCode) . '\\Controllers')
|
||||
->group(function () use ($apiRouteFile) {
|
||||
require $apiRouteFile;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件的视图
|
||||
*/
|
||||
protected function loadViews(string $pluginCode): void
|
||||
{
|
||||
$viewsPath = $this->getPluginPath($pluginCode) . '/resources/views';
|
||||
if (File::exists($viewsPath)) {
|
||||
View::addNamespace(Str::studly($pluginCode), $viewsPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件命令
|
||||
*/
|
||||
protected function registerPluginCommands(string $pluginCode, AbstractPlugin $pluginInstance): void
|
||||
{
|
||||
try {
|
||||
// 调用插件的命令注册方法
|
||||
$pluginInstance->registerCommands();
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to register commands for plugin '{$pluginCode}': " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
*/
|
||||
public function install(string $pluginCode): bool
|
||||
{
|
||||
$configFile = $this->getPluginPath($pluginCode) . '/config.json';
|
||||
|
||||
if (!File::exists($configFile)) {
|
||||
throw new \Exception('Plugin config file not found');
|
||||
}
|
||||
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
if (!$this->validateConfig($config)) {
|
||||
throw new \Exception('Invalid plugin config');
|
||||
}
|
||||
|
||||
// 检查插件是否已安装
|
||||
if (Plugin::where('code', $pluginCode)->exists()) {
|
||||
throw new \Exception('Plugin already installed');
|
||||
}
|
||||
|
||||
// 检查依赖
|
||||
if (!$this->checkDependencies($config['require'] ?? [])) {
|
||||
throw new \Exception('Dependencies not satisfied');
|
||||
}
|
||||
|
||||
// 运行数据库迁移
|
||||
$this->runMigrations(pluginCode: $pluginCode);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// 提取配置默认值
|
||||
$defaultValues = $this->extractDefaultConfig($config);
|
||||
|
||||
// 创建插件实例
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
|
||||
// 注册到数据库
|
||||
Plugin::create([
|
||||
'code' => $pluginCode,
|
||||
'name' => $config['name'],
|
||||
'version' => $config['version'],
|
||||
'type' => $config['type'] ?? Plugin::TYPE_FEATURE,
|
||||
'is_enabled' => false,
|
||||
'config' => json_encode($defaultValues),
|
||||
'installed_at' => now(),
|
||||
]);
|
||||
|
||||
// 运行插件安装方法
|
||||
if (method_exists($plugin, 'install')) {
|
||||
$plugin->install();
|
||||
}
|
||||
|
||||
// 发布插件资源
|
||||
$this->publishAssets($pluginCode);
|
||||
|
||||
DB::commit();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
if (DB::transactionLevel() > 0) {
|
||||
DB::rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取插件默认配置
|
||||
*/
|
||||
protected function extractDefaultConfig(array $config): array
|
||||
{
|
||||
$defaultValues = [];
|
||||
if (isset($config['config']) && is_array($config['config'])) {
|
||||
foreach ($config['config'] as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$defaultValues[$key] = $item['default'] ?? null;
|
||||
} else {
|
||||
$defaultValues[$key] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $defaultValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行插件数据库迁移
|
||||
*/
|
||||
protected function runMigrations(string $pluginCode): void
|
||||
{
|
||||
$migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations';
|
||||
|
||||
if (File::exists($migrationsPath)) {
|
||||
Artisan::call('migrate', [
|
||||
'--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations",
|
||||
'--force' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚插件数据库迁移
|
||||
*/
|
||||
protected function runMigrationsRollback(string $pluginCode): void
|
||||
{
|
||||
$migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations';
|
||||
|
||||
if (File::exists($migrationsPath)) {
|
||||
Artisan::call('migrate:rollback', [
|
||||
'--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations",
|
||||
'--force' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布插件资源
|
||||
*/
|
||||
protected function publishAssets(string $pluginCode): void
|
||||
{
|
||||
$assetsPath = $this->getPluginPath($pluginCode) . '/resources/assets';
|
||||
if (File::exists($assetsPath)) {
|
||||
$publishPath = public_path('plugins/' . $pluginCode);
|
||||
File::ensureDirectoryExists($publishPath);
|
||||
File::copyDirectory($assetsPath, $publishPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置文件
|
||||
*/
|
||||
protected function validateConfig(array $config): bool
|
||||
{
|
||||
$requiredFields = [
|
||||
'name',
|
||||
'code',
|
||||
'version',
|
||||
'description',
|
||||
'author'
|
||||
];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($config[$field]) || empty($config[$field])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证插件代码格式
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $config['code'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证版本号格式
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $config['version'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证插件类型
|
||||
if (isset($config['type'])) {
|
||||
$validTypes = ['feature', 'payment'];
|
||||
if (!in_array($config['type'], $validTypes)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用插件
|
||||
*/
|
||||
public function enable(string $pluginCode): bool
|
||||
{
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
|
||||
if (!$plugin) {
|
||||
Plugin::where('code', $pluginCode)->delete();
|
||||
throw new \Exception('Plugin not found: ' . $pluginCode);
|
||||
}
|
||||
|
||||
// 获取插件配置
|
||||
$dbPlugin = Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->first();
|
||||
|
||||
if ($dbPlugin && !empty($dbPlugin->config)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($pluginCode, $values);
|
||||
$plugin->setConfig($values);
|
||||
}
|
||||
|
||||
// 注册服务提供者
|
||||
$this->registerServiceProvider($pluginCode);
|
||||
|
||||
// 加载路由
|
||||
$this->loadRoutes($pluginCode);
|
||||
|
||||
// 加载视图
|
||||
$this->loadViews($pluginCode);
|
||||
|
||||
// 更新数据库状态
|
||||
Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->update([
|
||||
'is_enabled' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
// 初始化插件
|
||||
$plugin->boot();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用插件
|
||||
*/
|
||||
public function disable(string $pluginCode): bool
|
||||
{
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
if (!$plugin) {
|
||||
throw new \Exception('Plugin not found');
|
||||
}
|
||||
|
||||
Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->update([
|
||||
'is_enabled' => false,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$plugin->cleanup();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
*/
|
||||
public function uninstall(string $pluginCode): bool
|
||||
{
|
||||
$this->disable($pluginCode);
|
||||
$this->runMigrationsRollback($pluginCode);
|
||||
Plugin::query()->where('code', $pluginCode)->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(string $pluginCode): bool
|
||||
{
|
||||
// 先卸载插件
|
||||
if (Plugin::where('code', $pluginCode)->exists()) {
|
||||
$this->uninstall($pluginCode);
|
||||
}
|
||||
|
||||
$pluginPath = $this->getPluginPath($pluginCode);
|
||||
if (!File::exists($pluginPath)) {
|
||||
throw new \Exception('插件不存在');
|
||||
}
|
||||
|
||||
// 删除插件目录
|
||||
File::deleteDirectory($pluginPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查依赖关系
|
||||
*/
|
||||
protected function checkDependencies(array $requires): bool
|
||||
{
|
||||
foreach ($requires as $package => $version) {
|
||||
if ($package === 'xboard') {
|
||||
// 检查xboard版本
|
||||
// 实现版本比较逻辑
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级插件
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function update(string $pluginCode): bool
|
||||
{
|
||||
$dbPlugin = Plugin::where('code', $pluginCode)->first();
|
||||
if (!$dbPlugin) {
|
||||
throw new \Exception('Plugin not installed: ' . $pluginCode);
|
||||
}
|
||||
|
||||
// 获取插件配置文件中的最新版本
|
||||
$configFile = $this->getPluginPath($pluginCode) . '/config.json';
|
||||
if (!File::exists($configFile)) {
|
||||
throw new \Exception('Plugin config file not found');
|
||||
}
|
||||
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
if (!$config || !isset($config['version'])) {
|
||||
throw new \Exception('Invalid plugin config or missing version');
|
||||
}
|
||||
|
||||
$newVersion = $config['version'];
|
||||
$oldVersion = $dbPlugin->version;
|
||||
|
||||
if (version_compare($newVersion, $oldVersion, '<=')) {
|
||||
throw new \Exception('Plugin is already up to date');
|
||||
}
|
||||
|
||||
$this->disable($pluginCode);
|
||||
$this->runMigrations($pluginCode);
|
||||
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
if ($plugin) {
|
||||
if (!empty($dbPlugin->config)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($pluginCode, $values);
|
||||
$plugin->setConfig($values);
|
||||
}
|
||||
|
||||
$plugin->update($oldVersion, $newVersion);
|
||||
}
|
||||
|
||||
$dbPlugin->update([
|
||||
'version' => $newVersion,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->enable($pluginCode);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传插件
|
||||
*
|
||||
* @param \Illuminate\Http\UploadedFile $file
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function upload($file): bool
|
||||
{
|
||||
$tmpPath = storage_path('tmp/plugins');
|
||||
if (!File::exists($tmpPath)) {
|
||||
File::makeDirectory($tmpPath, 0755, true);
|
||||
}
|
||||
|
||||
$extractPath = $tmpPath . '/' . uniqid();
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($file->path()) !== true) {
|
||||
throw new \Exception('无法打开插件包文件');
|
||||
}
|
||||
|
||||
$zip->extractTo($extractPath);
|
||||
$zip->close();
|
||||
|
||||
$configFile = File::glob($extractPath . '/*/config.json');
|
||||
if (empty($configFile)) {
|
||||
$configFile = File::glob($extractPath . '/config.json');
|
||||
}
|
||||
|
||||
if (empty($configFile)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
throw new \Exception('插件包格式错误:缺少配置文件');
|
||||
}
|
||||
|
||||
$pluginPath = dirname(reset($configFile));
|
||||
$config = json_decode(File::get($pluginPath . '/config.json'), true);
|
||||
|
||||
if (!$this->validateConfig($config)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
throw new \Exception('插件配置文件格式错误');
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . '/' . Str::studly($config['code']);
|
||||
if (File::exists($targetPath)) {
|
||||
$installedConfigPath = $targetPath . '/config.json';
|
||||
if (!File::exists($installedConfigPath)) {
|
||||
throw new \Exception('已安装插件缺少配置文件,无法判断是否可升级');
|
||||
}
|
||||
$installedConfig = json_decode(File::get($installedConfigPath), true);
|
||||
|
||||
$oldVersion = $installedConfig['version'] ?? null;
|
||||
$newVersion = $config['version'] ?? null;
|
||||
if (!$oldVersion || !$newVersion) {
|
||||
throw new \Exception('插件缺少版本号,无法判断是否可升级');
|
||||
}
|
||||
if (version_compare($newVersion, $oldVersion, '<=')) {
|
||||
throw new \Exception('上传插件版本不高于已安装版本,无法升级');
|
||||
}
|
||||
|
||||
File::deleteDirectory($targetPath);
|
||||
}
|
||||
|
||||
File::copyDirectory($pluginPath, $targetPath);
|
||||
File::deleteDirectory($pluginPath);
|
||||
File::deleteDirectory($extractPath);
|
||||
|
||||
if (Plugin::where('code', $config['code'])->exists()) {
|
||||
return $this->update($config['code']);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all enabled plugins from the database.
|
||||
* This method ensures that plugins are loaded, and their routes, views,
|
||||
* and service providers are registered only once per request cycle.
|
||||
*/
|
||||
public function initializeEnabledPlugins(): void
|
||||
{
|
||||
if ($this->pluginsInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
$enabledPlugins = Plugin::where('is_enabled', true)->get();
|
||||
|
||||
foreach ($enabledPlugins as $dbPlugin) {
|
||||
try {
|
||||
$pluginCode = $dbPlugin->code;
|
||||
|
||||
$pluginInstance = $this->loadPlugin($pluginCode);
|
||||
if (!$pluginInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($dbPlugin->config)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($pluginCode, $values);
|
||||
$pluginInstance->setConfig($values);
|
||||
}
|
||||
|
||||
$this->registerServiceProvider($pluginCode);
|
||||
$this->loadRoutes($pluginCode);
|
||||
$this->loadViews($pluginCode);
|
||||
$this->registerPluginCommands($pluginCode, $pluginInstance);
|
||||
|
||||
$pluginInstance->boot();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to initialize plugin '{$dbPlugin->code}': " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->pluginsInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register scheduled tasks for all enabled plugins.
|
||||
* Called from Console Kernel. Only loads main plugin class and config for scheduling.
|
||||
* Avoids full HTTP/plugin boot overhead.
|
||||
*
|
||||
* @param \Illuminate\Console\Scheduling\Schedule $schedule
|
||||
*/
|
||||
public function registerPluginSchedules(Schedule $schedule): void
|
||||
{
|
||||
Plugin::where('is_enabled', true)
|
||||
->get()
|
||||
->each(function ($dbPlugin) use ($schedule) {
|
||||
try {
|
||||
$pluginInstance = $this->loadPlugin($dbPlugin->code);
|
||||
if (!$pluginInstance) {
|
||||
return;
|
||||
}
|
||||
if (!empty($dbPlugin->config)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($dbPlugin->code, $values);
|
||||
$pluginInstance->setConfig($values);
|
||||
}
|
||||
$pluginInstance->schedule($schedule);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to register schedule for plugin '{$dbPlugin->code}': " . $e->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled plugin instances.
|
||||
*
|
||||
* This method ensures that all enabled plugins are initialized and then returns them.
|
||||
* It's the central point for accessing active plugins.
|
||||
*
|
||||
* @return array<AbstractPlugin>
|
||||
*/
|
||||
public function getEnabledPlugins(): array
|
||||
{
|
||||
$this->initializeEnabledPlugins();
|
||||
|
||||
$enabledPluginCodes = Plugin::where('is_enabled', true)
|
||||
->pluck('code')
|
||||
->all();
|
||||
|
||||
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled plugins by type
|
||||
*/
|
||||
public function getEnabledPluginsByType(string $type): array
|
||||
{
|
||||
$this->initializeEnabledPlugins();
|
||||
|
||||
$enabledPluginCodes = Plugin::where('is_enabled', true)
|
||||
->byType($type)
|
||||
->pluck('code')
|
||||
->all();
|
||||
|
||||
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled payment plugins
|
||||
*/
|
||||
public function getEnabledPaymentPlugins(): array
|
||||
{
|
||||
return $this->getEnabledPluginsByType('payment');
|
||||
}
|
||||
|
||||
/**
|
||||
* install default plugins
|
||||
*/
|
||||
public static function installDefaultPlugins(): void
|
||||
{
|
||||
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
|
||||
if (!Plugin::where('code', $pluginCode)->exists()) {
|
||||
$pluginManager = app(self::class);
|
||||
$pluginManager->install($pluginCode);
|
||||
$pluginManager->enable($pluginCode);
|
||||
Log::info("Installed and enabled default plugin: {$pluginCode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 config.json 的类型信息对配置值进行类型转换(仅处理 type=json 键)。
|
||||
*/
|
||||
protected function castConfigValuesByType(string $pluginCode, array $values): array
|
||||
{
|
||||
$types = $this->getConfigTypes($pluginCode);
|
||||
foreach ($values as $key => $value) {
|
||||
$type = $types[$key] ?? null;
|
||||
|
||||
if ($type === 'json') {
|
||||
if (is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$values[$key] = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并缓存插件 config.json 中的键类型映射。
|
||||
*/
|
||||
protected function getConfigTypes(string $pluginCode): array
|
||||
{
|
||||
if (isset($this->configTypesCache[$pluginCode])) {
|
||||
return $this->configTypesCache[$pluginCode];
|
||||
}
|
||||
$types = [];
|
||||
$configFile = $this->getPluginPath($pluginCode) . '/config.json';
|
||||
if (File::exists($configFile)) {
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
$fields = $config['config'] ?? [];
|
||||
foreach ($fields as $key => $meta) {
|
||||
$types[$key] = is_array($meta) ? ($meta['type'] ?? 'string') : 'string';
|
||||
}
|
||||
}
|
||||
$this->configTypesCache[$pluginCode] = $types;
|
||||
return $types;
|
||||
}
|
||||
}
|
||||
296
Xboard/app/Services/ServerService.php
Normal file
296
Xboard/app/Services/ServerService.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerRoute;
|
||||
use App\Models\User;
|
||||
use App\Services\Plugin\HookManager;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ServerService
|
||||
{
|
||||
|
||||
/**
|
||||
* 获取所有服务器列表
|
||||
* @return Collection
|
||||
*/
|
||||
public static function getAllServers(): Collection
|
||||
{
|
||||
$query = Server::orderBy('sort', 'ASC');
|
||||
|
||||
return $query->get()->append([
|
||||
'last_check_at',
|
||||
'last_push_at',
|
||||
'online',
|
||||
'is_online',
|
||||
'available_status',
|
||||
'cache_key',
|
||||
'load_status',
|
||||
'metrics',
|
||||
'online_conn'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户可用的服务器列表
|
||||
* @param User $user
|
||||
* @return array
|
||||
*/
|
||||
public static function getAvailableServers(User $user): array
|
||||
{
|
||||
$servers = Server::whereJsonContains('group_ids', (string) $user->group_id)
|
||||
->where('show', true)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('transfer_enable')
|
||||
->orWhere('transfer_enable', 0)
|
||||
->orWhereRaw('u + d < transfer_enable');
|
||||
})
|
||||
->orderBy('sort', 'ASC')
|
||||
->get()
|
||||
->append(['last_check_at', 'last_push_at', 'online', 'is_online', 'available_status', 'cache_key', 'server_key']);
|
||||
|
||||
$servers = collect($servers)->map(function ($server) use ($user) {
|
||||
// 判断动态端口
|
||||
if (str_contains($server->port, '-')) {
|
||||
$port = $server->port;
|
||||
$server->port = (int) Helper::randomPort($port);
|
||||
$server->ports = $port;
|
||||
} else {
|
||||
$server->port = (int) $server->port;
|
||||
}
|
||||
$server->password = $server->generateServerPassword($user);
|
||||
$server->rate = $server->getCurrentRate();
|
||||
return $server;
|
||||
})->toArray();
|
||||
|
||||
return $servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据权限组获取可用的用户列表
|
||||
* @param array $groupIds
|
||||
* @return Collection
|
||||
*/
|
||||
public static function getAvailableUsers(Server $node)
|
||||
{
|
||||
$users = User::toBase()
|
||||
->whereIn('group_id', $node->group_ids)
|
||||
->whereRaw('u + d < transfer_enable')
|
||||
->where(function ($query) {
|
||||
$query->where('expired_at', '>=', time())
|
||||
->orWhere('expired_at', NULL);
|
||||
})
|
||||
->where('banned', 0)
|
||||
->select([
|
||||
'id',
|
||||
'uuid',
|
||||
'speed_limit',
|
||||
'device_limit'
|
||||
])
|
||||
->get();
|
||||
return HookManager::filter('server.users.get', $users, $node);
|
||||
}
|
||||
|
||||
// 获取路由规则
|
||||
public static function getRoutes(array $routeIds)
|
||||
{
|
||||
$routes = ServerRoute::select(['id', 'match', 'action', 'action_value'])->whereIn('id', $routeIds)->get();
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node metrics and load status
|
||||
*/
|
||||
public static function updateMetrics(Server $node, array $metrics): void
|
||||
{
|
||||
$nodeType = strtoupper($node->type);
|
||||
$nodeId = $node->id;
|
||||
$cacheTime = max(300, (int) admin_setting('server_push_interval', 60) * 3);
|
||||
|
||||
$metricsData = [
|
||||
'uptime' => (int) ($metrics['uptime'] ?? 0),
|
||||
'goroutines' => (int) ($metrics['goroutines'] ?? 0),
|
||||
'active_connections' => (int) ($metrics['active_connections'] ?? 0),
|
||||
'total_connections' => (int) ($metrics['total_connections'] ?? 0),
|
||||
'total_users' => (int) ($metrics['total_users'] ?? 0),
|
||||
'active_users' => (int) ($metrics['active_users'] ?? 0),
|
||||
'inbound_speed' => (int) ($metrics['inbound_speed'] ?? 0),
|
||||
'outbound_speed' => (int) ($metrics['outbound_speed'] ?? 0),
|
||||
'cpu_per_core' => $metrics['cpu_per_core'] ?? [],
|
||||
'load' => $metrics['load'] ?? [],
|
||||
'speed_limiter' => $metrics['speed_limiter'] ?? [],
|
||||
'gc' => $metrics['gc'] ?? [],
|
||||
'api' => $metrics['api'] ?? [],
|
||||
'ws' => $metrics['ws'] ?? [],
|
||||
'limits' => $metrics['limits'] ?? [],
|
||||
'updated_at' => now()->timestamp,
|
||||
'kernel_status' => (bool) ($metrics['kernel_status'] ?? false),
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\Cache::put(
|
||||
\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId),
|
||||
$metricsData,
|
||||
$cacheTime
|
||||
);
|
||||
}
|
||||
|
||||
public static function buildNodeConfig(Server $node): array
|
||||
{
|
||||
$nodeType = $node->type;
|
||||
$protocolSettings = $node->protocol_settings;
|
||||
$serverPort = $node->server_port;
|
||||
$host = $node->host;
|
||||
|
||||
$baseConfig = [
|
||||
'protocol' => $nodeType,
|
||||
'listen_ip' => '0.0.0.0',
|
||||
'server_port' => (int) $serverPort,
|
||||
'network' => data_get($protocolSettings, 'network'),
|
||||
'networkSettings' => data_get($protocolSettings, 'network_settings') ?: null,
|
||||
];
|
||||
|
||||
$response = match ($nodeType) {
|
||||
'shadowsocks' => [
|
||||
...$baseConfig,
|
||||
'cipher' => $protocolSettings['cipher'],
|
||||
'plugin' => $protocolSettings['plugin'],
|
||||
'plugin_opts' => $protocolSettings['plugin_opts'],
|
||||
'server_key' => match ($protocolSettings['cipher']) {
|
||||
'2022-blake3-aes-128-gcm' => Helper::getServerKey($node->created_at, 16),
|
||||
'2022-blake3-aes-256-gcm' => Helper::getServerKey($node->created_at, 32),
|
||||
default => null,
|
||||
},
|
||||
],
|
||||
'vmess' => [
|
||||
...$baseConfig,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
],
|
||||
'trojan' => [
|
||||
...$baseConfig,
|
||||
'host' => $host,
|
||||
'server_name' => $protocolSettings['server_name'],
|
||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'tls_settings' => match ((int) $protocolSettings['tls']) {
|
||||
2 => $protocolSettings['reality_settings'],
|
||||
default => null,
|
||||
},
|
||||
],
|
||||
'vless' => [
|
||||
...$baseConfig,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'flow' => $protocolSettings['flow'],
|
||||
'decryption' => data_get($protocolSettings, 'encryption.decryption'),
|
||||
'tls_settings' => match ((int) $protocolSettings['tls']) {
|
||||
2 => $protocolSettings['reality_settings'],
|
||||
default => $protocolSettings['tls_settings'],
|
||||
},
|
||||
'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
],
|
||||
'hysteria' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'version' => (int) $protocolSettings['version'],
|
||||
'host' => $host,
|
||||
'server_name' => $protocolSettings['tls']['server_name'],
|
||||
'up_mbps' => (int) $protocolSettings['bandwidth']['up'],
|
||||
'down_mbps' => (int) $protocolSettings['bandwidth']['down'],
|
||||
...match ((int) $protocolSettings['version']) {
|
||||
1 => ['obfs' => $protocolSettings['obfs']['password'] ?? null],
|
||||
2 => [
|
||||
'obfs' => $protocolSettings['obfs']['open'] ? $protocolSettings['obfs']['type'] : null,
|
||||
'obfs-password' => $protocolSettings['obfs']['password'] ?? null,
|
||||
],
|
||||
default => [],
|
||||
},
|
||||
],
|
||||
'tuic' => [
|
||||
...$baseConfig,
|
||||
'version' => (int) $protocolSettings['version'],
|
||||
'server_port' => (int) $serverPort,
|
||||
'server_name' => $protocolSettings['tls']['server_name'],
|
||||
'congestion_control' => $protocolSettings['congestion_control'],
|
||||
'tls_settings' => data_get($protocolSettings, 'tls_settings'),
|
||||
'auth_timeout' => '3s',
|
||||
'zero_rtt_handshake' => false,
|
||||
'heartbeat' => '3s',
|
||||
],
|
||||
'anytls' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'server_name' => $protocolSettings['tls']['server_name'],
|
||||
'padding_scheme' => $protocolSettings['padding_scheme'],
|
||||
],
|
||||
'socks' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
],
|
||||
'naive' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'tls_settings' => $protocolSettings['tls_settings'],
|
||||
],
|
||||
'http' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'tls' => (int) $protocolSettings['tls'],
|
||||
'tls_settings' => $protocolSettings['tls_settings'],
|
||||
],
|
||||
'mieru' => [
|
||||
...$baseConfig,
|
||||
'server_port' => (int) $serverPort,
|
||||
'transport' => data_get($protocolSettings, 'transport', 'TCP'),
|
||||
'traffic_pattern' => $protocolSettings['traffic_pattern'],
|
||||
// 'multiplex' => data_get($protocolSettings, 'multiplex'),
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
// $response = array_filter(
|
||||
// $response,
|
||||
// static fn ($value) => $value !== null
|
||||
// );
|
||||
|
||||
if (!empty($node['route_ids'])) {
|
||||
$response['routes'] = self::getRoutes($node['route_ids']);
|
||||
}
|
||||
|
||||
if (!empty($node['custom_outbounds'])) {
|
||||
$response['custom_outbounds'] = $node['custom_outbounds'];
|
||||
}
|
||||
|
||||
if (!empty($node['custom_routes'])) {
|
||||
$response['custom_routes'] = $node['custom_routes'];
|
||||
}
|
||||
|
||||
if (!empty($node['cert_config']) && data_get($node['cert_config'],'cert_mode') !== 'none' ) {
|
||||
$response['cert_config'] = $node['cert_config'];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据协议类型和标识获取服务器
|
||||
* @param int $serverId
|
||||
* @param string $serverType
|
||||
* @return Server|null
|
||||
*/
|
||||
public static function getServer($serverId, ?string $serverType = null): Server | null
|
||||
{
|
||||
return Server::query()
|
||||
->when($serverType, function ($query) use ($serverType) {
|
||||
$query->where('type', Server::normalizeType($serverType));
|
||||
})
|
||||
->where(function ($query) use ($serverId) {
|
||||
$query->where('code', $serverId)
|
||||
->orWhere('id', $serverId);
|
||||
})
|
||||
->orderByRaw('CASE WHEN code = ? THEN 0 ELSE 1 END', [$serverId])
|
||||
->first();
|
||||
}
|
||||
}
|
||||
18
Xboard/app/Services/SettingService.php
Normal file
18
Xboard/app/Services/SettingService.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting as SettingModel;
|
||||
|
||||
class SettingService
|
||||
{
|
||||
public function get($name, $default = null)
|
||||
{
|
||||
$setting = SettingModel::where('name', $name)->first();
|
||||
return $setting ? $setting->value : $default;
|
||||
}
|
||||
|
||||
public function getAll(){
|
||||
return SettingModel::all()->pluck('value', 'name')->toArray();
|
||||
}
|
||||
}
|
||||
378
Xboard/app/Services/StatisticalService.php
Normal file
378
Xboard/app/Services/StatisticalService.php
Normal file
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CommissionLog;
|
||||
use App\Models\Order;
|
||||
use App\Models\Server;
|
||||
use App\Models\Stat;
|
||||
use App\Models\StatServer;
|
||||
use App\Models\StatUser;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class StatisticalService
|
||||
{
|
||||
protected $userStats;
|
||||
protected $startAt;
|
||||
protected $endAt;
|
||||
protected $serverStats;
|
||||
protected $statServerKey;
|
||||
protected $statUserKey;
|
||||
protected $redis;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
ini_set('memory_limit', -1);
|
||||
$this->redis = Redis::connection();
|
||||
|
||||
}
|
||||
|
||||
public function setStartAt($timestamp)
|
||||
{
|
||||
$this->startAt = $timestamp;
|
||||
$this->statServerKey = "stat_server_{$this->startAt}";
|
||||
$this->statUserKey = "stat_user_{$this->startAt}";
|
||||
}
|
||||
|
||||
public function setEndAt($timestamp)
|
||||
{
|
||||
$this->endAt = $timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成统计报表
|
||||
*/
|
||||
public function generateStatData(): array
|
||||
{
|
||||
$startAt = $this->startAt;
|
||||
$endAt = $this->endAt;
|
||||
if (!$startAt || !$endAt) {
|
||||
$startAt = strtotime(date('Y-m-d'));
|
||||
$endAt = strtotime('+1 day', $startAt);
|
||||
}
|
||||
$data = [];
|
||||
$data['order_count'] = Order::where('created_at', '>=', $startAt)
|
||||
->where('created_at', '<', $endAt)
|
||||
->count();
|
||||
$data['order_total'] = Order::where('created_at', '>=', $startAt)
|
||||
->where('created_at', '<', $endAt)
|
||||
->sum('total_amount');
|
||||
$data['paid_count'] = Order::where('paid_at', '>=', $startAt)
|
||||
->where('paid_at', '<', $endAt)
|
||||
->whereNotIn('status', [0, 2])
|
||||
->count();
|
||||
$data['paid_total'] = Order::where('paid_at', '>=', $startAt)
|
||||
->where('paid_at', '<', $endAt)
|
||||
->whereNotIn('status', [0, 2])
|
||||
->sum('total_amount');
|
||||
$commissionLogBuilder = CommissionLog::where('created_at', '>=', $startAt)
|
||||
->where('created_at', '<', $endAt);
|
||||
$data['commission_count'] = $commissionLogBuilder->count();
|
||||
$data['commission_total'] = $commissionLogBuilder->sum('get_amount');
|
||||
$data['register_count'] = User::where('created_at', '>=', $startAt)
|
||||
->where('created_at', '<', $endAt)
|
||||
->count();
|
||||
$data['invite_count'] = User::where('created_at', '>=', $startAt)
|
||||
->where('created_at', '<', $endAt)
|
||||
->whereNotNull('invite_user_id')
|
||||
->count();
|
||||
$data['transfer_used_total'] = StatServer::where('created_at', '>=', $startAt)
|
||||
->where('created_at', '<', $endAt)
|
||||
->select(DB::raw('SUM(u) + SUM(d) as total'))
|
||||
->value('total') ?? 0;
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 往服务器报表缓存正追加流量使用数据
|
||||
*/
|
||||
public function statServer($serverId, $serverType, $u, $d)
|
||||
{
|
||||
$u_menber = "{$serverType}_{$serverId}_u"; //储存上传流量的集合成员
|
||||
$d_menber = "{$serverType}_{$serverId}_d"; //储存下载流量的集合成员
|
||||
$this->redis->zincrby($this->statServerKey, $u, $u_menber);
|
||||
$this->redis->zincrby($this->statServerKey, $d, $d_menber);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加用户使用流量
|
||||
*/
|
||||
public function statUser($rate, $userId, $u, $d)
|
||||
{
|
||||
$u_menber = "{$rate}_{$userId}_u"; //储存上传流量的集合成员
|
||||
$d_menber = "{$rate}_{$userId}_d"; //储存下载流量的集合成员
|
||||
$this->redis->zincrby($this->statUserKey, $u, $u_menber);
|
||||
$this->redis->zincrby($this->statUserKey, $d, $d_menber);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户的流量使用情况
|
||||
*/
|
||||
public function getStatUserByUserID(int|string $userId): array
|
||||
{
|
||||
|
||||
$stats = [];
|
||||
$statsUser = $this->redis->zrange($this->statUserKey, 0, -1, true);
|
||||
foreach ($statsUser as $member => $value) {
|
||||
list($rate, $uid, $type) = explode('_', $member);
|
||||
if (intval($uid) !== intval($userId))
|
||||
continue;
|
||||
$key = "{$rate}_{$uid}";
|
||||
$stats[$key] = $stats[$key] ?? [
|
||||
'record_at' => $this->startAt,
|
||||
'server_rate' => number_format((float) $rate, 2, '.', ''),
|
||||
'u' => 0,
|
||||
'd' => 0,
|
||||
'user_id' => intval($userId),
|
||||
];
|
||||
$stats[$key][$type] += $value;
|
||||
}
|
||||
return array_values($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存中的用户报表
|
||||
*/
|
||||
public function getStatUser()
|
||||
{
|
||||
$stats = [];
|
||||
$statsUser = $this->redis->zrange($this->statUserKey, 0, -1, true);
|
||||
foreach ($statsUser as $member => $value) {
|
||||
list($rate, $uid, $type) = explode('_', $member);
|
||||
$key = "{$rate}_{$uid}";
|
||||
$stats[$key] = $stats[$key] ?? [
|
||||
'record_at' => $this->startAt,
|
||||
'server_rate' => $rate,
|
||||
'u' => 0,
|
||||
'd' => 0,
|
||||
'user_id' => intval($uid),
|
||||
];
|
||||
$stats[$key][$type] += $value;
|
||||
}
|
||||
return array_values($stats);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve server statistics from Redis cache.
|
||||
*
|
||||
* @return array<int, array{server_id: int, server_type: string, u: float, d: float}>
|
||||
*/
|
||||
public function getStatServer(): array
|
||||
{
|
||||
/** @var array<string, array{server_id: int, server_type: string, u: float, d: float}> $stats */
|
||||
$stats = [];
|
||||
$statsServer = $this->redis->zrange($this->statServerKey, 0, -1, true);
|
||||
|
||||
foreach ($statsServer as $member => $value) {
|
||||
$parts = explode('_', $member);
|
||||
if (count($parts) !== 3) {
|
||||
continue; // Skip malformed members
|
||||
}
|
||||
[$serverType, $serverId, $type] = $parts;
|
||||
|
||||
if (!in_array($type, ['u', 'd'], true)) {
|
||||
continue; // Skip invalid types
|
||||
}
|
||||
|
||||
$key = "{$serverType}_{$serverId}";
|
||||
if (!isset($stats[$key])) {
|
||||
$stats[$key] = [
|
||||
'server_id' => (int) $serverId,
|
||||
'server_type' => $serverType,
|
||||
'u' => 0.0,
|
||||
'd' => 0.0,
|
||||
];
|
||||
}
|
||||
$stats[$key][$type] += (float) $value;
|
||||
}
|
||||
|
||||
return array_values($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户报表缓存数据
|
||||
*/
|
||||
public function clearStatUser()
|
||||
{
|
||||
$this->redis->del($this->statUserKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除服务器报表缓存数据
|
||||
*/
|
||||
public function clearStatServer()
|
||||
{
|
||||
$this->redis->del($this->statServerKey);
|
||||
}
|
||||
|
||||
public function getStatRecord($type)
|
||||
{
|
||||
switch ($type) {
|
||||
case "paid_total": {
|
||||
return Stat::select([
|
||||
'*',
|
||||
DB::raw('paid_total / 100 as paid_total')
|
||||
])
|
||||
->where('record_at', '>=', $this->startAt)
|
||||
->where('record_at', '<', $this->endAt)
|
||||
->orderBy('record_at', 'ASC')
|
||||
->get();
|
||||
}
|
||||
case "commission_total": {
|
||||
return Stat::select([
|
||||
'*',
|
||||
DB::raw('commission_total / 100 as commission_total')
|
||||
])
|
||||
->where('record_at', '>=', $this->startAt)
|
||||
->where('record_at', '<', $this->endAt)
|
||||
->orderBy('record_at', 'ASC')
|
||||
->get();
|
||||
}
|
||||
case "register_count": {
|
||||
return Stat::where('record_at', '>=', $this->startAt)
|
||||
->where('record_at', '<', $this->endAt)
|
||||
->orderBy('record_at', 'ASC')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getRanking($type, $limit = 20)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'server_traffic_rank': {
|
||||
return $this->buildServerTrafficRank($limit);
|
||||
}
|
||||
case 'user_consumption_rank': {
|
||||
return $this->buildUserConsumptionRank($limit);
|
||||
}
|
||||
case 'invite_rank': {
|
||||
return $this->buildInviteRank($limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期范围内的节点流量排行
|
||||
* @param mixed ...$times 可选值:'today', 'tomorrow', 'last_week',或指定日期范围,格式:timestamp
|
||||
* @return array
|
||||
*/
|
||||
|
||||
public static function getServerRank(...$times)
|
||||
{
|
||||
$startAt = 0;
|
||||
$endAt = Carbon::tomorrow()->endOfDay()->timestamp;
|
||||
|
||||
if (count($times) == 1) {
|
||||
switch ($times[0]) {
|
||||
case 'today':
|
||||
$startAt = Carbon::today()->startOfDay()->timestamp;
|
||||
$endAt = Carbon::today()->endOfDay()->timestamp;
|
||||
break;
|
||||
case 'yesterday':
|
||||
$startAt = Carbon::yesterday()->startOfDay()->timestamp;
|
||||
$endAt = Carbon::yesterday()->endOfDay()->timestamp;
|
||||
break;
|
||||
case 'last_week':
|
||||
$startAt = Carbon::now()->subWeek()->startOfWeek()->timestamp;
|
||||
$endAt = Carbon::now()->endOfDay()->timestamp;
|
||||
break;
|
||||
}
|
||||
} else if (count($times) == 2) {
|
||||
$startAt = $times[0];
|
||||
$endAt = $times[1];
|
||||
}
|
||||
|
||||
$statistics = Server::whereHas(
|
||||
'stats',
|
||||
function ($query) use ($startAt, $endAt) {
|
||||
$query->where('record_at', '>=', $startAt)
|
||||
->where('record_at', '<', $endAt)
|
||||
->where('record_type', 'd');
|
||||
}
|
||||
)
|
||||
->withSum('stats as u', 'u') // 预加载 u 的总和
|
||||
->withSum('stats as d', 'd') // 预加载 d 的总和
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'server_name' => optional($item->parent)->name ?? $item->name,
|
||||
'server_id' => $item->id,
|
||||
'server_type' => $item->type,
|
||||
'u' => (int) $item->u,
|
||||
'd' => (int) $item->d,
|
||||
'total' => (int) $item->u + (int) $item->d,
|
||||
];
|
||||
})
|
||||
->sortByDesc('total')
|
||||
->values()
|
||||
->toArray();
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
private function buildInviteRank($limit)
|
||||
{
|
||||
$stats = User::select([
|
||||
'invite_user_id',
|
||||
DB::raw('count(*) as count')
|
||||
])
|
||||
->where('created_at', '>=', $this->startAt)
|
||||
->where('created_at', '<', $this->endAt)
|
||||
->whereNotNull('invite_user_id')
|
||||
->groupBy('invite_user_id')
|
||||
->orderBy('count', 'DESC')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$users = User::whereIn('id', $stats->pluck('invite_user_id')->toArray())->get()->keyBy('id');
|
||||
foreach ($stats as $k => $v) {
|
||||
if (!isset($users[$v['invite_user_id']]))
|
||||
continue;
|
||||
$stats[$k]['email'] = $users[$v['invite_user_id']]['email'];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function buildUserConsumptionRank($limit)
|
||||
{
|
||||
$stats = StatUser::select([
|
||||
'user_id',
|
||||
DB::raw('sum(u) as u'),
|
||||
DB::raw('sum(d) as d'),
|
||||
DB::raw('sum(u) + sum(d) as total')
|
||||
])
|
||||
->where('record_at', '>=', $this->startAt)
|
||||
->where('record_at', '<', $this->endAt)
|
||||
->groupBy('user_id')
|
||||
->orderBy('total', 'DESC')
|
||||
->limit($limit)
|
||||
->get();
|
||||
$users = User::whereIn('id', $stats->pluck('user_id')->toArray())->get()->keyBy('id');
|
||||
foreach ($stats as $k => $v) {
|
||||
if (!isset($users[$v['user_id']]))
|
||||
continue;
|
||||
$stats[$k]['email'] = $users[$v['user_id']]['email'];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function buildServerTrafficRank($limit)
|
||||
{
|
||||
return StatServer::select([
|
||||
'server_id',
|
||||
'server_type',
|
||||
DB::raw('sum(u) as u'),
|
||||
DB::raw('sum(d) as d'),
|
||||
DB::raw('sum(u) + sum(d) as total')
|
||||
])
|
||||
->where('record_at', '>=', $this->startAt)
|
||||
->where('record_at', '<', $this->endAt)
|
||||
->groupBy('server_id', 'server_type')
|
||||
->orderBy('total', 'DESC')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
160
Xboard/app/Services/TelegramService.php
Normal file
160
Xboard/app/Services/TelegramService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Jobs\SendTelegramJob;
|
||||
use App\Models\User;
|
||||
use App\Services\Plugin\HookManager;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelegramService
|
||||
{
|
||||
protected PendingRequest $http;
|
||||
protected string $apiUrl;
|
||||
|
||||
public function __construct(?string $token = null)
|
||||
{
|
||||
$botToken = admin_setting('telegram_bot_token', $token);
|
||||
$this->apiUrl = "https://api.telegram.org/bot{$botToken}/";
|
||||
|
||||
$this->http = Http::timeout(30)
|
||||
->retry(3, 1000)
|
||||
->withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendMessage(int $chatId, string $text, string $parseMode = ''): void
|
||||
{
|
||||
$text = $parseMode === 'markdown' ? str_replace('_', '\_', $text) : $text;
|
||||
|
||||
$this->request('sendMessage', [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => $parseMode ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function approveChatJoinRequest(int $chatId, int $userId): void
|
||||
{
|
||||
$this->request('approveChatJoinRequest', [
|
||||
'chat_id' => $chatId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function declineChatJoinRequest(int $chatId, int $userId): void
|
||||
{
|
||||
$this->request('declineChatJoinRequest', [
|
||||
'chat_id' => $chatId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getMe(): object
|
||||
{
|
||||
return $this->request('getMe');
|
||||
}
|
||||
|
||||
public function setWebhook(string $url): object
|
||||
{
|
||||
$result = $this->request('setWebhook', ['url' => $url]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 Bot 命令列表
|
||||
*/
|
||||
public function registerBotCommands(): void
|
||||
{
|
||||
try {
|
||||
$commands = HookManager::filter('telegram.bot.commands', []);
|
||||
|
||||
if (empty($commands)) {
|
||||
Log::warning('没有找到任何 Telegram Bot 命令');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->request('setMyCommands', [
|
||||
'commands' => json_encode($commands),
|
||||
'scope' => json_encode(['type' => 'default'])
|
||||
]);
|
||||
|
||||
Log::info('Telegram Bot 命令注册成功', [
|
||||
'commands_count' => count($commands),
|
||||
'commands' => $commands
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Telegram Bot 命令注册失败', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前注册的命令列表
|
||||
*/
|
||||
public function getMyCommands(): object
|
||||
{
|
||||
return $this->request('getMyCommands');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除所有命令
|
||||
*/
|
||||
public function deleteMyCommands(): object
|
||||
{
|
||||
return $this->request('deleteMyCommands');
|
||||
}
|
||||
|
||||
public function sendMessageWithAdmin(string $message, bool $isStaff = false): void
|
||||
{
|
||||
$query = User::where('telegram_id', '!=', null);
|
||||
$query->where(
|
||||
fn($q) => $q->where('is_admin', 1)
|
||||
->when($isStaff, fn($q) => $q->orWhere('is_staff', 1))
|
||||
);
|
||||
$users = $query->get();
|
||||
foreach ($users as $user) {
|
||||
SendTelegramJob::dispatch($user->telegram_id, $message);
|
||||
}
|
||||
}
|
||||
|
||||
protected function request(string $method, array $params = []): object
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get($this->apiUrl . $method, $params);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new ApiException("HTTP 请求失败: {$response->status()}");
|
||||
}
|
||||
|
||||
$data = $response->object();
|
||||
|
||||
if (!isset($data->ok)) {
|
||||
throw new ApiException('无效的 Telegram API 响应');
|
||||
}
|
||||
|
||||
if (!$data->ok) {
|
||||
$description = $data->description ?? '未知错误';
|
||||
throw new ApiException("Telegram API 错误: {$description}");
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Telegram API 请求失败', [
|
||||
'method' => $method,
|
||||
'params' => $params,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw new ApiException("Telegram 服务错误: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
424
Xboard/app/Services/ThemeService.php
Normal file
424
Xboard/app/Services/ThemeService.php
Normal file
@@ -0,0 +1,424 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Exception;
|
||||
use ZipArchive;
|
||||
|
||||
class ThemeService
|
||||
{
|
||||
private const SYSTEM_THEME_DIR = 'theme/';
|
||||
private const USER_THEME_DIR = '/storage/theme/';
|
||||
private const CONFIG_FILE = 'config.json';
|
||||
private const SETTING_PREFIX = 'theme_';
|
||||
private const SYSTEM_THEMES = ['Xboard', 'v2board'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->registerThemeViewPaths();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register theme view paths
|
||||
*/
|
||||
private function registerThemeViewPaths(): void
|
||||
{
|
||||
$systemPath = base_path(self::SYSTEM_THEME_DIR);
|
||||
if (File::exists($systemPath)) {
|
||||
View::addNamespace('theme', $systemPath);
|
||||
}
|
||||
|
||||
$userPath = base_path(self::USER_THEME_DIR);
|
||||
if (File::exists($userPath)) {
|
||||
View::prependNamespace('theme', $userPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme view path
|
||||
*/
|
||||
public function getThemeViewPath(string $theme): ?string
|
||||
{
|
||||
$themePath = $this->getThemePath($theme);
|
||||
if (!$themePath) {
|
||||
return null;
|
||||
}
|
||||
return $themePath . '/dashboard.blade.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available themes
|
||||
*/
|
||||
public function getList(): array
|
||||
{
|
||||
$themes = [];
|
||||
|
||||
// 获取系统主题
|
||||
$systemPath = base_path(self::SYSTEM_THEME_DIR);
|
||||
if (File::exists($systemPath)) {
|
||||
$themes = $this->getThemesFromPath($systemPath, false);
|
||||
}
|
||||
|
||||
// 获取用户主题
|
||||
$userPath = base_path(self::USER_THEME_DIR);
|
||||
if (File::exists($userPath)) {
|
||||
$themes = array_merge($themes, $this->getThemesFromPath($userPath, true));
|
||||
}
|
||||
|
||||
return $themes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get themes from specified path
|
||||
*/
|
||||
private function getThemesFromPath(string $path, bool $canDelete): array
|
||||
{
|
||||
return collect(File::directories($path))
|
||||
->mapWithKeys(function ($dir) use ($canDelete) {
|
||||
$name = basename($dir);
|
||||
if (
|
||||
!File::exists($dir . '/' . self::CONFIG_FILE) ||
|
||||
!File::exists($dir . '/dashboard.blade.php')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
$config = $this->readConfigFile($name);
|
||||
if (!$config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config['can_delete'] = $canDelete && $name !== admin_setting('current_theme');
|
||||
$config['is_system'] = !$canDelete;
|
||||
return [$name => $config];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload new theme
|
||||
*/
|
||||
public function upload(UploadedFile $file): bool
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$tmpPath = storage_path('tmp/' . uniqid());
|
||||
|
||||
try {
|
||||
if ($zip->open($file->path()) !== true) {
|
||||
throw new Exception('Invalid theme package');
|
||||
}
|
||||
|
||||
$configEntry = collect(range(0, $zip->numFiles - 1))
|
||||
->map(fn($i) => $zip->getNameIndex($i))
|
||||
->first(fn($name) => basename($name) === self::CONFIG_FILE);
|
||||
|
||||
if (!$configEntry) {
|
||||
throw new Exception('Theme config file not found');
|
||||
}
|
||||
|
||||
$zip->extractTo($tmpPath);
|
||||
$zip->close();
|
||||
|
||||
$sourcePath = $tmpPath . '/' . rtrim(dirname($configEntry), '.');
|
||||
$configFile = $sourcePath . '/' . self::CONFIG_FILE;
|
||||
|
||||
if (!File::exists($configFile)) {
|
||||
throw new Exception('Theme config file not found');
|
||||
}
|
||||
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
if (empty($config['name'])) {
|
||||
throw new Exception('Theme name not configured');
|
||||
}
|
||||
|
||||
if (in_array($config['name'], self::SYSTEM_THEMES)) {
|
||||
throw new Exception('Cannot upload theme with same name as system theme');
|
||||
}
|
||||
|
||||
if (!File::exists($sourcePath . '/dashboard.blade.php')) {
|
||||
throw new Exception('Missing required theme file: dashboard.blade.php');
|
||||
}
|
||||
|
||||
$userThemePath = base_path(self::USER_THEME_DIR);
|
||||
if (!File::exists($userThemePath)) {
|
||||
File::makeDirectory($userThemePath, 0755, true);
|
||||
}
|
||||
|
||||
$targetPath = $userThemePath . $config['name'];
|
||||
if (File::exists($targetPath)) {
|
||||
$oldConfigFile = $targetPath . '/config.json';
|
||||
if (!File::exists($oldConfigFile)) {
|
||||
throw new Exception('Existing theme missing config file');
|
||||
}
|
||||
$oldConfig = json_decode(File::get($oldConfigFile), true);
|
||||
$oldVersion = $oldConfig['version'] ?? '0.0.0';
|
||||
$newVersion = $config['version'] ?? '0.0.0';
|
||||
if (version_compare($newVersion, $oldVersion, '>')) {
|
||||
$this->cleanupThemeFiles($config['name']);
|
||||
File::deleteDirectory($targetPath);
|
||||
File::copyDirectory($sourcePath, $targetPath);
|
||||
// 更新主题时保留用户配置
|
||||
$this->initConfig($config['name'], true);
|
||||
return true;
|
||||
} else {
|
||||
throw new Exception('Theme exists and not a newer version');
|
||||
}
|
||||
}
|
||||
|
||||
File::copyDirectory($sourcePath, $targetPath);
|
||||
$this->initConfig($config['name']);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
} finally {
|
||||
if (File::exists($tmpPath)) {
|
||||
File::deleteDirectory($tmpPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch theme
|
||||
*/
|
||||
public function switch(string|null $theme): bool
|
||||
{
|
||||
if ($theme === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$currentTheme = admin_setting('current_theme');
|
||||
|
||||
try {
|
||||
$themePath = $this->getThemePath($theme);
|
||||
if (!$themePath) {
|
||||
throw new Exception('Theme not found');
|
||||
}
|
||||
|
||||
if (!File::exists($this->getThemeViewPath($theme))) {
|
||||
throw new Exception('Theme view file not found');
|
||||
}
|
||||
|
||||
if ($currentTheme && $currentTheme !== $theme) {
|
||||
$this->cleanupThemeFiles($currentTheme);
|
||||
}
|
||||
|
||||
$targetPath = public_path('theme/' . $theme);
|
||||
if (!File::copyDirectory($themePath, $targetPath)) {
|
||||
throw new Exception('Failed to copy theme files');
|
||||
}
|
||||
|
||||
admin_setting(['current_theme' => $theme]);
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Theme switch failed', ['theme' => $theme, 'error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete theme
|
||||
*/
|
||||
public function delete(string $theme): bool
|
||||
{
|
||||
try {
|
||||
if (in_array($theme, self::SYSTEM_THEMES)) {
|
||||
throw new Exception('System theme cannot be deleted');
|
||||
}
|
||||
|
||||
if ($theme === admin_setting('current_theme')) {
|
||||
throw new Exception('Current theme cannot be deleted');
|
||||
}
|
||||
|
||||
$themePath = base_path(self::USER_THEME_DIR . $theme);
|
||||
if (!File::exists($themePath)) {
|
||||
throw new Exception('Theme not found');
|
||||
}
|
||||
|
||||
$this->cleanupThemeFiles($theme);
|
||||
File::deleteDirectory($themePath);
|
||||
admin_setting([self::SETTING_PREFIX . $theme => null]);
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Theme deletion failed', ['theme' => $theme, 'error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if theme exists
|
||||
*/
|
||||
public function exists(string $theme): bool
|
||||
{
|
||||
return $this->getThemePath($theme) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme path
|
||||
*/
|
||||
public function getThemePath(string $theme): ?string
|
||||
{
|
||||
$systemPath = base_path(self::SYSTEM_THEME_DIR . $theme);
|
||||
if (File::exists($systemPath)) {
|
||||
return $systemPath;
|
||||
}
|
||||
|
||||
$userPath = base_path(self::USER_THEME_DIR . $theme);
|
||||
if (File::exists($userPath)) {
|
||||
return $userPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme config
|
||||
*/
|
||||
public function getConfig(string $theme): ?array
|
||||
{
|
||||
$config = admin_setting(self::SETTING_PREFIX . $theme);
|
||||
if ($config === null) {
|
||||
$this->initConfig($theme);
|
||||
$config = admin_setting(self::SETTING_PREFIX . $theme);
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update theme config
|
||||
*/
|
||||
public function updateConfig(string $theme, array $config): bool
|
||||
{
|
||||
try {
|
||||
if (!$this->getThemePath($theme)) {
|
||||
throw new Exception('Theme not found');
|
||||
}
|
||||
|
||||
$schema = $this->readConfigFile($theme);
|
||||
if (!$schema) {
|
||||
throw new Exception('Invalid theme config file');
|
||||
}
|
||||
|
||||
$validFields = collect($schema['configs'] ?? [])->pluck('field_name')->toArray();
|
||||
$validConfig = collect($config)
|
||||
->only($validFields)
|
||||
->toArray();
|
||||
|
||||
$currentConfig = $this->getConfig($theme) ?? [];
|
||||
$newConfig = array_merge($currentConfig, $validConfig);
|
||||
|
||||
admin_setting([self::SETTING_PREFIX . $theme => $newConfig]);
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Config update failed', ['theme' => $theme, 'error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read theme config file
|
||||
*/
|
||||
private function readConfigFile(string $theme): ?array
|
||||
{
|
||||
$themePath = $this->getThemePath($theme);
|
||||
if (!$themePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$file = $themePath . '/' . self::CONFIG_FILE;
|
||||
return File::exists($file) ? json_decode(File::get($file), true) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up theme files including public directory
|
||||
*/
|
||||
public function cleanupThemeFiles(string $theme): void
|
||||
{
|
||||
try {
|
||||
$publicThemePath = public_path('theme/' . $theme);
|
||||
if (File::exists($publicThemePath)) {
|
||||
File::deleteDirectory($publicThemePath);
|
||||
Log::info('Cleaned up public theme files', ['theme' => $theme, 'path' => $publicThemePath]);
|
||||
}
|
||||
|
||||
$cacheKey = "theme_{$theme}_assets";
|
||||
if (cache()->has($cacheKey)) {
|
||||
cache()->forget($cacheKey);
|
||||
Log::info('Cleaned up theme cache', ['theme' => $theme, 'cache_key' => $cacheKey]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to cleanup theme files', [
|
||||
'theme' => $theme,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh current theme public files
|
||||
*/
|
||||
public function refreshCurrentTheme(): bool
|
||||
{
|
||||
try {
|
||||
$currentTheme = admin_setting('current_theme');
|
||||
if (!$currentTheme) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->cleanupThemeFiles($currentTheme);
|
||||
|
||||
$themePath = $this->getThemePath($currentTheme);
|
||||
if (!$themePath) {
|
||||
throw new Exception('Current theme path not found');
|
||||
}
|
||||
|
||||
$targetPath = public_path('theme/' . $currentTheme);
|
||||
if (!File::copyDirectory($themePath, $targetPath)) {
|
||||
throw new Exception('Failed to copy theme files');
|
||||
}
|
||||
|
||||
Log::info('Refreshed current theme files', ['theme' => $currentTheme]);
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to refresh current theme', [
|
||||
'theme' => $currentTheme,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize theme config
|
||||
*
|
||||
* @param string $theme 主题名称
|
||||
* @param bool $preserveExisting 是否保留现有配置(更新主题时使用)
|
||||
*/
|
||||
private function initConfig(string $theme, bool $preserveExisting = false): void
|
||||
{
|
||||
$config = $this->readConfigFile($theme);
|
||||
if (!$config) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaults = collect($config['configs'] ?? [])
|
||||
->mapWithKeys(fn($col) => [$col['field_name'] => $col['default_value'] ?? ''])
|
||||
->toArray();
|
||||
|
||||
if ($preserveExisting) {
|
||||
$existingConfig = admin_setting(self::SETTING_PREFIX . $theme) ?? [];
|
||||
$mergedConfig = array_merge($defaults, $existingConfig);
|
||||
admin_setting([self::SETTING_PREFIX . $theme => $mergedConfig]);
|
||||
} else {
|
||||
admin_setting([self::SETTING_PREFIX . $theme => $defaults]);
|
||||
}
|
||||
}
|
||||
}
|
||||
125
Xboard/app/Services/TicketService.php
Normal file
125
Xboard/app/Services/TicketService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Jobs\SendEmailJob;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\Plugin\HookManager;
|
||||
|
||||
class TicketService
|
||||
{
|
||||
public function reply($ticket, $message, $userId)
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
$ticketMessage = TicketMessage::create([
|
||||
'user_id' => $userId,
|
||||
'ticket_id' => $ticket->id,
|
||||
'message' => $message
|
||||
]);
|
||||
if ($userId !== $ticket->user_id) {
|
||||
$ticket->reply_status = Ticket::STATUS_OPENING;
|
||||
} else {
|
||||
$ticket->reply_status = Ticket::STATUS_CLOSED;
|
||||
}
|
||||
if (!$ticketMessage || !$ticket->save()) {
|
||||
throw new \Exception();
|
||||
}
|
||||
DB::commit();
|
||||
return $ticketMessage;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function replyByAdmin($ticketId, $message, $userId): void
|
||||
{
|
||||
$ticket = Ticket::where('id', $ticketId)
|
||||
->first();
|
||||
if (!$ticket) {
|
||||
throw new ApiException('工单不存在');
|
||||
}
|
||||
$ticket->status = Ticket::STATUS_OPENING;
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
$ticketMessage = TicketMessage::create([
|
||||
'user_id' => $userId,
|
||||
'ticket_id' => $ticket->id,
|
||||
'message' => $message
|
||||
]);
|
||||
if ($userId !== $ticket->user_id) {
|
||||
$ticket->reply_status = Ticket::STATUS_OPENING;
|
||||
} else {
|
||||
$ticket->reply_status = Ticket::STATUS_CLOSED;
|
||||
}
|
||||
if (!$ticketMessage || !$ticket->save()) {
|
||||
throw new ApiException('工单回复失败');
|
||||
}
|
||||
DB::commit();
|
||||
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
$this->sendEmailNotify($ticket, $ticketMessage);
|
||||
}
|
||||
|
||||
public function createTicket($userId, $subject, $level, $message)
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
if (Ticket::where('status', 0)->where('user_id', $userId)->lockForUpdate()->first()) {
|
||||
DB::rollBack();
|
||||
throw new ApiException('存在未关闭的工单');
|
||||
}
|
||||
$ticket = Ticket::create([
|
||||
'user_id' => $userId,
|
||||
'subject' => $subject,
|
||||
'level' => $level
|
||||
]);
|
||||
if (!$ticket) {
|
||||
throw new ApiException('工单创建失败');
|
||||
}
|
||||
$ticketMessage = TicketMessage::create([
|
||||
'user_id' => $userId,
|
||||
'ticket_id' => $ticket->id,
|
||||
'message' => $message
|
||||
]);
|
||||
if (!$ticketMessage) {
|
||||
DB::rollBack();
|
||||
throw new ApiException('工单消息创建失败');
|
||||
}
|
||||
DB::commit();
|
||||
return $ticket;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// 半小时内不再重复通知
|
||||
private function sendEmailNotify(Ticket $ticket, TicketMessage $ticketMessage)
|
||||
{
|
||||
$user = User::find($ticket->user_id);
|
||||
$cacheKey = 'ticket_sendEmailNotify_' . $ticket->user_id;
|
||||
if (!Cache::get($cacheKey)) {
|
||||
Cache::put($cacheKey, 1, 1800);
|
||||
SendEmailJob::dispatch([
|
||||
'email' => $user->email,
|
||||
'subject' => '您在' . admin_setting('app_name', 'XBoard') . '的工单得到了回复',
|
||||
'template_name' => 'notify',
|
||||
'template_value' => [
|
||||
'name' => admin_setting('app_name', 'XBoard'),
|
||||
'url' => admin_setting('app_url'),
|
||||
'content' => "主题:{$ticket->subject}\r\n回复内容:{$ticketMessage->message}"
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
415
Xboard/app/Services/TrafficResetService.php
Normal file
415
Xboard/app/Services/TrafficResetService.php
Normal file
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Plan;
|
||||
use App\Models\TrafficResetLog;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\Plugin\HookManager;
|
||||
|
||||
/**
|
||||
* Service for handling traffic reset.
|
||||
*/
|
||||
class TrafficResetService
|
||||
{
|
||||
/**
|
||||
* Check if a user's traffic should be reset and perform the reset.
|
||||
*/
|
||||
public function checkAndReset(User $user, string $triggerSource = TrafficResetLog::SOURCE_AUTO): bool
|
||||
{
|
||||
if (!$user->shouldResetTraffic()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->performReset($user, $triggerSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the traffic reset for a user.
|
||||
*/
|
||||
public function performReset(User $user, string $triggerSource = TrafficResetLog::SOURCE_MANUAL): bool
|
||||
{
|
||||
try {
|
||||
return DB::transaction(function () use ($user, $triggerSource) {
|
||||
$oldUpload = $user->u ?? 0;
|
||||
$oldDownload = $user->d ?? 0;
|
||||
$oldTotal = $oldUpload + $oldDownload;
|
||||
|
||||
$nextResetTime = $this->calculateNextResetTime($user);
|
||||
|
||||
$user->update([
|
||||
'u' => 0,
|
||||
'd' => 0,
|
||||
'last_reset_at' => time(),
|
||||
'reset_count' => $user->reset_count + 1,
|
||||
'next_reset_at' => $nextResetTime ? $nextResetTime->timestamp : null,
|
||||
]);
|
||||
|
||||
$this->recordResetLog($user, [
|
||||
'reset_type' => $this->getResetTypeFromPlan($user->plan),
|
||||
'trigger_source' => $triggerSource,
|
||||
'old_upload' => $oldUpload,
|
||||
'old_download' => $oldDownload,
|
||||
'old_total' => $oldTotal,
|
||||
'new_upload' => 0,
|
||||
'new_download' => 0,
|
||||
'new_total' => 0,
|
||||
]);
|
||||
|
||||
$this->clearUserCache($user);
|
||||
HookManager::call('traffic.reset.after', $user);
|
||||
return true;
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
Log::error(__('traffic_reset.reset_failed'), [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'error' => $e->getMessage(),
|
||||
'trigger_source' => $triggerSource,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next traffic reset time for a user.
|
||||
*/
|
||||
public function calculateNextResetTime(User $user): ?Carbon
|
||||
{
|
||||
if (
|
||||
!$user->plan
|
||||
|| $user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_NEVER
|
||||
|| ($user->plan->reset_traffic_method === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM
|
||||
&& (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY) === Plan::RESET_TRAFFIC_NEVER)
|
||||
|| $user->expired_at === NULL
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resetMethod = $user->plan->reset_traffic_method;
|
||||
|
||||
if ($resetMethod === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM) {
|
||||
$resetMethod = (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY);
|
||||
}
|
||||
|
||||
$now = Carbon::now(config('app.timezone'));
|
||||
|
||||
return match ($resetMethod) {
|
||||
Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => $this->getNextMonthFirstDay($now),
|
||||
Plan::RESET_TRAFFIC_MONTHLY => $this->getNextMonthlyReset($user, $now),
|
||||
Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => $this->getNextYearFirstDay($now),
|
||||
Plan::RESET_TRAFFIC_YEARLY => $this->getNextYearlyReset($user, $now),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first day of the next month.
|
||||
*/
|
||||
private function getNextMonthFirstDay(Carbon $from): Carbon
|
||||
{
|
||||
return $from->copy()->addMonth()->startOfMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next monthly reset time based on the user's expiration date.
|
||||
*
|
||||
* Logic:
|
||||
* 1. If the user has no expiration date, reset on the 1st of each month.
|
||||
* 2. If the user has an expiration date, use the day of that date as the monthly reset day.
|
||||
* 3. Prioritize the reset day in the current month if it has not passed yet.
|
||||
* 4. Handle cases where the day does not exist in a month (e.g., 31st in February).
|
||||
*/
|
||||
private function getNextMonthlyReset(User $user, Carbon $from): Carbon
|
||||
{
|
||||
$expiredAt = Carbon::createFromTimestamp($user->expired_at, config('app.timezone'));
|
||||
$resetDay = $expiredAt->day;
|
||||
$resetTime = [$expiredAt->hour, $expiredAt->minute, $expiredAt->second];
|
||||
|
||||
$currentMonthTarget = $from->copy()->day($resetDay)->setTime(...$resetTime);
|
||||
if ($currentMonthTarget->timestamp > $from->timestamp) {
|
||||
return $currentMonthTarget;
|
||||
}
|
||||
|
||||
$nextMonthTarget = $from->copy()->startOfMonth()->addMonths(1)->day($resetDay)->setTime(...$resetTime);
|
||||
|
||||
if ($nextMonthTarget->month !== ($from->month % 12) + 1) {
|
||||
$nextMonth = ($from->month % 12) + 1;
|
||||
$nextYear = $from->year + ($from->month === 12 ? 1 : 0);
|
||||
$lastDayOfNextMonth = Carbon::create($nextYear, $nextMonth, 1)->endOfMonth()->day;
|
||||
$targetDay = min($resetDay, $lastDayOfNextMonth);
|
||||
$nextMonthTarget = Carbon::create($nextYear, $nextMonth, $targetDay)->setTime(...$resetTime);
|
||||
}
|
||||
|
||||
return $nextMonthTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first day of the next year.
|
||||
*/
|
||||
private function getNextYearFirstDay(Carbon $from): Carbon
|
||||
{
|
||||
return $from->copy()->addYear()->startOfYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next yearly reset time based on the user's expiration date.
|
||||
*
|
||||
* Logic:
|
||||
* 1. If the user has no expiration date, reset on January 1st of each year.
|
||||
* 2. If the user has an expiration date, use the month and day of that date as the yearly reset date.
|
||||
* 3. Prioritize the reset date in the current year if it has not passed yet.
|
||||
* 4. Handle the case of February 29th in a leap year.
|
||||
*/
|
||||
private function getNextYearlyReset(User $user, Carbon $from): Carbon
|
||||
{
|
||||
$expiredAt = Carbon::createFromTimestamp($user->expired_at, config('app.timezone'));
|
||||
$resetMonth = $expiredAt->month;
|
||||
$resetDay = $expiredAt->day;
|
||||
$resetTime = [$expiredAt->hour, $expiredAt->minute, $expiredAt->second];
|
||||
|
||||
$currentYearTarget = $from->copy()->month($resetMonth)->day($resetDay)->setTime(...$resetTime);
|
||||
if ($currentYearTarget->timestamp > $from->timestamp) {
|
||||
return $currentYearTarget;
|
||||
}
|
||||
|
||||
$nextYearTarget = $from->copy()->startOfYear()->addYears(1)->month($resetMonth)->day($resetDay)->setTime(...$resetTime);
|
||||
|
||||
if ($nextYearTarget->month !== $resetMonth) {
|
||||
$nextYear = $from->year + 1;
|
||||
$lastDayOfMonth = Carbon::create($nextYear, $resetMonth, 1)->endOfMonth()->day;
|
||||
$targetDay = min($resetDay, $lastDayOfMonth);
|
||||
$nextYearTarget = Carbon::create($nextYear, $resetMonth, $targetDay)->setTime(...$resetTime);
|
||||
}
|
||||
|
||||
return $nextYearTarget;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Record the traffic reset log.
|
||||
*/
|
||||
private function recordResetLog(User $user, array $data): void
|
||||
{
|
||||
TrafficResetLog::create([
|
||||
'user_id' => $user->id,
|
||||
'reset_type' => $data['reset_type'],
|
||||
'reset_time' => now(),
|
||||
'old_upload' => $data['old_upload'],
|
||||
'old_download' => $data['old_download'],
|
||||
'old_total' => $data['old_total'],
|
||||
'new_upload' => $data['new_upload'],
|
||||
'new_download' => $data['new_download'],
|
||||
'new_total' => $data['new_total'],
|
||||
'trigger_source' => $data['trigger_source'],
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reset type from the user's plan.
|
||||
*/
|
||||
private function getResetTypeFromPlan(?Plan $plan): string
|
||||
{
|
||||
if (!$plan) {
|
||||
return TrafficResetLog::TYPE_MANUAL;
|
||||
}
|
||||
|
||||
$resetMethod = $plan->reset_traffic_method;
|
||||
|
||||
if ($resetMethod === Plan::RESET_TRAFFIC_FOLLOW_SYSTEM) {
|
||||
$resetMethod = (int) admin_setting('reset_traffic_method', Plan::RESET_TRAFFIC_MONTHLY);
|
||||
}
|
||||
|
||||
return match ($resetMethod) {
|
||||
Plan::RESET_TRAFFIC_FIRST_DAY_MONTH => TrafficResetLog::TYPE_FIRST_DAY_MONTH,
|
||||
Plan::RESET_TRAFFIC_MONTHLY => TrafficResetLog::TYPE_MONTHLY,
|
||||
Plan::RESET_TRAFFIC_FIRST_DAY_YEAR => TrafficResetLog::TYPE_FIRST_DAY_YEAR,
|
||||
Plan::RESET_TRAFFIC_YEARLY => TrafficResetLog::TYPE_YEARLY,
|
||||
Plan::RESET_TRAFFIC_NEVER => TrafficResetLog::TYPE_MANUAL,
|
||||
default => TrafficResetLog::TYPE_MANUAL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user-related cache.
|
||||
*/
|
||||
private function clearUserCache(User $user): void
|
||||
{
|
||||
$cacheKeys = [
|
||||
"user_traffic_{$user->id}",
|
||||
"user_reset_status_{$user->id}",
|
||||
"user_subscription_{$user->token}",
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
Cache::forget($key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch check and reset users. Processes all eligible users in batches.
|
||||
*/
|
||||
public function batchCheckReset(int $batchSize = 100, ?callable $progressCallback = null): array
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$totalResetCount = 0;
|
||||
$totalProcessedCount = 0;
|
||||
$batchNumber = 1;
|
||||
$errors = [];
|
||||
$lastProcessedId = 0;
|
||||
|
||||
try {
|
||||
do {
|
||||
$users = User::where('next_reset_at', '<=', time())
|
||||
->whereNotNull('next_reset_at')
|
||||
->where('id', '>', $lastProcessedId)
|
||||
->where(function ($query) {
|
||||
$query->where('expired_at', '>', time())
|
||||
->orWhereNull('expired_at');
|
||||
})
|
||||
->where('banned', 0)
|
||||
->whereNotNull('plan_id')
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$batchResetCount = 0;
|
||||
|
||||
if ($progressCallback) {
|
||||
$progressCallback([
|
||||
'batch_number' => $batchNumber,
|
||||
'batch_size' => $users->count(),
|
||||
'total_processed' => $totalProcessedCount,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($users as $user) {
|
||||
try {
|
||||
if ($this->checkAndReset($user, TrafficResetLog::SOURCE_CRON)) {
|
||||
$batchResetCount++;
|
||||
$totalResetCount++;
|
||||
}
|
||||
$totalProcessedCount++;
|
||||
$lastProcessedId = $user->id;
|
||||
} catch (\Exception $e) {
|
||||
$error = [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'error' => $e->getMessage(),
|
||||
'batch' => $batchNumber,
|
||||
'timestamp' => now()->toDateTimeString(),
|
||||
];
|
||||
$batchErrors[] = $error;
|
||||
$errors[] = $error;
|
||||
|
||||
Log::error('User traffic reset failed', $error);
|
||||
|
||||
$totalProcessedCount++;
|
||||
$lastProcessedId = $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
$batchNumber++;
|
||||
|
||||
if ($batchNumber % 10 === 0) {
|
||||
gc_collect_cycles();
|
||||
}
|
||||
|
||||
if ($batchNumber % 5 === 0) {
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
} while (true);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Batch traffic reset task failed with an exception', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'total_processed' => $totalProcessedCount,
|
||||
'total_reset' => $totalResetCount,
|
||||
'last_processed_id' => $lastProcessedId,
|
||||
]);
|
||||
|
||||
$errors[] = [
|
||||
'type' => 'system_error',
|
||||
'error' => $e->getMessage(),
|
||||
'batch' => $batchNumber,
|
||||
'last_processed_id' => $lastProcessedId,
|
||||
'timestamp' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
$totalDuration = round(microtime(true) - $startTime, 2);
|
||||
|
||||
$result = [
|
||||
'total_processed' => $totalProcessedCount,
|
||||
'total_reset' => $totalResetCount,
|
||||
'total_batches' => $batchNumber - 1,
|
||||
'error_count' => count($errors),
|
||||
'errors' => $errors,
|
||||
'duration' => $totalDuration,
|
||||
'batch_size' => $batchSize,
|
||||
'last_processed_id' => $lastProcessedId,
|
||||
'completed_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the initial reset time for a new user.
|
||||
*/
|
||||
public function setInitialResetTime(User $user): void
|
||||
{
|
||||
if ($user->next_reset_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nextResetTime = $this->calculateNextResetTime($user);
|
||||
|
||||
if ($nextResetTime) {
|
||||
$user->update(['next_reset_at' => $nextResetTime->timestamp]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's traffic reset history.
|
||||
*/
|
||||
public function getUserResetHistory(User $user, int $limit = 10): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return $user->trafficResetLogs()
|
||||
->orderBy('reset_time', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is eligible for traffic reset.
|
||||
*/
|
||||
public function canReset(User $user): bool
|
||||
{
|
||||
return $user->isActive() && $user->plan !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reset a user's traffic (Admin function).
|
||||
*/
|
||||
public function manualReset(User $user, array $metadata = []): bool
|
||||
{
|
||||
if (!$this->canReset($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->performReset($user, TrafficResetLog::SOURCE_MANUAL);
|
||||
}
|
||||
}
|
||||
458
Xboard/app/Services/UpdateService.php
Normal file
458
Xboard/app/Services/UpdateService.php
Normal file
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Utils\CacheKey;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class UpdateService
|
||||
{
|
||||
const UPDATE_CHECK_INTERVAL = 86400; // 24 hours
|
||||
const GITHUB_API_URL = 'https://api.github.com/repos/cedar2025/xboard/commits';
|
||||
const CACHE_UPDATE_INFO = 'UPDATE_INFO';
|
||||
const CACHE_LAST_CHECK = 'LAST_UPDATE_CHECK';
|
||||
const CACHE_UPDATE_LOCK = 'UPDATE_LOCK';
|
||||
const CACHE_VERSION = 'CURRENT_VERSION';
|
||||
const CACHE_VERSION_DATE = 'CURRENT_VERSION_DATE';
|
||||
|
||||
/**
|
||||
* Get current version from cache or generate new one
|
||||
*/
|
||||
public function getCurrentVersion(): string
|
||||
{
|
||||
$date = Cache::get(self::CACHE_VERSION_DATE) ?? date('Ymd');
|
||||
$hash = Cache::rememberForever(self::CACHE_VERSION, function () {
|
||||
return $this->getCurrentCommit();
|
||||
});
|
||||
return $date . '-' . $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update version cache
|
||||
*/
|
||||
public function updateVersionCache(): void
|
||||
{
|
||||
try {
|
||||
$result = Process::run('git log -1 --format=%cd:%H --date=format:%Y%m%d');
|
||||
if ($result->successful()) {
|
||||
list($date, $hash) = explode(':', trim($result->output()));
|
||||
Cache::forever(self::CACHE_VERSION_DATE, $date);
|
||||
Cache::forever(self::CACHE_VERSION, substr($hash, 0, 7));
|
||||
// Log::info('Version cache updated: ' . $date . '-' . substr($hash, 0, 7));
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to get version with date: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Fallback
|
||||
Cache::forever(self::CACHE_VERSION_DATE, date('Ymd'));
|
||||
$fallbackHash = $this->getCurrentCommit();
|
||||
Cache::forever(self::CACHE_VERSION, $fallbackHash);
|
||||
Log::info('Version cache updated (fallback): ' . date('Ymd') . '-' . $fallbackHash);
|
||||
}
|
||||
|
||||
public function checkForUpdates(): array
|
||||
{
|
||||
try {
|
||||
// Get current version commit
|
||||
$currentCommit = $this->getCurrentCommit();
|
||||
if ($currentCommit === 'unknown') {
|
||||
// If unable to get current commit, try to get the first commit
|
||||
$currentCommit = $this->getFirstCommit();
|
||||
}
|
||||
// Get local git logs
|
||||
$localLogs = $this->getLocalGitLogs();
|
||||
if (empty($localLogs)) {
|
||||
Log::error('Failed to get local git logs');
|
||||
return $this->getCachedUpdateInfo();
|
||||
}
|
||||
|
||||
// Get remote latest commits
|
||||
$response = Http::withHeaders([
|
||||
'Accept' => 'application/vnd.github.v3+json',
|
||||
'User-Agent' => 'XBoard-Update-Checker'
|
||||
])->get(self::GITHUB_API_URL . '?per_page=50');
|
||||
|
||||
if ($response->successful()) {
|
||||
$commits = $response->json();
|
||||
|
||||
if (empty($commits) || !is_array($commits)) {
|
||||
Log::error('Invalid GitHub response format');
|
||||
return $this->getCachedUpdateInfo();
|
||||
}
|
||||
|
||||
$latestCommit = $this->formatCommitHash($commits[0]['sha']);
|
||||
$currentIndex = -1;
|
||||
$updateLogs = [];
|
||||
|
||||
// First, find the current version position in remote commit history
|
||||
foreach ($commits as $index => $commit) {
|
||||
$shortSha = $this->formatCommitHash($commit['sha']);
|
||||
if ($shortSha === $currentCommit) {
|
||||
$currentIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check local version status
|
||||
$isLocalNewer = false;
|
||||
if ($currentIndex === -1) {
|
||||
// Current version not found in remote history, check local commits
|
||||
foreach ($localLogs as $localCommit) {
|
||||
$localHash = $this->formatCommitHash($localCommit['hash']);
|
||||
// If latest remote commit found, local is not newer
|
||||
if ($localHash === $latestCommit) {
|
||||
$isLocalNewer = false;
|
||||
break;
|
||||
}
|
||||
// Record additional local commits
|
||||
$updateLogs[] = [
|
||||
'version' => $localHash,
|
||||
'message' => $localCommit['message'],
|
||||
'author' => $localCommit['author'],
|
||||
'date' => $localCommit['date'],
|
||||
'is_local' => true
|
||||
];
|
||||
$isLocalNewer = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If local is not newer, collect commits that need to be updated
|
||||
if (!$isLocalNewer && $currentIndex > 0) {
|
||||
$updateLogs = [];
|
||||
// Collect all commits between current version and latest version
|
||||
for ($i = 0; $i < $currentIndex; $i++) {
|
||||
$commit = $commits[$i];
|
||||
$updateLogs[] = [
|
||||
'version' => $this->formatCommitHash($commit['sha']),
|
||||
'message' => $commit['commit']['message'],
|
||||
'author' => $commit['commit']['author']['name'],
|
||||
'date' => $commit['commit']['author']['date'],
|
||||
'is_local' => false
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$hasUpdate = !$isLocalNewer && $currentIndex > 0;
|
||||
|
||||
$updateInfo = [
|
||||
'has_update' => $hasUpdate,
|
||||
'is_local_newer' => $isLocalNewer,
|
||||
'latest_version' => $isLocalNewer ? $currentCommit : $latestCommit,
|
||||
'current_version' => $currentCommit,
|
||||
'update_logs' => $updateLogs,
|
||||
'download_url' => $commits[0]['html_url'] ?? '',
|
||||
'published_at' => $commits[0]['commit']['author']['date'] ?? '',
|
||||
'author' => $commits[0]['commit']['author']['name'] ?? '',
|
||||
];
|
||||
|
||||
// Cache check results
|
||||
$this->setLastCheckTime();
|
||||
Cache::put(self::CACHE_UPDATE_INFO, $updateInfo, now()->addHours(24));
|
||||
|
||||
return $updateInfo;
|
||||
}
|
||||
|
||||
return $this->getCachedUpdateInfo();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Update check failed: ' . $e->getMessage());
|
||||
return $this->getCachedUpdateInfo();
|
||||
}
|
||||
}
|
||||
|
||||
public function executeUpdate(): array
|
||||
{
|
||||
// Check for new version first
|
||||
$updateInfo = $this->checkForUpdates();
|
||||
if ($updateInfo['is_local_newer']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('update.local_newer')
|
||||
];
|
||||
}
|
||||
if (!$updateInfo['has_update']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('update.already_latest')
|
||||
];
|
||||
}
|
||||
|
||||
// Check for update lock
|
||||
if (Cache::get(self::CACHE_UPDATE_LOCK)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('update.process_running')
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
// Set update lock
|
||||
Cache::put(self::CACHE_UPDATE_LOCK, true, now()->addMinutes(30));
|
||||
|
||||
// 1. Backup database
|
||||
$this->backupDatabase();
|
||||
|
||||
// 2. Pull latest code
|
||||
$result = $this->pullLatestCode();
|
||||
if (!$result['success']) {
|
||||
throw new \Exception($result['message']);
|
||||
}
|
||||
|
||||
// 3. Run database migrations
|
||||
$this->runMigrations();
|
||||
|
||||
// 4. Clear cache
|
||||
$this->clearCache();
|
||||
|
||||
// 5. Create update flag
|
||||
$this->createUpdateFlag();
|
||||
|
||||
// 6. Restart Octane if running
|
||||
$this->restartOctane();
|
||||
|
||||
// Remove update lock
|
||||
Cache::forget(self::CACHE_UPDATE_LOCK);
|
||||
|
||||
// Format update logs
|
||||
$logMessages = array_map(function($log) {
|
||||
return sprintf("- %s (%s): %s",
|
||||
$log['version'],
|
||||
date('Y-m-d H:i', strtotime($log['date'])),
|
||||
$log['message']
|
||||
);
|
||||
}, $updateInfo['update_logs']);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => __('update.success', [
|
||||
'from' => $updateInfo['current_version'],
|
||||
'to' => $updateInfo['latest_version']
|
||||
]),
|
||||
'version' => $updateInfo['latest_version'],
|
||||
'update_info' => [
|
||||
'from_version' => $updateInfo['current_version'],
|
||||
'to_version' => $updateInfo['latest_version'],
|
||||
'update_logs' => $logMessages,
|
||||
'author' => $updateInfo['author'],
|
||||
'published_at' => $updateInfo['published_at']
|
||||
]
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Update execution failed: ' . $e->getMessage());
|
||||
Cache::forget(self::CACHE_UPDATE_LOCK);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('update.failed', ['error' => $e->getMessage()])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function getCurrentCommit(): string
|
||||
{
|
||||
try {
|
||||
// Ensure git configuration is correct
|
||||
Process::run(sprintf('git config --global --add safe.directory %s', base_path()));
|
||||
$result = Process::run('git rev-parse HEAD');
|
||||
$fullHash = trim($result->output());
|
||||
return $fullHash ? $this->formatCommitHash($fullHash) : 'unknown';
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to get current commit: ' . $e->getMessage());
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
protected function getFirstCommit(): string
|
||||
{
|
||||
try {
|
||||
// Get first commit hash
|
||||
$result = Process::run('git rev-list --max-parents=0 HEAD');
|
||||
$fullHash = trim($result->output());
|
||||
return $fullHash ? $this->formatCommitHash($fullHash) : 'unknown';
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to get first commit: ' . $e->getMessage());
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatCommitHash(string $hash): string
|
||||
{
|
||||
// Use 7 characters for commit hash
|
||||
return substr($hash, 0, 7);
|
||||
}
|
||||
|
||||
protected function backupDatabase(): void
|
||||
{
|
||||
try {
|
||||
// Use existing backup command
|
||||
Process::run('php artisan backup:database');
|
||||
|
||||
if (!Process::result()->successful()) {
|
||||
throw new \Exception(__('update.backup_failed', ['error' => Process::result()->errorOutput()]));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Database backup failed: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function pullLatestCode(): array
|
||||
{
|
||||
try {
|
||||
// Get current project root directory
|
||||
$basePath = base_path();
|
||||
|
||||
// Ensure git configuration is correct
|
||||
Process::run(sprintf('git config --global --add safe.directory %s', $basePath));
|
||||
|
||||
// Pull latest code
|
||||
Process::run('git fetch origin master');
|
||||
Process::run('git reset --hard origin/master');
|
||||
|
||||
// Update dependencies
|
||||
Process::run('composer install --no-dev --optimize-autoloader');
|
||||
|
||||
// Update version cache after pulling new code
|
||||
$this->updateVersionCache();
|
||||
|
||||
return ['success' => true];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('update.code_update_failed', ['error' => $e->getMessage()])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function runMigrations(): void
|
||||
{
|
||||
try {
|
||||
Process::run('php artisan migrate --force');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Migration failed: ' . $e->getMessage());
|
||||
throw new \Exception(__('update.migration_failed', ['error' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
protected function clearCache(): void
|
||||
{
|
||||
try {
|
||||
$commands = [
|
||||
'php artisan config:clear',
|
||||
'php artisan cache:clear',
|
||||
'php artisan view:clear',
|
||||
'php artisan route:clear'
|
||||
];
|
||||
|
||||
foreach ($commands as $command) {
|
||||
Process::run($command);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Cache clearing failed: ' . $e->getMessage());
|
||||
throw new \Exception(__('update.cache_clear_failed', ['error' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
protected function createUpdateFlag(): void
|
||||
{
|
||||
try {
|
||||
// Create update flag file for external script to detect and restart container
|
||||
$flagFile = storage_path('update_pending');
|
||||
File::put($flagFile, date('Y-m-d H:i:s'));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to create update flag: ' . $e->getMessage());
|
||||
throw new \Exception(__('update.flag_create_failed', ['error' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
protected function restartOctane(): void
|
||||
{
|
||||
try {
|
||||
if (!config('octane.server')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Octane running status
|
||||
$statusResult = Process::run('php artisan octane:status');
|
||||
if (!$statusResult->successful()) {
|
||||
Log::info('Octane is not running, skipping restart.');
|
||||
return;
|
||||
}
|
||||
|
||||
$output = $statusResult->output();
|
||||
if (str_contains($output, 'Octane server is running')) {
|
||||
Log::info('Restarting Octane server after update...');
|
||||
// Update version cache before restart
|
||||
$this->updateVersionCache();
|
||||
Process::run('php artisan octane:stop');
|
||||
Log::info('Octane server restarted successfully.');
|
||||
} else {
|
||||
Log::info('Octane is not running, skipping restart.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to restart Octane server: ' . $e->getMessage());
|
||||
// Non-fatal error, don't throw exception
|
||||
}
|
||||
}
|
||||
|
||||
public function getLastCheckTime()
|
||||
{
|
||||
return Cache::get(self::CACHE_LAST_CHECK, null);
|
||||
}
|
||||
|
||||
protected function setLastCheckTime(): void
|
||||
{
|
||||
Cache::put(self::CACHE_LAST_CHECK, now()->timestamp, now()->addDays(30));
|
||||
}
|
||||
|
||||
public function getCachedUpdateInfo(): array
|
||||
{
|
||||
return Cache::get(self::CACHE_UPDATE_INFO, [
|
||||
'has_update' => false,
|
||||
'latest_version' => $this->getCurrentCommit(),
|
||||
'current_version' => $this->getCurrentCommit(),
|
||||
'update_logs' => [],
|
||||
'download_url' => '',
|
||||
'published_at' => '',
|
||||
'author' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getLocalGitLogs(int $limit = 50): array
|
||||
{
|
||||
try {
|
||||
// 获取本地git log
|
||||
$result = Process::run(
|
||||
sprintf('git log -%d --pretty=format:"%%H||%%s||%%an||%%ai"', $limit)
|
||||
);
|
||||
|
||||
if (!$result->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
$lines = explode("\n", trim($result->output()));
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode('||', $line);
|
||||
if (count($parts) === 4) {
|
||||
$logs[] = [
|
||||
'hash' => $parts[0],
|
||||
'message' => $parts[1],
|
||||
'author' => $parts[2],
|
||||
'date' => $parts[3]
|
||||
];
|
||||
}
|
||||
}
|
||||
return $logs;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to get local git logs: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
288
Xboard/app/Services/UserService.php
Normal file
288
Xboard/app/Services/UserService.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\StatServerJob;
|
||||
use App\Jobs\StatUserJob;
|
||||
use App\Jobs\TrafficFetchJob;
|
||||
use App\Models\Order;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\Plugin\HookManager;
|
||||
use App\Services\TrafficResetService;
|
||||
use App\Models\TrafficResetLog;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserService
|
||||
{
|
||||
/**
|
||||
* Get the remaining days until the next traffic reset for a user.
|
||||
* This method reuses the TrafficResetService logic for consistency.
|
||||
*/
|
||||
public function getResetDay(User $user): ?int
|
||||
{
|
||||
// Use TrafficResetService to calculate the next reset time
|
||||
$trafficResetService = app(TrafficResetService::class);
|
||||
$nextResetTime = $trafficResetService->calculateNextResetTime($user);
|
||||
|
||||
if (!$nextResetTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate the remaining days from now to the next reset time
|
||||
$now = time();
|
||||
$resetTimestamp = $nextResetTime->timestamp;
|
||||
|
||||
if ($resetTimestamp <= $now) {
|
||||
return 0; // Reset time has passed or is now
|
||||
}
|
||||
|
||||
// Calculate the difference in days (rounded up)
|
||||
$daysDifference = ceil(($resetTimestamp - $now) / 86400);
|
||||
|
||||
return (int) $daysDifference;
|
||||
}
|
||||
|
||||
public function isAvailable(User $user)
|
||||
{
|
||||
if (!$user->banned && $user->transfer_enable && ($user->expired_at > time() || $user->expired_at === NULL)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getAvailableUsers()
|
||||
{
|
||||
return User::whereRaw('u + d < transfer_enable')
|
||||
->where(function ($query) {
|
||||
$query->where('expired_at', '>=', time())
|
||||
->orWhere('expired_at', NULL);
|
||||
})
|
||||
->where('banned', 0)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getUnAvailbaleUsers()
|
||||
{
|
||||
return User::where(function ($query) {
|
||||
$query->where('expired_at', '<', time())
|
||||
->orWhere('expired_at', 0);
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->where('plan_id', NULL)
|
||||
->orWhere('transfer_enable', 0);
|
||||
})
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getUsersByIds($ids)
|
||||
{
|
||||
return User::whereIn('id', $ids)->get();
|
||||
}
|
||||
|
||||
public function getAllUsers()
|
||||
{
|
||||
return User::all();
|
||||
}
|
||||
|
||||
public function addBalance(int $userId, int $balance): bool
|
||||
{
|
||||
$user = User::lockForUpdate()->find($userId);
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
$user->balance = $user->balance + $balance;
|
||||
if ($user->balance < 0) {
|
||||
return false;
|
||||
}
|
||||
if (!$user->save()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isNotCompleteOrderByUserId(int $userId): bool
|
||||
{
|
||||
$order = Order::whereIn('status', [0, 1])
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function trafficFetch(Server $server, string $protocol, array $data)
|
||||
{
|
||||
$server->rate = $server->getCurrentRate();
|
||||
$server = $server->toArray();
|
||||
|
||||
list($server, $protocol, $data) = HookManager::filter('traffic.process.before', [$server, $protocol, $data]);
|
||||
// Compatible with legacy hook
|
||||
list($server, $protocol, $data) = HookManager::filter('traffic.before_process', [$server, $protocol, $data]);
|
||||
|
||||
$timestamp = strtotime(date('Y-m-d'));
|
||||
collect($data)->chunk(1000)->each(function ($chunk) use ($timestamp, $server, $protocol) {
|
||||
TrafficFetchJob::dispatch($server, $chunk->toArray(), $protocol, $timestamp);
|
||||
StatUserJob::dispatch($server, $chunk->toArray(), $protocol, 'd');
|
||||
StatServerJob::dispatch($server, $chunk->toArray(), $protocol, 'd');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户流量信息(增加重置检查)
|
||||
*/
|
||||
public function getUserTrafficInfo(User $user): array
|
||||
{
|
||||
// 检查是否需要重置流量
|
||||
app(TrafficResetService::class)->checkAndReset($user, TrafficResetLog::SOURCE_USER_ACCESS);
|
||||
|
||||
// 重新获取用户数据(可能已被重置)
|
||||
$user->refresh();
|
||||
|
||||
return [
|
||||
'upload' => $user->u ?? 0,
|
||||
'download' => $user->d ?? 0,
|
||||
'total_used' => $user->getTotalUsedTraffic(),
|
||||
'total_available' => $user->transfer_enable ?? 0,
|
||||
'remaining' => $user->getRemainingTraffic(),
|
||||
'usage_percentage' => $user->getTrafficUsagePercentage(),
|
||||
'next_reset_at' => $user->next_reset_at,
|
||||
'last_reset_at' => $user->last_reset_at,
|
||||
'reset_count' => $user->reset_count,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
public function createUser(array $data): User
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
// 基本信息
|
||||
$user->email = $data['email'];
|
||||
$user->password = isset($data['password'])
|
||||
? Hash::make($data['password'])
|
||||
: Hash::make($data['email']);
|
||||
$user->uuid = Helper::guid(true);
|
||||
$user->token = Helper::guid();
|
||||
|
||||
// 默认设置
|
||||
$user->remind_expire = admin_setting('default_remind_expire', 1);
|
||||
$user->remind_traffic = admin_setting('default_remind_traffic', 1);
|
||||
$user->expired_at = null;
|
||||
|
||||
// 可选字段
|
||||
$this->setOptionalFields($user, $data);
|
||||
|
||||
// 处理计划
|
||||
if (isset($data['plan_id'])) {
|
||||
$this->setPlanForUser($user, $data['plan_id'], $data['expired_at'] ?? null);
|
||||
} else {
|
||||
$this->setTryOutPlan(user: $user);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置可选字段
|
||||
*/
|
||||
private function setOptionalFields(User $user, array $data): void
|
||||
{
|
||||
$optionalFields = [
|
||||
'invite_user_id',
|
||||
'telegram_id',
|
||||
'group_id',
|
||||
'speed_limit',
|
||||
'expired_at',
|
||||
'transfer_enable'
|
||||
];
|
||||
|
||||
foreach ($optionalFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$user->{$field} = $data[$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户设置计划
|
||||
*/
|
||||
private function setPlanForUser(User $user, int $planId, ?int $expiredAt = null): void
|
||||
{
|
||||
$plan = Plan::find($planId);
|
||||
if (!$plan)
|
||||
return;
|
||||
|
||||
$user->plan_id = $plan->id;
|
||||
$user->group_id = $plan->group_id;
|
||||
$user->transfer_enable = $plan->transfer_enable * 1073741824;
|
||||
$user->speed_limit = $plan->speed_limit;
|
||||
|
||||
if ($expiredAt) {
|
||||
$user->expired_at = $expiredAt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户分配一个新套餐或续费现有套餐
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param Plan $plan 套餐模型
|
||||
* @param int $validityDays 购买天数
|
||||
* @return User 更新后的用户模型
|
||||
*/
|
||||
public function assignPlan(User $user, Plan $plan, int $validityDays): User
|
||||
{
|
||||
$user->plan_id = $plan->id;
|
||||
$user->group_id = $plan->group_id;
|
||||
$user->transfer_enable = $plan->transfer_enable * 1073741824;
|
||||
$user->speed_limit = $plan->speed_limit;
|
||||
$user->device_limit = $plan->device_limit;
|
||||
|
||||
if ($validityDays > 0) {
|
||||
$user = $this->extendSubscription($user, $validityDays);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 延长用户的订阅有效期
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @param int $days 延长天数
|
||||
* @return User 更新后的用户模型
|
||||
*/
|
||||
public function extendSubscription(User $user, int $days): User
|
||||
{
|
||||
$currentExpired = $user->expired_at ?? time();
|
||||
$user->expired_at = max($currentExpired, time()) + ($days * 86400);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置试用计划
|
||||
*/
|
||||
private function setTryOutPlan(User $user): void
|
||||
{
|
||||
if (!(int) admin_setting('try_out_plan_id', 0))
|
||||
return;
|
||||
|
||||
$plan = Plan::find(admin_setting('try_out_plan_id'));
|
||||
if (!$plan)
|
||||
return;
|
||||
|
||||
$user->transfer_enable = $plan->transfer_enable * 1073741824;
|
||||
$user->plan_id = $plan->id;
|
||||
$user->group_id = $plan->group_id;
|
||||
$user->expired_at = time() + (admin_setting('try_out_hour', 1) * 3600);
|
||||
$user->speed_limit = $plan->speed_limit;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user