Просмотр исходного кода

feat(api-v13): discussions 迁 v3;agent 写入授权;讨论角标批量计数

- /v3/discussions(列表、summary、单条、replies、新建、PATCH、软删除)取代 v2 discussion:
  可见性对列表 / 单条 / 回复一致;关闭后回复 409;回复类型跟随话题;每条带 abilities;
  删除放开为作者或挂靠对象的管理者;有回复的话题删除后留墓碑
- 批注(note / commentary)并入:只挂句子、只有该句管理者能建、带锚点 pos_* / quote_*、
  可回复、无开关;列表与 summary 不带 type 时只算评论线程
- POST /v3/agent-grants:人类为自己名下的 AI 模型签 channel 写入授权(服务端写入授权人、
  绑定模型与 token_version、独立密钥);写入带 X-Agent-Grant,editor = 授权人、
  co_editor = 模型,人亲自改内容时清空 co_editor
- POST /v3/discussions/batch-count:一批句子 / 逐词的讨论角标(未关闭的评论线程数与我发起的),
  取代 v2 discussion-count 的讨论部分;课程「学生的讨论」留 TODO
- migration:discussions 加 severity、deleted_at、co_editor_uid(均 nullable)
- Discussion 模型:SoftDeletes、keyType=string(uuid 主键 whereIn 被转成整数)、coEditor 关系
- PaliContentService:批注下的回复不注入阅读页;ExportDiscussion 的裸查询排除软删行
- UserService::eagerLoadAssistant()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 6 дней назад
Родитель
Сommit
9b37b7e28c
32 измененных файлов с 2936 добавлено и 2 удалено
  1. 4 0
      api-v13/app/Console/Commands/ExportDiscussion.php
  2. 48 0
      api-v13/app/Http/Controllers/V3/AgentGrantController.php
  3. 46 0
      api-v13/app/Http/Controllers/V3/DiscussionBatchCountController.php
  4. 183 0
      api-v13/app/Http/Controllers/V3/DiscussionController.php
  5. 76 0
      api-v13/app/Http/Controllers/V3/DiscussionReplyController.php
  6. 42 0
      api-v13/app/Http/Controllers/V3/DiscussionSummaryController.php
  7. 33 0
      api-v13/app/Http/Requests/V3/BatchCountDiscussionRequest.php
  8. 27 0
      api-v13/app/Http/Requests/V3/IndexDiscussionReplyRequest.php
  9. 41 0
      api-v13/app/Http/Requests/V3/IndexDiscussionRequest.php
  10. 32 0
      api-v13/app/Http/Requests/V3/StoreAgentGrantRequest.php
  11. 29 0
      api-v13/app/Http/Requests/V3/StoreDiscussionReplyRequest.php
  12. 81 0
      api-v13/app/Http/Requests/V3/StoreDiscussionRequest.php
  13. 30 0
      api-v13/app/Http/Requests/V3/SummaryDiscussionRequest.php
  14. 42 0
      api-v13/app/Http/Requests/V3/UpdateDiscussionRequest.php
  15. 31 0
      api-v13/app/Http/Resources/V3/AgentGrantResource.php
  16. 28 0
      api-v13/app/Http/Resources/V3/DiscussionCountResource.php
  17. 97 0
      api-v13/app/Http/Resources/V3/DiscussionResource.php
  18. 27 0
      api-v13/app/Http/Resources/V3/DiscussionSummaryResource.php
  19. 100 1
      api-v13/app/Models/Discussion.php
  20. 2 0
      api-v13/app/Services/PaliContentService.php
  21. 8 0
      api-v13/app/Services/UserService.php
  22. 154 0
      api-v13/app/Services/V3/AgentGrantService.php
  23. 809 0
      api-v13/app/Services/V3/DiscussionService.php
  24. 30 0
      api-v13/database/migrations/2026_10_13_100000_add_severity_in_discussions.php
  25. 29 0
      api-v13/database/migrations/2026_10_15_100000_add_deleted_at_in_discussions.php
  26. 30 0
      api-v13/database/migrations/2026_10_15_110000_add_co_editor_uid_in_discussions.php
  27. 20 0
      api-v13/resources/lang/en/messages.php
  28. 20 0
      api-v13/resources/lang/zh-Hans/messages.php
  29. 6 0
      api-v13/routes/v3/auth.php
  30. 30 1
      api-v13/routes/v3/interaction.php
  31. 189 0
      api-v13/tests/Feature/AgentGrantV3Test.php
  32. 612 0
      api-v13/tests/Feature/DiscussionV3Test.php

+ 4 - 0
api-v13/app/Console/Commands/ExportDiscussion.php

@@ -52,6 +52,8 @@ class ExportDiscussion extends Command
         $total = DB::table('discussions')
             ->where('editor_uid', $editorUid)
             ->where('status', 'active')
+            // discussions 已改为软删除;裸 DB::table() 不走模型的全局作用域,要自己排除
+            ->whereNull('deleted_at')
             ->count();
 
         if ($total === 0) {
@@ -85,6 +87,8 @@ class ExportDiscussion extends Command
         DB::table('discussions')
             ->where('editor_uid', $editorUid)
             ->where('status', 'active')
+            // discussions 已改为软删除;裸 DB::table() 不走模型的全局作用域,要自己排除
+            ->whereNull('deleted_at')
             ->orderBy('created_at', 'asc')
             ->select(['id', 'res_id', 'res_type', 'content', 'created_at'])
             ->chunk(50, function ($discussions) use ($progressBar) {

+ 48 - 0
api-v13/app/Http/Controllers/V3/AgentGrantController.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\StoreAgentGrantRequest;
+use App\Http\Resources\V3\AgentGrantResource;
+use App\Services\V3\AgentGrantService;
+
+/**
+ * agent 写入授权:人类授权自己名下的 AI 模型代写某个 channel。
+ *
+ * 取代 `POST /v2/access-token`(`AccessTokenController@store`)的 agent 用途;v2 那份在
+ * dashboard-v4 下线前原样保留。区别见 {@see AgentGrantService}。
+ */
+class AgentGrantController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly AgentGrantService $grants) {}
+
+    /**
+     * 签发 agent 写入授权
+     *
+     * 必须用人类的登录 token 调用(模型 token 403);模型必须归当前用户所有,当前用户
+     * 必须能编辑该 channel。授权 7 天有效,只能由指定的模型使用;撤销该模型的 token
+     * (token_version 自增)时一并失效。
+     *
+     * 模型写入时用自己的模型 token 登录,并带请求头 `X-Agent-Grant: <token>`;
+     * 写入的记录 editor 为授权人、co_editor 为模型,权限按授权人判断。
+     *
+     * @responseStatus 201 签发成功
+     * @responseStatus 403 用模型 token 调用、模型不归当前用户,或当前用户不能编辑该 channel
+     *
+     * @bodyParam ai_model string required 被授权的 AI 模型 uid
+     * @bodyParam target_type string required 授权范围的类型。Enum: channel
+     * @bodyParam target_id string required channel 的 uid
+     * @bodyParam book integer 限定 book,0 或不传表示不限。Default: 0
+     */
+    public function store(StoreAgentGrantRequest $request)
+    {
+        return AgentGrantResource::make($this->grants->issue(
+            $this->currentUser($request)['user_uid'],
+            $request->validated(),
+        ))->response()->setStatusCode(201);
+    }
+}

+ 46 - 0
api-v13/app/Http/Controllers/V3/DiscussionBatchCountController.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\BatchCountDiscussionRequest;
+use App\Http\Resources\V3\DiscussionCountResource;
+use App\Services\V3\DiscussionService;
+
+/**
+ * 一批对象的讨论角标。
+ *
+ * 取代 `POST /v2/discussion-count` 的讨论部分(`discussions`);那个端点同时返回标签,
+ * 标签部分还没迁,v6 暂时仍调它取 `tags`。v2 那份在 dashboard-v4 下线前原样保留。
+ */
+class DiscussionBatchCountController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly DiscussionService $discussions) {}
+
+    /**
+     * 批量取讨论角标
+     *
+     * 每个对象返回未关闭的评论线程数(total)与其中当前用户发起的数目(mine,未登录为 0),
+     * 按请求顺序、一个不少(没有讨论的为 0)。只数评论线程(discussion / qa / help / issue),
+     * 批注与已删除的不算;可见性与列表相同:未登录不数协作类话题,basic 角色在无权管理的
+     * 对象上只数自己发起的协作类话题。最多 500 个对象。
+     *
+     * 与 v2 的区别:入参是对象 uid 而不是句子坐标;只给计数不给明细;v2 不分类型、不管可见性。
+     *
+     * @unauthenticated
+     *
+     * @bodyParam targets array required 讨论对象列表,每项 {type, id},最多 500 个
+     * @bodyParam targets.*.type string required 对象类型。Enum: sentence,wbw
+     * @bodyParam targets.*.id string required 对象 uid
+     */
+    public function __invoke(BatchCountDiscussionRequest $request)
+    {
+        return DiscussionCountResource::collection($this->discussions->batchCount(
+            $request->validated('targets'),
+            $this->currentUserUid($request),
+        ));
+    }
+}

+ 183 - 0
api-v13/app/Http/Controllers/V3/DiscussionController.php

@@ -0,0 +1,183 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexDiscussionRequest;
+use App\Http\Requests\V3\StoreDiscussionRequest;
+use App\Http\Requests\V3\UpdateDiscussionRequest;
+use App\Http\Resources\V3\DiscussionResource;
+use App\Models\Discussion;
+use App\Services\V3\AgentGrantService;
+use App\Services\V3\DiscussionService;
+use Illuminate\Http\Request;
+
+/**
+ * 评论线程:挂在句子、逐词、文章、术语、任务上的话题与回复;以及挂在句子上的批注
+ * (note 边注、commentary 义注 / 复注对应——巴利注释书,不是普通评论)。
+ *
+ * 取代 `GET/POST /v2/discussion`(view=question / res_id / question-by-topic)、
+ * `GET/PUT/DELETE /v2/discussion/{discussion}`。v2 那份在 dashboard-v4 下线前原样保留。
+ * 回复见 {@see DiscussionReplyController}(取代 view=answer 与带 parent 的 POST)。
+ *
+ * 底层仍是 `discussions` 表。批注会注入阅读页,规则与评论不同,见各方法说明。
+ *
+ * 写操作(新建、修改、删除,以及回复)支持 AI 代写:模型用模型 token 登录,并带请求头
+ * `X-Agent-Grant`(人类经 `POST /v3/agent-grants` 签发)。此时 editor 记授权人、
+ * co_editor 记模型,权限按授权人判断,且只能写授权覆盖的 channel / book 里的句子上的内容。
+ * 模型不带授权写入一律 403。
+ */
+class DiscussionController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(
+        private readonly DiscussionService $discussions,
+        private readonly AgentGrantService $grants,
+    ) {}
+
+    /**
+     * 列出某个对象上的话题
+     *
+     * 只列话题(不含回复),删除了但还有回复的话题以墓碑出现(deleted_at 有值、内容为空)。
+     * 未登录看不到协作类话题(discussion / issue);basic 角色在自己无权管理的对象上只看得到
+     * 自己发起的协作类话题。各状态的数量与「能发起哪些类型」见 `GET /v3/discussions/summary`。
+     * 不传 type 时只列评论线程(discussion / qa / help / issue),批注要显式 type=note / commentary。
+     *
+     * @unauthenticated
+     *
+     * @queryParam target_type string required 挂靠对象的类型。Enum: sentence,wbw,article,term,task
+     * @queryParam target_id string required 挂靠对象的 uuid
+     * @queryParam type string 类型,不传则只列评论线程。Enum: discussion,qa,help,issue,note,commentary
+     * @queryParam status string 话题状态,不传则两种都要。Enum: active,close
+     * @queryParam severity string 严重程度,逗号分隔多选。Example: error,warning
+     * @queryParam sort string 排序,前缀 - 表示倒序。Enum: created_at,-created_at,updated_at,-updated_at Default: -created_at
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function index(IndexDiscussionRequest $request)
+    {
+        return DiscussionResource::collection($this->discussions->listTopics(
+            $request->validated(),
+            $this->currentUserUid($request),
+            $request->integer('per_page', 15),
+        ));
+    }
+
+    /**
+     * 发起话题或新建批注
+     *
+     * discussion / issue 登录即可发;qa / help 只能发在文章上,且只有文章的编辑者能发。
+     * severity 的取值随 type:issue 为 error / warning / info,help 为 urgent / normal,
+     * 其它类型不收。默认会通知对象的作者(站内信 + webhook),`notify=false` 关闭。
+     *
+     * 批注(note / commentary)只能加在句子上,且只有该句所在 channel 的编辑者能加;
+     * 标题可省、正文必填,commentary 的正文必须是义注句子模板 `{{book-para-start-end}}`
+     * (可多句并列);可带锚点 pos_* / quote_*(评论线程传了 422);不发通知。
+     *
+     * @responseStatus 201 创建成功
+     * @responseStatus 403 当前用户不能在这个对象上发起这种类型的话题
+     *
+     * @bodyParam target_type string required 挂靠对象的类型。Enum: sentence,wbw,article,term,task
+     * @bodyParam target_id string required 挂靠对象的 uuid
+     * @bodyParam type string 类型。Enum: discussion,qa,help,issue,note,commentary Default: discussion
+     * @bodyParam title string 标题,最长 256 字符;评论线程必填,批注可省
+     * @bodyParam content string 正文(markdown);批注必填
+     * @bodyParam severity string 严重程度。Enum: error,warning,info,urgent,normal
+     * @bodyParam pos_start integer 批注锚点在句子文本中的起始偏移(只限批注)
+     * @bodyParam pos_end integer 批注锚点的结束偏移,批注插在这里(只限批注)
+     * @bodyParam quote_exact string 锚定的原文片段(只限批注)
+     * @bodyParam quote_prefix string 锚定片段前的上下文,用于重新定位(只限批注)
+     * @bodyParam quote_suffix string 锚定片段后的上下文,用于重新定位(只限批注)
+     * @bodyParam tpl_id string 话题模板(文章)的 uuid
+     * @bodyParam notify boolean 是否发通知(批注不发)。Default: true
+     */
+    public function store(StoreDiscussionRequest $request)
+    {
+        return DiscussionResource::make($this->discussions->createTopic(
+            $this->grants->actor(
+                $this->currentUser($request)['user_uid'],
+                $request->header(AgentGrantService::HEADER),
+            ),
+            $request->validated(),
+        ));
+    }
+
+    /**
+     * 获取单条话题或回复
+     *
+     * 删除了但还有回复的话题返回墓碑;删除了的回复、删除了且没有回复的话题返回 404。
+     * 当前用户看不见的话题同样 404。
+     *
+     * @unauthenticated
+     *
+     * @urlParam discussion string required 话题或回复的 uuid
+     */
+    public function show(Request $request, Discussion $discussion)
+    {
+        return DiscussionResource::make(
+            $this->discussions->show($discussion, $this->currentUserUid($request))
+        );
+    }
+
+    /**
+     * 修改话题或回复(局部更新)
+     *
+     * 只改传了的字段。title / status / type / severity 只对话题有效,回复传了 422。
+     * 作者或对象的管理者(句子、逐词所在 channel 的编辑者,文章的编辑者)可改;
+     * 改 type 时回复跟着改,新类型不收原来的 severity 时 severity 清空。
+     * 批注:没有 status,type 只能在 note / commentary 之间改(不能与评论线程互转),
+     * 正文不能清空,可改锚点。
+     * 改了内容(标题、正文、锚点)时 co_editor 跟着这次写入走:人亲自改则清空,AI 代改则换成该模型;
+     * 只改 status / type / severity 不动 co_editor。
+     *
+     * @responseStatus 403 没有修改权限
+     *
+     * @urlParam discussion string required 话题或回复的 uuid
+     *
+     * @bodyParam title string 标题(只限话题)
+     * @bodyParam content string 正文(markdown)
+     * @bodyParam status string 状态(只限话题)。Enum: active,close
+     * @bodyParam type string 类型(只限话题;评论线程与批注不能互转)。Enum: discussion,qa,help,issue,note,commentary
+     * @bodyParam severity string 严重程度(只限话题),null 表示清空。Enum: error,warning,info,urgent,normal
+     * @bodyParam pos_start integer 锚点起始偏移(只限批注)
+     * @bodyParam pos_end integer 锚点结束偏移(只限批注)
+     * @bodyParam quote_exact string 锚定的原文片段(只限批注)
+     * @bodyParam quote_prefix string 锚定片段前的上下文(只限批注)
+     * @bodyParam quote_suffix string 锚定片段后的上下文(只限批注)
+     */
+    public function update(UpdateDiscussionRequest $request, Discussion $discussion)
+    {
+        return DiscussionResource::make($this->discussions->update(
+            $discussion,
+            $this->grants->actor(
+                $this->currentUser($request)['user_uid'],
+                $request->header(AgentGrantService::HEADER),
+            ),
+            $request->validated(),
+        ));
+    }
+
+    /**
+     * 删除话题或回复(软删除)
+     *
+     * 作者本人或挂靠对象的管理者(句子 / 逐词所在 channel、文章的编辑者)能删;
+     * v2 只允许作者。删话题不动回复:有回复的话题以墓碑形式留在列表里。
+     * 返回删除后的资源(200),不是 204——软删的资源并没有消失。
+     *
+     * @responseStatus 403 既不是作者,也不是挂靠对象的管理者
+     *
+     * @urlParam discussion string required 话题或回复的 uuid
+     */
+    public function destroy(Request $request, Discussion $discussion)
+    {
+        return DiscussionResource::make($this->discussions->delete(
+            $discussion,
+            $this->grants->actor(
+                $this->currentUser($request)['user_uid'],
+                $request->header(AgentGrantService::HEADER),
+            ),
+        ));
+    }
+}

