2
0
Эх сурвалжийг харах

refactor(api-v13): 为前十常用接口补中文注释,标注 55 条 FIXME

按前端调用频次选出的九个控制器:Channel、DhammaTerm、Article、User、Task、
Project、CourseMember、UserDict、Discussion。

- 每个方法补中文摘要、权限要求、边界与副作用说明
- 参数用 @queryParam / @bodyParam / @urlParam 标注,openapi 生成器据此产出
  精确的参数说明、枚举与默认值
- 删掉 Laravel 脚手架的英文样板、@return Response 与随之无用的 import
- 把审查中发现但不宜在本次改动的 40 余个问题写成 55 条 FIXME 留在原位,
  供以后按「渐进式还债」规则逐步处理

本次不改变任何运行时行为(三处真实 bug 已在前一个 commit 单独修复)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 долоо хоног өмнө
parent
commit
fb747c0d80

+ 90 - 21
api-v13/app/Http/Controllers/ArticleController.php

@@ -16,7 +16,6 @@ use App\Models\Sentence;
 use App\Services\AuthService;
 use App\Tools\OpsLog;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
@@ -138,13 +137,28 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display a listing of the resource.
+     * 列出 article(文章)
      *
-     * @return Response
+     * 按 view 指定的口径返回文章列表,支持关键字搜索、分页、排序。
+     * 默认只返回列表字段(uid、title、subtitle、summary、owner、lang、status、editor_id、时间),
+     * content=true 时才附带正文与正文类型。
+     * view=studio 需要登录,且当前登录用户必须就是 name 指定的 studio 的拥有者,否则返回 401/403。
+     *
+     * @queryParam view string required 查询口径。template=按 studio_name 取该 studio 的全部文章(模版库);studio=取某 studio 内的文章,需要登录;public=只取已公开(status=30)的文章。Enum: template,studio,public
+     * @queryParam content string 是否返回文章正文。传 true 时结果附带 content 与 content_type。Example: true
+     * @queryParam studio_name string view=template 时使用,指定 studio 的名称
+     * @queryParam name string view=studio 时使用,指定 studio 的名称,必须与当前登录用户一致
+     * @queryParam view2 string view=studio 时的二级口径:my=我创建的(owner 为该 studio),其余值=协作的(别人共享给我的,res_type=3)。Default: my
+     * @queryParam anthology string view=studio 时按文集过滤。all=不过滤;none=不属于我任何文集的文章;其余值按文集 uid 过滤。Enum: all,none
+     * @queryParam search string 按标题模糊搜索(title like %search%)
+     * @queryParam subtitle string 按副标题过滤(subtitle like,需自行带通配符)
+     * @queryParam order string 排序字段。Default: updated_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 分页起始位置。Default: 0
+     * @queryParam limit integer 每页条数。Default: 1000
      */
     public function index(Request $request)
     {
-        //
         $field = [
             'uid',
             'title',
@@ -217,6 +231,8 @@ class ArticleController extends Controller
                 $table = $table->where('status', 30);
                 break;
             default:
+                // FIXME: 这里只调用 error() 没有 return,view 非法时会继续往下执行并返回全表数据(最多 limit 条);
+                // 建议改成 return $this->error('view error', [], 400)。
                 $this->error('view error');
                 break;
         }
@@ -244,9 +260,12 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display a listing of the resource.
+     * 统计我的文章数量
+     *
+     * 返回指定 studio 下「我的」和「协作的」两类文章条数。
+     * 需要登录,且当前登录用户必须就是 studio 的拥有者,否则返回鉴权失败。
      *
-     * @return Response
+     * @queryParam studio string required studio 名称,必须与当前登录用户一致
      */
     public function showMyNumber(Request $request)
     {
@@ -273,9 +292,22 @@ class ArticleController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建 article(文章)
+     *
+     * 需要登录。权限:当前用户必须是 studio 的拥有者;若不是,则必须对 anthologyId 指定的文集
+     * 拥有大于只读的权限(power>10),否则返回 403。
+     * 副作用:整个新建过程在事务中执行——写入 articles 表,若给了 anthologyId 则同时把文章挂进文集;
+     * 若再给了 parentNode,会按目录树重建该文集的 article_collections 记录(删除后整表重插)并刷新文集缓存。
+     * 标题超过 128 字会被截断。
      *
-     * @return Response
+     * @bodyParam studio string required studio 名称,文章归属的 studio
+     * @bodyParam title string required 文章标题,最多 128 字,超出截断
+     * @bodyParam lang string required 文章语言代码
+     * @bodyParam status integer 公开状态,30 表示公开。不传则用数据库默认值
+     * @bodyParam parentId string 父文章 uid,写入 articles.parent
+     * @bodyParam anthologyId string 文集 uid,传入则把新文章加入该文集
+     * @bodyParam parentNode string 文集目录中的挂接点文章 uid,新文章作为它的子节点插入;
+     *                             不传则作为一级节点(level=1)追加到文集末尾
      */
     public function store(Request $request)
     {
@@ -370,6 +402,8 @@ class ArticleController extends Controller
                             $newMap[] = $newNode;
                         }
                     } else {
+                        // FIXME: parentNode 找不到挂接点时只写日志,不把文章加入文集,结果文章建成了却不在目录里、前端看不到;
+                        // 建议改为回退成一级节点追加,或直接抛异常让事务回滚并向前端报错。
                         Log::warning('没找到挂接点'.$parentNode);
                     }
 
@@ -410,13 +444,19 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单篇 article(文章)
      *
-     * @return Response
+     * 前端阅读页最常用的接口。允许匿名访问:status=30(公开)的文章任何人可读;
+     * 非公开文章需要登录,且满足以下之一——是文章 owner、对该文章有只读以上共享权限(power>=10)、
+     * 或对该文章所在的(同一 owner 的)文集有只读以上共享权限,否则返回 403。
+     *
+     * @urlParam article string required 文章 uid(路由模型绑定到 Article,主键即 uid)
+     *
+     * @queryParam anthology string 所在文集 uid,用于在返回结果里渲染文集路径(path)
+     * @queryParam channel string 渲染标题所用的 channel uid,多个用下划线分隔;不传则回退到文集的默认 channel
      */
     public function show(Request $request, Article $article)
     {
-        //
         if (! $article) {
             return $this->error('no recorder');
         }
@@ -437,14 +477,22 @@ class ArticleController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 预览文章正文
+     *
+     * PUT /api/v2/article-preview/{id}。用请求体里的 content 覆盖文章正文后走一遍渲染再返回,
+     * 不写数据库,用于编辑器里的即时预览。
+     * 权限与查看文章一致:公开文章匿名可预览,否则需要读权限,无权限返回 401。
+     * 未传 content 时返回 error('no content'),但 HTTP 状态码仍是 200。
      *
-     * @param  string  $article
-     * @return Response
+     * @urlParam id string required 文章 uid
+     *
+     * @bodyParam content string required 待预览的正文内容(markdown)
+     *
+     * @queryParam anthology string 所在文集 uid,用于渲染文集路径
+     * @queryParam channel string 渲染所用 channel uid,多个用下划线分隔
      */
     public function preview(Request $request, string $articleId)
     {
-        //
         $article = Article::find($articleId);
         if (! $article) {
             return $this->error('no recorder');
@@ -471,13 +519,28 @@ class ArticleController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新 article(文章)
+     *
+     * 需要登录。权限:必须是文章 owner,或对文章有编辑以上共享权限(power>=20),
+     * 或对文章所在的同 owner 文集有编辑以上权限,否则返回 401。
+     * 字段会整体覆盖:title/subtitle 截断到 128 字,summary 截断到 1024 字,
+     * status 未传时会被重置为 10(非公开),即部分更新也必须带全字段。
+     * 副作用:to_tpl=true 时把正文转成模版——按行切句,正文中的句子被替换成 {{book-para-start-end}} 占位,
+     * 同时把原文逐句写入 sentences 表(必要时为文集新建 CustomBook 与 channel),该过程需要对应 channel 的写权限。
      *
-     * @return Response
+     * @urlParam article string required 文章 uid(路由模型绑定)
+     *
+     * @bodyParam title string required 标题,最多 128 字
+     * @bodyParam subtitle string 副标题,最多 128 字
+     * @bodyParam summary string 摘要,最多 1024 字
+     * @bodyParam content string required 正文(markdown)
+     * @bodyParam lang string required 语言代码
+     * @bodyParam status integer 公开状态,30 表示公开。Default: 10
+     * @bodyParam to_tpl boolean 是否把正文转换为模版并把原文切句入库,必须是布尔 true 才生效
+     * @bodyParam anthology_id string to_tpl=true 时必填,文集 uid,用于定位/创建书号与 channel
      */
     public function update(Request $request, Article $article)
     {
-        //
         if (! $article) {
             return $this->error('no recorder');
         }
@@ -521,6 +584,8 @@ class ArticleController extends Controller
         $article->summary = mb_substr($request->input('summary'), 0, 1024, 'UTF-8');
         $article->content = $content;
         $article->lang = $request->input('lang');
+        // FIXME: status 未传时会被静默重置为 10(非公开),已公开的文章做部分更新会被降级;
+        // 建议改为只有请求体明确带 status 时才赋值,否则保留原值。
         $article->status = $request->input('status', 10);
         $article->editor_id = $user['user_id'];
         $article->modify_time = time() * 1000;
@@ -532,13 +597,15 @@ class ArticleController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除 article(文章)
+     *
+     * 需要登录,且只有文章 owner 本人可以删除(协作者不行),否则返回鉴权失败。
+     * 副作用:在事务中删除文章记录,并同步把该文章从所有文集目录(article_collections)中摘除。
      *
-     * @return Response
+     * @urlParam article string required 文章 uid(路由模型绑定)
      */
     public function destroy(Request $request, Article $article)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'));
@@ -548,6 +615,8 @@ class ArticleController extends Controller
             return $this->error(__('auth.failed'));
         }
         $delete = 0;
+        // FIXME: $delete 按值捕获,闭包内的赋值传不出来,返回值恒为 0,前端拿不到真实删除结果;
+        // 建议改为 use (&$delete) 引用捕获,或直接用 DB::transaction 的返回值。
         DB::transaction(function () use ($article, $delete) {
             // TODO 删除文集中的文章
             $delete = $article->delete();

+ 69 - 15
api-v13/app/Http/Controllers/ChannelController.php

@@ -22,9 +22,26 @@ use Illuminate\Support\Facades\DB;
 class ChannelController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出 channel(译文集)
      *
-     * @return Response
+     * 按 view 指定的口径返回 channel 列表,支持关键字搜索、分页、排序。
+     * 需要登录的口径:studio、studio-all、user-edit、user-in-chapter。
+     *
+     * @queryParam view string required 查询口径。
+     *             Enum: public,studio,studio-all,user-edit,user-in-chapter,system,paragraphs,id
+     * @queryParam name string studio 名称,view=studio / studio-all 时必填
+     * @queryParam view2 string view=studio 时的二级口径:my=我的,其余值=协作的。Default: my
+     * @queryParam collaborator string view=studio 且 view2 为协作时,按协作者 uid 过滤。Default: all
+     * @queryParam id string view=id 时的 channel uid 列表,逗号分隔
+     * @queryParam book integer view=user-in-chapter 时的典籍 id;传入后会联查该章节的翻译进度
+     * @queryParam para string view=user-in-chapter 时的段落号
+     * @queryParam book_id integer view=paragraphs 时的典籍 id
+     * @queryParam paragraph string 联查 progress_chapters 时的段落号,与 book 配合使用
+     * @queryParam progress string 传入则在每行附带 final 逐句完成情况,依赖 book 与 para
+     * @queryParam type integer 按 channel 类型过滤
+     * @queryParam updated_at string 只返回该时间之后更新的记录,用于离线包增量同步。
+     *             Example: 2023-09-18T05:39:51.000000Z
+     * @queryParam created_at string 只返回该时间之后新建的记录
      */
     public function index(Request $request)
     {
@@ -47,6 +64,8 @@ class ChannelController extends Controller
         if ($request->has('book')) {
             $indexCol[] = 'progress_chapters.progress';
         }
+        // FIXME: switch 没有 default 分支,view 缺失或不在枚举内时 $table 始终未定义,
+        // 下面的 $table->count() 会直接 500。应补 default 返回参数错误。
         switch ($request->input('view')) {
             case 'public':
                 $table = Channel::select($indexCol)
@@ -183,6 +202,8 @@ class ChannelController extends Controller
                     $table = Channel::select($indexCol)
                         ->whereIn('uid', $channelIds);
                 } else {
+                    // FIXME: whereIsNull 不是 Eloquent 方法,会被动态 where 解析成 where('is_null', 'uid'),
+                    // 查不存在的列直接报 SQL 错误。本意应是返回空结果集,改用 ->whereRaw('1 = 0') 或 whereNull('uid')。
                     $table = Channel::select($indexCol)->whereIsNull('uid');
                 }
                 break;
@@ -533,19 +554,29 @@ class ChannelController extends Controller
             }
         }
 
+        // FIXME: 第二个元素漏写了键名,实际返回的是 {rows: [...], 0: n} 而不是 {rows, count},
+        // 与本控制器其他列表接口的返回结构不一致。应为 'count' => count($result)。
         return $this->ok(['rows' => $result, count($result)]);
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建 channel
      *
-     * @return Response
+     * 在指定 studio 下新建译文集。同一 studio 内不允许重名。
+     * 调用者必须对该 studio 有管理权限。
+     *
+     * @bodyParam studio string required 所属 studio 名称
+     * @bodyParam name string required channel 名称,在同一 studio 内唯一
+     * @bodyParam type integer required channel 类型
+     * @bodyParam lang string required 译文语言代码。Example: zh-Hans
      */
     public function store(Request $request)
     {
         //
         $user = AuthService::current($request);
         if (! $user) {
+            // FIXME: error() 的签名是 error(string $message, mixed $data, int $status),
+            // 这里把 401 当成了 data 传进去,响应体的 data 会变成数字 401。本文件多处同样写法。
             return $this->error(__('auth.failed'), 401, 401);
         }
         // 判断当前用户是否有指定的studio的权限
@@ -582,10 +613,11 @@ class ChannelController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 获取单个 channel
      *
-     * @param  int  $id
-     * @return Response
+     * 返回 channel 详情,附带所属 studio 与作者信息。
+     *
+     * @urlParam channel string required channel uid
      */
     public function show($id)
     {
@@ -602,9 +634,9 @@ class ChannelController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 按名称获取 channel
      *
-     * @return Response
+     * @urlParam name string required channel 名称
      */
     public function showByName(string $name)
     {
@@ -619,9 +651,20 @@ class ChannelController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 整体更新 channel
      *
-     * @return Response
+     * 覆盖式更新,未传的字段会被写成 null;只改部分字段请用 PATCH /channel。
+     * 系统 channel 不可修改;非 owner 需要 30 以上的协作权限。
+     *
+     * @urlParam channel string required channel uid
+     *
+     * @bodyParam name string required channel 名称
+     * @bodyParam type integer required channel 类型
+     * @bodyParam summary string required 简介
+     * @bodyParam lang string required 译文语言代码
+     * @bodyParam status integer required 状态。30 为全网公开
+     * @bodyParam source_type string 来源类型,传入才会更新
+     * @bodyParam source_id string 来源 id,传入才会更新
      */
     public function update(Request $request, Channel $channel)
     {
@@ -657,9 +700,16 @@ class ChannelController extends Controller
     }
 
     /**
-     * patch the specified resource in storage.
+     * 局部更新 channel
      *
-     * @return Response
+     * 只更新请求体中出现的字段。系统 channel 不可修改;非 owner 需要 30 以上的协作权限。
+     *
+     * @bodyParam name string channel 名称
+     * @bodyParam type integer channel 类型
+     * @bodyParam summary string 简介
+     * @bodyParam lang string 译文语言代码
+     * @bodyParam status integer 状态。30 为全网公开
+     * @bodyParam config string channel 配置
      */
     public function patch(Request $request, Channel $channel)
     {
@@ -702,9 +752,11 @@ class ChannelController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除 channel
      *
-     * @return Response
+     * 只有 owner 可以删除。channel 下已有译文、术语或逐词解析数据时拒绝删除。
+     *
+     * @urlParam channel string required channel uid
      */
     public function destroy(Request $request, Channel $channel)
     {
@@ -727,6 +779,8 @@ class ChannelController extends Controller
         if (WbwBlock::where('channel_uid', $channel->uid)->exists()) {
             return $this->error('逐词解析有数据无法删除');
         }
+        // FIXME: $delete 按值捕获,闭包内的赋值传不出来,接口永远返回 0。
+        // 应改为 use (&$delete) 或让闭包 return 删除结果。
         $delete = 0;
         DB::transaction(function () use ($channel, $delete) {
             // TODO 删除相关资源

+ 83 - 13
api-v13/app/Http/Controllers/CourseMemberController.php

@@ -9,21 +9,37 @@ use App\Models\CourseMember;
 use App\Models\UserInfo;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
 class CourseMemberController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出课程成员
      *
-     * @return Response
+     * 按 view 指定的口径返回课程成员列表,支持角色/状态过滤、按昵称搜索、分页与排序。
+     * 必须登录,且当前用户必须已经是该 course 的成员(在 course_member 中有 role),否则返回 403。
+     * 返回 rows(成员列表)、role(当前用户在该 course 中的当前角色)、count(过滤后的总数)。
+     *
+     * @queryParam view string required 查询口径。course=课程内全部当前成员;timeline=某用户的成员变更时间线。Enum: course,timeline
+     * @queryParam id string required course uid。view=timeline 时也用于权限校验
+     * @queryParam course string 权限校验与 timeline 口径使用的 course uid,缺省时回退到 id
+     * @queryParam userId string view=timeline 时必填,要查看时间线的用户 uid
+     * @queryParam timeline string view=timeline 时的范围:current=只看 course 参数指定的课程,其余值=该用户全部课程。Default: current
+     * @queryParam role string 按角色过滤,all 表示不过滤。Example: student
+     * @queryParam status string 按状态过滤,逗号分隔可多选。Example: joined,applied
+     * @queryParam search string 按成员昵称模糊搜索
+     * @queryParam order string 排序字段。Default: created_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: asc
+     * @queryParam offset integer 跳过的记录数。Default: 0
+     * @queryParam limit integer 返回条数上限。Default: 1000
      */
     public function index(Request $request)
     {
         //
         $user = AuthService::current($request);
+        // FIXME: [403] 和 403 被当成了 __() 的替换参数与 locale,没有传给 error(),实际返回的仍是默认状态码。
+        // 应改为 $this->error(__('auth.failed'), [403], 403);本文件多处同样写法。
         if (! $user) {
             return $this->error(__('auth.failed', [403], 403));
         }
@@ -76,6 +92,8 @@ class CourseMemberController extends Controller
             $usersId = UserInfo::where('nickname', 'like', '%'.$request->input('search').'%')
                 ->select('userid')
                 ->get();
+            // FIXME: whereIn 传入的是 UserInfo 模型集合而非 id 数组,取到的是主键而不是 userid 列,search 过滤很可能不生效。
+            // 建议改为 ->pluck('userid')->all() 后再传入。
             $table = $table->whereIn('user_id', $usersId);
         }
 
@@ -92,6 +110,8 @@ class CourseMemberController extends Controller
         $result = $table->get();
 
         // 获取当前用户角色
+        // FIXME: 这里固定读 input('id'),view=timeline 且只传了 course 时取不到 role,返回的 role 会是空。
+        // 建议与上面的权限校验一致,改用 input('id', input('course'))。
         $role = CourseMember::where('course_id', $request->input('id'))
             ->where('user_id', $user['user_uid'])
             ->where('is_current', true)
@@ -101,9 +121,19 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新增课程成员记录(加入/申请/邀请)
+     *
+     * 必须登录。status=invited 表示由当前用户邀请他人,成员归属 user_id 指定的用户;
+     * 其余情况(自己加入或申请)一律忽略 user_id,成员归属当前登录用户。
+     * 非 invited 时若该 user 已在该 course 中存在任何记录,返回 member exists(HTTP 200)。
+     * 写入前会把同一 course + user 的旧记录 is_current 置为 false,新记录成为当前记录。
+     * 状态必须与课程加入方式匹配:course.join=open 只接受 joined/invited,manual 只接受 applied/invited,
+     * 不匹配返回 invalid course(HTTP 200)。course 不存在返回 invalid course。
      *
-     * @return Response
+     * @bodyParam user_id string required 成员用户 uid,仅在 status=invited 时生效
+     * @bodyParam course_id string required course uid
+     * @bodyParam role string required 成员角色。Example: student
+     * @bodyParam status string required 成员状态。Enum: joined,applied,invited
      */
     public function store(Request $request)
     {
@@ -178,9 +208,14 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询某用户在指定课程中的当前成员记录
+     *
+     * 必须登录。默认查当前登录用户,传 user_uid 可查他人(无额外权限校验)。
+     * 只返回 is_current=true 的那条记录;查不到返回 no result(HTTP 200)。
+     *
+     * @urlParam courseId string required course uid
      *
-     * @return Response
+     * @queryParam user_uid string 要查询的用户 uid,缺省为当前登录用户
      */
     public function show(Request $request, string $courseId)
     {
@@ -205,9 +240,16 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新课程成员(以新增记录的方式保留历史)
      *
-     * @return Response
+     * 必须登录。不会就地修改原记录:先复制一条新记录,把原记录 is_current 置为 false,
+     * 再把请求中出现的字段写到新记录上,原记录作为历史保留。
+     * 修改 channel_id 只允许成员本人操作,否则返回 auth.failed。
+     *
+     * @urlParam course_member string required 要更新的 course_member 记录 id
+     *
+     * @bodyParam channel_id string 该成员在课程中使用的 channel uid,仅本人可改
+     * @bodyParam status string 新的成员状态。Example: joined
      */
     public function update(Request $request, CourseMember $courseMember)
     {
@@ -246,6 +288,15 @@ class CourseMemberController extends Controller
         return $this->ok(new CourseMemberResource($newMember));
     }
 
+    /**
+     * 设置当前用户在某课程中使用的 channel
+     *
+     * 必须登录。就地修改当前用户在该 course 的 is_current 记录(不产生历史记录)。
+     * 未传 channel_id、或当前用户不是该课程的当前成员,都返回 auth.failed。
+     *
+     * @bodyParam course_id string required course uid
+     * @bodyParam channel_id string required 作业所用的 channel uid
+     */
     public function set_channel(Request $request)
     {
         //
@@ -273,9 +324,12 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除课程成员记录
      *
-     * @return Response
+     * 必须登录。课程所有者(course.studio_id 等于当前用户)可以直接删除;
+     * 否则按当前用户在该课程中的角色判断权限,普通 student 无删除权限,返回 auth.failed。
+     *
+     * @urlParam course_member string required 要删除的 course_member 记录 id
      */
     public function destroy(Request $request, CourseMember $courseMember)
     {
@@ -293,6 +347,8 @@ class CourseMemberController extends Controller
                 ->select('role')->first();
             // open 课程 可以删除自己
 
+            // FIXME: 条件写反了。$courseUser 为 null 时才进入分支并立刻对 null 取属性,会 fatal error;
+            // 而真正有记录的 student 反而绕过了权限检查,等于对所有非 owner 的已有成员放行。应改为 if ($courseUser) {。
             if (! $courseUser) {
                 // 被删除的不是自己
                 if ($courseUser->role === 'student') {
@@ -308,9 +364,12 @@ class CourseMemberController extends Controller
     }
 
     /**
-     * 获取当前用户权限
+     * 获取当前用户在指定课程中的角色与 channel
+     *
+     * 必须登录。只读取 is_current=true 的记录,返回 role 与 channel_id 两个字段。
+     * 当前用户不是该课程成员时返回 not member。
      *
-     * @return Response
+     * @queryParam course_id string required course uid
      */
     public function curr(Request $request)
     {
@@ -329,8 +388,19 @@ class CourseMemberController extends Controller
         }
     }
 
+    /**
+     * 导出课程成员名单为 Excel
+     *
+     * 导出该课程全部 is_current 成员,表头为 nickname、username、role、status、created_at,
+     * 昵称与用户名通过 UserApi 按 user_id 逐条查询。
+     * 直接以 xlsx 附件(course_member.xlsx)写入输出流,不返回 JSON,且不做登录与权限校验。
+     *
+     * @queryParam course_id string required course uid
+     */
     public function export(Request $request)
     {
+        // FIXME: 本方法没有任何登录与权限校验,任何人都能导出任意课程的成员名单(含昵称与用户名),属于信息泄露。
+        // 应先 AuthService::current() 并校验当前用户是该 course 的管理者或 studio 拥有者。
 
         $courseUser = CourseMember::where('course_id', $request->input('course_id'))
             ->where('is_current', true)

+ 114 - 21
api-v13/app/Http/Controllers/DhammaTermController.php

@@ -14,7 +14,6 @@ use App\Services\AuthService;
 use App\Services\TermIndexService;
 use App\Tools\Tools;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
@@ -29,9 +28,35 @@ class DhammaTermController extends Controller
     ) {}
 
     /**
-     * Display a listing of the resource.
+     * 列出术语(dhamma term)
      *
-     * @return Response
+     * 按 view 指定的口径返回术语列表。studio、channel、user 三个口径需要登录;
+     * studio 要求 name 指向的 studio 属于当前用户,否则 403;channel 要求当前用户是
+     * channel owner 或协作者(ShareApi 权限值非 0),否则 403。
+     * create-by-channel、show、hot-meaning 三个口径直接返回各自的数据结构,
+     * 不走后面的搜索/排序/分页;其余口径统一返回 {rows, count}。
+     * hot-meaning 结果会按 config('mint.cache.expire') 缓存在 term/hot_meaning 键下。
+     * 未指定 view(或给了无法识别的值)时没有任何基础查询,会直接报错,调用方必须传 view。
+     *
+     * @queryParam view string required 查询口径。Enum: create-by-channel,studio,channel,show,user,word,tag,hot-meaning
+     * @queryParam channel string view=create-by-channel 时必填,目标 channel 的 uid,
+     *                            用于确定语言、同 studio 的 channel 列表与 studio 信息
+     * @queryParam word string view=create-by-channel 时为要新建的术语词形;
+     *                         view=word 时为逗号分隔的词或义项列表,按 word 或 meaning 命中
+     * @queryParam name string view=studio 时必填,studio 名称,须与当前用户的 studio 一致
+     * @queryParam id string view=channel 时为 channel id(uid);view=show 时为术语主键 id
+     * @queryParam tag string view=tag 时必填,逗号分隔的 tag 列表
+     * @queryParam language string view=hot-meaning 时必填,统计该语言下每个词出现次数最多的义项
+     * @queryParam search string 关键字,按 word、word_en 前缀匹配或 meaning 模糊匹配
+     * @queryParam order string 排序字段。Default: updated_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 分页偏移量。Default: 0
+     * @queryParam limit integer 每页条数。Default: 1000
+     * @queryParam mode string 渲染 note/summary 的模式,透传给 Markdown 渲染器。Default: read
+     * @queryParam format string note 的渲染输出格式。Enum: react,html,text Default: react
+     * @queryParam community_summary boolean 传入该参数时,自身没有 note 的术语会回落到社区
+     *                                       channel 的解释作为 summary
+     * @queryParam exp boolean 传入该参数时附带编辑者截至本条更新时间的累计经验值(秒)
      */
     public function index(Request $request)
     {
@@ -53,6 +78,8 @@ class DhammaTermController extends Controller
         ];
 
         switch ($request->input('view')) {
+            // FIXME: create-by-channel 口径完全不做登录/权限校验,任何人都能枚举任意
+            // channel 所属 studio 的全部 channel 列表;应补上与 channel 口径一致的鉴权。
             case 'create-by-channel':
                 // 新建术语时。根据术语所在channel 给出新建术语所需数据。如语言,备选意思等。
                 // 获取channel信息
@@ -122,6 +149,8 @@ class DhammaTermController extends Controller
                     return $this->error(__('auth.failed'));
                 }
                 // 判断当前用户是否有指定的 channel 的权限
+                // FIXME: find() 按主键查(其余地方 channel 都按 uid 查),且结果未判空就访问
+                // ->owner_uid,id 不存在时直接 500;应改为 where('uid', ...)->first() 并判空返回 404。
                 $channel = Channel::find($request->input('id'));
                 if ($user['user_uid'] !== $channel->owner_uid) {
                     // 看是否为协作
@@ -134,6 +163,8 @@ class DhammaTermController extends Controller
                     ->where('channal', $request->input('id'));
                 break;
             case 'show':
+                // FIXME: 不做登录校验且直接返回原始模型(未过 TermResource),会把 owner、
+                // editor_id 等非公开字段暴露出去;应改用 TermResource 并补权限判定。
                 return $this->ok(DhammaTerm::find($request->input('id')));
                 break;
             case 'user':
@@ -143,11 +174,14 @@ class DhammaTermController extends Controller
                     return $this->error(__('auth.failed'));
                 }
                 $userUid = $user['user_uid'];
+                // FIXME: 这里的 $search 是死代码,会被方法末尾同名变量重新赋值覆盖;应直接删除。
                 $search = $request->input('search');
                 $table = DhammaTerm::select($indexCol)
                     ->where('owner', $userUid);
                 break;
             case 'word':
+                // FIXME: whereIn / orWhereIn 未用闭包分组,后面追加的 search 条件会与这个 or
+                // 错误结合导致越界结果;应包成 ->where(fn ($q) => $q->whereIn(...)->orWhereIn(...))。
                 $table = DhammaTerm::select($indexCol)
                     ->whereIn('word', explode(',', $request->input('word')))
                     ->orWhereIn('meaning', explode(',', $request->input('word')));
@@ -165,6 +199,8 @@ class DhammaTermController extends Controller
                         ->groupby('word')
                         ->get();
 
+                    // FIXME: 典型 N+1,每个 word 单独发一次聚合查询;应改成一次
+                    // group by word, meaning 的查询再在 PHP 里取每个词的 top 1。
                     foreach ($words as $key => $word) {
                         // code...
                         $result = DhammaTerm::select(DB::raw('count(*) as word_count, meaning'))
@@ -182,6 +218,8 @@ class DhammaTermController extends Controller
                             ];
                         }
                     }
+                    // FIXME: 外层 Cache::remember 已负责写缓存,这里重复写入且 TTL(3600)
+                    // 与 config('mint.cache.expire') 不一致;应删除这一行。
                     Cache::put($key, $hotMeaning, 3600);
 
                     return $hotMeaning;
@@ -189,6 +227,8 @@ class DhammaTermController extends Controller
 
                 return $this->ok(['rows' => $value, 'count' => count($value)]);
                 break;
+                // FIXME: 未传 view 或传了无法识别的 view 时 $table 未定义,会一路走到下面的
+                // $table->count() 直接抛错;应在 default 里返回明确的参数错误。
             default:
                 // code...
                 break;
@@ -197,9 +237,9 @@ class DhammaTermController extends Controller
         $search = $request->input('search');
         if (! empty($search)) {
             $table = $table->where(function ($query) use ($search) {
-                $query->where('word', 'like', $search . '%')
-                    ->orWhere('word_en', 'like', $search . '%')
-                    ->orWhere('meaning', 'like', '%' . $search . '%');
+                $query->where('word', 'like', $search.'%')
+                    ->orWhere('word_en', 'like', $search.'%')
+                    ->orWhere('meaning', 'like', '%'.$search.'%');
             });
         }
         $count = $table->count();
@@ -212,9 +252,28 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建术语
+     *
+     * 需要登录。传了 channel 则建 channel 级术语,owner 取该 channel 所属 studio、
+     * language 取该 channel 的语言(请求里的 language 被覆盖),并要求当前身份对该
+     * channel 有编辑权(owner / 协作者 / channel access token),否则 403。
+     * 不传 channel 则建 studio 级术语,只允许 studio 本人创建(studioId 必须等于当前
+     * 用户 uid,否则 403);AI 模型身份一律禁止(403),因为 access token 只能代持
+     * channel 级权限。
+     * 唯一性:channel 级按 channel+word+tag 查重,studio 级按 owner+word+tag+language
+     * 且 channal 为空查重;重复时返回 'word existed',HTTP 状态仍是 200。
+     * 写入成功后清除该词的 term 缓存并重建 OpenSearch 索引(索引失败只记日志)。
      *
-     * @return Response
+     * @bodyParam word string required 术语词形,同时用于生成 word_en
+     * @bodyParam meaning string required 主要义项
+     * @bodyParam other_meaning string 其他义项,逗号分隔
+     * @bodyParam note string 术语解释,Markdown
+     * @bodyParam tag string 标签,参与查重
+     * @bodyParam channel string channel uid。给出则建 channel 级术语,否则建 studio 级术语
+     * @bodyParam language string 语言。仅 studio 级术语生效,channel 级会被 channel 语言覆盖
+     * @bodyParam studioId string studio 的 uuid,不传 channel 时必填(或改用 studioName)
+     * @bodyParam studioName string studio 名称,未给 studioId 时用它换取 studioId
+     * @bodyParam access_token string channel access token,供 AI 模型等非 owner 身份验证 channel 编辑权
      */
     public function store(Request $request)
     {
@@ -343,6 +402,8 @@ class DhammaTermController extends Controller
             // 通用 查询studio所有channel
             $channels = Channel::where('owner_uid', $term->owner)->select('uid')->get();
             foreach ($channels as $channel) {
+                // FIXME: $channel 是 Eloquent 模型对象,插值后得到的是 JSON 而非 uid,
+                // 缓存键与写入时的键对不上,studio 级术语的缓存实际没被清掉;应用 $channel->uid。
                 Cache::forget("/term/{$channel}/{$term->word}");
             }
         } else {
@@ -369,14 +430,21 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询单条术语
+     *
+     * 按 guid(不是自增 id)查找,公开接口,不需要登录。查不到返回错误 '没有查询到数据'。
      *
-     * @param  string  $id
-     * @return Response
+     * @urlParam id string required 术语的 guid
+     *
+     * @queryParam channel string 渲染 note 时使用的 channel 列表,多个用下划线分隔;
+     *                            不传则按术语自身的 channel 或对应语言的社区 channel 渲染
+     * @queryParam mode string 渲染模式。Default: read
+     * @queryParam format string note 的渲染输出格式。Enum: react,html,text Default: react
+     * @queryParam community_summary boolean 传入该参数时,没有 note 的术语回落到社区解释作为 summary
+     * @queryParam exp boolean 传入该参数时附带编辑者累计经验值(秒)
      */
     public function show(Request $request, $id)
     {
-        //
         $result = DhammaTerm::where('guid', $id)->first();
         if ($result) {
             return $this->ok(new TermResource($result));
@@ -386,14 +454,27 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改术语
+     *
+     * 需要登录(未登录 401),按主键 id 查找,不存在返回 '404'。
+     * 权限:studio 级术语(channal 为空)只有 owner 本人能改,AI 模型一律 403;
+     * channel 级术语要求对该 channel 有编辑权(owner / 协作者 / access token),否则 403。
+     * 增量更新——只有请求里出现的字段才会被写入,未提交的字段保持原值;
+     * 提交 word 时会同步重算 word_en。create_time 不变,只刷新 modify_time。
+     * 保存后清除该词的 term 缓存并重建 OpenSearch 索引(索引失败只记日志)。
      *
-     * @param  DhammaTerm  $dhammaTerm
-     * @return Response
+     * @urlParam id string required 术语主键 id
+     *
+     * @bodyParam word string 术语词形,提交时同步重算 word_en
+     * @bodyParam meaning string 主要义项
+     * @bodyParam other_meaning string 其他义项,逗号分隔
+     * @bodyParam note string 术语解释,Markdown
+     * @bodyParam tag string 标签
+     * @bodyParam language string 语言
+     * @bodyParam access_token string channel access token,供非 owner 身份验证 channel 编辑权
      */
     public function update(Request $request, string $id)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), [], 401);
@@ -449,22 +530,32 @@ class DhammaTermController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 批量删除术语
+     *
+     * 需要登录。一次可以删除多条,返回实际删除的条数;无权限的条目被跳过而不报错。
+     * 传了 uuid 参数时,id 按数组处理:owner 本人可删,非 owner 且术语属于某个 channel 时
+     * 需要该 channel 的协作权限达到 20(编辑)才可删,studio 级术语非 owner 一律跳过。
+     * 未传 uuid 时,id 按 JSON 字符串数组处理,且只能删除 owner 是当前用户的术语。
+     * 每删一条都会清除该词对应的 term 缓存。
+     *
+     * @urlParam dhammaTerm string required 路由模型绑定占位的术语 id,实际删除对象由 id 参数决定
      *
-     * @return Response
+     * @queryParam uuid string 存在该参数即表示 id 以数组形式提交,并启用 channel 协作者删除权限判定
+     * @queryParam id array required 要删除的术语 id 列表。带 uuid 时为数组,否则为 JSON 数组字符串
      */
     public function destroy(DhammaTerm $dhammaTerm, Request $request)
     {
-        /**
-         * 一次删除多个单词
-         */
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'));
         }
         $count = 0;
+        // FIXME: 判断用的是 uuid 参数、实际读的却是 id 参数,uuid 纯当开关用,语义混乱
+        // 极易误用;应改为按 id 的实际类型分支,或统一成一个明确的参数格式。
         if ($request->has('uuid')) {
             // 查看是否有删除权限
+            // FIXME: 未校验 input('id') 是数组,客户端传字符串会导致 foreach 直接 fatal;
+            // 应先 validate(['id' => 'required|array'])。
             foreach ($request->input('id') as $key => $uuid) {
                 $term = DhammaTerm::find($uuid);
                 if (! $term) {
@@ -486,6 +577,8 @@ class DhammaTermController extends Controller
                 $this->deleteCache($term);
             }
         } else {
+            // FIXME: json_decode 结果未判空,非法 JSON 时得到 null 会让下面的 foreach 报错;
+            // 应判断解析结果是数组,否则返回参数错误。
             $arrId = json_decode($request->input('id'), true);
             foreach ($arrId as $key => $id) {
                 // code...

+ 97 - 14
api-v13/app/Http/Controllers/DiscussionController.php

@@ -16,14 +16,29 @@ use App\Models\Wbw;
 use App\Models\WbwBlock;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 
 class DiscussionController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出讨论 / 批注
      *
-     * @return Response
+     * 按 view 指定的口径返回 discussion 列表,支持状态过滤、分页与排序。
+     * 权限:未登录用户请求 type=discussion 时直接返回空结果;basic 角色用户在自己无编辑权限的
+     * channel 上只能看到自己发表的 discussion;view=topic-by-user 必须登录,否则返回 403。
+     * 返回 rows、count(当前口径总数)、active/close(顶级节点的活跃与关闭数)、
+     * can_create/can_reply(当前用户能否发起与回复,按 type 与 res_type 计算)。
+     *
+     * @queryParam view string required 查询口径。question=按资源查顶级讨论;question-by-topic=按某条讨论所属资源查同资源讨论;answer=查某讨论的回复;res_id=某资源的顶级节点及其直接子节点;topic-by-user=当前用户发表的全部顶级讨论;all=全部顶级讨论。Enum: question,question-by-topic,answer,res_id,topic-by-user,all
+     * @queryParam id string required 资源 uid(question/res_id)、讨论 id(question-by-topic/answer)
+     * @queryParam type string 讨论类型。Enum: discussion,qa,help,note Default: discussion
+     * @queryParam res_type string 资源类型,影响权限判断与学员提问聚合。Enum: sentence,wbw,article
+     * @queryParam status string 状态过滤;res_id 与 topic-by-user 口径支持逗号分隔多选。Enum: active,close Default: active
+     * @queryParam course string 课程 uid,配合 show_student 聚合学员提问
+     * @queryParam show_student string 是否把学员 channel 上的同位置提问一并返回。Enum: true,false
+     * @queryParam order string 排序字段。Default: created_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 跳过的记录数。Default: 0
+     * @queryParam limit integer 返回条数上限。Default: 100
      */
     public function index(Request $request)
     {
@@ -34,6 +49,9 @@ class DiscussionController extends Controller
         }
         switch ($request->input('view')) {
             case 'question-by-topic':
+                // FIXME: $topic 拿到的是 Builder 而不是模型,->first() 的结果被丢弃,
+                // 因此下面的 if (! $topic) 永远为 false,$topic->res_id 走的是 Builder 魔术属性。
+                // 应写成 $topic = Discussion::where('id', ...)->where('status', ...)->select('res_id')->first();
                 $topic = Discussion::where('id', $request->input('id'));
                 $topic->where('status', $request->input('status', 'active'))
                     ->select('res_id')->first();
@@ -197,9 +215,13 @@ class DiscussionController extends Controller
                     ->where('status', 'close')->count();
                 break;
         }
+        // FIXME: $search 从未定义,这段标题搜索是永不执行的死代码。
+        // 应改为从 $request->input('search') 取值,或直接删除。
         if (! empty($search)) {
             $table = $table->where('title', 'like', $search.'%');
         }
+        // FIXME: view 不在枚举内(含缺省)时 $table 从未被赋值,这里会 fatal error。
+        // 建议 switch 补 default 分支直接返回参数错误。
         $count = $table->count();
 
         $table = $table->orderBy($request->input('order', 'created_at'), $request->input('dir', 'desc'));
@@ -253,6 +275,20 @@ class DiscussionController extends Controller
         ]);
     }
 
+    /**
+     * 按句子批量查询讨论概览
+     *
+     * 对传入的每个句子定位(book/paragraph/word_start/word_end/channel_id)先查到 sentence,
+     * 再取该句上全部顶级讨论(title、children_count、editor_uid,按创建时间倒序)。
+     * 没有讨论的句子会被跳过,不做登录与权限校验。返回 rows 与 count。
+     *
+     * @bodyParam data array required 句子定位数组
+     * @bodyParam data[].book integer required 卷号
+     * @bodyParam data[].paragraph integer required 段号
+     * @bodyParam data[].word_start integer required 句子起始词序号
+     * @bodyParam data[].word_end integer required 句子结束词序号
+     * @bodyParam data[].channel_id string required 译文集 channel uid
+     */
     public function discussion_tree(Request $request)
     {
         $output = [];
@@ -291,9 +327,28 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 发表讨论 / 批注或回复
+     *
+     * 必须登录,未登录返回 401。传了 parent 即为回复:res_id 与 res_type 继承自父节点,
+     * 无需再传,父节点不存在返回 no record,保存后父节点 children_count 自增;
+     * 不传 parent 为顶级节点,此时 res_id、res_type、title 均为必填。
+     * 锚点字段(pos_start/pos_end/quote_exact/quote_prefix/quote_suffix)两种情况都可选。
+     * 默认会向消息队列 discussion 推送通知,可用 notification=false 关闭。
      *
-     * @return Response
+     * @bodyParam parent string 父讨论 id,传入表示这是一条回复
+     * @bodyParam res_id string required 关联资源 uid(有 parent 时忽略并继承父节点)
+     * @bodyParam res_type string required 关联资源类型(有 parent 时忽略并继承父节点)。Enum: sentence,wbw,article
+     * @bodyParam title string required 标题(有 parent 时非必填)
+     * @bodyParam content string 正文内容
+     * @bodyParam content_type string 正文格式。Default: markdown
+     * @bodyParam type string 讨论类型。Enum: discussion,qa,help,note Default: discussion
+     * @bodyParam tpl_id string 模板 id
+     * @bodyParam pos_start integer 锚点在资源文本中的起始偏移,非负整数
+     * @bodyParam pos_end integer 锚点在资源文本中的结束偏移,非负整数
+     * @bodyParam quote_exact string 锚定的原文片段
+     * @bodyParam quote_prefix string 锚定片段前的上下文,用于重新定位
+     * @bodyParam quote_suffix string 锚定片段后的上下文,用于重新定位
+     * @bodyParam notification boolean 是否推送消息队列通知。Default: true
      */
     public function store(Request $request)
     {
@@ -362,9 +417,11 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询单条讨论
      *
-     * @return Response
+     * 按路径参数做路由模型绑定并直接返回,不做登录与权限校验。
+     *
+     * @urlParam discussion string required 讨论 id
      */
     public function show(Discussion $discussion)
     {
@@ -373,10 +430,13 @@ class DiscussionController extends Controller
     }
 
     /**
-     * 获取discussion 锚点的数据。以句子为最小单位,逐词解析也要显示单词所在的句子
+     * 获取 discussion 锚点的数据
+     *
+     * 以句子为最小单位返回锚点上下文:res_type=wbw 时,由单词定位到所属 wbw block 与巴利句子,
+     * 再用该 block 的 channel 渲染出句子内容的 markdown。其他 res_type 暂返回空字符串。
+     * 不做登录与权限校验;wbw、wbw block 或句子任一查不到时分别返回 no wbw data / no wbwBlock data / no sent data。
      *
-     * @param  string  $id
-     * @return Response
+     * @urlParam id string required 讨论 id
      */
     public function anchor($id)
     {
@@ -416,9 +476,25 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改讨论 / 批注
+     *
+     * 必须登录,未登录返回 403。作者本人可改;非作者则要求是资源所属 channel 的可编辑者
+     * (res_type=sentence 取 sentence.channel_uid,res_type=wbw 经 wbw block 取 channel_uid),
+     * 两者都不满足返回 403。
+     * title、content、status 按请求覆盖,未提交时分别回落为 null、null、active;
+     * type 与五个锚点字段为增量更新,只改请求里出现的字段,未提交的原样保留。
      *
-     * @return Response
+     * @urlParam discussion string required 讨论 id
+     *
+     * @bodyParam title string 标题,未提交会被清空
+     * @bodyParam content string 正文内容,未提交会被清空
+     * @bodyParam status string 状态,未提交会重置为 active。Enum: active,close Default: active
+     * @bodyParam type string 讨论类型,仅在提交时更新。Enum: discussion,qa,help,note
+     * @bodyParam pos_start integer 锚点起始偏移,仅在提交时更新
+     * @bodyParam pos_end integer 锚点结束偏移,仅在提交时更新
+     * @bodyParam quote_exact string 锚定的原文片段,仅在提交时更新
+     * @bodyParam quote_prefix string 锚定片段前的上下文,仅在提交时更新
+     * @bodyParam quote_suffix string 锚定片段后的上下文,仅在提交时更新
      */
     public function update(Request $request, Discussion $discussion)
     {
@@ -459,6 +535,8 @@ class DiscussionController extends Controller
             return $this->error(__('auth.failed'), [403], 403);
         }
 
+        // FIXME: 未提交 title/content/status 时会被覆盖成 null/active,与下方锚点字段的增量更新策略不一致,
+        // 前端只想改单个字段时容易误清内容。建议统一改为 $request->has() 判断后再赋值。
         $discussion->title = $request->input('title', null);
         $discussion->content = $request->input('content', null);
         $discussion->status = $request->input('status', 'active');
@@ -479,9 +557,12 @@ class DiscussionController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除讨论 / 批注
+     *
+     * 必须登录,未登录返回 401。目前只允许作者本人删除,其他人返回 403。
+     * 不会级联删除子回复,也不会回退父节点的 children_count。
      *
-     * @return Response
+     * @urlParam discussion string required 讨论 id
      */
     public function destroy(Request $request, Discussion $discussion)
     {
@@ -494,6 +575,8 @@ class DiscussionController extends Controller
         if ($discussion->editor_uid !== $user['user_uid']) {
             return $this->error(__('auth.failed'), [403], 403);
         }
+        // FIXME: 删除时既不级联删除子回复(产生孤儿节点),也不回退父节点的 children_count,计数会漂移。
+        // 建议在事务里一并删除子节点并 decrement 父节点计数。
         $delete = $discussion->delete();
 
         return $this->ok($delete);

+ 64 - 14
api-v13/app/Http/Controllers/ProjectController.php

@@ -8,15 +8,27 @@ use App\Http\Resources\ProjectResource;
 use App\Models\Project;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Str;
 
 class ProjectController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出工程(project)
      *
-     * @return Response
+     * 按 view 指定的口径返回工程列表,支持关键字搜索、状态过滤、排序和分页。
+     * 必须登录,否则返回 401;view 缺失或不在枚举内时返回错误提示 view。
+     * 未传 studio 时,studio 默认取当前用户自己。
+     *
+     * @queryParam view string required 查询口径:studio=某 studio 下的顶层工程,project-tree=某工程及其所有子工程,shared=分享给该 studio 的工程,community=其他 studio 的公开顶层工程。Enum: studio,project-tree,shared,community
+     * @queryParam studio string studio 名称;不传则使用当前登录用户
+     * @queryParam type string 工程类型,用于 studio/shared/community 口径。Enum: instance,article Default: instance
+     * @queryParam project_id string view=project-tree 时必填,工程 uid;同时匹配该工程本身及 path 中包含它的子工程
+     * @queryParam keyword string 标题模糊搜索关键字
+     * @queryParam status string 工程状态,逗号分隔可多选
+     * @queryParam order string 排序字段。Default: id
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: asc
+     * @queryParam offset integer 分页偏移量。Default: 0
+     * @queryParam limit integer 每页条数。Default: 10000
      */
     public function index(Request $request)
     {
@@ -56,6 +68,8 @@ class ProjectController extends Controller
                     ->where('type', $request->input('type', 'instance'));
                 break;
             default:
+                // FIXME: view 非法时用 200 状态码表达错误,与项目其他接口的 4xx 约定不一致,
+                // 建议改为 422 或 400。
                 return $this->error('view', 200, 200);
                 break;
         }
@@ -64,6 +78,8 @@ class ProjectController extends Controller
             $table = $table->where('title', 'like', '%'.$request->input('keyword').'%');
         }
         if ($request->has('status')) {
+            // FIXME: projects.status 是 jsonb 列,这里却按字符串 whereIn 比较,PostgreSQL 下匹配不到或报类型错误,
+            // 建议改用 whereJsonContains 或把 status 规整为字符串列。
             $table = $table->whereIn('status', explode(',', $request->input('status')));
         }
         $count = $table->count();
@@ -83,19 +99,36 @@ class ProjectController extends Controller
         );
     }
 
+    /**
+     * 判断用户是否有权编辑该 studio 下的工程
+     *
+     * 目前仅允许 studio 本人,即用户 uid 与 studio uid 相同。
+     *
+     * @param  string  $user_uid  用户 uuid
+     * @param  string  $studio_uid  studio uuid
+     */
     public static function canEdit($user_uid, $studio_uid)
     {
         return $user_uid == $studio_uid;
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建工程
+     *
+     * 在指定 studio 下创建工程。必须登录(否则 401),且当前用户必须是该 studio 本人
+     * (studio_name 解析出的 id 等于当前用户 uid),否则返回 403。
+     * 传入 parent_id 时会读取父工程的 path 并追加父工程 id,写入本工程的 path,
+     * 以便按树查询子工程。
      *
-     * @return Response
+     * @bodyParam studio_name string required studio 名称,用于解析归属 studio 并做权限校验
+     * @bodyParam id string 工程 uid;为合法 uuid 时用它(已存在则更新该工程),否则服务端生成新 uuid
+     * @bodyParam title string required 工程标题
+     * @bodyParam description string 工程描述
+     * @bodyParam parent_id string 父工程 uid,填写后作为子工程并自动计算 path
+     * @bodyParam type string 工程类型。Enum: instance,article Default: instance
      */
     public function store(Request $request)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -132,24 +165,37 @@ class ProjectController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单个工程
      *
-     * @return Response
+     * 通过路由模型绑定按工程 uid 取出工程并返回详情,工程不存在时返回 404。
+     * 该接口不做登录与权限校验,私有工程同样可被读取。
+     *
+     * @urlParam project string required 工程 uid
      */
     public function show(Project $project)
     {
-        //
+        // FIXME: 该接口完全不鉴权,privacy=private 的工程也能被任意匿名用户按 uid 读取,
+        // 建议补上登录校验并对私有工程限制为拥有者或被分享者可见。
         return $this->ok(new ProjectResource($project));
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改工程
+     *
+     * 整体覆盖式更新:title、description、parent_id、privacy 会无条件按请求体写入,
+     * 未传的字段会被置空,调用方需回传完整数据。必须登录(否则 401),
+     * 且只有工程拥有者本人可以修改,否则返回 403。
+     * 传入合法的 parent_id 时会按父工程重新计算 path。
      *
-     * @return Response
+     * @urlParam project string required 工程 uid
+     *
+     * @bodyParam title string required 工程标题
+     * @bodyParam description string 工程描述
+     * @bodyParam parent_id string 父工程 uid;为合法 uuid 时重算 path
+     * @bodyParam privacy string 隐私性:public 表示社区可见。Enum: private,public Default: private
      */
     public function update(Request $request, Project $project)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -158,6 +204,8 @@ class ProjectController extends Controller
             return $this->error(__('auth.failed'), 403, 403);
         }
 
+        // FIXME: 以下字段无条件整体覆盖,前端只想改单个字段时会把其余字段清空,且 privacy 未做白名单校验,
+        // 建议改为 $request->has() 逐字段更新并校验 privacy 只能为 private|public。
         $project->title = $request->input('title');
         $project->description = $request->input('description');
         $project->parent_id = $request->input('parent_id');
@@ -179,9 +227,11 @@ class ProjectController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除工程
+     *
+     * 尚未实现:方法体为空,不会删除任何数据,也不返回任何内容(响应为 200 空 body)。
      *
-     * @return Response
+     * @urlParam project string required 工程 uid
      */
     public function destroy(Project $project)
     {

+ 84 - 21
api-v13/app/Http/Controllers/TaskController.php

@@ -10,19 +10,37 @@ use App\Models\Task;
 use App\Models\TaskAssignee;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Str;
 
 class TaskController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出任务
      *
-     * @return Response
+     * 按 view 指定的口径返回任务列表,支持按执行人、责任人(指派)、参与者、
+     * 关键字、状态过滤,并支持排序与分页。必须登录,未登录返回 401。
+     * 注意:view 取值不在枚举内时不会构造查询,会导致请求失败。
+     *
+     * @queryParam view string required 查询口径:all=全部有归属的任务,instance=实例任务(type=instance),studio=当前用户名下(owner_id=当前用户)的任务,project=某工程及其子工程下的任务。Enum: all,instance,studio,project
+     * @queryParam project_id string view=project 时必填,工程 uid;同时匹配该工程本身及 path 中包含它的子工程
+     * @queryParam executor_id_includes string 实际执行人 uid,逗号分隔,只返回执行人在其中的任务
+     * @queryParam executor_id_not-includes string 实际执行人 uid,逗号分隔,排除执行人在其中的任务
+     * @queryParam assignees_id_includes string 责任人(指派人)uid,逗号分隔,只返回指派给其中任一人的任务
+     * @queryParam assignees_id_not-includes string 责任人(指派人)uid,逗号分隔,排除指派给其中任一人的任务
+     * @queryParam assignees_id_null string 传该参数(任意值)表示只返回没有任何责任人的任务
+     * @queryParam assignees_id_not-null string 传该参数(任意值)表示只返回至少有一个责任人的任务
+     * @queryParam sign_up_equals string 值为 true 时只返回可领取的任务,即责任人和实际执行人都为空。Enum: true,false
+     * @queryParam participants_id_includes string 参与者 uid,逗号分隔,实际执行人或责任人命中其一即返回
+     * @queryParam participants_id_not-includes string 参与者 uid,逗号分隔,排除这些人参与的任务
+     * @queryParam keyword string 标题模糊搜索关键字
+     * @queryParam status string 任务状态,逗号分隔可多选;传 all 表示不过滤。数据库默认值为 pending
+     * @queryParam order string 排序字段。Default: created_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: asc
+     * @queryParam offset integer 分页偏移量。Default: 0
+     * @queryParam limit integer 每页条数。Default: 1000
      */
     public function index(Request $request)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -46,6 +64,8 @@ class TaskController extends Controller
                 $table = Task::whereIn('project_id', $projects);
                 break;
             default:
+                // FIXME: view 缺失或不在枚举内时 $table 未被赋值,后续 $table->count() 会致命错误,
+                // 建议参照 ProjectController::index 在此直接返回参数错误响应。
                 // code...
                 break;
         }
@@ -81,6 +101,8 @@ class TaskController extends Controller
             $table = $table->has('task_assignees');
         }
 
+        // FIXME: 责任人现已存放在 task_assignees 表,tasks.assignees_id 列不再写入,
+        // 这里的 whereNull('assignees_id') 形同虚设,建议改用 doesntHave('task_assignees')。
         if ($request->input('sign_up_equals') === 'true') {
             $table = $table->whereNull('assignees_id')
                 ->whereNull('executor_id');
@@ -131,13 +153,22 @@ class TaskController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建任务
+     *
+     * 在指定 studio 下创建任务。必须登录(否则 401),且当前用户必须是该 studio 本人
+     * (studio_name 解析出的 id 等于当前用户 uid),否则返回 403。
+     * 新任务的 order 自动取同一 parent(无 parent 时取同一 project)下最大 order + 1,
+     * 没有兄弟节点时为 1;owner_id 为该 studio,creator_id 与 editor_id 为当前用户。
      *
-     * @return Response
+     * @bodyParam studio_name string required studio 名称,用于解析归属 studio 并做权限校验
+     * @bodyParam id string 任务 uuid;为合法 uuid 时用它,否则服务端生成新 uuid
+     * @bodyParam title string required 任务标题
+     * @bodyParam project_id string 所属工程 uid
+     * @bodyParam parent_id string 父任务 uuid,填写后为子任务
+     * @bodyParam type string 任务类型。Default: project
      */
     public function store(Request $request)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -147,6 +178,8 @@ class TaskController extends Controller
         if (! self::canEdit($user['user_uid'], $studioId)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
+        // FIXME: 传入已存在的 id 时会命中旧任务并覆盖其 title/type/parent_id 并重算 order,
+        // 而权限只校验了 studio_name 而非该任务自身归属,建议按 id 存在与否区分新建/更新并校验任务 owner。
         $new = Task::firstOrNew(
             [
                 'id' => $request->input('id'),
@@ -186,24 +219,47 @@ class TaskController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单个任务
      *
-     * @return Response
+     * 通过路由模型绑定按任务 uuid 取出任务并返回详情,任务不存在时返回 404。
+     * 该接口不做登录与权限校验,任何人都能读取。
+     *
+     * @urlParam task string required 任务 uuid
      */
     public function show(Task $task)
     {
-        //
+        // FIXME: 该接口完全不鉴权,任何匿名用户按 uuid 即可读取任意任务详情,
+        // 建议补上 AuthService::current 校验并按任务归属/可见性过滤。
         return $this->ok(new TaskResource($task));
     }
 
     /**
-     * Update the specified resource in storage.
+     * 修改任务
+     *
+     * 局部更新:只更新请求体里出现的字段,未出现的字段保持不变。必须登录(否则 401);
+     * 只有任务拥有者、实际执行人或责任人之一可以修改,否则返回 403。
+     * 副作用:传 assignees_id 会先删除该任务全部责任人再整体重建;
+     * 传 pre_task_id / next_task_id 会整体重写任务的前置 / 后续关联关系。
+     * 每次保存都会把 editor_id 更新为当前用户。
      *
-     * @return Response
+     * @urlParam task string required 任务 uuid
+     *
+     * @bodyParam title string 任务标题
+     * @bodyParam description string 任务描述(markdown)
+     * @bodyParam category string 类别,如 翻译、审稿、百科
+     * @bodyParam progress integer 进度,0-100
+     * @bodyParam assignees_id array 责任人 uid 数组,整体覆盖原有责任人;传空数组等于清空
+     * @bodyParam roles array 领取该任务所需的角色要求,存为 json
+     * @bodyParam executor_id string 实际执行人 uid
+     * @bodyParam executor_relation_task_id string 执行人关联任务 uuid,表示执行人与该任务保持一致
+     * @bodyParam project_id string 所属工程 uid
+     * @bodyParam pre_task_id string 前置任务 uuid,逗号分隔,整体覆盖原有前置关系
+     * @bodyParam next_task_id string 后续任务 uuid,逗号分隔,整体覆盖原有后续关系
+     * @bodyParam is_milestone boolean 是否为里程碑
+     * @bodyParam order integer 拖拽排序顺序
      */
     public function update(Request $request, Task $task)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
@@ -278,21 +334,27 @@ class TaskController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除任务
+     *
+     * 必须登录(否则 401),且当前用户必须是任务拥有者,否则返回 403。
+     * 删除成功返回 ok,失败返回 500。
      *
-     * @return Response
+     * @urlParam task string required 任务 uuid
      */
     public function destroy(Request $request, Task $task)
     {
-        //
         $user = AuthService::current($request);
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
+        // FIXME: tasks 表只有 owner_id 列,Task 模型也没有 owner 关系,$task->owner 恒为 null,
+        // 导致删除接口对所有人恒返回 403;应改为 $task->owner_id,但需与下方 trashed() 一并修复。
         if (! self::canEdit($user['user_uid'], $task->owner)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
         $task->delete();
+        // FIXME: Task 模型未 use SoftDeletes(tasks 表也无 deleted_at 列),trashed() 会抛
+        // BadMethodCallException;上面 owner 那条修好后就会走到这里,两处必须一起改(加软删除或改判 delete() 返回值)。
         if ($task->trashed()) {
             return $this->ok('ok');
         } else {
@@ -306,13 +368,14 @@ class TaskController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 判断用户是否有权修改该任务
+     *
+     * 任务拥有者、实际执行人,或该任务的责任人之一,均可修改。
      *
-     * @param  string  $user_uid
-     * @param  Task  $task
-     * @return bool
+     * @param  string  $user_uid  用户 uuid
+     * @param  Task  $task  任务
      */
-    public static function canUpdate($user_uid, $task)
+    public static function canUpdate($user_uid, $task): bool
     {
         if ($user_uid === $task->owner_id) {
             return true;

+ 45 - 17
api-v13/app/Http/Controllers/UserController.php

@@ -5,20 +5,32 @@ namespace App\Http\Controllers;
 use App\Http\Resources\UserResource;
 use App\Models\UserInfo;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 
 class UserController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出用户
      *
-     * @return Response
+     * 按 view 指定的口径返回用户列表,支持按昵称搜索、按角色过滤、分页与排序。
+     * 注意:view 只识别 key 和 all 两个值,传其他值或不传会因为查询对象未初始化而报错。
+     *
+     * @queryParam view string required 查询口径。key=按关键字匹配用户名或昵称;all=全部用户。Enum: key,all
+     * @queryParam key string view=key 时的关键字,同时模糊匹配 username 与 nickname
+     * @queryParam search string 按昵称再做一次模糊过滤(nickname like %search%)
+     * @queryParam role string 按角色过滤,匹配 role 这个 JSON 数组中是否包含该值。Example: admin
+     * @queryParam order string 排序字段。Default: username
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 分页起始位置。Default: 0
+     * @queryParam limit integer 每页条数。Default: 20
      */
     public function index(Request $request)
     {
-        //
+        // FIXME: switch 没有 default 分支,view 不是 key/all(含不传)时 $table 从未初始化,下面直接致命错误 500;
+        // 建议补 default 返回 400 错误,或先给 $table 一个基础查询再按 view 叠加条件。
         switch ($request->input('view')) {
             case 'key':
+                // FIXME: where(...)->orWhere(...) 没有分组括号,后面追加的 search / role 过滤会因 AND/OR 优先级
+                // 对 orWhere 那半边失效;建议把这两个条件包进 where(function ($q) { ... }) 里。
                 $table = UserInfo::where('username', 'like', '%'.$request->input('key').'%')
                     ->orWhere('nickname', 'like', '%'.$request->input('key').'%');
 
@@ -47,9 +59,10 @@ class UserController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 新建用户(未实现)
      *
-     * @return Response
+     * 路由已注册 POST /api/v2/user,但方法体为空,不做任何事也不返回内容。
+     * 用户注册走认证相关接口,不走这里。
      */
     public function store(Request $request)
     {
@@ -57,28 +70,40 @@ class UserController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查看单个用户
      *
-     * @param  int  $id
-     * @return Response
+     * 按 userid 查询用户资料并返回(id、用户名、昵称、邮箱、角色、头像等)。
+     * 方法内没有任何鉴权,任何人都可以读取;查不到用户时会以空资源返回。
+     *
+     * @urlParam user integer required 用户的 userid(不是表自增主键 id)
      */
     public function show($id)
     {
-        //
+        // FIXME: 没有任何鉴权,任何人可读取任意用户资料(含邮箱);且 userid 不存在时会以空资源返回。
+        // 建议按产品侧确认的可见性策略补鉴权,并对查不到的情况返回 404。
         $user = UserInfo::where('userid', $id)->first();
 
         return $this->ok(new UserResource($user));
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新用户资料或角色
      *
-     * @param  string  $id
-     * @return Response
+     * 两种互斥的用法:请求体里带 roles 时只更新角色(整体覆盖,以 JSON 存入 role 字段);
+     * 不带 roles 时更新昵称、头像、邮箱三个字段(未传的会被置空)。
+     * 方法内没有任何鉴权,也没有校验,调用方需自行保证权限;userid 不存在时会报错。
+     *
+     * @urlParam user integer required 用户的 userid(不是表自增主键 id)
+     *
+     * @bodyParam roles array 角色列表,传了就只更新角色,整体覆盖原有角色
+     * @bodyParam nickName string 昵称,未传 roles 时生效
+     * @bodyParam avatar string 头像文件名,未传 roles 时生效
+     * @bodyParam email string 邮箱,未传 roles 时生效
      */
     public function update(Request $request, $id)
     {
-        //
+        // FIXME: 安全问题——本方法没有任何鉴权和参数校验,任意调用者都能改他人的 roles(提权)、邮箱、昵称、头像;
+        // 需先由产品侧确认权限策略(本人仅可改资料、roles 仅管理员可改)后再补鉴权与 validate,同时处理 userid 不存在报错。
         $user = UserInfo::where('userid', $id)->first();
         if ($request->has('roles')) {
             $user->role = json_encode($request->input('roles'));
@@ -93,13 +118,16 @@ class UserController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除用户(未实现)
+     *
+     * 路由已注册 DELETE /api/v2/user/{user},但方法体为空,不会删除任何数据也不返回内容。
      *
-     * @param  int  $id
-     * @return Response
+     * @urlParam user integer required 用户的 userid
      */
     public function destroy($id)
     {
+        // FIXME: 方法体为空但路由已暴露 DELETE,调用方会以为删除成功;且同样没有鉴权。
+        // 建议要么实现并加上鉴权,要么下掉该路由。
         //
     }
 }

+ 79 - 14
api-v13/app/Http/Controllers/UserDictController.php

@@ -9,16 +9,30 @@ use App\Models\DictInfo;
 use App\Models\UserDict;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Redis;
 use Illuminate\Support\Str;
 
 class UserDictController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出用户词典条目
      *
-     * @return Response
+     * 按 view 指定的口径返回 user_dict 记录,支持按单词、词根、词典过滤,以及分页与排序。
+     * 需要登录的口径:all、studio;studio 口径还要求 name 指定的 studio 属于当前用户,否则返回 auth.failed。
+     * community 口径只返回 status>5 且来源为 _USER_WBW_/_USER_DICT_ 的社区公开数据。
+     * 返回 rows 与 count(过滤后总数)。
+     *
+     * @queryParam view string required 查询口径。all=全部;studio=某 studio 创建者的用户词条;user=cookie 中用户的词条(排除系统汇总);word=按单词查全部;community=社区公开词条;compound=复合词机器人词典;dict=指定词典。Enum: all,studio,user,word,community,compound,dict
+     * @queryParam name string studio 口径为 studio 名;dict 口径为系统词典短名。Example: robot_compound
+     * @queryParam id string dict 口径下直接指定的词典 uid(与 name 二选一)
+     * @queryParam word string 要查询的单词,word/community/compound 口径必填;其他口径传入时也会追加过滤
+     * @queryParam parent string 按词根(parent)过滤
+     * @queryParam dict string 按词典短名(dict_info.shortname)过滤,解析为 uuid 后才生效
+     * @queryParam search string 按单词前缀模糊搜索
+     * @queryParam order string 排序字段。Default: updated_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam offset integer 跳过的记录数。Default: 0
+     * @queryParam limit integer 返回条数上限。Default: 200
      */
     public function index(Request $request)
     {
@@ -84,6 +98,7 @@ class UserDictController extends Controller
                 break;
             case 'compound':
                 $dict_id = DictApi::getSysDict('robot_compound');
+                // FIXME: $this->error(...) 没有 return,取不到词典时不会中断,会继续用 false 去查询。应改为 return $this->error(...)。
                 if ($dict_id === false) {
                     $this->error('no robot_compound');
                 }
@@ -97,15 +112,19 @@ class UserDictController extends Controller
                     $dict_id = $request->input('id');
                 }
 
+                // FIXME: $this->error(...) 没有 return,词典不存在时不会中断,会继续用 false 去查询。应改为 return $this->error(...)。
                 if ($dict_id === false) {
                     $this->error('no dict', [], 404);
                 }
                 $table = UserDict::select($indexCol)
                     ->where('dict_id', $dict_id);
+                // FIXME: 这里缺 break,会贯穿到 default(当前恰好无副作用,但属于隐患)。应补上 break;。
             default:
                 // code...
                 break;
         }
+        // FIXME: view 不在枚举内(含缺省)时 $table 从未被赋值,下面的 $table->count() 会 fatal error。
+        // 建议 default 分支直接 return $this->error('无法识别的参数view', 400, 400)。
         if ($request->has('search')) {
             $table->where('word', 'like', $request->input('search').'%');
         }
@@ -137,14 +156,23 @@ class UserDictController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 批量新增/更新用户词典条目
+     *
+     * 需要登录。data 是 JSON 字符串,解码后逐条处理:以 creator_id + word 以及请求中给出的
+     * type/grammar/parent/mean/factors 组合判重,不存在则插入(自动生成雪花 id、按 view 写入 source、
+     * 记录 create_time 与 creator_id),已存在则只更新 note 与 confidence。
+     * 新增且 status>5 时会同步刷新系统汇总词典 _SYS_USER_WBW_;每次新增都会刷新该单词的 Redis 缓存 dict/user。
+     * 返回 [新增条数, 汇总表更新结果]。
      *
-     * @return Response
+     * @bodyParam view string required 数据来源口径,决定写入的 source:dict=_USER_DICT_,wbw=_USER_WBW_。Enum: dict,wbw
+     * @bodyParam data string required 词条数组的 JSON 字符串,每项含 word(必填)及可选 type、grammar、parent、mean、factors、factormean、note、confidence、status、language
      */
     public function store(Request $request)
     {
         //
         $user = AuthService::current($request);
+        // FIXME: $this->error('not login') 没有 return,未登录时会继续往下执行并在 $user['user_id'] 处 fatal error。
+        // 应改为 return $this->error(__('auth.failed'), [], 401)。
         if (! $user) {
             $this->error('not login');
         }
@@ -216,10 +244,11 @@ class UserDictController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 查询单条用户词典记录
+     *
+     * 按主键查找,不做登录与权限校验;查不到返回「没有查询到数据」。
      *
-     * @param  int  $id
-     * @return Response
+     * @urlParam userdict string required 词条 id
      */
     public function show($id)
     {
@@ -233,14 +262,31 @@ class UserDictController extends Controller
     }
 
     /**
-     * Update the specified resource in storage.
+     * 更新单条用户词典记录
      *
-     * @param  int  $id
-     * @return Response
+     * 把请求体中的全部字段直接写入该记录(未做字段白名单与权限校验)。
+     * 更新成功后同步刷新系统汇总词典 _SYS_USER_WBW_ 与该单词的 Redis 缓存 dict/user。
+     * 没有记录被更新时返回「没有查询到数据」。
+     *
+     * @urlParam userdict string required 词条 id
+     *
+     * @bodyParam word string 单词
+     * @bodyParam type string 词性
+     * @bodyParam grammar string 语法属性
+     * @bodyParam parent string 词根
+     * @bodyParam mean string 词义
+     * @bodyParam factors string 复合词拆分
+     * @bodyParam factormean string 复合词各部分词义
+     * @bodyParam note string 备注
+     * @bodyParam confidence integer 置信度
+     * @bodyParam status integer 状态,大于 5 表示公开到社区
+     * @bodyParam language string 语言代码
      */
     public function update(Request $request, $id)
     {
         //
+        // FIXME: 安全问题。直接把 $request->all() 整体落库,既无字段白名单(可改 creator_id、source、dict_id 等)
+        // 也无登录与创建者校验,任何人都能改任意词条。应先鉴权并只允许更新白名单字段。
         $newData = $request->all();
         $result = UserDict::where('id', $id)
             ->update($newData);
@@ -255,10 +301,15 @@ class UserDictController extends Controller
     }
 
     /**
-     * Remove the specified resource from storage.
+     * 删除用户词典记录(支持批量)
+     *
+     * 需要登录,未登录返回 403。传了查询参数 id(JSON 数组)时走批量分支,逐条只删除 creator_id
+     * 等于当前用户的记录,返回 [实际删除条数, 汇总表更新结果];否则删除路径参数指定的单条记录,
+     * 非本人创建返回 auth.failed。两种分支都会同步刷新 _SYS_USER_WBW_ 汇总与 Redis 缓存 dict/user。
      *
-     * @param  int  $id
-     * @return Response
+     * @urlParam userdict string required 要删除的词条 id(未传查询参数 id 时使用)
+     *
+     * @queryParam id string 要批量删除的词条 id 数组的 JSON 字符串。Example: ["123","456"]
      */
     public function destroy(Request $request, $id)
     {
@@ -275,6 +326,7 @@ class UserDictController extends Controller
             $updateOk = false;
             foreach ($arrId as $key => $id) {
                 // 找到对应数据
+                // FIXME: find() 可能返回 null,下一行对 null 取属性会 fatal error。应先判空再处理。
                 $data = UserDict::find($id);
                 // 查看是否有权限删除
                 if ($data->creator_id == $user_id) {
@@ -291,6 +343,8 @@ class UserDictController extends Controller
             // 删除单个单词
             $userDict = UserDict::find($id);
             // 判断当前用户是否有指定的studio的权限
+            // FIXME: $userDict 可能为 null(fatal error);且用 (int) 与 creator_id 做 !== 严格比较,
+            // creator_id 若是字符串列则永远不相等,会误判为无权限。建议先判空并改用 == 或统一类型。
             if ((int) $user_id !== $userDict->creator_id) {
                 return $this->error(__('auth.failed'));
             }
@@ -300,8 +354,19 @@ class UserDictController extends Controller
         }
     }
 
+    /**
+     * 批量删除用户词典记录(旧接口)
+     *
+     * 不走 AuthService,凭据取自 cookie 中的 user_id,只删除该用户创建的记录。
+     * 每删一条都会同步刷新 _SYS_USER_WBW_ 汇总与该单词的 Redis 缓存 dict/user。
+     * 返回 deleted(实际删除条数)。
+     *
+     * @queryParam id string required 要删除的词条 id 数组的 JSON 字符串。Example: ["123","456"]
+     */
     public function delete(Request $request)
     {
+        // FIXME: 本方法不走 AuthService,凭据直接取自 $_COOKIE['user_id'](见下方 $param),
+        // 未登录时会 undefined array key。应改用 AuthService::current($request) 并在未登录时返回 401。
         $arrId = json_decode($request->input('id'), true);
         $count = 0;
         $updateOk = false;