You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

423 lines
18 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Blacklist;
use App\Models\Reservation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class BlacklistController extends Controller
{
public function users(Request $request): JsonResponse
{
$user = $request->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<int, string> $phones
* @param Collection<int, int> $venueIds
* @param Collection<int, int> $allowedVenueIds
* @return array<string, mixed>
*/
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<int, int>
*/
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();
}
}