69eb3e5019
- Implemented a new endpoint in the Note controller to handle image uploads associated with notes. - Added the `uploadImage` method in NoteService to manage image storage and return public URLs. - Updated API routes to include the new image upload endpoint, enhancing note management capabilities.
551 lines
17 KiB
PHP
551 lines
17 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\note\service;
|
||
|
||
use app\note\model\NoteAudio;
|
||
use app\note\model\NoteAiSummary;
|
||
use app\note\model\NoteItem;
|
||
use app\note\model\NoteShare;
|
||
use app\note\model\NoteTranscript;
|
||
use think\File;
|
||
use think\facade\Filesystem;
|
||
|
||
/**
|
||
* note 模块笔记服务
|
||
*/
|
||
class NoteService
|
||
{
|
||
/**
|
||
* 创建笔记
|
||
*
|
||
* @param int $noteUserId
|
||
* @param array $data
|
||
* @return array
|
||
*/
|
||
public function create(int $noteUserId, array $data): array
|
||
{
|
||
$now = time();
|
||
$note = new NoteItem();
|
||
$note->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();
|
||
/**
|
||
* 线上环境若未启用 fileinfo/finfo,ThinkPHP 的 getMime() 会直接抛错。
|
||
* 这里改为按文件扩展名做稳定兜底,避免“文件已写入磁盘但接口因取 mime 失败而整体报错”。
|
||
*/
|
||
$audio->mime_type = $this->detectAudioMimeType($savedPath, $file);
|
||
$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);
|
||
}
|
||
|
||
/**
|
||
* 上传笔记图片。
|
||
*
|
||
* 这里不额外建表,直接返回公开图片地址,由前端把图片标记写回 note.content,
|
||
* 以便在跨设备打开同一笔记时仍能恢复图片内容。
|
||
*
|
||
* @param int $noteUserId
|
||
* @param int $id
|
||
* @param File $file
|
||
* @return array
|
||
* @throws \Exception
|
||
*/
|
||
public function uploadImage(int $noteUserId, int $id, File $file): array
|
||
{
|
||
$this->getOwnedNote($noteUserId, $id);
|
||
$savedPath = str_replace('\\', '/', Filesystem::disk('public')->putFile('note/image', $file));
|
||
|
||
return [
|
||
'disk' => 'public',
|
||
'file_path' => $savedPath,
|
||
'image_url' => $this->buildPublicFileUrl($savedPath),
|
||
'file_size' => (int) $file->getSize(),
|
||
'mime_type' => $this->detectImageMimeType($savedPath, $file),
|
||
'updated_at' => time(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 创建分享
|
||
*
|
||
* @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,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 推断音频 MIME。
|
||
*
|
||
* 优先使用文件扩展名,避免依赖 fileinfo 扩展;若扩展名缺失,再尝试读取客户端原始文件名。
|
||
*
|
||
* @param string $savedPath
|
||
* @param File $file
|
||
* @return string
|
||
*/
|
||
private function detectAudioMimeType(string $savedPath, File $file): string
|
||
{
|
||
$extension = strtolower((string) pathinfo($savedPath, PATHINFO_EXTENSION));
|
||
if ($extension === '') {
|
||
$extension = strtolower((string) pathinfo((string) $file->getOriginalName(), PATHINFO_EXTENSION));
|
||
}
|
||
|
||
$mimeMap = [
|
||
'aac' => 'audio/aac',
|
||
'amr' => 'audio/amr',
|
||
'm4a' => 'audio/mp4',
|
||
'mp3' => 'audio/mpeg',
|
||
'mp4' => 'audio/mp4',
|
||
'ogg' => 'audio/ogg',
|
||
'pcm' => 'audio/L16',
|
||
'wav' => 'audio/wav',
|
||
'webm' => 'audio/webm',
|
||
];
|
||
|
||
return $mimeMap[$extension] ?? 'application/octet-stream';
|
||
}
|
||
|
||
/**
|
||
* 推断图片 MIME。
|
||
*
|
||
* 与音频上传一样,这里优先按扩展名兜底,避免依赖 fileinfo 扩展。
|
||
*
|
||
* @param string $savedPath
|
||
* @param File $file
|
||
* @return string
|
||
*/
|
||
private function detectImageMimeType(string $savedPath, File $file): string
|
||
{
|
||
$extension = strtolower((string) pathinfo($savedPath, PATHINFO_EXTENSION));
|
||
if ($extension === '') {
|
||
$extension = strtolower((string) pathinfo((string) $file->getOriginalName(), PATHINFO_EXTENSION));
|
||
}
|
||
|
||
$mimeMap = [
|
||
'avif' => 'image/avif',
|
||
'bmp' => 'image/bmp',
|
||
'gif' => 'image/gif',
|
||
'heic' => 'image/heic',
|
||
'heif' => 'image/heif',
|
||
'jpeg' => 'image/jpeg',
|
||
'jpg' => 'image/jpeg',
|
||
'png' => 'image/png',
|
||
'svg' => 'image/svg+xml',
|
||
'webp' => 'image/webp',
|
||
];
|
||
|
||
return $mimeMap[$extension] ?? 'application/octet-stream';
|
||
}
|
||
|
||
/**
|
||
* 规范化标题
|
||
*
|
||
* @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, '/');
|
||
}
|
||
}
|