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

feat(api-v13): discussions 新增 glossary 类型(术语讨论);issue / glossary 可带锚点

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 15 часов назад
Родитель
Сommit
e150ef44bc

+ 21 - 16
api-v13/app/Http/Controllers/V3/DiscussionController.php

@@ -49,7 +49,7 @@ class DiscussionController extends Controller
      *
      * @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 type string 类型,不传则只列评论线程。Enum: discussion,qa,help,issue,glossary,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
@@ -74,22 +74,26 @@ class DiscussionController extends Controller
      *
      * 批注(note / commentary)只能加在句子上,且只有该句所在 channel 的编辑者能加;
      * 标题可省、正文必填,commentary 的正文必须是义注句子模板 `{{book-para-start-end}}`
-     * (可多句并列);可带锚点 pos_* / quote_*(评论线程传了 422);不发通知。
+     * (可多句并列);可带锚点 pos_* / quote_*;不发通知。
+     *
+     * glossary(术语讨论)是评论线程,title 必填、填术语词头;与批注一样只能挂在句子上、
+     * 只有该句所在 channel 的编辑者能发,对所有人公开可见。
+     * 锚点只有 note / commentary / issue / glossary 收,其它类型传了 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 type string 类型。Enum: discussion,qa,help,issue,glossary,note,commentary Default: discussion
+     * @bodyParam title string 标题,最长 256 字符;评论线程必填(glossary 填术语词头),批注可省
      * @bodyParam content string 正文(markdown);批注必填
      * @bodyParam severity string 严重程度。Enum: error,warning,suggestion,urgent,normal
-     * @bodyParam pos_start integer 批注锚点在句子文本中的起始偏移(只限批注)
-     * @bodyParam pos_end integer 批注锚点的结束偏移,批注插在这里(只限批注)
-     * @bodyParam quote_exact string 锚定的原文片段(只限批注)
-     * @bodyParam quote_prefix string 锚定片段前的上下文,用于重新定位(只限批注)
-     * @bodyParam quote_suffix string 锚定片段后的上下文,用于重新定位(只限批注)
+     * @bodyParam pos_start integer 锚点在句子文本中的起始偏移(只限 note / commentary / issue / glossary)
+     * @bodyParam pos_end integer 锚点的结束偏移,批注插在这里(只限 note / commentary / issue / glossary)
+     * @bodyParam quote_exact string 锚定的原文片段(只限 note / commentary / issue / glossary)
+     * @bodyParam quote_prefix string 锚定片段前的上下文,用于重新定位(只限 note / commentary / issue / glossary)
+     * @bodyParam quote_suffix string 锚定片段后的上下文,用于重新定位(只限 note / commentary / issue / glossary)
      * @bodyParam tpl_id string 话题模板(文章)的 uuid
      * @bodyParam notify boolean 是否发通知(批注不发)。Default: true
      */
@@ -126,7 +130,8 @@ class DiscussionController extends Controller
      *
      * 只改传了的字段。title / status / type / severity 只对话题有效,回复传了 422。
      * 作者或对象的管理者(句子、逐词所在 channel 的编辑者,文章的编辑者)可改;
-     * 改 type 时回复跟着改,新类型不收原来的 severity 时 severity 清空。
+     * 改 type 时回复跟着改,新类型不收原来的 severity 时 severity 清空(锚点保留,改回可带锚点的类型时恢复);
+     * 改成 glossary 须挂在句子上、且是该句的管理者。
      * 批注:没有 status,type 只能在 note / commentary 之间改(不能与评论线程互转),
      * 正文不能清空,可改锚点。
      * 改了内容(标题、正文、锚点)时 co_editor 跟着这次写入走:人亲自改则清空,AI 代改则换成该模型;
@@ -139,13 +144,13 @@ class DiscussionController extends Controller
      * @bodyParam title string 标题(只限话题)
      * @bodyParam content string 正文(markdown)
      * @bodyParam status string 状态(只限话题)。Enum: active,close
-     * @bodyParam type string 类型(只限话题;评论线程与批注不能互转)。Enum: discussion,qa,help,issue,note,commentary
+     * @bodyParam type string 类型(只限话题;评论线程与批注不能互转)。Enum: discussion,qa,help,issue,glossary,note,commentary
      * @bodyParam severity string 严重程度(只限话题),null 表示清空。Enum: error,warning,suggestion,urgent,normal
