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.

705 lines
27 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\TicketGrabEvent;
use App\Models\TicketGrabEventVenue;
use App\Models\Venue;
use App\Models\WechatUser;
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) {
$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' => $this->activityListCover($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' => 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,
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
];
});
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);
$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);
$bookingDays = $a->activityDays
->values()
->map(function (ActivityDay $d) use ($mySet) {
$already = isset($mySet[$d->id]);
return $d->toH5BookingDayArray($already);
});
$cover = $this->activityListCover($a);
return response()->json([
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'detail_html' => $a->detail_html,
'image' => $cover,
'gallery_media' => $a->gallery_media ?? [],
'carousel' => $this->buildActivityH5Carousel($a),
'venue' => $a->venue,
'location' => $a->location,
'address' => $a->location ?: $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,
'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,
'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' => $onlineBookable ? $bookingDays : [],
]);
}
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) {
// 直接使用 toArray因为 visibleOnH5 已经过滤了审核状态和启用状态
$p = $v->toArray();
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),
];
})
->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'] = $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);
$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()
->orderForH5Listing()
->with('venue:id,cover_image')
->limit(50)
->get(['id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address', 'location', 'venue_id'])
->map(function (Activity $a) {
return [
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'cover_image' => $this->activityListCover($a),
'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->location ?: $a->address,
];
})
->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;
}
/**
* 轮播素材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;
}
private function activitySupportsOnlineBooking(Activity $a): bool
{
$t = (string) ($a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE);
return $t === '' || $t === Activity::RESERVATION_TYPE_ONLINE;
}
/**
* 列表/详情头图:优先活动封面,无则场馆封面。
*/
private function activityListCover(Activity $a): ?string
{
$c = trim((string) ($a->cover_image ?? ''));
if ($c !== '') {
return $c;
}
$v = trim((string) ($a->venue?->cover_image ?? ''));
return $v !== '' ? $v : null;
}
/**
* H5 轮播:优先活动上传的轮播/封面;皆无则用场馆轮播/封面。
*
* @return array<int, array{type: string, url: string}>
*/
private function buildActivityH5Carousel(Activity $a): array
{
$fromActivity = $this->buildGalleryCarousel($a);
if (count($fromActivity) > 0) {
return $fromActivity;
}
if ($a->venue) {
return $this->buildGalleryCarousel($a->venue);
}
return [];
}
private function reservationTypeLabel(?string $type): string
{
return match ((string) ($type ?? Activity::RESERVATION_TYPE_ONLINE)) {
Activity::RESERVATION_TYPE_OFFLINE => '线下预约',
Activity::RESERVATION_TYPE_OTHER => '其他预约',
default => '线上预约',
};
}
/**
* 与首页地图场馆一致:字典 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) {
$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' => $this->activityListCover($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' => 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,
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
];
})->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;
}
}