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.

399 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 App\Models\TicketGrabEvent;
use App\Models\TicketGrabVenueReleaseDay;
use App\Models\Venue;
use App\Models\WechatUser;
use App\Services\ChineseIdCardHelper;
use App\Services\TicketGrabReleaseDayService;
use App\Support\CalendarDateFormat;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\PersonalAccessToken;
class H5TicketGrabController extends Controller
{
public function show(Request $request, int $id): JsonResponse
{
$e = TicketGrabEvent::query()
->with(['venues' => function ($q) {
$q->select(
'venues.id',
'venues.name',
'venues.address',
'venues.cover_image',
'venues.district',
'venues.lat',
'venues.lng',
);
}])
->visibleOnH5()
->findOrFail($id);
$e->setAttribute('schedule_status', TicketGrabEvent::computeScheduleStatusFromBounds($e->start_at, $e->end_at));
$eventId = (int) $e->id;
$today = now()->toDateString();
// 参与场馆列表:开放时间用抢票-场馆「编辑详情」的 opening_hours其余列表字段仍用场馆主表今日余票为当日 release 行剩余
$venues = $e->venues->map(function (Venue $v) use ($eventId, $today) {
TicketGrabReleaseDayService::syncCarryInChain($eventId, (int) $v->id);
$release = TicketGrabVenueReleaseDay::query()
->where('ticket_grab_event_id', $eventId)
->where('venue_id', (int) $v->id)
->whereDate('release_date', $today)
->first();
$todayRemaining = $release ? $release->availableRemaining() : 0;
$pivot = $v->pivot;
$opening = $pivot?->opening_hours;
$openTime = is_string($opening) && trim($opening) !== '' ? $opening : null;
return [
'id' => (int) $v->id,
'name' => $v->name,
'cover_image' => $v->cover_image,
'district' => $v->district,
'open_time' => $openTime,
'address' => $v->address,
'lat' => $v->lat !== null ? (float) $v->lat : null,
'lng' => $v->lng !== null ? (float) $v->lng : null,
'today_remaining' => (int) $todayRemaining,
];
})->values();
return response()->json([
'id' => $e->id,
'list_kind' => 'ticket_grab',
'title' => $e->title,
'summary' => $e->summary,
'detail_html' => $e->detail_html,
'image' => $e->cover_image,
'carousel' => $this->buildGalleryCarousel($e),
'start_at' => CalendarDateFormat::ymdFromDatetime($e->start_at),
'end_at' => CalendarDateFormat::ymdFromDatetime($e->end_at),
'schedule_status' => $e->schedule_status,
'registered_count' => (int) $e->registered_count,
'tags' => array_values($e->tags ?? []),
'reservation_notice' => $e->reservation_notice,
'booking_start_at' => CalendarDateFormat::ymdFromDateValue($e->booking_start_at),
'booking_end_at' => CalendarDateFormat::ymdFromDateValue($e->booking_end_at),
'daily_release_start_time' => $e->daily_release_start_time,
'daily_release_end_time' => $e->daily_release_end_time,
'booking_audience' => $e->booking_audience,
'venue_blocks' => $venues,
]);
}
public function bookingInfo(Request $request, int $id): JsonResponse
{
$e = TicketGrabEvent::query()->visibleOnH5()->findOrFail($id);
$data = $request->validate(['venue_id' => ['required', 'integer', 'exists:venues,id']]);
$venueId = (int) $data['venue_id'];
$pivot = $e->venues()->where('venues.id', $venueId)->exists();
if (! $pivot) {
throw ValidationException::withMessages(['venue_id' => ['该抢票活动未开放此场馆']]);
}
TicketGrabReleaseDayService::syncCarryInChain($e->id, $venueId);
$today = now()->toDateString();
$release = TicketGrabVenueReleaseDay::query()
->where('ticket_grab_event_id', $e->id)
->where('venue_id', $venueId)
->whereDate('release_date', $today)
->first();
$inBookingWindow = $e->booking_start_at && $e->booking_end_at
&& $today >= $e->booking_start_at->toDateString()
&& $today <= $e->booking_end_at->toDateString();
$timeOk = $this->dailyReleaseTimeOpen($e);
$remaining = 0;
if ($release) {
$remaining = $release->availableRemaining();
}
$entryDates = $this->entryDateRange($e);
$wechatUser = $this->authWechatUser($request);
$hasExisting = $wechatUser && $this->userHasActiveTicketGrabForEvent($e->id, $wechatUser->id);
return response()->json([
'event' => $this->eventMini($e),
'venue_id' => $venueId,
'has_existing_reservation' => (bool) $hasExisting,
'today_release' => $release ? [
'id' => $release->id,
'release_date' => $release->release_date->toDateString(),
'day_quota' => (int) $release->day_quota,
'carry_in' => (int) $release->carry_in,
'booked_count' => (int) $release->booked_count,
'remaining' => (int) $remaining,
] : null,
'in_booking_window' => (bool) $inBookingWindow,
'in_daily_release_time' => $timeOk,
'can_book_now' => $inBookingWindow && $timeOk && $release && $remaining > 0,
'entry_dates' => $entryDates,
]);
}
public function create(Request $request, int $id): JsonResponse
{
$wechatUser = $this->authWechatUser($request);
if (! $wechatUser) {
return response()->json(['message' => '请先微信登录后再预约'], 401);
}
$e = TicketGrabEvent::query()->visibleOnH5()->findOrFail($id);
$data = $request->validate([
'venue_id' => ['required', 'integer', 'exists:venues,id'],
'visitor_name' => ['required', 'string', 'max:80'],
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
'id_card' => ['required', 'string', 'size:18'],
'entry_date' => ['required', 'date'],
'ticket_count' => ['nullable', 'integer', 'min:1', 'max:2'],
'ticket_mode' => ['nullable', 'in:single,pair'],
]);
if (! $e->venues()->where('venues.id', (int) $data['venue_id'])->exists()) {
throw ValidationException::withMessages(['venue_id' => ['该抢票活动未开放此场馆']]);
}
$venueId = (int) $data['venue_id'];
if (Blacklist::query()->active()->where('venue_id', $venueId)->where('visitor_phone', $data['visitor_phone'])->exists()) {
throw ValidationException::withMessages([
'visitor_phone' => ['您已被该场馆限制预约,如有疑问请联系场馆。'],
]);
}
$tz = (string) config('app.timezone');
$birth = ChineseIdCardHelper::parseBirthdate($data['id_card']);
if (! $birth) {
throw ValidationException::withMessages(['id_card' => ['身份证格式无效']]);
}
if ($e->age_limit_start || $e->age_limit_end) {
if (! ChineseIdCardHelper::isBirthInRange($birth, $e->age_limit_start, $e->age_limit_end, $tz)) {
throw ValidationException::withMessages(['id_card' => ['该证件不在本次活动允许的出生日期范围内']]);
}
}
if ($e->booking_audience === TicketGrabEvent::AUDIENCE_ALL) {
$ticketCount = 1;
} else {
$mode = (string) ($data['ticket_mode'] ?? 'single');
if ($mode === 'pair') {
$ticketCount = 2;
} else {
$ticketCount = 1;
}
}
if ($e->start_at && $e->end_at) {
$en = Carbon::parse($data['entry_date'], $tz)->startOfDay();
if ($en->lt($e->start_at->copy()->startOfDay()) || $en->gt($e->end_at->copy()->startOfDay())) {
throw ValidationException::withMessages(['entry_date' => ['入馆日期需落在活动举办日期范围内']]);
}
}
$today = now()->toDateString();
if (! $e->booking_start_at || ! $e->booking_end_at
|| $today < $e->booking_start_at->toDateString()
|| $today > $e->booking_end_at->toDateString()) {
throw ValidationException::withMessages(['message' => ['当前不在预约开放时间内']]);
}
if (! $this->dailyReleaseTimeOpen($e)) {
throw ValidationException::withMessages(['message' => ['当前不在今日放票时段内']]);
}
// 同一微信用户:同一抢票活动下仅允许 1 条非取消订单(一个场馆、一个入馆日)
if ($this->userHasActiveTicketGrabForEvent($e->id, $wechatUser->id)) {
throw ValidationException::withMessages([
'message' => ['您已预约过本活动,每位用户仅可预约一个参与场馆的指定入馆日,无需重复预约'],
]);
}
$dup = Reservation::query()
->where('reservation_kind', Reservation::KIND_TICKET_GRAB)
->where('ticket_grab_event_id', $e->id)
->where('id_card', $data['id_card'])
->whereDate('entry_date', $data['entry_date'])
->where('status', '!=', 'cancelled')
->exists();
if ($dup) {
throw ValidationException::withMessages(['id_card' => ['该证件对所选入馆日已有一条预约,无需重复']]);
}
TicketGrabReleaseDayService::syncCarryInChain($e->id, $venueId);
$pre = TicketGrabVenueReleaseDay::query()
->where('ticket_grab_event_id', $e->id)
->where('venue_id', $venueId)
->whereDate('release_date', $today)
->first();
if (! $pre) {
throw ValidationException::withMessages(['message' => ['今日无放票计划']]);
}
if ($pre->availableRemaining() < $ticketCount) {
throw ValidationException::withMessages(['message' => ['当前余票不足']]);
}
if ($e->booking_audience === TicketGrabEvent::AUDIENCE_SCHOOL_AGE) {
if ($ticketCount === 2 && $pre->availableRemaining() < 2) {
throw ValidationException::withMessages(['message' => ['余票仅 1 张,无法选择「一大一小」']]);
}
}
$res = DB::transaction(function () use ($e, $data, $wechatUser, $venueId, $ticketCount) {
// 串行同活动下并发下单,防同一微信用户重复插单
TicketGrabEvent::query()->whereKey($e->id)->lockForUpdate()->first();
if ($this->userHasActiveTicketGrabForEvent($e->id, $wechatUser->id)) {
throw ValidationException::withMessages([
'message' => ['您已预约过本活动,每位用户仅可预约一个参与场馆的指定入馆日,无需重复预约'],
]);
}
if (Reservation::query()
->where('reservation_kind', Reservation::KIND_TICKET_GRAB)
->where('ticket_grab_event_id', $e->id)
->where('id_card', $data['id_card'])
->whereDate('entry_date', $data['entry_date'])
->where('status', '!=', 'cancelled')
->exists()) {
throw ValidationException::withMessages(['id_card' => ['该证件对所选入馆日已有一条预约,无需重复']]);
}
$r = TicketGrabVenueReleaseDay::query()
->where('ticket_grab_event_id', $e->id)
->where('venue_id', $venueId)
->whereDate('release_date', now()->toDateString())
->lockForUpdate()
->first();
if (! $r) {
throw ValidationException::withMessages(['message' => ['今日无放票计划']]);
}
if ($r->availableRemaining() < $ticketCount) {
throw ValidationException::withMessages(['message' => ['当前余票不足']]);
}
$r->increment('booked_count', $ticketCount);
$row = Reservation::create([
'reservation_kind' => Reservation::KIND_TICKET_GRAB,
'venue_id' => $venueId,
'activity_id' => null,
'ticket_grab_event_id' => $e->id,
'activity_day_id' => null,
'ticket_grab_venue_release_day_id' => $r->id,
'entry_date' => Carbon::parse($data['entry_date'])->toDateString(),
'wechat_user_id' => $wechatUser->id,
'visitor_name' => $data['visitor_name'],
'visitor_phone' => $data['visitor_phone'],
'id_card' => $data['id_card'],
'ticket_count' => $ticketCount,
'ticket_mode' => $e->booking_audience === TicketGrabEvent::AUDIENCE_SCHOOL_AGE
? ($data['ticket_mode'] ?? 'single') : null,
'booking_type' => 'individual',
'qr_token' => (string) Str::uuid(),
'status' => 'pending',
'reservation_source' => 'wechat_h5_tg',
]);
\App\Models\TicketGrabEvent::refreshRegisteredCountFromReservations($e->id);
$row->load(['ticketGrabEvent', 'venue', 'ticketGrabReleaseDay']);
return $row;
});
$arr = app(H5ReservationController::class)->reservationToH5Array($res);
return response()->json([
'message' => '预约成功',
'reservation' => $arr,
], 201);
}
/**
* 同一微信用户在同一抢票活动下是否已有非取消预约(含待核销、已核销、已过期等)。
*/
private function userHasActiveTicketGrabForEvent(int $ticketGrabEventId, int $wechatUserId): bool
{
return Reservation::query()
->where('reservation_kind', Reservation::KIND_TICKET_GRAB)
->where('ticket_grab_event_id', $ticketGrabEventId)
->where('wechat_user_id', $wechatUserId)
->where('status', '!=', 'cancelled')
->exists();
}
private function dailyReleaseTimeOpen(TicketGrabEvent $e): bool
{
return TicketGrabEvent::isDailyReleaseTimeWindowOpen($e);
}
private function eventMini(TicketGrabEvent $e): array
{
return [
'id' => $e->id,
'title' => $e->title,
'booking_start_at' => CalendarDateFormat::ymdFromDateValue($e->booking_start_at),
'booking_end_at' => CalendarDateFormat::ymdFromDateValue($e->booking_end_at),
'start_at' => CalendarDateFormat::ymdFromDatetime($e->start_at),
'end_at' => CalendarDateFormat::ymdFromDatetime($e->end_at),
'daily_release_start_time' => $e->daily_release_start_time,
'daily_release_end_time' => $e->daily_release_end_time,
'booking_audience' => $e->booking_audience,
'reservation_notice' => $e->reservation_notice,
'age_limit_start' => CalendarDateFormat::ymdFromDateValue($e->age_limit_start),
'age_limit_end' => CalendarDateFormat::ymdFromDateValue($e->age_limit_end),
];
}
private function entryDateRange(TicketGrabEvent $e): array
{
if (! $e->start_at || ! $e->end_at) {
return [];
}
$tz = (string) config('app.timezone');
$s = CalendarDateFormat::ymdFromDatetime($e->start_at);
$x = CalendarDateFormat::ymdFromDatetime($e->end_at);
if (! $s || ! $x) {
return [];
}
$out = [];
foreach (CarbonPeriod::create(
Carbon::parse($s, $tz)->startOfDay(),
Carbon::parse($x, $tz)->startOfDay()
) as $d) {
$out[] = $d->toDateString();
}
return $out;
}
private function buildGalleryCarousel(TicketGrabEvent $model): array
{
$items = [];
$seen = [];
foreach ($model->gallery_media ?? [] as $m) {
if (! is_array($m)) {
continue;
}
$url = trim((string) ($m['url'] ?? ''));
if ($url === '' || isset($seen[$url])) {
continue;
}
$type = $m['type'] ?? 'image';
if (! in_array($type, ['image', 'video'], true)) {
$type = 'image';
}
$seen[$url] = true;
$items[] = ['type' => $type, 'url' => $url];
}
if ($items === [] && $model->cover_image) {
$url = trim((string) $model->cover_image);
if ($url !== '') {
$items[] = ['type' => 'image', 'url' => $url];
}
}
return $items;
}
private function authWechatUser(Request $request): ?WechatUser
{
$token = $request->bearerToken();
if (! $token) {
return null;
}
$access = PersonalAccessToken::findToken($token);
if (! $access || ! ($access->tokenable instanceof WechatUser)) {
return null;
}
return $access->tokenable;
}
}