+ 76 - 0
api-v13/app/Http/Controllers/V3/DiscussionReplyController.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexDiscussionReplyRequest;
+use App\Http\Requests\V3\StoreDiscussionReplyRequest;
+use App\Http\Resources\V3\DiscussionResource;
+use App\Models\Discussion;
+use App\Services\V3\AgentGrantService;
+use App\Services\V3\DiscussionService;
+
+/**
+ * 话题下的回复。线程只有两层:回复不能再被回复。
+ *
+ * 取代 `GET /v2/discussion?view=answer` 与带 `parent` 的 `POST /v2/discussion`。
+ */
+class DiscussionReplyController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(
+        private readonly DiscussionService $discussions,
+        private readonly AgentGrantService $grants,
+    ) {}
+
+    /**
+     * 列出话题的回复
+     *
+     * 按时间正序。删除了的话题(墓碑)下的回复照样可读。
+     *
+     * @unauthenticated
+     *
+     * @urlParam discussion string required 话题的 uuid
+     *
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 50
+     */
+    public function index(IndexDiscussionReplyRequest $request, Discussion $discussion)
+    {
+        return DiscussionResource::collection($this->discussions->listReplies(
+            $discussion,
+            $this->currentUserUid($request),
+            $request->integer('per_page', 50),
+        ));
+    }
+
+    /**
+     * 回复话题
+     *
+     * 类型与挂靠对象继承话题。已关闭的话题不能回复(409),对一条回复再回复 422。
+     * AI 代写同 `POST /v3/discussions`(`X-Agent-Grant` 请求头)。
+     * 文章上的 qa 只有文章的编辑者能回答。默认发通知,`notify=false` 关闭。
+     *
+     * @responseStatus 201 回复成功
+     * @responseStatus 403 当前用户不能回复这个话题
+     * @responseStatus 409 话题已关闭
+     *
+     * @urlParam discussion string required 话题的 uuid
+     *
+     * @bodyParam content string required 正文(markdown)
+     * @bodyParam notify boolean 是否发通知。Default: true
+     */
+    public function store(StoreDiscussionReplyRequest $request, Discussion $discussion)
+    {
+        return DiscussionResource::make($this->discussions->createReply(
+            $discussion,
+            $this->grants->actor(
+                $this->currentUser($request)['user_uid'],
+                $request->header(AgentGrantService::HEADER),
+            ),
+            $request->validated(),
+        ));
+    }
+}

+ 42 - 0
api-v13/app/Http/Controllers/V3/DiscussionSummaryController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\SummaryDiscussionRequest;
+use App\Http\Resources\V3\DiscussionSummaryResource;
+use App\Services\V3\DiscussionService;
+
+/**
+ * 某个对象上的话题概况。
+ *
+ * 取代 v2 `GET /v2/discussion` 响应里夹带的 active / close / can_create(v2 的 can_reply
+ * 两个前端都没读过,不迁;能否回复改为每条话题自带的 abilities.reply)。
+ */
+class DiscussionSummaryController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly DiscussionService $discussions) {}
+
+    /**
+     * 话题概况
+     *
+     * 各状态的话题数(只数没删除的、当前用户看得见的),以及当前用户能在这个对象上
+     * 发起哪些类型的话题(未登录为空数组)。
+     *
+     * @unauthenticated
+     *
+     * @queryParam target_type string required 挂靠对象的类型。Enum: sentence,wbw,article,term,task
+     * @queryParam target_id string required 挂靠对象的 uuid
+     * @queryParam type string 只数这种类型,不传则只数评论线程。Enum: discussion,qa,help,issue,note,commentary
+     */
+    public function __invoke(SummaryDiscussionRequest $request)
+    {
+        return DiscussionSummaryResource::make($this->discussions->summary(
+            $request->validated(),
+            $this->currentUserUid($request),
+        ));
+    }
+}

+ 33 - 0
api-v13/app/Http/Requests/V3/BatchCountDiscussionRequest.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `POST /v3/discussions/batch-count` 的入参:一批讨论对象(句子 / 逐词)。
+ *
+ * 用 POST 是因为一页可能有几百个对象,uuid 放查询串会超长。上限写死,免得一次请求拖垮库。
+ */
+class BatchCountDiscussionRequest extends FormRequest
+{
+    public const MAX_TARGETS = 500;
+
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'targets' => ['required', 'array', 'min:1', 'max:'.self::MAX_TARGETS],
+            'targets.*.type' => ['required', 'string', Rule::in(['sentence', 'wbw'])],
+            'targets.*.id' => ['required', 'uuid'],
+        ];
+    }
+}

+ 27 - 0
api-v13/app/Http/Requests/V3/IndexDiscussionReplyRequest.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/discussions/{discussion}/replies` 的入参:只有分页。
+ */
+class IndexDiscussionReplyRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'page' => ['integer', 'min:1'],
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 41 - 0
api-v13/app/Http/Requests/V3/IndexDiscussionRequest.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Discussion;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/discussions` 的入参:某个对象上的话题列表。
+ *
+ * target 必填:discussions 没有全表可读的用例,不带条件就能拉全表的列表端点是设计错误。
+ */
+class IndexDiscussionRequest extends FormRequest
+{
+    /**
+     * 可选登录,权限在 Service 里按查看者过滤。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'target_type' => ['required', 'string', Rule::in(Discussion::TARGET_TYPES)],
+            'target_id' => ['required', 'uuid'],
+            'type' => ['sometimes', 'string', Rule::in(Discussion::COMMENT_TYPES)],
+            'status' => ['sometimes', 'string', Rule::in(Discussion::STATUSES)],
+            // 逗号分隔多选,如 error,warning
+            'severity' => ['sometimes', 'string', 'regex:/^[a-z]+(,[a-z]+)*$/'],
+            'sort' => ['sometimes', 'string', Rule::in(['created_at', '-created_at', 'updated_at', '-updated_at'])],
+            'page' => ['integer', 'min:1'],
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 32 - 0
api-v13/app/Http/Requests/V3/StoreAgentGrantRequest.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `POST /v3/agent-grants` 的入参:给哪个模型、在哪个 channel(可限 book)上授权。
+ *
+ * 目前只有 channel 一种范围:句子、逐词、句子上的批注都落在 channel 里。
+ */
+class StoreAgentGrantRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'ai_model' => ['required', 'uuid'],
+            'target_type' => ['required', 'string', Rule::in(['channel'])],
+            'target_id' => ['required', 'uuid'],
+            'book' => ['sometimes', 'integer', 'min:0'],
+        ];
+    }
+}

+ 29 - 0
api-v13/app/Http/Requests/V3/StoreDiscussionReplyRequest.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `POST /v3/discussions/{discussion}/replies` 的入参:回复只有正文。
+ *
+ * 类型、挂靠对象一律继承话题,不收客户端传的;也没有标题。
+ */
+class StoreDiscussionReplyRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'content' => ['required', 'string', 'max:20000'],
+            'notify' => ['sometimes', 'boolean'],
+        ];
+    }
+}

+ 81 - 0
api-v13/app/Http/Requests/V3/StoreDiscussionRequest.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Discussion;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `POST /v3/discussions` 的入参:发起话题,或新建一条批注。
+ *
+ * 不收 content_type:v3 只写 markdown(v6 已没有 html 编辑器)。
+ * severity 的取值随 type 而定,见 {@see Discussion::SEVERITIES};不收 severity 的类型传了就 422。
+ *
+ * 批注(note / commentary)与评论线程的规则不同:只能挂在句子上、标题可省、正文必填,
+ * commentary 的正文必须是义注句子模板;锚点(pos_* / quote_*)只有批注收。
+ */
+class StoreDiscussionRequest extends FormRequest
+{
+    private const ANCHOR_FIELDS = ['pos_start', 'pos_end', 'quote_exact', 'quote_prefix', 'quote_suffix'];
+
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        $type = $this->input('type', 'discussion');
+        $severities = Discussion::SEVERITIES[$type] ?? [];
+        $isAnnotation = in_array($type, Discussion::INJECTED_TYPES, true);
+        // 锚点只有批注收:评论线程传了 422,免得静默丢掉
+        $anchor = fn (array $rules) => $isAnnotation ? ['sometimes', 'nullable', ...$rules] : ['prohibited'];
+
+        return [
+            'target_type' => ['required', 'string', Rule::in($isAnnotation ? ['sentence'] : Discussion::TARGET_TYPES)],
+            'target_id' => ['required', 'uuid'],
+            'type' => ['sometimes', 'string', Rule::in(Discussion::COMMENT_TYPES)],
+            'title' => $isAnnotation
+                ? ['sometimes', 'nullable', 'string', 'max:256']
+                : ['required', 'string', 'max:256'],
+            'content' => match ($type) {
+                Discussion::TYPE_COMMENTARY => ['required', 'string', 'max:20000', 'regex:'.Discussion::COMMENTARY_PATTERN],
+                Discussion::TYPE_NOTE => ['required', 'string', 'max:20000'],
+                default => ['sometimes', 'nullable', 'string', 'max:20000'],
+            },
+            'severity' => $severities === []
+                ? ['prohibited']
+                : ['sometimes', 'nullable', 'string', Rule::in($severities)],
+            'pos_start' => $anchor(['integer', 'min:0']),
+            'pos_end' => $anchor(['integer', 'min:0', 'gte:pos_start']),
+            'quote_exact' => $anchor(['string', 'max:1024']),
+            'quote_prefix' => $anchor(['string', 'max:1024']),
+            'quote_suffix' => $anchor(['string', 'max:1024']),
+            'tpl_id' => ['sometimes', 'nullable', 'uuid'],
+            'notify' => ['sometimes', 'boolean'],
+        ];
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    public function messages(): array
+    {
+        $messages = [
+            'content.regex' => __('messages.discussion_commentary_format'),
+            'severity.prohibited' => __('messages.discussion_severity_not_allowed'),
+        ];
+        if (in_array($this->input('type'), Discussion::INJECTED_TYPES, true)) {
+            $messages['target_type.in'] = __('messages.discussion_annotation_target');
+        }
+        foreach (self::ANCHOR_FIELDS as $field) {
+            $messages["{$field}.prohibited"] = __('messages.discussion_anchor_thread');
+        }
+
+        return $messages;
+    }
+}

