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

2 weeks ago
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
1 week ago
use App\Models\Blacklist;
2 weeks ago
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);
1 week ago
$wechatUser = $this->authWechatUser($request);
$hasExisting = $wechatUser && $this->userHasActiveTicketGrabForEvent($e->id, $wechatUser->id);
2 weeks ago
return response()->json([
'event' => $this->eventMini($e),
'venue_id' => $venueId,
1 week ago
'has_existing_reservation' => (bool) $hasExisting,
2 weeks ago
'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'],
1 week ago
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
2 weeks ago
'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'];
1 week ago
if (Blacklist::query()->where('venue_id', $venueId)->where('visitor_phone', $data['visitor_phone'])->exists()) {
throw ValidationException::withMessages([
'visitor_phone' => ['您已被该场馆限制预约,如有疑问请联系场馆。'],
]);
}
2 weeks ago
$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' => ['当前不在今日放票时段内']]);
}
2 weeks ago
// 同一微信用户:同一抢票活动下仅允许 1 条非取消订单(一个场馆、一个入馆日)
if ($this->userHasActiveTicketGrabForEvent($e->id, $wechatUser->id)) {
throw ValidationException::withMessages([
'message' => ['您已预约过本活动,每位用户仅可预约一个参与场馆的指定入馆日,无需重复预约'],
]);
}
2 weeks ago
$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) {
2 weeks ago
// 串行同活动下并发下单,防同一微信用户重复插单
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' => ['该证件对所选入馆日已有一条预约,无需重复']]);
}
2 weeks ago
$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'],
1 week ago
'visitor_phone' => $data['visitor_phone'],
2 weeks ago
'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);
}
2 weeks ago
/**
* 同一微信用户在同一抢票活动下是否已有非取消预约(含待核销、已核销、已过期等)。
*/
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();
}
2 weeks ago
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;
}
}