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.

864 lines
33 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\DictItem;
use App\Models\Reservation;
use App\Models\StudyTour;
use App\Support\StudyTourPayload;
use App\Models\TicketGrabEvent;
use App\Models\TicketGrabEventVenue;
use App\Models\Venue;
use App\Models\WechatUser;
use App\Support\ActivityH5View;
use App\Support\CalendarDateFormat;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Laravel\Sanctum\PersonalAccessToken;
class H5ContentController extends Controller
{
public function activities(Request $request): JsonResponse
{
Activity::clearHotFlagsForEndedActivities();
if ($request->boolean('include_ticket_grab')) {
return $this->mixedActivities($request);
}
$size = max(1, min(30, (int) $request->input('page_size', 10)));
$rows = Activity::query()
->with(['venue:'.Venue::referenceNameSelectString(['lat', 'lng', 'cover_image']), 'activityDays'])
->visibleOnH5()
->when($request->filled('keyword'), function ($q) use ($request) {
$keyword = trim((string) $request->input('keyword'));
$q->where('title', 'like', "%{$keyword}%");
})
->when($request->filled('schedule_status'), function ($q) use ($request) {
$st = trim((string) $request->input('schedule_status'));
if (in_array($st, ['not_started', 'ongoing', 'ended'], true)) {
$q->whereComputedScheduleStatus($st);
}
})
->orderByHotThenH5ActivityListRecentEnded()
->paginate($size);
$rows->getCollection()->transform(function ($a) {
if ($a->venue instanceof Venue) {
$a->venue->applyDisplayNameForReference();
}
$online = $this->activitySupportsOnlineBooking($a);
$isBookable = $online && $a->activityDays->contains(
fn (ActivityDay $d) => $d->isCurrentlyBookable()
);
return [
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'image' => ActivityH5View::listCover($a),
'venue_name' => $a->venue?->name,
'address' => $a->location ?: $a->address,
'lat' => $a->lat,
'lng' => $a->lng,
'venue_lat' => $a->venue?->lat,
'venue_lng' => $a->venue?->lng,
'start_at' => CalendarDateFormat::ymdFromDateValue($a->start_at),
'end_at' => CalendarDateFormat::ymdFromDateValue($a->end_at),
'schedule_status' => Activity::computeScheduleStatusForDisplay($a),
'registered_count' => (int) ($a->registered_count ?? 0),
'tags' => array_values($a->tags ?? []),
'is_bookable' => $isBookable,
'all_slots_full' => $a->areAllActivityDaySlotsExhausted(),
'booking_closed_not_full' => $online && $a->isBookingClosedWithRemainingCapacity(),
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
'offline_reservation_method' => (string) ($a->offline_reservation_method ?? ''),
'ticket_fee_note' => (string) ($a->ticket_fee_note ?? ''),
'has_behind_scenes' => $this->activityHasBehindScenes($a),
'is_hot' => (bool) ($a->is_hot ?? false),
];
});
return response()->json($rows);
}
public function activityDetail(Request $request, int $id): JsonResponse
{
$a = Activity::query()
->with([
'venue:'.Venue::referenceNameSelectString([
'address', 'lat', 'lng', 'open_time', 'district', 'venue_type', 'venue_types',
'ticket_type', 'unit_name', 'contact_phone', 'cover_image', 'gallery_media',
]),
'activityDays',
])
->visibleOnH5()
->findOrFail($id);
$onlineBookable = $this->activitySupportsOnlineBooking($a);
$isBookable = $onlineBookable && $a->activityDays->contains(
fn (ActivityDay $d) => $d->isCurrentlyBookable()
);
$wechatUser = $this->authWechatUser($request);
$myReservedDayIds = [];
if ($wechatUser) {
$myReservedDayIds = Reservation::query()
->where('activity_id', $a->id)
->where('status', '!=', 'cancelled')
->whereNotNull('activity_day_id')
->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 ($rowId) => (int) $rowId)
->all();
}
$mySet = array_fill_keys($myReservedDayIds, true);
$bookingDaysRaw = $a->activityDays
->filter(fn (ActivityDay $d) => $d->isSessionMode())
->values();
$allowPlatformBooking = $this->activitySupportsOnlineBooking($a);
$bookingDays = $bookingDaysRaw
->map(function (ActivityDay $d) use ($mySet, $allowPlatformBooking) {
$already = isset($mySet[$d->id]);
return $d->toH5BookingDayArray($already, $allowPlatformBooking);
});
$cover = ActivityH5View::listCover($a);
$activityCoverOnly = trim((string) ($a->cover_image ?? ''));
$payload = [
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'detail_html' => $a->detail_html,
'image' => $cover,
// 仅活动后台配置的封面;分享等场景不用场馆兜底(与 image 区分开)
'cover_image' => $activityCoverOnly !== '' ? $activityCoverOnly : null,
'gallery_media' => $a->gallery_media ?? [],
'carousel' => ActivityH5View::buildActivityH5Carousel($a),
'venue' => $a->venue,
'location' => $a->location,
'check_in_meeting_point' => $a->check_in_meeting_point,
'address' => $a->location ?: $a->address,
'contact_name' => $a->contact_name,
'contact_phone' => $a->contact_phone,
'lat' => $a->lat,
'lng' => $a->lng,
'start_at' => CalendarDateFormat::ymdFromDateValue($a->start_at),
'end_at' => CalendarDateFormat::ymdFromDateValue($a->end_at),
'schedule_status' => Activity::computeScheduleStatusForDisplay($a),
'registered_count' => (int) ($a->registered_count ?? 0),
'tags' => array_values($a->tags ?? []),
'reservation_notice' => $a->reservation_notice,
'is_bookable' => $isBookable,
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
'reservation_type_label' => $this->reservationTypeLabel($a->reservation_type),
'specific_time' => $a->specific_time,
'offline_reservation_method' => $a->offline_reservation_method,
'booking_method_note' => (string) ($a->booking_method_note ?? ''),
'ticket_fee_note' => $a->ticket_fee_note,
'age_group' => $a->age_group,
'age_group_label' => DictItem::labelFor('age_groups', $a->age_group),
'external_url' => $a->external_url,
'view_count' => (int) ($a->view_count ?? 0),
'external_link_click_count' => (int) ($a->external_link_click_count ?? 0),
'venue_type_color' => $this->resolveVenueTypeColor($a->venue?->venue_type, $a->venue?->venue_types),
'booking_days' => $bookingDays->isNotEmpty() ? $bookingDays->all() : [],
'behind_scenes_media' => $this->normalizeBehindScenesForH5(is_array($a->behind_scenes_media) ? $a->behind_scenes_media : []),
'has_behind_scenes' => $this->activityHasBehindScenes($a),
];
if ($this->activitySupportsOnlineBooking($a)) {
$payload['registrations_preview'] = $this->buildActivityRegistrationsPreview($a);
}
return response()->json($payload);
}
/**
* 活动公开报名名单(仅线上预约活动;不含已取消)
*/
public function activityRegistrations(Request $request, int $id): JsonResponse
{
$activity = Activity::query()->visibleOnH5()->findOrFail($id);
if (! $this->activitySupportsOnlineBooking($activity)) {
return response()->json([
'activity_title' => $activity->title,
'total' => 0,
'current_page' => 1,
'last_page' => 1,
'data' => [],
]);
}
$pageSize = max(1, min(50, (int) $request->input('page_size', 20)));
$page = max(1, (int) $request->input('page', 1));
$base = $this->activityRegistrationsBaseQuery($activity);
$total = (clone $base)->count();
$lastPage = $total > 0 ? (int) ceil($total / $pageSize) : 1;
$rows = (clone $base)
->with([
'wechatUser:id,avatar_url,nickname,real_name',
'activityDay',
])
->orderByDesc('id')
->forPage($page, $pageSize)
->get();
$data = $rows->map(fn (Reservation $r) => $this->mapReservationToH5Public($r, true))->values()->all();
return response()->json([
'activity_title' => $activity->title,
'total' => $total,
'current_page' => $page,
'last_page' => $lastPage,
'data' => $data,
]);
}
/**
* @return array{total: int, reservation_total: int, items: list<array<string, mixed>>}
*/
private function buildActivityRegistrationsPreview(Activity $activity): array
{
$base = $this->activityRegistrationsBaseQuery($activity);
$reservationTotal = (clone $base)->count();
$rows = (clone $base)
->with(['wechatUser:id,avatar_url,nickname,real_name'])
->orderByDesc('id')
->get();
$seen = [];
$items = [];
foreach ($rows as $r) {
$key = $this->reservationUserKey($r);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$items[] = $this->mapReservationToH5Public($r, false);
if (count($items) >= 5) {
break;
}
}
return [
'total' => $reservationTotal,
'reservation_total' => $reservationTotal,
'items' => $items,
];
}
private function reservationUserKey(Reservation $r): string
{
if ($r->wechat_user_id) {
return 'w:'.(int) $r->wechat_user_id;
}
$phone = preg_replace('/\s+/', '', (string) $r->visitor_phone);
return 'p:'.strtolower($phone);
}
private function activityRegistrationsBaseQuery(Activity $activity)
{
return Reservation::query()
->where('activity_id', $activity->id)
->where(function ($q) {
$q->whereNull('reservation_kind')
->orWhere('reservation_kind', Reservation::KIND_ACTIVITY);
})
->where('status', '!=', 'cancelled');
}
/**
* @return array<string, mixed>
*/
private function mapReservationToH5Public(Reservation $r, bool $withSession): array
{
$wu = $r->wechatUser;
$name = trim((string) $r->visitor_name);
if ($name === '' && $wu) {
$name = trim((string) ($wu->real_name ?? '')) ?: trim((string) ($wu->nickname ?? ''));
}
if ($name === '') {
$name = '用户';
}
$avatar = ($wu && $wu->avatar_url) ? (string) $wu->avatar_url : '';
$out = [
'id' => (int) $r->id,
'visitor_name' => $name,
'avatar_url' => $avatar,
'created_at' => $r->created_at?->toIso8601String(),
];
if ($withSession) {
$d = $r->activityDay;
if ($d) {
$timeText = $d->time_range_text;
if ($timeText === '' || $timeText === null) {
$timeText = $d->formatSessionTimeRangeZh();
}
$sessionName = trim((string) $d->session_name);
$out['session_name'] = $sessionName;
$out['session_time_text'] = $timeText;
$out['activity_date'] = $d->activity_date?->format('Y-m-d');
} else {
$out['session_name'] = null;
$out['session_time_text'] = null;
$out['activity_date'] = null;
}
}
return $out;
}
public function recordActivityView(Request $request, int $id): JsonResponse
{
$a = Activity::query()->visibleOnH5()->findOrFail($id);
$a->increment('view_count');
return response()->json([
'ok' => true,
'view_count' => (int) $a->fresh()->view_count,
]);
}
public function recordActivityExternalLinkClick(Request $request, int $id): JsonResponse
{
$a = Activity::query()->visibleOnH5()->findOrFail($id);
$a->increment('external_link_click_count');
return response()->json([
'ok' => true,
'external_link_click_count' => (int) $a->fresh()->external_link_click_count,
]);
}
public function venues(Request $request): JsonResponse
{
$query = Venue::query()
->visibleOnH5()
->orderBy('sort')
->orderByDesc('id');
// 只返回纳入人数统计的场馆(用于客流量统计页面)
if ($request->boolean('only_included_in_stats')) {
$query->where('is_included_in_stats', true);
}
$rows = $query->get()
->map(function (Venue $v) {
$p = $v->toH5Payload();
if ($p === null) {
return null;
}
return [
'id' => $p['id'],
'name' => $p['name'],
'sort' => (int) ($p['sort'] ?? 0),
'district' => $p['district'],
'address' => $p['address'],
'lat' => $p['lat'],
'lng' => $p['lng'],
'cover_image' => $p['cover_image'],
'open_time' => $p['open_time'],
'venue_type' => $p['venue_type'],
'venue_types' => is_array($p['venue_types'] ?? null) ? array_values($p['venue_types']) : [],
'ticket_type' => $p['ticket_type'],
'appointment_type' => $p['appointment_type'],
'booking_mode' => $p['booking_mode'] ?? null,
'open_mode' => $p['open_mode'] ?? null,
'is_included_in_stats' => (bool) ($p['is_included_in_stats'] ?? false),
];
})
->filter()
->values();
return response()->json($rows);
}
public function venueDetail(Request $request, int $id): JsonResponse
{
$v = Venue::query()->find($id);
if ($v === null) {
return response()->json(['message' => '场馆不存在'], 404);
}
$merged = $v->toH5Payload();
if ($merged === null) {
return response()->json(['message' => '场馆不存在'], 404);
}
$display = Venue::make($merged);
$display->id = $v->id;
$display->exists = true;
$payload = $display->toArray();
unset($payload['audit_status'], $payload['audit_remark'], $payload['last_approved_snapshot']);
$payload['carousel'] = ActivityH5View::buildGalleryCarousel($display);
$payload['live_people_count'] = (int) ($display->live_people_count ?? 0);
$payload['venue_type_color'] = $this->resolveVenueTypeColor($display->venue_type, $display->venue_types);
$eid = (int) $request->query('ticket_grab_event_id', 0);
if ($eid > 0) {
$p = TicketGrabEventVenue::query()
->where('ticket_grab_event_id', $eid)
->where('venue_id', $v->id)
->first();
if ($p === null) {
return response()->json(['message' => '该抢票活动未关联此场馆'], 404);
}
$venueOpenTimeForDaily = $payload['open_time'] ?? null;
$payload = $this->mergeTicketGrabVenueIntoH5Payload($payload, $p);
$vo = is_string($venueOpenTimeForDaily) ? trim($venueOpenTimeForDaily) : '';
$payload['venue_open_time'] = $vo !== '' ? $venueOpenTimeForDaily : null;
}
$payload['activities'] = Activity::query()
->where('venue_id', $v->id)
->visibleOnH5()
->orderByHotThenH5ActivityListRecentEnded()
->with('activityDays')
->with('venue:id,cover_image')
->limit(50)
->get(['id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address', 'location', 'venue_id', 'behind_scenes_media', 'is_hot', 'reservation_type'])
->map(function (Activity $a) {
return [
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'cover_image' => ActivityH5View::listCover($a),
'start_at' => CalendarDateFormat::ymdFromDateValue($a->start_at),
'end_at' => CalendarDateFormat::ymdFromDateValue($a->end_at),
'schedule_status' => Activity::computeScheduleStatusForDisplay($a),
'registered_count' => (int) ($a->registered_count ?? 0),
'address' => $a->location ?: $a->address,
'has_behind_scenes' => $this->activityHasBehindScenes($a),
];
})
->values()
->all();
return response()->json($payload);
}
/**
* 抢票进馆页:主卡/详情区仅使用「抢票-参与场馆-编辑详情」的 pivot 字段,无值则为 null不回落场馆主表。
* 轮播 carousel、封面、场馆名称等入参前已按场馆主表算好在此方法外保持不变。
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function mergeTicketGrabVenueIntoH5Payload(array $payload, TicketGrabEventVenue $p): array
{
$s = function (?string $t): ?string {
if ($t === null) {
return null;
}
$t = trim($t);
return $t === '' ? null : $t;
};
$payload['open_time'] = $s(is_string($p->opening_hours) ? $p->opening_hours : null);
$payload['address'] = $s(is_string($p->address) ? $p->address : null);
$payload['lat'] = $p->lat === null ? null : (float) $p->lat;
$payload['lng'] = $p->lng === null ? null : (float) $p->lng;
$payload['unit_name'] = $s(is_string($p->unit_name) ? $p->unit_name : null);
$payload['contact_name'] = $s(is_string($p->contact_name) ? $p->contact_name : null);
$payload['contact_phone'] = $s(is_string($p->contact_phone) ? $p->contact_phone : null);
$payload['qr_verify_method'] = $s(is_string($p->qr_verify_method) ? $p->qr_verify_method : null);
$payload['verify_contact_info'] = $s(is_string($p->verify_contact_info) ? $p->verify_contact_info : null);
if (is_string($p->detail_html) && trim($p->detail_html) !== '') {
$payload['detail_html'] = $p->detail_html;
} else {
$payload['detail_html'] = null;
}
$payload['consultation_hours'] = null;
return $payload;
}
private function activityHasBehindScenes(Activity $a): bool
{
$m = $a->behind_scenes_media;
if (! is_array($m)) {
return false;
}
foreach ($m as $row) {
if (is_array($row) && trim((string) ($row['url'] ?? '')) !== '') {
return true;
}
}
return false;
}
/**
* @param list<array<string, mixed>> $raw
* @return list<array{type: string, url: string}>
*/
private function normalizeBehindScenesForH5(array $raw): array
{
$out = [];
foreach ($raw as $row) {
if (! is_array($row)) {
continue;
}
$url = trim((string) ($row['url'] ?? ''));
if ($url === '') {
continue;
}
$out[] = ['type' => 'image', 'url' => $url];
}
return $out;
}
private function activitySupportsOnlineBooking(Activity $a): bool
{
$t = (string) ($a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE);
return $t === '' || $t === Activity::RESERVATION_TYPE_ONLINE;
}
private function reservationTypeLabel(?string $type): string
{
$t = trim((string) ($type ?? ''));
return match ($t) {
Activity::RESERVATION_TYPE_OFFLINE => '线下预约',
Activity::RESERVATION_TYPE_OFFLINE_VISIT => '线下预约',
Activity::RESERVATION_TYPE_OTHER => '外链跳转预约',
Activity::RESERVATION_TYPE_PHONE => '电话预约',
Activity::RESERVATION_TYPE_WECHAT_MP => '公众号预约',
Activity::RESERVATION_TYPE_NONE => '无需平台预约公益性科普活动',
Activity::RESERVATION_TYPE_PAID_STUDY => '收费科普研学活动',
Activity::RESERVATION_TYPE_ONLINE => '公益性需预约活动',
'' => '公益性需预约活动',
default => $t,
};
}
/**
* 与首页地图场馆一致:字典 venue_type 的 item_remark 为色值。
*
* @param array<int, mixed>|null $venueTypes
*/
private function resolveVenueTypeColor(?string $venueType, $venueTypes = null): string
{
$first = $venueType;
if (is_array($venueTypes) && count($venueTypes)) {
$first = (string) ($venueTypes[0] ?? '');
}
if ($first === null || $first === '') {
return '#05c9ac';
}
$raw = DictItem::query()
->where('dict_type', 'venue_type')
->where('is_active', true)
->where('item_value', $first)
->value('item_remark');
$color = '#05c9ac';
if (is_string($raw) && trim($raw) !== '') {
$t = trim($raw);
if (! str_starts_with($t, '#')) {
$t = '#'.$t;
}
if (preg_match('/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/', $t)) {
$color = $t;
}
}
return $color;
}
public function studyTourDetail(int $id): JsonResponse
{
$row = StudyTour::query()
->where('is_on_shelf', true)
->findOrFail($id);
return response()->json(StudyTourPayload::h5DetailPayload(
$row,
fn (?string $venueType, ?array $venueTypes) => $this->resolveVenueTypeColor($venueType, $venueTypes)
));
}
public function studyTours(Request $request): JsonResponse
{
$q = StudyTour::query()->where('is_on_shelf', true);
StudyTourPayload::applyListFilters($q, $request);
$rows = $q->orderBy('sort')
->orderByDesc('id')
->get(['id', 'name', 'tags', 'venue_ids', 'venue_items', 'org_name', 'seasons', 'grade_levels', 'intro_html', 'cover_image', 'gallery_media']);
$venueIds = $rows
->flatMap(fn (StudyTour $row) => StudyTourPayload::venueIdsFromItems(StudyTourPayload::venueItemsForRecord($row)))
->unique()
->values()
->all();
$venueMap = Venue::query()
->whereIn('id', $venueIds)
->get(['id', 'name', 'district', 'address', 'cover_image', 'lat', 'lng'])
->keyBy('id');
$payload = $rows
->map(fn (StudyTour $row) => StudyTourPayload::h5ListPayload($row, $venueMap))
->values();
return response()->json($payload);
}
public function venueDicts(): JsonResponse
{
return response()->json([
'district' => DictItem::activeOptions('district'),
'venue_type' => DictItem::activeVenueTypeOptionsWithColor(),
'venue_appointment_type' => DictItem::activeOptions('venue_appointment_type'),
'venue_booking_mode' => DictItem::activeOptions('venue_booking_mode'),
'venue_open_mode' => DictItem::activeOptions('venue_open_mode'),
'ticket_type' => DictItem::activeOptions('ticket_type'),
]);
}
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;
}
public function mixedActivities(Request $request): JsonResponse
{
$size = max(1, min(30, (int) $request->input('page_size', 10)));
$page = max(1, (int) $request->input('page', 1));
$keyword = $request->filled('keyword') ? trim((string) $request->input('keyword')) : null;
$schedule = $request->filled('schedule_status') ? trim((string) $request->input('schedule_status')) : null;
if ($schedule && ! in_array($schedule, ['not_started', 'ongoing', 'ended'], true)) {
$schedule = null;
}
$aQuery = Activity::query()
->with(['venue:'.Venue::referenceNameSelectString(['lat', 'lng', 'cover_image']), 'activityDays'])
->visibleOnH5();
if ($keyword !== null && $keyword !== '') {
$aQuery->where('title', 'like', "%{$keyword}%");
}
if ($schedule) {
$aQuery->whereComputedScheduleStatus($schedule);
}
$activities = $aQuery->get();
$tQuery = TicketGrabEvent::query()
->with('venues')
->visibleOnH5();
if ($keyword !== null && $keyword !== '') {
$tQuery->where('title', 'like', "%{$keyword}%");
}
if ($schedule) {
$tQuery->whereComputedScheduleStatus($schedule);
}
$grabs = $tQuery->get();
$items = $activities->map(function (Activity $a) {
$online = $this->activitySupportsOnlineBooking($a);
$isBookable = $online && $a->activityDays->contains(
fn (ActivityDay $d) => $d->isCurrentlyBookable()
);
return [
'list_kind' => 'activity',
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'image' => ActivityH5View::listCover($a),
'venue_name' => $a->venue?->name,
'address' => $a->location ?: $a->address,
'lat' => $a->lat,
'lng' => $a->lng,
'venue_lat' => $a->venue?->lat,
'venue_lng' => $a->venue?->lng,
'start_at' => CalendarDateFormat::ymdFromDateValue($a->start_at),
'end_at' => CalendarDateFormat::ymdFromDateValue($a->end_at),
'schedule_status' => Activity::computeScheduleStatusForDisplay($a),
'registered_count' => (int) ($a->registered_count ?? 0),
'tags' => array_values($a->tags ?? []),
'is_bookable' => $isBookable,
'all_slots_full' => $a->areAllActivityDaySlotsExhausted(),
'booking_closed_not_full' => $online && $a->isBookingClosedWithRemainingCapacity(),
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
'is_hot' => (bool) ($a->is_hot ?? false),
'has_behind_scenes' => $this->activityHasBehindScenes($a),
'offline_reservation_method' => (string) ($a->offline_reservation_method ?? ''),
'ticket_fee_note' => (string) ($a->ticket_fee_note ?? ''),
];
})->merge($grabs->map(function (TicketGrabEvent $e) {
$firstVenue = $e->venues->first();
return [
'list_kind' => 'ticket_grab',
'id' => $e->id,
'title' => $e->title,
'summary' => $e->summary,
'image' => $e->cover_image,
'venue_name' => $firstVenue?->name,
'address' => $e->address,
'lat' => null,
'lng' => null,
'venue_lat' => $firstVenue?->lat,
'venue_lng' => $firstVenue?->lng,
'start_at' => CalendarDateFormat::ymdFromDatetime($e->start_at),
'end_at' => CalendarDateFormat::ymdFromDatetime($e->end_at),
'schedule_status' => TicketGrabEvent::computeScheduleStatusFromBounds($e->start_at, $e->end_at),
'registered_count' => (int) ($e->registered_count ?? 0),
'tags' => array_values($e->tags ?? []),
'is_bookable' => $this->heuristicTicketGrabBookable($e),
'can_grab_today' => $e->canGrabToday(),
'venue_count' => $e->venues->count(),
'is_hot' => false,
'has_behind_scenes' => false,
];
}))->values();
$items = $items->sort(function (array $x, array $y) {
$hx = (($x['list_kind'] ?? 'activity') === 'activity' && ! empty($x['is_hot'])) ? 0 : 1;
$hy = (($y['list_kind'] ?? 'activity') === 'activity' && ! empty($y['is_hot'])) ? 0 : 1;
if ($hx !== $hy) {
return $hx <=> $hy;
}
$sx = $this->scheduleRankFromComputedStatus($x['schedule_status'] ?? null);
$sy = $this->scheduleRankFromComputedStatus($y['schedule_status'] ?? null);
if ($sx !== $sy) {
return $sx <=> $sy;
}
/** 已结束:按结束日期由近到远(字符串比较兼容 ISO8601 / YYYY-MM-DD */
if ($sx === 2) {
$ae = (string) ($x['end_at'] ?? '');
$be = (string) ($y['end_at'] ?? '');
if ($ae !== $be) {
if ($ae === '') {
return 1;
}
if ($be === '') {
return -1;
}
return $be <=> $ae;
}
} else {
$aStart = $x['start_at'] ?? '';
$bStart = $y['start_at'] ?? '';
if ($aStart !== $bStart) {
if ($aStart === '' || $aStart === null) {
return 1;
}
if ($bStart === '' || $bStart === null) {
return -1;
}
return $aStart <=> $bStart;
}
}
$tx = $this->mixedListActivityTicketPaidRank($x);
$ty = $this->mixedListActivityTicketPaidRank($y);
if ($tx !== $ty) {
return $tx <=> $ty;
}
return (int) $y['id'] <=> (int) $x['id'];
})->values();
if ($request->boolean('bookable_only')) {
$items = $items
->filter(fn (array $row) => ! empty($row['is_bookable']))
->values();
}
$total = $items->count();
$slice = $items->forPage($page, $size)->values();
$paginator = new LengthAwarePaginator(
$slice,
$total,
$size,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
],
);
return response()->json($paginator);
}
/**
* 与 {@see Activity::scopeOrderByTicketNoteFreeBeforePaid} 一致:仅活动行区分收费门票;抢票为 0。
*/
private function mixedListActivityTicketPaidRank(array $row): int
{
if (($row['list_kind'] ?? '') !== 'activity') {
return 0;
}
return (($row['offline_reservation_method'] ?? '') === Activity::TICKET_PAID) ? 1 : 0;
}
/**
* 0=进行中, 1=未开始, 2=已结束(与 Activity::scopeOrderByScheduleStatusPriority 展示口径一致).
*/
private function scheduleRankFromComputedStatus(?string $scheduleStatus): int
{
return match ($scheduleStatus) {
'not_started' => 1,
'ended' => 2,
default => 0,
};
}
private function heuristicTicketGrabBookable(TicketGrabEvent $e): bool
{
$t = now()->toDateString();
if (! $e->booking_start_at || ! $e->booking_end_at) {
return false;
}
if ($t < $e->booking_start_at->toDateString() || $t > $e->booking_end_at->toDateString()) {
return false;
}
if ($e->end_at && $e->end_at->toDateString() < $t) {
return false;
}
return true;
}
}