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
+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,
];
}
}