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.

383 lines
16 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\ActivityDay;
use App\Models\Reservation;
use App\Models\WechatUser;
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 H5ReservationController extends Controller
{
private const BOOKING_MODE_INDIVIDUAL = 'individual';
private const BOOKING_MODE_GROUP = 'group';
private const BOOKING_MODE_BOTH = 'both';
public function bookingInfo(int $activityId): JsonResponse
{
$activity = Activity::query()
->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<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];
}
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>
*/
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,
];
}
}