with(['courseSystemItem', 'courseTypeItem', 'coverMedia', 'promoMedia']) ->withCount(['signups']); if ($kw = $request->query('keyword')) { $query->where(function ($q) use ($kw) { $q->where('title', 'like', "%{$kw}%") ->orWhere('code', 'like', "%{$kw}%") ->orWhere('code_prefix', 'like', "%{$kw}%"); }); } if ($request->filled('course_system_dict_item_id')) { $query->where('course_system_dict_item_id', (int) $request->query('course_system_dict_item_id')); } if ($request->filled('course_type_dict_item_id')) { $query->where('course_type_dict_item_id', (int) $request->query('course_type_dict_item_id')); } if ($request->filled('progress_status')) { $query->where('progress_status', (int) $request->query('progress_status')); } if ($request->filled('published')) { $query->where('published', (int) $request->query('published')); } $paginator = $query ->orderByDesc('id') ->paginate((int) $request->query('page_size', 20)) ->withQueryString(); $paginator->getCollection()->transform(fn (Course $c) => $this->serializeCourseList($c)); return $this->paginated($paginator); } public function store(Request $request): JsonResponse { if (! DictType::query()->where('code', 'course_system')->where('status', 1)->exists() || ! DictType::query()->where('code', 'course_type')->where('status', 1)->exists()) { return $this->fail('字典「课程体系」或「课程类型」未配置,请在后台维护数据字典或执行 CourseDictionarySeeder', 422); } $data = $this->validatedCourse($request); $data['progress_status'] = ScheduleProgressStatus::resolve( $data['teach_start_date'] ?? null, $data['teach_end_date'] ?? null, $data['signup_start_date'] ?? null, $data['signup_end_date'] ?? null, ); $course = Course::query()->create($data); CourseCheckinDaySync::ensureCourseCode($course); CourseCheckinDaySync::syncForCourse($course->fresh()); return $this->ok(['id' => $course->id], '已创建'); } public function show(int $course): JsonResponse { $model = Course::query() ->with(['courseSystemItem', 'courseTypeItem', 'autoAddTeacherItem', 'coverMedia', 'promoMedia', 'news']) ->withCount(['signups']) ->findOrFail($course); return $this->ok($this->serializeCourseDetail($model)); } public function update(Request $request, int $course): JsonResponse { $model = Course::query()->findOrFail($course); $data = $this->validatedCourse($request, partial: true); $model->fill($data); $model->progress_status = ScheduleProgressStatus::resolve( $model->teach_start_date?->toDateString(), $model->teach_end_date?->toDateString(), $model->signup_start_date?->toDateString(), $model->signup_end_date?->toDateString(), ); $model->save(); CourseCheckinDaySync::ensureCourseCode($model); CourseCheckinDaySync::syncForCourse($model->fresh()); return $this->ok(null, '已保存'); } public function updateShelf(Request $request, int $course): JsonResponse { $data = $request->validate([ 'published' => ['required', 'integer', 'in:0,1'], ]); $model = Course::query()->findOrFail($course); $model->published = (int) $data['published']; $model->save(); return $this->ok(null, '已更新发布状态'); } public function destroy(int $course): JsonResponse { Course::query()->findOrFail($course)->delete(); return $this->ok(null, '已删除'); } /** * @return array */ protected function validatedCourse(Request $request, bool $partial = false): array { $courseId = $partial ? (int) $request->route('course') : null; $systemTypeId = DictType::query()->where('code', 'course_system')->where('status', 1)->value('id'); $courseTypeTypeId = DictType::query()->where('code', 'course_type')->where('status', 1)->value('id'); $yesNoTypeId = DictType::query()->where('code', 'yes_no')->where('status', 1)->value('id'); $codeRules = ['nullable', 'string', 'max:64']; if ($partial) { array_unshift($codeRules, 'sometimes'); $codeRules[] = Rule::unique('courses', 'code')->ignore($courseId); } else { $codeRules[] = Rule::unique('courses', 'code'); } $rules = [ 'code' => $codeRules, 'code_prefix' => ['nullable', 'string', 'max:32'], 'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:255'], 'course_system_dict_item_id' => $this->dictItemRules($systemTypeId, $partial ? 'sometimes' : 'required'), 'course_type_dict_item_id' => $this->dictItemRules($courseTypeTypeId, $partial ? 'sometimes' : 'required'), 'teach_start_date' => ['nullable', 'date'], 'teach_end_date' => $partial ? ['nullable', 'date'] : ['nullable', 'date', 'after_or_equal:teach_start_date'], 'location' => ['nullable', 'string', 'max:255'], 'teach_start_time' => ['nullable', 'date_format:H:i'], 'teach_end_time' => ['nullable', 'date_format:H:i'], 'recruit_targets' => ['nullable', 'array'], 'recruit_targets.*' => ['string', 'max:255'], 'main_speakers' => ['nullable', 'array'], 'main_speakers.*.teacher_id' => ['nullable', 'integer'], 'main_speakers.*.name' => ['required_with:main_speakers', 'string', 'max:64'], 'main_speakers.*.title' => ['nullable', 'string', 'max:64'], 'main_speakers.*.university' => ['nullable', 'string', 'max:255'], 'main_speakers.*.remark' => ['nullable', 'string', 'max:255'], 'signup_start_date' => ['nullable', 'date'], 'signup_end_date' => $partial ? ['nullable', 'date'] : ['nullable', 'date', 'after_or_equal:signup_start_date'], 'auto_add_teacher_dict_item_id' => $this->dictItemRules($yesNoTypeId, 'nullable'), 'capacity' => ['nullable', 'integer', 'min:0'], 'cover_media_id' => array_merge( $partial ? ['sometimes', 'nullable'] : ['nullable'], ['integer', Rule::exists('course_media', 'id')->where('category', 'covers')], ), 'promo_media_id' => array_merge( $partial ? ['sometimes', 'nullable'] : ['nullable'], ['integer', Rule::exists('course_media', 'id')->where('category', 'promos')], ), 'news_id' => array_merge( $partial ? ['sometimes', 'nullable'] : ['nullable'], ['integer', 'exists:news,id'], ), 'news_link_url' => ['nullable', 'string', 'max:512'], 'intro_html' => ['nullable', 'string'], 'signup_form_schema' => ['nullable', 'array'], 'published' => ['nullable', 'integer', 'in:0,1'], 'remark' => ['nullable', 'string'], 'sort' => ['nullable', 'integer'], ]; return $request->validate($rules); } /** * @return array */ protected function dictItemRules(?int $dictTypeId, string $presence): array { if (! $dictTypeId) { return match ($presence) { 'required' => ['required', 'integer'], 'sometimes' => ['sometimes', 'nullable', 'integer'], default => ['nullable', 'integer'], }; } $exists = Rule::exists('dict_items', 'id')->where( fn ($q) => $q->where('dict_type_id', $dictTypeId)->where('status', 1) ); return match ($presence) { 'required' => ['required', 'integer', $exists], 'sometimes' => ['sometimes', 'nullable', 'integer', $exists], default => ['nullable', 'integer', $exists], }; } /** * @return array */ protected function serializeCourseList(Course $c): array { return [ 'id' => $c->id, 'code' => $c->code, 'signin_code' => CourseCheckinDaySync::courseSigninCode($c), 'code_prefix' => $c->code_prefix, 'title' => $c->title, 'course_system_dict_item_id' => $c->course_system_dict_item_id, 'course_type_dict_item_id' => $c->course_type_dict_item_id, 'course_system_item' => $this->serializeDictItem($c->courseSystemItem), 'course_type_item' => $this->serializeDictItem($c->courseTypeItem), 'teach_start_date' => $c->teach_start_date?->toDateString(), 'teach_end_date' => $c->teach_end_date?->toDateString(), 'location' => $c->location, 'teach_start_time' => $this->formatTimeValue($c->teach_start_time), 'teach_end_time' => $this->formatTimeValue($c->teach_end_time), 'recruit_targets' => $c->recruit_targets ?? [], 'main_speakers' => $c->main_speakers ?? [], 'signup_start_date' => $c->signup_start_date?->toDateString(), 'signup_end_date' => $c->signup_end_date?->toDateString(), 'capacity' => (int) $c->capacity, 'progress_status' => ScheduleProgressStatus::resolve( $c->teach_start_date?->toDateString(), $c->teach_end_date?->toDateString(), $c->signup_start_date?->toDateString(), $c->signup_end_date?->toDateString(), ), 'published' => (int) $c->published, 'signups_count' => (int) ($c->signups_count ?? 0), 'created_at' => $c->created_at?->toIso8601String(), 'cover' => $this->serializeCourseMedia($c->coverMedia) ?? $this->legacyUrlAsMedia($c->cover_url, 'covers'), 'promo' => $this->serializeCourseMedia($c->promoMedia) ?? $this->legacyUrlAsMedia($c->promo_url, 'promos'), ]; } /** * @return array */ protected function serializeCourseDetail(Course $c): array { $row = $this->serializeCourseList($c); $row['auto_add_teacher_dict_item_id'] = $c->auto_add_teacher_dict_item_id; $row['auto_add_teacher_item'] = $this->serializeDictItem($c->autoAddTeacherItem); $row['news_id'] = $c->news_id; $row['news'] = $c->news ? [ 'id' => $c->news->id, 'title' => $c->news->title, 'status' => (int) $c->news->status, ] : null; $row['news_link_url'] = $c->news_link_url; $row['intro_html'] = $c->intro_html; $row['signup_form_schema'] = $c->signup_form_schema; $row['remark'] = $c->remark; return $row; } /** * @return array|null */ protected function serializeCourseMedia(?CourseMedia $media): ?array { if (! $media) { return null; } return $media->toApiArray(); } /** * 旧数据仅有 URL 字符串时的兼容展示(无 media 主键)。 * * @return array|null */ protected function legacyUrlAsMedia(?string $url, string $category): ?array { if ($url === null || $url === '') { return null; } return [ 'id' => null, 'disk' => 'public', 'path' => null, 'url' => $url, 'category' => $category, 'original_name' => null, 'mime_type' => null, 'size_bytes' => null, 'created_at' => null, ]; } /** * @return array{id:int,label:string,value:string}|null */ protected function serializeDictItem(?DictItem $item): ?array { if (! $item) { return null; } return [ 'id' => $item->id, 'label' => $item->label, 'value' => $item->value, ]; } protected function formatTimeValue(mixed $time): ?string { if ($time === null || $time === '') { return null; } $str = (string) $time; return strlen($str) >= 5 ? substr($str, 0, 5) : $str; } }