Files
Xboard/app/Http/Controllers/V2/Admin/UserController.php
Xboard b779bd4fd5 Merge pull request #789 from socksprox/feat/or-filter-logic
feat: Add OR logic support to user fetch API filters
2026-03-21 07:49:03 +08:00

596 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UserGenerate;
use App\Http\Requests\Admin\UserSendMail;
use App\Http\Requests\Admin\UserUpdate;
use App\Jobs\SendEmailJob;
use App\Models\Plan;
use App\Models\User;
use App\Services\AuthService;
use App\Services\NodeSyncService;
use App\Services\UserService;
use App\Traits\QueryOperators;
use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class UserController extends Controller
{
use QueryOperators;
public function resetSecret(Request $request)
{
$user = User::find($request->input('id'));
if (!$user)
return $this->fail([400202, '用户不存在']);
$user->token = Helper::guid();
$user->uuid = Helper::guid(true);
return $this->success($user->save());
}
// Apply filters and sorts to the query builder.
private function applyFiltersAndSorts(Request $request, Builder|QueryBuilder $builder): void
{
$this->applyFilters($request, $builder);
$this->applySorting($request, $builder);
}
// Apply filters to the query builder.
private function applyFilters(Request $request, Builder|QueryBuilder $builder): void
{
if (!$request->has('filter')) {
return;
}
collect($request->input('filter'))->each(function ($filter) use ($builder) {
$field = $filter['id'];
$value = $filter['value'];
$logic = strtolower($filter['logic'] ?? 'and');
if ($logic === 'or') {
$builder->orWhere(function ($query) use ($field, $value) {
$this->buildFilterQuery($query, $field, $value);
});
} else {
$builder->where(function ($query) use ($field, $value) {
$this->buildFilterQuery($query, $field, $value);
});
}
});
}
// Build one filter query condition.
private function buildFilterQuery(Builder|QueryBuilder $query, string $field, mixed $value): void
{
// 处理关联查询
if (str_contains($field, '.')) {
if (!method_exists($query, 'whereHas')) {
return;
}
[$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;
}
// 处理基于运算符的过滤
if (!is_string($value) || !str_contains($value, ':')) {
$query->where($field, 'like', "%{$value}%");
return;
}
[$operator, $filterValue] = explode(':', $value, 2);
// 转换数字字符串为适当的类型
if (is_numeric($filterValue)) {
$filterValue = strpos($filterValue, '.') !== false
? (float) $filterValue
: (int) $filterValue;
}
// 处理计算字段
$queryField = match ($field) {
'total_used' => DB::raw('(u + d)'),
default => $field
};
$this->applyQueryCondition($query, $queryField, $operator, $filterValue);
}
// Apply sorting rules to the query builder.
private function applySorting(Request $request, Builder|QueryBuilder $builder): void
{
if (!$request->has('sort')) {
return;
}
collect($request->input('sort'))->each(function ($sort) use ($builder) {
$field = $sort['id'];
$direction = $sort['desc'] ? 'DESC' : 'ASC';
$builder->orderBy($field, $direction);
});
}
// Resolve bulk operation scope and normalize user_ids.
private function resolveScope(Request $request): array
{
$scope = $request->input('scope');
$userIds = $request->input('user_ids');
$hasSelection = is_array($userIds) && count(array_filter($userIds, static fn($v) => is_numeric($v))) > 0;
$hasFilter = $request->has('filter') && !empty($request->input('filter'));
if (!in_array($scope, ['selected', 'filtered', 'all'], true)) {
if ($hasSelection) {
$scope = 'selected';
} elseif ($hasFilter) {
$scope = 'filtered';
} else {
$scope = 'all';
}
}
$normalizedIds = [];
if ($scope === 'selected') {
$normalizedIds = is_array($userIds) ? $userIds : [];
$normalizedIds = array_values(array_unique(array_map(static function ($v) {
return is_numeric($v) ? (int) $v : null;
}, $normalizedIds)));
$normalizedIds = array_values(array_filter($normalizedIds, static fn($v) => is_int($v)));
}
return [
'scope' => $scope,
'user_ids' => $normalizedIds,
];
}
// Fetch paginated user list (filters + sorting).
public function fetch(Request $request)
{
$current = $request->input('current', 1);
$pageSize = $request->input('pageSize', 10);
$userModel = User::query()
->with(['plan:id,name', 'invite_user:id,email', 'group:id,name'])
->select((new User())->getTable() . '.*')
->selectRaw('(u + d) as total_used');
$this->applyFiltersAndSorts($request, $userModel);
$users = $userModel->orderBy('id', 'desc')
->paginate($pageSize, ['*'], 'page', $current);
$users->getCollection()->transform(function ($user): array {
return self::transformUserData($user);
});
return $this->paginate($users);
}
// Transform user fields for API response.
public static function transformUserData(User $user): array
{
$user = $user->toArray();
$user['balance'] = $user['balance'] / 100;
$user['commission_balance'] = $user['commission_balance'] / 100;
$user['subscribe_url'] = Helper::getSubscribeUrl($user['token']);
return $user;
}
public function getUserInfoById(Request $request)
{
$request->validate([
'id' => 'required|numeric'
], [
'id.required' => '用户ID不能为空'
]);
$user = User::find($request->input('id'))->load('invite_user');
return $this->success($user);
}
public function update(UserUpdate $request)
{
$params = $request->validated();
$user = User::find($request->input('id'));
if (!$user) {
return $this->fail([400202, '用户不存在']);
}
if (isset($params['email'])) {
if (User::where('email', $params['email'])->first() && $user->email !== $params['email']) {
return $this->fail([400201, '邮箱已被使用']);
}
}
// 处理密码
if (isset($params['password'])) {
$params['password'] = password_hash($params['password'], PASSWORD_DEFAULT);
$params['password_algo'] = NULL;
} else {
unset($params['password']);
}
// 处理订阅计划
if (isset($params['plan_id'])) {
$plan = Plan::find($params['plan_id']);
if (!$plan) {
return $this->fail([400202, '订阅计划不存在']);
}
$params['group_id'] = $plan->group_id;
}
// 处理邀请用户
if ($request->input('invite_user_email') && $inviteUser = User::where('email', $request->input('invite_user_email'))->first()) {
$params['invite_user_id'] = $inviteUser->id;
} else {
$params['invite_user_id'] = null;
}
if (isset($params['banned']) && (int) $params['banned'] === 1) {
$authService = new AuthService($user);
$authService->removeAllSessions();
}
if (isset($params['balance'])) {
$params['balance'] = $params['balance'] * 100;
}
if (isset($params['commission_balance'])) {
$params['commission_balance'] = $params['commission_balance'] * 100;
}
try {
$user->update($params);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
// Export users to CSV.
public function dumpCSV(Request $request)
{
ini_set('memory_limit', '-1');
gc_enable(); // 启用垃圾回收
$scopeInfo = $this->resolveScope($request);
$scope = $scopeInfo['scope'];
$userIds = $scopeInfo['user_ids'];
if ($scope === 'selected') {
if (empty($userIds)) {
return $this->fail([422, 'user_ids不能为空']);
}
}
// 优化查询使用with预加载plan关系避免N+1问题
$query = User::query()
->with('plan:id,name')
->orderBy('id', 'asc')
->select([
'email',
'balance',
'commission_balance',
'transfer_enable',
'u',
'd',
'expired_at',
'token',
'plan_id'
]);
if ($scope === 'selected') {
$query->whereIn('id', $userIds);
} elseif ($scope === 'filtered') {
$this->applyFiltersAndSorts($request, $query);
} // all: ignore filter/sort
$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)
{
if ($request->input('email_prefix')) {
$email = $request->input('email_prefix') . '@' . $request->input('email_suffix');
if (User::where('email', $email)->exists()) {
return $this->fail([400201, '邮箱已存在于系统中']);
}
$userService = app(UserService::class);
$user = $userService->createUser([
'email' => $email,
'password' => $request->input('password') ?? $email,
'plan_id' => $request->input('plan_id'),
'expired_at' => $request->input('expired_at'),
]);
if (!$user->save()) {
return $this->fail([500, '生成失败']);
}
return $this->success(true);
}
if ($request->input('generate_count')) {
return $this->multiGenerate($request);
}
}
private function multiGenerate(Request $request)
{
$userService = app(UserService::class);
$usersData = [];
for ($i = 0; $i < $request->input('generate_count'); $i++) {
$email = Helper::randomChar(6) . '@' . $request->input('email_suffix');
$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');
$scopeInfo = $this->resolveScope($request);
$scope = $scopeInfo['scope'];
$userIds = $scopeInfo['user_ids'];
if ($scope === 'selected') {
if (empty($userIds)) {
return $this->fail([422, 'user_ids不能为空']);
}
}
$sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC';
$sort = $request->input('sort') ? $request->input('sort') : 'created_at';
$builder = User::query()
->with('plan:id,name')
->orderBy('id', 'desc');
if ($scope === 'filtered') {
// filtered: apply filters/sort
$builder->orderBy($sort, $sortType);
$this->applyFiltersAndSorts($request, $builder);
} elseif ($scope === 'selected') {
$builder->whereIn('id', $userIds);
} // all: ignore filter/sort
$subject = $request->input('subject');
$content = $request->input('content');
$appName = admin_setting('app_name', 'XBoard');
$appUrl = admin_setting('app_url');
$chunkSize = 1000;
$builder->chunk($chunkSize, function ($users) use ($subject, $content, $appName, $appUrl) {
foreach ($users as $user) {
$vars = [
'app.name' => $appName,
'app.url' => $appUrl,
'now' => now()->format('Y-m-d H:i:s'),
'user.id' => $user->id,
'user.email' => $user->email,
'user.uuid' => $user->uuid,
'user.plan_name' => $user->plan?->name ?? '',
'user.expired_at' => $user->expired_at ? date('Y-m-d H:i:s', $user->expired_at) : '',
'user.transfer_enable' => (int) ($user->transfer_enable ?? 0),
'user.transfer_used' => (int) (($user->u ?? 0) + ($user->d ?? 0)),
'user.transfer_left' => (int) (($user->transfer_enable ?? 0) - (($user->u ?? 0) + ($user->d ?? 0))),
];
$templateValue = [
'name' => $appName,
'url' => $appUrl,
'content' => $content,
'vars' => $vars,
'content_mode' => 'text',
];
dispatch(new SendEmailJob([
'email' => $user->email,
'subject' => $subject,
'template_name' => 'notify',
'template_value' => $templateValue
], 'send_email_mass'));
}
});
return $this->success(true);
}
public function ban(Request $request)
{
$scopeInfo = $this->resolveScope($request);
$scope = $scopeInfo['scope'];
$userIds = $scopeInfo['user_ids'];
if ($scope === 'selected') {
if (empty($userIds)) {
return $this->fail([422, 'user_ids不能为空']);
}
}
$sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC';
$sort = $request->input('sort') ? $request->input('sort') : 'created_at';
$builder = User::query()->orderBy('id', 'desc');
if ($scope === 'filtered') {
// filtered: keep current semantics
$builder->orderBy($sort, $sortType);
$this->applyFiltersAndSorts($request, $builder);
} elseif ($scope === 'selected') {
$builder->whereIn('id', $userIds);
} // all: ignore filter/sort
try {
$builder->update([
'banned' => 1
]);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([500, '处理失败']);
}
// Full refresh not implemented.
return $this->success(true);
}
// Delete user and related data.
public function destroy(Request $request)
{
$request->validate([
'id' => 'required|exists:App\Models\User,id'
], [
'id.required' => '用户ID不能为空',
'id.exists' => '用户不存在'
]);
$user = User::find($request->input('id'));
try {
DB::beginTransaction();
$user->orders()->delete();
$user->codes()->delete();
$user->stat()->delete();
$user->tickets()->delete();
$user->delete();
DB::commit();
return $this->success(true);
} catch (\Exception $e) {
DB::rollBack();
Log::error($e);
return $this->fail([500, '删除失败']);
}
}
}