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.

526 lines
22 KiB

3 weeks ago
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\ActivityDay;
2 weeks ago
use App\Models\Blacklist;
use App\Models\PhoneBookingBan;
3 weeks ago
use App\Models\Reservation;
1 week ago
use App\Models\TicketGrabEvent;
3 weeks ago
use App\Models\WechatUser;
1 week ago
use App\Models\TicketGrabVenueReleaseDay;
2 weeks ago
use App\Services\NoShowBlacklistService;
1 week ago
use App\Services\ReservationExpiryService;
1 week ago
use App\Support\CalendarDateFormat;
3 weeks ago
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
3 weeks ago
use Laravel\Sanctum\PersonalAccessToken;
3 weeks ago
class H5ReservationController extends Controller
{
private const BOOKING_MODE_INDIVIDUAL = 'individual';
3 weeks ago
3 weeks ago
private const BOOKING_MODE_GROUP = 'group';
3 weeks ago
3 weeks ago
private const BOOKING_MODE_BOTH = 'both';
1 week ago
public function bookingInfo(Request $request, int $activityId): JsonResponse
3 weeks ago
{
$activity = Activity::query()
3 weeks ago
->with(['venue:id,name,address,appointment_type', 'activityDays'])
2 weeks ago
->visibleOnH5()
3 weeks ago
->findOrFail($activityId);
3 weeks ago
$mode = $activity->booking_audience ?: self::BOOKING_MODE_BOTH;
if ($activity->venue && $activity->venue->appointment_type === 'team_only') {
$mode = self::BOOKING_MODE_GROUP;
}
if ($mode === self::BOOKING_MODE_INDIVIDUAL) {
$minPeople = 1;
$maxPeople = 1;
} else {
$minPeople = max(1, (int) ($activity->min_people_per_order ?? 1));
$maxPeople = max($minPeople, (int) ($activity->max_people_per_order ?? $minPeople));
}
1 week ago
$wechatUser = $this->authWechatUser($request);
$myReservedDayIds = [];
if ($wechatUser) {
$myReservedDayIds = Reservation::query()
->where('activity_id', $activity->id)
->where('status', '!=', 'cancelled')
1 week ago
->whereNotNull('activity_day_id')
1 week ago
->where(function ($sub) use ($wechatUser) {
$sub->where('wechat_user_id', $wechatUser->id);
$p = trim((string) ($wechatUser->phone ?? ''));
if ($p !== '' && preg_match('/^1\d{10}$/', $p)) {
$sub->orWhere('visitor_phone', $p);
}
})
->pluck('activity_day_id')
->map(fn ($id) => (int) $id)
->all();
}
$mySet = array_fill_keys($myReservedDayIds, true);
3 weeks ago
$days = $activity->activityDays
->values()
1 week ago
->map(function (ActivityDay $d) use ($mySet) {
$already = isset($mySet[$d->id]);
1 week ago
return $d->toH5BookingDayArray($already);
3 weeks ago
});
return response()->json([
'activity' => [
'id' => $activity->id,
'title' => $activity->title,
'image' => $activity->cover_image,
3 weeks ago
'booking_audience' => $mode,
'min_people_per_order' => $minPeople,
'max_people_per_order' => $maxPeople,
'booking_modes' => $this->bookingModesFor($mode),
3 weeks ago
'reservation_notice' => $activity->reservation_notice,
2 weeks ago
'start_at' => optional($activity->start_at)?->toIso8601String(),
'end_at' => optional($activity->end_at)?->toIso8601String(),
'schedule_status' => Activity::computeScheduleStatusFromBounds($activity->start_at, $activity->end_at),
3 weeks ago
'venue' => $activity->venue,
],
'days' => $days,
]);
}
1 week ago
public function create(Request $request, int $activityId, NoShowBlacklistService $noShowBlacklistService, ReservationExpiryService $reservationExpiryService): JsonResponse
3 weeks ago
{
1 week ago
$reservationExpiryService->expireStalePendingReservations();
3 weeks ago
$activity = Activity::query()
->with('venue:id,appointment_type')
2 weeks ago
->visibleOnH5()
3 weeks ago
->findOrFail($activityId);
3 weeks ago
$mode = $activity->booking_audience ?: self::BOOKING_MODE_BOTH;
3 weeks ago
if ($activity->venue && $activity->venue->appointment_type === 'team_only') {
$mode = self::BOOKING_MODE_GROUP;
}
if ($mode === self::BOOKING_MODE_INDIVIDUAL) {
$minPeople = 1;
$maxPeople = 1;
} else {
$minPeople = max(1, (int) ($activity->min_people_per_order ?? 1));
$maxPeople = max($minPeople, (int) ($activity->max_people_per_order ?? $minPeople));
}
3 weeks ago
$data = $request->validate([
'activity_day_id' => ['required', 'integer', 'exists:activity_days,id'],
'visitor_name' => ['required', 'string', 'max:80'],
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
// 新版:显式人数 + 类型;兼容旧版 ticket_mode
'booking_type' => ['nullable', 'in:individual,group'],
'people_count' => ['nullable', 'integer', 'min:1'],
'ticket_mode' => ['nullable', 'in:single,pair'],
]);
/** @var ActivityDay|null $day */
$day = ActivityDay::query()
->where('id', (int) $data['activity_day_id'])
->where('activity_id', $activity->id)
->first();
if (!$day) {
1 week ago
throw ValidationException::withMessages(['activity_day_id' => ['预约场次不存在或不属于该活动']]);
3 weeks ago
}
1 week ago
if ($day->isSessionMode()) {
if (now()->gt($day->booking_deadline_at)) {
throw ValidationException::withMessages(['activity_day_id' => ['该场次预约已截止']]);
}
} else {
if ($day->opens_at->gt(now())) {
$closeText = ($day->closes_at ?: $day->opens_at->copy()->endOfDay())->format('Y-m-d H:i');
throw ValidationException::withMessages(['activity_day_id' => ['该日期尚未开放预约,可预约时段:'.$day->opens_at->format('Y-m-d H:i').' ~ '.$closeText]]);
}
$closesAt = $day->closes_at ?: $day->opens_at->copy()->endOfDay();
if ($closesAt->lt(now())) {
throw ValidationException::withMessages(['activity_day_id' => ['该日期预约已截止,可预约时段:'.$day->opens_at->format('Y-m-d H:i').' ~ '.$closesAt->format('Y-m-d H:i')]]);
}
3 weeks ago
}
[$bookingType, $peopleCount] = $this->resolveBookingTypeAndPeopleCount($mode, $data, $minPeople, $maxPeople);
3 weeks ago
$wechatUser = $this->authWechatUser($request);
2 weeks ago
$tz = (string) config('app.timezone');
$noShowBlacklistService->syncForPhone((string) $data['visitor_phone']);
if (PhoneBookingBan::isGloballyBlocked((string) $data['visitor_phone'])) {
$until = PhoneBookingBan::activeBanUntil((string) $data['visitor_phone']);
$untilText = $until ? $until->timezone($tz)->format('Y-m-d') : '';
throw ValidationException::withMessages([
'visitor_phone' => [
'您因累计 3 次预约成功后未核销,已限制预约所有场馆活动至 '.$untilText.',期满后方可再次预约。',
],
]);
}
if (Blacklist::query()->where('venue_id', $activity->venue_id)->where('visitor_phone', $data['visitor_phone'])->exists()) {
throw ValidationException::withMessages([
'visitor_phone' => ['您已被该场馆限制预约,如有疑问请联系场馆。'],
]);
}
3 weeks ago
$reservation = DB::transaction(function () use ($activity, $day, $data, $peopleCount, $bookingType, $wechatUser) {
3 weeks ago
$day->refresh();
$available = (int) $day->day_quota - (int) $day->booked_count;
if ($available < $peopleCount) {
1 week ago
throw ValidationException::withMessages(['activity_day_id' => ['该场次余票不足']]);
3 weeks ago
}
3 weeks ago
// 去重:同一活动、同一活动日,同一用户只能有一单未取消预约(取消后可再约)
// — 按手机号;已登录时同时按微信用户,避免换手机号重复占坑
$dup = Reservation::query()
3 weeks ago
->where('activity_id', $activity->id)
->where('activity_day_id', $day->id)
->where('status', '!=', 'cancelled')
3 weeks ago
->where(function ($q) use ($data, $wechatUser) {
$q->where('visitor_phone', $data['visitor_phone']);
if ($wechatUser) {
$q->orWhere('wechat_user_id', $wechatUser->id);
}
})
3 weeks ago
->exists();
3 weeks ago
if ($dup) {
throw ValidationException::withMessages([
1 week ago
'activity_day_id' => ['您已预约过该场次,无需重复预约'],
3 weeks ago
]);
3 weeks ago
}
$row = Reservation::create([
'venue_id' => $activity->venue_id,
'activity_id' => $activity->id,
'activity_day_id' => $day->id,
3 weeks ago
'wechat_user_id' => $wechatUser?->id,
3 weeks ago
'visitor_name' => $data['visitor_name'],
'visitor_phone' => $data['visitor_phone'],
3 weeks ago
'id_card' => null,
3 weeks ago
'ticket_count' => $peopleCount,
'ticket_mode' => $data['ticket_mode'] ?? null,
'booking_type' => $bookingType,
'qr_token' => (string) Str::uuid(),
'status' => 'pending',
'reservation_source' => 'wechat_h5',
]);
$day->increment('booked_count', $peopleCount);
Activity::refreshRegisteredCountFromReservations($activity->id);
return $row;
});
3 weeks ago
$reservation->load([
'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng',
1 week ago
'venue:id,name,address,lat,lng',
1 week ago
'activityDay:id,activity_id,activity_date,session_name,session_start_at,session_end_at,booking_deadline_at',
3 weeks ago
]);
3 weeks ago
return response()->json([
'message' => '预约成功',
3 weeks ago
'reservation' => $this->reservationToH5Array($reservation),
3 weeks ago
], 201);
}
1 week ago
public function myReservations(Request $request, ReservationExpiryService $reservationExpiryService): JsonResponse
3 weeks ago
{
1 week ago
$reservationExpiryService->expireStalePendingReservations();
3 weeks ago
$wechatUser = $this->authWechatUser($request);
3 weeks ago
3 weeks ago
$query = Reservation::query()
->with([
'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng',
1 week ago
'ticketGrabEvent:id,title,summary,cover_image,start_at,end_at,tags',
1 week ago
'venue:id,name,address,lat,lng',
1 week ago
'activityDay:id,activity_id,activity_date,session_name,session_start_at,session_end_at,booking_deadline_at',
3 weeks ago
])
3 weeks ago
->orderByDesc('id')
3 weeks ago
->limit(200);
3 weeks ago
3 weeks ago
if ($wechatUser) {
$rows = (clone $query)->where('wechat_user_id', $wechatUser->id)->get();
} else {
$data = $request->validate([
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
]);
$rows = (clone $query)->where('visitor_phone', $data['visitor_phone'])->get();
}
return response()->json($rows->map(fn (Reservation $r) => $this->reservationToH5Array($r))->values());
3 weeks ago
}
1 week ago
public function detail(Request $request, int $reservationId, ReservationExpiryService $reservationExpiryService): JsonResponse
3 weeks ago
{
1 week ago
$reservationExpiryService->expireStalePendingReservations();
3 weeks ago
$wechatUser = $this->authWechatUser($request);
3 weeks ago
3 weeks ago
$q = Reservation::query()
->with([
'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng',
1 week ago
'ticketGrabEvent:id,title,summary,cover_image,start_at,end_at,tags',
1 week ago
'venue:id,name,address,lat,lng',
1 week ago
'activityDay:id,activity_id,activity_date,session_name,session_start_at,session_end_at,booking_deadline_at',
3 weeks ago
])
->where('id', $reservationId);
if ($wechatUser) {
$row = $q->where('wechat_user_id', $wechatUser->id)->firstOrFail();
} else {
$data = $request->validate([
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
]);
$row = $q->where('visitor_phone', $data['visitor_phone'])->firstOrFail();
}
3 weeks ago
3 weeks ago
return response()->json($this->reservationToH5Array($row));
3 weeks ago
}
public function cancel(Request $request, int $reservationId): JsonResponse
{
3 weeks ago
$wechatUser = $this->authWechatUser($request);
$q = Reservation::query()->where('id', $reservationId);
if ($wechatUser) {
$reservation = $q->where('wechat_user_id', $wechatUser->id)->firstOrFail();
} else {
$data = $request->validate([
'visitor_phone' => ['required', 'regex:/^1\d{10}$/'],
]);
$reservation = $q->where('visitor_phone', $data['visitor_phone'])->firstOrFail();
}
3 weeks ago
if ($reservation->status === 'verified') {
throw ValidationException::withMessages(['status' => ['已核销预约不可取消']]);
}
if ($reservation->status === 'cancelled') {
throw ValidationException::withMessages(['status' => ['该预约已取消']]);
}
1 week ago
if ($reservation->status === 'expired') {
throw ValidationException::withMessages(['status' => ['预约已过期,无法取消']]);
}
3 weeks ago
DB::transaction(function () use ($reservation) {
$reservation->status = 'cancelled';
$reservation->save();
if ($reservation->activity_day_id) {
$day = ActivityDay::query()->find($reservation->activity_day_id);
if ($day) {
$newBooked = max(0, (int) $day->booked_count - max(1, (int) ($reservation->ticket_count ?? 1)));
$day->booked_count = $newBooked;
$day->save();
}
}
1 week ago
if ((string) ($reservation->reservation_kind ?? 'activity') === Reservation::KIND_TICKET_GRAB
&& $reservation->ticket_grab_venue_release_day_id) {
$rd = TicketGrabVenueReleaseDay::query()->find($reservation->ticket_grab_venue_release_day_id);
if ($rd) {
$rd->booked_count = max(0, (int) $rd->booked_count - max(1, (int) ($reservation->ticket_count ?? 1)));
$rd->save();
}
}
3 weeks ago
if ($reservation->activity_id) {
Activity::refreshRegisteredCountFromReservations($reservation->activity_id);
}
1 week ago
if ($reservation->ticket_grab_event_id) {
TicketGrabEvent::refreshRegisteredCountFromReservations((int) $reservation->ticket_grab_event_id);
}
3 weeks ago
});
return response()->json(['message' => '取消成功']);
}
/**
* @return array<int, string>
*/
private function bookingModesFor(string $mode): array
{
if ($mode === self::BOOKING_MODE_INDIVIDUAL) return ['individual'];
if ($mode === self::BOOKING_MODE_GROUP) return ['group'];
return ['individual', 'group'];
}
/**
* @param array<string, mixed> $data
* @return array{0: string, 1: int} booking_type, people_count
*/
private function resolveBookingTypeAndPeopleCount(string $mode, array $data, int $minPeople, int $maxPeople): array
{
$bookingType = (string) ($data['booking_type'] ?? '');
$peopleCount = (int) ($data['people_count'] ?? 0);
// Backward compatible: ticket_mode -> people_count
if ($peopleCount <= 0) {
$ticketMode = (string) ($data['ticket_mode'] ?? '');
if ($ticketMode === 'pair') $peopleCount = 2;
else $peopleCount = 1;
}
// Infer booking_type if not provided.
if ($bookingType !== 'individual' && $bookingType !== 'group') {
$bookingType = ($peopleCount > 1 ? 'group' : 'individual');
}
if ($mode === self::BOOKING_MODE_INDIVIDUAL) {
if ($bookingType !== 'individual' || $peopleCount !== 1) {
throw ValidationException::withMessages(['people_count' => ['该活动仅支持个人预约(人数=1']]);
}
return [$bookingType, 1];
}
if ($mode === self::BOOKING_MODE_GROUP) {
if ($bookingType !== 'group') {
throw ValidationException::withMessages(['booking_type' => ['该活动仅支持团体预约']]);
}
if ($peopleCount < $minPeople || $peopleCount > $maxPeople) {
throw ValidationException::withMessages(['people_count' => ["团体人数需在 {$minPeople}-{$maxPeople} 人之间"]]);
}
return [$bookingType, $peopleCount];
}
// both: individual must be 1; group must be within min/max
if ($bookingType === 'individual') {
if ($peopleCount !== 1) {
throw ValidationException::withMessages(['people_count' => ['个人预约人数固定为 1 人']]);
}
return [$bookingType, 1];
}
if ($peopleCount < $minPeople || $peopleCount > $maxPeople) {
throw ValidationException::withMessages(['people_count' => ["团体人数需在 {$minPeople}-{$maxPeople} 人之间"]]);
}
return ['group', $peopleCount];
}
3 weeks ago
private function authWechatUser(Request $request): ?WechatUser
{
$token = $request->bearerToken();
if (!$token) {
return null;
}
$accessToken = PersonalAccessToken::findToken($token);
if (!$accessToken || ! ($accessToken->tokenable instanceof WechatUser)) {
return null;
}
return $accessToken->tokenable;
}
/**
* @return array<string, mixed>
*/
1 week ago
public function reservationToH5Array(Reservation $r): array
3 weeks ago
{
$a = $r->relationLoaded('activity') ? $r->activity : $r->activity()->first();
$day = $r->relationLoaded('activityDay') ? $r->activityDay : $r->activityDay()->first();
$venue = $r->relationLoaded('venue') ? $r->venue : $r->venue()->first();
1 week ago
$kind = (string) ($r->reservation_kind ?? 'activity');
$tg = null;
if ($kind === Reservation::KIND_TICKET_GRAB) {
$tg = $r->relationLoaded('ticketGrabEvent') ? $r->ticketGrabEvent : $r->ticketGrabEvent()->first();
}
3 weeks ago
1 week ago
$activityPayload = null;
$activityDayPayload = null;
if ($kind === Reservation::KIND_TICKET_GRAB && $tg) {
$addr = trim((string) ($tg->address ?? '')) !== '' ? (string) $tg->address : (string) ($venue?->address ?? '');
$lat = $venue?->lat;
$lng = $venue?->lng;
$activityPayload = [
'id' => (int) $tg->id,
'title' => $tg->title,
'summary' => $tg->summary,
'cover_image' => $tg->cover_image,
'address' => $addr,
'lat' => $lat !== null ? (float) $lat : null,
'lng' => $lng !== null ? (float) $lng : null,
'start_at' => $tg->start_at
? (string) CalendarDateFormat::ymdFromDatetime($tg->start_at) : null,
'end_at' => $tg->end_at
? (string) CalendarDateFormat::ymdFromDatetime($tg->end_at) : null,
'tags' => array_values($tg->tags ?? []),
];
if ($r->entry_date) {
$ed = $r->entry_date;
$edStr = $ed instanceof \Carbon\CarbonInterface
? $ed->format('Y-m-d') : (string) $ed;
$activityDayPayload = [
'id' => 0,
'activity_date' => $edStr,
'session_name' => '入馆日',
'session_start_at' => null,
'session_end_at' => null,
'time_range_text' => '入馆日 '.$edStr,
];
}
} else {
$activityPayload = $a ? [
3 weeks ago
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'cover_image' => $a->cover_image,
'address' => $a->address,
'lat' => $a->lat !== null ? (float) $a->lat : null,
'lng' => $a->lng !== null ? (float) $a->lng : null,
'start_at' => $a->start_at?->format('Y-m-d H:i:s'),
'end_at' => $a->end_at?->format('Y-m-d H:i:s'),
'tags' => array_values($a->tags ?? []),
1 week ago
] : null;
$activityDayPayload = $day && $day->activity_date ? [
3 weeks ago
'id' => $day->id,
'activity_date' => $day->activity_date->format('Y-m-d'),
1 week ago
'session_name' => (string) ($day->session_name ?? ''),
'session_start_at' => $day->session_start_at?->format('Y-m-d H:i:s'),
'session_end_at' => $day->session_end_at?->format('Y-m-d H:i:s'),
'time_range_text' => $day->formatSessionTimeRangeZh(),
1 week ago
] : null;
}
return [
'id' => $r->id,
'reservation_kind' => $kind,
'status' => $r->status,
'visitor_name' => $r->visitor_name,
'visitor_phone' => $r->visitor_phone,
'id_card' => $r->id_card,
'ticket_count' => (int) ($r->ticket_count ?? 1),
'ticket_mode' => $r->ticket_mode,
'booking_type' => $r->booking_type,
'qr_token' => $r->qr_token,
'created_at' => $r->created_at?->format('Y-m-d H:i:s'),
'verified_at' => $r->verified_at?->format('Y-m-d H:i:s'),
'wechat_user_id' => $r->wechat_user_id,
'entry_date' => $r->entry_date?->toDateString(),
'activity' => $activityPayload,
'activity_day' => $activityDayPayload,
3 weeks ago
'venue' => $venue ? [
'id' => $venue->id,
'name' => $venue->name,
'address' => $venue->address,
1 week ago
'lat' => $venue->lat !== null ? (float) $venue->lat : null,
'lng' => $venue->lng !== null ? (float) $venue->lng : null,
3 weeks ago
] : null,
1 week ago
'ticket_grab_event' => $tg ? [
'id' => $tg->id,
'title' => $tg->title,
'summary' => $tg->summary,
'cover_image' => $tg->cover_image,
1 week ago
'address' => $tg->address,
1 week ago
'start_at' => CalendarDateFormat::ymdFromDatetime($tg->start_at),
'end_at' => CalendarDateFormat::ymdFromDatetime($tg->end_at),
'tags' => array_values($tg->tags ?? []),
] : null,
3 weeks ago
];
}
3 weeks ago
}