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\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([
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Submodule public/assets/admin updated: ab21b5e00e...9d13978a61
Reference in New Issue
Block a user