Compare commits

...

15 Commits

Author SHA1 Message Date
xboard
048530a893 Remove duplicate doc files 2026-03-30 18:04:41 +08:00
xboard
7ed5fc8fd3 fix: remove 2026_03_28_050000_lowercase_existing_emails.php 2026-03-30 17:59:39 +08:00
xboard
5f1afe4bdc feat: add Vless Encryption support 2026-03-30 17:03:37 +08:00
Xboard
0cd20d12dd Merge pull request #755 from socksprox/feat/server-id-stat-user
feat: Track user traffic per node (server_id)
2026-03-30 13:55:11 +08:00
Xboard
b4a94d1605 Merge pull request #689 from socksprox/fix-user-generation-multiple-prefix
Fix user generation with email_prefix to support multiple users
2026-03-30 13:32:46 +08:00
Xboard
7879a9ef85 Merge pull request #786 from lithromantic/master
Add sha256salt hashing option in password verification
2026-03-30 13:05:39 +08:00
lithromantic
6cac241144 Merge branch 'cedar2025:master' into master 2026-03-29 00:00:34 +01:00
lithromantic
f6abc362fd Add sha256salt hashing option in password verification 2026-01-18 00:04:00 +01:00
socksprox
c327fecb49 do not return strings, but int 2025-11-29 17:05:07 +01:00
socksprox
0446f88e9e again: update api combining times 2025-11-29 17:05:07 +01:00
socksprox
a01151130e Revert "Combine data with node_id in api output, so its all still "one day", and fits vanilla xboard behaviour"
This reverts commit de39230cbe111bbf793f11bcf5046ef717c67f87.

The api change caused issues
2025-11-29 17:05:07 +01:00
socksprox
9ca8da045c Combine data with node_id in api output, so its all still "one day", and fits vanilla xboard behaviour 2025-11-29 14:07:10 +01:00
socksprox
1ebf86b510 fix: do not merge traffic from different nodes 2025-11-29 13:47:21 +01:00
socksprox
9e35d16fa6 User traffic can now be viewed by node 2025-11-29 13:47:15 +01:00
socksprox
051813d39d Make that user batch generation works again 2025-09-15 15:43:43 +02:00
15 changed files with 267 additions and 50 deletions

View File

@@ -14,13 +14,29 @@ class StatController extends Controller
public function getTrafficLog(Request $request)
{
$startDate = now()->startOfMonth()->timestamp;
// Aggregate per-node data into per-day entries for backward compatibility
$records = StatUser::query()
->select([
'user_id',
'server_rate',
'record_at',
'record_type',
DB::raw('SUM(u) as u'),
DB::raw('SUM(d) as d'),
])
->where('user_id', $request->user()->id)
->where('record_at', '>=', $startDate)
->groupBy(['user_id', 'server_rate', 'record_at', 'record_type'])
->orderBy('record_at', 'DESC')
->get();
->get()
->map(function ($item) {
$item->u = (int) $item->u;
$item->d = (int) $item->d;
return $item;
});
$data = TrafficLogResource::collection(collect($records));
$data = TrafficLogResource::collection($records);
return $this->success($data);
}
}

View File

@@ -13,6 +13,7 @@ use App\Models\Ticket;
use App\Models\User;
use App\Services\StatisticalService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StatController extends Controller
{
@@ -234,14 +235,38 @@ class StatController extends Controller
]);
$pageSize = $request->input('pageSize', 10);
$records = StatUser::orderBy('record_at', 'DESC')
$page = $request->input('page', 1);
// Aggregate per-node data into per-day entries for backward compatibility
$query = StatUser::query()
->select([
'user_id',
'server_rate',
'record_at',
'record_type',
DB::raw('SUM(u) as u'),
DB::raw('SUM(d) as d'),
DB::raw('MAX(created_at) as created_at'),
DB::raw('MAX(updated_at) as updated_at'),
])
->where('user_id', $request->input('user_id'))
->paginate($pageSize);
->groupBy(['user_id', 'server_rate', 'record_at', 'record_type'])
->orderBy('record_at', 'DESC');
// Manual pagination for grouped query
$total = (clone $query)->get()->count();
$data = $query->skip(($page - 1) * $pageSize)->take($pageSize)->get()
->map(function ($item) {
$item->u = (int) $item->u;
$item->d = (int) $item->d;
$item->created_at = (int) $item->created_at;
$item->updated_at = (int) $item->updated_at;
return $item;
});
$data = $records->items();
return [
'data' => $data,
'total' => $records->total(),
'total' => $total,
];
}

View File

