with(['venues' => function ($q) { $q->select('venues.id', 'name', 'address', 'cover_image', 'district', 'open_time', 'lat', 'lng'); }]) ->visibleOnH5() ->findOrFail($id); $e->setAttribute('schedule_status', TicketGrabEvent::computeScheduleStatusFromBounds($e->start_at, $e->end_at)); // 直接使用已关联场馆字段;勿用 toH5Payload() 过滤,否则未过审/未激活的场馆会从列表消失,导致 H5 无「参与场馆」 $venues = $e->venues->map(function (Venue $v) { return [ 'id' => (int) $v->id, 'name' => $v->name, 'cover_image' => $v->cover_image, 'district' => $v->district, 'open_time' => $v->open_time, 'address' => $v->address, 'lat' => $v->lat !== null ? (float) $v->lat : null, 'lng' => $v->lng !== null ? (float) $v->lng : null, ]; })->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()->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; } }