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.
378 lines
17 KiB
378 lines
17 KiB
<?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', '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;
|
|
}
|
|
|
|
}
|