mirror of
https://github.com/lkddi/Xboard.git
synced 2026-04-03 10:30:51 +08:00
feat: Add admin bulk-mail placeholder variables and template rendering
This commit is contained in:
@@ -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<string, mixed>
|
||||
*/
|
||||
// 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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user