feat: Add TUIC protocol support and fix user filtering/export issues

This commit is contained in:
xboard 2025-02-23 00:13:04 +08:00
parent 4667eb232c
commit b7e87ba18d
12 changed files with 365 additions and 54 deletions

View File

@ -26,6 +26,7 @@ class ClientController extends Controller
'shadowsocks' => '[ss]', 'shadowsocks' => '[ss]',
'vmess' => '[vmess]', 'vmess' => '[vmess]',
'trojan' => '[trojan]', 'trojan' => '[trojan]',
'tuic' => '[tuic]',
]; ];
// 支持hy2 的客户端版本列表 // 支持hy2 的客户端版本列表
@ -46,7 +47,7 @@ class ClientController extends Controller
'flclash' => '0.8.0' '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) public function subscribe(Request $request)

View File

@ -138,6 +138,15 @@ class UniProxyController extends Controller
default => [] 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 => [] default => []
}; };

View File

@ -10,6 +10,7 @@ use App\Jobs\SendEmailJob;
use App\Models\Plan; use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Services\AuthService; use App\Services\AuthService;
use App\Traits\QueryOperators;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -19,6 +20,8 @@ use Illuminate\Support\Facades\Log;
class UserController extends Controller class UserController extends Controller
{ {
use QueryOperators;
public function resetSecret(Request $request) public function resetSecret(Request $request)
{ {
$user = User::find($request->input('id')); $user = User::find($request->input('id'));
@ -75,13 +78,29 @@ class UserController extends Controller
*/ */
private function buildFilterQuery(Builder $query, string $field, mixed $value): void 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)) { if (is_array($value)) {
$query->whereIn($field === 'group_ids' ? 'group_id' : $field, $value); $query->whereIn($field === 'group_ids' ? 'group_id' : $field, $value);
return; return;
} }
// Handle operator-based filtering // 处理基于运算符的过滤
if (!is_string($value) || !str_contains($value, ':')) { if (!is_string($value) || !str_contains($value, ':')) {
$query->where($field, 'like', "%{$value}%"); $query->where($field, 'like', "%{$value}%");
return; return;
@ -89,36 +108,20 @@ class UserController extends Controller
[$operator, $filterValue] = explode(':', $value, 2); [$operator, $filterValue] = explode(':', $value, 2);
// Convert numeric strings to appropriate type // 转换数字字符串为适当的类型
if (is_numeric($filterValue)) { if (is_numeric($filterValue)) {
$filterValue = strpos($filterValue, '.') !== false $filterValue = strpos($filterValue, '.') !== false
? (float) $filterValue ? (float) $filterValue
: (int) $filterValue; : (int) $filterValue;
} }
// Handle computed fields // 处理计算字段
$queryField = match ($field) { $queryField = match ($field) {
'total_used' => DB::raw('(u + d)'), 'total_used' => DB::raw('(u + d)'),
default => $field default => $field
}; };
// Apply operator $this->applyQueryCondition($query, $queryField, $operator, $filterValue);
$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
});
} }
/** /**
@ -250,33 +253,88 @@ class UserController extends Controller
return $this->success(true); return $this->success(true);
} }
/**
* 导出用户数据为CSV格式
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\StreamedResponse
*/
public function dumpCSV(Request $request) public function dumpCSV(Request $request)
{ {
ini_set('memory_limit', -1); ini_set('memory_limit', '-1');
$userModel = User::orderBy('id', 'asc'); gc_enable(); // 启用垃圾回收
$this->applyFiltersAndSorts($request, $userModel);
$res = $userModel->get(); // 优化查询使用with预加载plan关系避免N+1问题
$plan = Plan::get(); $query = User::with('plan:id,name')
for ($i = 0; $i < count($res); $i++) { ->orderBy('id', 'asc')
for ($k = 0; $k < count($plan); $k++) { ->select([
if ($plan[$k]['id'] == $res[$i]['plan_id']) { 'email',
$res[$i]['plan_name'] = $plan[$k]['name']; '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();
$data = "邮箱,余额,推广佣金,总流量,剩余流量,套餐到期时间,订阅计划,订阅地址\r\n"; });
foreach ($res as $user) {
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']); fclose($output);
$balance = $user['balance'] / 100; }, $filename, [
$commissionBalance = $user['commission_balance'] / 100; 'Content-Type' => 'text/csv; charset=UTF-8',
$transferEnable = $user['transfer_enable'] ? $user['transfer_enable'] / 1073741824 : 0; 'Content-Disposition' => 'attachment; filename="' . $filename . '"'
$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;
} }
public function generate(UserGenerate $request) public function generate(UserGenerate $request)

View File

@ -131,11 +131,17 @@ class Server extends Model
] ]
], ],
self::TYPE_TUIC => [ self::TYPE_TUIC => [
'version' => ['type' => 'integer', 'default' => 5],
'congestion_control' => ['type' => 'string', 'default' => 'cubic'], 'congestion_control' => ['type' => 'string', 'default' => 'cubic'],
'alpn' => ['type' => 'array', 'default' => ['h3']], 'alpn' => ['type' => 'array', 'default' => ['h3']],
'udp_relay_mode' => ['type' => 'string', 'default' => 'native'], 'udp_relay_mode' => ['type' => 'string', 'default' => 'native'],
'allow_insecure' => ['type' => 'boolean', 'default' => false], 'tls' => [
'tls_settings' => ['type' => 'array', 'default' => null] 'type' => 'object',
'fields' => [
'server_name' => ['type' => 'string', 'default' => null],
'allow_insecure' => ['type' => 'boolean', 'default' => false]
]
]
] ]
]; ];