+ 30 - 0
api-v13/app/Http/Requests/V3/SummaryDiscussionRequest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Discussion;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/discussions/summary` 的入参:某个对象上的话题概况。
+ */
+class SummaryDiscussionRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'target_type' => ['required', 'string', Rule::in(Discussion::TARGET_TYPES)],
+            'target_id' => ['required', 'uuid'],
+            'type' => ['sometimes', 'string', Rule::in(Discussion::COMMENT_TYPES)],
+        ];
+    }
+}

+ 42 - 0
api-v13/app/Http/Requests/V3/UpdateDiscussionRequest.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Discussion;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `PATCH /v3/discussions/{discussion}` 的入参:局部更新,只改传了的字段。
+ *
+ * title / status / type / severity 只对话题有效,回复传了 422——这一条与
+ * severity 是否匹配最终 type,都要看库里那条记录,在 Service 里判。
+ * 批注相关的规则(锚点只有批注收、commentary 正文格式、不能与评论线程互转、没有 status)
+ * 同样取决于那条记录的类型,也在 Service 里判。
+ */
+class UpdateDiscussionRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'title' => ['sometimes', 'string', 'max:256'],
+            'content' => ['sometimes', 'nullable', 'string', 'max:20000'],
+            'status' => ['sometimes', 'string', Rule::in(Discussion::STATUSES)],
+            'type' => ['sometimes', 'string', Rule::in(Discussion::COMMENT_TYPES)],
+            'severity' => ['sometimes', 'nullable', 'string'],
+            'pos_start' => ['sometimes', 'nullable', 'integer', 'min:0'],
+            'pos_end' => ['sometimes', 'nullable', 'integer', 'min:0'],
+            'quote_exact' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'quote_prefix' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'quote_suffix' => ['sometimes', 'nullable', 'string', 'max:1024'],
+        ];
+    }
+}

+ 31 - 0
api-v13/app/Http/Resources/V3/AgentGrantResource.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\V3\AgentGrantService;
+use Illuminate\Http\Request;
+
+/**
+ * 一张 agent 写入授权:模型写入时把 token 放进 `X-Agent-Grant` 请求头。
+ *
+ * 载荷由 {@see AgentGrantService::issue()} 组好,本类只做形状声明。
+ */
+class AgentGrantResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     token: string,
+     *     grantor: string,
+     *     ai_model: string,
+     *     target_type: string,
+     *     target_id: string,
+     *     book: int,
+     *     expires_at: string
+     * }
+     */
+    public function toArray($request): array
+    {
+        return $this->resource;
+    }
+}

+ 28 - 0
api-v13/app/Http/Resources/V3/DiscussionCountResource.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\V3\DiscussionService;
+use Illuminate\Http\Request;
+
+/**
+ * 一个对象上的讨论角标:未关闭的评论线程数,以及其中当前用户发起的数目。
+ *
+ * 载荷由 {@see DiscussionService::batchCount()} 组好,本类只做形状声明。
+ */
+class DiscussionCountResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     target_type: string,
+     *     target_id: string,
+     *     total: int,
+     *     mine: int
+     * }
+     */
+    public function toArray($request): array
+    {
+        return $this->resource;
+    }
+}

+ 97 - 0
api-v13/app/Http/Resources/V3/DiscussionResource.php

@@ -0,0 +1,97 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Http\Resources\Concerns\ResolvesActor;
+use App\Models\Discussion;
+use App\Services\V3\DiscussionService;
+use Illuminate\Http\Request;
+
+/**
+ * 一条评论:话题(parent_id 为 null)或回复;或一条批注(note / commentary)。
+ *
+ * 批注带锚点(pos_start / pos_end / quote_*),指明它挂在句子文本的哪一段;
+ * 评论线程的这几个字段恒为 null。
+ *
+ * 删除了但还挂着回复的话题以「墓碑」出现:deleted_at 有值,title / content 为 null,
+ * 回复照常可读(与 Google Drive 的 comments.deleted、GitHub 的 deletedAt 同一做法)。
+ *
+ * editor 是对内容负责的人;AI 凭人类授权代写时,co_editor 是那个模型(仿 git 的
+ * Co-Authored-By)。存量数据里 AI 写的行没有 co_editor,editor 直接是模型。
+ *
+ * **本类不查库。** 编辑者从预加载的 `user` / `aiModel` / `coEditor` 关系上取;权限与正文渲染
+ * 由 {@see DiscussionService} 算好挂在模型的 `abilities` / `rendered` 属性上。
+ *
+ * 字段名改了两处(v2 → v3):res_type / res_id → target_type / target_id,
+ * parent → parent_id;children_count → reply_count(只有话题有)。
+ *
+ * @mixin Discussion
+ */
+class DiscussionResource extends BaseResource
+{
+    use ResolvesActor;
+
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     target_type: string,
+     *     target_id: string,
+     *     parent_id: string|null,
+     *     type: string,
+     *     severity: string|null,
+     *     status: string,
+     *     title: string|null,
+     *     content: string|null,
+     *     content_type: string,
+     *     html?: string,
+     *     summary?: string,
+     *     tpl_id: string|null,
+     *     pos_start: int|null,
+     *     pos_end: int|null,
+     *     quote_exact: string|null,
+     *     quote_prefix: string|null,
+     *     quote_suffix: string|null,
+     *     reply_count?: int,
+     *     editor: array{id: string, nickName: string, userName: string, realName: string, sn: int, avatar: string|null, roles: array|null}|null,
+     *     co_editor: array{id: string, nickName: string, userName: string, realName: string, sn: int, avatar: string|null, roles: array|null}|null,
+     *     abilities: array{update: bool, delete: bool, close: bool, reply: bool},
+     *     created_at: string,
+     *     updated_at: string,
+     *     deleted_at: string|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        $tombstone = $this->trashed();
+
+        return [
+            'id' => (string) $this->id,
+            'target_type' => $this->res_type,
+            'target_id' => $this->res_id,
+            'parent_id' => $this->parent,
+            'type' => $this->type,
+            'severity' => $this->severity,
+            'status' => $this->status,
+            'title' => $tombstone ? null : $this->title,
+            'content' => $tombstone ? null : $this->content,
+            'content_type' => $this->content_type,
+            'html' => $this->when($this->rendered !== null, fn () => $this->rendered['html']),
+            'summary' => $this->when($this->rendered !== null, fn () => $this->rendered['summary']),
+            'tpl_id' => $this->tpl_id,
+            'pos_start' => $this->pos_start,
+            'pos_end' => $this->pos_end,
+            'quote_exact' => $tombstone ? null : $this->quote_exact,
+            'quote_prefix' => $tombstone ? null : $this->quote_prefix,
+            'quote_suffix' => $tombstone ? null : $this->quote_suffix,
+            'reply_count' => $this->when($this->parent === null, fn () => (int) $this->reply_count),
+            'editor' => $this->actor($this->user, $this->aiModel),
+            // AI 代写时的模型;人类自己写的、存量数据都为 null
+            'co_editor' => $this->coEditor ? $this->actor(null, $this->coEditor) : null,
+            'abilities' => $this->abilities ?? ['update' => false, 'delete' => false, 'close' => false, 'reply' => false],
+            'created_at' => $this->created_at,
+            'updated_at' => $this->updated_at,
+            'deleted_at' => $this->deleted_at,
+        ];
+    }
+}

+ 27 - 0
api-v13/app/Http/Resources/V3/DiscussionSummaryResource.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\V3\DiscussionService;
+use Illuminate\Http\Request;
+
+/**
+ * 某个对象上的话题概况。载荷是 {@see DiscussionService::summary()} 返回的数组。
+ */
+class DiscussionSummaryResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     status_counts: array{active: int, close: int},
+     *     creatable_types: list<string>
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'status_counts' => $this->resource['status_counts'],
+            'creatable_types' => $this->resource['creatable_types'],
+        ];
+    }
+}

+ 100 - 1
api-v13/app/Models/Discussion.php

@@ -5,13 +5,33 @@ namespace App\Models;
 use App\Services\PaliContentService;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
+/**
+ * 表 `discussions`。
+ *
+ * 这一张表装着几种东西:评论线程(discussion / qa / help / issue)、阅读页批注
+ * (note / commentary,会被注入阅读页),以及 v2 TaskStatusController 写进来的任务
+ * 状态日志(res_type=task)。v3 的 `/v3/discussions` 管前两种({@see self::COMMENT_TYPES})。
+ *
+ * 2026-10-15 起软删除:删除话题时这一行留作「墓碑」,回复不受影响。
+ */
 class Discussion extends Model
 {
     use HasFactory;
+    use SoftDeletes;
 
     protected $primaryKey = 'id';
 
+    /**
+     * 主键是 uuid,由库的默认值生成(所以 incrementing 仍为 true:靠 insert … returning id
+     * 拿回主键)。keyType 必须声明为 string:默认的 int 会让 whereIn 走 whereIntegerInRaw,
+     * 把 uuid 转成整数(loadCount / 预加载时出现 `id in (2)`)。
+     */
+    protected $keyType = 'string';
+
     protected $casts = [
         'id' => 'string',
         'pos_start' => 'integer',
@@ -36,15 +56,94 @@ class Discussion extends Model
         'quote_suffix',
     ];
 
-    /** 注释书对应:content 是下一层句子模板,渲染时带 <cite> 出处 */
+    /**
+     * 义注 / 复注对应(巴利注释书,不是普通评论):content 是注释书的句子模板
+     * `{{book-para-start-end}}`(可多句并列),渲染成那几句的译文并带 <cite> 出处。
+     */
     public const TYPE_COMMENTARY = 'commentary';
 
+    /** commentary 的 content 格式:一个或多个句子模板(与 PaliContentService 注入时的判定一致) */
+    public const COMMENTARY_PATTERN = '/^(?:\{\{\d+-\d+-\d+-\d+\}\}\s*)+$/';
+
     /** 普通边注:content 就是注解正文,没有出处 */
     public const TYPE_NOTE = 'note';
 
     /** 会被注入阅读页的批注类型 */
     public const INJECTED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE];
 
+    /** 评论线程:话题 + 一层回复,有开关状态 */
+    public const THREAD_TYPES = ['discussion', 'qa', 'help', 'issue'];
+
+    /**
+     * `/v3/discussions` 管的全部类型:评论线程({@see self::THREAD_TYPES})+ 批注
+     * ({@see self::INJECTED_TYPES})。批注锚定在句子的某个位置、注入阅读页,没有回复和开关。
+     */
+    public const COMMENT_TYPES = ['discussion', 'qa', 'help', 'issue', 'note', 'commentary'];
+
+    /** 各类型允许的 severity 取值;不在表里的类型不允许设 severity */
+    public const SEVERITIES = [
+        'issue' => ['error', 'warning', 'info'],
+        'help' => ['urgent', 'normal'],
+    ];
+
+    /** 话题状态:active 未关闭,close 已关闭(v4 在读这两个值,不能改) */
+    public const STATUSES = ['active', 'close'];
+
+    /**
+     * 挂靠目标的类型(res_type)。v3 对外叫 target_type。
+     * chapter / channel / feedback 在两个前端里都没有调用方,不收。
+     */
+    public const TARGET_TYPES = ['sentence', 'wbw', 'article', 'term', 'task'];
+
+    /**
+     * 当前查看者能做的动作,由 `App\Services\V3\DiscussionService` 算好后挂上,
+     * 供 Resource 读取(Resource 不查库)。声明成属性而不是 attribute,不会被存进表。
+     *
+     * @var array{update: bool, delete: bool, close: bool, reply: bool}|null
+     */
+    public ?array $abilities = null;
+
+    /**
+     * 正文渲染结果(只有挂在句子上的才有,需要句子所在的 channel 才能渲染模板),
+     * 同样由 Service 算好后挂上。
+     *
+     * @var array{html: string, summary: string}|null
+     */
+    public ?array $rendered = null;
+
+    /**
+     * 编辑者(人类用户)。editor_uid 对 user_infos.userid。
+     */
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(UserInfo::class, 'editor_uid', 'userid');
+    }
+
+    /**
+     * 编辑者(AI 助手)。与 {@see self::user()} 共用 uuid 空间,二者择一。
+     */
+    public function aiModel(): BelongsTo
+    {
+        return $this->belongsTo(AiModel::class, 'editor_uid', 'uid');
+    }
+
+    /**
+     * 协作者:人类授权代写的 AI 模型(co_editor_uid)。此时 editor 是授权人。
+     * 存量数据里 AI 写的行没有它,editor_uid 直接是模型,见 {@see self::aiModel()}。
+     */
+    public function coEditor(): BelongsTo
+    {
+        return $this->belongsTo(AiModel::class, 'co_editor_uid', 'uid');
+    }
+
+    /**
+     * 话题下的回复(只有一层)。软删的回复自动排除。
+     */
+    public function replies(): HasMany
+    {
+        return $this->hasMany(Discussion::class, 'parent');
+    }
+
     /**
      * 这两类批注会被注入到所挂句子的阅读页里(PaliContentService::
      * injectAnnotationNotes),阅读页按 (book, para, channel) 缓存,所以增改删它们

+ 2 - 0
api-v13/app/Services/PaliContentService.php

@@ -756,6 +756,8 @@ class PaliContentService
         $notes = Discussion::where('res_type', 'sentence')
             ->where('res_id', $row->uid)
             ->whereIn('type', Discussion::INJECTED_TYPES)
+            // 只注入批注本身;批注下的回复(v3 起允许回复,类型跟随批注)不进阅读页
+            ->whereNull('parent')
             ->get();
 
         if ($notes->isEmpty()) {

+ 8 - 0
api-v13/app/Services/UserService.php

@@ -152,6 +152,14 @@ class UserService
         ];
     }
 
+    /**
+     * 只有 AI 一条关系时的预加载声明(如协作者 co_editor),用法同 {@see self::eagerLoadActor()}。
+     */
+    public static function eagerLoadAssistant(string $relation): string
+    {
+        return $relation.':'.implode(',', self::ASSISTANT_COLUMNS);
+    }
+
     /**
      * 批量取摘要,返回**位置数组**:保持传入顺序,解析不到的整条丢弃。
      *

+ 154 - 0
api-v13/app/Services/V3/AgentGrantService.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Http\Api\ChannelApi;
+use App\Models\AiModel;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * agent 协作:人类授权 AI 模型代自己写入(`/v3/agent-grants`),以及写入时认出「谁授权、谁代写」。
+ *
+ * 流程(仿 git 的 Co-Authored-By,记录见根目录 CLAUDE.md「agent 协作」):
+ * 1. 人类用自己的登录 token 为「自己名下的某个模型 + 某个 channel(可限 book)」签一张授权;
+ * 2. 模型用自己的模型 token 登录,写入时在 `X-Agent-Grant` 头里带上这张授权;
+ * 3. 服务端记 editor = 授权人(对内容负责)、co_editor = 模型,权限按授权人判,
+ *    并且只在授权覆盖的 channel / book 内有效。
+ *
+ * 与 v2 `POST /v2/access-token` 的区别:
+ * - 授权人、模型、范围全由服务端写进 token,不收客户端拼的载荷;
+ * - 绑定具体模型(act),换个身份拿去用无效;
+ * - 带模型的 token_version(ver):撤销模型 token 时,它的授权一并失效;
+ * - 用独立的签名密钥,v2 签出的 access token 在这里一律无效(v2 的载荷由客户端决定,
+ *   可以伪造任何声明)。
+ */
+class AgentGrantService
+{
+    /** 授权有效期:一次批量写入作业足够,又把泄漏窗口限制住(与 v2 access token 相同) */
+    public const TTL = 60 * 60 * 24 * 7;
+
+    /** 写入请求里携带授权的请求头 */
+    public const HEADER = 'X-Agent-Grant';
+
+    private const TYPE = 'agent-grant';
+
+    /**
+     * 人类为自己名下的模型签发 channel 写入授权。
+     *
+     * @param  array{ai_model: string, target_type: string, target_id: string, book?: int}  $data
+     * @return array{token: string, grantor: string, ai_model: string, target_type: string, target_id: string, book: int, expires_at: string}
+     */
+    public function issue(string $userUid, array $data): array
+    {
+        if ($this->isModel($userUid)) {
+            // 授权必须出自人类:模型不能给自己(或别的模型)签授权
+            throw new AuthorizationException(__('messages.agent_grant_by_model'));
+        }
+        $model = AiModel::where('uid', $data['ai_model'])->first(['uid', 'owner_id', 'token_version']);
+        if (! $model) {
+            throw ValidationException::withMessages(['ai_model' => __('messages.agent_grant_model_not_found')]);
+        }
+        // 与 v2 取模型 token 同一口径(AiModelController::canEdit):模型归属于当前用户
+        if ($model->owner_id !== $userUid) {
+            throw new AuthorizationException(__('messages.agent_grant_not_owner'));
+        }
+        if (! ChannelApi::userCanEdit($userUid, $data['target_id'])) {
+            throw new AuthorizationException(__('messages.agent_grant_no_power'));
+        }
+
+        $book = (int) ($data['book'] ?? 0);
+        $expiresAt = time() + self::TTL;
+        $token = JWT::encode([
+            'typ' => self::TYPE,
+            'grantor' => $userUid,
+            'act' => $model->uid,
+            'ver' => (int) $model->token_version,
+            'res_type' => $data['target_type'],
+            'res_id' => $data['target_id'],
+            'book' => $book,
+            'nbf' => time(),
+            'exp' => $expiresAt,
+        ], $this->key(), 'HS512');
+
+        return [
+            'token' => $token,
+            'grantor' => $userUid,
+            'ai_model' => $model->uid,
+            'target_type' => $data['target_type'],
+            'target_id' => $data['target_id'],
+            'book' => $book,
+            'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresAt),
+        ];
+    }
+
+    /**
+     * 写入请求的署名:人类直接写 → editor 是自己;模型代写 → editor 是授权人、co_editor 是模型。
+     *
+     * 模型没带授权一律 403:v3 的写入必须有一个对内容负责的人。人类带了授权 422(授权只给模型用)。
+     *
+     * @return array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}
+     */
+    public function actor(string $userUid, ?string $grant): array
+    {
+        $isModel = $this->isModel($userUid);
+        if (! $isModel) {
+            if ($grant !== null && $grant !== '') {
+                throw ValidationException::withMessages([self::HEADER => __('messages.agent_grant_human')]);
+            }
+
+            return ['editor' => $userUid, 'co_editor' => null, 'scope' => null];
+        }
+        if ($grant === null || $grant === '') {
+            throw new AuthorizationException(__('messages.agent_grant_required'));
+        }
+
+        try {
+            $claims = JWT::decode($grant, new Key($this->key(), 'HS512'));
+        } catch (\Throwable) {
+            // 伪造、过期、未生效、密钥不对,一律当作没有授权
+            throw new AuthorizationException(__('messages.agent_grant_invalid'));
+        }
+        $version = AiModel::where('uid', $userUid)->value('token_version');
+        if (($claims->typ ?? null) !== self::TYPE
+            || ($claims->act ?? null) !== $userUid
+            || (int) ($claims->ver ?? -1) !== (int) $version
+            || ($claims->res_type ?? null) !== 'channel'
+            || empty($claims->grantor)) {
+            throw new AuthorizationException(__('messages.agent_grant_invalid'));
+        }
+
+        return [
+            'editor' => (string) $claims->grantor,
+            'co_editor' => $userUid,
+            'scope' => ['channel' => (string) $claims->res_id, 'book' => (int) ($claims->book ?? 0)],
+        ];
+    }
+
+    /**
+     * 授权是否覆盖这次写入落到的 channel / book。人类直接写(scope 为 null)恒为真。
+     *
+     * @param  array{channel: string, book: int}|null  $scope
+     */
+    public function covers(?array $scope, ?string $channelUid, ?int $book): bool
+    {
+        if ($scope === null) {
+            return true;
+        }
+
+        return $channelUid === $scope['channel'] && ($scope['book'] === 0 || $book === $scope['book']);
+    }
+
+    private function isModel(string $uid): bool
+    {
+        return AiModel::where('uid', $uid)->exists();
+    }
+
+    /** 与登录 token 分开的签名密钥:授权 token 不能被当成登录 token 用,反之亦然 */
+    private function key(): string
+    {
+        return hash('sha512', config('mint.app.jwt_secrets_key').'|'.self::TYPE);
+    }
+}

+ 809 - 0
api-v13/app/Services/V3/DiscussionService.php

@@ -0,0 +1,809 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Exceptions\BusinessException;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\MdRender;
+use App\Http\Api\Mq;
+use App\Http\Api\ShareApi;
+use App\Http\Resources\DiscussionResource as MqDiscussionPayload;
+use App\Models\Article;
+use App\Models\ArticleCollection;
+use App\Models\Collection as ArticleCollectionOwner;
+use App\Models\DhammaTerm;
+use App\Models\Discussion;
+use App\Models\Sentence;
+use App\Models\Task;
+use App\Models\Wbw;
+use App\Models\WbwBlock;
+use App\Services\UserService;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * 评论线程(`/v3/discussions`)的业务逻辑:查询、可见性、权限、写入。
+ *
+ * 底层是 `discussions` 表,v2 的 `DiscussionController` 另有一套实现,两边不共用——
+ * 见 CLAUDE.md 铁律 2。
+ *
+ * 线程模型固定两层:话题(parent 为空)+ 一层回复。回复的类型、挂靠对象一律跟随话题,
+ * 不单独存取;状态、标题、severity 只对话题有意义。
+ *
+ * 批注(note / commentary,{@see Discussion::INJECTED_TYPES})也走这里,但它不是评论:
+ * 锚定在句子的某个位置、注入该句所在 channel 的阅读页(所有读者可见),所以
+ * - 只能挂在句子上,只有该句的管理者(channel 编辑者)能新建;改、删同评论(作者或管理者);
+ * - 其他用户可以回复;没有开关状态,不能与评论线程互转;新建批注不发通知;
+ * - 列表与统计不带 type 时只算评论线程,要批注须显式 type=note / commentary。
+ *
+ * 写入方(`$actor`)由 {@see AgentGrantService::actor()} 给出:人类直接写,或 AI 模型凭人类
+ * 授权代写。代写时 editor 是授权人、co_editor 是模型,权限一律按 editor 判,
+ * 而且只能写授权覆盖的 channel / book 里的东西。
+ *
+ * 可见性沿用 v2 `view=question` 的口径,但这里对列表、单条、回复一视同仁(v2 只在
+ * 列表上做了这个判断):
+ * - 未登录看不到协作类话题(discussion / issue);
+ * - basic 角色在自己无权管理的对象上,只能看到自己发起的协作类话题。
+ *
+ * TODO: 以下两项需要加列,2026-10-15 用户决定暂缓:
+ * - qa 的「已回答」现在用 status=close 表示;社区通行做法是把某条回复标为答案
+ *   (GitHub Discussions 的 isAnswer),需要加一列(如 answer_id)。
+ * - 关闭话题不留痕,不知道是谁、什么时候关的。可以学 Google Drive 用带 action 的
+ *   回复记录,也可以加 closed_by / closed_at 两列。
+ */
+class DiscussionService
+{
+    /** 未登录用户看不到、basic 角色只能看自己的这几类话题 */
+    private const COLLABORATION_TYPES = ['discussion', 'issue'];
+
+    /** 批注的锚点字段:定位到句子里的哪一段 */
+    private const ANCHOR_FIELDS = ['pos_start', 'pos_end', 'quote_exact', 'quote_prefix', 'quote_suffix'];
+
+    /**
+     * 一次调用内的权限缓存。每个公开方法进来先清空:Service 随控制器实例存活,
+     * 控制器实例又会被路由缓存(测试里、常驻进程里都会跨请求复用),不清空就会把
+     * 上一个请求的权限带进下一个请求。
+     *
+     * @var array<string, array<int, string>> 用户 uid → 可编辑的 channel uid 列表
+     */
+    private array $editableChannels = [];
+
+    /** @var array<string, bool> "用户 uid|对象类型|对象 id" → 是否能管理该对象 */
+    private array $managers = [];
+
+    public function __construct(
+        private readonly UserService $users,
+        private readonly AgentGrantService $grants,
+    ) {}
+
+    /**
+     * 某个对象上的话题列表。删除了但还有回复的话题以墓碑形式出现。
+     *
+     * @param  array{target_type: string, target_id: string, type?: string, status?: string, severity?: string, sort?: string}  $filters
+     * @return LengthAwarePaginator<int, Discussion>
+     */
+    public function listTopics(array $filters, ?string $viewerUid, int $perPage): LengthAwarePaginator
+    {
+        $this->resetCache();
+        $query = $this->topicQuery()
+            ->where('res_type', $filters['target_type'])
+            ->where('res_id', $filters['target_id'])
+            ->tap(fn ($q) => $this->applyTypeFilter($q, $filters['type'] ?? null))
+            ->when($filters['status'] ?? null, fn ($q, $value) => $q->where('status', $value))
+            ->when($filters['severity'] ?? null, fn ($q, $value) => $q->whereIn('severity', explode(',', $value)))
+            ->withCount('replies as reply_count');
+        $this->applyVisibility($query, $viewerUid, $filters['target_type'], $filters['target_id']);
+
+        $sort = $filters['sort'] ?? '-created_at';
+        $query->orderBy(ltrim($sort, '-'), str_starts_with($sort, '-') ? 'desc' : 'asc');
+
+        $page = $query->paginate($perPage);
+        $this->decorate($page->getCollection(), $viewerUid);
+
+        return $page;
+    }
+
+    /**
+     * 某个对象上的话题概况:各状态的话题数(只数没删的、当前用户看得见的),
+     * 以及当前用户能在这里发起哪些类型的话题。
+     *
+     * @param  array{target_type: string, target_id: string, type?: string}  $filters
+     * @return array{status_counts: array{active: int, close: int}, creatable_types: list<string>}
+     */
+    public function summary(array $filters, ?string $viewerUid): array
+    {
+        $this->resetCache();
+        $query = Discussion::query()
+            ->whereNull('parent')
+            ->whereIn('type', Discussion::COMMENT_TYPES)
+            ->where('res_type', $filters['target_type'])
+            ->where('res_id', $filters['target_id'])
+            ->tap(fn ($q) => $this->applyTypeFilter($q, $filters['type'] ?? null));
+        $this->applyVisibility($query, $viewerUid, $filters['target_type'], $filters['target_id']);
+
+        $counts = $query->toBase()
+            ->select('status', DB::raw('count(*) as total'))
+            ->groupBy('status')
+            ->pluck('total', 'status');
+
+        return [
+            'status_counts' => [
+                'active' => (int) ($counts['active'] ?? 0),
+                'close' => (int) ($counts['close'] ?? 0),
+            ],
+            'creatable_types' => $this->creatableTypes($viewerUid, $filters['target_type'], $filters['target_id']),
+        ];
+    }
+
+    /**
+     * 一批对象(句子 / 逐词)的讨论角标:未关闭的评论线程数,以及其中查看者发起的数目。
+     *
+     * 一条 group by 查完;可见性口径同列表({@see self::applyVisibility()}),basic 角色能管理
+     * 哪些对象批量算出,不逐个查。按请求顺序返回,没有讨论的给 0。
+     *
+     * TODO: 课程迁 v3 之后,补上 v2 课程模式的「学生的讨论」:逐词按坐标(book / paragraph / wid)
+     * 匹配学员 channel 上同一个词的讨论(v2 `DiscussionCountController@store` 的 course_id 分支,
+     * 前端 `WbwPaliDiscussionIcon` 的 wbw 参数)。现在只数这个对象本身上的。
+     *
+     * @param  list<array{type: string, id: string}>  $targets
+     * @return list<array{target_type: string, target_id: string, total: int, mine: int}>
+     */
+    public function batchCount(array $targets, ?string $viewerUid): array
+    {
+        $this->resetCache();
+        $idsByType = collect($targets)->groupBy('type')->map(fn ($items) => $items->pluck('id')->unique()->values()->all());
+
+        $query = Discussion::query()
+            ->whereNull('parent')
+            ->where('status', 'active')
+            ->whereIn('type', Discussion::THREAD_TYPES)
+            ->where(function ($q) use ($idsByType) {
+                foreach ($idsByType as $type => $ids) {
+                    $q->orWhere(fn ($q) => $q->where('res_type', $type)->whereIn('res_id', $ids));
+                }
+            });
+        if ($viewerUid === null) {
+            $query->whereNotIn('type', self::COLLABORATION_TYPES);
+        } elseif ($this->isBasic($viewerUid)) {
+            $managed = $this->managedTargets($viewerUid, $idsByType->all());
+            $query->where(fn ($q) => $q->whereNotIn('type', self::COLLABORATION_TYPES)
+                ->orWhere('editor_uid', $viewerUid)
+                ->orWhereIn('res_id', $managed));
+        }
+
+        $rows = $query->toBase()
+            ->select('res_type', 'res_id', DB::raw('count(*) as total'))
+            ->selectRaw('sum(case when editor_uid = ? then 1 else 0 end) as mine', [$viewerUid])
+            ->groupBy('res_type', 'res_id')
+            ->get()
+            ->keyBy(fn ($row) => $row->res_type.'/'.$row->res_id);
+
+        return array_map(function (array $target) use ($rows) {
+            $row = $rows[$target['type'].'/'.$target['id']] ?? null;
+
+            return [
+                'target_type' => $target['type'],
+                'target_id' => $target['id'],
+                'total' => (int) ($row->total ?? 0),
+                'mine' => (int) ($row->mine ?? 0),
+            ];
+        }, array_values($targets));
+    }
+
+    /**
+     * 单条(话题或回复)。删除了的回复、删除了且没有回复的话题都当作不存在。
+     */
+    public function show(Discussion $discussion, ?string $viewerUid): Discussion
+    {
+        $this->resetCache();
+        $this->assertComment($discussion);
+        if ($discussion->trashed() && ($discussion->parent !== null || ! $discussion->replies()->exists())) {
+            abort(404);
+        }
+        $topic = $discussion->parent === null ? $discussion : Discussion::withTrashed()->findOrFail($discussion->parent);
+        $this->assertVisible($topic, $viewerUid);
+
+        $discussion->load(self::relations());
+        if ($discussion->parent === null) {
+            $discussion->loadCount('replies as reply_count');
+        } else {
+            $discussion->type = $topic->type;
+        }
+        $this->decorate(collect([$discussion]), $viewerUid);
+
+        return $discussion;
+    }
+
+    /**
+     * 话题下的回复,按时间正序。墓碑话题的回复照样可读。
+     *
+     * @return LengthAwarePaginator<int, Discussion>
+     */
+    public function listReplies(Discussion $topic, ?string $viewerUid, int $perPage): LengthAwarePaginator
+    {
+        $this->resetCache();
+        $this->assertComment($topic);
+        if ($topic->parent !== null) {
+            abort(404);
+        }
+        $this->assertVisible($topic, $viewerUid);
+
+        $page = Discussion::query()
+            ->where('parent', $topic->id)
+            ->with(self::relations())
+            ->orderBy('created_at')
+            ->paginate($perPage);
+        // 回复的类型一律跟随话题:历史数据里有 qa 话题下 type=discussion 的回复
+        $page->getCollection()->each(fn (Discussion $reply) => $reply->type = $topic->type);
+        $this->decorate($page->getCollection(), $viewerUid);
+
+        return $page;
+    }
+
+    /**
+     * 发起话题,或新建一条批注(批注不发通知)。
+     *
+     * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
+     * @param  array{target_type: string, target_id: string, type?: string, title?: string|null, content?: string|null, severity?: string|null, pos_start?: int|null, pos_end?: int|null, quote_exact?: string|null, quote_prefix?: string|null, quote_suffix?: string|null, tpl_id?: string|null, notify?: bool}  $data
+     */
+    public function createTopic(array $actor, array $data): Discussion
+    {
+        $this->resetCache();
+        $userUid = $actor['editor'];
+        if (! $this->targetExists($data['target_type'], $data['target_id'])) {
+            throw ValidationException::withMessages(['target_id' => __('messages.discussion_target_not_found')]);
+        }
+        $this->assertGrantCovers($actor, $data['target_type'], $data['target_id']);
+        $type = $data['type'] ?? 'discussion';
+        if (! in_array($type, $this->creatableTypes($userUid, $data['target_type'], $data['target_id']), true)) {
+            abort(403, __('messages.discussion_forbidden'));
+        }
+
+        $topic = new Discussion;
+        $topic->res_type = $data['target_type'];
+        $topic->res_id = $data['target_id'];
+        $topic->type = $type;
+        $topic->title = $data['title'] ?? null;
+        $topic->content = $data['content'] ?? null;
+        $topic->content_type = 'markdown';
+        $topic->severity = $data['severity'] ?? null;
+        $topic->tpl_id = $data['tpl_id'] ?? null;
+        $topic->fill(array_intersect_key($data, array_flip(self::ANCHOR_FIELDS)));
+        $topic->editor_uid = $userUid;
+        $topic->co_editor_uid = $actor['co_editor'];
+        $topic->save();
+
+        if (! $this->isAnnotation($topic) && ($data['notify'] ?? true)) {
+            $this->notify($topic);
+        }
+
+        return $this->fresh($topic, $userUid);
+    }
+
+    /**
+     * 回复话题。话题必须没关闭;不能回复一条回复。
+     *
+     * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
+     * @param  array{content: string, notify?: bool}  $data
+     */
+    public function createReply(Discussion $topic, array $actor, array $data): Discussion
+    {
+        $this->resetCache();
+        $userUid = $actor['editor'];
+        $this->assertComment($topic);
+        if ($topic->parent !== null) {
+            throw ValidationException::withMessages(['discussion' => __('messages.discussion_reply_to_reply')]);
+        }
+        $this->assertVisible($topic, $userUid);
+        $this->assertGrantCovers($actor, $topic->res_type, $topic->res_id);
+        if ($topic->status === 'close') {
+            throw new BusinessException(__('messages.discussion_closed'), 409, 'discussion-closed');
+        }
+        if (! $this->canReply($userUid, $topic)) {
+            abort(403, __('messages.discussion_forbidden'));
+        }
+
+        $reply = DB::transaction(function () use ($topic, $userUid, $actor, $data) {
+            $reply = new Discussion;
+            $reply->res_type = $topic->res_type;
+            $reply->res_id = $topic->res_id;
+            $reply->type = $topic->type;
+            $reply->parent = $topic->id;
+            $reply->content = $data['content'];
+            $reply->content_type = 'markdown';
+            $reply->editor_uid = $userUid;
+            $reply->co_editor_uid = $actor['co_editor'];
+            $reply->save();
+            // children_count 列只有 v2 的 sent-discussion-tree 在读,v3 现算;这里只是让它别漂移
+            $topic->increment('children_count');
+
+            return $reply;
+        });
+
+        if ($data['notify'] ?? true) {
+            $this->notify($reply);
+        }
+
+        return $this->fresh($reply, $userUid);
+    }
+
+    /**
+     * 局部更新。title / status / type / severity 只对话题有效。
+     *
+     * 改 type 时回复跟着改;新类型不收原来的 severity 时,severity 清空。
+     * 批注:锚点可改,没有 status,type 只能在 note / commentary 之间改,
+     * 正文不能清空,commentary 的正文必须是义注句子模板(按改后的 type 与正文判)。
+     *
+     * 改了内容(标题、正文、锚点)时 co_editor 改成这次写入的协作者:人亲自改则清空,
+     * AI 代改则换成该模型。只改 status / type / severity 不动 co_editor。
+     *
+     * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
+     * @param  array{title?: string, content?: string|null, status?: string, type?: string, severity?: string|null, pos_start?: int|null, pos_end?: int|null, quote_exact?: string|null, quote_prefix?: string|null, quote_suffix?: string|null}  $data
+     */
+    public function update(Discussion $discussion, array $actor, array $data): Discussion
+    {
+        $this->resetCache();
+        $userUid = $actor['editor'];
+        $this->assertComment($discussion);
+        $this->assertGrantCovers($actor, $discussion->res_type, $discussion->res_id);
+        $abilities = $this->abilities($discussion, $userUid);
+        if (! $abilities['update']) {
+            abort(403, __('messages.discussion_forbidden'));
+        }
+        $isTopic = $discussion->parent === null;
+        $topicOnly = array_intersect_key($data, array_flip(['title', 'status', 'type', 'severity']));
+        if (! $isTopic && $topicOnly !== []) {
+            throw ValidationException::withMessages(
+                array_fill_keys(array_keys($topicOnly), __('messages.discussion_topic_only'))
+            );
+        }
+        $this->assertAnnotationUpdate($discussion, $data);
+        if (array_key_exists('status', $data) && ! $abilities['close']) {
+            abort(403, __('messages.discussion_forbidden'));
+        }
+
+        $type = $data['type'] ?? $discussion->type;
+        if (array_key_exists('severity', $data)) {
+            if ($data['severity'] !== null && ! in_array($data['severity'], Discussion::SEVERITIES[$type] ?? [], true)) {
+                throw ValidationException::withMessages(['severity' => __('messages.discussion_severity_not_allowed')]);
+            }
+        } elseif ($discussion->severity !== null && ! in_array($discussion->severity, Discussion::SEVERITIES[$type] ?? [], true)) {
+            $data['severity'] = null;
+        }
+
+        DB::transaction(function () use ($discussion, $data, $actor) {
+            $typeChanged = array_key_exists('type', $data) && $data['type'] !== $discussion->type;
+            $authored = array_intersect_key($data, array_flip(['title', 'content', ...self::ANCHOR_FIELDS]));
+            $discussion->fill($authored);
+            // 改了内容,co_editor 就跟着这次写的人走:人亲自改 → 清空,AI 代改 → 换成这个模型。
+            // 只改状态 / 类型 / severity 是管理动作,不动内容的署名。
+            if ($authored !== []) {
+                $discussion->co_editor_uid = $actor['co_editor'];
+            }
+            foreach (['status', 'type', 'severity'] as $field) {
+                if (array_key_exists($field, $data)) {
+                    $discussion->{$field} = $data[$field];
+                }
+            }
+            $discussion->save();
+            if ($typeChanged) {
+                Discussion::where('parent', $discussion->id)->update(['type' => $discussion->type]);
+            }
+        });
+
+        return $this->fresh($discussion, $userUid);
+    }
+
+    /**
+     * 软删除。作者本人或挂靠对象的管理者能删(v2 只允许作者)。
+     *
+     * 删话题不动回复:有回复的话题以墓碑形式继续出现在列表里;删回复会让话题的
+     * children_count 减一。返回删除后的资源(AIP-164:软删的资源没有消失)。
+     *
+     * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
+     */
+    public function delete(Discussion $discussion, array $actor): Discussion
+    {
+        $this->resetCache();
+        $userUid = $actor['editor'];
+        $this->assertComment($discussion);
+        $this->assertGrantCovers($actor, $discussion->res_type, $discussion->res_id);
+        if (! $this->abilities($discussion, $userUid)['delete']) {
+            abort(403, __('messages.discussion_forbidden'));
+        }
+
+        DB::transaction(function () use ($discussion) {
+            $discussion->delete();
+            if ($discussion->parent !== null) {
+                Discussion::where('id', $discussion->parent)
+                    ->where('children_count', '>', 0)
+                    ->decrement('children_count');
+            }
+        });
+
+        return $this->fresh($discussion, $userUid);
+    }
+
+    /**
+     * 编辑者与协作者的预加载声明。
+     *
+     * @return array<int, string>
+     */
+    private static function relations(): array
+    {
+        return [...UserService::eagerLoadActor(), UserService::eagerLoadAssistant('coEditor')];
+    }
+
+    /**
+     * AI 代写时,授权必须覆盖这次写入落到的 channel / book;只有句子能定位到 channel,
+     * 挂在别的对象上的一律不覆盖。人类直接写不受限。
+     *
+     * @param  array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}  $actor
+     */
+    private function assertGrantCovers(array $actor, string $targetType, string $targetId): void
+    {
+        if ($actor['scope'] === null) {
+            return;
+        }
+        $sentence = $targetType === 'sentence'
+            ? Sentence::where('uid', $targetId)->first(['channel_uid', 'book_id'])
+            : null;
+        if (! $this->grants->covers($actor['scope'], $sentence?->channel_uid, $sentence ? (int) $sentence->book_id : null)) {
+            abort(403, __('messages.agent_grant_scope'));
+        }
+    }
+
+    /**
+     * 这批对象里查看者能管理的(所在 channel 可编辑),一次查出,供 basic 角色的可见性用。
+     *
+     * @param  array<string, list<string>>  $idsByType
+     * @return list<string>
+     */
+    private function managedTargets(string $userUid, array $idsByType): array
+    {
+        $editable = $this->editableChannels($userUid);
+        $managed = [];
+        if (! empty($idsByType['sentence'])) {
+            array_push($managed, ...Sentence::whereIn('uid', $idsByType['sentence'])
+                ->whereIn('channel_uid', $editable)->pluck('uid')->all());
+        }
+        if (! empty($idsByType['wbw'])) {
+            $blocks = WbwBlock::whereIn('channel_uid', $editable)->select('uid');
+            array_push($managed, ...Wbw::whereIn('uid', $idsByType['wbw'])
+                ->whereIn('block_uid', $blocks)->pluck('uid')->all());
+        }
+
+        return array_map('strval', $managed);
+    }
+
+    private function isAnnotation(Discussion $discussion): bool
+    {
+        return in_array($discussion->type, Discussion::INJECTED_TYPES, true);
+    }
+
+    /**
+     * 列表 / 统计的类型过滤:传了就按传的,没传只算评论线程(批注要显式点名)。
+     *
+     * @param  Builder<Discussion>  $query
+     */
+    private function applyTypeFilter(Builder $query, ?string $type): void
+    {
+        $type === null
+            ? $query->whereIn('type', Discussion::THREAD_TYPES)
+            : $query->where('type', $type);
+    }
+
+    /**
+     * 批注与评论线程在 PATCH 上的差异,见 {@see self::update()}。
+     *
+     * @param  array<string, mixed>  $data
+     */
+    private function assertAnnotationUpdate(Discussion $discussion, array $data): void
+    {
+        $isAnnotation = $this->isAnnotation($discussion);
+        $errors = [];
+        if (array_key_exists('type', $data)
+            && in_array($data['type'], Discussion::INJECTED_TYPES, true) !== $isAnnotation) {
+            $errors['type'] = __('messages.discussion_type_group');
+        }
+        if (! $isAnnotation) {
+            foreach (array_intersect(self::ANCHOR_FIELDS, array_keys($data)) as $field) {
+                $errors[$field] = __('messages.discussion_anchor_thread');
+            }
+        } else {
+            if (array_key_exists('status', $data)) {
+                $errors['status'] = __('messages.discussion_annotation_no_status');
+            }
+            $type = $data['type'] ?? $discussion->type;
+            $content = trim((string) (array_key_exists('content', $data) ? $data['content'] : $discussion->content));
+            if ($content === '') {
+                $errors['content'] = __('validation.required', ['attribute' => 'content']);
+            } elseif ($type === Discussion::TYPE_COMMENTARY && ! preg_match(Discussion::COMMENTARY_PATTERN, $content)) {
+                $errors['content'] = __('messages.discussion_commentary_format');
+            }
+        }
+        if ($errors !== []) {
+            throw ValidationException::withMessages($errors);
+        }
+    }
+
+    private function resetCache(): void
+    {
+        $this->editableChannels = [];
+        $this->managers = [];
+    }
+
+    /**
+     * 话题的基础查询:只要评论类型、只要顶级;删了的话题只有还挂着回复时才留下(墓碑)。
+     *
+     * @return Builder<Discussion>
+     */
+    private function topicQuery(): Builder
+    {
+        return Discussion::withTrashed()
+            ->whereNull('parent')
+            ->whereIn('type', Discussion::COMMENT_TYPES)
+            ->where(fn ($q) => $q->whereNull('deleted_at')->orWhereHas('replies'))
+            ->with(self::relations());
+    }
+
+    /**
+     * 列表上的可见性过滤。口径见类注释。
+     *
+     * @param  Builder<Discussion>  $query
+     */
+    private function applyVisibility(Builder $query, ?string $viewerUid, string $targetType, string $targetId): void
+    {
+        if ($viewerUid === null) {
+            $query->whereNotIn('type', self::COLLABORATION_TYPES);
+
+            return;
+        }
+        if ($this->isBasic($viewerUid) && ! $this->canManageTarget($viewerUid, $targetType, $targetId)) {
+            $query->where(fn ($q) => $q->whereNotIn('type', self::COLLABORATION_TYPES)
+                ->orWhere('editor_uid', $viewerUid));
+        }
+    }
+
+    /**
+     * 单条话题的可见性。看不见就当作不存在(404),不暴露它的存在。
+     */
+    private function assertVisible(Discussion $topic, ?string $viewerUid): void
+    {
+        if (! in_array($topic->type, self::COLLABORATION_TYPES, true)) {
+            return;
+        }
+        if ($viewerUid === null) {
+            abort(404);
+        }
+        if ($topic->editor_uid !== $viewerUid
+            && $this->isBasic($viewerUid)
+            && ! $this->canManageTarget($viewerUid, $topic->res_type, $topic->res_id)) {
+            abort(404);
+        }
+    }
+
+    /**
+     * 评论线程与批注以外的记录(如任务状态日志)不归 v3 discussions 管。
+     */
+    private function assertComment(Discussion $discussion): void
+    {
+        if (! in_array($discussion->type, Discussion::COMMENT_TYPES, true)) {
+            abort(404);
+        }
+    }
+
+    /**
+     * 当前用户能在这个对象上发起哪些类型的话题。
+     *
+     * 协作类(discussion / issue)登录即可;qa / help 沿用 v2 `can_create` 的口径:
+     * 只能发在文章上,而且只有文章的编辑者能发;批注(note / commentary)只能加在句子上,
+     * 而且只有该句的管理者能加(v2 登录即可,v3 收紧)。
+     *
+     * @return list<string>
+     */
+    private function creatableTypes(?string $userUid, string $targetType, string $targetId): array
+    {
+        if ($userUid === null) {
+            return [];
+        }
+        $types = self::COLLABORATION_TYPES;
+        if ($targetType === 'article' && $this->canManageTarget($userUid, $targetType, $targetId)) {
+            array_push($types, 'qa', 'help');
+        }
+        // 批注注入阅读页、所有读者可见,只有该句的管理者能加
+        if ($targetType === 'sentence' && $this->canManageTarget($userUid, $targetType, $targetId)) {
+            array_push($types, Discussion::TYPE_NOTE, Discussion::TYPE_COMMENTARY);
+        }
+
+        return $types;
+    }
+
+    /**
+     * 当前用户对一条记录能做的动作。前端按它决定显示哪些菜单,后端按它放行。
+     *
+     * @return array{update: bool, delete: bool, close: bool, reply: bool}
+     */
+    private function abilities(Discussion $discussion, ?string $viewerUid): array
+    {
+        $none = ['update' => false, 'delete' => false, 'close' => false, 'reply' => false];
+        if ($viewerUid === null || $discussion->trashed()) {
+            return $none;
+        }
+        $isAuthor = $discussion->editor_uid === $viewerUid;
+        $canManage = $isAuthor || $this->canManageTarget($viewerUid, $discussion->res_type, $discussion->res_id);
+        $isTopic = $discussion->parent === null;
+        if ($this->isAnnotation($discussion)) {
+            // 批注没有开关;回复与评论线程一样,登录即可
+            return [
+                'update' => $canManage,
+                'delete' => $canManage,
+                'close' => false,
+                'reply' => $isTopic && $this->canReply($viewerUid, $discussion),
+            ];
+        }
+
+        return [
+            'update' => $canManage,
+            // 作者,或挂靠对象的管理者(审核别人的评论)。v2 只允许作者删,v3 放开:
+            // 删除是软删,删错了可以恢复
+            'delete' => $canManage,
+            'close' => $isTopic && $canManage,
+            'reply' => $isTopic && $discussion->status === 'active' && $this->canReply($viewerUid, $discussion),
+        ];
+    }
+
+    /**
+     * 能否回复:登录即可;文章上的 qa 只有文章编辑者能答(沿用 v2 `can_reply` 的口径)。
+     */
+    private function canReply(?string $userUid, Discussion $topic): bool
+    {
+        if ($userUid === null) {
+            return false;
+        }
+        if ($topic->type === 'qa' && $topic->res_type === 'article') {
+            return $this->canManageTarget($userUid, 'article', $topic->res_id);
+        }
+
+        return true;
+    }
+
+    /**
+     * 能否管理挂靠的对象:句子与逐词看所在 channel 的编辑权,文章看文章的编辑权,
+     * 其它对象(术语、任务)没有「管理者」这一说,只有作者本人。
+     */
+    private function canManageTarget(string $userUid, string $targetType, string $targetId): bool
+    {
+        $key = "{$userUid}|{$targetType}|{$targetId}";
+        if (array_key_exists($key, $this->managers)) {
+            return $this->managers[$key];
+        }
+
+        $result = match ($targetType) {
+            'sentence', 'wbw' => ($channel = $this->channelOf($targetType, $targetId)) !== null
+                && in_array($channel, $this->editableChannels($userUid), true),
+            'article' => $this->canEditArticle($userUid, $targetId),
+            default => false,
+        };
+
+        return $this->managers[$key] = $result;
+    }
+
+    /**
+     * 句子 / 逐词所在的 channel。
+     */
+    private function channelOf(string $targetType, string $targetId): ?string
+    {
+        if ($targetType === 'sentence') {
+            return Sentence::where('uid', $targetId)->value('channel_uid');
+        }
+        $blockUid = Wbw::where('uid', $targetId)->value('block_uid');
+
+        return $blockUid ? WbwBlock::where('uid', $blockUid)->value('channel_uid') : null;
+    }
+
+    /**
+     * @return array<int, string>
+     */
+    private function editableChannels(string $userUid): array
+    {
+        return $this->editableChannels[$userUid] ??= ChannelApi::getCanEditByUser($userUid);
+    }
+
+    /**
+     * 文章编辑权:所有者、文章协作者(power ≥ 20),或文章所在的同主人文集的协作者。
+     * 与 v2 `ArticleController::userCanEdit()` 同口径,v3 不去依赖 v2 的控制器。
+     */
+    private function canEditArticle(string $userUid, string $articleUid): bool
+    {
+        $article = Article::where('uid', $articleUid)->first(['uid', 'owner']);
+        if (! $article) {
+            return false;
+        }
+        if ($article->owner === $userUid || ShareApi::getResPower($userUid, $article->uid) >= 20) {
+            return true;
+        }
+        $collections = ArticleCollectionOwner::whereIn(
+            'uid',
+            ArticleCollection::where('article_id', $article->uid)->select('collect_id')
+        )->where('owner', $article->owner)->pluck('uid');
+
+        return $collections->contains(fn ($uid) => ShareApi::getResPower($userUid, $uid) >= 20);
+    }
+
+    private function isBasic(string $userUid): bool
+    {
+        return in_array('basic', (array) ($this->users->byUuid($userUid)['roles'] ?? []), true);
+    }
+
+    private function targetExists(string $targetType, string $targetId): bool
+    {
+        return match ($targetType) {
+            'sentence' => Sentence::where('uid', $targetId)->exists(),
+            'wbw' => Wbw::where('uid', $targetId)->exists(),
+            'article' => Article::where('uid', $targetId)->exists(),
+            'term' => DhammaTerm::where('guid', $targetId)->exists(),
+            'task' => Task::where('id', $targetId)->exists(),
+            default => false,
+        };
+    }
+
+    /**
+     * 写操作之后重新读出完整资源(含墓碑),挂好关系与权限。
+     *
+     * 保留 wasRecentlyCreated:Laravel 的 ResourceResponse 据此把新建的响应设为 201,
+     * 控制器不用手写状态码。
+     */
+    private function fresh(Discussion $discussion, string $userUid): Discussion
+    {
+        $fresh = Discussion::withTrashed()->with(self::relations())->findOrFail($discussion->id);
+        $fresh->wasRecentlyCreated = $discussion->wasRecentlyCreated;
+        if ($fresh->parent === null) {
+            $fresh->loadCount('replies as reply_count');
+        }
+        $this->decorate(collect([$fresh]), $userUid);
+
+        return $fresh;
+    }
+
+    /**
+     * 给一批记录挂上权限与正文渲染结果。
+     *
+     * 只有挂在句子上的才渲染:正文里的模板要用句子所在的 channel 去解析。
+     * channel 一次批量查出,不逐行查。
+     *
+     * @param  Collection<int, Discussion>  $items
+     */
+    private function decorate(Collection $items, ?string $viewerUid): void
+    {
+        $sentenceIds = $items->where('res_type', 'sentence')->pluck('res_id')->unique()->values();
+        $channels = $sentenceIds->isEmpty()
+            ? collect()
+            : Sentence::whereIn('uid', $sentenceIds)->pluck('channel_uid', 'uid');
+
+        foreach ($items as $item) {
+            $item->abilities = $this->abilities($item, $viewerUid);
+            $channel = $item->res_type === 'sentence' ? ($channels[$item->res_id] ?? null) : null;
+            if ($channel && ! $item->trashed() && $item->content) {
+                $item->rendered = [
+                    'html' => MdRender::render($item->content, [$channel], null, 'read'),
+                    'summary' => MdRender::render($item->content, [$channel], null, 'read', 'translation', 'markdown', 'text'),
+                ];
+            }
+        }
+    }
+
+    /**
+     * 推给 mq:discussion 工作进程(站内信 + webhook)。
+     *
+     * 载荷沿用 v2 `DiscussionResource` 的形状:那是工作进程读的消息契约
+     * (读 editor.nickName、parent、res_type…),不是 v2 的 HTTP 契约。
+     */
+    private function notify(Discussion $discussion): void
+    {
+        Mq::publish('discussion', new MqDiscussionPayload($discussion));
+    }
+}

+ 30 - 0
api-v13/database/migrations/2026_10_13_100000_add_severity_in_discussions.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * 给 discussions 增加严重/紧急程度 severity,取值随 type 而定:
+     * - type=issue → error / warning / info
+     * - type=help  → urgent / normal
+     *
+     * nullable:存量数据与 dashboard-v4(v2 写入)都不会带这个值。
+     */
+    public function up(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->string('severity', 16)->nullable()->index()
+                ->comment('严重/紧急程度,取值随 type 而定');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->dropColumn('severity');
+        });
+    }
+};

+ 29 - 0
api-v13/database/migrations/2026_10_15_100000_add_deleted_at_in_discussions.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * discussions 改为软删除(v3 评论线程重构,2026-10-15 用户同意)。
+     *
+     * 删除话题时保留这一行做「墓碑」:内容不再返回,回复原样保留,
+     * 与 Google Drive 的 comments.deleted、GitHub 的 deletedAt、Discourse 的软删一致。
+     * 通知里存着讨论 id,行还在,链接就还能解析。
+     */
+    public function up(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->softDeletes()->index();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->dropSoftDeletes();
+        });
+    }
+};

+ 30 - 0
api-v13/database/migrations/2026_10_15_110000_add_co_editor_uid_in_discussions.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * 给 discussions 增加协作者 co_editor_uid:人类授权 AI 模型代写时,editor_uid 记人类
+     * (对内容负责的人),co_editor_uid 记模型(ai_models.uid)。仿 git 的 Co-Authored-By。
+     *
+     * nullable:人类自己写的、存量数据、v2 写入的都没有协作者。存量里 AI 写的行
+     * editor_uid 直接是模型 uid,不回填。
+     */
+    public function up(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->uuid('co_editor_uid')->nullable()->index()
+                ->comment('协作者:代写的 AI 模型 uid(ai_models.uid)');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('discussions', function (Blueprint $table) {
+            $table->dropColumn('co_editor_uid');
+        });
+    }
+};

+ 20 - 0
api-v13/resources/lang/en/messages.php

@@ -17,4 +17,24 @@ return [
     'sign_up_code_invalid' => 'The verification code is incorrect or has expired.',
     'invite_invalid' => 'This invitation is invalid or has already been used.',
 
+    'discussion_closed' => 'This topic is closed and no longer accepts replies.',
+    'discussion_reply_to_reply' => 'You cannot reply to a reply. Reply to the topic instead.',
+    'discussion_target_not_found' => 'The item this discussion is attached to does not exist.',
+    'discussion_topic_only' => 'Replies cannot have this field.',
+    'discussion_forbidden' => 'You do not have permission to do this.',
+    'discussion_severity_not_allowed' => 'This severity does not match the discussion type.',
+    'discussion_annotation_target' => 'Annotations can only be attached to sentences.',
+    'discussion_annotation_no_status' => 'Annotations cannot be closed or reopened.',
+    'discussion_type_group' => 'A comment thread and an annotation cannot be converted into each other.',
+    'discussion_commentary_format' => 'Commentary content must be sentence templates such as {{book-para-start-end}}.',
+    'discussion_anchor_thread' => 'Only annotations can be anchored to a text position.',
+    'agent_grant_by_model' => 'Only a person can grant an AI model permission to write.',
+    'agent_grant_model_not_found' => 'AI model not found.',
+    'agent_grant_not_owner' => 'This AI model does not belong to you.',
+    'agent_grant_no_power' => 'You cannot edit this channel, so you cannot grant it to an AI model.',
+    'agent_grant_human' => 'Only an AI model can use an agent grant.',
+    'agent_grant_required' => 'An AI model needs a grant from a person (X-Agent-Grant) to write.',
+    'agent_grant_invalid' => 'The agent grant is invalid, expired or revoked.',
+    'agent_grant_scope' => 'The agent grant does not cover this content.',
+
 ];

+ 20 - 0
api-v13/resources/lang/zh-Hans/messages.php

@@ -17,4 +17,24 @@ return [
     'sign_up_code_invalid' => '验证码不正确或已过期。',
     'invite_invalid' => '邀请无效或已被使用。',
 
+    'discussion_closed' => '话题已关闭,不能再回复。',
+    'discussion_reply_to_reply' => '不能回复一条回复,请回复它所在的话题。',
+    'discussion_target_not_found' => '讨论挂靠的对象不存在。',
+    'discussion_topic_only' => '回复不能设置这个字段。',
+    'discussion_forbidden' => '你没有权限执行这个操作。',
+    'discussion_severity_not_allowed' => '严重程度与讨论类型不匹配。',
+    'discussion_annotation_target' => '批注只能挂在句子上。',
+    'discussion_annotation_no_status' => '批注没有关闭 / 打开状态。',
+    'discussion_type_group' => '评论线程与批注之间不能互相转换。',
+    'discussion_commentary_format' => '义注的内容必须是句子模板,如 {{book-para-start-end}}。',
+    'discussion_anchor_thread' => '只有批注能锚定到文本位置。',
+    'agent_grant_by_model' => '只有人类能给 AI 模型授权写入。',
+    'agent_grant_model_not_found' => 'AI 模型不存在。',
+    'agent_grant_not_owner' => '这个 AI 模型不属于你。',
+    'agent_grant_no_power' => '你不能编辑这个 channel,也就不能把它授权给 AI 模型。',
+    'agent_grant_human' => '写入授权只能由 AI 模型使用。',
+    'agent_grant_required' => 'AI 模型写入需要人类的授权(X-Agent-Grant)。',
+    'agent_grant_invalid' => '写入授权无效、已过期或已撤销。',
+    'agent_grant_scope' => '写入授权不覆盖这条内容。',
+
 ];

+ 6 - 0
api-v13/routes/v3/auth.php

@@ -9,6 +9,7 @@
 |
 */
 
+use App\Http\Controllers\V3\AgentGrantController;
 use App\Http\Controllers\V3\EmailCertificationController;
 use App\Http\Controllers\V3\InviteController;
 use App\Http\Controllers\V3\MeController;
@@ -51,3 +52,8 @@ Route::post('sessions', [SessionController::class, 'store'])
 Route::get('me', [MeController::class, 'show'])
     ->middleware('auth.v3')
     ->name('me.show');
+
+// agent 协作:人类授权自己名下的 AI 模型代写某个 channel(见 AgentGrantService)
+Route::post('agent-grants', [AgentGrantController::class, 'store'])
+    ->middleware('auth.v3')
+    ->name('agent-grants.store');

+ 30 - 1
api-v13/routes/v3/interaction.php

@@ -7,10 +7,14 @@
 |
 | 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
 |
-| 将来 discussion、notification、tag 也落在这个文件。
+| 将来 notification、tag 也落在这个文件。
 |
 */
 
+use App\Http\Controllers\V3\DiscussionBatchCountController;
+use App\Http\Controllers\V3\DiscussionController;
+use App\Http\Controllers\V3\DiscussionReplyController;
+use App\Http\Controllers\V3\DiscussionSummaryController;
 use App\Http\Controllers\V3\MeReactionController;
 use App\Http\Controllers\V3\ReactionController;
 use App\Http\Controllers\V3\ReactionTallyController;
@@ -30,3 +34,28 @@ Route::apiResource('reactions', ReactionController::class)->only(['index']);
 Route::prefix('me')->as('me.')->middleware('auth.v3')->group(function () {
     Route::apiResource('reactions', MeReactionController::class)->only(['index', 'store', 'destroy']);
 });
+
+// discussions(评论线程:话题 + 一层回复)。批注(note / commentary)同表但不归这里管。
+
+// summary 要注册在 apiResource 之前,否则 {discussion} 会把它当成 uuid 吃掉
+Route::get('discussions/summary', DiscussionSummaryController::class);
+// 一批对象(句子 / 逐词)的讨论角标。POST:一页几百个 uid 放不进查询串
+Route::post('discussions/batch-count', DiscussionBatchCountController::class);
+
+// 读公开(可选登录),写要登录。show 与回复列表要能读到墓碑(软删了但还挂着回复的话题)。
+// 只注册 PATCH,不注册 PUT:v3 的更新只做局部更新(v3-resource skill 硬规范第 10 条)
+Route::apiResource('discussions', DiscussionController::class)
+    ->except(['update'])
+    ->middlewareFor(['store', 'destroy'], 'auth.v3')
+    ->withTrashed(['show'])
+    ->whereUuid('discussion');
+Route::patch('discussions/{discussion}', [DiscussionController::class, 'update'])
+    ->middleware('auth.v3')
+    ->whereUuid('discussion');
+
+Route::get('discussions/{discussion}/replies', [DiscussionReplyController::class, 'index'])
+    ->withTrashed()
+    ->whereUuid('discussion');
+Route::post('discussions/{discussion}/replies', [DiscussionReplyController::class, 'store'])
+    ->middleware('auth.v3')
+    ->whereUuid('discussion');

+ 189 - 0
api-v13/tests/Feature/AgentGrantV3Test.php

@@ -0,0 +1,189 @@
+<?php
+
+use App\Models\AiModel;
+use App\Models\Discussion;
+use App\Services\AuthService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/*
+ * agent 协作:人类签发授权(POST /v3/agent-grants),模型凭模型 token + X-Agent-Grant 代写。
+ * 代写的记录 editor = 授权人、co_editor = 模型,权限按授权人判、范围限授权的 channel / book。
+ */
+
+/** 模型身份的请求头;$grant 给了就带上授权 */
+function modelHeader(AiModel $model, ?string $grant = null): array
+{
+    $headers = ['Authorization' => 'Bearer '.AuthService::getUserToken($model->uid)];
+    if ($grant !== null) {
+        $headers['X-Agent-Grant'] = $grant;
+    }
+
+    return $headers;
+}
+
+/** 人类为自己的模型在 channel 上签一张授权,返回 token */
+function grantFor(string $human, AiModel $model, string $channel, int $book = 0): string
+{
+    return test()->postJson('/api/v3/agent-grants', [
+        'ai_model' => $model->uid, 'target_type' => 'channel', 'target_id' => $channel, 'book' => $book,
+    ], authHeader($human))->assertCreated()->json('data.token');
+}
+
+/* ------------------------------------------------------------------ 签发 */
+
+it('issues a grant bound to the model, the channel and the grantor', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    $this->postJson('/api/v3/agent-grants', [
+        'ai_model' => $model->uid, 'target_type' => 'channel', 'target_id' => $channel, 'book' => 3,
+    ], authHeader($human))
+        ->assertCreated()
+        ->assertJsonPath('data.grantor', $human)
+        ->assertJsonPath('data.ai_model', $model->uid)
+        ->assertJsonPath('data.target_id', $channel)
+        ->assertJsonPath('data.book', 3)
+        ->assertJsonStructure(['data' => ['token', 'expires_at']]);
+});
+
+it('refuses to issue a grant without the right to do so', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+    $body = ['ai_model' => $model->uid, 'target_type' => 'channel', 'target_id' => $channel];
+
+    $this->postJson('/api/v3/agent-grants', $body)->assertUnauthorized();
+    $this->postJson('/api/v3/agent-grants', $body, modelHeader($model))->assertForbidden();           // 模型不能给自己授权
+    $this->postJson('/api/v3/agent-grants', $body, authHeader(makeStudio('stranger')))->assertForbidden(); // 模型不是他的
+    $other = makeChannel(makeStudio('other'));
+    $this->postJson('/api/v3/agent-grants', [...$body, 'target_id' => $other], authHeader($human))->assertForbidden(); // channel 编不了
+    $this->postJson('/api/v3/agent-grants', [...$body, 'ai_model' => (string) Str::uuid()], authHeader($human))
+        ->assertStatus(422)->assertJsonValidationErrors('ai_model');
+    $this->postJson('/api/v3/agent-grants', [...$body, 'target_type' => 'article'], authHeader($human))
+        ->assertStatus(422)->assertJsonValidationErrors('target_type');
+});
+
+/* ------------------------------------------------------------------ 代写 */
+
+it('records the grantor as editor and the model as co-editor', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
+    $model = AiModel::factory()->ownedBy($human)->create();
+    $grant = grantFor($human, $model, $channel);
+
+    // 批注:只有句子的管理者能建——按授权人判,所以模型能写进来
+    $note = $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'commentary', 'content' => '{{1-2-3-4}}',
+    ], modelHeader($model, $grant))
+        ->assertCreated()
+        ->assertJsonPath('data.editor.id', $human)
+        ->assertJsonPath('data.co_editor.id', $model->uid)
+        ->json('data');
+
+    $this->postJson("/api/v3/discussions/{$note['id']}/replies", ['content' => '补充', 'notify' => false], modelHeader($model, $grant))
+        ->assertCreated()
+        ->assertJsonPath('data.co_editor.id', $model->uid);
+    $this->patchJson("/api/v3/discussions/{$note['id']}", ['pos_end' => 2], modelHeader($model, $grant))->assertOk();
+
+    expect(Discussion::find($note['id']))
+        ->editor_uid->toBe($human)
+        ->co_editor_uid->toBe($model->uid);
+
+    // 人类自己写的没有协作者
+    $this->getJson("/api/v3/discussions/{$note['id']}")->assertJsonPath('data.co_editor.id', $model->uid);
+    $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence', 'target_id' => $sentence, 'title' => 't', 'notify' => false,
+    ], authHeader($human))->assertCreated()->assertJsonPath('data.co_editor', null);
+});
+
+it('rejects agent writes that are not covered by a valid grant', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
+    $model = AiModel::factory()->ownedBy($human)->create();
+    $body = ['target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'note', 'content' => 'x'];
+
+    $this->postJson('/api/v3/discussions', $body, modelHeader($model))->assertForbidden();             // 没授权
+    $this->postJson('/api/v3/discussions', $body, modelHeader($model, 'garbage'))->assertForbidden();  // 伪造
+
+    $otherChannel = makeChannel($human, 'other');
+    $this->postJson('/api/v3/discussions', $body, modelHeader($model, grantFor($human, $model, $otherChannel)))
+        ->assertForbidden();                                                                           // 别的 channel
+    $this->postJson('/api/v3/discussions', $body, modelHeader($model, grantFor($human, $model, $channel, 2)))
+        ->assertForbidden();                                                                           // 别的 book
+
+    $another = AiModel::factory()->ownedBy($human)->create();
+    $this->postJson('/api/v3/discussions', $body, modelHeader($another, grantFor($human, $model, $channel)))
+        ->assertForbidden();                                                                           // 授权给的不是它
+
+    // 人类带授权:授权只给模型用
+    $this->postJson('/api/v3/discussions', $body, [...authHeader($human), 'X-Agent-Grant' => grantFor($human, $model, $channel)])
+        ->assertStatus(422);
+});
+
+it('does not accept a v2 access token as a grant', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
+    $model = AiModel::factory()->ownedBy($human)->create();
+    // v2 的载荷由客户端决定,伪造 grantor / act 也签得出来
+    $v2 = $this->postJson('/api/v2/access-token', ['payload' => [[
+        'res_type' => 'channel', 'res_id' => $channel, 'power' => 'edit', 'book' => 0,
+        'typ' => 'agent-grant', 'grantor' => $human, 'act' => $model->uid, 'ver' => 0,
+    ]]], authHeader($human))->json('data.rows.0.token');
+
+    $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'note', 'content' => 'x',
+    ], modelHeader($model, $v2))->assertForbidden();
+});
+
+it('voids grants when the model token is revoked', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
+    $model = AiModel::factory()->ownedBy($human)->create();
+    $grant = grantFor($human, $model, $channel);
+
+    $model->increment('token_version');   // 撤销:v2 DELETE /v2/ai-model-token 做的就是这个
+
+    $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'note', 'content' => 'x',
+    ], modelHeader($model->fresh(), $grant))->assertForbidden();
+});
+
+it('lets the grantor, not the model, own what the model wrote', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
+    $model = AiModel::factory()->ownedBy($human)->create();
+    $id = $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence', 'target_id' => $sentence, 'title' => 'by agent', 'notify' => false,
+    ], modelHeader($model, grantFor($human, $model, $channel)))->json('data.id');
+
+    $this->getJson("/api/v3/discussions/{$id}", authHeader($human))
+        ->assertJsonPath('data.abilities.update', true)
+        ->assertJsonPath('data.abilities.delete', true);
+    $this->deleteJson("/api/v3/discussions/{$id}", [], authHeader($human))->assertOk();
+});
+
+it('clears the co-editor when a person edits the content, but not on a status change', function () {
+    $human = makeStudio('owner');
+    $channel = makeChannel($human);
+    $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
+    $model = AiModel::factory()->ownedBy($human)->create();
+    $grant = grantFor($human, $model, $channel);
+    $id = $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence', 'target_id' => $sentence, 'title' => 'by agent', 'notify' => false,
+    ], modelHeader($model, $grant))->json('data.id');
+
+    $this->patchJson("/api/v3/discussions/{$id}", ['status' => 'close'], authHeader($human))
+        ->assertJsonPath('data.co_editor.id', $model->uid);    // 管理动作,不动署名
+    $this->patchJson("/api/v3/discussions/{$id}", ['content' => '人改过'], authHeader($human))
+        ->assertJsonPath('data.co_editor', null);              // 人亲自改了内容
+    $this->patchJson("/api/v3/discussions/{$id}", ['content' => 'AI 又改'], modelHeader($model, $grant))
+        ->assertJsonPath('data.co_editor.id', $model->uid);    // AI 代改,换回模型
+});

+ 612 - 0
api-v13/tests/Feature/DiscussionV3Test.php

@@ -0,0 +1,612 @@
+<?php
+
+use App\Models\Discussion;
+use App\Models\UserInfo;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v3 discussions:评论线程(话题 + 一层回复),底层是 discussions 表。
+ *
+ * 这里断言的是形状而非契约(spectator 未安装,见 v3-resource skill 第 3 步)。
+ *
+ * 端点:
+ *   GET    /api/v3/discussions?target_type=&target_id=     话题列表(可选登录)
+ *   GET    /api/v3/discussions/summary?target_type=&target_id=  概况(可选登录)
+ *   POST   /api/v3/discussions                              发起话题(登录)
+ *   GET    /api/v3/discussions/{d}                          单条(可选登录,含墓碑)
+ *   PATCH  /api/v3/discussions/{d}                          局部更新(登录)
+ *   DELETE /api/v3/discussions/{d}                          软删除(登录)
+ *   GET    /api/v3/discussions/{d}/replies                  回复列表(可选登录)
+ *   POST   /api/v3/discussions/{d}/replies                  回复(登录)
+ */
+function makeDiscussion(array $attrs = []): Discussion
+{
+    return Discussion::forceCreate(array_merge([
+        'res_type' => 'sentence',
+        'res_id' => (string) Str::uuid(),
+        'type' => 'discussion',
+        'title' => 'topic',
+        'content' => 'body',
+        'editor_uid' => makeStudio('d-'.Str::random(8)),
+    ], $attrs));
+}
+
+/**
+ * 一个句子目标:channel 归 $owner 所有,返回句子 uid。
+ */
+function makeTargetSentence(string $owner): string
+{
+    return makeSentence(makeChannel($owner), 1, 1, 1, 'sentence text')->uid;
+}
+
+function makeBasicUser(string $name): string
+{
+    $uid = makeStudio($name);
+    UserInfo::where('userid', $uid)->update(['role' => json_encode(['basic'])]);
+
+    return $uid;
+}
+
+/* ------------------------------------------------------------------ 列表 */
+
+it('lists only topics of the target, with reply counts and abilities', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $topic = makeDiscussion(['res_id' => $target, 'editor_uid' => $owner]);
+    makeDiscussion(['res_id' => $target, 'parent' => $topic->id, 'title' => null]);
+    makeDiscussion(['res_id' => $target, 'type' => 'commentary']);   // 不带 type 时不列批注
+    makeDiscussion();                                                 // 别的对象
+
+    $json = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($owner))
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toHaveCount(1)
+        ->and($json['data'][0])->toHaveKeys(['id', 'target_type', 'target_id', 'parent_id', 'type', 'severity', 'status', 'editor', 'abilities', 'deleted_at'])
+        ->and($json['data'][0]['reply_count'])->toBe(1)
+        ->and($json['data'][0]['abilities'])->toBe(['update' => true, 'delete' => true, 'close' => true, 'reply' => true])
+        ->and($json['meta']['total'])->toBe(1);
+});
+
+it('hides collaboration topics from anonymous viewers', function () {
+    $target = makeTargetSentence(makeStudio('owner'));
+    makeDiscussion(['res_id' => $target, 'type' => 'discussion']);
+    makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'error']);
+    makeDiscussion(['res_id' => $target, 'type' => 'qa']);
+
+    $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}")
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(1)
+        ->and($data[0]['type'])->toBe('qa')
+        ->and($data[0]['abilities']['reply'])->toBeFalse();
+});
+
+it('shows a basic user only their own collaboration topics on targets they cannot manage', function () {
+    $target = makeTargetSentence(makeStudio('owner'));
+    $basic = makeBasicUser('basic');
+    makeDiscussion(['res_id' => $target, 'editor_uid' => $basic, 'title' => 'mine']);
+    $others = makeDiscussion(['res_id' => $target, 'title' => 'others']);
+
+    $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($basic))
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(1)
+        ->and($data[0]['title'])->toBe('mine');
+
+    // 单条与回复列表守同一条规则(v2 只在列表上判断)
+    $this->getJson("/api/v3/discussions/{$others->id}", authHeader($basic))->assertNotFound();
+    $this->getJson("/api/v3/discussions/{$others->id}/replies", authHeader($basic))->assertNotFound();
+});
+
+it('lets a basic user see all topics on a target they can manage', function () {
+    $basic = makeBasicUser('basic-owner');
+    $target = makeTargetSentence($basic);
+    makeDiscussion(['res_id' => $target]);
+    makeDiscussion(['res_id' => $target]);
+
+    $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($basic))
+        ->assertOk()
+        ->assertJsonCount(2, 'data');
+});
+
+it('filters topics by type, status and severity', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'error']);
+    makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'info']);
+    makeDiscussion(['res_id' => $target, 'type' => 'issue', 'severity' => 'warning', 'status' => 'close']);
+    makeDiscussion(['res_id' => $target]);
+
+    $base = "/api/v3/discussions?target_type=sentence&target_id={$target}";
+    $this->getJson("{$base}&type=issue", authHeader($owner))->assertJsonCount(3, 'data');
+    $this->getJson("{$base}&type=issue&status=active", authHeader($owner))->assertJsonCount(2, 'data');
+    $this->getJson("{$base}&severity=error,warning", authHeader($owner))->assertJsonCount(2, 'data');
+});
+
+it('rejects an invalid list query', function () {
+    $target = (string) Str::uuid();
+
+    $this->getJson('/api/v3/discussions?target_type=sentence')->assertStatus(422);
+    $this->getJson("/api/v3/discussions?target_type=chapter&target_id={$target}")->assertStatus(422);
+    $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&type=feedback")->assertStatus(422);
+    $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&per_page=1000")->assertStatus(422);
+    $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}&sort=title")->assertStatus(422);
+});
+
+it('keeps a deleted topic with replies as a tombstone and drops one without replies', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $withReplies = makeDiscussion(['res_id' => $target, 'title' => 'kept']);
+    makeDiscussion(['res_id' => $target, 'parent' => $withReplies->id, 'title' => null]);
+    $withReplies->delete();
+    makeDiscussion(['res_id' => $target, 'title' => 'gone'])->delete();
+
+    $data = $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", authHeader($owner))
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(1)
+        ->and($data[0]['id'])->toBe($withReplies->id)
+        ->and($data[0]['deleted_at'])->not->toBeNull()
+        ->and($data[0]['title'])->toBeNull()
+        ->and($data[0]['content'])->toBeNull()
+        ->and($data[0]['abilities'])->toBe(['update' => false, 'delete' => false, 'close' => false, 'reply' => false]);
+
+    // 墓碑的回复照样可读
+    $this->getJson("/api/v3/discussions/{$withReplies->id}/replies", authHeader($owner))
+        ->assertOk()
+        ->assertJsonCount(1, 'data');
+});
+
+it('loads a page of topics with a constant number of queries', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $countQueriesFor = function (int $rows) use ($owner, $target) {
+        Discussion::where('res_id', $target)->forceDelete();
+        foreach (range(1, $rows) as $i) {
+            makeDiscussion(['res_id' => $target, 'editor_uid' => makeStudio("n1-{$rows}-{$i}")]);
+        }
+        $headers = authHeader($owner);
+        DB::flushQueryLog();
+        DB::enableQueryLog();
+        $this->getJson("/api/v3/discussions?target_type=sentence&target_id={$target}", $headers)->assertOk();
+        $count = count(DB::getQueryLog());
+        DB::disableQueryLog();
+
+        return $count;
+    };
+
+    // 先热身一次:UserService 是 singleton,查看者自己的摘要只在第一次请求时查库
+    $countQueriesFor(1);
+
+    // 正文渲染(MdRender)是逐行的,内容不带模板时不查库;这里守的是编辑者与权限不逐行查
+    expect($countQueriesFor(12))->toBe($countQueriesFor(2));
+});
+
+/* ------------------------------------------------------------------ 概况 */
+
+it('summarises status counts and creatable types', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    makeDiscussion(['res_id' => $target]);
+    makeDiscussion(['res_id' => $target, 'status' => 'close']);
+    makeDiscussion(['res_id' => $target, 'status' => 'close']);
+    makeDiscussion(['res_id' => $target])->delete();
+
+    $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.status_counts', ['active' => 1, 'close' => 2])
+        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'note', 'commentary']);   // 句子的管理者能加批注
+
+    $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}")
+        ->assertOk()
+        ->assertJsonPath('data.status_counts', ['active' => 0, 'close' => 0])
+        ->assertJsonPath('data.creatable_types', []);
+});
+
+/* ------------------------------------------------------------------ 发起话题 */
+
+it('creates a topic', function () {
+    $user = makeStudio('author');
+    $target = makeTargetSentence(makeStudio('owner'));
+
+    $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence',
+        'target_id' => $target,
+        'type' => 'issue',
+        'title' => '译文漏了一句',
+        'content' => '第二句没有译出',
+        'severity' => 'error',
+        'notify' => false,
+    ], authHeader($user))
+        ->assertCreated()
+        ->assertJsonPath('data.type', 'issue')
+        ->assertJsonPath('data.severity', 'error')
+        ->assertJsonPath('data.parent_id', null)
+        ->assertJsonPath('data.reply_count', 0)
+        ->assertJsonPath('data.content_type', 'markdown');
+
+    expect(Discussion::where('editor_uid', $user)->value('res_id'))->toBe($target);
+});
+
+it('requires login to create a topic', function () {
+    $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence',
+        'target_id' => (string) Str::uuid(),
+        'title' => 't',
+    ])->assertUnauthorized();
+});
+
+it('validates severity against the topic type', function () {
+    $user = makeStudio('author');
+    $target = makeTargetSentence(makeStudio('owner'));
+    $payload = ['target_type' => 'sentence', 'target_id' => $target, 'title' => 't', 'notify' => false];
+
+    $this->postJson('/api/v3/discussions', $payload + ['type' => 'discussion', 'severity' => 'error'], authHeader($user))
+        ->assertStatus(422)->assertJsonValidationErrors('severity');
+    $this->postJson('/api/v3/discussions', $payload + ['type' => 'issue', 'severity' => 'urgent'], authHeader($user))
+        ->assertStatus(422)->assertJsonValidationErrors('severity');
+    $this->postJson('/api/v3/discussions', $payload + ['type' => 'help', 'severity' => 'urgent'], authHeader($user))
+        ->assertStatus(403);   // help 只能发在文章上
+});
+
+it('rejects a topic on a target that does not exist', function () {
+    $this->postJson('/api/v3/discussions', [
+        'target_type' => 'sentence',
+        'target_id' => (string) Str::uuid(),
+        'title' => 't',
+        'notify' => false,
+    ], authHeader(makeStudio('author')))
+        ->assertStatus(422)
+        ->assertJsonValidationErrors('target_id');
+});
+
+/* ------------------------------------------------------------------ 回复 */
+
+it('replies to a topic, inheriting its type and target', function () {
+    $topic = makeDiscussion(['res_id' => makeTargetSentence(makeStudio('owner')), 'type' => 'issue', 'severity' => 'warning']);
+    $user = makeStudio('replier');
+
+    $this->postJson("/api/v3/discussions/{$topic->id}/replies", ['content' => '已修正', 'notify' => false], authHeader($user))
+        ->assertCreated()
+        ->assertJsonPath('data.parent_id', $topic->id)
+        ->assertJsonPath('data.type', 'issue')
+        ->assertJsonPath('data.target_id', $topic->res_id)
+        ->assertJsonMissingPath('data.reply_count');
+
+    expect($topic->fresh()->children_count)->toBe(1);
+});
+
+it('lists replies oldest first with the topic type', function () {
+    $topic = makeDiscussion(['type' => 'qa']);
+    $first = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'discussion', 'created_at' => now()->subHour()]);
+    makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'qa']);
+
+    $data = $this->getJson("/api/v3/discussions/{$topic->id}/replies")
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(2)
+        ->and($data[0]['id'])->toBe($first->id)
+        ->and(array_column($data, 'type'))->toBe(['qa', 'qa']);   // 历史数据里回复的 type 可能与话题不一致
+});
+
+it('refuses to reply to a reply or to a closed topic', function () {
+    $user = makeStudio('replier');
+    $topic = makeDiscussion();
+    $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id]);
+    $closed = makeDiscussion(['status' => 'close']);
+
+    $this->postJson("/api/v3/discussions/{$reply->id}/replies", ['content' => 'x', 'notify' => false], authHeader($user))
+        ->assertStatus(422);
+    $this->postJson("/api/v3/discussions/{$closed->id}/replies", ['content' => 'x', 'notify' => false], authHeader($user))
+        ->assertStatus(409)
+        ->assertJsonPath('type', fn ($type) => str_contains($type, 'discussion-closed'));
+    $this->postJson("/api/v3/discussions/{$topic->id}/replies", ['notify' => false], authHeader($user))
+        ->assertStatus(422);
+});
+
+/* ------------------------------------------------------------------ 修改 */
+
+it('patches only the fields that were sent', function () {
+    $author = makeStudio('author');
+    $topic = makeDiscussion(['editor_uid' => $author, 'title' => 'keep me', 'status' => 'close']);
+
+    $this->patchJson("/api/v3/discussions/{$topic->id}", ['content' => 'new body'], authHeader($author))
+        ->assertOk()
+        ->assertJsonPath('data.content', 'new body')
+        ->assertJsonPath('data.title', 'keep me')
+        ->assertJsonPath('data.status', 'close');   // v2 的 PUT 在这里会把话题重新打开
+});
+
+it('lets the channel editor update someone else\'s topic', function () {
+    $owner = makeStudio('owner');
+    $topic = makeDiscussion(['res_id' => makeTargetSentence($owner)]);
+
+    $this->patchJson("/api/v3/discussions/{$topic->id}", ['status' => 'close'], authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.status', 'close');
+    $this->patchJson("/api/v3/discussions/{$topic->id}", ['content' => 'x'], authHeader(makeStudio('stranger')))
+        ->assertForbidden();
+});
+
+it('rejects topic-only fields on a reply', function () {
+    $author = makeStudio('author');
+    $topic = makeDiscussion();
+    $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'editor_uid' => $author]);
+
+    $this->patchJson("/api/v3/discussions/{$reply->id}", ['title' => 't'], authHeader($author))
+        ->assertStatus(422)
+        ->assertJsonValidationErrors('title');
+    $this->patchJson("/api/v3/discussions/{$reply->id}", ['content' => 'fixed'], authHeader($author))
+        ->assertOk();
+});
+
+it('converts a topic type, cascading to replies and clearing a severity the new type does not take', function () {
+    $author = makeStudio('author');
+    $topic = makeDiscussion(['editor_uid' => $author, 'type' => 'issue', 'severity' => 'error']);
+    $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'type' => 'issue']);
+
+    $this->patchJson("/api/v3/discussions/{$topic->id}", ['type' => 'discussion'], authHeader($author))
+        ->assertOk()
+        ->assertJsonPath('data.type', 'discussion')
+        ->assertJsonPath('data.severity', null);
+
+    expect($reply->fresh()->type)->toBe('discussion');
+
+    $this->patchJson("/api/v3/discussions/{$topic->id}", ['severity' => 'error'], authHeader($author))
+        ->assertStatus(422)
+        ->assertJsonValidationErrors('severity');
+});
+
+/* ------------------------------------------------------------------ 批量角标 */
+
+it('counts active threads per target in request order, with zeros', function () {
+    $owner = makeStudio('owner');
+    $a = makeTargetSentence($owner);
+    $b = makeTargetSentence($owner);
+    $empty = (string) Str::uuid();
+    makeDiscussion(['res_id' => $a, 'type' => 'qa', 'editor_uid' => $owner]);
+    makeDiscussion(['res_id' => $a, 'type' => 'help']);
+    makeDiscussion(['res_id' => $a, 'type' => 'qa', 'status' => 'close']);          // 关闭的不数
+    makeDiscussion(['res_id' => $a, 'type' => 'note']);                              // 批注不数
+    makeDiscussion(['res_id' => $a, 'type' => 'qa'])->delete();                      // 删除的不数
+    $topic = makeDiscussion(['res_id' => $b, 'type' => 'qa']);
+    makeDiscussion(['res_id' => $b, 'type' => 'qa', 'parent' => $topic->id]);        // 回复不数
+
+    $this->postJson('/api/v3/discussions/batch-count', ['targets' => [
+        ['type' => 'sentence', 'id' => $b],
+        ['type' => 'sentence', 'id' => $a],
+        ['type' => 'wbw', 'id' => $empty],
+    ]], authHeader($owner))
+        ->assertOk()
+        ->assertJsonCount(3, 'data')
+        ->assertJsonPath('data.0.target_id', $b)
+        ->assertJsonPath('data.0.total', 1)
+        ->assertJsonPath('data.0.mine', 0)
+        ->assertJsonPath('data.1.target_id', $a)
+        ->assertJsonPath('data.1.total', 2)
+        ->assertJsonPath('data.1.mine', 1)
+        ->assertJsonPath('data.2.target_type', 'wbw')
+        ->assertJsonPath('data.2.total', 0);
+});
+
+it('applies list visibility to batch counts', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $basic = makeBasicUser('basic');
+    makeDiscussion(['res_id' => $target, 'type' => 'qa']);
+    makeDiscussion(['res_id' => $target, 'type' => 'discussion']);
+    makeDiscussion(['res_id' => $target, 'type' => 'issue', 'editor_uid' => $basic]);
+    $body = ['targets' => [['type' => 'sentence', 'id' => $target]]];
+
+    $this->postJson('/api/v3/discussions/batch-count', $body)->assertJsonPath('data.0.total', 1);                       // 匿名:只数 qa
+    $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($basic))->assertJsonPath('data.0.total', 2);  // basic:qa + 自己的 issue
+    $this->postJson('/api/v3/discussions/batch-count', $body, authHeader($owner))->assertJsonPath('data.0.total', 3);  // 管理者:全部
+});
+
+it('rejects an invalid batch count request', function () {
+    $this->postJson('/api/v3/discussions/batch-count', ['targets' => []])->assertStatus(422);
+    $this->postJson('/api/v3/discussions/batch-count', ['targets' => [['type' => 'article', 'id' => (string) Str::uuid()]]])
+        ->assertStatus(422)->assertJsonValidationErrors('targets.0.type');
+    $tooMany = array_fill(0, 501, ['type' => 'sentence', 'id' => (string) Str::uuid()]);
+    $this->postJson('/api/v3/discussions/batch-count', ['targets' => $tooMany])->assertStatus(422);
+});
+
+it('counts a batch in a fixed number of queries', function () {
+    $owner = makeStudio('owner');
+    $targets = collect(range(1, 20))->map(function () use ($owner) {
+        $id = makeTargetSentence($owner);
+        makeDiscussion(['res_id' => $id, 'type' => 'qa']);
+
+        return ['type' => 'sentence', 'id' => $id];
+    })->all();
+    $this->postJson('/api/v3/discussions/batch-count', ['targets' => [$targets[0]]], authHeader($owner));   // 预热 UserService
+
+    DB::enableQueryLog();
+    $this->postJson('/api/v3/discussions/batch-count', ['targets' => $targets], authHeader($owner))->assertOk();
+    expect(count(DB::getQueryLog()))->toBeLessThan(5);
+});
+
+/* ------------------------------------------------------------------ 批注 */
+
+it('lists annotations only when asked for by type, and counts only threads by default', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    makeDiscussion(['res_id' => $target]);
+    $note = makeDiscussion(['res_id' => $target, 'type' => 'commentary', 'title' => null,
+        'content' => '{{135-404-50-54}}', 'pos_start' => 3, 'pos_end' => 8, 'quote_exact' => 'dukkh']);
+    $base = "/api/v3/discussions?target_type=sentence&target_id={$target}";
+
+    $this->getJson($base, authHeader($owner))->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'discussion');
+    $this->getJson("{$base}&type=commentary")   // 批注对匿名也可见
+        ->assertJsonCount(1, 'data')
+        ->assertJsonPath('data.0.id', $note->id)
+        ->assertJsonPath('data.0.pos_end', 8)
+        ->assertJsonPath('data.0.quote_exact', 'dukkh');
+    $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
+        ->assertJsonPath('data.status_counts.active', 1);
+    $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}&type=commentary")
+        ->assertJsonPath('data.status_counts.active', 1);
+});
+
+it('shows an annotation to anyone, without close', function () {
+    $owner = makeStudio('owner');
+    $note = makeDiscussion(['res_id' => makeTargetSentence($owner), 'type' => 'note', 'editor_uid' => $owner]);
+
+    $this->getJson("/api/v3/discussions/{$note->id}")->assertOk();
+    $this->getJson("/api/v3/discussions/{$note->id}", authHeader($owner))
+        ->assertJsonPath('data.abilities', ['update' => true, 'delete' => true, 'close' => false, 'reply' => true]);
+});
+
+it('lets only the sentence manager create annotations, without notifying', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $stranger = makeStudio('stranger');
+    $payload = ['target_type' => 'sentence', 'target_id' => $target, 'type' => 'commentary',
+        'content' => '{{135-404-50-54}} {{135-404-55-65}}', 'pos_start' => 0, 'pos_end' => 4, 'quote_exact' => 'sent'];
+
+    $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($owner))
+        ->assertJsonPath('data.creatable_types', ['discussion', 'issue', 'note', 'commentary']);
+    $this->getJson("/api/v3/discussions/summary?target_type=sentence&target_id={$target}", authHeader($stranger))
+        ->assertJsonPath('data.creatable_types', ['discussion', 'issue']);
+
+    $this->postJson('/api/v3/discussions', $payload, authHeader($stranger))->assertForbidden();
+
+    Log::spy();
+    $this->postJson('/api/v3/discussions', $payload, authHeader($owner))   // 没传 notify:批注本来就不发
+        ->assertCreated()
+        ->assertJsonPath('data.type', 'commentary')
+        ->assertJsonPath('data.title', null)
+        ->assertJsonPath('data.pos_end', 4);
+    Log::shouldNotHaveReceived('debug', fn (string $message) => $message === 'mq publish');
+});
+
+it('validates annotation input', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $post = fn (array $data) => $this->postJson('/api/v3/discussions',
+        ['target_type' => 'sentence', 'target_id' => $target, 'notify' => false, ...$data], authHeader($owner));
+
+    $post(['type' => 'commentary', 'content' => '这不是句子模板'])->assertStatus(422)->assertJsonValidationErrors('content');
+    $post(['type' => 'note'])->assertStatus(422)->assertJsonValidationErrors('content');
+    $post(['type' => 'note', 'content' => 'x', 'target_type' => 'article'])->assertStatus(422)->assertJsonValidationErrors('target_type');
+    $post(['type' => 'note', 'content' => 'x', 'pos_start' => 5, 'pos_end' => 2])->assertStatus(422)->assertJsonValidationErrors('pos_end');
+    $post(['type' => 'discussion', 'title' => 't', 'pos_start' => 1])->assertStatus(422)->assertJsonValidationErrors('pos_start');
+});
+
+it('keeps annotations and threads apart on update', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $note = makeDiscussion(['res_id' => $target, 'type' => 'note', 'title' => null, 'content' => 'gloss']);
+    $topic = makeDiscussion(['res_id' => $target]);
+    $patch = fn (Discussion $d, array $data) => $this->patchJson("/api/v3/discussions/{$d->id}", $data, authHeader($owner));
+
+    $patch($note, ['status' => 'close'])->assertStatus(422)->assertJsonValidationErrors('status');
+    $patch($note, ['type' => 'discussion'])->assertStatus(422)->assertJsonValidationErrors('type');
+    $patch($topic, ['type' => 'note'])->assertStatus(422)->assertJsonValidationErrors('type');
+    $patch($topic, ['pos_end' => 3])->assertStatus(422)->assertJsonValidationErrors('pos_end');
+    $patch($note, ['content' => ''])->assertStatus(422)->assertJsonValidationErrors('content');
+    $patch($note, ['type' => 'commentary'])->assertStatus(422)->assertJsonValidationErrors('content');   // 正文不是句子模板
+
+    $patch($note, ['type' => 'commentary', 'content' => '{{1-2-3-4}}', 'pos_end' => 6])
+        ->assertOk()
+        ->assertJsonPath('data.type', 'commentary')
+        ->assertJsonPath('data.pos_end', 6);
+});
+
+it('lets other users reply to an annotation, without injecting the reply into the reading page', function () {
+    $owner = makeStudio('owner');
+    $target = makeTargetSentence($owner);
+    $note = makeDiscussion(['res_id' => $target, 'type' => 'note', 'title' => null, 'content' => 'gloss', 'editor_uid' => $owner]);
+    $reader = makeStudio('reader');
+
+    $this->getJson("/api/v3/discussions/{$note->id}", authHeader($reader))->assertJsonPath('data.abilities.reply', true);
+    $this->postJson("/api/v3/discussions/{$note->id}/replies", ['content' => '请教出处', 'notify' => false], authHeader($reader))
+        ->assertCreated()
+        ->assertJsonPath('data.type', 'note')
+        ->assertJsonPath('data.parent_id', $note->id);
+
+    // 阅读页只注入批注本身:查询口径与 PaliContentService::injectAnnotationNotes 相同
+    expect(Discussion::where('res_type', 'sentence')->where('res_id', $target)
+        ->whereIn('type', Discussion::INJECTED_TYPES)->whereNull('parent')->pluck('id')->all())
+        ->toBe([$note->id]);
+});
+
+it('still hides records that are neither threads nor annotations', function () {
+    $log = makeDiscussion(['res_type' => 'task', 'type' => 'task-status']);
+
+    $this->getJson("/api/v3/discussions/{$log->id}")->assertNotFound();
+});
+
+/* ------------------------------------------------------------------ 删除 */
+
+it('soft deletes a topic for its author and returns the tombstone', function () {
+    $author = makeStudio('author');
+    $topic = makeDiscussion(['editor_uid' => $author]);
+
+    $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($author))
+        ->assertOk()
+        ->assertJsonPath('data.id', $topic->id)
+        ->assertJsonPath('data.content', null)
+        ->assertJsonPath('data.deleted_at', fn ($value) => $value !== null);
+
+    $this->assertSoftDeleted($topic);
+    // 没有回复的墓碑不再可读
+    $this->getJson("/api/v3/discussions/{$topic->id}")->assertNotFound();
+});
+
+it('decrements the topic reply count when a reply is deleted', function () {
+    $author = makeStudio('author');
+    $topic = makeDiscussion(['children_count' => 1]);
+    $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id, 'editor_uid' => $author]);
+
+    $this->deleteJson("/api/v3/discussions/{$reply->id}", [], authHeader($author))->assertOk();
+
+    $this->assertSoftDeleted($reply);
+    expect($topic->fresh()->children_count)->toBe(0);
+    $this->getJson("/api/v3/discussions/{$topic->id}", authHeader($author))->assertJsonPath('data.reply_count', 0);
+});
+
+it('lets the target manager delete someone else\'s reply and topic', function () {
+    $owner = makeStudio('owner');
+    $topic = makeDiscussion(['res_id' => makeTargetSentence($owner)]);
+    $reply = makeDiscussion(['parent' => $topic->id, 'res_id' => $topic->res_id]);
+
+    $this->getJson("/api/v3/discussions/{$reply->id}", authHeader($owner))
+        ->assertJsonPath('data.abilities.delete', true);
+    $this->deleteJson("/api/v3/discussions/{$reply->id}", [], authHeader($owner))->assertOk();
+    $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($owner))->assertOk();
+    $this->assertSoftDeleted($reply);
+    $this->assertSoftDeleted($topic);
+});
+
+it('forbids deleting for users who are neither author nor manager', function () {
+    $topic = makeDiscussion(['res_id' => makeTargetSentence(makeStudio('owner'))]);
+
+    $this->getJson("/api/v3/discussions/{$topic->id}", authHeader($stranger = makeStudio('stranger')))
+        ->assertJsonPath('data.abilities.delete', false);
+    $this->deleteJson("/api/v3/discussions/{$topic->id}", [], authHeader($stranger))->assertForbidden();
+    $this->deleteJson("/api/v3/discussions/{$topic->id}")->assertUnauthorized();
+});
+
+/* ------------------------------------------------------------------ v2 快照 */
+
+it('keeps the v2 delete response while soft deleting underneath', function () {
+    $author = makeStudio('author');
+    $topic = makeDiscussion(['editor_uid' => $author]);
+
+    $this->deleteJson("/api/v2/discussion/{$topic->id}", [], authHeader($author))
+        ->assertOk()
+        ->assertJsonPath('ok', true);
+
+    $this->assertSoftDeleted($topic);
+});