diff --git a/.example.env b/.example.env index c457fe5..c2c9a87 100644 --- a/.example.env +++ b/.example.env @@ -8,4 +8,14 @@ DB_PASS = password DB_PORT = 3306 DB_CHARSET = utf8 +DB_NOTE_HOSTNAME = 127.0.0.1 +DB_NOTE_DATABASE = tp +DB_NOTE_USERNAME = tp +DB_NOTE_PASSWORD = your_note_password +DB_NOTE_HOSTPORT = 3306 +DB_NOTE_CHARSET = utf8mb4 + +WECHAT_MINI_APPID = your_wechat_mini_appid +WECHAT_MINI_SECRET = your_wechat_mini_secret + DEFAULT_LANG = zh-cn diff --git a/app/api/controller/BaseController.php b/app/api/controller/BaseController.php index 8e3b26f..c4be72e 100644 --- a/app/api/controller/BaseController.php +++ b/app/api/controller/BaseController.php @@ -42,6 +42,36 @@ abstract class BaseController protected function initialize() {} + /** + * 获取当前登录用户载荷。 + * + * 说明: + * - 登录态由路由中间件 `\app\api\middleware\Auth` 统一校验 + * - 控制器只负责读取中间件已经注入的用户信息 + * + * @return array + */ + protected function getLoginPayload(): array + { + $payload = $this->request->middleware('payload', []); + + if (empty($payload['userid'])) { + throw new \RuntimeException('未登录', 401); + } + + return $payload; + } + + /** + * 获取当前登录用户 ID。 + * + * @return int + */ + protected function getLoginUserId(): int + { + return (int) $this->getLoginPayload()['userid']; + } + /** * 成功响应 * @param mixed $data 返回数据 diff --git a/app/api/controller/v1/Auth.php b/app/api/controller/v1/Auth.php index 4919ba1..092a89e 100644 --- a/app/api/controller/v1/Auth.php +++ b/app/api/controller/v1/Auth.php @@ -117,12 +117,9 @@ class Auth extends BaseController public function me() { try { - $payload = $this->request->payload ?? null; - if (!$payload) { - return Response::error('未登录', 401); - } + $userid = $this->getLoginUserId(); - $result = $this->authService->getUserInfo($payload['userid']); + $result = $this->authService->getUserInfo($userid); return Response::success($result); } catch (\Exception $e) { @@ -146,10 +143,7 @@ class Auth extends BaseController public function password() { try { - $payload = $this->request->payload ?? null; - if (!$payload) { - return Response::error('未登录', 401); - } + $userid = $this->getLoginUserId(); $data = $this->request->post(); @@ -164,7 +158,7 @@ class Auth extends BaseController ])->check($data); $this->authService->changePassword( - $payload['userid'], + $userid, $data['old_password'], $data['new_password'] ); diff --git a/app/api/controller/v1/Platform.php b/app/api/controller/v1/Platform.php index ae7fb37..9104c41 100644 --- a/app/api/controller/v1/Platform.php +++ b/app/api/controller/v1/Platform.php @@ -40,10 +40,7 @@ class Platform extends BaseController public function accounts() { try { - $payload = $this->request->payload ?? null; - if (!$payload || empty($payload['userid'])) { - return Response::error('未登录', 401); - } + $userid = $this->getLoginUserId(); $platformInput = $this->request->get('platform'); $platform = null; @@ -56,7 +53,7 @@ class Platform extends BaseController $platform = (int) $platformInput; } - $result = $this->platformService->getAccountList((int) $payload['userid'], $platform); + $result = $this->platformService->getAccountList($userid, $platform); return Response::success($result); } catch (\Exception $exception) { diff --git a/app/api/controller/v1/PublishPlan.php b/app/api/controller/v1/PublishPlan.php index f6fc914..f0f47c7 100644 --- a/app/api/controller/v1/PublishPlan.php +++ b/app/api/controller/v1/PublishPlan.php @@ -45,12 +45,9 @@ class PublishPlan extends BaseController public function index() { try { - $payload = $this->request->payload ?? null; - if (!$payload || empty($payload['userid'])) { - return Response::error('未登录', 401); - } + $userid = $this->getLoginUserId(); - $result = $this->publishPlanService->getPlanList((int) $payload['userid'], [ + $result = $this->publishPlanService->getPlanList($userid, [ 'status' => (string) $this->request->get('status', 'all'), 'page' => (int) $this->request->get('page', 1), 'page_size' => (int) $this->request->get('page_size', 20), @@ -74,12 +71,9 @@ class PublishPlan extends BaseController public function start(int $id) { try { - $payload = $this->request->payload ?? null; - if (!$payload || empty($payload['userid'])) { - return Response::error('未登录', 401); - } + $userid = $this->getLoginUserId(); - $result = $this->publishPlanService->startPlan((int) $payload['userid'], $id); + $result = $this->publishPlanService->startPlan($userid, $id); return Response::success($result, '设置成功'); } catch (\Exception $exception) { @@ -100,12 +94,9 @@ class PublishPlan extends BaseController public function stop(int $id) { try { - $payload = $this->request->payload ?? null; - if (!$payload || empty($payload['userid'])) { - return Response::error('未登录', 401); - } + $userid = $this->getLoginUserId(); - $result = $this->publishPlanService->stopPlan((int) $payload['userid'], $id); + $result = $this->publishPlanService->stopPlan($userid, $id); return Response::success($result, '设置成功'); } catch (\Exception $exception) { diff --git a/app/api/controller/v1/VideoWork.php b/app/api/controller/v1/VideoWork.php index 710cd15..efeb354 100644 --- a/app/api/controller/v1/VideoWork.php +++ b/app/api/controller/v1/VideoWork.php @@ -46,12 +46,9 @@ class VideoWork extends BaseController public function index() { try { - $payload = $this->request->payload ?? null; - if (!$payload || empty($payload['userid'])) { - return Response::error('未登录', 401); - } + $userid = $this->getLoginUserId(); - $result = $this->videoWorkService->getVideoList((int) $payload['userid'], [ + $result = $this->videoWorkService->getVideoList($userid, [ 'platform' => $this->request->get('platform', 'all'), 'vuid' => $this->request->get('vuid', 'all'), 'page' => (int) $this->request->get('page', 1), diff --git a/app/api/middleware/Auth.php b/app/api/middleware/Auth.php index c3a7648..0391176 100644 --- a/app/api/middleware/Auth.php +++ b/app/api/middleware/Auth.php @@ -33,9 +33,11 @@ class Auth return Response::error('令牌无效或已过期', 401); } - // 将用户信息注入请求 - $request->payload = $payload; - $request->userid = $payload['userid'] ?? null; + // 将用户信息写入请求中间件上下文,供控制器统一读取 + $request->withMiddleware([ + 'payload' => $payload, + 'userid' => (int) ($payload['userid'] ?? 0), + ]); return $next($request); } diff --git a/app/note/controller/BaseController.php b/app/note/controller/BaseController.php new file mode 100644 index 0000000..8018c3c --- /dev/null +++ b/app/note/controller/BaseController.php @@ -0,0 +1,30 @@ +getLoginPayload(); + + if (($payload['guard'] ?? '') !== 'note') { + throw new \RuntimeException('note 模块登录态无效', 401); + } + + return (int) ($payload['userid'] ?? 0); + } +} diff --git a/app/note/controller/v1/Ai.php b/app/note/controller/v1/Ai.php new file mode 100644 index 0000000..3fbf02f --- /dev/null +++ b/app/note/controller/v1/Ai.php @@ -0,0 +1,81 @@ +aiService = new AiService(); + $this->noteService = new NoteService(); + } + + /** + * 发起 AI 总结 + * POST /note/v1/ai/summary/:id + */ + public function summary(int $id) + { + try { + $noteUserId = $this->getCurrentNoteUserId(); + + if ($id <= 0) { + return Response::error('笔记 ID 不正确', 400); + } + + $note = $this->noteService->getOwnedNote($noteUserId, $id); + $data = $this->request->post(); + $result = $this->aiService->createSummary( + $note, + (string) ($data['summary_type'] ?? 'brief'), + !empty($data['force_refresh']) + ); + + return Response::success($result, '总结生成成功'); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 查看 AI 总结结果 + * GET /note/v1/ai/summary/:id + */ + public function readSummary(int $id) + { + try { + $noteUserId = $this->getCurrentNoteUserId(); + + if ($id <= 0) { + return Response::error('笔记 ID 不正确', 400); + } + + $this->noteService->getOwnedNote($noteUserId, $id); + $result = $this->aiService->getSummary($id); + return Response::success($result); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } +} diff --git a/app/note/controller/v1/Auth.php b/app/note/controller/v1/Auth.php new file mode 100644 index 0000000..a3abd80 --- /dev/null +++ b/app/note/controller/v1/Auth.php @@ -0,0 +1,70 @@ +authService = new AuthService(); + } + + /** + * 微信小程序登录 + * POST /note/v1/auth/wechat-login + */ + public function wechatLogin() + { + try { + $data = $this->request->post(); + + validate([ + 'code' => 'require', + ], [ + 'code.require' => '微信登录 code 不能为空', + ])->check($data); + + $result = $this->authService->wechatLogin( + (string) $data['code'], + isset($data['nickname']) ? (string) $data['nickname'] : null, + isset($data['avatar_url']) ? (string) $data['avatar_url'] : null + ); + + return Response::success($result, '登录成功'); + } catch (ValidateException $e) { + return Response::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 获取当前小程序用户信息 + * GET /note/v1/auth/me + */ + public function me() + { + try { + $result = $this->authService->getUserInfo($this->getCurrentNoteUserId()); + return Response::success($result); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } +} diff --git a/app/note/controller/v1/Meta.php b/app/note/controller/v1/Meta.php new file mode 100644 index 0000000..c0e6d38 --- /dev/null +++ b/app/note/controller/v1/Meta.php @@ -0,0 +1,35 @@ +planningService = new PlanningService(); + } + + /** + * 获取 note 模块接口规划概览 + * GET /note/v1/meta/interfaces + */ + public function interfaces() + { + return Response::success($this->planningService->getModuleOverview()); + } +} diff --git a/app/note/controller/v1/Note.php b/app/note/controller/v1/Note.php new file mode 100644 index 0000000..e7ba74a --- /dev/null +++ b/app/note/controller/v1/Note.php @@ -0,0 +1,156 @@ +noteService = new NoteService(); + } + + /** + * 创建笔记 + * POST /note/v1/item/create + */ + public function create() + { + try { + $noteUserId = $this->getCurrentNoteUserId(); + $data = $this->request->post(); + + validate([ + 'source_type' => 'require|in:text,audio,mix', + ], [ + 'source_type.require' => '笔记来源类型不能为空', + 'source_type.in' => '笔记来源类型不正确', + ])->check($data); + + $result = $this->noteService->create($noteUserId, $data); + return Response::success($result, '创建成功'); + } catch (ValidateException $e) { + return Response::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 笔记列表 + * GET /note/v1/item/list + */ + public function index() + { + try { + $result = $this->noteService->getList($this->getCurrentNoteUserId(), $this->request->get()); + return Response::success($result); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 笔记详情 + * GET /note/v1/item/:id + */ + public function read(int $id) + { + try { + $noteUserId = $this->getCurrentNoteUserId(); + + if ($id <= 0) { + return Response::error('笔记 ID 不正确', 400); + } + + $result = $this->noteService->getDetail($noteUserId, $id); + return Response::success($result); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 更新笔记 + * POST /note/v1/item/update/:id + */ + public function update(int $id) + { + try { + $noteUserId = $this->getCurrentNoteUserId(); + + if ($id <= 0) { + return Response::error('笔记 ID 不正确', 400); + } + + $result = $this->noteService->update($noteUserId, $id, $this->request->post()); + return Response::success($result, '更新成功'); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 删除笔记 + * POST /note/v1/item/delete/:id + */ + public function delete(int $id) + { + try { + $noteUserId = $this->getCurrentNoteUserId(); + + if ($id <= 0) { + return Response::error('笔记 ID 不正确', 400); + } + + $result = $this->noteService->delete($noteUserId, $id); + return Response::success($result, '删除成功'); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + /** + * 保存实时转写内容 + * POST /note/v1/item/transcript/:id + */ + public function transcript(int $id) + { + try { + $noteUserId = $this->getCurrentNoteUserId(); + $data = $this->request->post(); + + if ($id <= 0) { + return Response::error('笔记 ID 不正确', 400); + } + + validate([ + 'full_text' => 'require', + ], [ + 'full_text.require' => '转写文本不能为空', + ])->check($data); + + $result = $this->noteService->saveTranscript($noteUserId, $id, $data); + return Response::success($result, '转写保存成功'); + } catch (ValidateException $e) { + return Response::error($e->getMessage(), 400); + } catch (\Throwable $e) { + return Response::error($e->getMessage(), $e->getCode() ?: 500); + } + } +} diff --git a/app/note/model/NoteAiSummary.php b/app/note/model/NoteAiSummary.php new file mode 100644 index 0000000..189cbda --- /dev/null +++ b/app/note/model/NoteAiSummary.php @@ -0,0 +1,33 @@ +order('id', 'desc') + ->find(); + } +} diff --git a/app/note/model/NoteItem.php b/app/note/model/NoteItem.php new file mode 100644 index 0000000..49e18e5 --- /dev/null +++ b/app/note/model/NoteItem.php @@ -0,0 +1,47 @@ +where('deleted_at', 0); + } + + /** + * 查询当前用户拥有的笔记 + * + * @param int $noteUserId + * @param int $id + * @return self|null + */ + public static function findOwnedNote(int $noteUserId, int $id): ?self + { + return self::buildUserQuery($noteUserId) + ->where('id', $id) + ->find(); + } +} diff --git a/app/note/model/NoteTranscript.php b/app/note/model/NoteTranscript.php new file mode 100644 index 0000000..853d243 --- /dev/null +++ b/app/note/model/NoteTranscript.php @@ -0,0 +1,34 @@ +where('segment_no', $segmentNo) + ->find(); + } +} diff --git a/app/note/model/NoteUser.php b/app/note/model/NoteUser.php new file mode 100644 index 0000000..385a7bb --- /dev/null +++ b/app/note/model/NoteUser.php @@ -0,0 +1,46 @@ +where('deleted_at', 0) + ->find(); + } + + /** + * 根据主键查找用户 + * + * @param int $id + * @return self|null + */ + public static function findActiveById(int $id): ?self + { + return self::where('id', $id) + ->where('deleted_at', 0) + ->find(); + } +} diff --git a/app/note/route/app.php b/app/note/route/app.php new file mode 100644 index 0000000..9113e2e --- /dev/null +++ b/app/note/route/app.php @@ -0,0 +1,36 @@ +middleware(\app\api\middleware\Auth::class); diff --git a/app/note/service/AiService.php b/app/note/service/AiService.php new file mode 100644 index 0000000..d109973 --- /dev/null +++ b/app/note/service/AiService.php @@ -0,0 +1,228 @@ +id); + + if ($existing && !$forceRefresh && $existing->status === 'success') { + return $this->formatSummary($existing); + } + + $sourceText = trim((string) $note->content); + if ($sourceText === '') { + $sourceText = trim((string) $note->transcript_text); + } + if ($sourceText === '') { + throw new \Exception('笔记内容为空,无法生成总结', 400); + } + + $generated = $this->generateSummaryPayload($sourceText, $summaryType); + $now = time(); + + $summary = $existing ?: new NoteAiSummary(); + if (!$existing) { + $summary->note_id = (int) $note->id; + $summary->created_at = $now; + } + + $summary->summary_type = $summaryType; + $summary->summary_text = $generated['summary_text']; + $summary->todo_list = json_encode($generated['todo_list'], JSON_UNESCAPED_UNICODE); + $summary->keywords = json_encode($generated['keywords'], JSON_UNESCAPED_UNICODE); + $summary->status = 'success'; + $summary->error_message = ''; + $summary->updated_at = $now; + $summary->save(); + + $note->summary_status = 'success'; + $note->updated_at = $now; + $note->save(); + + return $this->formatSummary($summary); + } + + /** + * 读取总结 + * + * @param int $noteId + * @return array + * @throws \Exception + */ + public function getSummary(int $noteId): array + { + $summary = NoteAiSummary::findLatestByNoteId($noteId); + if (!$summary) { + throw new \Exception('该笔记暂无总结结果', 404); + } + + return $this->formatSummary($summary); + } + + /** + * 生成规则版总结 + * + * @param string $text + * @param string $summaryType + * @return array + */ + private function generateSummaryPayload(string $text, string $summaryType): array + { + $sentences = $this->splitSentences($text); + $summarySentences = array_slice($sentences, 0, $summaryType === 'outline' ? 5 : 3); + $summaryText = implode("\n", array_map(function ($item, $index) use ($summaryType) { + if ($summaryType === 'outline') { + return sprintf('%d. %s', $index + 1, $item); + } + return $item; + }, $summarySentences, array_keys($summarySentences))); + + $todoList = $this->extractTodoList($text); + $keywords = $this->extractKeywords($text); + + if ($summaryType === 'todo' && !empty($todoList)) { + $summaryText = "待办事项:\n" . implode("\n", array_map(function ($item, $index) { + return sprintf('%d. %s', $index + 1, $item); + }, $todoList, array_keys($todoList))); + } + + return [ + 'summary_text' => $summaryText !== '' ? $summaryText : mb_substr($text, 0, 200), + 'todo_list' => $todoList, + 'keywords' => $keywords, + ]; + } + + /** + * 切分句子 + * + * @param string $text + * @return array + */ + private function splitSentences(string $text): array + { + $normalized = preg_replace('/\s+/', ' ', trim($text)); + $parts = preg_split('/[。!?!?;;\n\r]+/u', (string) $normalized); + $parts = array_values(array_filter(array_map('trim', $parts), function ($item) { + return $item !== ''; + })); + + return empty($parts) ? [mb_substr($normalized, 0, 200)] : $parts; + } + + /** + * 提取待办 + * + * @param string $text + * @return array + */ + private function extractTodoList(string $text): array + { + $lines = preg_split('/[\n\r]+/u', $text); + $todoList = []; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + if (preg_match('/^(待办|todo|todo:|TODO|TODO:|需要|安排|跟进|完成|处理)/u', $line)) { + $todoList[] = $line; + } + } + + if (!empty($todoList)) { + return array_slice(array_values(array_unique($todoList)), 0, 10); + } + + $sentences = $this->splitSentences($text); + $fallback = []; + foreach ($sentences as $sentence) { + if (preg_match('/(需要|安排|跟进|完成|处理|确认|整理|联系)/u', $sentence)) { + $fallback[] = $sentence; + } + } + + return array_slice(array_values(array_unique($fallback)), 0, 10); + } + + /** + * 提取关键词 + * + * @param string $text + * @return array + */ + private function extractKeywords(string $text): array + { + preg_match_all('/[\x{4e00}-\x{9fa5}A-Za-z0-9]{2,20}/u', $text, $matches); + $words = $matches[0] ?? []; + $stopWords = ['我们', '你们', '他们', '这个', '那个', '然后', '以及', '因为', '所以', '可以', '进行', '一个', '没有', '已经', '需要', '今天', '目前', '如果', '就是']; + $countMap = []; + + foreach ($words as $word) { + if (in_array($word, $stopWords, true)) { + continue; + } + $countMap[$word] = ($countMap[$word] ?? 0) + 1; + } + + arsort($countMap); + return array_slice(array_keys($countMap), 0, 10); + } + + /** + * 格式化总结结果 + * + * @param NoteAiSummary $summary + * @return array + */ + private function formatSummary(NoteAiSummary $summary): array + { + return [ + 'summary_id' => (int) $summary->id, + 'note_id' => (int) $summary->note_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, + 'error_message' => (string) $summary->error_message, + 'created_at' => (int) $summary->created_at, + 'updated_at' => (int) $summary->updated_at, + ]; + } + + /** + * 解析 JSON 列表 + * + * @param string $value + * @return array + */ + private function decodeJsonList(string $value): array + { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/app/note/service/AuthService.php b/app/note/service/AuthService.php new file mode 100644 index 0000000..0f92510 --- /dev/null +++ b/app/note/service/AuthService.php @@ -0,0 +1,164 @@ +fetchWechatSession($code); + $openid = trim((string) ($session['openid'] ?? '')); + + if ($openid === '') { + throw new \Exception('微信登录失败,未获取到 openid', 500); + } + + $now = time(); + $user = NoteUser::findByOpenid($openid); + $isNewUser = false; + + if (!$user) { + $user = new NoteUser(); + $user->openid = $openid; + $user->created_at = $now; + $isNewUser = true; + } + + if (!$isNewUser && (int) $user->status === 0) { + throw new \Exception('小程序账号已被禁用', 403); + } + + $user->unionid = (string) ($session['unionid'] ?? ''); + $user->session_key = (string) ($session['session_key'] ?? ''); + $user->nickname = $nickname !== null ? trim($nickname) : (string) $user->nickname; + $user->avatar_url = $avatarUrl !== null ? trim($avatarUrl) : (string) $user->avatar_url; + $user->status = 1; + $user->last_login_ip = (string) request()->ip(); + $user->last_login_time = $now; + $user->updated_at = $now; + $user->deleted_at = 0; + + if (!$user->save()) { + throw new \Exception('小程序用户登录保存失败', 500); + } + + $token = Jwt::encode([ + 'userid' => (int) $user->id, + 'guard' => 'note', + 'openid' => $user->openid, + ]); + + $refreshToken = Jwt::encode([ + 'userid' => (int) $user->id, + 'guard' => 'note', + 'type' => 'refresh', + 'openid' => $user->openid, + ]); + + return [ + 'token' => $token, + 'refresh_token' => $refreshToken, + 'expires_in' => config('jwt.expire', 604800), + 'user' => [ + 'id' => (int) $user->id, + 'member_id' => (int) ($user->member_id ?? 0), + 'openid' => (string) $user->openid, + 'nickname' => (string) $user->nickname, + 'avatar_url' => (string) $user->avatar_url, + 'mobile' => (string) $user->mobile, + 'is_new_user' => $isNewUser, + ], + ]; + } + + /** + * 获取当前小程序用户信息 + * + * @param int $noteUserId + * @return array + * @throws \Exception + */ + public function getUserInfo(int $noteUserId): array + { + $user = NoteUser::findActiveById($noteUserId); + if (!$user) { + throw new \Exception('小程序用户不存在', 404); + } + + return [ + 'id' => (int) $user->id, + 'member_id' => (int) ($user->member_id ?? 0), + 'openid' => (string) $user->openid, + 'nickname' => (string) $user->nickname, + 'avatar_url' => (string) $user->avatar_url, + 'mobile' => (string) $user->mobile, + 'status' => (int) $user->status, + 'last_login_time' => (int) $user->last_login_time, + 'created_at' => (int) $user->created_at, + ]; + } + + /** + * 请求微信 code2Session + * + * @param string $code + * @return array + * @throws \Exception + */ + private function fetchWechatSession(string $code): array + { + $appId = (string) env('WECHAT_MINI_APPID', ''); + $appSecret = (string) env('WECHAT_MINI_SECRET', ''); + + if ($appId === '' || $appSecret === '') { + throw new \Exception('缺少微信小程序配置 WECHAT_MINI_APPID / WECHAT_MINI_SECRET', 500); + } + + $url = sprintf( + 'https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code', + urlencode($appId), + urlencode($appSecret), + urlencode($code) + ); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + $response = curl_exec($ch); + $error = curl_error($ch); + curl_close($ch); + + if ($response === false || $response === '' || $error) { + throw new \Exception('微信登录请求失败', 502); + } + + $data = json_decode($response, true); + if (!is_array($data)) { + throw new \Exception('微信登录响应解析失败', 502); + } + + if (!empty($data['errcode'])) { + throw new \Exception(sprintf('微信登录失败:%s', (string) ($data['errmsg'] ?? 'unknown error')), 400); + } + + return $data; + } +} diff --git a/app/note/service/NoteService.php b/app/note/service/NoteService.php new file mode 100644 index 0000000..21be883 --- /dev/null +++ b/app/note/service/NoteService.php @@ -0,0 +1,294 @@ +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); + + $result = $this->formatNoteItem($note); + $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 + * @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 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 : []; + } +} diff --git a/app/note/service/PlanningService.php b/app/note/service/PlanningService.php new file mode 100644 index 0000000..95d04e7 --- /dev/null +++ b/app/note/service/PlanningService.php @@ -0,0 +1,206 @@ + 'note', + 'version' => 'v1', + 'description' => '笔记小程序独立模块,负责小程序登录、笔记、转写、AI 总结能力', + 'business_blocks' => [ + [ + 'name' => '小程序用户登录', + 'goal' => '基于微信 code 登录,换取 openid / session_key,并建立本地用户映射与 JWT 登录态', + ], + [ + 'name' => '笔记创建与实时录音转文字', + 'goal' => '支持纯文本笔记、语音笔记、实时转写文本落地、后续编辑与查看', + ], + [ + 'name' => 'AI 总结', + 'goal' => '对单条笔记或一次录音转写结果生成结构化摘要、待办、标签等内容', + ], + ], + 'routes' => [ + ['method' => 'GET', 'path' => '/note/v1/meta/interfaces', 'desc' => '获取 note 模块接口规划'], + ['method' => 'POST', 'path' => '/note/v1/auth/wechat-login', 'desc' => '微信小程序登录'], + ['method' => 'GET', 'path' => '/note/v1/auth/me', 'desc' => '获取当前小程序用户信息'], + ['method' => 'POST', 'path' => '/note/v1/item/create', 'desc' => '创建笔记'], + ['method' => 'GET', 'path' => '/note/v1/item/list', 'desc' => '笔记列表'], + ['method' => 'GET', 'path' => '/note/v1/item/:id', 'desc' => '笔记详情'], + ['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/ai/summary/:id', 'desc' => '发起 AI 总结'], + ['method' => 'GET', 'path' => '/note/v1/ai/summary/:id', 'desc' => '查看 AI 总结结果'], + ], + 'suggested_tables' => [ + 'note_user', + 'note_item', + 'note_transcript', + 'note_ai_summary', + ], + 'development_priority' => [ + '1. 先落小程序登录,打通微信 openid 与 JWT', + '2. 再落笔记主表与笔记 CRUD', + '3. 再补实时转写保存接口', + '4. 最后接入 AI 总结任务与结果存储', + ], + ]; + } + + /** + * 小程序登录接口规划。 + * + * @return array + */ + public function getWechatLoginSpec(): array + { + return [ + 'name' => '微信小程序登录', + 'route' => 'POST /note/v1/auth/wechat-login', + 'request' => [ + 'code' => '微信 wx.login 获取的临时 code,必填', + 'nickname' => '用户昵称,可选', + 'avatar_url' => '头像地址,可选', + 'device' => '设备信息,可选', + ], + 'response' => [ + 'token' => '沿用 tp 现有 JWT 登录态', + 'refresh_token' => '刷新令牌', + 'expires_in' => '过期时间', + 'user' => [ + 'id' => 'note_user 主键 ID', + 'member_id' => '关联 tp 现有 member 用户 ID,可为空', + 'openid' => '微信 openid', + 'nickname' => '昵称', + 'avatar_url' => '头像', + 'is_new_user' => '是否首次登录', + ], + ], + 'depends_on' => [ + '微信 code2Session', + 'note_user 表', + '是否与现有 member 表映射的业务策略', + ], + ]; + } + + /** + * 笔记接口规划。 + * + * @return array + */ + public function getNoteSpecs(): array + { + return [ + 'create' => [ + 'route' => 'POST /note/v1/item/create', + 'request' => [ + 'title' => '标题,可选', + 'content' => '正文,可选', + 'source_type' => '来源类型:text / audio / mix', + 'audio_duration_ms' => '录音时长,可选', + 'status' => '状态:draft / completed,默认 draft', + ], + ], + 'list' => [ + 'route' => 'GET /note/v1/item/list', + 'request' => [ + 'page' => '页码,默认 1', + 'page_size' => '每页数量,默认 10', + 'keyword' => '标题/正文搜索,可选', + 'status' => 'draft / completed,可选', + ], + ], + 'detail' => [ + 'route' => 'GET /note/v1/item/:id', + 'response' => [ + 'id' => '笔记 ID', + 'title' => '标题', + 'content' => '正文', + 'transcript_text' => '转写文本', + 'audio_duration_ms' => '录音时长', + 'summary' => 'AI 总结结果,可选', + ], + ], + 'update' => [ + 'route' => 'POST /note/v1/item/update/:id', + ], + 'delete' => [ + 'route' => 'POST /note/v1/item/delete/:id', + ], + 'transcript' => [ + 'route' => 'POST /note/v1/item/transcript/:id', + 'request' => [ + 'segment_no' => '分片序号', + 'segment_text' => '本次实时转写文本,可选', + 'full_text' => '当前整段累计文本,必填', + 'is_final' => '是否最终片段', + 'audio_duration_ms' => '当前累计录音时长,可选', + ], + ], + ]; + } + + /** + * AI 总结接口规划。 + * + * @return array + */ + public function getAiSpecs(): array + { + return [ + 'create_summary' => [ + 'route' => 'POST /note/v1/ai/summary/:id', + 'request' => [ + 'summary_type' => '摘要类型:brief / outline / todo', + 'force_refresh' => '是否强制重新生成,可选', + ], + ], + 'read_summary' => [ + 'route' => 'GET /note/v1/ai/summary/:id', + 'response' => [ + 'summary_id' => '总结记录 ID', + 'summary_type' => '摘要类型', + 'summary_text' => '总结内容', + 'todo_list' => '待办列表,可选', + 'keywords' => '关键词列表,可选', + 'status' => '状态', + ], + ], + ]; + } + + /** + * 返回接口尚未落库的统一说明。 + * + * @param array $spec + * @return array + */ + public function buildPendingImplementationPayload(array $spec): array + { + return [ + 'implemented' => false, + 'reason' => '当前阶段仅完成 note 独立模块接口规划骨架,数据库表已设计,真实业务逻辑待继续实现', + 'spec' => $spec, + ]; + } +} diff --git a/config/database.php b/config/database.php index 38ed2e3..7b31dae 100644 --- a/config/database.php +++ b/config/database.php @@ -62,6 +62,27 @@ return [ 'fields_cache' => false, ], + // 笔记数据库(独立 note 模块) + 'dbnote' => [ + 'type' => 'mysql', + 'hostname' => env('DB_NOTE_HOSTNAME', '127.0.0.1'), + 'database' => env('DB_NOTE_DATABASE', 'tp'), + 'username' => env('DB_NOTE_USERNAME', 'tp'), + 'password' => env('DB_NOTE_PASSWORD', ''), + 'hostport' => env('DB_NOTE_HOSTPORT', '3306'), + 'params' => [], + 'charset' => env('DB_NOTE_CHARSET', 'utf8mb4'), + 'prefix' => '', + 'deploy' => 0, + 'rw_separate' => false, + 'master_num' => 1, + 'slave_no' => '', + 'fields_strict' => false, + 'break_reconnect' => false, + 'trigger_sql' => env('APP_DEBUG', true), + 'fields_cache' => false, + ], + // 抖音业务数据库(用于首页发布作品统计分表) 'dbdouying' => [ 'type' => 'mysql', diff --git a/database.sql b/database.sql index ca087f2..d04658c 100644 --- a/database.sql +++ b/database.sql @@ -21,3 +21,85 @@ CREATE TABLE IF NOT EXISTS `user` ( INSERT INTO `user` (`username`, `password`, `nickname`, `email`, `status`) VALUES ('demo', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '演示用户', 'demo@example.com', 1); -- 密码为: password + + +-- ============================================ +-- note 独立模块:笔记小程序表结构 +-- ============================================ + +-- 小程序用户表 +CREATE TABLE IF NOT EXISTS `note_user` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `member_id` int(11) unsigned DEFAULT NULL COMMENT '关联 tp 现有 member.userid,可为空', + `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信小程序 openid', + `unionid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信 unionid', + `session_key` varchar(128) NOT NULL DEFAULT '' COMMENT '微信 session_key', + `nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '用户昵称', + `avatar_url` varchar(255) NOT NULL DEFAULT '' COMMENT '头像地址', + `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0禁用 1正常', + `last_login_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '最后登录IP', + `last_login_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 '更新时间', + `deleted_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '删除时间,0表示未删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_openid` (`openid`), + KEY `idx_member_id` (`member_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='note模块-小程序用户表'; + +-- 笔记主表 +CREATE TABLE IF NOT EXISTS `note_item` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '笔记ID', + `note_user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT 'note_user.id', + `title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题', + `content` longtext COMMENT '用户整理后的正文', + `transcript_text` longtext COMMENT '录音转写累计文本', + `source_type` varchar(20) NOT NULL DEFAULT 'text' COMMENT '来源类型:text/audio/mix', + `status` varchar(20) NOT NULL DEFAULT 'draft' COMMENT '状态:draft/completed/archived', + `audio_duration_ms` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '录音时长(毫秒)', + `summary_status` varchar(20) NOT NULL DEFAULT 'none' COMMENT 'AI总结状态:none/pending/success/failed', + `last_transcript_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 '更新时间', + `deleted_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '删除时间,0表示未删除', + PRIMARY KEY (`id`), + KEY `idx_note_user_id` (`note_user_id`), + KEY `idx_status` (`status`), + KEY `idx_summary_status` (`summary_status`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='note模块-笔记主表'; + +-- 笔记实时转写表 +CREATE TABLE IF NOT EXISTS `note_transcript` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `note_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT 'note_item.id', + `segment_no` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '分片序号', + `segment_text` text COMMENT '本次分片转写文本', + `full_text` longtext COMMENT '当前累计完整转写文本', + `is_final` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否最终片段:0否 1是', + `audio_duration_ms` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '当前累计录音时长', + `created_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_note_id` (`note_id`), + KEY `idx_note_segment` (`note_id`, `segment_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='note模块-实时转写记录表'; + +-- AI 总结结果表 +CREATE TABLE IF NOT EXISTS `note_ai_summary` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `note_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT 'note_item.id', + `summary_type` varchar(20) NOT NULL DEFAULT 'brief' COMMENT '总结类型:brief/outline/todo', + `summary_text` longtext COMMENT 'AI总结正文', + `todo_list` longtext COMMENT '待办列表,建议JSON字符串', + `keywords` longtext COMMENT '关键词列表,建议JSON字符串', + `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT '状态:pending/success/failed', + `error_message` varchar(500) NOT NULL DEFAULT '' 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`), + KEY `idx_status` (`status`), + KEY `idx_summary_type` (`summary_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='note模块-AI总结结果表'; diff --git a/docs/note_api.md b/docs/note_api.md new file mode 100644 index 0000000..96b40dd --- /dev/null +++ b/docs/note_api.md @@ -0,0 +1,549 @@ +# Note 模块接口文档 + +## 1. 模块说明 + +`note` 是独立于现有 `app/api` 业务的笔记小程序模块,代码位置如下: + +- [app/note/controller](/root/work/tp/app/note/controller) +- [app/note/service](/root/work/tp/app/note/service) +- [app/note/model](/root/work/tp/app/note/model) + +当前已实现能力: + +1. 微信小程序登录 +2. 笔记创建、列表、详情、更新、删除 +3. 实时转写文本保存 +4. AI 总结生成与查询 + +路由注册位置: + +- [route/app.php](/root/work/tp/route/app.php) + +--- + +## 2. 鉴权说明 + +除登录接口外,其余 `note` 接口都需要携带 JWT: + +- Header: `Authorization: Bearer {token}` + +`note` 模块复用现有 JWT 机制,但要求 token 载荷中必须包含: + +```json +{ + "userid": 123, + "guard": "note" +} +``` + +说明: + +- `userid` 对应 `note_user.id` +- `guard=note` 用于和旧 `api` 模块登录态隔离 + +--- + +## 3. 环境变量 + +微信登录依赖以下配置: + +- `WECHAT_MINI_APPID` +- `WECHAT_MINI_SECRET` +- `DB_NOTE_HOSTNAME` +- `DB_NOTE_DATABASE` +- `DB_NOTE_USERNAME` +- `DB_NOTE_PASSWORD` +- `DB_NOTE_HOSTPORT` + +示例位置: + +- [\.example.env](/root/work/tp/.example.env) + +--- + +## 4. 数据表 + +表结构定义位置: + +- [database.sql](/root/work/tp/database.sql#L26) + +当前 `note` 模块使用以下 4 张表: + +1. `note_user` + 用于小程序用户登录和用户资料 + +2. `note_item` + 笔记主表,包含正文、转写累计文本、状态、录音时长等 + +3. `note_transcript` + 实时转写分片记录表 + +4. `note_ai_summary` + AI 总结结果表 + +--- + +## 5. 接口列表 + +### 5.1 获取模块概览 + +- 方法:`GET` +- 路径:`/note/v1/meta/interfaces` +- 是否鉴权:否 + +用途: + +- 返回 note 模块接口规划概览 + +--- + +### 5.2 微信小程序登录 + +- 方法:`POST` +- 路径:`/note/v1/auth/wechat-login` +- 是否鉴权:否 + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| `code` | string | 是 | `wx.login` 获取的临时 code | +| `nickname` | string | 否 | 用户昵称 | +| `avatar_url` | string | 否 | 用户头像地址 | + +请求示例: + +```json +{ + "code": "021xxx", + "nickname": "张三", + "avatar_url": "https://example.com/avatar.png" +} +``` + +成功响应示例: + +```json +{ + "code": 200, + "msg": "登录成功", + "data": { + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.xxx", + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.xxx", + "expires_in": 604800, + "user": { + "id": 1, + "member_id": 0, + "openid": "oxxx", + "nickname": "张三", + "avatar_url": "https://example.com/avatar.png", + "mobile": "", + "is_new_user": true + } + }, + "time": 1710000000 +} +``` + +说明: + +1. 首次登录时自动创建 `note_user` +2. 后续登录时按 `openid` 更新资料与最后登录时间 +3. 若用户被禁用,返回 403 + +--- + +### 5.3 获取当前用户信息 + +- 方法:`GET` +- 路径:`/note/v1/auth/me` +- 是否鉴权:是 + +成功响应示例: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "member_id": 0, + "openid": "oxxx", + "nickname": "张三", + "avatar_url": "https://example.com/avatar.png", + "mobile": "", + "status": 1, + "last_login_time": 1710000000, + "created_at": 1710000000 + }, + "time": 1710000001 +} +``` + +--- + +### 5.4 创建笔记 + +- 方法:`POST` +- 路径:`/note/v1/item/create` +- 是否鉴权:是 + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| `source_type` | string | 是 | `text` / `audio` / `mix` | +| `title` | string | 否 | 标题 | +| `content` | string | 否 | 正文 | +| `audio_duration_ms` | int | 否 | 录音时长 | +| `status` | string | 否 | 默认 `draft` | + +请求示例: + +```json +{ + "source_type": "text", + "title": "会议纪要", + "content": "今天确认了下周需求排期", + "status": "draft" +} +``` + +成功响应示例: + +```json +{ + "code": 200, + "msg": "创建成功", + "data": { + "id": 10, + "note_user_id": 1, + "title": "会议纪要", + "content": "今天确认了下周需求排期", + "transcript_text": "", + "source_type": "text", + "status": "draft", + "audio_duration_ms": 0, + "summary_status": "none", + "last_transcript_time": 0, + "created_at": 1710000000, + "updated_at": 1710000000 + }, + "time": 1710000000 +} +``` + +说明: + +- 如果不传 `title`,后端会根据 `content` 自动生成标题 +- 如果 `title` 和 `content` 都为空,标题会默认为 `未命名笔记` + +--- + +### 5.5 笔记列表 + +- 方法:`GET` +- 路径:`/note/v1/item/list` +- 是否鉴权:是 + +查询参数: + +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| `page` | int | 否 | 默认 1 | +| `page_size` | int | 否 | 默认 10,最大 100 | +| `keyword` | string | 否 | 搜索标题、正文、转写文本 | +| `status` | string | 否 | 按状态筛选 | + +响应示例: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "list": [ + { + "id": 10, + "note_user_id": 1, + "title": "会议纪要", + "content": "今天确认了下周需求排期", + "transcript_text": "", + "source_type": "text", + "status": "draft", + "audio_duration_ms": 0, + "summary_status": "none", + "last_transcript_time": 0, + "created_at": 1710000000, + "updated_at": 1710000000 + } + ], + "total": 1, + "page": 1, + "page_size": 10 + }, + "time": 1710000001 +} +``` + +--- + +### 5.6 笔记详情 + +- 方法:`GET` +- 路径:`/note/v1/item/:id` +- 是否鉴权:是 + +响应示例: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "id": 10, + "note_user_id": 1, + "title": "会议纪要", + "content": "今天确认了下周需求排期", + "transcript_text": "需要跟进接口联调", + "source_type": "mix", + "status": "draft", + "audio_duration_ms": 120000, + "summary_status": "success", + "last_transcript_time": 1710000100, + "created_at": 1710000000, + "updated_at": 1710000200, + "summary": { + "summary_id": 3, + "summary_type": "brief", + "summary_text": "今天确认了下周需求排期\n需要跟进接口联调", + "todo_list": [ + "需要跟进接口联调" + ], + "keywords": [ + "需求", + "排期", + "接口联调" + ], + "status": "success" + } + }, + "time": 1710000201 +} +``` + +--- + +### 5.7 更新笔记 + +- 方法:`POST` +- 路径:`/note/v1/item/update/:id` +- 是否鉴权:是 + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| `title` | string | 否 | 标题 | +| `content` | string | 否 | 正文 | +| `status` | string | 否 | 状态 | +| `audio_duration_ms` | int | 否 | 录音时长 | + +说明: + +- 只更新传入字段 +- 只能更新自己的笔记 + +--- + +### 5.8 删除笔记 + +- 方法:`POST` +- 路径:`/note/v1/item/delete/:id` +- 是否鉴权:是 + +说明: + +- 当前为软删除,写入 `deleted_at` + +成功响应示例: + +```json +{ + "code": 200, + "msg": "删除成功", + "data": { + "deleted": true, + "id": 10 + }, + "time": 1710000300 +} +``` + +--- + +### 5.9 保存实时转写内容 + +- 方法:`POST` +- 路径:`/note/v1/item/transcript/:id` +- 是否鉴权:是 + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| `full_text` | string | 是 | 当前累计完整转写文本 | +| `segment_no` | int | 否 | 分片序号,默认 0 | +| `segment_text` | string | 否 | 当前分片文本 | +| `is_final` | int/bool | 否 | 是否最终片段 | +| `audio_duration_ms` | int | 否 | 当前累计录音时长 | + +请求示例: + +```json +{ + "segment_no": 3, + "segment_text": "需要跟进接口联调", + "full_text": "今天确认了下周需求排期,需要跟进接口联调", + "is_final": 1, + "audio_duration_ms": 120000 +} +``` + +成功响应示例: + +```json +{ + "code": 200, + "msg": "转写保存成功", + "data": { + "note_id": 10, + "segment_no": 3, + "is_final": 1, + "transcript_text": "今天确认了下周需求排期,需要跟进接口联调", + "audio_duration_ms": 120000, + "updated_at": 1710000400 + }, + "time": 1710000400 +} +``` + +说明: + +- 后端会同步更新 `note_item.transcript_text` +- 同一个 `note_id + segment_no` 会覆盖写入 + +--- + +### 5.10 生成 AI 总结 + +- 方法:`POST` +- 路径:`/note/v1/ai/summary/:id` +- 是否鉴权:是 + +请求参数: + +| 字段 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| `summary_type` | string | 否 | `brief` / `outline` / `todo` | +| `force_refresh` | int/bool | 否 | 是否强制重新生成 | + +请求示例: + +```json +{ + "summary_type": "brief", + "force_refresh": 1 +} +``` + +成功响应示例: + +```json +{ + "code": 200, + "msg": "总结生成成功", + "data": { + "summary_id": 3, + "note_id": 10, + "summary_type": "brief", + "summary_text": "今天确认了下周需求排期\n需要跟进接口联调", + "todo_list": [ + "需要跟进接口联调" + ], + "keywords": [ + "需求", + "排期", + "接口联调" + ], + "status": "success", + "error_message": "", + "created_at": 1710000500, + "updated_at": 1710000500 + }, + "time": 1710000500 +} +``` + +说明: + +- 当前实现为规则版总结,不依赖外部大模型 +- 若已存在成功总结且 `force_refresh=0`,会直接返回已有结果 + +--- + +### 5.11 查看 AI 总结 + +- 方法:`GET` +- 路径:`/note/v1/ai/summary/:id` +- 是否鉴权:是 + +说明: + +- 返回指定笔记最新的一条总结记录 + +--- + +## 6. 错误码约定 + +当前模块沿用项目统一返回结构: + +```json +{ + "code": 400, + "msg": "错误信息", + "data": [], + "time": 1710000000 +} +``` + +常见情况: + +- `400`:参数错误 +- `401`:未登录或登录态无效 +- `403`:用户被禁用 +- `404`:资源不存在 +- `500`:服务端错误 +- `502`:调用微信接口失败 + +--- + +## 7. 调用顺序建议 + +小程序端推荐按以下顺序接入: + +1. 调 `wx.login` +2. 把 `code` 发给 `POST /note/v1/auth/wechat-login` +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` + +--- + +## 8. 当前限制 + +1. AI 总结目前是规则版,不是大模型版 +2. 微信登录依赖服务端已正确配置 `WECHAT_MINI_APPID` 和 `WECHAT_MINI_SECRET` +3. 当前未实现文件音频上传,只实现了“转写文本写回后端”的数据链路 diff --git a/route/app.php b/route/app.php index 7a76990..19aacd9 100644 --- a/route/app.php +++ b/route/app.php @@ -6,6 +6,10 @@ use app\api\controller\v1\Auth; use app\api\controller\v1\Platform; use app\api\controller\v1\PublishPlan; use app\api\controller\v1\VideoWork; +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; /** * 全局路由入口。 @@ -45,3 +49,24 @@ Route::group('api/v1/publish-plan', function () { Route::group('api/v1/video-work', function () { Route::get('list', [VideoWork::class, 'index']); })->middleware(\app\api\middleware\Auth::class); + +// note v1 笔记小程序模块接口规划(公开) +Route::group('note/v1', function () { + Route::get('meta/interfaces', [NoteMeta::class, 'interfaces']); + Route::post('auth/wechat-login', [NoteAuth::class, 'wechatLogin']); +}); + +// note v1 笔记小程序模块接口(需登录) +Route::group('note/v1', function () { + Route::get('auth/me', [NoteAuth::class, 'me']); + + Route::post('item/create', [NoteItem::class, 'create']); + Route::get('item/list', [NoteItem::class, 'index']); + Route::get('item/:id', [NoteItem::class, 'read']); + 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('ai/summary/:id', [NoteAi::class, 'summary']); + Route::get('ai/summary/:id', [NoteAi::class, 'readSummary']); +})->middleware(\app\api\middleware\Auth::class);