feat: Add admin bulk-mail placeholder variables and template rendering

This commit is contained in:
xboard
2026-03-19 05:02:16 +08:00
parent 47983dec40
commit 64e6d8148e
4 changed files with 188 additions and 74 deletions

View File

@@ -15,6 +15,7 @@ use App\Services\UserService;
use App\Traits\QueryOperators; use App\Traits\QueryOperators;
use App\Utils\Helper; use App\Utils\Helper;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -35,27 +36,15 @@ class UserController extends Controller
return $this->success($user->save()); return $this->success($user->save());
} }
/** // Apply filters and sorts to the query builder.
* Apply filters and sorts to the query builder private function applyFiltersAndSorts(Request $request, Builder|QueryBuilder $builder): void
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applyFiltersAndSorts(Request $request, Builder $builder): void
{ {
$this->applyFilters($request, $builder); $this->applyFilters($request, $builder);
$this->applySorting($request, $builder); $this->applySorting($request, $builder);
} }
/** // Apply filters to the query builder.
* Apply filters to the query builder private function applyFilters(Request $request, Builder|QueryBuilder $builder): void
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applyFilters(Request $request, Builder $builder): void
{ {
if (!$request->has('filter')) { if (!$request->has('filter')) {
return; return;
@@ -71,18 +60,14 @@ class UserController extends Controller
}); });
} }
/** // Build one filter query condition.
* Build the filter query based on field and value private function buildFilterQuery(Builder|QueryBuilder $query, string $field, mixed $value): void
*
* @param Builder $query
* @param string $field
* @param mixed $value
* @return void
*/
private function buildFilterQuery(Builder $query, string $field, mixed $value): void
{ {
// 处理关联查询 // 处理关联查询
if (str_contains($field, '.')) { if (str_contains($field, '.')) {
if (!method_exists($query, 'whereHas')) {
return;
}
[$relation, $relationField] = explode('.', $field); [$relation, $relationField] = explode('.', $field);
$query->whereHas($relation, function ($q) use ($relationField, $value) { $query->whereHas($relation, function ($q) use ($relationField, $value) {
if (is_array($value)) { if (is_array($value)) {
@@ -127,14 +112,8 @@ class UserController extends Controller
$this->applyQueryCondition($query, $queryField, $operator, $filterValue); $this->applyQueryCondition($query, $queryField, $operator, $filterValue);
} }
/** // Apply sorting rules to the query builder.
* Apply sorting to the query builder private function applySorting(Request $request, Builder|QueryBuilder $builder): void
*
* @param Request $request
* @param Builder $builder
* @return void
*/
private function applySorting(Request $request, Builder $builder): void
{ {
if (!$request->has('sort')) { if (!$request->has('sort')) {
return; return;
@@ -147,19 +126,50 @@ class UserController extends Controller
}); });
} }
/** // Resolve bulk operation scope and normalize user_ids.
* Fetch paginated user list with filters and sorting private function resolveScope(Request $request): array
* {
* @param Request $request $scope = $request->input('scope');
* @return \Illuminate\Http\Response $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) public function fetch(Request $request)
{ {
$current = $request->input('current', 1); $current = $request->input('current', 1);
$pageSize = $request->input('pageSize', 10); $pageSize = $request->input('pageSize', 10);
$userModel = User::with(['plan:id,name', 'invite_user:id,email', 'group:id,name']) $userModel = User::query()
->select(DB::raw('*, (u+d) as total_used')); ->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); $this->applyFiltersAndSorts($request, $userModel);
@@ -173,12 +183,7 @@ class UserController extends Controller
return $this->paginate($users); return $this->paginate($users);
} }
/** // Transform user fields for API response.
* Transform user data for response
*
* @param User $user
* @return array<string, mixed>
*/
public static function transformUserData(User $user): array public static function transformUserData(User $user): array
{ {
$user = $user->toArray(); $user = $user->toArray();
@@ -254,19 +259,25 @@ class UserController extends Controller
return $this->success(true); return $this->success(true);
} }
/** // Export users to CSV.
* 导出用户数据为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');
gc_enable(); // 启用垃圾回收 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问题 // 优化查询使用with预加载plan关系避免N+1问题
$query = User::with('plan:id,name') $query = User::query()
->with('plan:id,name')
->orderBy('id', 'asc') ->orderBy('id', 'asc')
->select([ ->select([
'email', 'email',
@@ -280,7 +291,11 @@ class UserController extends Controller
'plan_id' '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'; $filename = 'users_' . date('Y-m-d_His') . '.csv';
@@ -440,23 +455,62 @@ class UserController extends Controller
public function sendMail(UserSendMail $request) public function sendMail(UserSendMail $request)
{ {
ini_set('memory_limit', '-1'); 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'; $sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC';
$sort = $request->input('sort') ? $request->input('sort') : 'created_at'; $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'); $subject = $request->input('subject');
$content = $request->input('content'); $content = $request->input('content');
$templateValue = [ $appName = admin_setting('app_name', 'XBoard');
'name' => admin_setting('app_name', 'XBoard'), $appUrl = admin_setting('app_url');
'url' => admin_setting('app_url'),
'content' => $content
];
$chunkSize = 1000; $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) { 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([ dispatch(new SendEmailJob([
'email' => $user->email, 'email' => $user->email,
'subject' => $subject, 'subject' => $subject,
@@ -471,10 +525,29 @@ class UserController extends Controller
public function ban(Request $request) 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'; $sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC';
$sort = $request->input('sort') ? $request->input('sort') : 'created_at'; $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 { try {
$builder->update([ $builder->update([
'banned' => 1 'banned' => 1
@@ -483,16 +556,11 @@ class UserController extends Controller
Log::error($e); Log::error($e);
return $this->fail([500, '处理失败']); return $this->fail([500, '处理失败']);
} }
NodeSyncService::notifyUsersUpdated(); // Full refresh not implemented.
return $this->success(true); return $this->success(true);
} }
/** // Delete user and related data.
* 删除用户及其关联数据
*
* @param Request $request
* @return JsonResponse
*/
public function destroy(Request $request) public function destroy(Request $request)
{ {
$request->validate([ $request->validate([

View File

@@ -13,6 +13,33 @@ use Illuminate\Support\Facades\Mail;
class MailService 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']; $email = $params['email'];
$subject = $params['subject']; $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']; $params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $params['template_name'];
try { try {
Mail::send( Mail::send(

View File

@@ -17,7 +17,7 @@ class CreateV2SettingsTable extends Migration
$table->id(); $table->id();
$table->string('group')->comment('设置分组')->nullable(); $table->string('group')->comment('设置分组')->nullable();
$table->string('type')->comment('设置类型')->nullable(); $table->string('type')->comment('设置类型')->nullable();
$table->string('name')->comment('设置名称')->uniqid(); $table->string('name')->comment('设置名称')->unique();
$table->string('value')->comment('设置值')->nullable(); $table->string('value')->comment('设置值')->nullable();
$table->timestamps(); $table->timestamps();
}); });