user(); $allowedVenueIds = $this->allowedVenueIds($request); $pageSize = max(1, min(100, (int) $request->input('page_size', 10))); if ($user->isSuperAdmin() && Schema::hasTable('wechat_users')) { $query = DB::table('wechat_users as wu') ->leftJoin('reservations as r', 'r.wechat_user_id', '=', 'wu.id') ->when($request->filled('venue_id'), fn ($q) => $q->where('r.venue_id', (int) $request->input('venue_id'))) ->when($request->filled('keyword'), function ($q) use ($request) { $keyword = trim((string) $request->input('keyword')); $q->where(function ($w) use ($keyword) { $w->where('wu.nickname', 'like', "%{$keyword}%") ->orWhere('wu.openid', 'like', "%{$keyword}%") ->orWhere('wu.unionid', 'like', "%{$keyword}%") ->orWhere('r.visitor_phone', 'like', "%{$keyword}%") ->orWhere('r.visitor_name', 'like', "%{$keyword}%") ->orWhere('r.id_card', 'like', "%{$keyword}%"); }); }) ->selectRaw('wu.id as wechat_user_id, wu.openid, wu.unionid, wu.nickname, MAX(r.id) as latest_id, COUNT(r.id) as reservation_count, MAX(r.created_at) as last_reserved_at') ->groupBy('wu.id', 'wu.openid', 'wu.unionid', 'wu.nickname') ->orderByDesc('wu.id'); $base = $query->paginate($pageSize); $latestIds = collect($base->items())->pluck('latest_id')->filter()->map(fn ($v) => (int) $v)->values(); $wechatIds = collect($base->items())->pluck('wechat_user_id')->filter()->map(fn ($v) => (int) $v)->values(); $latestRows = Reservation::query() ->whereIn('id', $latestIds) ->get(['id', 'wechat_user_id', 'visitor_phone', 'visitor_name', 'id_card']) ->keyBy('id'); $venueRows = Reservation::query() ->join('venues', 'venues.id', '=', 'reservations.venue_id') ->whereIn('reservations.wechat_user_id', $wechatIds) ->selectRaw('reservations.wechat_user_id, reservations.venue_id, venues.name') ->groupBy('reservations.wechat_user_id', 'reservations.venue_id', 'venues.name') ->get() ->groupBy('wechat_user_id'); $phones = $latestRows->pluck('visitor_phone')->filter()->values(); $blackMapByPhone = Blacklist::query() ->join('venues', 'venues.id', '=', 'blacklists.venue_id') ->whereIn('blacklists.visitor_phone', $phones) ->selectRaw('blacklists.visitor_phone, blacklists.venue_id, venues.name, blacklists.reason') ->get() ->groupBy('visitor_phone'); $base->setCollection(collect($base->items())->map(function ($row) use ($latestRows, $venueRows, $blackMapByPhone) { $latest = $row->latest_id ? $latestRows->get((int) $row->latest_id) : null; $currentVenues = ($venueRows->get((int) $row->wechat_user_id) ?? collect()) ->map(fn ($v) => ['id' => (int) $v->venue_id, 'name' => (string) $v->name]) ->values(); $blacklistedVenues = $latest?->visitor_phone ? ($blackMapByPhone->get($latest->visitor_phone) ?? collect()) ->map(fn ($v) => ['id' => (int) $v->venue_id, 'name' => (string) $v->name, 'reason' => $v->reason]) ->values() : collect(); return [ 'user_key' => 'wu-'.$row->wechat_user_id, 'visitor_phone' => $latest?->visitor_phone, 'visitor_name' => $latest?->visitor_name ?: $row->nickname, 'id_card' => $latest?->id_card, 'openid' => $row->openid, 'unionid' => $row->unionid, 'reservation_count' => (int) $row->reservation_count, 'last_reserved_at' => $row->last_reserved_at, 'venues' => $currentVenues, 'blacklisted_venues' => $blacklistedVenues, 'disabled' => !$latest?->visitor_phone, ]; })); return response()->json($base); } $query = Reservation::query() ->whereNotNull('visitor_phone') ->where('visitor_phone', '!=', ''); if (!$user->isSuperAdmin()) { $query->whereIn('venue_id', $allowedVenueIds); } elseif ($request->filled('venue_id')) { $query->where('venue_id', (int) $request->input('venue_id')); } if ($request->filled('keyword')) { $keyword = trim((string) $request->input('keyword')); $query->where(function ($q) use ($keyword) { $q->where('visitor_phone', 'like', "%{$keyword}%") ->orWhere('visitor_name', 'like', "%{$keyword}%") ->orWhere('id_card', 'like', "%{$keyword}%"); }); } $base = (clone $query) ->selectRaw('visitor_phone, MAX(id) as latest_id, COUNT(*) as reservation_count, MAX(created_at) as last_reserved_at') ->groupBy('visitor_phone') ->orderByDesc('latest_id') ->paginate($pageSize); $phones = collect($base->items())->pluck('visitor_phone')->filter()->values(); if ($phones->isEmpty()) { return response()->json($base); } $latestRows = Reservation::query() ->whereIn('id', collect($base->items())->pluck('latest_id')) ->get(['id', 'visitor_phone', 'visitor_name', 'id_card']) ->keyBy('id'); $venueRows = Reservation::query() ->join('venues', 'venues.id', '=', 'reservations.venue_id') ->whereIn('reservations.visitor_phone', $phones) ->when(!$user->isSuperAdmin(), fn ($q) => $q->whereIn('reservations.venue_id', $allowedVenueIds)) ->selectRaw('reservations.visitor_phone, reservations.venue_id, venues.name') ->groupBy('reservations.visitor_phone', 'reservations.venue_id', 'venues.name') ->get(); $blackRows = Blacklist::query() ->join('venues', 'venues.id', '=', 'blacklists.venue_id') ->whereIn('blacklists.visitor_phone', $phones) ->when(!$user->isSuperAdmin(), fn ($q) => $q->whereIn('blacklists.venue_id', $allowedVenueIds)) ->selectRaw('blacklists.visitor_phone, blacklists.venue_id, venues.name, blacklists.reason') ->get(); $venueMap = $venueRows->groupBy('visitor_phone'); $blackMap = $blackRows->groupBy('visitor_phone'); $base->setCollection(collect($base->items())->map(function ($row) use ($latestRows, $venueMap, $blackMap) { $latest = $latestRows->get((int) $row->latest_id); $venues = ($venueMap->get($row->visitor_phone) ?? collect()) ->map(fn ($v) => ['id' => (int) $v->venue_id, 'name' => (string) $v->name]) ->values(); $blacklistedVenues = ($blackMap->get($row->visitor_phone) ?? collect()) ->map(fn ($v) => ['id' => (int) $v->venue_id, 'name' => (string) $v->name, 'reason' => $v->reason]) ->values(); return [ 'user_key' => 'phone-'.$row->visitor_phone, 'visitor_phone' => $row->visitor_phone, 'visitor_name' => $latest?->visitor_name, 'id_card' => $latest?->id_card, 'openid' => null, 'unionid' => null, 'reservation_count' => (int) $row->reservation_count, 'last_reserved_at' => $row->last_reserved_at, 'venues' => $venues, 'blacklisted_venues' => $blacklistedVenues, 'disabled' => false, ]; })); return response()->json($base); } public function batchBlacklist(Request $request): JsonResponse { $data = $request->validate([ 'phones' => ['required', 'array', 'min:1'], 'phones.*' => ['required', 'string', 'max:20'], 'venue_ids' => ['required', 'array', 'min:1'], 'venue_ids.*' => ['required', 'integer', 'exists:venues,id'], 'reason' => ['required', 'string', 'max:255'], ]); $allowedVenueIds = $this->allowedVenueIds($request); $venueIds = collect($data['venue_ids'])->map(fn ($v) => (int) $v)->unique()->values(); foreach ($venueIds as $venueId) { $this->ensureVenuePermission($request, $venueId); } $phones = collect($data['phones'])->map(fn ($v) => trim((string) $v))->filter()->unique()->values(); $result = $this->applyBlacklistAction($phones, $venueIds, (string) $data['reason'], $allowedVenueIds, true); return response()->json(array_merge(['message' => '批量拉黑完成'], $result)); } public function batchUnblacklist(Request $request): JsonResponse { $data = $request->validate([ 'phones' => ['required', 'array', 'min:1'], 'phones.*' => ['required', 'string', 'max:20'], 'venue_ids' => ['required', 'array', 'min:1'], 'venue_ids.*' => ['required', 'integer', 'exists:venues,id'], 'reason' => ['required', 'string', 'max:255'], ]); $allowedVenueIds = $this->allowedVenueIds($request); $venueIds = collect($data['venue_ids'])->map(fn ($v) => (int) $v)->unique()->values(); foreach ($venueIds as $venueId) { $this->ensureVenuePermission($request, $venueId); } $phones = collect($data['phones'])->map(fn ($v) => trim((string) $v))->filter()->unique()->values(); $result = $this->applyBlacklistAction($phones, $venueIds, (string) $data['reason'], $allowedVenueIds, false); return response()->json(array_merge(['message' => '批量解除黑名单完成'], $result)); } public function index(Request $request): JsonResponse { $query = Blacklist::with('venue:id,name')->orderByDesc('id'); $this->restrictByVenue($request, $query); if ($request->filled('keyword')) { $keyword = trim((string) $request->input('keyword')); $query->where(function ($q) use ($keyword) { $q->where('visitor_phone', 'like', "%{$keyword}%") ->orWhere('visitor_name', 'like', "%{$keyword}%"); }); } return response()->json($query->get()); } public function store(Request $request): JsonResponse { $data = $request->validate([ 'venue_id' => ['required', 'integer', 'exists:venues,id'], 'visitor_name' => ['nullable', 'string', 'max:100'], 'visitor_phone' => ['required', 'string', 'max:20'], 'reason' => ['nullable', 'string', 'max:255'], ]); $this->ensureVenuePermission($request, (int) $data['venue_id']); $item = Blacklist::updateOrCreate( ['venue_id' => $data['venue_id'], 'visitor_phone' => $data['visitor_phone']], ['visitor_name' => $data['visitor_name'] ?? null, 'reason' => $data['reason'] ?? null], ); return response()->json($item->load('venue:id,name'), 201); } public function destroy(Request $request, Blacklist $blacklist): JsonResponse { $this->ensureVenuePermission($request, $blacklist->venue_id); $blacklist->delete(); return response()->json(['message' => '删除成功']); } public function update(Request $request, Blacklist $blacklist): JsonResponse { $this->ensureVenuePermission($request, $blacklist->venue_id); $data = $request->validate([ 'visitor_name' => ['nullable', 'string', 'max:100'], 'reason' => ['nullable', 'string', 'max:255'], ]); $blacklist->fill($data)->save(); return response()->json($blacklist->fresh()->load('venue:id,name')); } public function batchImport(Request $request): JsonResponse { $data = $request->validate([ 'venue_id' => ['required', 'integer', 'exists:venues,id'], 'phones' => ['required', 'string'], 'reason' => ['nullable', 'string', 'max:255'], 'dedupe_mode' => ['nullable', 'in:overwrite,keep'], ]); $this->ensureVenuePermission($request, (int) $data['venue_id']); $phones = preg_split('/[\s,;,;]+/u', $data['phones']) ?: []; $phones = array_values(array_unique(array_filter(array_map('trim', $phones)))); $created = 0; $failed = []; $dedupeMode = $data['dedupe_mode'] ?? 'overwrite'; foreach ($phones as $phone) { if ($phone === '') { continue; } if (!preg_match('/^1\d{10}$/', $phone)) { $failed[] = ['phone' => $phone, 'reason' => '手机号格式不正确']; continue; } $existing = Blacklist::where('venue_id', (int) $data['venue_id']) ->where('visitor_phone', $phone) ->first(); if ($existing) { if ($dedupeMode === 'overwrite') { $existing->reason = $data['reason'] ?? $existing->reason ?? '批量导入'; $existing->save(); } } else { Blacklist::create([ 'venue_id' => (int) $data['venue_id'], 'visitor_phone' => $phone, 'reason' => $data['reason'] ?? '批量导入', ]); $created++; } } return response()->json([ 'message' => '批量导入完成', 'count' => $created, 'failed_count' => count($failed), 'failed' => $failed, ]); } private function restrictByVenue(Request $request, $query): void { $user = $request->user(); if ($user->isSuperAdmin()) { return; } $query->whereIn('venue_id', $user->venues()->pluck('venues.id')); } private function ensureVenuePermission(Request $request, int $venueId): void { $user = $request->user(); if ($user->isSuperAdmin()) { return; } $allowed = $user->venues()->where('venues.id', $venueId)->exists(); abort_unless($allowed, 403, '仅可操作已绑定场馆'); } /** * @param Collection $phones * @param Collection $venueIds * @param Collection $allowedVenueIds * @return array */ private function applyBlacklistAction( Collection $phones, Collection $venueIds, string $reason, Collection $allowedVenueIds, bool $toBlacklist ): array { $pairs = Reservation::query() ->whereIn('visitor_phone', $phones) ->whereIn('venue_id', $venueIds) ->select(['visitor_phone', 'venue_id']) ->groupBy('visitor_phone', 'venue_id') ->get() ->map(fn ($r) => $r->visitor_phone.'#'.$r->venue_id) ->values() ->all(); $pairSet = array_fill_keys($pairs, true); $affected = 0; $skipped = 0; $failed = []; DB::beginTransaction(); try { foreach ($phones as $phone) { foreach ($venueIds as $venueId) { if ($allowedVenueIds->isNotEmpty() && !$allowedVenueIds->contains($venueId)) { $failed[] = ['phone' => $phone, 'venue_id' => $venueId, 'reason' => '无场馆权限']; continue; } $key = $phone.'#'.$venueId; if (!isset($pairSet[$key])) { $failed[] = ['phone' => $phone, 'venue_id' => $venueId, 'reason' => '用户未预约该场馆']; continue; } if ($toBlacklist) { Blacklist::updateOrCreate( ['venue_id' => $venueId, 'visitor_phone' => $phone], ['reason' => $reason] ); $affected++; } else { $deleted = Blacklist::where('venue_id', $venueId)->where('visitor_phone', $phone)->delete(); if ($deleted > 0) { $affected += $deleted; } else { $skipped++; } } } } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); throw $e; } return [ 'count' => $affected, 'skipped_count' => $skipped, 'failed_count' => count($failed), 'failed' => $failed, ]; } /** * @return Collection */ private function allowedVenueIds(Request $request): Collection { $user = $request->user(); if ($user->isSuperAdmin()) { return collect(); } return $user->venues()->pluck('venues.id')->map(fn ($id) => (int) $id)->values(); } }