with(['venue:id,name,address', 'activityDays']) ->where('is_active', true) ->findOrFail($activityId); $days = $activity->activityDays ->sortBy('activity_date') ->values() ->map(function (ActivityDay $d) { $available = max(0, (int) $d->day_quota - (int) $d->booked_count); $closesAt = $d->closes_at ?: $d->opens_at->copy()->endOfDay(); $now = now(); $isOpenWindow = $d->opens_at->lte($now) && $closesAt->gte($now); $isBookable = $d->isCurrentlyBookable(); $unavailableReason = null; if ($available <= 0) { $unavailableReason = 'sold_out'; } elseif ($d->opens_at->gt($now)) { $unavailableReason = 'not_started'; } elseif ($closesAt->lt($now)) { $unavailableReason = 'closed'; } return [ 'id' => $d->id, 'activity_date' => $d->activity_date->format('Y-m-d'), 'opens_at' => $d->opens_at->format('Y-m-d H:i:s'), 'closes_at' => $closesAt->format('Y-m-d H:i:s'), 'day_quota' => (int) $d->day_quota, 'booked_count' => (int) $d->booked_count, 'available_count' => $available, 'is_open' => $isOpenWindow, 'is_bookable' => $isBookable, 'unavailable_reason' => $unavailableReason, ]; }); return response()->json([ 'activity' => [ 'id' => $activity->id, 'title' => $activity->title, 'image' => $activity->cover_image, 'booking_audience' => $activity->booking_audience ?: self::BOOKING_MODE_BOTH, 'min_people_per_order' => max(1, (int) ($activity->min_people_per_order ?? 1)), 'max_people_per_order' => max(1, (int) ($activity->max_people_per_order ?? 1)), 'booking_modes' => $this->bookingModesFor($activity->booking_audience ?: self::BOOKING_MODE_BOTH), 'reservation_notice' => $activity->reservation_notice, 'venue' => $activity->venue, ], 'days' => $days, ]); } public function create(Request $request, int $activityId): JsonResponse { $activity = Activity::query()->where('is_active', true)->findOrFail($activityId); $mode = $activity->booking_audience ?: self::BOOKING_MODE_BOTH; $minPeople = max(1, (int) ($activity->min_people_per_order ?? 1)); $maxPeople = max(1, (int) ($activity->max_people_per_order ?? 1)); $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}$/'], 'id_card' => ['nullable', 'string', 'max:18'], // 新版:显式人数 + 类型;兼容旧版 ticket_mode 'booking_type' => ['nullable', 'in:individual,group'], 'people_count' => ['nullable', 'integer', 'min:1'], 'ticket_mode' => ['nullable', 'in:single,pair'], ]); if (!empty($data['id_card']) && !preg_match('/^\d{17}[\dXx]$/', $data['id_card'])) { throw ValidationException::withMessages(['id_card' => ['身份证号格式不正确']]); } /** @var ActivityDay|null $day */ $day = ActivityDay::query() ->where('id', (int) $data['activity_day_id']) ->where('activity_id', $activity->id) ->first(); if (!$day) { throw ValidationException::withMessages(['activity_day_id' => ['预约日期不存在或不属于该活动']]); } 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')]]); } [$bookingType, $peopleCount] = $this->resolveBookingTypeAndPeopleCount($mode, $data, $minPeople, $maxPeople); $reservation = DB::transaction(function () use ($activity, $day, $data, $peopleCount, $bookingType) { $day->refresh(); $available = (int) $day->day_quota - (int) $day->booked_count; if ($available < $peopleCount) { throw ValidationException::withMessages(['activity_day_id' => ['该日期余票不足']]); } // 去重规则:同活动同活动日同手机号只能预约一单(未取消) $dupByPhone = Reservation::query() ->where('activity_id', $activity->id) ->where('activity_day_id', $day->id) ->where('visitor_phone', $data['visitor_phone']) ->where('status', '!=', 'cancelled') ->exists(); if ($dupByPhone) { throw ValidationException::withMessages(['visitor_phone' => ['该手机号在该活动日期已预约过']]); } $wechatUser = $this->authWechatUser($request); $row = Reservation::create([ 'venue_id' => $activity->venue_id, 'activity_id' => $activity->id, 'activity_day_id' => $day->id, 'wechat_user_id' => $wechatUser?->id, 'visitor_name' => $data['visitor_name'], 'visitor_phone' => $data['visitor_phone'], 'id_card' => $data['id_card'] ?? null, '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; }); $reservation->load([ 'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng', 'venue:id,name,address', 'activityDay:id,activity_id,activity_date', ]); return response()->json([ 'message' => '预约成功', 'reservation' => $this->reservationToH5Array($reservation), ], 201); } public function myReservations(Request $request): JsonResponse { $wechatUser = $this->authWechatUser($request); $query = Reservation::query() ->with([ 'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng', 'venue:id,name,address', 'activityDay:id,activity_id,activity_date', ]) ->orderByDesc('id') ->limit(200); 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()); } public function detail(Request $request, int $reservationId): JsonResponse { $wechatUser = $this->authWechatUser($request); $q = Reservation::query() ->with([ 'activity:id,title,summary,cover_image,address,start_at,end_at,tags,lat,lng', 'venue:id,name,address', 'activityDay:id,activity_id,activity_date', ]) ->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(); } return response()->json($this->reservationToH5Array($row)); } public function cancel(Request $request, int $reservationId): JsonResponse { $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(); } if ($reservation->status === 'verified') { throw ValidationException::withMessages(['status' => ['已核销预约不可取消']]); } if ($reservation->status === 'cancelled') { throw ValidationException::withMessages(['status' => ['该预约已取消']]); } 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(); } } if ($reservation->activity_id) { Activity::refreshRegisteredCountFromReservations($reservation->activity_id); } }); return response()->json(['message' => '取消成功']); } /** * @return array */ 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 $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]; } 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 */ private function reservationToH5Array(Reservation $r): array { $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(); return [ 'id' => $r->id, '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), '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, 'activity' => $a ? [ '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 ?? []), ] : null, 'activity_day' => $day && $day->activity_date ? [ 'id' => $day->id, 'activity_date' => $day->activity_date->format('Y-m-d'), ] : null, 'venue' => $venue ? [ 'id' => $venue->id, 'name' => $venue->name, 'address' => $venue->address, ] : null, ]; } }