-     * @bodyParam pos_start integer 锚点起始偏移(只限批注)
-     * @bodyParam pos_end integer 锚点结束偏移(只限批注)
-     * @bodyParam quote_exact string 锚定的原文片段(只限批注)
-     * @bodyParam quote_prefix string 锚定片段前的上下文(只限批注)
-     * @bodyParam quote_suffix string 锚定片段后的上下文(只限批注)
+     * @bodyParam pos_start integer 锚点起始偏移(只限 note / commentary / issue / glossary)
+     * @bodyParam pos_end integer 锚点结束偏移(只限 note / commentary / issue / glossary)
+     * @bodyParam quote_exact string 锚定的原文片段(只限 note / commentary / issue / glossary)
+     * @bodyParam quote_prefix string 锚定片段前的上下文(只限 note / commentary / issue / glossary)
+     * @bodyParam quote_suffix string 锚定片段后的上下文(只限 note / commentary / issue / glossary)
      */
     public function update(UpdateDiscussionRequest $request, Discussion $discussion)
     {

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

@@ -30,7 +30,7 @@ class DiscussionSummaryController extends Controller
      *
      * @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 type string 只数这种类型,不传则只数评论线程。Enum: discussion,qa,help,issue,glossary,note,commentary
      */
     public function __invoke(SummaryDiscussionRequest $request)
     {

+ 10 - 4
api-v13/app/Http/Requests/V3/StoreDiscussionRequest.php

@@ -13,7 +13,8 @@ use Illuminate\Validation\Rule;
  * severity 的取值随 type 而定,见 {@see Discussion::SEVERITIES};不收 severity 的类型传了就 422。
  *
  * 批注(note / commentary)与评论线程的规则不同:只能挂在句子上、标题可省、正文必填,
- * commentary 的正文必须是义注句子模板;锚点(pos_* / quote_*)只有批注收。
+ * commentary 的正文必须是义注句子模板。glossary 也只能挂在句子上。
+ * 锚点(pos_* / quote_*)只有 {@see Discussion::ANCHORED_TYPES} 收。
  */
 class StoreDiscussionRequest extends FormRequest
 {
@@ -32,11 +33,14 @@ class StoreDiscussionRequest extends FormRequest
         $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'];
+        $sentenceOnly = in_array($type, Discussion::SENTENCE_ONLY_TYPES, true);
+        // 不收锚点的类型传了 422,免得静默丢掉
+        $anchor = fn (array $rules) => in_array($type, Discussion::ANCHORED_TYPES, true)
+            ? ['sometimes', 'nullable', ...$rules]
+            : ['prohibited'];
 
         return [
-            'target_type' => ['required', 'string', Rule::in($isAnnotation ? ['sentence'] : Discussion::TARGET_TYPES)],
+            'target_type' => ['required', 'string', Rule::in($sentenceOnly ? ['sentence'] : Discussion::TARGET_TYPES)],
             'target_id' => ['required', 'uuid'],
             'type' => ['sometimes', 'string', Rule::in(Discussion::COMMENT_TYPES)],
             'title' => $isAnnotation
@@ -71,6 +75,8 @@ class StoreDiscussionRequest extends FormRequest
         ];
         if (in_array($this->input('type'), Discussion::INJECTED_TYPES, true)) {
             $messages['target_type.in'] = __('messages.discussion_annotation_target');
+        } elseif ($this->input('type') === Discussion::TYPE_GLOSSARY) {
+            $messages['target_type.in'] = __('messages.discussion_glossary_target');
         }
         foreach (self::ANCHOR_FIELDS as $field) {
             $messages["{$field}.prohibited"] = __('messages.discussion_anchor_thread');

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

@@ -11,7 +11,7 @@ use Illuminate\Validation\Rule;
  *
  * title / status / type / severity 只对话题有效,回复传了 422——这一条与
  * severity 是否匹配最终 type,都要看库里那条记录,在 Service 里判。
- * 批注相关的规则(锚点只有批注收、commentary 正文格式、不能与评论线程互转、没有 status)
+ * 批注相关的规则(锚点只有可锚定的类型收、commentary 正文格式、不能与评论线程互转、没有 status)
  * 同样取决于那条记录的类型,也在 Service 里判。
  */
 class UpdateDiscussionRequest extends FormRequest

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

@@ -10,8 +10,8 @@ use Illuminate\Http\Request;
 /**
  * 一条评论:话题(parent_id 为 null)或回复;或一条批注(note / commentary)。
  *
- * 批注带锚点(pos_start / pos_end / quote_*),指明它挂在句子文本的哪一段;
- * 评论线程的这几个字段恒为 null。
+ * 批注与 issue / glossary 可带锚点(pos_start / pos_end / quote_*),指明它挂在句子文本的
+ * 哪一段;其它评论线程的这几个字段为 null。glossary 的 title 是术语词头。
  *
  * 删除了但还挂着回复的话题以「墓碑」出现:deleted_at 有值,title / content 为 null,
  * 回复照常可读(与 Google Drive 的 comments.deleted、GitHub 的 deletedAt 同一做法)。

+ 11 - 2
api-v13/app/Models/Discussion.php

@@ -71,14 +71,23 @@ class Discussion extends Model
     /** 会被注入阅读页的批注类型 */
     public const INJECTED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE];
 
+    /** 术语讨论:针对句子里某个词的译法提问或建议,带锚点、只能挂在句子上 */
+    public const TYPE_GLOSSARY = 'glossary';
+
     /** 评论线程:话题 + 一层回复,有开关状态 */
-    public const THREAD_TYPES = ['discussion', 'qa', 'help', 'issue'];
+    public const THREAD_TYPES = ['discussion', 'qa', 'help', 'issue', self::TYPE_GLOSSARY];
+
+    /** 可以带锚点(pos_* / quote_*)、定位到句子文本某一段的类型:批注 + issue / glossary */
+    public const ANCHORED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY];
+
+    /** 只能挂在句子上的类型:批注 + glossary */
+    public const SENTENCE_ONLY_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_GLOSSARY];
 
     /**
      * `/v3/discussions` 管的全部类型:评论线程({@see self::THREAD_TYPES})+ 批注
      * ({@see self::INJECTED_TYPES})。批注锚定在句子的某个位置、注入阅读页,没有回复和开关。
      */
-    public const COMMENT_TYPES = ['discussion', 'qa', 'help', 'issue', 'note', 'commentary'];
+    public const COMMENT_TYPES = ['discussion', 'qa', 'help', 'issue', 'glossary', 'note', 'commentary'];
 
     /** 各类型允许的 severity 取值;不在表里的类型不允许设 severity */
     public const SEVERITIES = [

+ 28 - 6
api-v13/app/Services/V3/DiscussionService.php

@@ -39,6 +39,9 @@ use Illuminate\Validation\ValidationException;
  * - 其他用户可以回复;没有开关状态,不能与评论线程互转;新建批注不发通知;
  * - 列表与统计不带 type 时只算评论线程,要批注须显式 type=note / commentary。
  *
+ * glossary 是评论线程(有回复和开关),但像批注一样只能挂在句子上、只有该句的管理者能发起,
+ * 对所有人公开可见。锚点除批注外,issue 与 glossary 也收({@see Discussion::ANCHORED_TYPES})。
+ *
  * 写入方(`$actor`)由 {@see AgentGrantService::actor()} 给出:人类直接写,或 AI 模型凭人类
  * 授权代写。代写时 editor 是授权人、co_editor 是模型,权限一律按 editor 判,
  * 而且只能写授权覆盖的 channel / book 里的东西。
@@ -333,7 +336,9 @@ class DiscussionService
     /**
      * 局部更新。title / status / type / severity 只对话题有效。
      *
-     * 改 type 时回复跟着改;新类型不收原来的 severity 时,severity 清空。
+     * 改 type 时回复跟着改;新类型不收原来的 severity 时,severity 清空。锚点不清:
+     * 留着不显示也不影响校验,改回可带锚点的类型时位置自动恢复。
+     * 改成 glossary 须挂在句子上、且当前用户是该句的管理者(同新建)。
      * 批注:锚点可改,没有 status,type 只能在 note / commentary 之间改,
      * 正文不能清空,commentary 的正文必须是义注句子模板(按改后的 type 与正文判)。
      *
@@ -366,6 +371,9 @@ class DiscussionService
         }
 
         $type = $data['type'] ?? $discussion->type;
+        if ($type === Discussion::TYPE_GLOSSARY && $discussion->type !== Discussion::TYPE_GLOSSARY) {
+            $this->assertCanBecomeGlossary($discussion, $userUid);
+        }
         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')]);
@@ -509,11 +517,12 @@ class DiscussionService
             && in_array($data['type'], Discussion::INJECTED_TYPES, true) !== $isAnnotation) {
             $errors['type'] = __('messages.discussion_type_group');
         }
-        if (! $isAnnotation) {
+        if (! in_array($data['type'] ?? $discussion->type, Discussion::ANCHORED_TYPES, true)) {
             foreach (array_intersect(self::ANCHOR_FIELDS, array_keys($data)) as $field) {
                 $errors[$field] = __('messages.discussion_anchor_thread');
             }
-        } else {
+        }
+        if ($isAnnotation) {
             if (array_key_exists('status', $data)) {
                 $errors['status'] = __('messages.discussion_annotation_no_status');
             }
@@ -530,6 +539,19 @@ class DiscussionService
         }
     }
 
+    /**
+     * 话题改成 glossary:与新建同口径,只能挂在句子上、只有该句的管理者能改。
+     */
+    private function assertCanBecomeGlossary(Discussion $discussion, string $userUid): void
+    {
+        if ($discussion->res_type !== 'sentence') {
+            throw ValidationException::withMessages(['type' => __('messages.discussion_glossary_target')]);
+        }
+        if (! $this->canManageTarget($userUid, $discussion->res_type, $discussion->res_id)) {
+            abort(403, __('messages.discussion_forbidden'));
+        }
+    }
+
     private function resetCache(): void
     {
         $this->editableChannels = [];
@@ -600,8 +622,8 @@ class DiscussionService
      * 当前用户能在这个对象上发起哪些类型的话题。
      *
      * 协作类(discussion / issue)登录即可;qa / help 沿用 v2 `can_create` 的口径:
-     * 只能发在文章上,而且只有文章的编辑者能发;批注(note / commentary)只能加在句子上,
-     * 而且只有该句的管理者能加(v2 登录即可,v3 收紧)。
+     * 只能发在文章上,而且只有文章的编辑者能发;批注(note / commentary)与 glossary 只能加在
+     * 句子上,而且只有该句的管理者能加(v2 登录即可,v3 收紧)。
      *
      * @return list<string>
      */
@@ -616,7 +638,7 @@ class DiscussionService
         }
         // 批注注入阅读页、所有读者可见,只有该句的管理者能加
         if ($targetType === 'sentence' && $this->canManageTarget($userUid, $targetType, $targetId)) {
-            array_push($types, Discussion::TYPE_NOTE, Discussion::TYPE_COMMENTARY);
+            array_push($types, Discussion::TYPE_GLOSSARY, Discussion::TYPE_NOTE, Discussion::TYPE_COMMENTARY);
         }
 
         return $types;

+ 2 - 1
api-v13/resources/lang/en/messages.php

@@ -27,7 +27,8 @@ return [
     '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.',
+    'discussion_anchor_thread' => 'This discussion type cannot be anchored to a text position.',
+    'discussion_glossary_target' => 'Glossary discussions can only be attached to sentences.',
     '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.',

+ 2 - 1
api-v13/resources/lang/zh-Hans/messages.php

@@ -27,7 +27,8 @@ return [
     'discussion_annotation_no_status' => '批注没有关闭 / 打开状态。',
     'discussion_type_group' => '评论线程与批注之间不能互相转换。',
     'discussion_commentary_format' => '义注的内容必须是句子模板,如 {{book-para-start-end}}。',
-    'discussion_anchor_thread' => '只有批注能锚定到文本位置。',
+    'discussion_anchor_thread' => '这种类型的讨论不能锚定到文本位置。',
+    'discussion_glossary_target' => '术语讨论只能挂在句子上。',
     'agent_grant_by_model' => '只有人类能给 AI 模型授权写入。',
     'agent_grant_model_not_found' => 'AI 模型不存在。',
     'agent_grant_not_owner' => '这个 AI 模型不属于你。',