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

6 days ago
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\ActivityDay;
use App\Models\Reservation;
6 days ago
use App\Models\WechatUser;
6 days ago
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
6 days ago
use Laravel\Sanctum\PersonalAccessToken;
6 days ago
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' => ['该手机号在该活动日期已预约过']]);
}
6 days ago
$wechatUser = $this->authWechatUser($request);
6 days ago
$row = Reservation::create([
'venue_id' => $activity->venue_id,
'activity_id' => $activity->id,
'activity_day_id' => $day->id,
6 days ago
'wechat_user_id' => $wechatUser?->id,
6 days ago
'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;
});
6 days ago
$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',
]);
6 days ago
return response()->json([
'message' => '预约成功',
6 days ago
'reservation' => $this->reservationToH5Array($reservation),
6 days ago
], 201);
}
public function myReservations(Request $request): JsonResponse
{
6 days ago
$wechatUser = $this->authWechatUser($request);
6 days ago
6 days ago
$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',
])
6 days ago
->orderByDesc('id')
6 days ago
->limit(200);
6 days ago
6 days 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());
6 days ago
}
public function detail(Request $request, int $reservationId): JsonResponse
{
6 days ago
$wechatUser = $this->authWechatUser($request);
6 days ago
6 days ago
$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();
}
6 days ago
6 days ago
return response()->json($this->reservationToH5Array($row));
6 days ago
}
public function cancel(Request $request, int $reservationId): JsonResponse
{
6 days 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();
}
6 days ago
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];
}
6 days 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>
*/
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,
];
}
6 days ago
}