diff --git a/app/Http/Controllers/V2/Admin/UserController.php b/app/Http/Controllers/V2/Admin/UserController.php index 29233ca..1e9bcf6 100644 --- a/app/Http/Controllers/V2/Admin/UserController.php +++ b/app/Http/Controllers/V2/Admin/UserController.php @@ -15,6 +15,7 @@ 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; @@ -35,27 +36,15 @@ class UserController extends Controller return $this->success($user->save()); } - /** - * Apply filters and sorts to the query builder - * - * @param Request $request - * @param Builder $builder - * @return void - */ - private function applyFiltersAndSorts(Request $request, Builder $builder): void + // 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 - * - * @param Request $request - * @param Builder $builder - * @return void - */ - private function applyFilters(Request $request, Builder $builder): void + // Apply filters to the query builder. + private function applyFilters(Request $request, Builder|QueryBuilder $builder): void { if (!$request->has('filter')) { return; @@ -71,18 +60,14 @@ class UserController extends Controller }); } - /** - * Build the filter query based on field and value - * - * @param Builder $query - * @param string $field - * @param mixed $value - * @return void - */ - private function buildFilterQuery(Builder $query, string $field, mixed $value): void + // 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)) { @@ -127,14 +112,8 @@ class UserController extends Controller $this->applyQueryCondition($query, $queryField, $operator, $filterValue); } - /** - * Apply sorting to the query builder - * - * @param Request $request - * @param Builder $builder - * @return void - */ - private function applySorting(Request $request, Builder $builder): void + // Apply sorting rules to the query builder. + private function applySorting(Request $request, Builder|QueryBuilder $builder): void { if (!$request->has('sort')) { return; @@ -147,19 +126,50 @@ class UserController extends Controller }); } - /** - * Fetch paginated user list with filters and sorting - * - * @param Request $request - * @return \Illuminate\Http\Response - */ + // 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::with(['plan:id,name', 'invite_user:id,email', 'group:id,name']) - ->select(DB::raw('*, (u+d) as total_used')); + $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); @@ -173,12 +183,7 @@ class UserController extends Controller return $this->paginate($users); } - /** - * Transform user data for response - * - * @param User $user - * @return array - */ + // Transform user fields for API response. public static function transformUserData(User $user): array { $user = $user->toArray(); @@ -254,19 +259,25 @@ class UserController extends Controller return $this->success(true); } - /** - * 导出用户数据为CSV格式 - * - * @param Request $request - * @return \Symfony\Component\HttpFoundation\StreamedResponse - */ + // 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::with('plan:id,name') + $query = User::query() + ->with('plan:id,name') ->orderBy('id', 'asc') ->select([ 'email', @@ -280,7 +291,11 @@ class UserController extends Controller 'plan_id' ]); - $this->applyFiltersAndSorts($request, $query); + 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'; @@ -440,23 +455,62 @@ class UserController extends Controller 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::orderBy($sort, $sortType); - $this->applyFiltersAndSorts($request, $builder); + + $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'); - $templateValue = [ - 'name' => admin_setting('app_name', 'XBoard'), - 'url' => admin_setting('app_url'), - 'content' => $content - ]; + $appName = admin_setting('app_name', 'XBoard'); + $appUrl = admin_setting('app_url'); $chunkSize = 1000; - $builder->chunk($chunkSize, function ($users) use ($subject, $templateValue, &$totalProcessed) { + $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, @@ -471,10 +525,29 @@ class UserController extends Controller 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::orderBy($sort, $sortType); - $this->applyFilters($request, $builder); + + $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 @@ -483,16 +556,11 @@ class UserController extends Controller Log::error($e); return $this->fail([500, '处理失败']); } - NodeSyncService::notifyUsersUpdated(); + // Full refresh not implemented. return $this->success(true); } - /** - * 删除用户及其关联数据 - * - * @param Request $request - * @return JsonResponse - */ + // Delete user and related data. public function destroy(Request $request) { $request->validate([ diff --git a/app/Services/MailService.php b/app/Services/MailService.php index a7a9f55..7630111 100644 --- a/app/Services/MailService.php +++ b/app/Services/MailService.php @@ -13,6 +13,33 @@ use Illuminate\Support\Facades\Mail; class MailService { + // Render {{key}} / {{key|default}} placeholders. + private static function renderPlaceholders(string $template, array $vars): string + { + if ($template === '' || empty($vars)) { + return $template; + } + + return (string) preg_replace_callback('/\{\{\s*([a-zA-Z0-9_.-]+)(?:\|([^}]*))?\s*\}\}/', function ($m) use ($vars) { + $key = $m[1] ?? ''; + $default = array_key_exists(2, $m) ? trim((string) $m[2]) : null; + + if (!array_key_exists($key, $vars) || $vars[$key] === null || $vars[$key] === '') { + return $default !== null ? $default : $m[0]; + } + + $value = $vars[$key]; + if (is_bool($value)) { + return $value ? '1' : '0'; + } + if (is_scalar($value)) { + return (string) $value; + } + + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''; + }, $template); + } + /** * 获取需要发送提醒的用户总数 */ @@ -222,6 +249,25 @@ class MailService } $email = $params['email']; $subject = $params['subject']; + + $templateValue = $params['template_value'] ?? []; + $vars = is_array($templateValue) ? ($templateValue['vars'] ?? []) : []; + $contentMode = is_array($templateValue) ? ($templateValue['content_mode'] ?? null) : null; + + if (is_array($vars) && !empty($vars)) { + $subject = self::renderPlaceholders((string) $subject, $vars); + + if (is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) { + $templateValue['content'] = self::renderPlaceholders($templateValue['content'], $vars); + } + } + + // Mass mail default: treat admin content as plain text and escape. + if ($contentMode === 'text' && is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) { + $templateValue['content'] = e($templateValue['content']); + } + + $params['template_value'] = $templateValue; $params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $params['template_name']; try { Mail::send( diff --git a/database/migrations/2023_08_14_221234_create_v2_settings_table.php b/database/migrations/2023_08_14_221234_create_v2_settings_table.php index 5dd7be2..b6e788b 100644 --- a/database/migrations/2023_08_14_221234_create_v2_settings_table.php +++ b/database/migrations/2023_08_14_221234_create_v2_settings_table.php @@ -17,7 +17,7 @@ class CreateV2SettingsTable extends Migration $table->id(); $table->string('group')->comment('设置分组')->nullable(); $table->string('type')->comment('设置类型')->nullable(); - $table->string('name')->comment('设置名称')->uniqid(); + $table->string('name')->comment('设置名称')->unique(); $table->string('value')->comment('设置值')->nullable(); $table->timestamps(); }); diff --git a/public/assets/admin b/public/assets/admin index ab21b5e..9d13978 160000 --- a/public/assets/admin +++ b/public/assets/admin @@ -1 +1 @@ -Subproject commit ab21b5e00e2fd55665bef360cccfd9945557ea0b +Subproject commit 9d13978a619687bb4c2bc7232857350ad2bf226c