@@ -363,6 +363,12 @@ class UserController extends Controller
public function generate(UserGenerate $request)
{
if ($request->input('email_prefix')) {
// If generate_count is specified with email_prefix, generate multiple users with incremented emails
if ($request->input('generate_count')) {
return $this->multiGenerateWithPrefix($request);
}
// Single user generation with email_prefix
$email = $request->input('email_prefix') . '@' . $request->input('email_suffix');
if (User::byEmail($email)->exists()) {
@@ -459,6 +465,87 @@ class UserController extends Controller
]);
}
private function multiGenerateWithPrefix(Request $request)
{
$userService = app(UserService::class);
$usersData = [];
$emailPrefix = $request->input('email_prefix');
$emailSuffix = $request->input('email_suffix');
$generateCount = $request->input('generate_count');
// Check if any of the emails with prefix already exist
for ($i = 1; $i <= $generateCount; $i++) {
$email = $emailPrefix . '_' . $i . '@' . $emailSuffix;
if (User::where('email', $email)->exists()) {
return $this->fail([400201, '邮箱 ' . $email . ' 已存在于系统中']);
}
}
// Generate user data for batch creation
for ($i = 1; $i <= $generateCount; $i++) {
$email = $emailPrefix . '_' . $i . '@' . $emailSuffix;
$usersData[] = [
'email' => $email,
'password' => $request->input('password') ?? $email,
'plan_id' => $request->input('plan_id'),
'expired_at' => $request->input('expired_at'),
];
}
try {
DB::beginTransaction();
$users = [];
foreach ($usersData as $userData) {
$user = $userService->createUser($userData);
$user->save();
$users[] = $user;
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
return $this->fail([500, '生成失败']);
}
// 判断是否导出 CSV
if ($request->input('download_csv')) {
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="users.csv"',
];
$callback = function () use ($users, $request) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['账号', '密码', '过期时间', 'UUID', '创建时间', '订阅地址']);
foreach ($users as $user) {
$user = $user->refresh();
$expireDate = $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']);
$createDate = date('Y-m-d H:i:s', $user['created_at']);
$password = $request->input('password') ?? $user['email'];
$subscribeUrl = Helper::getSubscribeUrl($user['token']);
fputcsv($handle, [$user['email'], $password, $expireDate, $user['uuid'], $createDate, $subscribeUrl]);
}
fclose($handle);
};
return response()->streamDownload($callback, 'users.csv', $headers);
}
// 默认返回 JSON
$data = collect($users)->map(function ($user) use ($request) {
return [
'email' => $user['email'],
'password' => $request->input('password') ?? $user['email'],
'expired_at' => $user['expired_at'] === NULL ? '长期有效' : date('Y-m-d H:i:s', $user['expired_at']),
'uuid' => $user['uuid'],
'created_at' => date('Y-m-d H:i:s', $user['created_at']),
'subscribe_url' => Helper::getSubscribeUrl($user['token']),
];
});
return response()->json([
'code' => 0,
'message' => '批量生成成功',
'data' => $data,
]);
}
public function sendMail(UserSendMail $request)
{
ini_set('memory_limit', '-1');

View File

@@ -71,6 +71,10 @@ class ServerSave extends FormRequest
'network' => 'required|string',
'network_settings' => 'nullable|array',
'flow' => 'nullable|string',
'encryption' => 'nullable|array',
'encryption.enabled' => 'nullable|boolean',
'encryption.encryption' => 'nullable|string',
'encryption.decryption' => 'nullable|string',
'tls_settings.server_name' => 'nullable|string',
'tls_settings.allow_insecure' => 'nullable|boolean',
'reality_settings.allow_insecure' => 'nullable|boolean',

View File

@@ -78,6 +78,7 @@ class StatUserJob implements ShouldQueue
DB::transaction(function () use ($uid, $v, $recordAt) {
$existingRecord = StatUser::where([
'user_id' => $uid,
'server_id' => $this->server['id'],
'server_rate' => $this->server['rate'],
'record_at' => $recordAt,
'record_type' => $this->recordType,
@@ -92,6 +93,7 @@ class StatUserJob implements ShouldQueue
} else {
StatUser::create([
'user_id' => $uid,
'server_id' => $this->server['id'],
'server_rate' => $this->server['rate'],
'record_at' => $recordAt,
'record_type' => $this->recordType,
@@ -109,6 +111,7 @@ class StatUserJob implements ShouldQueue
StatUser::upsert(
[
'user_id' => $uid,
'server_id' => $this->server['id'],
'server_rate' => $this->server['rate'],
'record_at' => $recordAt,
'record_type' => $this->recordType,
@@ -117,7 +120,7 @@ class StatUserJob implements ShouldQueue
'created_at' => time(),
'updated_at' => time(),
],
['user_id', 'server_rate', 'record_at', 'record_type'],
['user_id', 'server_id', 'server_rate', 'record_at', 'record_type'],
[
'u' => DB::raw("u + VALUES(u)"),
'd' => DB::raw("d + VALUES(d)"),
@@ -136,9 +139,9 @@ class StatUserJob implements ShouldQueue
$u = intval($v[0] * $this->server['rate']);
$d = intval($v[1] * $this->server['rate']);
$sql = "INSERT INTO {$table} (user_id, server_rate, record_at, record_type, u, d, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, server_rate, record_at)
$sql = "INSERT INTO {$table} (user_id, server_id, server_rate, record_at, record_type, u, d, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, server_id, server_rate, record_at)
DO UPDATE SET
u = {$table}.u + EXCLUDED.u,
d = {$table}.d + EXCLUDED.d,
@@ -146,6 +149,7 @@ class StatUserJob implements ShouldQueue
DB::statement($sql, [
$uid,
$this->server['id'],
$this->server['rate'],
$recordAt,
$this->recordType,

View File

@@ -203,6 +203,15 @@ class Server extends Model
'tls' => ['type' => 'integer', 'default' => 0],
'tls_settings' => ['type' => 'array', 'default' => null],
'flow' => ['type' => 'string', 'default' => null],
'encryption' => [
'type' => 'object',
'default' => null,
'fields' => [
'enabled' => ['type' => 'boolean', 'default' => false],
'encryption' => ['type' => 'string', 'default' => null], // 客户端公钥
'decryption' => ['type' => 'string', 'default' => null], // 服务端私钥
]
],
'network' => ['type' => 'string', 'default' => null],
'network_settings' => ['type' => 'array', 'default' => null],
...self::REALITY_CONFIGURATION,

View File

@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model;
*
* @property int $id
* @property int $user_id 用户ID
* @property int|null $server_id 节点ID (nullable for legacy data)
* @property int $u 上行流量
* @property int $d 下行流量
* @property int $record_at 记录时间
@@ -25,4 +26,12 @@ class StatUser extends Model
'created_at' => 'timestamp',
'updated_at' => 'timestamp'
];
/**
* Get the server that this traffic stat belongs to
*/
public function server()
{
return $this->belongsTo(Server::class, 'server_id');
}
}

View File

@@ -332,6 +332,10 @@ class ClashMeta extends AbstractProtocol
'cipher' => 'auto',
'udp' => true,
'flow' => data_get($protocol_settings, 'flow'),
'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) {
true => data_get($protocol_settings, 'encryption.encryption', 'none'),
default => 'none'
},
'tls' => false
];

View File

@@ -151,7 +151,10 @@ class General extends AbstractProtocol
$config = [
'mode' => 'multi', //grpc传输模式
'security' => '', //传输层安全 tls/reality
'encryption' => 'none', //加密方式
'encryption' => match (data_get($protocol_settings, 'encryption.enabled')) {
true => data_get($protocol_settings, 'encryption.encryption', 'none'),
default => 'none'
},
'type' => data_get($server, 'protocol_settings.network'), //传输协议
'flow' => data_get($protocol_settings, 'flow'),
];

View File

@@ -183,6 +183,7 @@ class ServerService
...$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'],

View File

@@ -86,6 +86,7 @@ class Helper
case 'md5': return md5($password) === $hash;
case 'sha256': return hash('sha256', $password) === $hash;
case 'md5salt': return md5($password . $salt) === $hash;
case 'sha256salt': return hash('sha256', $password . $salt) === $hash;
default: return password_verify($password, $hash);
}
}

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v2_stat_user', function (Blueprint $table) {
// Add server_id column as nullable for backward compatibility
$table->integer('server_id')->nullable()->after('user_id')->comment('节点ID (nullable for legacy data)');
// Add index for per-node queries
if (config('database.default') !== 'sqlite') {
$table->index(['user_id', 'server_id', 'record_at'], 'user_server_record_idx');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v2_stat_user', function (Blueprint $table) {
if (config('database.default') !== 'sqlite') {
$table->dropIndex('user_server_record_idx');
}
$table->dropColumn('server_id');
});
}
};

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Updates the unique constraint on v2_stat_user to include server_id,
* allowing per-node user traffic tracking.
*/
public function up(): void
{
if (config('database.default') === 'sqlite') {
// SQLite uses explicit WHERE queries in code, no constraint changes needed
return;
}
Schema::table('v2_stat_user', function (Blueprint $table) {
// Drop the old unique constraint that doesn't include server_id
$table->dropUnique('server_rate_user_id_record_at');
// Add new unique constraint including server_id
// Note: NULL server_id values (legacy) are treated as distinct in MySQL
$table->unique(
['user_id', 'server_id', 'server_rate', 'record_at', 'record_type'],
'stat_user_unique_idx'
);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (config('database.default') === 'sqlite') {
return;
}
Schema::table('v2_stat_user', function (Blueprint $table) {
// Drop new constraint
$table->dropUnique('stat_user_unique_idx');
// Restore original constraint
$table->unique(
['server_rate', 'user_id', 'record_at'],
'server_rate_user_id_record_at'
);
});
}
};

View File

@@ -1,38 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 统计需要转换的记录数
$count = DB::table('v2_user')
->whereNotNull('email')
->whereRaw('email != LOWER(email)')
->count();
if ($count > 0) {
Log::info("Converting {$count} email(s) to lowercase");
DB::table('v2_user')
->whereNotNull('email')
->whereRaw('email != LOWER(email)')
->update(['email' => DB::raw('LOWER(email)')]);
Log::info("Email lowercase conversion completed");
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 无法恢复原始大小写
}
};