note_user_id = $noteUserId; $note->title = $this->normalizeTitle( (string) ($data['title'] ?? ''), (string) ($data['content'] ?? '') ); $note->content = (string) ($data['content'] ?? ''); $note->transcript_text = ''; $note->source_type = (string) ($data['source_type'] ?? 'text'); $note->status = (string) ($data['status'] ?? 'draft'); $note->audio_duration_ms = (int) ($data['audio_duration_ms'] ?? 0); $note->summary_status = 'none'; $note->last_transcript_time = 0; $note->created_at = $now; $note->updated_at = $now; $note->deleted_at = 0; $note->save(); return $this->formatNoteItem($note); } /** * 获取笔记列表 * * @param int $noteUserId * @param array $params * @return array */ public function getList(int $noteUserId, array $params): array { $page = max(1, (int) ($params['page'] ?? 1)); $pageSize = max(1, min(100, (int) ($params['page_size'] ?? 10))); $keyword = trim((string) ($params['keyword'] ?? '')); $status = trim((string) ($params['status'] ?? '')); $query = NoteItem::buildUserQuery($noteUserId); if ($status !== '') { $query->where('status', $status); } if ($keyword !== '') { $query->where(function ($subQuery) use ($keyword) { $subQuery->whereLike('title', '%' . $keyword . '%') ->whereOrLike('content', '%' . $keyword . '%') ->whereOrLike('transcript_text', '%' . $keyword . '%'); }); } $total = (int) $query->count(); $list = $query->order('id', 'desc') ->page($page, $pageSize) ->select(); return [ 'list' => array_map(function ($item) { return $this->formatNoteItem($item); }, $list->all()), 'total' => $total, 'page' => $page, 'page_size' => $pageSize, ]; } /** * 获取笔记详情 * * @param int $noteUserId * @param int $id * @return array * @throws \Exception */ public function getDetail(int $noteUserId, int $id): array { $note = $this->getOwnedNote($noteUserId, $id); $summary = NoteAiSummary::findLatestByNoteId($id); $audio = NoteAudio::findLatestByNoteId($id); $result = $this->formatNoteItem($note); $result['audio'] = $audio ? $this->formatAudio($audio) : null; $result['summary'] = $summary ? [ 'summary_id' => (int) $summary->id, 'summary_type' => (string) $summary->summary_type, 'summary_text' => (string) $summary->summary_text, 'todo_list' => $this->decodeJsonList((string) $summary->todo_list), 'keywords' => $this->decodeJsonList((string) $summary->keywords), 'status' => (string) $summary->status, ] : null; return $result; } /** * 更新笔记 * * @param int $noteUserId * @param int $id * @param array $data * @return array * @throws \Exception */ public function update(int $noteUserId, int $id, array $data): array { $note = $this->getOwnedNote($noteUserId, $id); if (array_key_exists('title', $data)) { $note->title = $this->normalizeTitle((string) $data['title'], (string) ($data['content'] ?? $note->content)); } if (array_key_exists('content', $data)) { $note->content = (string) $data['content']; if (trim((string) $note->title) === '') { $note->title = $this->normalizeTitle('', (string) $note->content); } } if (array_key_exists('status', $data) && $data['status'] !== '') { $note->status = (string) $data['status']; } if (array_key_exists('audio_duration_ms', $data)) { $note->audio_duration_ms = max(0, (int) $data['audio_duration_ms']); } $note->updated_at = time(); $note->save(); return $this->formatNoteItem($note); } /** * 删除笔记 * * @param int $noteUserId * @param int $id * @return array * @throws \Exception */ public function delete(int $noteUserId, int $id): array { $note = $this->getOwnedNote($noteUserId, $id); $note->deleted_at = time(); $note->updated_at = time(); $note->save(); return [ 'deleted' => true, 'id' => $id, ]; } /** * 保存转写内容 * * @param int $noteUserId * @param int $id * @param array $data * @return array * @throws \Exception */ public function saveTranscript(int $noteUserId, int $id, array $data): array { $note = $this->getOwnedNote($noteUserId, $id); $segmentNo = max(0, (int) ($data['segment_no'] ?? 0)); $now = time(); $transcript = NoteTranscript::findByNoteAndSegment($id, $segmentNo); if (!$transcript) { $transcript = new NoteTranscript(); $transcript->note_id = $id; $transcript->segment_no = $segmentNo; $transcript->created_at = $now; } $transcript->segment_text = (string) ($data['segment_text'] ?? ''); $transcript->full_text = (string) ($data['full_text'] ?? ''); $transcript->is_final = empty($data['is_final']) ? 0 : 1; $transcript->audio_duration_ms = max(0, (int) ($data['audio_duration_ms'] ?? 0)); $transcript->save(); $note->transcript_text = $transcript->full_text; $note->audio_duration_ms = max($note->audio_duration_ms, (int) $transcript->audio_duration_ms); $note->last_transcript_time = $now; $note->updated_at = $now; if ($note->title === '') { $note->title = $this->normalizeTitle('', $note->transcript_text); } $note->save(); return [ 'note_id' => $id, 'segment_no' => $segmentNo, 'is_final' => (int) $transcript->is_final, 'transcript_text' => (string) $note->transcript_text, 'audio_duration_ms' => (int) $note->audio_duration_ms, 'updated_at' => (int) $note->updated_at, ]; } /** * 上传录音文件 * * @param int $noteUserId * @param int $id * @param File $file * @param int $durationMs * @return array * @throws \Exception */ public function uploadAudio(int $noteUserId, int $id, File $file, int $durationMs = 0): array { $note = $this->getOwnedNote($noteUserId, $id); $savedPath = str_replace('\\', '/', Filesystem::disk('public')->putFile('note/audio', $file)); $now = time(); $audio = NoteAudio::findLatestByNoteId($id); if (!$audio) { $audio = new NoteAudio(); $audio->note_id = $id; $audio->created_at = $now; } $audio->disk = 'public'; $audio->file_path = $savedPath; $audio->file_url = $this->buildPublicFileUrl($savedPath); $audio->file_size = (int) $file->getSize(); $audio->mime_type = (string) $file->getMime(); $audio->duration_ms = max(0, $durationMs); $audio->updated_at = $now; $audio->save(); $note->audio_duration_ms = max((int) $note->audio_duration_ms, (int) $audio->duration_ms); if ($note->source_type === 'text') { $note->source_type = trim((string) $note->content) !== '' ? 'mix' : 'audio'; } $note->updated_at = $now; $note->save(); return $this->formatAudio($audio); } /** * 创建分享 * * @param int $noteUserId * @param int $id * @return array * @throws \Exception */ public function createShare(int $noteUserId, int $id): array { $note = $this->getOwnedNote($noteUserId, $id); $share = NoteShare::findActiveByNote($id, $noteUserId); $now = time(); if (!$share) { $share = new NoteShare(); $share->note_id = $id; $share->note_user_id = $noteUserId; $share->share_token = bin2hex(random_bytes(16)); $share->view_count = 0; $share->status = 1; $share->created_at = $now; } $share->title = (string) $note->title; $share->updated_at = $now; $share->save(); return [ 'note_id' => $id, 'share_token' => (string) $share->share_token, 'share_path' => '/pages/note/edit?share_token=' . $share->share_token, 'title' => (string) $note->title, ]; } /** * 获取分享详情 * * @param string $token * @return array * @throws \Exception */ public function getSharedDetail(string $token): array { $share = NoteShare::findByToken($token); if (!$share) { throw new \Exception('分享内容不存在或已失效', 404); } if ((int) $share->expired_at > 0 && (int) $share->expired_at < time()) { throw new \Exception('分享已过期', 410); } $note = NoteItem::where('id', (int) $share->note_id) ->where('deleted_at', 0) ->find(); if (!$note) { throw new \Exception('分享内容不存在', 404); } $summary = NoteAiSummary::findLatestByNoteId((int) $note->id); $audio = NoteAudio::findLatestByNoteId((int) $note->id); $share->view_count = (int) $share->view_count + 1; $share->last_view_time = time(); $share->save(); $result = $this->formatNoteItem($note); $result['audio'] = $audio ? $this->formatAudio($audio) : null; $result['summary'] = $summary ? [ 'summary_text' => (string) $summary->summary_text, 'status' => (string) $summary->status, ] : null; $result['share'] = [ 'share_token' => (string) $share->share_token, 'title' => (string) $share->title, 'view_count' => (int) $share->view_count, ]; return $result; } /** * 获取当前用户拥有的笔记 * * @param int $noteUserId * @param int $id * @return NoteItem * @throws \Exception */ public function getOwnedNote(int $noteUserId, int $id): NoteItem { $note = NoteItem::findOwnedNote($noteUserId, $id); if (!$note) { throw new \Exception('笔记不存在', 404); } return $note; } /** * 格式化笔记返回 * * @param NoteItem $note * @return array */ private function formatNoteItem(NoteItem $note): array { return [ 'id' => (int) $note->id, 'note_user_id' => (int) $note->note_user_id, 'title' => (string) $note->title, 'content' => (string) $note->content, 'transcript_text' => (string) $note->transcript_text, 'source_type' => (string) $note->source_type, 'status' => (string) $note->status, 'audio_duration_ms' => (int) $note->audio_duration_ms, 'summary_status' => (string) $note->summary_status, 'last_transcript_time' => (int) $note->last_transcript_time, 'created_at' => (int) $note->created_at, 'updated_at' => (int) $note->updated_at, ]; } /** * 格式化音频附件 * * @param NoteAudio $audio * @return array */ private function formatAudio(NoteAudio $audio): array { return [ 'audio_id' => (int) $audio->id, 'disk' => (string) $audio->disk, 'file_path' => (string) $audio->file_path, 'audio_url' => (string) $audio->file_url, 'file_size' => (int) $audio->file_size, 'mime_type' => (string) $audio->mime_type, 'duration_ms' => (int) $audio->duration_ms, 'updated_at' => (int) $audio->updated_at, ]; } /** * 规范化标题 * * @param string $title * @param string $fallback * @return string */ private function normalizeTitle(string $title, string $fallback): string { $title = trim($title); if ($title !== '') { return mb_substr($title, 0, 255); } $fallback = trim(preg_replace('/\s+/', ' ', $fallback)); if ($fallback === '') { return '未命名笔记'; } return mb_substr($fallback, 0, 50); } /** * 解析 JSON 列表 * * @param string $value * @return array */ private function decodeJsonList(string $value): array { $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } /** * 拼接公开文件 URL * * @param string $savedPath * @return string */ private function buildPublicFileUrl(string $savedPath): string { return rtrim((string) request()->domain(), '/') . '/storage/' . ltrim($savedPath, '/'); } }