feat: add note module and route fixes

This commit is contained in:
nepiedg
2026-04-17 07:48:44 +00:00
parent 866ddb046b
commit 84e1c0daac
25 changed files with 2196 additions and 38 deletions
+10
View File
@@ -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
+30
View File
@@ -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 返回数据
+4 -10
View File
@@ -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']
);
+2 -5
View File
@@ -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) {
+6 -15
View File
@@ -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) {
+2 -5
View File
@@ -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),
+5 -3
View File
@@ -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);
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace app\note\controller;
/**
* note 模块基础控制器
*/
abstract class BaseController extends \app\api\controller\BaseController
{
/**
* 获取当前 note 模块登录用户 ID。
*
* 说明:
* - note 模块复用全局 JWT 中间件
* - 但要求 token 载荷中必须带 `guard=note`
*
* @return int
*/
protected function getCurrentNoteUserId(): int
{
$payload = $this->getLoginPayload();
if (($payload['guard'] ?? '') !== 'note') {
throw new \RuntimeException('note 模块登录态无效', 401);
}
return (int) ($payload['userid'] ?? 0);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace app\note\controller\v1;
use app\api\common\Response;
use app\note\controller\BaseController;
use app\note\service\AiService;
use app\note\service\NoteService;
use think\App;
/**
* 笔记 AI 能力控制器
*/
class Ai extends BaseController
{
/**
* @var PlanningService
*/
protected $aiService;
/**
* @var NoteService
*/
protected $noteService;
public function __construct(App $app)
{
parent::__construct($app);
$this->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);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\note\controller\v1;
use app\api\common\Response;
use app\note\controller\BaseController;
use app\note\service\AuthService;
use think\App;
use think\exception\ValidateException;
/**
* 笔记小程序认证控制器
*/
class Auth extends BaseController
{
/**
* @var PlanningService
*/
protected $authService;
public function __construct(App $app)
{
parent::__construct($app);
$this->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);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace app\note\controller\v1;
use app\api\common\Response;
use app\note\controller\BaseController;
use app\note\service\PlanningService;
use think\App;
/**
* 笔记模块元信息控制器
*/
class Meta extends BaseController
{
/**
* @var PlanningService
*/
protected $planningService;
public function __construct(App $app)
{
parent::__construct($app);
$this->planningService = new PlanningService();
}
/**
* 获取 note 模块接口规划概览
* GET /note/v1/meta/interfaces
*/
public function interfaces()
{
return Response::success($this->planningService->getModuleOverview());
}
}
+156
View File
@@ -0,0 +1,156 @@
<?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;
use think\exception\ValidateException;
/**
* 笔记控制器
*/
class Note extends BaseController
{
/**
* @var PlanningService
*/
protected $noteService;
public function __construct(App $app)
{
parent::__construct($app);
$this->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);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace app\note\model;
use think\Model;
/**
* note 模块 AI 总结模型
*/
class NoteAiSummary extends Model
{
protected $connection = 'dbnote';
protected $name = 'note_ai_summary';
protected $pk = 'id';
protected $autoWriteTimestamp = false;
/**
* 获取笔记最新总结
*
* @param int $noteId
* @return self|null
*/
public static function findLatestByNoteId(int $noteId): ?self
{
return self::where('note_id', $noteId)
->order('id', 'desc')
->find();
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace app\note\model;
use think\Model;
use think\db\Query;
/**
* note 模块笔记主表模型
*/
class NoteItem extends Model
{
protected $connection = 'dbnote';
protected $name = 'note_item';
protected $pk = 'id';
protected $autoWriteTimestamp = false;
/**
* 创建当前用户的基础查询
*
* @param int $noteUserId
* @return Query
*/
public static function buildUserQuery(int $noteUserId): Query
{
return self::where('note_user_id', $noteUserId)
->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();
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace app\note\model;
use think\Model;
/**
* note 模块实时转写模型
*/
class NoteTranscript extends Model
{
protected $connection = 'dbnote';
protected $name = 'note_transcript';
protected $pk = 'id';
protected $autoWriteTimestamp = false;
/**
* 查找某条笔记的指定分片
*
* @param int $noteId
* @param int $segmentNo
* @return self|null
*/
public static function findByNoteAndSegment(int $noteId, int $segmentNo): ?self
{
return self::where('note_id', $noteId)
->where('segment_no', $segmentNo)
->find();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace app\note\model;
use think\Model;
/**
* note 模块小程序用户模型
*/
class NoteUser extends Model
{
protected $connection = 'dbnote';
protected $name = 'note_user';
protected $pk = 'id';
protected $autoWriteTimestamp = false;
/**
* 根据 openid 查找用户
*
* @param string $openid
* @return self|null
*/
public static function findByOpenid(string $openid): ?self
{
return self::where('openid', $openid)
->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();
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use think\facade\Route;
use app\note\controller\v1\Ai;
use app\note\controller\v1\Auth;
use app\note\controller\v1\Meta;
use app\note\controller\v1\Note;
/**
* note 应用路由
*
* 由于项目启用了 think-multi-app
* `/note/...` 会优先进入 note 应用,
* 因此 note 模块自己的接口必须定义在 `app/note/route/app.php` 下,
* 路由前缀应从 `v1/...` 开始,而不是再重复写 `note/...`。
*/
// v1 笔记模块接口规划(公开)
Route::get('v1/meta/interfaces', [Meta::class, 'interfaces']);
Route::post('v1/auth/wechat-login', [Auth::class, 'wechatLogin']);
// v1 笔记模块接口(需登录)
Route::group('v1', function () {
Route::get('auth/me', [Auth::class, 'me']);
Route::post('item/create', [Note::class, 'create']);
Route::get('item/list', [Note::class, 'index']);
Route::get('item/:id', [Note::class, 'read']);
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('ai/summary/:id', [Ai::class, 'summary']);
Route::get('ai/summary/:id', [Ai::class, 'readSummary']);
})->middleware(\app\api\middleware\Auth::class);
+228
View File
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace app\note\service;
use app\note\model\NoteAiSummary;
use app\note\model\NoteItem;
/**
* note 模块 AI 总结服务
*
* 当前先提供规则版总结,保证接口可用。
* 后续如需接入大模型,可在本服务内部替换实现,不影响控制器接口。
*/
class AiService
{
/**
* 生成或刷新总结
*
* @param NoteItem $note
* @param string $summaryType
* @param bool $forceRefresh
* @return array
*/
public function createSummary(NoteItem $note, string $summaryType = 'brief', bool $forceRefresh = false): array
{
$summaryType = in_array($summaryType, ['brief', 'outline', 'todo'], true) ? $summaryType : 'brief';
$existing = NoteAiSummary::findLatestByNoteId((int) $note->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 : [];
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace app\note\service;
use app\api\common\Jwt;
use app\note\model\NoteUser;
/**
* note 模块认证服务
*/
class AuthService
{
/**
* 微信小程序登录
*
* @param string $code
* @param string|null $nickname
* @param string|null $avatarUrl
* @return array
* @throws \Exception
*/
public function wechatLogin(string $code, ?string $nickname = null, ?string $avatarUrl = null): array
{
$session = $this->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;
}
}
+294
View File
@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace app\note\service;
use app\note\model\NoteAiSummary;
use app\note\model\NoteItem;
use app\note\model\NoteTranscript;
/**
* 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);
$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 : [];
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace app\note\service;
/**
* 笔记小程序接口规划服务
*
* 说明:
* - 当前阶段先在独立 `note` 模块沉淀接口规划与模块骨架
* - 暂未落真实业务逻辑,接口先返回规划结构与字段建议
* - 后续确定表结构后,可直接在 `app/note` 模块内继续补 Model / Service / Controller
*/
class PlanningService
{
/**
* 获取整个 note 模块接口概览。
*
* @return array
*/
public function getModuleOverview(): array
{
return [
'module' => '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,
];
}
}
+21
View File
@@ -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',
+82
View File
@@ -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总结结果表';
+549
View File
@@ -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. 当前未实现文件音频上传,只实现了“转写文本写回后端”的数据链路
+25
View File
@@ -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);