9cc82df980
- Replaced direct MIME type retrieval with a new method `detectAudioMimeType` to enhance stability in environments without fileinfo support. - The new method prioritizes file extensions and falls back to the original file name if necessary, ensuring reliable MIME type detection for audio files.
492 lines
15 KiB
PHP
492 lines
15 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);
|
||
}
|
||
|
||
/**
|
||
* 创建分享
|
||
*
|
||
* @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';
|
||
}
|
||
|
||
/**
|
||
* 规范化标题
|
||
*
|
||
* @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, '/');
|
||
}
|
||
}
|