diff --git a/app/note/controller/v1/Meta.php b/app/note/controller/v1/Meta.php index c0e6d38..5afdb63 100644 --- a/app/note/controller/v1/Meta.php +++ b/app/note/controller/v1/Meta.php @@ -11,7 +11,7 @@ use think\App; /** * 笔记模块元信息控制器 */ -class Meta extends BaseController +class c extends BaseController { /** * @var PlanningService diff --git a/app/note/controller/v1/Note.php b/app/note/controller/v1/Note.php index e7ba74a..3d263c1 100644 --- a/app/note/controller/v1/Note.php +++ b/app/note/controller/v1/Note.php @@ -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); + } + } } diff --git a/app/note/controller/v1/Share.php b/app/note/controller/v1/Share.php new file mode 100644 index 0000000..c34cbd9 --- /dev/null +++ b/app/note/controller/v1/Share.php @@ -0,0 +1,51 @@ +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); + } + } +} diff --git a/app/note/model/NoteAudio.php b/app/note/model/NoteAudio.php new file mode 100644 index 0000000..ea1759b --- /dev/null +++ b/app/note/model/NoteAudio.php @@ -0,0 +1,27 @@ +order('id', 'desc') + ->find(); + } +} diff --git a/app/note/model/NoteShare.php b/app/note/model/NoteShare.php new file mode 100644 index 0000000..522b65d --- /dev/null +++ b/app/note/model/NoteShare.php @@ -0,0 +1,36 @@ +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(); + } +} diff --git a/app/note/route/app.php b/app/note/route/app.php index 9113e2e..52313dd 100644 --- a/app/note/route/app.php +++ b/app/note/route/app.php @@ -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); diff --git a/app/note/service/NoteService.php b/app/note/service/NoteService.php index 21be883..1731d6b 100644 --- a/app/note/service/NoteService.php +++ b/app/note/service/NoteService.php @@ -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, '/'); + } } diff --git a/app/note/service/PlanningService.php b/app/note/service/PlanningService.php index 95d04e7..5d3945e 100644 --- a/app/note/service/PlanningService.php +++ b/app/note/service/PlanningService.php @@ -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' => '小程序分享路径', + ], + ], ]; } diff --git a/database.sql b/database.sql index d04658c..4d8ad42 100644 --- a/database.sql +++ b/database.sql @@ -103,3 +103,38 @@ CREATE TABLE IF NOT EXISTS `note_ai_summary` ( KEY `idx_status` (`status`), KEY `idx_summary_type` (`summary_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='note模块-AI总结结果表'; + +-- 笔记录音附件表 +CREATE TABLE IF NOT EXISTS `note_audio` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `note_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT 'note_item.id', + `disk` varchar(30) NOT NULL DEFAULT 'public' COMMENT '存储磁盘', + `file_path` varchar(255) NOT NULL DEFAULT '' COMMENT '磁盘相对路径', + `file_url` varchar(500) NOT NULL DEFAULT '' COMMENT '公开访问地址', + `file_size` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '文件大小', + `mime_type` varchar(100) NOT NULL DEFAULT '' COMMENT '文件类型', + `duration_ms` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '录音时长', + `created_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', + `updated_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_note_id` (`note_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='note模块-录音附件表'; + +-- 笔记分享表 +CREATE TABLE IF NOT EXISTS `note_share` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `note_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT 'note_item.id', + `note_user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT 'note_user.id', + `share_token` varchar(64) NOT NULL DEFAULT '' COMMENT '分享 token', + `title` varchar(255) NOT NULL DEFAULT '' COMMENT '分享标题', + `view_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '查看次数', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0失效 1有效', + `expired_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '过期时间,0不过期', + `last_view_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后查看时间', + `created_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', + `updated_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_share_token` (`share_token`), + KEY `idx_note_id` (`note_id`), + KEY `idx_note_user_id` (`note_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='note模块-分享记录表'; diff --git a/docs/note_api.md b/docs/note_api.md index 96b40dd..711a1ff 100644 --- a/docs/note_api.md +++ b/docs/note_api.md @@ -13,7 +13,9 @@ 1. 微信小程序登录 2. 笔记创建、列表、详情、更新、删除 3. 实时转写文本保存 -4. AI 总结生成与查询 +4. 录音文件上传与播放 +5. AI 总结生成与查询 +6. 笔记分享与分享只读访问 路由注册位置: @@ -67,7 +69,7 @@ - [database.sql](/root/work/tp/database.sql#L26) -当前 `note` 模块使用以下 4 张表: +当前 `note` 模块使用以下 6 张表: 1. `note_user` 用于小程序用户登录和用户资料 @@ -81,6 +83,12 @@ 4. `note_ai_summary` AI 总结结果表 +5. `note_audio` + 录音附件表,保存上传后的音频文件地址与时长 + +6. `note_share` + 分享记录表,保存分享 token 与查看次数 + --- ## 5. 接口列表 @@ -435,7 +443,48 @@ --- -### 5.10 生成 AI 总结 +### 5.10 上传录音文件 + +- 方法:`POST` +- 路径:`/note/v1/item/audio/:id` +- 是否鉴权:是 +- Content-Type:`multipart/form-data` + +表单参数: + +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| `audio` | file | 是 | 录音文件 | +| `audio_duration_ms` | int | 否 | 录音时长 | + +说明: + +- 上传成功后会返回可直接播放的 `audio_url` +- 后端会同步写入 `note_audio`,并更新笔记的录音时长 + +成功响应示例: + +```json +{ + "code": 200, + "msg": "上传成功", + "data": { + "audio_id": 2, + "disk": "public", + "file_path": "note/audio/20260417/test.m4a", + "audio_url": "http://127.0.0.1:8000/storage/note/audio/20260417/test.m4a", + "file_size": 20480, + "mime_type": "audio/mp4", + "duration_ms": 18500, + "updated_at": 1710000400 + }, + "time": 1710000400 +} +``` + +--- + +### 5.11 生成 AI 总结 - 方法:`POST` - 路径:`/note/v1/ai/summary/:id` @@ -492,7 +541,7 @@ --- -### 5.11 查看 AI 总结 +### 5.12 查看 AI 总结 - 方法:`GET` - 路径:`/note/v1/ai/summary/:id` @@ -504,6 +553,90 @@ --- +### 5.13 创建分享 + +- 方法:`POST` +- 路径:`/note/v1/share/create/:id` +- 是否鉴权:是 + +成功响应示例: + +```json +{ + "code": 200, + "msg": "分享已生成", + "data": { + "note_id": 10, + "share_token": "7a4d2f2d4a5f1b20f6f9670bb1f4d123", + "share_path": "/pages/note/edit?share_token=7a4d2f2d4a5f1b20f6f9670bb1f4d123", + "title": "会议纪要" + }, + "time": 1710000600 +} +``` + +说明: + +- 返回 `share_token` 和小程序页面路径 +- 小程序可用该路径做转发 + +--- + +### 5.14 读取分享内容 + +- 方法:`GET` +- 路径:`/note/v1/share/read/:token` +- 是否鉴权:否 + +成功响应示例: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "id": 10, + "note_user_id": 1, + "title": "会议纪要", + "content": "今天确认了下周需求排期", + "transcript_text": "今天确认了下周需求排期", + "source_type": "mix", + "status": "draft", + "audio_duration_ms": 18500, + "summary_status": "success", + "last_transcript_time": 1710000300, + "created_at": 1710000000, + "updated_at": 1710000600, + "audio": { + "audio_id": 2, + "disk": "public", + "file_path": "note/audio/20260417/test.m4a", + "audio_url": "http://127.0.0.1:8000/storage/note/audio/20260417/test.m4a", + "file_size": 20480, + "mime_type": "audio/mp4", + "duration_ms": 18500, + "updated_at": 1710000400 + }, + "summary": { + "summary_text": "今天确认了下周需求排期,需要继续跟进接口联调。", + "status": "success" + }, + "share": { + "share_token": "7a4d2f2d4a5f1b20f6f9670bb1f4d123", + "title": "会议纪要", + "view_count": 3 + } + }, + "time": 1710000601 +} +``` + +说明: + +- 用于收件人直接查看分享的笔记内容、录音和 AI 摘要 + +--- + ## 6. 错误码约定 当前模块沿用项目统一返回结构: @@ -537,8 +670,10 @@ 3. 保存返回的 `token` 4. 创建笔记 `POST /note/v1/item/create` 5. 录音转写过程中持续调用 `POST /note/v1/item/transcript/:id` -6. 需要生成总结时调用 `POST /note/v1/ai/summary/:id` -7. 打开详情页时调用 `GET /note/v1/item/:id` +6. 录音停止后上传录音文件 `POST /note/v1/item/audio/:id` +7. 需要生成总结时调用 `POST /note/v1/ai/summary/:id` +8. 分享前调用 `POST /note/v1/share/create/:id` +9. 打开详情页时调用 `GET /note/v1/item/:id` --- @@ -546,4 +681,4 @@ 1. AI 总结目前是规则版,不是大模型版 2. 微信登录依赖服务端已正确配置 `WECHAT_MINI_APPID` 和 `WECHAT_MINI_SECRET` -3. 当前未实现文件音频上传,只实现了“转写文本写回后端”的数据链路 +3. 分享读取接口为公开接口,建议前端仅用于只读展示 diff --git a/public/storage/note/audio/20260417/00a3e4cf9f27ce67356ad755410d3e08.aac b/public/storage/note/audio/20260417/00a3e4cf9f27ce67356ad755410d3e08.aac new file mode 100644 index 0000000..aa452ae Binary files /dev/null and b/public/storage/note/audio/20260417/00a3e4cf9f27ce67356ad755410d3e08.aac differ diff --git a/public/storage/note/audio/20260417/42e8d9f6af7322edc1ac68496957e305.aac b/public/storage/note/audio/20260417/42e8d9f6af7322edc1ac68496957e305.aac new file mode 100644 index 0000000..c0e5e88 --- /dev/null +++ b/public/storage/note/audio/20260417/42e8d9f6af7322edc1ac68496957e305.aac @@ -0,0 +1 @@ +uZ \ No newline at end of file diff --git a/public/storage/note/audio/20260417/788b3d3555ac0b10f0d8dc1438aebd1a.aac b/public/storage/note/audio/20260417/788b3d3555ac0b10f0d8dc1438aebd1a.aac new file mode 100644 index 0000000..1b5d01f Binary files /dev/null and b/public/storage/note/audio/20260417/788b3d3555ac0b10f0d8dc1438aebd1a.aac differ diff --git a/public/storage/note/audio/20260417/8c3d9502811ecbdb8087f91a64150cce.aac b/public/storage/note/audio/20260417/8c3d9502811ecbdb8087f91a64150cce.aac new file mode 100644 index 0000000..a72b2dd Binary files /dev/null and b/public/storage/note/audio/20260417/8c3d9502811ecbdb8087f91a64150cce.aac differ diff --git a/route/app.php b/route/app.php index 19aacd9..ee8a4c5 100644 --- a/route/app.php +++ b/route/app.php @@ -10,6 +10,7 @@ use app\note\controller\v1\Ai as NoteAi; use app\note\controller\v1\Auth as NoteAuth; use app\note\controller\v1\Meta as NoteMeta; use app\note\controller\v1\Note as NoteItem; +use app\note\controller\v1\Share as NoteShare; /** * 全局路由入口。 @@ -54,6 +55,7 @@ Route::group('api/v1/video-work', function () { Route::group('note/v1', function () { Route::get('meta/interfaces', [NoteMeta::class, 'interfaces']); Route::post('auth/wechat-login', [NoteAuth::class, 'wechatLogin']); + Route::get('share/read/:token', [NoteShare::class, 'read']); }); // note v1 笔记小程序模块接口(需登录) @@ -66,7 +68,9 @@ Route::group('note/v1', function () { Route::post('item/update/:id', [NoteItem::class, 'update']); Route::post('item/delete/:id', [NoteItem::class, 'delete']); Route::post('item/transcript/:id', [NoteItem::class, 'transcript']); + Route::post('item/audio/:id', [NoteItem::class, 'audio']); Route::post('ai/summary/:id', [NoteAi::class, 'summary']); Route::get('ai/summary/:id', [NoteAi::class, 'readSummary']); + Route::post('share/create/:id', [NoteShare::class, 'create']); })->middleware(\app\api\middleware\Auth::class);