fix: resolve various issues and add new features

This commit is contained in:
xboard 2025-01-13 20:29:09 +08:00
parent 1f31c6b585
commit b7f2af7d6a
11 changed files with 563 additions and 43 deletions

View File

@ -47,6 +47,60 @@ class ClientController extends Controller
private const ALLOWED_TYPES = ['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'hysteria2'];
/**
* 处理浏览器访问订阅的情况
*/
private function handleBrowserSubscribe($user, UserService $userService)
{
$useTraffic = $user['u'] + $user['d'];
$totalTraffic = $user['transfer_enable'];
$remainingTraffic = Helper::trafficConvert($totalTraffic - $useTraffic);
$expiredDate = $user['expired_at'] ? date('Y-m-d', $user['expired_at']) : __('Unlimited');
$resetDay = $userService->getResetDay($user);
// 获取通用订阅地址
$subscriptionUrl = Helper::getSubscribeUrl($user->token);
// 生成二维码
$writer = new \BaconQrCode\Writer(
new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
)
);
$qrCode = base64_encode($writer->writeString($subscriptionUrl));
$data = [
'username' => $user->email,
'status' => $userService->isAvailable($user) ? 'active' : 'inactive',
'data_limit' => $totalTraffic ? Helper::trafficConvert($totalTraffic) : '∞',
'data_used' => Helper::trafficConvert($useTraffic),
'expired_date' => $expiredDate,
'reset_day' => $resetDay,
'subscription_url' => $subscriptionUrl,
'qr_code' => $qrCode
];
// 只有当 device_limit 不为 null 时才添加到返回数据中
if ($user->device_limit !== null) {
$data['device_limit'] = $user->device_limit;
}
return response()->view('client.subscribe', $data);
}
/**
* 检查是否是浏览器访问
*/
private function isBrowserAccess(Request $request): bool
{
$userAgent = strtolower($request->header('User-Agent'));
return str_contains($userAgent, 'mozilla')
|| str_contains($userAgent, 'chrome')
|| str_contains($userAgent, 'safari')
|| str_contains($userAgent, 'edge');
}
public function subscribe(Request $request)
{
$request->validate([
@ -62,6 +116,11 @@ class ClientController extends Controller
return response()->json(['message' => 'Account unavailable'], 403);
}
// 检测是否是浏览器访问
if ($this->isBrowserAccess($request)) {
return $this->handleBrowserSubscribe($user, $userService);
}
$types = $this->getFilteredTypes($request->input('types', 'all'));
$filterArr = $this->getFilterArray($request->input('filter'));
$clientInfo = $this->getClientInfo($request);
@ -178,7 +237,7 @@ class ClientController extends Controller
$useTraffic = $user['u'] + $user['d'];
$totalTraffic = $user['transfer_enable'];
$remainingTraffic = Helper::trafficConvert($totalTraffic - $useTraffic);
$expiredDate = $user['expired_at'] ? date('Y-m-d', $user['expired_at']) : '长期有效';
$expiredDate = $user['expired_at'] ? date('Y-m-d', $user['expired_at']) : __('长期有效');
$userService = new UserService();
$resetDay = $userService->getResetDay($user);
array_unshift($servers, array_merge($servers[0], [

View File

@ -23,6 +23,35 @@ class StatController extends Controller
}
public function getOverride(Request $request)
{
// 获取在线节点数
$onlineNodes = Server::all()->filter(function ($server) {
$server->loadServerStatus();
return $server->is_online;
})->count();
// 获取在线设备数和在线用户数
$onlineDevices = User::where('t', '>=', time() - 600)
->sum('online_count');
$onlineUsers = User::where('t', '>=', time() - 600)
->count();
// 获取今日流量统计
$todayStart = strtotime('today');
$todayTraffic = StatServer::where('record_at', '>=', $todayStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取本月流量统计
$monthStart = strtotime(date('Y-m-1'));
$monthTraffic = StatServer::where('record_at', '>=', $monthStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取总流量统计
$totalTraffic = StatServer::selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
return [
'data' => [
'month_income' => Order::where('created_at', '>=', strtotime(date('Y-m-1')))
@ -53,6 +82,25 @@ class StatController extends Controller
'commission_last_month_payout' => CommissionLog::where('created_at', '>=', strtotime('-1 month', strtotime(date('Y-m-1'))))
->where('created_at', '<', strtotime(date('Y-m-1')))
->sum('get_amount'),
// 新增统计数据
'online_nodes' => $onlineNodes,
'online_devices' => $onlineDevices,
'online_users' => $onlineUsers,
'today_traffic' => [
'upload' => $todayTraffic->upload ?? 0,
'download' => $todayTraffic->download ?? 0,
'total' => $todayTraffic->total ?? 0
],
'month_traffic' => [
'upload' => $monthTraffic->upload ?? 0,
'download' => $monthTraffic->download ?? 0,
'total' => $monthTraffic->total ?? 0
],
'total_traffic' => [
'upload' => $totalTraffic->upload ?? 0,
'download' => $totalTraffic->download ?? 0,
'total' => $totalTraffic->total ?? 0
]
]
];
}
@ -213,11 +261,39 @@ class StatController extends Controller
$currentMonthStart = strtotime(date('Y-m-01'));
$lastMonthStart = strtotime('-1 month', $currentMonthStart);
$twoMonthsAgoStart = strtotime('-2 month', $currentMonthStart);
// Today's start timestamp
$todayStart = strtotime('today');
$yesterdayStart = strtotime('-1 day', $todayStart);
// 获取在线节点数
$onlineNodes = Server::all()->filter(function ($server) {
$server->loadServerStatus();
return $server->is_online;
})->count();
// 获取在线设备数和在线用户数
$onlineDevices = User::where('t', '>=', time() - 600)
->sum('online_count');
$onlineUsers = User::where('t', '>=', time() - 600)
->count();
// 获取今日流量统计
$todayTraffic = StatServer::where('record_at', '>=', $todayStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取本月流量统计
$monthTraffic = StatServer::where('record_at', '>=', $currentMonthStart)
->where('record_at', '<', time())
->selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// 获取总流量统计
$totalTraffic = StatServer::selectRaw('SUM(u) as upload, SUM(d) as download, SUM(u + d) as total')
->first();
// Today's income
$todayIncome = Order::where('created_at', '>=', $todayStart)
->where('created_at', '<', time())
@ -230,10 +306,6 @@ class StatController extends Controller
->whereNotIn('status', [0, 2])
->sum('total_amount');
// Online users (active in last 10 minutes)
$onlineUsers = User::where('t', '>=', time() - 600)
->count();
// Current month income
$currentMonthIncome = Order::where('created_at', '>=', $currentMonthStart)
->where('created_at', '<', time())
@ -251,6 +323,11 @@ class StatController extends Controller
->where('created_at', '<', $currentMonthStart)
->sum('get_amount');
// Current month commission payout
$currentMonthCommissionPayout = CommissionLog::where('created_at', '>=', $currentMonthStart)
->where('created_at', '<', time())
->sum('get_amount');
// Current month new users
$currentMonthNewUsers = User::where('created_at', '>=', $currentMonthStart)
->where('created_at', '<', time())
@ -288,21 +365,60 @@ class StatController extends Controller
$userGrowth = $lastMonthNewUsers > 0 ? round(($currentMonthNewUsers - $lastMonthNewUsers) / $lastMonthNewUsers * 100, 1) : 0;
$dayIncomeGrowth = $yesterdayIncome > 0 ? round(($todayIncome - $yesterdayIncome) / $yesterdayIncome * 100, 1) : 0;
// 获取待处理工单和佣金数据
$ticketPendingTotal = Ticket::where('status', 0)->count();
$commissionPendingTotal = Order::where('commission_status', 0)
->where('invite_user_id', '!=', NULL)
->whereIn('status', [Order::STATUS_COMPLETED])
->where('commission_balance', '>', 0)
->count();
return [
'data' => [
// 收入相关
'todayIncome' => $todayIncome,
'onlineUsers' => $onlineUsers,
'dayIncomeGrowth' => $dayIncomeGrowth,
'currentMonthIncome' => $currentMonthIncome,
'lastMonthIncome' => $lastMonthIncome,
'monthIncomeGrowth' => $monthIncomeGrowth,
'lastMonthIncomeGrowth' => $lastMonthIncomeGrowth,
// 佣金相关
'currentMonthCommissionPayout' => $currentMonthCommissionPayout,
'lastMonthCommissionPayout' => $lastMonthCommissionPayout,
'commissionGrowth' => $commissionGrowth,
'commissionPendingTotal' => $commissionPendingTotal,
// 用户相关
'currentMonthNewUsers' => $currentMonthNewUsers,
'totalUsers' => $totalUsers,
'activeUsers' => $activeUsers,
'monthIncomeGrowth' => $monthIncomeGrowth,
'lastMonthIncomeGrowth' => $lastMonthIncomeGrowth,
'commissionGrowth' => $commissionGrowth,
'userGrowth' => $userGrowth
'userGrowth' => $userGrowth,
'onlineUsers' => $onlineUsers,
'onlineDevices' => $onlineDevices,
// 工单相关
'ticketPendingTotal' => $ticketPendingTotal,
// 节点相关
'onlineNodes' => $onlineNodes,
// 流量统计
'todayTraffic' => [
'upload' => $todayTraffic->upload ?? 0,
'download' => $todayTraffic->download ?? 0,
'total' => $todayTraffic->total ?? 0
],
'monthTraffic' => [
'upload' => $monthTraffic->upload ?? 0,
'download' => $monthTraffic->download ?? 0,
'total' => $monthTraffic->total ?? 0
],
'totalTraffic' => [
'upload' => $totalTraffic->upload ?? 0,
'download' => $totalTraffic->download ?? 0,
'total' => $totalTraffic->total ?? 0
]
]
];
}

View File

@ -6,11 +6,6 @@ use App\Http\Controllers\V2\Admin\PlanController;
use App\Http\Controllers\V2\Admin\Server\GroupController;
use App\Http\Controllers\V2\Admin\Server\RouteController;
use App\Http\Controllers\V2\Admin\Server\ManageController;
use App\Http\Controllers\V2\Admin\Server\TrojanController;
use App\Http\Controllers\V2\Admin\Server\VmessController;
use App\Http\Controllers\V2\Admin\Server\ShadowsocksController;
use App\Http\Controllers\V2\Admin\Server\HysteriaController;
use App\Http\Controllers\V2\Admin\Server\VlessController;
use App\Http\Controllers\V2\Admin\OrderController;
use App\Http\Controllers\V2\Admin\UserController;
use App\Http\Controllers\V2\Admin\StatController;
@ -118,15 +113,14 @@ class AdminRoute
$router->group([
'prefix' => 'stat'
], function ($router) {
$router->get('/getStat', [StatController::class, 'getStat']);
$router->get('/getOverride', [StatController::class, 'getOverride']);
$router->get('/getStats', [StatController::class, 'getStats']);
$router->get('/getServerLastRank', [StatController::class, 'getServerLastRank']);
$router->get('/getServerYesterdayRank', [StatController::class, 'getServerYesterdayRank']);
$router->get('/getOrder', [StatController::class, 'getOrder']);
$router->any('/getStatUser', [StatController::class, 'getStatUser']);
$router->get('/getRanking', [StatController::class, 'getRanking']);
$router->get('/getStatRecord', [StatController::class, 'getStatRecord']);
$router->get('/getStats', [StatController::class, 'getStats']);
$router->get('/getTrafficRank', [StatController::class, 'getTrafficRank']);
});

View File

@ -12,6 +12,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"bacon/bacon-qr-code": "^2.0",
"doctrine/dbal": "^3.7",
"google/cloud-storage": "^1.35",
"google/recaptcha": "^1.2",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -95,5 +95,23 @@
"Sending frequently, please try again later": "Sending frequently, please try again later",
"Current product is sold out": "Current product is sold out",
"There are too many password errors, please try again after :minute minutes.": "There are too many password errors, please try again after :minute minutes.",
"Reset failed, Please try again later": "Reset failed, Please try again later"
"Reset failed, Please try again later": "Reset failed, Please try again later",
"Subscribe": "Subscribe",
"User Information": "User Information",
"Username": "Username",
"Status": "Status",
"Active": "Active",
"Inactive": "Inactive",
"Data Used": "Data Used",
"Data Limit": "Data Limit",
"Expiration Date": "Expiration Date",
"Reset In": "Reset In",
"Days": "Days",
"Subscription Link": "Subscription Link",
"Copy": "Copy",
"Copied": "Copied",
"QR Code": "QR Code",
"Unlimited": "Unlimited",
"Device Limit": "Device Limit",
"Devices": "Devices"
}

View File

@ -95,5 +95,23 @@
"Sending frequently, please try again later": "发送频繁,请稍后再试",
"Current product is sold out": "当前商品已售罄",
"There are too many password errors, please try again after :minute minutes.": "密码错误次数过多,请 :minute 分钟后再试",
"Reset failed, Please try again later": "重置失败,请稍后再试"
"Reset failed, Please try again later": "重置失败,请稍后再试",
"Subscribe": "订阅信息",
"User Information": "用户信息",
"Username": "用户名",
"Status": "状态",
"Active": "正常",
"Inactive": "未激活",
"Data Used": "已用流量",
"Data Limit": "流量限制",
"Expiration Date": "到期时间",
"Reset In": "距离重置",
"Days": "天",
"Subscription Link": "订阅链接",
"Copy": "复制",
"Copied": "已复制",
"QR Code": "二维码",
"Unlimited": "长期有效",
"Device Limit": "设备限制",
"Devices": "台设备"
}

View File

@ -95,5 +95,23 @@
"Sending frequently, please try again later": "發送頻繁,請稍後再試",
"Current product is sold out": "當前商品已售罄",
"There are too many password errors, please try again after :minute minutes.": "密碼錯誤次數過多,請 :minute 分鐘後再試",
"Reset failed, Please try again later": "重置失敗,請稍後再試"
"Reset failed, Please try again later": "重置失敗,請稍後再試",
"Subscribe": "訂閱資訊",
"User Information": "用戶資訊",
"Username": "用戶名",
"Status": "狀態",
"Active": "正常",
"Inactive": "未啟用",
"Data Used": "已用流量",
"Data Limit": "流量限制",
"Expiration Date": "到期時間",
"Reset In": "距離重置",
"Days": "天",
"Subscription Link": "訂閱連結",
"Copy": "複製",
"Copied": "已複製",
"QR Code": "二維碼",
"Unlimited": "長期有效",
"Device Limit": "設備限制",
"Devices": "台設備"
}

View File

@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ __('Subscribe') }}</title>
<style>
:root {
--bg: #ffffff;
--text: #000000;
--text-secondary: #666666;
--primary: #2196f3;
--success: #4caf50;
--danger: #f44336;
--border: #eee;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
padding: 2rem 1rem;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.title {
font-size: 1.75rem;
font-weight: bold;
margin-bottom: 2rem;
}
.info-list {
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-bottom: 3rem;
}
.info-item {
display: flex;
align-items: center;
font-size: 1rem;
}
.info-label {
min-width: 100px;
color: var(--text-secondary);
}
.info-value {
color: var(--text);
flex: 1;
font-weight: 500;
margin-left: 1rem;
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.status.active {
background: var(--success);
color: white;
}
.status.inactive {
background: var(--danger);
color: white;
}
.links-section {
margin-top: 2rem;
}
.links-title {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 1rem;
color: var(--text-secondary);
}
.link-item {
position: relative;
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.link-input {
flex: 1;
width: 100%;
padding: 0.75rem;
padding-right: 4rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.875rem;
color: var(--text);
background: #f5f5f5;
}
.link-input:focus {
outline: none;
border-color: var(--primary);
}
.copy-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.5rem;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.25rem;
transition: color 0.2s;
}
.copy-btn:hover {
color: var(--primary);
}
.copy-btn svg {
width: 1rem;
height: 1rem;
}
.copy-btn.copied {
color: var(--success);
}
.qr-section {
display: flex;
justify-content: center;
margin-top: 2rem;
}
.qr-section img {
width: 180px;
height: 180px;
padding: 0.75rem;
background: white;
border-radius: 4px;
}
@media (max-width: 640px) {
body {
padding: 1.5rem 1rem;
}
.title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.info-item {
font-size: 0.875rem;
}
.info-label {
min-width: 80px;
}
.qr-section img {
width: 160px;
height: 160px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #000000;
--text: #ffffff;
--text-secondary: #999999;
--border: #222;
}
.link-input {
background: #111;
border-color: var(--border);
color: var(--text);
}
}
</style>
</head>
<body>
<div class="container">
<h1 class="title">{{ __('User Information') }}</h1>
<div class="info-list">
<div class="info-item">
<div class="info-label">{{ __('Username') }}</div>
<div class="info-value">{{ $username }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Status') }}</div>
<div class="info-value">
<span class="status {{ $status }}">
{{ $status === 'active' ? __('Active') : __('Inactive') }}
</span>
</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Data Used') }}</div>
<div class="info-value">{{ $data_used }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Data Limit') }}</div>
<div class="info-value">{{ $data_limit }}</div>
</div>
<div class="info-item">
<div class="info-label">{{ __('Expiration Date') }}</div>
<div class="info-value">{{ $expired_date }}</div>
</div>
@if (isset($device_limit))
<div class="info-item">
<div class="info-label">{{ __('Device Limit') }}</div>
<div class="info-value">{{ $device_limit }} {{ __('Devices') }}</div>
</div>
@endif
@if ($reset_day)
<div class="info-item">
<div class="info-label">{{ __('Reset In') }}</div>
<div class="info-value">{{ $reset_day }} {{ __('Days') }}</div>
</div>
@endif
</div>
<div class="links-section">
<h2 class="links-title">{{ __('Subscription Link') }}</h2>
<div class="link-item">
<input type="text" value="{{ $subscription_url }}" readonly id="sub_url" class="link-input" onclick="this.select()">
<button class="copy-btn" onclick="copyToClipboard('sub_url')" title="{{ __('Copy') }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
<span>{{ __('Copy') }}</span>
</button>
</div>
<div class="qr-section">
<img src="data:image/svg+xml;base64,{{ $qr_code }}" alt="{{ __('QR Code') }}">
</div>
</div>
</div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
document.execCommand('copy');
element.blur();
const btn = element.nextElementSibling;
const span = btn.querySelector('span');
const originalText = span.textContent;
btn.classList.add('copied');
span.textContent = '{{ __('Copied') }}';
setTimeout(() => {
btn.classList.remove('copied');
span.textContent = originalText;
}, 1000);
}
</script>
</body>
</html>