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.

483 lines
20 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)));
$onlyBlacklisted = $request->boolean('only_blacklisted');
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($onlyBlacklisted, function ($q) use ($request) {
// 本分支仅超级管理员 + 存在 wechat_users 表时进入
$q->whereExists(function ($sub) use ($request) {
$sub->selectRaw('1')
->from('blacklists as b')
->join('reservations as r_bl', function ($j) {
$j->on('r_bl.visitor_phone', '=', 'b.visitor_phone')
->whereColumn('r_bl.wechat_user_id', 'wu.id');
})
->whereNull('b.revoked_at');
if ($request->filled('venue_id')) {
$sub->where('b.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()
->active()
->join('venues', 'venues.id', '=', 'blacklists.venue_id')
->whereIn('blacklists.visitor_phone', $phones)
->selectRaw('blacklists.visitor_phone, blacklists.venue_id, venues.name, blacklists.reason, blacklists.created_at as blacklisted_at, blacklists.revoked_at as unblacklisted_at')
->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,
'blacklisted_at' => $v->blacklisted_at,
'unblacklisted_at' => $v->unblacklisted_at,
])
->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 ($onlyBlacklisted) {
$query->whereExists(function ($sub) use ($request, $user, $allowedVenueIds) {
$sub->selectRaw('1')
->from('blacklists as b')
->whereColumn('b.visitor_phone', 'reservations.visitor_phone')
->whereNull('b.revoked_at');
if (! $user->isSuperAdmin()) {
$sub->whereIn('b.venue_id', $allowedVenueIds);
} elseif ($request->filled('venue_id')) {
$sub->where('b.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()
->active()
->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, blacklists.created_at as blacklisted_at, blacklists.revoked_at as unblacklisted_at')
->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,
'blacklisted_at' => $v->blacklisted_at,
'unblacklisted_at' => $v->unblacklisted_at,
])
->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::query()->active()->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,
'revoked_at' => null,
],
);
return response()->json($item->load('venue:id,name'), 201);
}
public function destroy(Request $request, Blacklist $blacklist): JsonResponse
{
$this->ensureVenuePermission($request, $blacklist->venue_id);
if ($blacklist->revoked_at) {
return response()->json(['message' => '该记录已解除']);
}
$blacklist->update(['revoked_at' => now()]);
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::query()
->where('venue_id', (int) $data['venue_id'])
->where('visitor_phone', $phone)
->first();
if (! $existing) {
Blacklist::create([
'venue_id' => (int) $data['venue_id'],
'visitor_phone' => $phone,
'reason' => $data['reason'] ?? '批量导入',
]);
$created++;
continue;
}
if ($dedupeMode === 'overwrite') {
$existing->fill([
'reason' => $data['reason'] ?? $existing->reason ?? '批量导入',
'revoked_at' => null,
])->save();
}
}
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, 'revoked_at' => null]
);
$affected++;
} else {
$n = Blacklist::query()
->active()
->where('venue_id', $venueId)
->where('visitor_phone', $phone)
->update(['revoked_at' => now()]);
if ($n > 0) {
$affected += $n;
} 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();
}
}