orderByHotThenScheduleStatusPriority(); $this->restrictByVenue($request, $query); if ($request->filled('keyword')) { $keyword = trim((string) $request->input('keyword')); $query->where('title', 'like', "%{$keyword}%"); } if ($request->filled('venue_id')) { $query->where('venue_id', (int) $request->input('venue_id')); } if ($request->filled('is_active')) { $query->where('is_active', (bool) $request->boolean('is_active')); } if ($request->filled('schedule_status')) { $query->whereComputedScheduleStatus($request->string('schedule_status')); } if ($request->filled('audit_status')) { $query->where('audit_status', $request->string('audit_status')); } if ($request->filled('reservation_type')) { $query->where('reservation_type', (string) $request->input('reservation_type')); } $pageSize = max(1, min(100, (int) $request->input('page_size', 10))); $page = $query->paginate($pageSize); $page->getCollection()->transform(function (Activity $a) use ($request) { $a->setAttribute( 'schedule_status', Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at) ); if (! $request->user()?->isSuperAdmin()) { $a->makeHidden(['is_hot']); } return $a; }); return response()->json($page); } public function store(Request $request): JsonResponse { $data = $request->validate([ 'venue_id' => ['required', 'integer', 'exists:venues,id'], 'reservation_type' => ['nullable', 'string', 'max:32'], 'location' => ['required', 'string', 'max:500'], 'check_in_meeting_point' => ['nullable', 'string', 'max:500'], 'specific_time' => ['nullable', 'string', 'max:2000'], 'offline_reservation_method' => ['nullable', 'string', 'max:500'], 'ticket_fee_note' => ['nullable', 'string', 'max:1000'], 'external_url' => ['nullable', 'string', 'max:2000'], 'title' => ['required', 'string', 'max:150'], 'contact_name' => ['required', 'string', 'max:100'], 'summary' => ['nullable', 'string', 'max:255'], 'category' => ['nullable', 'string', 'max:50'], 'quota' => ['nullable', 'integer', 'min:0'], 'start_at' => ['required', 'date'], 'end_at' => ['required', 'date'], 'address' => ['nullable', 'string', 'max:255'], 'contact_phone' => ['required', 'string', 'max:255'], 'lat' => ['nullable', 'numeric'], 'lng' => ['nullable', 'numeric'], 'detail_html' => ['nullable', 'string'], 'cover_image' => ['nullable', 'string', 'max:255'], 'gallery_media' => ['nullable', 'array'], 'gallery_media.*.type' => ['required_with:gallery_media', 'in:image,video'], 'gallery_media.*.url' => ['required_with:gallery_media', 'string', 'max:255'], 'tags' => ['nullable', 'array'], 'tags.*' => ['string', 'max:50'], 'reservation_notice' => ['nullable', 'string'], 'open_time' => ['nullable', 'string'], 'sort' => ['nullable', 'integer', 'min:0'], 'is_active' => ['boolean'], 'is_hot' => ['sometimes', 'boolean'], ]); if (! $request->user()?->isSuperAdmin()) { unset($data['is_hot']); } $data = $this->applyReservationTypeDefaults($data); $this->assertTicketNoteWhenNeeded($data, null); $this->assertOfflineMethodWhenNeeded($data, null); $this->assertExternalUrlWhenOther($data, null); $this->ensureVenuePermission($request, (int) $data['venue_id']); if (! $request->user()?->isSuperAdmin()) { unset($data['sort']); } $this->normalizeActivityDates($data); $data['schedule_status'] = Activity::computeScheduleStatusFromBounds( $data['start_at'] ?? null, $data['end_at'] ?? null ); $auditStatus = $request->user()->isSuperAdmin() ? Activity::AUDIT_APPROVED : Activity::AUDIT_PENDING; $activity = Activity::create($data + [ 'quota' => $data['quota'] ?? 0, 'tags' => array_values($data['tags'] ?? []), 'sort' => $data['sort'] ?? 0, 'is_active' => $data['is_active'] ?? true, 'audit_status' => $auditStatus, 'audit_remark' => null, 'last_approved_snapshot' => null, ]); if (! $activity->verify_portal_token) { $activity->forceFill(['verify_portal_token' => (string) Str::uuid()])->save(); } VerifyPortalCode::ensureForActivity($activity); $act = $activity->load('venue:id,name'); if (! $request->user()?->isSuperAdmin()) { $act->makeHidden(['is_hot']); } return response()->json($act, 201); } public function update(Request $request, Activity $activity): JsonResponse { $this->ensureVenuePermission($request, $activity->venue_id); $data = $request->validate([ 'venue_id' => ['sometimes', 'integer', 'exists:venues,id'], 'reservation_type' => ['nullable', 'string', 'max:32'], 'location' => ['required', 'string', 'max:500'], 'check_in_meeting_point' => ['nullable', 'string', 'max:500'], 'specific_time' => ['nullable', 'string', 'max:2000'], 'offline_reservation_method' => ['nullable', 'string', 'max:500'], 'ticket_fee_note' => ['nullable', 'string', 'max:1000'], 'external_url' => ['nullable', 'string', 'max:2000'], 'title' => ['sometimes', 'string', 'max:150'], 'contact_name' => ['sometimes', 'nullable', 'string', 'max:100'], 'summary' => ['sometimes', 'nullable', 'string', 'max:255'], 'category' => ['nullable', 'string', 'max:50'], 'quota' => ['sometimes', 'integer', 'min:0'], 'start_at' => ['required', 'date'], 'end_at' => ['required', 'date'], 'address' => ['nullable', 'string', 'max:255'], 'contact_phone' => ['required', 'string', 'max:255'], 'lat' => ['nullable', 'numeric'], 'lng' => ['nullable', 'numeric'], 'detail_html' => ['nullable', 'string'], 'cover_image' => ['nullable', 'string', 'max:255'], 'gallery_media' => ['nullable', 'array'], 'gallery_media.*.type' => ['required_with:gallery_media', 'in:image,video'], 'gallery_media.*.url' => ['required_with:gallery_media', 'string', 'max:255'], 'tags' => ['sometimes', 'nullable', 'array'], 'tags.*' => ['string', 'max:50'], 'reservation_notice' => ['nullable', 'string'], 'open_time' => ['nullable', 'string'], 'sort' => ['nullable', 'integer', 'min:0'], 'is_active' => ['boolean'], 'is_hot' => ['sometimes', 'boolean'], ]); $data = $this->applyReservationTypeDefaults($data, $activity); $this->assertTicketNoteWhenNeeded($data, $activity); $this->assertOfflineMethodWhenNeeded($data, $activity); $this->assertExternalUrlWhenOther($data, $activity); if (array_key_exists('venue_id', $data)) { $this->ensureVenuePermission($request, (int) $data['venue_id']); } $user = $request->user(); if (! $user?->isSuperAdmin()) { unset($data['sort'], $data['is_hot']); /** 场馆编辑(含已结束、已退回)后均进入待审核,由超级管理员再次审核 */ $data['audit_status'] = Activity::AUDIT_PENDING; $data['audit_remark'] = null; } else { $data['audit_status'] = Activity::AUDIT_APPROVED; $data['audit_remark'] = null; $data['last_approved_snapshot'] = null; } $this->normalizeActivityDates($data); $start = array_key_exists('start_at', $data) ? $data['start_at'] : $activity->start_at; $end = array_key_exists('end_at', $data) ? $data['end_at'] : $activity->end_at; $data['schedule_status'] = Activity::computeScheduleStatusFromBounds($start, $end); $activity->fill($data)->save(); if (array_key_exists('tags', $data)) { $activity->tags = array_values($data['tags'] ?? []); $activity->save(); } $fresh = $activity->fresh()->load('venue:id,name'); if (! $request->user()?->isSuperAdmin()) { $fresh->makeHidden(['is_hot']); } return response()->json($fresh); } public function approve(Request $request, Activity $activity): JsonResponse { abort_unless($request->user()?->isSuperAdmin(), 403, '仅超级管理员可审核'); $this->ensureVenuePermission($request, $activity->venue_id); $data = $request->validate([ 'mark_hot' => ['required', 'boolean'], ]); $activity->audit_status = Activity::AUDIT_APPROVED; $activity->audit_remark = null; $activity->last_approved_snapshot = null; $activity->is_hot = (bool) $data['mark_hot']; $activity->save(); return response()->json($activity->fresh()->load('venue:id,name')); } public function updateBehindScenes(Request $request, Activity $activity): JsonResponse { $this->ensureVenuePermission($request, $activity->venue_id); $status = Activity::computeScheduleStatusFromBounds($activity->start_at, $activity->end_at); abort_unless($status === 'ended', 422, '仅已结束活动可上传花絮'); $data = $request->validate([ 'behind_scenes_media' => ['present', 'array'], 'behind_scenes_media.*.type' => ['required', 'in:image'], 'behind_scenes_media.*.url' => ['required', 'string', 'max:255'], ]); $activity->behind_scenes_media = array_values($data['behind_scenes_media'] ?? []); $activity->save(); $fresh = $activity->fresh()->load('venue:id,name'); if (! $request->user()?->isSuperAdmin()) { $fresh->makeHidden(['is_hot']); } return response()->json($fresh); } public function setHotFlag(Request $request, Activity $activity): JsonResponse { abort_unless($request->user()?->isSuperAdmin(), 403, '仅超级管理员可设置热门活动'); $this->ensureVenuePermission($request, $activity->venue_id); $data = $request->validate([ 'is_hot' => ['required', 'boolean'], ]); $activity->is_hot = (bool) $data['is_hot']; $activity->save(); return response()->json($activity->fresh()->load('venue:id,name')); } public function reject(Request $request, Activity $activity): JsonResponse { abort_unless($request->user()?->isSuperAdmin(), 403, '仅超级管理员可审核'); $this->ensureVenuePermission($request, $activity->venue_id); $data = $request->validate([ 'remark' => ['nullable', 'string', 'max:2000'], ]); $activity->audit_status = Activity::AUDIT_REJECTED; $activity->audit_remark = $data['remark'] ?? null; $activity->save(); return response()->json($activity->fresh()->load('venue:id,name')); } public function toggle(Request $request, Activity $activity): JsonResponse { $this->ensureVenuePermission($request, $activity->venue_id); $activity->is_active = ! $activity->is_active; $activity->save(); return response()->json($activity->fresh()->load('venue:id,name')); } public function destroy(Request $request, Activity $activity): JsonResponse { $this->ensureVenuePermission($request, $activity->venue_id); $count = $activity->reservations()->count(); if ($count > 0) { return response()->json([ 'message' => '该活动已有报名记录,不能删除', 'reservation_count' => $count, ], 422); } $activity->delete(); return response()->json(['message' => '删除成功']); } public function restore(Request $request, int $activityId): JsonResponse { $activity = Activity::withTrashed()->findOrFail($activityId); $this->ensureVenuePermission($request, $activity->venue_id); $disableAfterRestore = $request->boolean('disable_after_restore'); $activity->restore(); if ($disableAfterRestore) { $activity->is_active = false; $activity->save(); } return response()->json($activity->fresh()->load('venue:id,name')); } /** * 开始/结束仅传年月日时:开始为当日 00:00:00,结束为当日 23:59:59,允许同一天。 * * @param array $data */ private function normalizeActivityDates(array &$data): void { $tz = config('app.timezone'); if (array_key_exists('start_at', $data)) { if ($data['start_at'] === null || $data['start_at'] === '') { $data['start_at'] = null; } else { $data['start_at'] = Carbon::parse($data['start_at'], $tz)->startOfDay(); } } if (array_key_exists('end_at', $data)) { if ($data['end_at'] === null || $data['end_at'] === '') { $data['end_at'] = null; } else { $data['end_at'] = Carbon::parse($data['end_at'], $tz)->endOfDay(); } } if (! empty($data['start_at']) && ! empty($data['end_at']) && $data['end_at']->lt($data['start_at'])) { throw ValidationException::withMessages(['end_at' => ['结束日期不能早于开始日期']]); } } /** * @param array $data * @return array */ private function applyReservationTypeDefaults(array $data, ?Activity $existing = null): array { $newTypes = [Activity::RESERVATION_TYPE_PHONE, Activity::RESERVATION_TYPE_WECHAT_MP, Activity::RESERVATION_TYPE_OFFLINE_VISIT]; if (array_key_exists('reservation_type', $data)) { $t = (string) ($data['reservation_type'] ?? ''); $data['reservation_type'] = $t === '' ? Activity::RESERVATION_TYPE_ONLINE : $t; } elseif ($existing !== null) { $data['reservation_type'] = (string) ($existing->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE); } else { $data['reservation_type'] = Activity::RESERVATION_TYPE_ONLINE; } $t = $data['reservation_type']; if (in_array($t, $newTypes, true)) { $data['external_url'] = null; if (! array_key_exists('offline_reservation_method', $data) && $existing !== null) { $data['offline_reservation_method'] = $existing->offline_reservation_method; } } elseif ($t === Activity::RESERVATION_TYPE_OFFLINE) { $data['external_url'] = null; if (! array_key_exists('offline_reservation_method', $data) && $existing !== null) { $data['offline_reservation_method'] = $existing->offline_reservation_method; } } elseif ($t === Activity::RESERVATION_TYPE_OTHER) { if (! array_key_exists('external_url', $data) && $existing !== null) { $data['external_url'] = $existing->external_url; } $data['offline_reservation_method'] = null; } elseif ($t === Activity::RESERVATION_TYPE_ONLINE) { $data['external_url'] = null; } elseif ($t === Activity::RESERVATION_TYPE_NONE) { $data['external_url'] = null; if (! array_key_exists('offline_reservation_method', $data) && $existing !== null) { $data['offline_reservation_method'] = $existing->offline_reservation_method; } } else { // 自定义文案或历史值:仅展示,不参与线上报名/外链;去掉外链以免脏数据 $data['external_url'] = null; } $ticket = trim((string) ($data['offline_reservation_method'] ?? '')); if ($ticket !== Activity::TICKET_PAID) { $data['ticket_fee_note'] = null; } elseif (array_key_exists('ticket_fee_note', $data)) { $fn = trim((string) $data['ticket_fee_note']); $data['ticket_fee_note'] = $fn !== '' ? $fn : null; } return $data; } /** * @param array $data */ private function assertTicketNoteWhenNeeded(array $data, ?Activity $existing = null): void { $newTypes = [ Activity::RESERVATION_TYPE_PHONE, Activity::RESERVATION_TYPE_WECHAT_MP, Activity::RESERVATION_TYPE_OFFLINE_VISIT, Activity::RESERVATION_TYPE_NONE, Activity::RESERVATION_TYPE_ONLINE, ]; if (! in_array(($data['reservation_type'] ?? ''), $newTypes, true)) { return; } $m = trim((string) ($data['offline_reservation_method'] ?? $existing?->offline_reservation_method ?? '')); if (! in_array($m, [Activity::TICKET_FREE, Activity::TICKET_PAID], true)) { throw ValidationException::withMessages(['offline_reservation_method' => ['请选择门票说明(免费/收费)']]); } } /** * @param array $data */ private function assertOfflineMethodWhenNeeded(array $data, ?Activity $existing = null): void { if (($data['reservation_type'] ?? '') !== Activity::RESERVATION_TYPE_OFFLINE) { return; } $m = trim((string) ($data['offline_reservation_method'] ?? $existing?->offline_reservation_method ?? '')); if ($m === '') { throw ValidationException::withMessages(['offline_reservation_method' => ['选择「线下预约」时请填写预约方式']]); } } /** * @param array $data */ private function assertExternalUrlWhenOther(array $data, ?Activity $existing = null): void { if (($data['reservation_type'] ?? '') !== Activity::RESERVATION_TYPE_OTHER) { return; } $url = trim((string) ($data['external_url'] ?? $existing?->external_url ?? '')); if ($url === '') { throw ValidationException::withMessages(['external_url' => ['选择「外链跳转预约」时请填写外链地址']]); } } private function restrictByVenue(Request $request, $query): void { $user = $request->user(); if ($user->isSuperAdmin()) { return; } $query->whereIn('venue_id', $user->venues()->pluck('venues.id')); } private function ensureVenuePermission(Request $request, int $venueId): void { $user = $request->user(); if ($user->isSuperAdmin()) { return; } $allowed = $user->venues()->where('venues.id', $venueId)->exists(); abort_unless($allowed, 403, '仅可操作已绑定场馆'); } }