mirror of
https://github.com/cedar2025/Xboard.git
synced 2025-03-11 07:28:13 -04:00
feat: Add TUIC protocol support and fix user filtering/export issues
This commit is contained in:
parent
4667eb232c
commit
b7e87ba18d
@ -26,6 +26,7 @@ class ClientController extends Controller
|
||||
'shadowsocks' => '[ss]',
|
||||
'vmess' => '[vmess]',
|
||||
'trojan' => '[trojan]',
|
||||
'tuic' => '[tuic]',
|
||||
];
|
||||
|
||||
// 支持hy2 的客户端版本列表
|
||||
@ -46,7 +47,7 @@ class ClientController extends Controller
|
||||
'flclash' => '0.8.0'
|
||||
];
|
||||
|
||||
private const ALLOWED_TYPES = ['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'hysteria2'];
|
||||
private const ALLOWED_TYPES = ['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'hysteria2', 'tuic'];
|
||||
|
||||
|
||||
public function subscribe(Request $request)
|
||||
|
@ -138,6 +138,15 @@ class UniProxyController extends Controller
|
||||
default => []
|
||||
}
|
||||
],
|
||||
'tuic' => [
|
||||
'version' => (int) $protocolSettings['version'],
|
||||
'server_port' => (int) $serverPort,
|
||||
'server_name' => $protocolSettings['tls']['server_name'],
|
||||
'congestion_control' => $protocolSettings['congestion_control'],
|
||||
'auth_timeout' => '3s',
|
||||
'zero_rtt_handshake' => false,
|
||||
'heartbeat' => "3s",
|
||||
],
|
||||
default => []
|
||||
};
|
||||
|
||||
|
@ -10,6 +10,7 @@ use App\Jobs\SendEmailJob;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Services\AuthService;
|
||||
use App\Traits\QueryOperators;
|
||||
use App\Utils\Helper;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
@ -19,6 +20,8 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
use QueryOperators;
|
||||
|
||||
public function resetSecret(Request $request)
|
||||
{
|
||||
$user = User::find($request->input('id'));
|
||||
@ -75,13 +78,29 @@ class UserController extends Controller
|
||||
*/
|
||||
private function buildFilterQuery(Builder $query, string $field, mixed $value): void
|
||||
{
|
||||
// Handle array values for 'in' operations
|
||||
// 处理关联查询
|
||||
if (str_contains($field, '.')) {
|
||||
[$relation, $relationField] = explode('.', $field);
|
||||
$query->whereHas($relation, function($q) use ($relationField, $value) {
|
||||
if (is_array($value)) {
|
||||
$q->whereIn($relationField, $value);
|
||||
} else if (is_string($value) && str_contains($value, ':')) {
|
||||
[$operator, $filterValue] = explode(':', $value, 2);
|
||||
$this->applyQueryCondition($q, $relationField, $operator, $filterValue);
|
||||
} else {
|
||||
$q->where($relationField, 'like', "%{$value}%");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理数组值的 'in' 操作
|
||||
if (is_array($value)) {
|
||||
$query->whereIn($field === 'group_ids' ? 'group_id' : $field, $value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle operator-based filtering
|
||||
// 处理基于运算符的过滤
|
||||
if (!is_string($value) || !str_contains($value, ':')) {
|
||||
$query->where($field, 'like', "%{$value}%");
|
||||
return;
|
||||
@ -89,36 +108,20 @@ class UserController extends Controller
|
||||
|
||||
[$operator, $filterValue] = explode(':', $value, 2);
|
||||
|
||||
// Convert numeric strings to appropriate type
|
||||
// 转换数字字符串为适当的类型
|
||||
if (is_numeric($filterValue)) {
|
||||
$filterValue = strpos($filterValue, '.') !== false
|
||||
? (float) $filterValue
|
||||
: (int) $filterValue;
|
||||
}
|
||||
|
||||
// Handle computed fields
|
||||
// 处理计算字段
|
||||
$queryField = match ($field) {
|
||||
'total_used' => DB::raw('(u + d)'),
|
||||
default => $field
|
||||
};
|
||||
|
||||
// Apply operator
|
||||
$query->where($queryField, match (strtolower($operator)) {
|
||||
'eq' => '=',
|
||||
'gt' => '>',
|
||||
'gte' => '>=',
|
||||
'lt' => '<',
|
||||
'lte' => '<=',
|
||||
'like' => 'like',
|
||||
'notlike' => 'not like',
|
||||
'null' => static fn($q) => $q->whereNull($queryField),
|
||||
'notnull' => static fn($q) => $q->whereNotNull($queryField),
|
||||
default => 'like'
|
||||
}, match (strtolower($operator)) {
|
||||
'like', 'notlike' => "%{$filterValue}%",
|
||||
'null', 'notnull' => null,
|
||||
default => $filterValue
|
||||
});
|
||||
$this->applyQueryCondition($query, $queryField, $operator, $filterValue);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -250,33 +253,88 @@ class UserController extends Controller
|
||||
return $this->success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用户数据为CSV格式
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \Symfony\Component\HttpFoundation\StreamedResponse
|
||||
*/
|
||||
public function dumpCSV(Request $request)
|
||||
{
|
||||
ini_set('memory_limit', -1);
|
||||
$userModel = User::orderBy('id', 'asc');
|
||||
$this->applyFiltersAndSorts($request, $userModel);
|
||||
$res = $userModel->get();
|
||||
$plan = Plan::get();
|
||||
for ($i = 0; $i < count($res); $i++) {
|
||||
for ($k = 0; $k < count($plan); $k++) {
|
||||
if ($plan[$k]['id'] == $res[$i]['plan_id']) {
|
||||
$res[$i]['plan_name'] = $plan[$k]['name'];
|
||||
}
|
||||
}
|
||||
}
|
||||
ini_set('memory_limit', '-1');
|
||||
gc_enable(); // 启用垃圾回收
|
||||
|
||||
$data = "邮箱,余额,推广佣金,总流量,剩余流量,套餐到期时间,订阅计划,订阅地址\r\n";
|
||||
foreach ($res as $user) {
|
||||
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
|
||||
$balance = $user['balance'] / 100;
|
||||
$commissionBalance = $user['commission_balance'] / 100;
|
||||
$transferEnable = $user['transfer_enable'] ? $user['transfer_enable'] / 1073741824 : 0;
|
||||
$notUseFlow = (($user['transfer_enable'] - ($user['u'] + $user['d'])) / 1073741824) ?? 0;
|
||||
$planName = $user['plan_name'] ?? '无订阅';
|
||||
$subscribeUrl = Helper::getSubscribeUrl('/api/v1/client/subscribe?token=' . $user['token']);
|
||||
$data .= "{$user['email']},{$balance},{$commissionBalance},{$transferEnable},{$notUseFlow},{$expireDate},{$planName},{$subscribeUrl}\r\n";
|
||||
}
|
||||
echo "\xEF\xBB\xBF" . $data;
|
||||
// 优化查询:使用with预加载plan关系,避免N+1问题
|
||||
$query = User::with('plan:id,name')
|
||||
->orderBy('id', 'asc')
|
||||
->select([
|
||||
'email',
|
||||
'balance',
|
||||
'commission_balance',
|
||||
'transfer_enable',
|
||||
'u',
|
||||
'd',
|
||||
'expired_at',
|
||||
'token',
|
||||
'plan_id'
|
||||
]);
|
||||
|
||||
$this->applyFiltersAndSorts($request, $query);
|
||||
|
||||
$filename = 'users_' . date('Y-m-d_His') . '.csv';
|
||||
|
||||
return response()->streamDownload(function() use ($query) {
|
||||
// 打开输出流
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// 添加BOM标记,确保Excel正确显示中文
|
||||
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
// 写入CSV头部
|
||||
fputcsv($output, [
|
||||
'邮箱',
|
||||
'余额',
|
||||
'推广佣金',
|
||||
'总流量',
|
||||
'剩余流量',
|
||||
'套餐到期时间',
|
||||
'订阅计划',
|
||||
'订阅地址'
|
||||
]);
|
||||
|
||||
// 分批处理数据以减少内存使用
|
||||
$query->chunk(500, function($users) use ($output) {
|
||||
foreach ($users as $user) {
|
||||
try {
|
||||
$row = [
|
||||
$user->email,
|
||||
number_format($user->balance / 100, 2),
|
||||
number_format($user->commission_balance / 100, 2),
|
||||
Helper::trafficConvert($user->transfer_enable),
|
||||
Helper::trafficConvert($user->transfer_enable - ($user->u + $user->d)),
|
||||
$user->expired_at ? date('Y-m-d H:i:s', $user->expired_at) : '长期有效',
|
||||
$user->plan ? $user->plan->name : '无订阅',
|
||||
Helper::getSubscribeUrl($user->token)
|
||||
];
|
||||
fputcsv($output, $row);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CSV导出错误: ' . $e->getMessage(), [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email
|
||||
]);
|
||||
continue; // 继续处理下一条记录
|
||||
}
|
||||
}
|
||||
|
||||
// 清理内存
|
||||
gc_collect_cycles();
|
||||
});
|
||||
|
||||
fclose($output);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"'
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(UserGenerate $request)
|
||||
|
@ -131,11 +131,17 @@ class Server extends Model
|
||||
]
|
||||
],
|
||||
self::TYPE_TUIC => [
|
||||
'version' => ['type' => 'integer', 'default' => 5],
|
||||
'congestion_control' => ['type' => 'string', 'default' => 'cubic'],
|
||||
'alpn' => ['type' => 'array', 'default' => ['h3']],
|
||||
'udp_relay_mode' => ['type' => 'string', 'default' => 'native'],
|
||||
'allow_insecure' => ['type' => 'boolean', 'default' => false],
|
||||
'tls_settings' => ['type' => 'array', 'default' => null]
|
||||
'tls' => [
|
||||
'type' => 'object',
|
||||
'fields' => [
|
||||
'server_name' => ['type' => 'string', 'default' => null],
|
||||
'allow_insecure' => ['type' => 'boolean', 'default' => false]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
|
@ -74,6 +74,10 @@ class ClashMeta implements ProtocolInterface
|
||||
array_push($proxy, self::buildHysteria($user['uuid'], $item, $user));
|
||||
array_push($proxies, $item['name']);
|
||||
}
|
||||
if ($item['type'] === 'tuic') {
|
||||
array_push($proxy, self::buildTuic($user['uuid'], $item));
|
||||
array_push($proxies, $item['name']);
|
||||
}
|
||||
}
|
||||
|
||||
$config['proxies'] = array_merge($config['proxies'] ? $config['proxies'] : [], $proxy);
|
||||
@ -332,6 +336,39 @@ class ClashMeta implements ProtocolInterface
|
||||
return $array;
|
||||
}
|
||||
|
||||
public static function buildTuic($password, $server)
|
||||
{
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
$array = [
|
||||
'name' => $server['name'],
|
||||
'type' => 'tuic',
|
||||
'server' => $server['host'],
|
||||
'port' => $server['port'],
|
||||
'udp' => true,
|
||||
];
|
||||
|
||||
if (data_get($protocol_settings, 'version') === 4) {
|
||||
$array['token'] = $password;
|
||||
} else {
|
||||
$array['uuid'] = $password;
|
||||
$array['password'] = $password;
|
||||
}
|
||||
|
||||
$array['skip-cert-verify'] = (bool) data_get($protocol_settings, 'tls.allow_insecure', false);
|
||||
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
|
||||
$array['sni'] = $serverName;
|
||||
}
|
||||
|
||||
if ($alpn = data_get($protocol_settings, 'alpn')) {
|
||||
$array['alpn'] = $alpn;
|
||||
}
|
||||
|
||||
$array['congestion-controller'] = data_get($protocol_settings, 'congestion_control', 'cubic');
|
||||
$array['udp-relay-mode'] = data_get($protocol_settings, 'udp_relay_mode', 'native');
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
private function isMatch($exp, $str)
|
||||
{
|
||||
return @preg_match($exp, $str);
|
||||
|
@ -80,6 +80,10 @@ class SingBox implements ProtocolInterface
|
||||
$hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item);
|
||||
$proxies[] = $hysteriaConfig;
|
||||
}
|
||||
if ($item['type'] === 'tuic') {
|
||||
$tuicConfig = $this->buildTuic($this->user['uuid'], $item);
|
||||
$proxies[] = $tuicConfig;
|
||||
}
|
||||
}
|
||||
foreach ($outbounds as &$outbound) {
|
||||
if (in_array($outbound['type'], ['urltest', 'selector'])) {
|
||||
@ -324,4 +328,37 @@ class SingBox implements ProtocolInterface
|
||||
$versionConfig
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildTuic($password, $server): array
|
||||
{
|
||||
$protocol_settings = data_get($server, 'protocol_settings', []);
|
||||
$array = [
|
||||
'type' => 'tuic',
|
||||
'tag' => $server['name'],
|
||||
'server' => $server['host'],
|
||||
'server_port' => $server['port'],
|
||||
'congestion_control' => data_get($protocol_settings, 'congestion_control', 'cubic'),
|
||||
'udp_relay_mode' => data_get($protocol_settings, 'udp_relay_mode', 'native'),
|
||||
'zero_rtt_handshake' => true,
|
||||
'heartbeat' => '10s',
|
||||
'tls' => [
|
||||
'enabled' => true,
|
||||
'insecure' => (bool) data_get($protocol_settings, 'tls.allow_insecure', false),
|
||||
'alpn' => data_get($protocol_settings, 'alpn', ['h3']),
|
||||
]
|
||||
];
|
||||
|
||||
if ($serverName = data_get($protocol_settings, 'tls.server_name')) {
|
||||
$array['tls']['server_name'] = $serverName;
|
||||
}
|
||||
|
||||
if (data_get($protocol_settings, 'version') === 4) {
|
||||
$array['token'] = $password;
|
||||
} else {
|
||||
$array['uuid'] = $password;
|
||||
$array['password'] = $password;
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
66
app/Traits/QueryOperators.php
Normal file
66
app/Traits/QueryOperators.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
trait QueryOperators
|
||||
{
|
||||
/**
|
||||
* 获取查询运算符映射
|
||||
*
|
||||
* @param string $operator
|
||||
* @return string
|
||||
*/
|
||||
protected function getQueryOperator(string $operator): string
|
||||
{
|
||||
return match (strtolower($operator)) {
|
||||
'eq' => '=',
|
||||
'gt' => '>',
|
||||
'gte' => '>=',
|
||||
'lt' => '<',
|
||||
'lte' => '<=',
|
||||
'like' => 'like',
|
||||
'notlike' => 'not like',
|
||||
'null' => 'null',
|
||||
'notnull' => 'notnull',
|
||||
default => 'like'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取查询值格式化
|
||||
*
|
||||
* @param string $operator
|
||||
* @param mixed $value
|
||||
* @return mixed
|
||||
*/
|
||||
protected function formatQueryValue(string $operator, mixed $value): mixed
|
||||
{
|
||||
return match (strtolower($operator)) {
|
||||
'like', 'notlike' => "%{$value}%",
|
||||
'null', 'notnull' => null,
|
||||
default => $value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用查询条件
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $field
|
||||
* @param string $operator
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
protected function applyQueryCondition($query, string $field, string $operator, mixed $value): void
|
||||
{
|
||||
$queryOperator = $this->getQueryOperator($operator);
|
||||
|
||||
if ($queryOperator === 'null') {
|
||||
$query->whereNull($field);
|
||||
} elseif ($queryOperator === 'notnull') {
|
||||
$query->whereNotNull($field);
|
||||
} else {
|
||||
$query->where($field, $queryOperator, $this->formatQueryValue($operator, $value));
|
||||
}
|
||||
}
|
||||
}
|
@ -27,6 +27,10 @@ class CacheKey
|
||||
'MULTI_SERVER_VLESS_ONLINE_USER' => 'vless节点多服务器在线用户',
|
||||
'SERVER_VLESS_LAST_CHECK_AT' => 'vless节点最后检查时间',
|
||||
'SERVER_VLESS_LAST_PUSH_AT' => 'vless节点最后推送时间',
|
||||
'SERVER_TUIC_ONLINE_USER' => 'TUIC节点在线用户',
|
||||
'MULTI_SERVER_TUIC_ONLINE_USER' => 'TUIC节点多服务器在线用户',
|
||||
'SERVER_TUIC_LAST_CHECK_AT' => 'TUIC节点最后检查时间',
|
||||
'SERVER_TUIC_LAST_PUSH_AT' => 'TUIC节点最后推送时间',
|
||||
'TEMP_TOKEN' => '临时令牌',
|
||||
'LAST_SEND_EMAIL_REMIND_TRAFFIC' => '最后发送流量邮件提醒',
|
||||
'SCHEDULE_LAST_CHECK_AT' => '计划任务最后检查时间',
|
||||
|
10
public/assets/admin/assets/index.js
vendored
10
public/assets/admin/assets/index.js
vendored
File diff suppressed because one or more lines are too long
31
public/assets/admin/locales/en-US.js
vendored
31
public/assets/admin/locales/en-US.js
vendored
@ -1741,6 +1741,37 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
|
||||
"label": "Flow Control",
|
||||
"placeholder": "Select flow control"
|
||||
}
|
||||
},
|
||||
"tuic": {
|
||||
"version": {
|
||||
"label": "Protocol Version",
|
||||
"placeholder": "Select TUIC Version"
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"placeholder": "Enter Password",
|
||||
"generate_success": "Password Generated Successfully"
|
||||
},
|
||||
"congestion_control": {
|
||||
"label": "Congestion Control",
|
||||
"placeholder": "Select Congestion Control Algorithm"
|
||||
},
|
||||
"udp_relay_mode": {
|
||||
"label": "UDP Relay Mode",
|
||||
"placeholder": "Select UDP Relay Mode"
|
||||
},
|
||||
"tls": {
|
||||
"server_name": {
|
||||
"label": "Server Name Indication (SNI)",
|
||||
"placeholder": "Used for certificate verification when domain differs from node address"
|
||||
},
|
||||
"allow_insecure": "Allow Insecure?",
|
||||
"alpn": {
|
||||
"label": "ALPN",
|
||||
"placeholder": "Select ALPN Protocols",
|
||||
"empty": "No ALPN Protocols Available"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network_settings": {
|
||||
|
31
public/assets/admin/locales/ko-KR.js
vendored
31
public/assets/admin/locales/ko-KR.js
vendored
@ -1709,6 +1709,37 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
|
||||
"label": "포트"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tuic": {
|
||||
"version": {
|
||||
"label": "프로토콜 버전",
|
||||
"placeholder": "TUIC 버전 선택"
|
||||
},
|
||||
"password": {
|
||||
"label": "비밀번호",
|
||||
"placeholder": "비밀번호 입력",
|
||||
"generate_success": "비밀번호가 생성되었습니다"
|
||||
},
|
||||
"congestion_control": {
|
||||
"label": "혼잡 제어",
|
||||
"placeholder": "혼잡 제어 알고리즘 선택"
|
||||
},
|
||||
"udp_relay_mode": {
|
||||
"label": "UDP 릴레이 모드",
|
||||
"placeholder": "UDP 릴레이 모드 선택"
|
||||
},
|
||||
"tls": {
|
||||
"server_name": {
|
||||
"label": "서버 이름 표시 (SNI)",
|
||||
"placeholder": "노드 주소와 인증서가 다를 때 인증서 확인에 사용"
|
||||
},
|
||||
"allow_insecure": "안전하지 않은 연결 허용?",
|
||||
"alpn": {
|
||||
"label": "ALPN",
|
||||
"placeholder": "ALPN 프로토콜 선택",
|
||||
"empty": "사용 가능한 ALPN 프로토콜이 없습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
31
public/assets/admin/locales/zh-CN.js
vendored
31
public/assets/admin/locales/zh-CN.js
vendored
@ -1708,6 +1708,37 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
|
||||
"label": "流控",
|
||||
"placeholder": "选择流控"
|
||||
}
|
||||
},
|
||||
"tuic": {
|
||||
"version": {
|
||||
"label": "协议版本",
|
||||
"placeholder": "选择TUIC版本"
|
||||
},
|
||||
"password": {
|
||||
"label": "密码",
|
||||
"placeholder": "请输入密码",
|
||||
"generate_success": "密码生成成功"
|
||||
},
|
||||
"congestion_control": {
|
||||
"label": "拥塞控制",
|
||||
"placeholder": "选择拥塞控制算法"
|
||||
},
|
||||
"udp_relay_mode": {
|
||||
"label": "UDP中继模式",
|
||||
"placeholder": "选择UDP中继模式"
|
||||
},
|
||||
"tls": {
|
||||
"server_name": {
|
||||
"label": "服务器名称指示(SNI)",
|
||||
"placeholder": "当节点地址与证书不一致时用于证书验证"
|
||||
},
|
||||
"allow_insecure": "允许不安全?",
|
||||
"alpn": {
|
||||
"label": "ALPN",
|
||||
"placeholder": "选择ALPN协议",
|
||||
"empty": "未找到可用的ALPN协议"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"network_settings": {
|
||||
|
Loading…
Reference in New Issue
Block a user