Files
mini_tp/app/note/service/NoteService.php
T
nepiedg 69eb3e5019 feat(note): add image upload functionality for notes
- 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.
2026-04-20 10:27:54 +00:00

551 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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/finfoThinkPHP 的 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, '/');
}
}