View File

@ -48,7 +48,7 @@ class ClashMeta implements ProtocolInterface
$config = Yaml::parse($template); $config = Yaml::parse($template);
$proxy = []; $proxy = [];
$proxies = []; $proxies = [];
foreach ($servers as $item) { foreach ($servers as $item) {
$protocol_settings = $item['protocol_settings']; $protocol_settings = $item['protocol_settings'];
if ($item['type'] === 'shadowsocks') { if ($item['type'] === 'shadowsocks') {
@ -74,6 +74,10 @@ class ClashMeta implements ProtocolInterface
array_push($proxy, self::buildHysteria($user['uuid'], $item, $user)); array_push($proxy, self::buildHysteria($user['uuid'], $item, $user));
array_push($proxies, $item['name']); 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); $config['proxies'] = array_merge($config['proxies'] ? $config['proxies'] : [], $proxy);
@ -332,6 +336,39 @@ class ClashMeta implements ProtocolInterface
return $array; 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) private function isMatch($exp, $str)
{ {
return @preg_match($exp, $str); return @preg_match($exp, $str);

View File

@ -80,6 +80,10 @@ class SingBox implements ProtocolInterface
$hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item); $hysteriaConfig = $this->buildHysteria($this->user['uuid'], $item);
$proxies[] = $hysteriaConfig; $proxies[] = $hysteriaConfig;
} }
if ($item['type'] === 'tuic') {
$tuicConfig = $this->buildTuic($this->user['uuid'], $item);
$proxies[] = $tuicConfig;
}
} }
foreach ($outbounds as &$outbound) { foreach ($outbounds as &$outbound) {
if (in_array($outbound['type'], ['urltest', 'selector'])) { if (in_array($outbound['type'], ['urltest', 'selector'])) {
@ -324,4 +328,37 @@ class SingBox implements ProtocolInterface
$versionConfig $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;
}
} }

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

View File

@ -27,6 +27,10 @@ class CacheKey
'MULTI_SERVER_VLESS_ONLINE_USER' => 'vless节点多服务器在线用户', 'MULTI_SERVER_VLESS_ONLINE_USER' => 'vless节点多服务器在线用户',
'SERVER_VLESS_LAST_CHECK_AT' => 'vless节点最后检查时间', 'SERVER_VLESS_LAST_CHECK_AT' => 'vless节点最后检查时间',
'SERVER_VLESS_LAST_PUSH_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' => '临时令牌', 'TEMP_TOKEN' => '临时令牌',
'LAST_SEND_EMAIL_REMIND_TRAFFIC' => '最后发送流量邮件提醒', 'LAST_SEND_EMAIL_REMIND_TRAFFIC' => '最后发送流量邮件提醒',
'SCHEDULE_LAST_CHECK_AT' => '计划任务最后检查时间', 'SCHEDULE_LAST_CHECK_AT' => '计划任务最后检查时间',

File diff suppressed because one or more lines are too long

View File

@ -1741,6 +1741,37 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"label": "Flow Control", "label": "Flow Control",
"placeholder": "Select 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": { "network_settings": {

View File

@ -1709,6 +1709,37 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"label": "포트" "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 프로토콜이 없습니다"
}
}
} }
} }
}, },

View File

@ -1708,6 +1708,37 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"label": "流控", "label": "流控",
"placeholder": "选择流控" "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": { "network_settings": {