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.

559 lines
21 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\Models\Venue;
use App\Models\WechatUser;
use App\Models\TicketGrabEvent;
use App\Support\CalendarDateFormat;
use Carbon\Carbon;
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
{
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:id,name,lat,lng', '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);
}
})
->orderByScheduleStatusPriority()
->paginate($size);
$rows->getCollection()->transform(function ($a) {
$isBookable = $a->activityDays->contains(
fn (ActivityDay $d) => $d->isCurrentlyBookable()
);
return [
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'image' => $a->cover_image,
'venue_name' => $a->venue?->name,
'address' => $a->address,
'lat' => $a->lat,
'lng' => $a->lng,
'venue_lat' => $a->venue?->lat,
'venue_lng' => $a->venue?->lng,
'start_at' => optional($a->start_at)?->toIso8601String(),
'end_at' => optional($a->end_at)?->toIso8601String(),
'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at),
'registered_count' => (int) ($a->registered_count ?? 0),
'tags' => array_values($a->tags ?? []),
'is_bookable' => $isBookable,
];
});
return response()->json($rows);
}
public function activityDetail(Request $request, int $id): JsonResponse
{
$a = Activity::query()
->with([
'venue:id,name,address,lat,lng,open_time,district,venue_type,venue_types,ticket_type,unit_name,contact_phone',
'activityDays',
])
->visibleOnH5()
->findOrFail($id);
$isBookable = $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);
$bookingDays = $a->activityDays
->values()
->map(function (ActivityDay $d) use ($mySet) {
$already = isset($mySet[$d->id]);
return $d->toH5BookingDayArray($already);
});
return response()->json([
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'detail_html' => $a->detail_html,
'image' => $a->cover_image,
'gallery_media' => $a->gallery_media ?? [],
'carousel' => $this->buildGalleryCarousel($a),
'venue' => $a->venue,
'address' => $a->address,
'contact_phone' => $a->contact_phone,
'lat' => $a->lat,
'lng' => $a->lng,
'start_at' => optional($a->start_at)?->toIso8601String(),
'end_at' => optional($a->end_at)?->toIso8601String(),
'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at),
'registered_count' => (int) ($a->registered_count ?? 0),
'tags' => array_values($a->tags ?? []),
'reservation_notice' => $a->reservation_notice,
'is_bookable' => $isBookable,
'venue_type_color' => $this->resolveVenueTypeColor($a->venue?->venue_type, $a->venue?->venue_types),
'booking_days' => $bookingDays,
]);
}
public function venues(): JsonResponse
{
$rows = Venue::query()
->visibleOnH5()
->orderBy('sort')
->orderByDesc('id')
->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'],
'open_mode' => $p['open_mode'] ?? null,
];
})
->filter()
->values();
return response()->json($rows);
}
public function venueDetail(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'] = $this->buildGalleryCarousel($display);
$payload['live_people_count'] = (int) ($display->live_people_count ?? 0);
$payload['venue_type_color'] = $this->resolveVenueTypeColor($display->venue_type, $display->venue_types);
$payload['activities'] = Activity::query()
->where('venue_id', $v->id)
->visibleOnH5()
->orderForH5Listing()
->limit(50)
->get(['id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address'])
->map(function (Activity $a) {
return [
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'cover_image' => $a->cover_image,
'start_at' => optional($a->start_at)?->toIso8601String(),
'end_at' => optional($a->end_at)?->toIso8601String(),
'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at),
'registered_count' => (int) ($a->registered_count ?? 0),
'address' => $a->address,
];
})
->values()
->all();
return response()->json($payload);
}
/**
* 轮播素材gallery_media可含图片/视频);为空时用封面图兜底。
*
* @return array<int, array{type: string, url: string}>
*/
private function buildGalleryCarousel(Venue|Activity|StudyTour $model): array
{
$items = [];
$seen = [];
foreach ($model->gallery_media ?? [] as $m) {
if (! is_array($m)) {
continue;
}
$url = trim((string) ($m['url'] ?? ''));
if ($url === '' || isset($seen[$url])) {
continue;
}
$type = $m['type'] ?? 'image';
if (! in_array($type, ['image', 'video'], true)) {
$type = 'image';
}
$seen[$url] = true;
$items[] = ['type' => $type, 'url' => $url];
}
if ($items === [] && $model->cover_image) {
$url = trim((string) $model->cover_image);
if ($url !== '') {
$items[] = ['type' => 'image', 'url' => $url];
}
}
return $items;
}
/**
* 与首页地图场馆一致:字典 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_active', true)->findOrFail($id);
$venueIds = collect($row->venue_ids ?? [])->filter()->values();
$venueMap = Venue::query()
->whereIn('id', $venueIds->all())
->get(['id', 'name', 'district', 'address', 'cover_image', 'lat', 'lng', 'ticket_type', 'venue_type', 'venue_types'])
->keyBy('id');
$venues = $venueIds
->map(function ($id) use ($venueMap) {
$v = $venueMap->get($id);
if ($v === null) {
return null;
}
$arr = $v->toArray();
$arr['venue_type_color'] = $this->resolveVenueTypeColor($v->venue_type, is_array($v->venue_types) ? $v->venue_types : null);
if (is_array($arr['venue_types'] ?? null)) {
$arr['venue_types'] = array_values($arr['venue_types']);
}
return $arr;
})
->filter()
->values();
return response()->json([
'id' => $row->id,
'name' => $row->name,
'tags' => array_values($row->tags ?? []),
'image' => $row->cover_image,
'gallery_media' => $row->gallery_media ?? [],
'carousel' => $this->buildGalleryCarousel($row),
'intro_html' => $row->intro_html,
'venues' => $venues,
]);
}
public function studyTours(Request $request): JsonResponse
{
$rows = StudyTour::query()
->where('is_active', true)
->when($request->filled('keyword'), function ($q) use ($request) {
$keyword = trim((string) $request->input('keyword'));
$q->where('name', 'like', "%{$keyword}%");
})
->orderBy('sort')
->orderByDesc('id')
->get(['id', 'name', 'tags', 'venue_ids', 'intro_html', 'cover_image', 'gallery_media']);
$venueIds = $rows->pluck('venue_ids')->flatten()->filter()->values()->all();
$venueMap = Venue::query()
->whereIn('id', $venueIds)
->get(['id', 'name', 'district', 'address', 'cover_image', 'lat', 'lng'])
->keyBy('id');
$payload = $rows->map(function ($row) use ($venueMap) {
$ids = collect($row->venue_ids ?? [])->filter()->values();
$firstVenue = $ids->isNotEmpty() ? $venueMap->get($ids->first()) : null;
$venueNames = $ids->map(fn ($id) => $venueMap->get($id)?->name)->filter()->values();
$fallbackCover = $ids->map(fn ($id) => $venueMap->get($id)?->cover_image)->filter()->first();
$tourCover = trim((string) ($row->cover_image ?? ''));
return [
'id' => $row->id,
'name' => $row->name,
'tags' => array_values($row->tags ?? []),
'venue_names' => $venueNames->all(),
'cover_image' => $tourCover !== '' ? $tourCover : ($fallbackCover ?: $firstVenue?->cover_image),
'first_address' => $firstVenue?->address,
'first_district' => $firstVenue?->district,
'venue_count' => $ids->count(),
];
})->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_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:id,name,lat,lng', '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) {
$isBookable = $a->activityDays->contains(
fn (ActivityDay $d) => $d->isCurrentlyBookable()
);
return [
'list_kind' => 'activity',
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'image' => $a->cover_image,
'venue_name' => $a->venue?->name,
'address' => $a->address,
'lat' => $a->lat,
'lng' => $a->lng,
'venue_lat' => $a->venue?->lat,
'venue_lng' => $a->venue?->lng,
'start_at' => optional($a->start_at)?->toIso8601String(),
'end_at' => optional($a->end_at)?->toIso8601String(),
'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at),
'registered_count' => (int) ($a->registered_count ?? 0),
'tags' => array_values($a->tags ?? []),
'is_bookable' => $isBookable,
];
})->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(),
];
}))->values();
$items = $items->sort(function (array $x, array $y) {
$t = Carbon::now((string) config('app.timezone'))->toDateString();
$sx = $this->scheduleRankForSort(
$x['start_at'] ? Carbon::parse($x['start_at'])->toDateString() : null,
$x['end_at'] ? Carbon::parse($x['end_at'])->toDateString() : null,
$t
);
$sy = $this->scheduleRankForSort(
$y['start_at'] ? Carbon::parse($y['start_at'])->toDateString() : null,
$y['end_at'] ? Carbon::parse($y['end_at'])->toDateString() : null,
$t
);
if ($sx !== $sy) {
return $sx <=> $sy;
}
$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;
}
return (int) $y['id'] <=> (int) $x['id'];
})->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);
}
/**
* 0=进行中, 1=未开始, 2=已结束(与 orderByScheduleStatusPriority 同序).
*/
private function scheduleRankForSort(?string $startD, ?string $endD, string $today): int
{
if (! $endD && ! $startD) {
return 0;
}
if ($endD && $endD < $today) {
return 2;
}
if ($startD && $startD > $today) {
return 1;
}
return 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;
}
}