feat(note): add audio upload and sharing functionality

- Introduced `note_audio` table for storing audio attachments related to notes.
- Implemented audio upload endpoint in `Note` controller to handle audio file uploads.
- Added sharing functionality with `note_share` table to manage share tokens and view counts.
- Updated API routes to include endpoints for audio uploads and share creation.
- Enhanced documentation to reflect new audio and sharing features.
This commit is contained in:
nepiedg
2026-04-17 10:33:33 +00:00
parent 84e1c0daac
commit 36c506f4bf
15 changed files with 507 additions and 8 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ use think\App;
/**
* 笔记模块元信息控制器
*/
class Meta extends BaseController
class c extends BaseController
{
/**
* @var PlanningService
+25
View File
@@ -8,6 +8,7 @@ use app\note\controller\BaseController;
use app\note\service\NoteService;
use think\App;
use think\exception\ValidateException;
use think\Request;
/**
* 笔记控制器
@@ -153,4 +154,28 @@ class Note extends BaseController
return Response::error($e->getMessage(), $e->getCode() ?: 500);
}
}
/**
* 上传笔记录音
* POST /note/v1/item/audio/:id
*/
public function audio(Request $request, int $id)
{
try {
if ($id <= 0) {
return Response::error('笔记 ID 不正确', 400);
}
$file = $request->file('audio');
if (!$file) {
return Response::error('录音文件不能为空', 400);
}
$durationMs = (int) $request->post('audio_duration_ms', 0);
$result = $this->noteService->uploadAudio($this->getCurrentNoteUserId(), $id, $file, $durationMs);
return Response::success($result, '上传成功');
} catch (\Throwable $e) {
return Response::error($e->getMessage(), $e->getCode() ?: 500);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace app\note\controller\v1;
use app\api\common\Response;
use app\note\controller\BaseController;
use app\note\service\NoteService;
use think\App;
/**
* 笔记分享控制器
*/
class Share extends BaseController
{
protected $noteService;
public function __construct(App $app)
{
parent::__construct($app);
$this->noteService = new NoteService();
}
public function create(int $id)
{
try {
if ($id <= 0) {
return Response::error('笔记 ID 不正确', 400);
}
$result = $this->noteService->createShare($this->getCurrentNoteUserId(), $id);
return Response::success($result, '分享已生成');
} catch (\Throwable $e) {
return Response::error($e->getMessage(), $e->getCode() ?: 500);
}
}
public function read(string $token)
{
try {
if (trim($token) === '') {
return Response::error('分享标识不能为空', 400);
}
$result = $this->noteService->getSharedDetail(trim($token));
return Response::success($result);
} catch (\Throwable $e) {
return Response::error($e->getMessage(), $e->getCode() ?: 500);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace app\note\model;
use think\Model;
/**
* note 模块录音附件模型
*/
class NoteAudio extends Model
{
protected $connection = 'dbnote';
protected $name = 'note_audio';
protected $pk = 'id';
protected $autoWriteTimestamp = false;
public static function findLatestByNoteId(int $noteId): ?self
{
return self::where('note_id', $noteId)
->order('id', 'desc')
->find();
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace app\note\model;
use think\Model;
/**
* note 模块分享模型
*/
class NoteShare extends Model
{
protected $connection = 'dbnote';
protected $name = 'note_share';
protected $pk = 'id';
protected $autoWriteTimestamp = false;
public static function findActiveByNote(int $noteId, int $noteUserId): ?self
{
return self::where('note_id', $noteId)
->where('note_user_id', $noteUserId)
->where('status', 1)
->order('id', 'desc')
->find();
}
public static function findByToken(string $token): ?self
{
return self::where('share_token', $token)
->where('status', 1)
->find();
}
}
+4
View File
@@ -6,6 +6,7 @@ use app\note\controller\v1\Ai;
use app\note\controller\v1\Auth;
use app\note\controller\v1\Meta;
use app\note\controller\v1\Note;
use app\note\controller\v1\Share;
/**
* note 应用路由
@@ -19,6 +20,7 @@ use app\note\controller\v1\Note;
// v1 笔记模块接口规划(公开)
Route::get('v1/meta/interfaces', [Meta::class, 'interfaces']);
Route::post('v1/auth/wechat-login', [Auth::class, 'wechatLogin']);
Route::get('v1/share/read/:token', [Share::class, 'read']);
// v1 笔记模块接口(需登录)
Route::group('v1', function () {
@@ -30,7 +32,9 @@ Route::group('v1', function () {
Route::post('item/update/:id', [Note::class, 'update']);
Route::post('item/delete/:id', [Note::class, 'delete']);
Route::post('item/transcript/:id', [Note::class, 'transcript']);
Route::post('item/audio/:id', [Note::class, 'audio']);
Route::post('ai/summary/:id', [Ai::class, 'summary']);
Route::get('ai/summary/:id', [Ai::class, 'readSummary']);
Route::post('share/create/:id', [Share::class, 'create']);
})->middleware(\app\api\middleware\Auth::class);
+162
View File
@@ -3,9 +3,13 @@ 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 模块笔记服务
@@ -98,8 +102,10 @@ class NoteService
{
$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,
@@ -216,6 +222,131 @@ class NoteService
];
}
/**
* 上传录音文件
*
* @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;
}
/**
* 获取当前用户拥有的笔记
*
@@ -258,6 +389,26 @@ class NoteService
];
}
/**
* 格式化音频附件
*
* @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,
];
}
/**
* 规范化标题
*
@@ -291,4 +442,15 @@ class NoteService
$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, '/');
}
}
+19
View File
@@ -48,14 +48,19 @@ class PlanningService
['method' => 'POST', 'path' => '/note/v1/item/update/:id', 'desc' => '更新笔记'],
['method' => 'POST', 'path' => '/note/v1/item/delete/:id', 'desc' => '删除笔记'],
['method' => 'POST', 'path' => '/note/v1/item/transcript/:id', 'desc' => '保存实时转写内容'],
['method' => 'POST', 'path' => '/note/v1/item/audio/:id', 'desc' => '上传录音附件'],
['method' => 'POST', 'path' => '/note/v1/ai/summary/:id', 'desc' => '发起 AI 总结'],
['method' => 'GET', 'path' => '/note/v1/ai/summary/:id', 'desc' => '查看 AI 总结结果'],
['method' => 'POST', 'path' => '/note/v1/share/create/:id', 'desc' => '生成分享标识'],
['method' => 'GET', 'path' => '/note/v1/share/read/:token', 'desc' => '读取分享内容'],
],
'suggested_tables' => [
'note_user',
'note_item',
'note_transcript',
'note_ai_summary',
'note_audio',
'note_share',
],
'development_priority' => [
'1. 先落小程序登录,打通微信 openid 与 JWT',
@@ -157,6 +162,20 @@ class PlanningService
'audio_duration_ms' => '当前累计录音时长,可选',
],
],
'audio' => [
'route' => 'POST /note/v1/item/audio/:id',
'request' => [
'audio' => '录音文件 multipart 字段,必填',
'audio_duration_ms' => '录音时长,可选',
],
],
'share' => [
'route' => 'POST /note/v1/share/create/:id',
'response' => [
'share_token' => '分享 token',
'share_path' => '小程序分享路径',
],
],
];
}