first commit

This commit is contained in:
CN-JS-HuiBai
2026-04-07 16:54:24 +08:00
commit 2c6a38c80d
399 changed files with 42205 additions and 0 deletions

View 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;
}
}

View 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;
}
}

View 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];
}
}

View 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
];
}
}

View 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];
}
}

View 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
]));
}
}
}
}

View 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(),
]);
// }
}
}

View 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(),
]);
}
}

View 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;
}
}

View 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);
}
}

View 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,
]);
}
}
}

View 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;
}
}
}

View 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);
}
}

View 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;
}
}

View 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
}
}

View 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', []);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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()}");
}
}
}

View 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]);
}
}
}

View 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}"
]
]);
}
}
}

View 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);
}
}

View 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 [];
}
}
}

View 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;
}
}